Skip to content

Commit 5f16be2

Browse files
committed
feat: add support for multiple context values
1 parent c2728bd commit 5f16be2

File tree

5 files changed

+318
-1
lines changed

5 files changed

+318
-1
lines changed

src/fsaba-types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,23 @@ export enum PolicyConditionMatchType {
6666
*/
6767
StringDoesNotMatch = 'string-does-not-match',
6868

69+
/**
70+
* Evaluate the value as a string, seeing if it matches the string in the condition.
71+
* If the field is missing from the context, the condition passes automatically.
72+
* Like actions and resources, a string-matches-if-exists condition allows for a '*' as
73+
* a wildcard.
74+
*/
75+
StringMatchesIfExists = 'string-matches-if-exists',
76+
77+
/**
78+
* Evaluates the value as a string, negating the regular string match. That is, the
79+
* condition "passes" if the string value from the context fails to match the string in
80+
* the condition. If the field is missing from the context, the condition passes
81+
* automatically. Like actions and resources, a string-does-not-match-if-exists
82+
* condition allows for a '*' as a wildcard.
83+
*/
84+
StringDoesNotMatchIfExists = 'string-does-not-match-if-exists',
85+
6986
}
7087

7188

src/utils/conditions-match.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { StringMap } from '@silvermine/toolbox';
1+
import { hasDefined, StringMap } from '@silvermine/toolbox';
22
import { isPolicyConditionConjunctionAllOf, isPolicyConditionConjunctionAnyOf, isPolicyConditionMatcher, PolicyCondition } from '..';
33
import { PolicyConditionMatcher, PolicyConditionMatchType } from '../fsaba-types';
44
import stringMatchesPattern from './string-matches-pattern';
@@ -29,6 +29,14 @@ function singleConditionSatisfied(cond: PolicyCondition, context: StringMap): bo
2929
throw new Error(`Unreachable: ${typeof cond} (${Object.getOwnPropertyNames(cond)})`);
3030
}
3131

32+
function ifExists(context: StringMap, field: string, callback: (value: string) => boolean): boolean {
33+
if (!hasDefined(context, field)) {
34+
return true;
35+
}
36+
37+
return callback(context[field]);
38+
}
39+
3240
/**
3341
* EXPORTED ONLY FOR TESTING
3442
*/
@@ -40,6 +48,16 @@ export function matcherSatisfied(matcher: PolicyConditionMatcher, context: Strin
4048
case PolicyConditionMatchType.StringDoesNotMatch: {
4149
return !stringMatchesPattern(matcher.value, context[matcher.field]);
4250
}
51+
case PolicyConditionMatchType.StringMatchesIfExists: {
52+
return ifExists(context, matcher.field, (value) => {
53+
return stringMatchesPattern(matcher.value, value);
54+
});
55+
}
56+
case PolicyConditionMatchType.StringDoesNotMatchIfExists: {
57+
return ifExists(context, matcher.field, (value) => {
58+
return !stringMatchesPattern(matcher.value, value);
59+
});
60+
}
4361
default: {
4462
return false;
4563
}

tests/multi-dimensional-context.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import {
66
BUDGET_VIEWER_MFG_HEAD,
77
BUDGET_VIEWER_KAZOO_PM,
88
BUDGET_VIEWER_ACCOUNTING_HEAD,
9+
VIEW_NON_CONFIDENTIAL_BLUEPRINTS,
10+
VIEW_ALL_BLUEPRINTS,
11+
BLUEPRINT_VIEWER_ENGINEERING,
12+
BLUEPRINT_VIEWER_ENGINEERING_ALL,
13+
BLUEPRINT_VIEWER_MULTI_DEPT,
914
} from './sample-data';
1015

1116
describe('Multi-dimensional context authorization', () => {
@@ -138,3 +143,132 @@ describe('Multi-dimensional context authorization', () => {
138143
});
139144
});
140145
});
146+
147+
describe('Multi-dimensional IfExists conditions', () => {
148+
const blueprintFactory = new AuthorizerFactory([ VIEW_NON_CONFIDENTIAL_BLUEPRINTS, VIEW_ALL_BLUEPRINTS ]),
149+
mockBlueprint = 'blueprints:a1b2c3d4-e5f6-7890-abcd-ef1234567890';
150+
151+
describe('BLUEPRINT_VIEWER_ENGINEERING (non-confidential only via StringDoesNotMatchIfExists)', () => {
152+
const authorizer = blueprintFactory.makeAuthorizerForSubject(BLUEPRINT_VIEWER_ENGINEERING);
153+
154+
it('allows viewing engineering blueprint with no classification (field missing)', () => {
155+
const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, {
156+
context: { 'blueprints:OwningDepartment': 'engineering' },
157+
});
158+
159+
expect(allowed).to.strictlyEqual(true);
160+
});
161+
162+
it('allows viewing engineering blueprint classified as public', () => {
163+
const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, {
164+
context: { 'blueprints:OwningDepartment': 'engineering', 'blueprints:Classification': 'public' },
165+
});
166+
167+
expect(allowed).to.strictlyEqual(true);
168+
});
169+
170+
it('denies viewing engineering blueprint classified as confidential', () => {
171+
const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, {
172+
context: { 'blueprints:OwningDepartment': 'engineering', 'blueprints:Classification': 'confidential' },
173+
});
174+
175+
expect(allowed).to.strictlyEqual(false);
176+
});
177+
178+
it('denies viewing manufacturing blueprint (wrong department)', () => {
179+
const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, {
180+
context: { 'blueprints:OwningDepartment': 'manufacturing', 'blueprints:Classification': 'public' },
181+
});
182+
183+
expect(allowed).to.strictlyEqual(false);
184+
});
185+
});
186+
187+
describe('BLUEPRINT_VIEWER_ENGINEERING_ALL (all blueprints including confidential)', () => {
188+
const authorizer = blueprintFactory.makeAuthorizerForSubject(BLUEPRINT_VIEWER_ENGINEERING_ALL);
189+
190+
it('allows viewing engineering blueprint with no classification', () => {
191+
const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, {
192+
context: { 'blueprints:OwningDepartment': 'engineering' },
193+
});
194+
195+
expect(allowed).to.strictlyEqual(true);
196+
});
197+
198+
it('allows viewing engineering blueprint classified as public', () => {
199+
const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, {
200+
context: { 'blueprints:OwningDepartment': 'engineering', 'blueprints:Classification': 'public' },
201+
});
202+
203+
expect(allowed).to.strictlyEqual(true);
204+
});
205+
206+
it('allows viewing engineering blueprint classified as confidential', () => {
207+
const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, {
208+
context: { 'blueprints:OwningDepartment': 'engineering', 'blueprints:Classification': 'confidential' },
209+
});
210+
211+
expect(allowed).to.strictlyEqual(true);
212+
});
213+
214+
it('denies viewing manufacturing blueprint (wrong department)', () => {
215+
const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, {
216+
context: { 'blueprints:OwningDepartment': 'manufacturing', 'blueprints:Classification': 'public' },
217+
});
218+
219+
expect(allowed).to.strictlyEqual(false);
220+
});
221+
});
222+
223+
describe('BLUEPRINT_VIEWER_MULTI_DEPT (non-confidential across engineering + manufacturing)', () => {
224+
const authorizer = blueprintFactory.makeAuthorizerForSubject(BLUEPRINT_VIEWER_MULTI_DEPT);
225+
226+
it('allows viewing engineering blueprint with no classification', () => {
227+
const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, {
228+
context: { 'blueprints:OwningDepartment': 'engineering' },
229+
});
230+
231+
expect(allowed).to.strictlyEqual(true);
232+
});
233+
234+
it('allows viewing manufacturing blueprint with no classification', () => {
235+
const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, {
236+
context: { 'blueprints:OwningDepartment': 'manufacturing' },
237+
});
238+
239+
expect(allowed).to.strictlyEqual(true);
240+
});
241+
242+
it('allows viewing engineering blueprint classified as internal', () => {
243+
const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, {
244+
context: { 'blueprints:OwningDepartment': 'engineering', 'blueprints:Classification': 'internal' },
245+
});
246+
247+
expect(allowed).to.strictlyEqual(true);
248+
});
249+
250+
it('denies viewing engineering confidential blueprint', () => {
251+
const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, {
252+
context: { 'blueprints:OwningDepartment': 'engineering', 'blueprints:Classification': 'confidential' },
253+
});
254+
255+
expect(allowed).to.strictlyEqual(false);
256+
});
257+
258+
it('denies viewing manufacturing confidential blueprint', () => {
259+
const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, {
260+
context: { 'blueprints:OwningDepartment': 'manufacturing', 'blueprints:Classification': 'confidential' },
261+
});
262+
263+
expect(allowed).to.strictlyEqual(false);
264+
});
265+
266+
it('denies viewing HR blueprint (unauthorized department)', () => {
267+
const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, {
268+
context: { 'blueprints:OwningDepartment': 'hr', 'blueprints:Classification': 'public' },
269+
});
270+
271+
expect(allowed).to.strictlyEqual(false);
272+
});
273+
});
274+
});

tests/sample-data.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,75 @@ export const BUDGET_VIEWER_ACCOUNTING_HEAD: Claims = {
301301
],
302302
};
303303

304+
export const VIEW_NON_CONFIDENTIAL_BLUEPRINTS: RoleDefinition = {
305+
roleID: 'view-non-confidential-blueprints',
306+
policies: [
307+
{
308+
effect: PolicyEffect.Allow,
309+
actions: [ 'blueprints:View' ],
310+
resources: [ 'blueprints:*' ],
311+
conditions: [
312+
{
313+
type: PolicyConditionMatchType.StringMatches,
314+
field: 'blueprints:OwningDepartment',
315+
value: '{CONTEXT_VALUE:department}',
316+
},
317+
{
318+
type: PolicyConditionMatchType.StringDoesNotMatchIfExists,
319+
field: 'blueprints:Classification',
320+
value: 'confidential',
321+
},
322+
],
323+
},
324+
],
325+
};
326+
327+
export const VIEW_ALL_BLUEPRINTS: RoleDefinition = {
328+
roleID: 'view-all-blueprints',
329+
policies: [
330+
{
331+
effect: PolicyEffect.Allow,
332+
actions: [ 'blueprints:View' ],
333+
resources: [ 'blueprints:*' ],
334+
conditions: [
335+
{
336+
type: PolicyConditionMatchType.StringMatches,
337+
field: 'blueprints:OwningDepartment',
338+
value: '{CONTEXT_VALUE:department}',
339+
},
340+
],
341+
},
342+
],
343+
};
344+
345+
export const BLUEPRINT_VIEWER_ENGINEERING_ID = 'e5f6a7b8-c9d0-1234-ef01-567890123456';
346+
347+
export const BLUEPRINT_VIEWER_ENGINEERING: Claims = {
348+
subjectID: BLUEPRINT_VIEWER_ENGINEERING_ID,
349+
roles: [
350+
{ roleID: VIEW_NON_CONFIDENTIAL_BLUEPRINTS.roleID, contextValue: { department: 'engineering' } },
351+
],
352+
};
353+
354+
export const BLUEPRINT_VIEWER_ENGINEERING_ALL_ID = 'f6a7b8c9-d0e1-2345-f012-678901234567';
355+
356+
export const BLUEPRINT_VIEWER_ENGINEERING_ALL: Claims = {
357+
subjectID: BLUEPRINT_VIEWER_ENGINEERING_ALL_ID,
358+
roles: [
359+
{ roleID: VIEW_ALL_BLUEPRINTS.roleID, contextValue: { department: 'engineering' } },
360+
],
361+
};
362+
363+
export const BLUEPRINT_VIEWER_MULTI_DEPT_ID = 'a7b8c9d0-e1f2-3456-0123-789012345678';
364+
365+
export const BLUEPRINT_VIEWER_MULTI_DEPT: Claims = {
366+
subjectID: BLUEPRINT_VIEWER_MULTI_DEPT_ID,
367+
roles: [
368+
{ roleID: VIEW_NON_CONFIDENTIAL_BLUEPRINTS.roleID, contextValue: { department: 'engineering' } },
369+
{ roleID: VIEW_NON_CONFIDENTIAL_BLUEPRINTS.roleID, contextValue: { department: 'manufacturing' } },
370+
],
371+
};
372+
304373
export const ALL_ROLES = [
305374
ADMINISTER_OWN_AUTH,
306375
ADMINISTER_OTHER_AUTH,
@@ -310,4 +379,6 @@ export const ALL_ROLES = [
310379
ADMINISTER_ORG_BUSINESS_ACCOUNTS_CONJUNCTIVE,
311380
ADMINISTER_ORG_BUSINESS_ACCOUNTS_ROOT_ARRAY,
312381
VIEW_BUDGET,
382+
VIEW_NON_CONFIDENTIAL_BLUEPRINTS,
383+
VIEW_ALL_BLUEPRINTS,
313384
];
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,85 @@
11
import { expect } from 'chai';
22
import { matcherSatisfied } from '../../src/utils/conditions-match';
3+
import { PolicyConditionMatchType } from '../../src/fsaba-types';
34

45
describe('matcherSatisfied', () => {
56
it('returns false on invalid matcher type', () => {
67
expect(matcherSatisfied({ type: 'foo' } as any, {})).to.eql(false);
78
});
9+
10+
describe('StringMatchesIfExists', () => {
11+
it('returns true when field is missing from context', () => {
12+
const matcher = {
13+
type: PolicyConditionMatchType.StringMatchesIfExists,
14+
field: 'department',
15+
value: 'manufacturing',
16+
};
17+
18+
const context = { product: 'kazoo' };
19+
20+
expect(matcherSatisfied(matcher, context)).to.eql(true);
21+
});
22+
23+
it('returns true when field is present and matches', () => {
24+
const matcher = {
25+
type: PolicyConditionMatchType.StringMatchesIfExists,
26+
field: 'department',
27+
value: 'manufacturing',
28+
};
29+
30+
const context = { department: 'manufacturing' };
31+
32+
expect(matcherSatisfied(matcher, context)).to.eql(true);
33+
});
34+
35+
it('returns false when field is present and does not match', () => {
36+
const matcher = {
37+
type: PolicyConditionMatchType.StringMatchesIfExists,
38+
field: 'department',
39+
value: 'manufacturing',
40+
};
41+
42+
const context = { department: 'marketing' };
43+
44+
expect(matcherSatisfied(matcher, context)).to.eql(false);
45+
});
46+
});
47+
48+
describe('StringDoesNotMatchIfExists', () => {
49+
it('returns true when field is missing from context', () => {
50+
const matcher = {
51+
type: PolicyConditionMatchType.StringDoesNotMatchIfExists,
52+
field: 'department',
53+
value: 'manufacturing',
54+
};
55+
56+
const context = { product: 'kazoo' };
57+
58+
expect(matcherSatisfied(matcher, context)).to.eql(true);
59+
});
60+
61+
it('returns false when field is present and matches pattern', () => {
62+
const matcher = {
63+
type: PolicyConditionMatchType.StringDoesNotMatchIfExists,
64+
field: 'department',
65+
value: 'manufacturing',
66+
};
67+
68+
const context = { department: 'manufacturing' };
69+
70+
expect(matcherSatisfied(matcher, context)).to.eql(false);
71+
});
72+
73+
it('returns true when field is present and does not match', () => {
74+
const matcher = {
75+
type: PolicyConditionMatchType.StringDoesNotMatchIfExists,
76+
field: 'department',
77+
value: 'manufacturing',
78+
};
79+
80+
const context = { department: 'marketing' };
81+
82+
expect(matcherSatisfied(matcher, context)).to.eql(true);
83+
});
84+
});
885
});

0 commit comments

Comments
 (0)