Skip to content

Commit d773d3a

Browse files
committed
feat: add global abilities with type-safe enforcement
Global abilities act as gatekeepers that are checked before any specific abilities. All global abilities must return true for permission checks to proceed. Features: - New GlobalAbility symbol to mark abilities as global - Type-safe overloads prevent mixing global and specific matchers - Global abilities checked first, fail-fast on denial Use cases: - Read-only mode enforcement - Maintenance mode restrictions - Feature flag checks - Multi-tenant isolation
1 parent 4ce04c3 commit d773d3a

File tree

6 files changed

+595
-16
lines changed

6 files changed

+595
-16
lines changed

README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,81 @@ This is especially useful when:
274274
- You want to prevent mistakes like checking `ability.can(User, 'edit')` when `User` only supports `'view'`
275275
- You're passing class constructors instead of string matchers
276276

277+
### 6. Global abilities (optional)
278+
279+
Global abilities are special abilities that act as gatekeepers for your permission
280+
system. They are checked **before** any specific abilities, and **all** global
281+
abilities must return `true` for the permission check to proceed.
282+
283+
**When to use global abilities:**
284+
285+
- Enforce read-only mode across your entire application
286+
- Implement maintenance mode restrictions
287+
- Check license or feature flags
288+
- Verify user status (banned, suspended, etc.)
289+
- Multi-tenant isolation checks
290+
291+
**Example:**
292+
293+
```typescript
294+
import { AbilityFor, Ability, GlobalAbility } from 'ng-ability';
295+
296+
// Global ability to enforce read-only mode
297+
@AbilityFor(GlobalAbility)
298+
export class ReadOnlyModeAbility implements Ability<User> {
299+
can(currentUser: User | null, action: string): boolean {
300+
// Block all write operations when user is in read-only mode
301+
if (currentUser?.readOnly && action !== 'read') {
302+
return false;
303+
}
304+
// Allow the check to continue to specific abilities
305+
return true;
306+
}
307+
}
308+
309+
// Regular ability
310+
@AbilityFor('Article')
311+
export class ArticleAbility implements Ability<User> {
312+
can(currentUser: User | null, action: string): boolean {
313+
// This will only be checked if all global abilities return true
314+
return currentUser != null;
315+
}
316+
}
317+
```
318+
319+
Register global abilities like any other ability:
320+
321+
```typescript
322+
bootstrapApplication(AppComponent, {
323+
providers: [
324+
provideAbilities(AbilityUserContext, [
325+
ReadOnlyModeAbility, // Global ability
326+
ArticleAbility, // Regular abilities
327+
// ... other abilities
328+
]),
329+
],
330+
});
331+
```
332+
333+
**Type safety:**
334+
335+
The type system enforces that abilities are either global OR specific:
336+
337+
```typescript
338+
// ✓ Valid
339+
@AbilityFor(GlobalAbility)
340+
export class GlobalCheck implements Ability<User> { ... }
341+
342+
// ✗ Invalid: Cannot mix GlobalAbility with matchers
343+
@AbilityFor(GlobalAbility, 'Article') // TypeScript error!
344+
```
345+
346+
**How it works:**
347+
348+
1. When you call `can('Article', 'write')`, all global abilities are checked first
349+
2. If any global ability returns `false`, the check fails immediately
350+
3. Only if all global abilities return `true` does the check proceed to `ArticleAbility`
351+
277352
## Development
278353

279354
### Build

docs/GLOBAL_ABILITIES.md

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
# Global Abilities
2+
3+
Global abilities are special abilities that act as gatekeepers for your permission system. They are checked **before** any specific abilities, and **all** global abilities must return `true` for the permission check to proceed.
4+
5+
## Use Cases
6+
7+
Global abilities are perfect for implementing cross-cutting authorization concerns that apply to all or most of your abilities, such as:
8+
9+
- Read-only mode enforcement
10+
- Maintenance mode restrictions
11+
- License/feature flag checks
12+
- Global user status checks (banned, suspended, etc.)
13+
- Tenant-level permissions in multi-tenant applications
14+
15+
## Usage
16+
17+
### 1. Import the GlobalAbility Symbol
18+
19+
```typescript
20+
import { AbilityFor, Ability, GlobalAbility } from 'ng-ability';
21+
```
22+
23+
### 2. Create a Global Ability
24+
25+
Mark any ability class with `@AbilityFor(GlobalAbility)` to make it a global ability:
26+
27+
```typescript
28+
@AbilityFor(GlobalAbility)
29+
export class ReadOnlyModeAbility implements Ability<User> {
30+
can(currentUser: User | null, action: string): boolean {
31+
// Block all write operations when user is in read-only mode
32+
if (currentUser?.readOnly && action !== 'read') {
33+
return false;
34+
}
35+
// Allow the check to continue to specific abilities
36+
return true;
37+
}
38+
}
39+
```
40+
41+
### 3. Register the Global Ability
42+
43+
Register it like any other ability using `provideAbilities()`:
44+
45+
```typescript
46+
import { provideAbilities } from 'ng-ability';
47+
import { ReadOnlyModeAbility } from './abilities/read-only-mode.ability';
48+
import { PostAbility } from './abilities/post.ability';
49+
import { CommentAbility } from './abilities/comment.ability';
50+
51+
export const appConfig: ApplicationConfig = {
52+
providers: [
53+
provideAbilities([
54+
ReadOnlyModeAbility, // Global ability
55+
PostAbility, // Specific abilities
56+
CommentAbility,
57+
// ... other abilities
58+
])
59+
]
60+
};
61+
```
62+
63+
## How It Works
64+
65+
When you call `can()`, the permission check follows this flow:
66+
67+
1. **Global Abilities Check**: All global abilities are invoked first
68+
- If **any** global ability returns `false`, the entire check fails immediately
69+
- Specific abilities are **not** checked if a global ability fails
70+
71+
2. **Specific Ability Check**: If all global abilities return `true`
72+
- The matching specific ability is found and invoked
73+
- That ability's result is returned
74+
75+
## Examples
76+
77+
### Example 1: Read-Only Mode
78+
79+
```typescript
80+
interface User {
81+
id: string;
82+
readOnly: boolean;
83+
}
84+
85+
@AbilityFor(GlobalAbility)
86+
export class ReadOnlyModeAbility implements Ability<User> {
87+
can(currentUser: User | null, action: string): boolean {
88+
if (currentUser?.readOnly && action !== 'read') {
89+
return false;
90+
}
91+
return true;
92+
}
93+
}
94+
95+
@AbilityFor('Post')
96+
export class PostAbility implements Ability<User> {
97+
can(currentUser: User | null, action: string): boolean {
98+
if (action === 'delete') {
99+
return currentUser?.role === 'admin';
100+
}
101+
return true;
102+
}
103+
}
104+
105+
// Usage in a component:
106+
export class PostComponent {
107+
private abilityService = inject(NgAbilityService);
108+
109+
// When user is in read-only mode:
110+
// - can('Post', 'read') → true (allowed by global ability)
111+
// - can('Post', 'write') → false (blocked by global ability, PostAbility never checked)
112+
// - can('Post', 'delete') → false (blocked by global ability, PostAbility never checked)
113+
114+
// When user is NOT in read-only mode:
115+
// - can('Post', 'read') → true (global passes, PostAbility allows)
116+
// - can('Post', 'write') → true (global passes, PostAbility allows)
117+
// - can('Post', 'delete') → depends on role (global passes, PostAbility checks role)
118+
}
119+
```
120+
121+
### Example 2: Multiple Global Abilities
122+
123+
You can have multiple global abilities - all must return `true`:
124+
125+
```typescript
126+
@AbilityFor(GlobalAbility)
127+
export class MaintenanceModeAbility implements Ability<User> {
128+
private maintenance = inject(MaintenanceService);
129+
130+
can(currentUser: User | null, action: string): boolean {
131+
// Only admins can do anything during maintenance
132+
if (this.maintenance.isActive() && currentUser?.role !== 'admin') {
133+
return false;
134+
}
135+
return true;
136+
}
137+
}
138+
139+
@AbilityFor(GlobalAbility)
140+
export class FeatureFlagAbility implements Ability<User> {
141+
private features = inject(FeatureService);
142+
143+
can(currentUser: User | null, action: string): boolean {
144+
// Check if the action requires a feature flag
145+
const requiredFeature = this.getRequiredFeature(action);
146+
if (requiredFeature && !this.features.isEnabled(requiredFeature)) {
147+
return false;
148+
}
149+
return true;
150+
}
151+
152+
private getRequiredFeature(action: string): string | null {
153+
// Map actions to feature flags
154+
if (action === 'export') return 'export-feature';
155+
if (action === 'share') return 'sharing-feature';
156+
return null;
157+
}
158+
}
159+
```
160+
161+
### Example 3: Tenant-Level Permissions
162+
163+
```typescript
164+
interface TenantUser {
165+
id: string;
166+
tenantId: string;
167+
role: string;
168+
}
169+
170+
@AbilityFor(GlobalAbility)
171+
export class TenantAccessAbility implements Ability<TenantUser> {
172+
private tenantService = inject(TenantService);
173+
174+
can(currentUser: TenantUser | null, action: string, thing?: any): boolean {
175+
if (!currentUser) return false;
176+
177+
// Check if user's tenant has access to the resource
178+
const resourceTenantId = thing?.tenantId;
179+
if (resourceTenantId && resourceTenantId !== currentUser.tenantId) {
180+
return false; // User can't access resources from other tenants
181+
}
182+
183+
return true;
184+
}
185+
}
186+
```
187+
188+
## Type Safety
189+
190+
The type system enforces that abilities are either global OR specific, not both:
191+
192+
```typescript
193+
// ✓ Valid: Global ability
194+
@AbilityFor(GlobalAbility)
195+
export class ReadOnlyAbility implements Ability<User> { ... }
196+
197+
// ✓ Valid: Specific ability
198+
@AbilityFor('Document')
199+
export class DocumentAbility implements Ability<User> { ... }
200+
201+
// ✗ Invalid: Cannot mix GlobalAbility with other matchers
202+
@AbilityFor(GlobalAbility, 'Document') // TypeScript error!
203+
export class InvalidAbility implements Ability<User> { ... }
204+
```
205+
206+
This prevents confusion and ensures clear separation between global and specific authorization logic. If you need both global checks and specific resource checks, create two separate abilities.
207+
208+
## Best Practices
209+
210+
1. **Keep Global Abilities Simple**: They run on every permission check, so keep them fast and focused.
211+
212+
2. **Return `true` to Continue**: Global abilities should return `true` to allow the check to proceed to specific abilities. Only return `false` when you want to block the action entirely.
213+
214+
3. **Order Doesn't Matter**: All global abilities are checked, and the order doesn't matter (they all must return `true`).
215+
216+
4. **Use for Cross-Cutting Concerns**: Global abilities are for concerns that span across multiple resources/actions. Use specific abilities for resource-specific logic.
217+
218+
## Performance Considerations
219+
220+
- Global abilities are called on **every** permission check
221+
- Keep global ability logic fast and efficient
222+
- Consider caching expensive operations (like feature flag checks)
223+
- Minimize the number of global abilities (2-3 is typical)
Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,38 @@
1-
import { AbilityMatcher } from './interfaces';
1+
import { AbilityMatcher, GlobalAbility } from './interfaces';
22

33
const abilityMatchersMap = new WeakMap<Function, AbilityMatcher<unknown>[]>();
4+
const globalAbilitiesSet = new WeakSet<Function>();
45

56
export function getAbilityMatchers(
67
klass: Function,
78
): AbilityMatcher<unknown>[] | undefined {
89
return abilityMatchersMap.get(klass);
910
}
1011

12+
export function isGlobalAbility(klass: Function): boolean {
13+
return globalAbilitiesSet.has(klass);
14+
}
15+
16+
export function AbilityFor<T>(global: typeof GlobalAbility): ClassDecorator;
1117
export function AbilityFor<T>(
1218
...abilityMatchers: AbilityMatcher<T>[]
19+
): ClassDecorator;
20+
export function AbilityFor<T>(
21+
...abilityMatchers: (AbilityMatcher<T> | typeof GlobalAbility)[]
1322
): ClassDecorator {
1423
return <TFunction extends Function>(klass: TFunction) => {
15-
abilityMatchersMap.set(klass, abilityMatchers as AbilityMatcher<unknown>[]);
24+
// Check if this is a global ability
25+
if (abilityMatchers[0] === GlobalAbility) {
26+
globalAbilitiesSet.add(klass);
27+
// Don't store GlobalAbility symbol in matchers map
28+
abilityMatchersMap.set(klass, []);
29+
} else {
30+
abilityMatchersMap.set(
31+
klass,
32+
abilityMatchers as AbilityMatcher<unknown>[],
33+
);
34+
}
35+
1636
return klass;
1737
};
1838
}

projects/ng-ability/src/lib/interfaces.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
import type { Signal } from '@angular/core';
22

3+
/**
4+
* Special symbol to mark an ability as global.
5+
* Global abilities are checked first before any specific abilities,
6+
* and all global abilities must return true for permission checks to proceed.
7+
*
8+
* @example
9+
* ```typescript
10+
* @AbilityFor(GlobalAbility)
11+
* export class ReadOnlyAbility implements Ability<User> {
12+
* can(currentUser: User | null, action: string) {
13+
* if (currentUser?.readOnly && action !== 'read') {
14+
* return false;
15+
* }
16+
* return true;
17+
* }
18+
* }
19+
* ```
20+
*/
21+
export const GlobalAbility = Symbol('GlobalAbility');
22+
323
export type AbilityMatcher<T> = { new (): T } | ((t: T) => boolean) | string;
424

525
// eslint-disable-next-line @typescript-eslint/no-empty-object-type

0 commit comments

Comments
 (0)