Skip to content

Commit 8c73dd2

Browse files
committed
feat: add hasPolicyGranting check
1 parent e883163 commit 8c73dd2

File tree

5 files changed

+138
-2
lines changed

5 files changed

+138
-2
lines changed

src/SubjectAuthorizer.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import {
77
PolicyEffect,
88
PolicyWithID,
99
ISubjectAuthorizerOpts,
10+
HasPolicyGrantingOpts,
1011
} from './fsaba-types';
1112
import isAllowed from './utils/is-allowed';
1213
import makeSubjectSpecificPolicies from './utils/make-subject-specific-policies';
14+
import hasPolicyGranting from './utils/has-policy-granting';
1315

1416
export class SubjectAuthorizer implements ISubjectAuthorizer {
1517

@@ -43,4 +45,8 @@ export class SubjectAuthorizer implements ISubjectAuthorizer {
4345
return isAllowed(this._policies, action, resource, opts);
4446
}
4547

48+
public hasPolicyGranting(action: string, opts?: Partial<HasPolicyGrantingOpts>): boolean {
49+
return hasPolicyGranting(this._policies, action, opts);
50+
}
51+
4652
}

src/fsaba-types.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,18 @@ export interface IsAllowedOpts {
274274
ignoreConditions: boolean;
275275
}
276276

277+
export interface HasPolicyGrantingOpts {
278+
279+
/**
280+
* An optional resource prefix pattern (e.g. 'budgets:kazoo/*'). If provided, the
281+
* authorizer checks if any policy grants the action on a resource that matches this
282+
* prefix.
283+
*
284+
* Note: This must end with a wildcard (`*`) and cannot contain wildcards elsewhere.
285+
*/
286+
resourcePrefixPattern?: string;
287+
}
288+
277289
/**
278290
* A subject authorizer is used to determine what actions each subject can perform.
279291
*/
@@ -288,6 +300,19 @@ export interface ISubjectAuthorizer {
288300
*/
289301
isAllowed(action: string, resource: string, opts?: Partial<IsAllowedOpts>): boolean;
290302

303+
/**
304+
* Perform an early check to see if the user has a policy allowing them to perform the
305+
* provided action at all, with an optional resource prefix. This is useful for APIs to
306+
* perform an early check before doing expensive processing to load any data needed to
307+
* determine if the user has access to perform the action.
308+
*
309+
* This does not replace the need to call `isAllowed` to determine if the user has
310+
* access to perform the action. This is a tool that can be used to reduce the
311+
* information disclosed (e.g. via timing-based probing) to users who don't have access
312+
* to perform an action at all.
313+
*/
314+
hasPolicyGranting(action: string, opts?: HasPolicyGrantingOpts): boolean;
315+
291316
}
292317

293318
/**

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './fsaba-types';
22
export * from './AuthorizerFactory';
3+
export * from './SubjectAuthorizer';

src/utils/has-policy-granting.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { HasPolicyGrantingOpts, PolicyWithID } from '../fsaba-types';
2+
import stringMatchesPattern from './string-matches-pattern';
3+
4+
/**
5+
* Perform an early check to see if the user has a policy to perform an action at all,
6+
* with an optional resource prefix.
7+
*/
8+
export default function hasPolicyGranting(policies: { allow: readonly PolicyWithID[] }, action: string, opts?: HasPolicyGrantingOpts): boolean {
9+
return policies.allow.some((policy) => {
10+
const actionMatches = policy.actions.some((policyAction) => {
11+
return stringMatchesPattern(policyAction, action);
12+
});
13+
14+
if (!actionMatches) {
15+
return false;
16+
}
17+
18+
const resourcePrefixPattern = opts?.resourcePrefixPattern;
19+
20+
if (!resourcePrefixPattern) {
21+
return true;
22+
}
23+
24+
if (!resourcePrefixPattern.endsWith('*') || resourcePrefixPattern.indexOf('*') !== resourcePrefixPattern.length - 1) {
25+
throw new Error('resourcePrefixPattern must end with a wildcard and cannot contain wildcards elsewhere');
26+
}
27+
28+
const prefix = resourcePrefixPattern.slice(0, -1);
29+
30+
return policy.resources.some((policyResource) => {
31+
return stringMatchesPattern(policyResource, resourcePrefixPattern)
32+
|| policyResource.startsWith(prefix);
33+
});
34+
});
35+
}

tests/SubjectAuthorizer.test.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { expect } from 'chai';
2-
import { AuthorizerFactory, Claims } from '../src';
3-
import { ADMINISTER_OWN_AUTH, ADMINISTER_OTHER_AUTH, DO_NEARLY_EVERYTHING } from './sample-data';
2+
import { AuthorizerFactory, Claims, SubjectAuthorizer } from '../src';
3+
import {
4+
ADMINISTER_OWN_AUTH,
5+
ADMINISTER_OTHER_AUTH,
6+
DO_NEARLY_EVERYTHING,
7+
ALL_ROLES,
8+
BUDGET_VIEWER_KAZOO_PM,
9+
BUDGET_VIEWER_SINGLE,
10+
BUDGET_VIEWER_MFG_HEAD,
11+
} from './sample-data';
412

513

614
describe('SubjectAuthorizer', () => {
@@ -39,4 +47,65 @@ describe('SubjectAuthorizer', () => {
3947
expect(factory2.makeAuthorizerForSubject(user, { throwOnUnknownRole: true })).to.be.ok; // eslint-disable-line no-unused-expressions
4048
});
4149

50+
describe('hasPolicyGranting', () => {
51+
52+
it('returns true if the user has a policy for the action', () => {
53+
const authorizer = new SubjectAuthorizer(ALL_ROLES, BUDGET_VIEWER_SINGLE);
54+
55+
expect(authorizer.hasPolicyGranting('budget:View')).to.strictlyEqual(true);
56+
});
57+
58+
it('returns false if the user does not have a policy for the action', () => {
59+
const authorizer = new SubjectAuthorizer(ALL_ROLES, BUDGET_VIEWER_SINGLE);
60+
61+
expect(authorizer.hasPolicyGranting('budget:Create')).to.strictlyEqual(false);
62+
});
63+
64+
it('returns true if the user has a policy matching resource prefix', () => {
65+
const authorizer = new SubjectAuthorizer(ALL_ROLES, BUDGET_VIEWER_KAZOO_PM);
66+
67+
expect(authorizer.hasPolicyGranting('budget:View', { resourcePrefixPattern: 'budget:kazoo/*' })).to.strictlyEqual(true);
68+
});
69+
70+
it('returns true if the policy resource is broader than the requested prefix', () => {
71+
const authorizer = new SubjectAuthorizer(ALL_ROLES, BUDGET_VIEWER_MFG_HEAD);
72+
73+
// BUDGET_VIEWER_MFG_HEAD has budget:*
74+
expect(authorizer.hasPolicyGranting('budget:View', { resourcePrefixPattern: 'budget:kazoo/mktg/*' })).to.strictlyEqual(true);
75+
});
76+
77+
it('returns false if the user has the action but on different resources', () => {
78+
const authorizer = new SubjectAuthorizer(ALL_ROLES, BUDGET_VIEWER_KAZOO_PM);
79+
80+
// KAZOO PM has budget:View on budget:* but we can test with a non-matching
81+
// prefix.
82+
expect(authorizer.hasPolicyGranting('budget:View', { resourcePrefixPattern: 'budget-v2/*' })).to.strictlyEqual(false);
83+
});
84+
85+
it('throws an error if resourcePrefixPattern does not end with a wildcard or contains internal wildcards', () => {
86+
const authorizer = new SubjectAuthorizer(ALL_ROLES, BUDGET_VIEWER_KAZOO_PM);
87+
88+
expect(() => { authorizer.hasPolicyGranting('budget:View', { resourcePrefixPattern: 'budget:kazoo' }); }).to.throw();
89+
90+
expect(() => { authorizer.hasPolicyGranting('budget:View', { resourcePrefixPattern: 'budget:*/foo' }); }).to.throw();
91+
92+
expect(() => { authorizer.hasPolicyGranting('budget:View', { resourcePrefixPattern: '*budget:foo' }); }).to.throw();
93+
});
94+
95+
it('correctly handles prefix match', () => {
96+
const authorizer = new SubjectAuthorizer(ALL_ROLES, BUDGET_VIEWER_KAZOO_PM);
97+
98+
// BUDGET_VIEWER_KAZOO_PM has policy for budget:kazoo/*. The requested prefix is
99+
// budget:kazoo/mktg/*. This should be true because the policy resource
100+
// (budget:kazoo/*) matches the requested prefix (budget:kazoo/mktg/*).
101+
expect(authorizer.hasPolicyGranting('budget:View', { resourcePrefixPattern: 'budget:kazoo/mktg/*' })).to.strictlyEqual(true);
102+
103+
// Now, if the requested prefix is budget:kaz*, it should still be true because
104+
// the requested resource prefix (budget:kaz*) matches the policy resource
105+
// (budget:kazoo/*).
106+
expect(authorizer.hasPolicyGranting('budget:View', { resourcePrefixPattern: 'budget:kaz*' })).to.strictlyEqual(true);
107+
});
108+
109+
});
110+
42111
});

0 commit comments

Comments
 (0)