Skip to content

Commit 31a8c4b

Browse files
authored
Merge pull request #28 from silvermine/has-policy-granting
feat: add hasPolicyGranting check
2 parents cc8a8c8 + 52d7c49 commit 31a8c4b

File tree

6 files changed

+213
-17
lines changed

6 files changed

+213
-17
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"test": "TS_NODE_PROJECT='tests/tsconfig.json' TS_NODE_FILES=true nyc mocha --opts ./.mocha.opts",
1212
"check-node-version": "check-node-version --npm 11.6.2 --print",
1313
"test:ci": "npm test -- --forbid-only",
14-
"eslint": "eslint '{,!(node_modules|dist)/**/}*.js'",
14+
"eslint": "eslint '{,!(node_modules|dist)/**/}*.{js,ts}'",
1515
"markdownlint": "markdownlint-cli2",
1616
"standards": "npm run commitlint && npm run markdownlint && npm run eslint",
1717
"release:preview": "node ./node_modules/@silvermine/standardization/scripts/release.js preview",

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?: HasPolicyGrantingOpts): boolean {
49+
return hasPolicyGranting(this._policies, action, opts);
50+
}
51+
4652
}

src/fsaba-types.ts

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,9 +264,28 @@ export interface IAuthorizerFactory {
264264
*/
265265
export interface IsAllowedOpts {
266266
context: StringMap;
267+
268+
/**
269+
* When true, policy conditions are bypassed, only action and resource patterns are
270+
* evaluated. Useful for UI scenarios where you need to know if a user *could* have
271+
* access to an action (e.g., showing a button) before the runtime context required by
272+
* conditions is available.
273+
*/
267274
ignoreConditions: boolean;
268275
}
269276

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+
270289
/**
271290
* A subject authorizer is used to determine what actions each subject can perform.
272291
*/
@@ -276,12 +295,78 @@ export interface ISubjectAuthorizer {
276295
* Is the subject allowed to perform this action on the given resource? Optionally,
277296
* provide a context that may be evaluated by policy conditions. In rare cases, you may
278297
* want to ignore conditions when determining if the user would have access to an
279-
* action.
280-
*
281-
* TODO: document more on when you'd ignore conditions.
298+
* action—for example, to decide whether to render a UI element before runtime context
299+
* is available.
282300
*/
283301
isAllowed(action: string, resource: string, opts?: Partial<IsAllowedOpts>): boolean;
284302

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+
* Scenario: A request is made for `budgets/it-dept/fy2025/q1`. Your code needs to:
315+
* (1) validate query params, and (2) load the IT Department budget for Q1 of FY2025
316+
* to see who has permission to view it (before an `isAllowed` call could be made). If
317+
* the query params seem invalid, or if there is no budget for those params, you'd
318+
* return a "Bad Request" or "Not Found" response. But, if the user doesn't have
319+
* `budget:View` for any combination of departments, etc, then there's no reason to
320+
* even perform those steps and potentially expose information through the "Bad
321+
* Request" or "Not Found" responses.
322+
*
323+
* Using this method, you could follow this flow:
324+
*
325+
* 1. hasPolicyGranting('budget:View') - if they don't have this, then stop right
326+
* there; if they do have a policy granting them view rights to a budget, they
327+
* might not be for this budget, but you can now continue your flow
328+
* 2. Validate query params; may be okay to return Bad Response depending on your
329+
* security requirements
330+
* 3. Look up the requested budget; may or may not be okay to return a 404 Not Found
331+
* depending on your security requirements
332+
* 4. If you did find that budget item, now, check if they have permission to view
333+
* this budget with isAllowed
334+
*
335+
* In pseudocode, this might look like:
336+
*
337+
* ```
338+
* // A request is made for budgets/it-dept/fy2025/q1.
339+
* // 1. Check if the user has ANY policy granting 'budget:View'
340+
* if (!authorizer.hasPolicyGranting('budget:View')) {
341+
* return 403 Forbidden;
342+
* }
343+
*
344+
* // 2. Validate query params
345+
* if (!params.isValid()) {
346+
* // Possibly safe to let the user know the request is invalid because we know
347+
* // they have SOME view rights
348+
* return 400 Bad Request;
349+
* }
350+
*
351+
* // 3. Load the specific resource
352+
* const budget = await loadBudget('it-dept', 'fy2025', 'q1');
353+
*
354+
* if (!budget) {
355+
* // Returning a 403 Forbidden vs 404 Not Found here depends on your security
356+
* // requirements.
357+
* return 403 Forbidden;
358+
* }
359+
*
360+
* // 4. Perform final check for this specific budget
361+
* if (!authorizer.isAllowed('budget:View', budget.id, { context: budget.context })) {
362+
* return 403 Forbidden;
363+
* }
364+
*
365+
* return 200 OK;
366+
* ```
367+
*/
368+
hasPolicyGranting(action: string, opts?: HasPolicyGrantingOpts): boolean;
369+
285370
}
286371

287372
/**

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: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,111 @@
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', () => {
7-
const ALL_ROLES_1 = [
15+
const allRoles1 = [
816
ADMINISTER_OWN_AUTH,
917
ADMINISTER_OTHER_AUTH,
1018
];
1119

12-
const ALL_ROLES_2 = [
20+
const allRoles2 = [
1321
ADMINISTER_OWN_AUTH,
1422
ADMINISTER_OTHER_AUTH,
1523
DO_NEARLY_EVERYTHING,
1624
];
1725

18-
const USER_ID = '73885b55-2e0d-40bd-8cb3-2e59cf78ed87';
26+
const userID = '73885b55-2e0d-40bd-8cb3-2e59cf78ed87';
1927

20-
const USER: Claims = {
21-
subjectID: USER_ID,
28+
const user: Claims = {
29+
subjectID: userID,
2230
roles: [
2331
{ roleID: ADMINISTER_OWN_AUTH.roleID },
2432
{ roleID: ADMINISTER_OTHER_AUTH.roleID, contextValue: '8e0fa760-9a1d-43ea-8686-768318d923b4' },
2533
{ roleID: DO_NEARLY_EVERYTHING.roleID },
2634
],
2735
};
2836

29-
const factory1 = new AuthorizerFactory(ALL_ROLES_1),
30-
factory2 = new AuthorizerFactory(ALL_ROLES_2);
37+
const factory1 = new AuthorizerFactory(allRoles1),
38+
factory2 = new AuthorizerFactory(allRoles2);
3139

3240
it('ignores unknown roles when no opts provided', () => {
33-
expect(factory1.makeAuthorizerForSubject(USER)).to.be.ok; // eslint-disable-line no-unused-expressions
34-
expect(factory2.makeAuthorizerForSubject(USER)).to.be.ok; // eslint-disable-line no-unused-expressions
41+
expect(factory1.makeAuthorizerForSubject(user)).to.be.ok; // eslint-disable-line no-unused-expressions
42+
expect(factory2.makeAuthorizerForSubject(user)).to.be.ok; // eslint-disable-line no-unused-expressions
3543
});
3644

3745
it('throws an error on unknown roles when requested', () => {
38-
expect(() => { factory1.makeAuthorizerForSubject(USER, { throwOnUnknownRole: true }); }).to.throw();
39-
expect(factory2.makeAuthorizerForSubject(USER, { throwOnUnknownRole: true })).to.be.ok; // eslint-disable-line no-unused-expressions
46+
expect(() => { factory1.makeAuthorizerForSubject(user, { throwOnUnknownRole: true }); }).to.throw();
47+
expect(factory2.makeAuthorizerForSubject(user, { throwOnUnknownRole: true })).to.be.ok; // eslint-disable-line no-unused-expressions
48+
});
49+
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+
40109
});
41110

42111
});

0 commit comments

Comments
 (0)