diff --git a/src/fsaba-types.ts b/src/fsaba-types.ts index bcced0f..74ea056 100644 --- a/src/fsaba-types.ts +++ b/src/fsaba-types.ts @@ -66,6 +66,23 @@ export enum PolicyConditionMatchType { */ StringDoesNotMatch = 'string-does-not-match', + /** + * Evaluate the value as a string, seeing if it matches the string in the condition. + * If the field is missing from the context, the condition passes automatically. + * Like actions and resources, a string-matches-if-exists condition allows for a '*' as + * a wildcard. + */ + StringMatchesIfExists = 'string-matches-if-exists', + + /** + * Evaluates the value as a string, negating the regular string match. That is, the + * condition "passes" if the string value from the context fails to match the string in + * the condition. If the field is missing from the context, the condition passes + * automatically. Like actions and resources, a string-does-not-match-if-exists + * condition allows for a '*' as a wildcard. + */ + StringDoesNotMatchIfExists = 'string-does-not-match-if-exists', + } diff --git a/src/utils/conditions-match.ts b/src/utils/conditions-match.ts index cbf7ff3..01c5c50 100644 --- a/src/utils/conditions-match.ts +++ b/src/utils/conditions-match.ts @@ -1,4 +1,4 @@ -import { StringMap } from '@silvermine/toolbox'; +import { hasDefined, StringMap } from '@silvermine/toolbox'; import { isPolicyConditionConjunctionAllOf, isPolicyConditionConjunctionAnyOf, isPolicyConditionMatcher, PolicyCondition } from '..'; import { PolicyConditionMatcher, PolicyConditionMatchType } from '../fsaba-types'; import stringMatchesPattern from './string-matches-pattern'; @@ -29,6 +29,14 @@ function singleConditionSatisfied(cond: PolicyCondition, context: StringMap): bo throw new Error(`Unreachable: ${typeof cond} (${Object.getOwnPropertyNames(cond)})`); } +function ifExists(context: StringMap, field: string, callback: (value: string) => boolean): boolean { + if (!hasDefined(context, field)) { + return true; + } + + return callback(context[field]); +} + /** * EXPORTED ONLY FOR TESTING */ @@ -40,6 +48,16 @@ export function matcherSatisfied(matcher: PolicyConditionMatcher, context: Strin case PolicyConditionMatchType.StringDoesNotMatch: { return !stringMatchesPattern(matcher.value, context[matcher.field]); } + case PolicyConditionMatchType.StringMatchesIfExists: { + return ifExists(context, matcher.field, (value) => { + return stringMatchesPattern(matcher.value, value); + }); + } + case PolicyConditionMatchType.StringDoesNotMatchIfExists: { + return ifExists(context, matcher.field, (value) => { + return !stringMatchesPattern(matcher.value, value); + }); + } default: { return false; } diff --git a/src/utils/make-subject-specific-policies.ts b/src/utils/make-subject-specific-policies.ts index 4d3e306..c238a51 100644 --- a/src/utils/make-subject-specific-policies.ts +++ b/src/utils/make-subject-specific-policies.ts @@ -1,3 +1,4 @@ +import { isStringMap, StringMap } from '@silvermine/toolbox'; import { isPolicyConditionConjunctionAllOf, isPolicyConditionConjunctionAnyOf, @@ -60,11 +61,20 @@ function makeSubjectConditions(tokenReplacer: (s: string) => string, cond: Reado throw new Error(`Unreachable: ${typeof cond} (${Object.getOwnPropertyNames(cond)})`); } -function makePolicyID(subjectID: string, roleID: string, policyIndex: number, contextValue?: string): string { +function makePolicyID(subjectID: string, roleID: string, policyIndex: number, contextValue?: string | StringMap): string { const parts = [ `${roleID}[${policyIndex}]`, subjectID ]; if (contextValue) { - parts.push(contextValue); + if (isStringMap(contextValue)) { + const sortedPairs = Object.keys(contextValue) + .sort() + .map((k) => { return `${k}=${contextValue[k]}`; }) + .join(';'); + + parts.push(sortedPairs); + } else { + parts.push(contextValue); + } } return parts.join('|'); diff --git a/src/utils/substitute-values.ts b/src/utils/substitute-values.ts index 11e5bff..b1c93f8 100644 --- a/src/utils/substitute-values.ts +++ b/src/utils/substitute-values.ts @@ -1,18 +1,35 @@ +import { hasDefined, isString, isStringMap, StringMap } from '@silvermine/toolbox'; + export interface KnownTokenValues { subjectID: string; - contextValue?: string; + contextValue?: string | StringMap; } +const CONTEXT_VALUE_REGEX = /{CONTEXT_VALUE(?::([^}]+))?}/g; + export default function substituteValues(vals: KnownTokenValues, roleID: string, input: string): string { - let v = input; + const result = input.replace(/{SUBJECT_ID}/g, vals.subjectID); + + return result.replace(CONTEXT_VALUE_REGEX, (_match: string, key?: string) => { + if (vals.contextValue === undefined) { + throw new Error(`Role "${roleID}" depends on a context value, but none was supplied`); + } + + if (key === undefined) { + if (!isString(vals.contextValue)) { + throw new Error(`Role "${roleID}" uses {CONTEXT_VALUE} but contextValue is not a string`); + } + return vals.contextValue; + } - v = v.replace(/{SUBJECT_ID}/g, vals.subjectID); + if (!isStringMap(vals.contextValue)) { + throw new Error(`Role "${roleID}" uses {CONTEXT_VALUE:key} but contextValue is not a StringMap`); + } - if (vals.contextValue) { - v = v.replace(/{CONTEXT_VALUE}/g, vals.contextValue); - } else if (/{CONTEXT_VALUE}/.test(v)) { - throw new Error(`Role "${roleID}" depends on a context value, but none was supplied`); - } + if (!hasDefined(vals.contextValue, key)) { + throw new Error(`Role "${roleID}" references context key "${key}" but it was not provided`); + } - return v; + return vals.contextValue[key]; + }); } diff --git a/tests/multi-dimensional-context.test.ts b/tests/multi-dimensional-context.test.ts new file mode 100644 index 0000000..bb5d00a --- /dev/null +++ b/tests/multi-dimensional-context.test.ts @@ -0,0 +1,274 @@ +import { expect } from 'chai'; +import { AuthorizerFactory } from '../src'; +import { + VIEW_BUDGET, + BUDGET_VIEWER_SINGLE, + BUDGET_VIEWER_MFG_HEAD, + BUDGET_VIEWER_KAZOO_PM, + BUDGET_VIEWER_ACCOUNTING_HEAD, + VIEW_NON_CONFIDENTIAL_BLUEPRINTS, + VIEW_ALL_BLUEPRINTS, + BLUEPRINT_VIEWER_ENGINEERING, + BLUEPRINT_VIEWER_ENGINEERING_ALL, + BLUEPRINT_VIEWER_MULTI_DEPT, +} from './sample-data'; + +describe('Multi-dimensional context authorization', () => { + const factory = new AuthorizerFactory([ VIEW_BUDGET ]), + mockBudget = 'budget:f47ac10b-58cc-4372-a567-0e02b2c3d479'; + + describe('BUDGET_VIEWER_SINGLE (kazoo manufacturing only)', () => { + const authorizer = factory.makeAuthorizerForSubject(BUDGET_VIEWER_SINGLE); + + it('allows viewing kazoo manufacturing budget', () => { + const allowed = authorizer.isAllowed('budget:View', mockBudget, { + context: { 'budget:OwningDepartment': 'manufacturing', 'budget:ProductLine': 'kazoo' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('denies viewing rubber-duck manufacturing budget', () => { + const allowed = authorizer.isAllowed('budget:View', mockBudget, { + context: { 'budget:OwningDepartment': 'manufacturing', 'budget:ProductLine': 'rubber-duck' }, + }); + + expect(allowed).to.strictlyEqual(false); + }); + + it('denies viewing kazoo marketing budget', () => { + const allowed = authorizer.isAllowed('budget:View', mockBudget, { + context: { 'budget:OwningDepartment': 'marketing', 'budget:ProductLine': 'kazoo' }, + }); + + expect(allowed).to.strictlyEqual(false); + }); + }); + + describe('BUDGET_VIEWER_MFG_HEAD (all products in manufacturing)', () => { + const authorizer = factory.makeAuthorizerForSubject(BUDGET_VIEWER_MFG_HEAD); + + it('allows viewing kazoo manufacturing budget', () => { + const allowed = authorizer.isAllowed('budget:View', mockBudget, { + context: { 'budget:OwningDepartment': 'manufacturing', 'budget:ProductLine': 'kazoo' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('allows viewing rubber-duck manufacturing budget', () => { + const allowed = authorizer.isAllowed('budget:View', mockBudget, { + context: { 'budget:OwningDepartment': 'manufacturing', 'budget:ProductLine': 'rubber-duck' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('denies viewing kazoo marketing budget', () => { + const allowed = authorizer.isAllowed('budget:View', mockBudget, { + context: { 'budget:OwningDepartment': 'marketing', 'budget:ProductLine': 'kazoo' }, + }); + + expect(allowed).to.strictlyEqual(false); + }); + }); + + describe('BUDGET_VIEWER_KAZOO_PM (kazoo across departments)', () => { + const authorizer = factory.makeAuthorizerForSubject(BUDGET_VIEWER_KAZOO_PM); + + it('allows viewing kazoo manufacturing budget', () => { + const allowed = authorizer.isAllowed('budget:View', mockBudget, { + context: { 'budget:OwningDepartment': 'manufacturing', 'budget:ProductLine': 'kazoo' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('allows viewing kazoo marketing budget', () => { + const allowed = authorizer.isAllowed('budget:View', mockBudget, { + context: { 'budget:OwningDepartment': 'marketing', 'budget:ProductLine': 'kazoo' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('denies viewing rubber-duck manufacturing budget', () => { + const allowed = authorizer.isAllowed('budget:View', mockBudget, { + context: { 'budget:OwningDepartment': 'manufacturing', 'budget:ProductLine': 'rubber-duck' }, + }); + + expect(allowed).to.strictlyEqual(false); + }); + }); + + describe('BUDGET_VIEWER_ACCOUNTING_HEAD (manufacturing + marketing + office)', () => { + const authorizer = factory.makeAuthorizerForSubject(BUDGET_VIEWER_ACCOUNTING_HEAD); + + it('allows viewing kazoo manufacturing budget', () => { + const allowed = authorizer.isAllowed('budget:View', mockBudget, { + context: { 'budget:OwningDepartment': 'manufacturing', 'budget:ProductLine': 'kazoo' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('allows viewing rubber-duck marketing budget', () => { + const allowed = authorizer.isAllowed('budget:View', mockBudget, { + context: { 'budget:OwningDepartment': 'marketing', 'budget:ProductLine': 'rubber-duck' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('allows viewing any office budget (wildcard product)', () => { + const supplies = authorizer.isAllowed('budget:View', mockBudget, { + context: { 'budget:OwningDepartment': 'office', 'budget:ProductLine': 'supplies' }, + }); + + const equipment = authorizer.isAllowed('budget:View', mockBudget, { + context: { 'budget:OwningDepartment': 'office', 'budget:ProductLine': 'equipment' }, + }); + + expect(supplies).to.strictlyEqual(true); + + expect(equipment).to.strictlyEqual(true); + }); + + it('denies viewing HR department budget', () => { + const allowed = authorizer.isAllowed('budget:View', mockBudget, { + context: { 'budget:OwningDepartment': 'hr', 'budget:ProductLine': 'kazoo' }, + }); + + expect(allowed).to.strictlyEqual(false); + }); + }); +}); + +describe('Multi-dimensional IfExists conditions', () => { + const blueprintFactory = new AuthorizerFactory([ VIEW_NON_CONFIDENTIAL_BLUEPRINTS, VIEW_ALL_BLUEPRINTS ]), + mockBlueprint = 'blueprints:a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + + describe('BLUEPRINT_VIEWER_ENGINEERING (non-confidential only via StringDoesNotMatchIfExists)', () => { + const authorizer = blueprintFactory.makeAuthorizerForSubject(BLUEPRINT_VIEWER_ENGINEERING); + + it('allows viewing engineering blueprint with no classification (field missing)', () => { + const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, { + context: { 'blueprints:OwningDepartment': 'engineering' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('allows viewing engineering blueprint classified as public', () => { + const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, { + context: { 'blueprints:OwningDepartment': 'engineering', 'blueprints:Classification': 'public' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('denies viewing engineering blueprint classified as confidential', () => { + const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, { + context: { 'blueprints:OwningDepartment': 'engineering', 'blueprints:Classification': 'confidential' }, + }); + + expect(allowed).to.strictlyEqual(false); + }); + + it('denies viewing manufacturing blueprint (wrong department)', () => { + const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, { + context: { 'blueprints:OwningDepartment': 'manufacturing', 'blueprints:Classification': 'public' }, + }); + + expect(allowed).to.strictlyEqual(false); + }); + }); + + describe('BLUEPRINT_VIEWER_ENGINEERING_ALL (all blueprints including confidential)', () => { + const authorizer = blueprintFactory.makeAuthorizerForSubject(BLUEPRINT_VIEWER_ENGINEERING_ALL); + + it('allows viewing engineering blueprint with no classification', () => { + const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, { + context: { 'blueprints:OwningDepartment': 'engineering' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('allows viewing engineering blueprint classified as public', () => { + const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, { + context: { 'blueprints:OwningDepartment': 'engineering', 'blueprints:Classification': 'public' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('allows viewing engineering blueprint classified as confidential', () => { + const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, { + context: { 'blueprints:OwningDepartment': 'engineering', 'blueprints:Classification': 'confidential' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('denies viewing manufacturing blueprint (wrong department)', () => { + const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, { + context: { 'blueprints:OwningDepartment': 'manufacturing', 'blueprints:Classification': 'public' }, + }); + + expect(allowed).to.strictlyEqual(false); + }); + }); + + describe('BLUEPRINT_VIEWER_MULTI_DEPT (non-confidential across engineering + manufacturing)', () => { + const authorizer = blueprintFactory.makeAuthorizerForSubject(BLUEPRINT_VIEWER_MULTI_DEPT); + + it('allows viewing engineering blueprint with no classification', () => { + const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, { + context: { 'blueprints:OwningDepartment': 'engineering' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('allows viewing manufacturing blueprint with no classification', () => { + const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, { + context: { 'blueprints:OwningDepartment': 'manufacturing' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('allows viewing engineering blueprint classified as internal', () => { + const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, { + context: { 'blueprints:OwningDepartment': 'engineering', 'blueprints:Classification': 'internal' }, + }); + + expect(allowed).to.strictlyEqual(true); + }); + + it('denies viewing engineering confidential blueprint', () => { + const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, { + context: { 'blueprints:OwningDepartment': 'engineering', 'blueprints:Classification': 'confidential' }, + }); + + expect(allowed).to.strictlyEqual(false); + }); + + it('denies viewing manufacturing confidential blueprint', () => { + const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, { + context: { 'blueprints:OwningDepartment': 'manufacturing', 'blueprints:Classification': 'confidential' }, + }); + + expect(allowed).to.strictlyEqual(false); + }); + + it('denies viewing HR blueprint (unauthorized department)', () => { + const allowed = authorizer.isAllowed('blueprints:View', mockBlueprint, { + context: { 'blueprints:OwningDepartment': 'hr', 'blueprints:Classification': 'public' }, + }); + + expect(allowed).to.strictlyEqual(false); + }); + }); +}); diff --git a/tests/sample-data.ts b/tests/sample-data.ts index bdbb3bc..25fe2d4 100644 --- a/tests/sample-data.ts +++ b/tests/sample-data.ts @@ -236,6 +236,140 @@ export const ORG_BUSINESS_ACCOUNT_ADMIN_ROOT_ARRAY: Claims = { ], }; +export const VIEW_BUDGET: RoleDefinition = { + roleID: 'view-budget', + policies: [ + { + effect: PolicyEffect.Allow, + actions: [ 'budget:View' ], + resources: [ 'budget:*' ], + conditions: [ + { + type: PolicyConditionMatchType.StringMatches, + field: 'budget:OwningDepartment', + value: '{CONTEXT_VALUE:department}', + }, + { + type: PolicyConditionMatchType.StringMatches, + field: 'budget:ProductLine', + value: '{CONTEXT_VALUE:product}', + }, + ], + }, + ], +}; + +export const BUDGET_VIEWER_SINGLE_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +export const BUDGET_VIEWER_SINGLE: Claims = { + subjectID: BUDGET_VIEWER_SINGLE_ID, + roles: [ + { roleID: VIEW_BUDGET.roleID, contextValue: { product: 'kazoo', department: 'manufacturing' } }, + ], +}; + +export const BUDGET_VIEWER_MFG_HEAD_ID = 'b2c3d4e5-f6a7-8901-bcde-f23456789012'; + +export const BUDGET_VIEWER_MFG_HEAD: Claims = { + subjectID: BUDGET_VIEWER_MFG_HEAD_ID, + roles: [ + { roleID: VIEW_BUDGET.roleID, contextValue: { product: 'kazoo', department: 'manufacturing' } }, + { roleID: VIEW_BUDGET.roleID, contextValue: { product: 'rubber-duck', department: 'manufacturing' } }, + ], +}; + +export const BUDGET_VIEWER_KAZOO_PM_ID = 'c3d4e5f6-a7b8-9012-cdef-345678901234'; + +export const BUDGET_VIEWER_KAZOO_PM: Claims = { + subjectID: BUDGET_VIEWER_KAZOO_PM_ID, + roles: [ + { roleID: VIEW_BUDGET.roleID, contextValue: { product: 'kazoo', department: 'manufacturing' } }, + { roleID: VIEW_BUDGET.roleID, contextValue: { product: 'kazoo', department: 'marketing' } }, + ], +}; + +export const BUDGET_VIEWER_ACCOUNTING_HEAD_ID = 'd4e5f6a7-b8c9-0123-def0-456789012345'; + +export const BUDGET_VIEWER_ACCOUNTING_HEAD: Claims = { + subjectID: BUDGET_VIEWER_ACCOUNTING_HEAD_ID, + roles: [ + { roleID: VIEW_BUDGET.roleID, contextValue: { product: 'kazoo', department: 'manufacturing' } }, + { roleID: VIEW_BUDGET.roleID, contextValue: { product: 'rubber-duck', department: 'manufacturing' } }, + { roleID: VIEW_BUDGET.roleID, contextValue: { product: 'kazoo', department: 'marketing' } }, + { roleID: VIEW_BUDGET.roleID, contextValue: { product: 'rubber-duck', department: 'marketing' } }, + { roleID: VIEW_BUDGET.roleID, contextValue: { product: '*', department: 'office' } }, + ], +}; + +export const VIEW_NON_CONFIDENTIAL_BLUEPRINTS: RoleDefinition = { + roleID: 'view-non-confidential-blueprints', + policies: [ + { + effect: PolicyEffect.Allow, + actions: [ 'blueprints:View' ], + resources: [ 'blueprints:*' ], + conditions: [ + { + type: PolicyConditionMatchType.StringMatches, + field: 'blueprints:OwningDepartment', + value: '{CONTEXT_VALUE:department}', + }, + { + type: PolicyConditionMatchType.StringDoesNotMatchIfExists, + field: 'blueprints:Classification', + value: 'confidential', + }, + ], + }, + ], +}; + +export const VIEW_ALL_BLUEPRINTS: RoleDefinition = { + roleID: 'view-all-blueprints', + policies: [ + { + effect: PolicyEffect.Allow, + actions: [ 'blueprints:View' ], + resources: [ 'blueprints:*' ], + conditions: [ + { + type: PolicyConditionMatchType.StringMatches, + field: 'blueprints:OwningDepartment', + value: '{CONTEXT_VALUE:department}', + }, + ], + }, + ], +}; + +export const BLUEPRINT_VIEWER_ENGINEERING_ID = 'e5f6a7b8-c9d0-1234-ef01-567890123456'; + +export const BLUEPRINT_VIEWER_ENGINEERING: Claims = { + subjectID: BLUEPRINT_VIEWER_ENGINEERING_ID, + roles: [ + { roleID: VIEW_NON_CONFIDENTIAL_BLUEPRINTS.roleID, contextValue: { department: 'engineering' } }, + ], +}; + +export const BLUEPRINT_VIEWER_ENGINEERING_ALL_ID = 'f6a7b8c9-d0e1-2345-f012-678901234567'; + +export const BLUEPRINT_VIEWER_ENGINEERING_ALL: Claims = { + subjectID: BLUEPRINT_VIEWER_ENGINEERING_ALL_ID, + roles: [ + { roleID: VIEW_ALL_BLUEPRINTS.roleID, contextValue: { department: 'engineering' } }, + ], +}; + +export const BLUEPRINT_VIEWER_MULTI_DEPT_ID = 'a7b8c9d0-e1f2-3456-0123-789012345678'; + +export const BLUEPRINT_VIEWER_MULTI_DEPT: Claims = { + subjectID: BLUEPRINT_VIEWER_MULTI_DEPT_ID, + roles: [ + { roleID: VIEW_NON_CONFIDENTIAL_BLUEPRINTS.roleID, contextValue: { department: 'engineering' } }, + { roleID: VIEW_NON_CONFIDENTIAL_BLUEPRINTS.roleID, contextValue: { department: 'manufacturing' } }, + ], +}; + export const ALL_ROLES = [ ADMINISTER_OWN_AUTH, ADMINISTER_OTHER_AUTH, @@ -244,4 +378,7 @@ export const ALL_ROLES = [ ADMINISTER_OWN_MONEY, ADMINISTER_ORG_BUSINESS_ACCOUNTS_CONJUNCTIVE, ADMINISTER_ORG_BUSINESS_ACCOUNTS_ROOT_ARRAY, + VIEW_BUDGET, + VIEW_NON_CONFIDENTIAL_BLUEPRINTS, + VIEW_ALL_BLUEPRINTS, ]; diff --git a/tests/utils/conditions-match.test.ts b/tests/utils/conditions-match.test.ts index ca962f2..2f73069 100644 --- a/tests/utils/conditions-match.test.ts +++ b/tests/utils/conditions-match.test.ts @@ -1,8 +1,85 @@ import { expect } from 'chai'; import { matcherSatisfied } from '../../src/utils/conditions-match'; +import { PolicyConditionMatchType } from '../../src/fsaba-types'; describe('matcherSatisfied', () => { it('returns false on invalid matcher type', () => { expect(matcherSatisfied({ type: 'foo' } as any, {})).to.eql(false); }); + + describe('StringMatchesIfExists', () => { + it('returns true when field is missing from context', () => { + const matcher = { + type: PolicyConditionMatchType.StringMatchesIfExists, + field: 'department', + value: 'manufacturing', + }; + + const context = { product: 'kazoo' }; + + expect(matcherSatisfied(matcher, context)).to.eql(true); + }); + + it('returns true when field is present and matches', () => { + const matcher = { + type: PolicyConditionMatchType.StringMatchesIfExists, + field: 'department', + value: 'manufacturing', + }; + + const context = { department: 'manufacturing' }; + + expect(matcherSatisfied(matcher, context)).to.eql(true); + }); + + it('returns false when field is present and does not match', () => { + const matcher = { + type: PolicyConditionMatchType.StringMatchesIfExists, + field: 'department', + value: 'manufacturing', + }; + + const context = { department: 'marketing' }; + + expect(matcherSatisfied(matcher, context)).to.eql(false); + }); + }); + + describe('StringDoesNotMatchIfExists', () => { + it('returns true when field is missing from context', () => { + const matcher = { + type: PolicyConditionMatchType.StringDoesNotMatchIfExists, + field: 'department', + value: 'manufacturing', + }; + + const context = { product: 'kazoo' }; + + expect(matcherSatisfied(matcher, context)).to.eql(true); + }); + + it('returns false when field is present and matches pattern', () => { + const matcher = { + type: PolicyConditionMatchType.StringDoesNotMatchIfExists, + field: 'department', + value: 'manufacturing', + }; + + const context = { department: 'manufacturing' }; + + expect(matcherSatisfied(matcher, context)).to.eql(false); + }); + + it('returns true when field is present and does not match', () => { + const matcher = { + type: PolicyConditionMatchType.StringDoesNotMatchIfExists, + field: 'department', + value: 'manufacturing', + }; + + const context = { department: 'marketing' }; + + expect(matcherSatisfied(matcher, context)).to.eql(true); + }); + }); }); diff --git a/tests/utils/substitute-values.test.ts b/tests/utils/substitute-values.test.ts index 06386f3..3f81323 100644 --- a/tests/utils/substitute-values.test.ts +++ b/tests/utils/substitute-values.test.ts @@ -2,9 +2,63 @@ import { expect } from 'chai'; import substituteValues from '../../src/utils/substitute-values'; describe('substituteValues', () => { - it('throws an error when CONTEXT_VALUE not supplied', () => { - expect(() => { substituteValues({ subjectID: 'foo' }, 'some-role', 'some:{CONTEXT_VALUE}'); }) - .to - .throw('Role "some-role" depends on a context value, but none was supplied'); + + describe('simple {CONTEXT_VALUE}', () => { + it('replaces CONTEXT_VALUE with string contextValue', () => { + const result = substituteValues({ subjectID: 'user1', contextValue: 'foo' }, 'role', 'resource:{CONTEXT_VALUE}'); + + expect(result).to.strictlyEqual('resource:foo'); + }); + + it('throws when contextValue is object but simple placeholder used', () => { + expect(() => { substituteValues({ subjectID: 'user1', contextValue: { a: 'x' } }, 'role', 'resource:{CONTEXT_VALUE}'); }) + .to + .throw('Role "role" uses {CONTEXT_VALUE} but contextValue is not a string'); + }); + + it('throws an error when CONTEXT_VALUE not supplied', () => { + expect(() => { substituteValues({ subjectID: 'foo' }, 'some-role', 'some:{CONTEXT_VALUE}'); }) + .to + .throw('Role "some-role" depends on a context value, but none was supplied'); + }); + }); + + describe('keyed {CONTEXT_VALUE:key}', () => { + it('extracts key from object contextValue', () => { + const result = substituteValues( + { subjectID: 'user1', contextValue: { product: 'kazoo', department: 'mfg' } }, + 'role', + 'budget:{CONTEXT_VALUE:department}:{CONTEXT_VALUE:product}' + ); + + expect(result).to.strictlyEqual('budget:mfg:kazoo'); + }); + + it('throws when contextValue is string but keyed placeholder used', () => { + expect(() => { substituteValues({ subjectID: 'user1', contextValue: 'foo' }, 'role', 'resource:{CONTEXT_VALUE:key}'); }) + .to + .throw('Role "role" uses {CONTEXT_VALUE:key} but contextValue is not a StringMap'); + }); + + it('throws when referenced key does not exist', () => { + expect(() => { substituteValues({ subjectID: 'user1', contextValue: { a: 'x' } }, 'role', 'resource:{CONTEXT_VALUE:missing}'); }) + .to + .throw('Role "role" references context key "missing" but it was not provided'); + }); + + it('throws when contextValue not supplied', () => { + expect(() => { substituteValues({ subjectID: 'user1' }, 'role', 'resource:{CONTEXT_VALUE:key}'); }) + .to + .throw('Role "role" depends on a context value, but none was supplied'); + }); }); + + describe('SUBJECT_ID', () => { + it('replaces SUBJECT_ID', () => { + const result = substituteValues({ subjectID: 'user123' }, 'role', 'auth:principals/{SUBJECT_ID}'); + + expect(result).to.strictlyEqual('auth:principals/user123'); + }); + }); + });