Skip to content

Commit c2728bd

Browse files
committed
feat: add support for multiple context values
1 parent 7f3de43 commit c2728bd

File tree

5 files changed

+302
-15
lines changed

5 files changed

+302
-15
lines changed

src/utils/make-subject-specific-policies.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isStringMap, StringMap } from '@silvermine/toolbox';
12
import {
23
isPolicyConditionConjunctionAllOf,
34
isPolicyConditionConjunctionAnyOf,
@@ -60,11 +61,20 @@ function makeSubjectConditions(tokenReplacer: (s: string) => string, cond: Reado
6061
throw new Error(`Unreachable: ${typeof cond} (${Object.getOwnPropertyNames(cond)})`);
6162
}
6263

63-
function makePolicyID(subjectID: string, roleID: string, policyIndex: number, contextValue?: string): string {
64+
function makePolicyID(subjectID: string, roleID: string, policyIndex: number, contextValue?: string | StringMap): string {
6465
const parts = [ `${roleID}[${policyIndex}]`, subjectID ];
6566

6667
if (contextValue) {
67-
parts.push(contextValue);
68+
if (isStringMap(contextValue)) {
69+
const sortedPairs = Object.keys(contextValue)
70+
.sort()
71+
.map((k) => { return `${k}=${contextValue[k]}`; })
72+
.join(';');
73+
74+
parts.push(sortedPairs);
75+
} else {
76+
parts.push(contextValue);
77+
}
6878
}
6979

7080
return parts.join('|');

src/utils/substitute-values.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,35 @@
1+
import { hasDefined, isString, isStringMap, StringMap } from '@silvermine/toolbox';
2+
13
export interface KnownTokenValues {
24
subjectID: string;
3-
contextValue?: string;
5+
contextValue?: string | StringMap;
46
}
57

8+
const CONTEXT_VALUE_REGEX = /{CONTEXT_VALUE(?::([^}]+))?}/g;
9+
610
export default function substituteValues(vals: KnownTokenValues, roleID: string, input: string): string {
7-
let v = input;
11+
const result = input.replace(/{SUBJECT_ID}/g, vals.subjectID);
12+
13+
return result.replace(CONTEXT_VALUE_REGEX, (_match: string, key?: string) => {
14+
if (vals.contextValue === undefined) {
15+
throw new Error(`Role "${roleID}" depends on a context value, but none was supplied`);
16+
}
17+
18+
if (key === undefined) {
19+
if (!isString(vals.contextValue)) {
20+
throw new Error(`Role "${roleID}" uses {CONTEXT_VALUE} but contextValue is not a string`);
21+
}
22+
return vals.contextValue;
23+
}
824

9-
v = v.replace(/{SUBJECT_ID}/g, vals.subjectID);
25+
if (!isStringMap(vals.contextValue)) {
26+
throw new Error(`Role "${roleID}" uses {CONTEXT_VALUE:key} but contextValue is not a StringMap`);
27+
}
1028

11-
if (vals.contextValue) {
12-
v = v.replace(/{CONTEXT_VALUE}/g, vals.contextValue);
13-
} else if (/{CONTEXT_VALUE}/.test(v)) {
14-
throw new Error(`Role "${roleID}" depends on a context value, but none was supplied`);
15-
}
29+
if (!hasDefined(vals.contextValue, key)) {
30+
throw new Error(`Role "${roleID}" references context key "${key}" but it was not provided`);
31+
}
1632

17-
return v;
33+
return vals.contextValue[key];
34+
});
1835
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { expect } from 'chai';
2+
import { AuthorizerFactory } from '../src';
3+
import {
4+
VIEW_BUDGET,
5+
BUDGET_VIEWER_SINGLE,
6+
BUDGET_VIEWER_MFG_HEAD,
7+
BUDGET_VIEWER_KAZOO_PM,
8+
BUDGET_VIEWER_ACCOUNTING_HEAD,
9+
} from './sample-data';
10+
11+
describe('Multi-dimensional context authorization', () => {
12+
const factory = new AuthorizerFactory([ VIEW_BUDGET ]),
13+
mockBudget = 'budget:f47ac10b-58cc-4372-a567-0e02b2c3d479';
14+
15+
describe('BUDGET_VIEWER_SINGLE (kazoo manufacturing only)', () => {
16+
const authorizer = factory.makeAuthorizerForSubject(BUDGET_VIEWER_SINGLE);
17+
18+
it('allows viewing kazoo manufacturing budget', () => {
19+
const allowed = authorizer.isAllowed('budget:View', mockBudget, {
20+
context: { 'budget:OwningDepartment': 'manufacturing', 'budget:ProductLine': 'kazoo' },
21+
});
22+
23+
expect(allowed).to.strictlyEqual(true);
24+
});
25+
26+
it('denies viewing rubber-duck manufacturing budget', () => {
27+
const allowed = authorizer.isAllowed('budget:View', mockBudget, {
28+
context: { 'budget:OwningDepartment': 'manufacturing', 'budget:ProductLine': 'rubber-duck' },
29+
});
30+
31+
expect(allowed).to.strictlyEqual(false);
32+
});
33+
34+
it('denies viewing kazoo marketing budget', () => {
35+
const allowed = authorizer.isAllowed('budget:View', mockBudget, {
36+
context: { 'budget:OwningDepartment': 'marketing', 'budget:ProductLine': 'kazoo' },
37+
});
38+
39+
expect(allowed).to.strictlyEqual(false);
40+
});
41+
});
42+
43+
describe('BUDGET_VIEWER_MFG_HEAD (all products in manufacturing)', () => {
44+
const authorizer = factory.makeAuthorizerForSubject(BUDGET_VIEWER_MFG_HEAD);
45+
46+
it('allows viewing kazoo manufacturing budget', () => {
47+
const allowed = authorizer.isAllowed('budget:View', mockBudget, {
48+
context: { 'budget:OwningDepartment': 'manufacturing', 'budget:ProductLine': 'kazoo' },
49+
});
50+
51+
expect(allowed).to.strictlyEqual(true);
52+
});
53+
54+
it('allows viewing rubber-duck manufacturing budget', () => {
55+
const allowed = authorizer.isAllowed('budget:View', mockBudget, {
56+
context: { 'budget:OwningDepartment': 'manufacturing', 'budget:ProductLine': 'rubber-duck' },
57+
});
58+
59+
expect(allowed).to.strictlyEqual(true);
60+
});
61+
62+
it('denies viewing kazoo marketing budget', () => {
63+
const allowed = authorizer.isAllowed('budget:View', mockBudget, {
64+
context: { 'budget:OwningDepartment': 'marketing', 'budget:ProductLine': 'kazoo' },
65+
});
66+
67+
expect(allowed).to.strictlyEqual(false);
68+
});
69+
});
70+
71+
describe('BUDGET_VIEWER_KAZOO_PM (kazoo across departments)', () => {
72+
const authorizer = factory.makeAuthorizerForSubject(BUDGET_VIEWER_KAZOO_PM);
73+
74+
it('allows viewing kazoo manufacturing budget', () => {
75+
const allowed = authorizer.isAllowed('budget:View', mockBudget, {
76+
context: { 'budget:OwningDepartment': 'manufacturing', 'budget:ProductLine': 'kazoo' },
77+
});
78+
79+
expect(allowed).to.strictlyEqual(true);
80+
});
81+
82+
it('allows viewing kazoo marketing budget', () => {
83+
const allowed = authorizer.isAllowed('budget:View', mockBudget, {
84+
context: { 'budget:OwningDepartment': 'marketing', 'budget:ProductLine': 'kazoo' },
85+
});
86+
87+
expect(allowed).to.strictlyEqual(true);
88+
});
89+
90+
it('denies viewing rubber-duck manufacturing budget', () => {
91+
const allowed = authorizer.isAllowed('budget:View', mockBudget, {
92+
context: { 'budget:OwningDepartment': 'manufacturing', 'budget:ProductLine': 'rubber-duck' },
93+
});
94+
95+
expect(allowed).to.strictlyEqual(false);
96+
});
97+
});
98+
99+
describe('BUDGET_VIEWER_ACCOUNTING_HEAD (manufacturing + marketing + office)', () => {
100+
const authorizer = factory.makeAuthorizerForSubject(BUDGET_VIEWER_ACCOUNTING_HEAD);
101+
102+
it('allows viewing kazoo manufacturing budget', () => {
103+
const allowed = authorizer.isAllowed('budget:View', mockBudget, {
104+
context: { 'budget:OwningDepartment': 'manufacturing', 'budget:ProductLine': 'kazoo' },
105+
});
106+
107+
expect(allowed).to.strictlyEqual(true);
108+
});
109+
110+
it('allows viewing rubber-duck marketing budget', () => {
111+
const allowed = authorizer.isAllowed('budget:View', mockBudget, {
112+
context: { 'budget:OwningDepartment': 'marketing', 'budget:ProductLine': 'rubber-duck' },
113+
});
114+
115+
expect(allowed).to.strictlyEqual(true);
116+
});
117+
118+
it('allows viewing any office budget (wildcard product)', () => {
119+
const supplies = authorizer.isAllowed('budget:View', mockBudget, {
120+
context: { 'budget:OwningDepartment': 'office', 'budget:ProductLine': 'supplies' },
121+
});
122+
123+
const equipment = authorizer.isAllowed('budget:View', mockBudget, {
124+
context: { 'budget:OwningDepartment': 'office', 'budget:ProductLine': 'equipment' },
125+
});
126+
127+
expect(supplies).to.strictlyEqual(true);
128+
129+
expect(equipment).to.strictlyEqual(true);
130+
});
131+
132+
it('denies viewing HR department budget', () => {
133+
const allowed = authorizer.isAllowed('budget:View', mockBudget, {
134+
context: { 'budget:OwningDepartment': 'hr', 'budget:ProductLine': 'kazoo' },
135+
});
136+
137+
expect(allowed).to.strictlyEqual(false);
138+
});
139+
});
140+
});

tests/sample-data.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,71 @@ export const ORG_BUSINESS_ACCOUNT_ADMIN_ROOT_ARRAY: Claims = {
236236
],
237237
};
238238

239+
export const VIEW_BUDGET: RoleDefinition = {
240+
roleID: 'view-budget',
241+
policies: [
242+
{
243+
effect: PolicyEffect.Allow,
244+
actions: [ 'budget:View' ],
245+
resources: [ 'budget:*' ],
246+
conditions: [
247+
{
248+
type: PolicyConditionMatchType.StringMatches,
249+
field: 'budget:OwningDepartment',
250+
value: '{CONTEXT_VALUE:department}',
251+
},
252+
{
253+
type: PolicyConditionMatchType.StringMatches,
254+
field: 'budget:ProductLine',
255+
value: '{CONTEXT_VALUE:product}',
256+
},
257+
],
258+
},
259+
],
260+
};
261+
262+
export const BUDGET_VIEWER_SINGLE_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
263+
264+
export const BUDGET_VIEWER_SINGLE: Claims = {
265+
subjectID: BUDGET_VIEWER_SINGLE_ID,
266+
roles: [
267+
{ roleID: VIEW_BUDGET.roleID, contextValue: { product: 'kazoo', department: 'manufacturing' } },
268+
],
269+
};
270+
271+
export const BUDGET_VIEWER_MFG_HEAD_ID = 'b2c3d4e5-f6a7-8901-bcde-f23456789012';
272+
273+
export const BUDGET_VIEWER_MFG_HEAD: Claims = {
274+
subjectID: BUDGET_VIEWER_MFG_HEAD_ID,
275+
roles: [
276+
{ roleID: VIEW_BUDGET.roleID, contextValue: { product: 'kazoo', department: 'manufacturing' } },
277+
{ roleID: VIEW_BUDGET.roleID, contextValue: { product: 'rubber-duck', department: 'manufacturing' } },
278+
],
279+
};
280+
281+
export const BUDGET_VIEWER_KAZOO_PM_ID = 'c3d4e5f6-a7b8-9012-cdef-345678901234';
282+
283+
export const BUDGET_VIEWER_KAZOO_PM: Claims = {
284+
subjectID: BUDGET_VIEWER_KAZOO_PM_ID,
285+
roles: [
286+
{ roleID: VIEW_BUDGET.roleID, contextValue: { product: 'kazoo', department: 'manufacturing' } },
287+
{ roleID: VIEW_BUDGET.roleID, contextValue: { product: 'kazoo', department: 'marketing' } },
288+
],
289+
};
290+
291+
export const BUDGET_VIEWER_ACCOUNTING_HEAD_ID = 'd4e5f6a7-b8c9-0123-def0-456789012345';
292+
293+
export const BUDGET_VIEWER_ACCOUNTING_HEAD: Claims = {
294+
subjectID: BUDGET_VIEWER_ACCOUNTING_HEAD_ID,
295+
roles: [
296+
{ roleID: VIEW_BUDGET.roleID, contextValue: { product: 'kazoo', department: 'manufacturing' } },
297+
{ roleID: VIEW_BUDGET.roleID, contextValue: { product: 'rubber-duck', department: 'manufacturing' } },
298+
{ roleID: VIEW_BUDGET.roleID, contextValue: { product: 'kazoo', department: 'marketing' } },
299+
{ roleID: VIEW_BUDGET.roleID, contextValue: { product: 'rubber-duck', department: 'marketing' } },
300+
{ roleID: VIEW_BUDGET.roleID, contextValue: { product: '*', department: 'office' } },
301+
],
302+
};
303+
239304
export const ALL_ROLES = [
240305
ADMINISTER_OWN_AUTH,
241306
ADMINISTER_OTHER_AUTH,
@@ -244,4 +309,5 @@ export const ALL_ROLES = [
244309
ADMINISTER_OWN_MONEY,
245310
ADMINISTER_ORG_BUSINESS_ACCOUNTS_CONJUNCTIVE,
246311
ADMINISTER_ORG_BUSINESS_ACCOUNTS_ROOT_ARRAY,
312+
VIEW_BUDGET,
247313
];

tests/utils/substitute-values.test.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,63 @@ import { expect } from 'chai';
22
import substituteValues from '../../src/utils/substitute-values';
33

44
describe('substituteValues', () => {
5-
it('throws an error when CONTEXT_VALUE not supplied', () => {
6-
expect(() => { substituteValues({ subjectID: 'foo' }, 'some-role', 'some:{CONTEXT_VALUE}'); })
7-
.to
8-
.throw('Role "some-role" depends on a context value, but none was supplied');
5+
6+
describe('simple {CONTEXT_VALUE}', () => {
7+
it('replaces CONTEXT_VALUE with string contextValue', () => {
8+
const result = substituteValues({ subjectID: 'user1', contextValue: 'foo' }, 'role', 'resource:{CONTEXT_VALUE}');
9+
10+
expect(result).to.strictlyEqual('resource:foo');
11+
});
12+
13+
it('throws when contextValue is object but simple placeholder used', () => {
14+
expect(() => { substituteValues({ subjectID: 'user1', contextValue: { a: 'x' } }, 'role', 'resource:{CONTEXT_VALUE}'); })
15+
.to
16+
.throw('Role "role" uses {CONTEXT_VALUE} but contextValue is not a string');
17+
});
18+
19+
it('throws an error when CONTEXT_VALUE not supplied', () => {
20+
expect(() => { substituteValues({ subjectID: 'foo' }, 'some-role', 'some:{CONTEXT_VALUE}'); })
21+
.to
22+
.throw('Role "some-role" depends on a context value, but none was supplied');
23+
});
24+
});
25+
26+
describe('keyed {CONTEXT_VALUE:key}', () => {
27+
it('extracts key from object contextValue', () => {
28+
const result = substituteValues(
29+
{ subjectID: 'user1', contextValue: { product: 'kazoo', department: 'mfg' } },
30+
'role',
31+
'budget:{CONTEXT_VALUE:department}:{CONTEXT_VALUE:product}'
32+
);
33+
34+
expect(result).to.strictlyEqual('budget:mfg:kazoo');
35+
});
36+
37+
it('throws when contextValue is string but keyed placeholder used', () => {
38+
expect(() => { substituteValues({ subjectID: 'user1', contextValue: 'foo' }, 'role', 'resource:{CONTEXT_VALUE:key}'); })
39+
.to
40+
.throw('Role "role" uses {CONTEXT_VALUE:key} but contextValue is not a StringMap');
41+
});
42+
43+
it('throws when referenced key does not exist', () => {
44+
expect(() => { substituteValues({ subjectID: 'user1', contextValue: { a: 'x' } }, 'role', 'resource:{CONTEXT_VALUE:missing}'); })
45+
.to
46+
.throw('Role "role" references context key "missing" but it was not provided');
47+
});
48+
49+
it('throws when contextValue not supplied', () => {
50+
expect(() => { substituteValues({ subjectID: 'user1' }, 'role', 'resource:{CONTEXT_VALUE:key}'); })
51+
.to
52+
.throw('Role "role" depends on a context value, but none was supplied');
53+
});
954
});
55+
56+
describe('SUBJECT_ID', () => {
57+
it('replaces SUBJECT_ID', () => {
58+
const result = substituteValues({ subjectID: 'user123' }, 'role', 'auth:principals/{SUBJECT_ID}');
59+
60+
expect(result).to.strictlyEqual('auth:principals/user123');
61+
});
62+
});
63+
1064
});

0 commit comments

Comments
 (0)