diff --git a/lib/__fixtures__/contexts.ts b/lib/__fixtures__/contexts.ts new file mode 100644 index 0000000..653cc8d --- /dev/null +++ b/lib/__fixtures__/contexts.ts @@ -0,0 +1,22 @@ +export const allowedContext = { + subject: { + id: 1234, + }, + doc: { + createdBy: 1234, + }, +}; + +export const deniedContext = { + subject: { + groups: ["group5"], + }, +}; + +export enum Actions { + create = "create", + read = "read", + createDocument = "documents:createDocument", + readDocument = "documents:readDocument", + signDocuments = "sign:documents", +} diff --git a/lib/__fixtures__/parsed-statements.ts b/lib/__fixtures__/parsed-statements.ts new file mode 100644 index 0000000..88d9900 --- /dev/null +++ b/lib/__fixtures__/parsed-statements.ts @@ -0,0 +1,28 @@ +import * as statements from "./statements"; +import { parsePolicyStatement } from "../parsed-policy-statement"; + +export const GlobAllStatement = parsePolicyStatement( + statements.GlobAllStatement +); +export const GlobEndStatement = parsePolicyStatement( + statements.GlobEndStatement +); +export const GlobStartStatement = parsePolicyStatement( + statements.GlobStartStatement +); + +export const BasicAllowStatement = parsePolicyStatement( + statements.BasicAllowStatement +); +export const MultipleActionsStatement = parsePolicyStatement( + statements.MultipleActionsStatement +); +export const ContextualAllowStatement = parsePolicyStatement( + statements.ContextualAllowStatement +); +export const ContextualDenyStatement = parsePolicyStatement( + statements.ContextualDenyStatement +); +export const ContextualGlobStatement = parsePolicyStatement( + statements.ContextualGlobStatement +); diff --git a/lib/__fixtures__/statements.ts b/lib/__fixtures__/statements.ts index 7aea773..3dabfb3 100644 --- a/lib/__fixtures__/statements.ts +++ b/lib/__fixtures__/statements.ts @@ -1,93 +1,72 @@ -import { parsePolicyStatement } from "../parsed-policy-statement"; +import { PolicyStatement } from "../types"; +import { Actions } from "./contexts"; -export const GlobAllStatement = parsePolicyStatement( - { - action: "*", - effect: "allow", - constraint: true, - }, - "GlobAllStatement" -); - -export const GlobStartStatement = parsePolicyStatement( - { - action: "*:documents", - effect: "allow", - constraint: true, - }, - "GlobStartStatement" -); +export const GlobAllStatement: PolicyStatement = { + sid: "GlobAllStatement", + action: "*", + effect: "allow", + constraint: true, +}; -export const GlobEndStatement = parsePolicyStatement( - { - action: "documents:*", - effect: "allow", - constraint: true, - }, - "GlobEndStatement" -); +export const GlobStartStatement: PolicyStatement = { + sid: "GlobStartStatement", + action: "*:documents", + effect: "allow", + constraint: true, +}; -export const BasicAllowStatement = parsePolicyStatement( - { - action: "create", - effect: "allow", - constraint: true, - }, - "BasicAllowStatement" -); +export const GlobEndStatement: PolicyStatement = { + sid: "GlobEndStatement", + action: "documents:*", + effect: "allow", + constraint: true, +}; -export const MultipleActionsStatement = parsePolicyStatement( - { - action: ["create", "read"], - effect: "allow", - constraint: true, - }, - "MultipleActionsStatement" -); +export const BasicAllowStatement: PolicyStatement = { + sid: "BasicAllowStatement", + action: Actions.create, + effect: "allow", + constraint: true, +}; -export const allowedContext = { - subject: { - id: 1234, - }, - doc: { - createdBy: 1234, - }, +export const MultipleActionsStatement: PolicyStatement = { + sid: "MultipleActionsStatement", + action: [Actions.create, Actions.read], + effect: "allow", + constraint: true, }; -export const ContextualAllowStatement = parsePolicyStatement( - { - action: "create", - effect: "allow", - constraint: { - "===": [{ var: "subject.id" }, { var: "doc.createdBy" }], - }, +export const ContextualAllowStatement: PolicyStatement = { + sid: "ContextualAllowStatement", + action: Actions.create, + effect: "allow", + constraint: { + "===": [{ var: "subject.id" }, { var: "doc.createdBy" }], }, - "ContextualAllowStatement" -); +}; -export const deniedContext = { - subject: { - groups: ["group5"], +export const ContextualDenyStatement: PolicyStatement = { + action: Actions.create, + effect: "deny", + sid: "ContextualDenyStatement", + constraint: { + some: [{ var: "subject.groups" }, { in: [{ var: "" }, ["group5"]] }], }, }; -export const ContextualDenyStatement = parsePolicyStatement( - { - action: "create", - effect: "deny", - constraint: { - some: [{ var: "subject.groups" }, { in: [{ var: "" }, ["group5"]] }], - }, +export const ContextualGlobStatement: PolicyStatement = { + sid: "ContextualGlobStatement", + action: "*", + effect: "allow", + constraint: { + "===": [{ var: "subject.id" }, { var: "doc.createdBy" }], }, - "ContextualDenyStatement" -); +}; -export const InvalidConstraintStatement = parsePolicyStatement( - { - action: "create", - effect: "allow", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constraint: { foo: "bar" } as any, - }, - "InvalidConstraintStatement" -); +export const InvalidConstraintStatement: PolicyStatement = { + sid: "InvalidConstraintStatement", + action: Actions.create, + effect: "allow", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constraint: { foo: "bar" } as any, +}; diff --git a/lib/parsed-policy-statement.ts b/lib/parsed-policy-statement.ts index 1661cc8..0c3d8a9 100644 --- a/lib/parsed-policy-statement.ts +++ b/lib/parsed-policy-statement.ts @@ -1,6 +1,7 @@ import { RulesLogic } from "json-logic-js"; import { randomUUID } from "node:crypto"; import { PolicyStatement } from "./types"; +import { arrayify } from "./utils/arr"; import { traverseRulesLogic } from "./utils/logic"; import { isStringLiteral, @@ -14,13 +15,18 @@ export type ActionsByType = { globAll: boolean; }; -export const SYM_SID = Symbol("PolicyStatementID"); +export type ParsedPolicyStatement = { + /** a globally-unique statement identifier */ + sid: string; -export type ParsedPolicyStatement = PolicyStatement & { + /** an optional grouping identifier */ + gid?: string; + + effect: PolicyStatement["effect"]; + constraint: PolicyStatement["constraint"]; /** paths referenced in the logic with { var: 'my.path' } */ contextPaths: string[]; actionsByType: ActionsByType; - [SYM_SID]: string; }; const LIST_OPS = ["map", "reduce", "filter", "all", "none", "some"]; @@ -40,9 +46,7 @@ function extractVarPaths(logic: RulesLogic): string[] { } // syntactic sugar means var could be one of string, [string], or [string, string] - const args = Array.isArray(innerLogic.var) - ? innerLogic.var - : [innerLogic.var]; + const args = arrayify(innerLogic.var); if (typeof args[0] !== "string") { throw new Error( `var: only path strings are permitted (at ${path.join(".")})` @@ -80,17 +84,25 @@ export function parsePolicyActions(actions: string[]): ActionsByType { return res; } +interface ParseOptions { + sid?: string; +} + export function parsePolicyStatement( statement: PolicyStatement, - sid?: string + opts: ParseOptions = {} ): ParsedPolicyStatement { - const { action, constraint } = statement; - const actions = Array.isArray(action) ? action : [action]; + const { action, constraint, effect } = statement; + const actions = arrayify(action); + + const sid = opts.sid ?? statement.sid ?? randomUUID(); return { - ...statement, + sid, + + effect, + constraint, contextPaths: extractVarPaths(constraint), actionsByType: parsePolicyActions(actions), - [SYM_SID]: sid ?? randomUUID(), }; } diff --git a/lib/policy-resolver.test.ts b/lib/policy-resolver.test.ts index b129beb..b72456e 100644 --- a/lib/policy-resolver.test.ts +++ b/lib/policy-resolver.test.ts @@ -1,10 +1,19 @@ +import { parsePolicyStatement } from "./parsed-policy-statement"; import { PolicyResolver } from "./policy-resolver"; +import { IndexedStatementsStore } from "./store"; import { + Actions, allowedContext, + deniedContext, +} from "./__fixtures__/contexts"; +import { BasicAllowStatement, ContextualAllowStatement, ContextualDenyStatement, - deniedContext, + ContextualGlobStatement, + GlobAllStatement, + GlobEndStatement, + GlobStartStatement, MultipleActionsStatement, } from "./__fixtures__/statements"; @@ -13,17 +22,17 @@ describe("PolicyParser", () => { const policies = PolicyResolver.fromStatements([BasicAllowStatement]); it("returns true for the create action", () => { - expect(policies.can("create")).toEqual(true); + expect(policies.can(Actions.create)).toEqual(true); }); it("returns false for another defined action", () => { - expect(policies.can("read")).toEqual(false); + expect(policies.can(Actions.read)).toEqual(false); }); }); describe("with a multiple action policy", () => { const policies = PolicyResolver.fromStatements([MultipleActionsStatement]); - const allowed = ["create", "read"]; + const allowed = [Actions.create, Actions.read]; it("returns true for both actions", () => { allowed.forEach((action) => { @@ -36,6 +45,42 @@ describe("PolicyParser", () => { }); }); + describe("glob matching", () => { + const policies = PolicyResolver.fromStatements([ + ContextualGlobStatement, + GlobStartStatement, + GlobEndStatement, + ]); + + it("matches actions matching the glob end", () => { + expect(policies.can(Actions.createDocument)).toEqual(true); + expect(policies.can(Actions.readDocument)).toEqual(true); + expect(policies.can(`${Actions.readDocument}aaaaaaaa`)).toEqual(true); + }); + + it("matches actions matching the glob start", () => { + expect(policies.can(Actions.signDocuments)).toEqual(true); + expect(policies.can(`aaaaaa${Actions.signDocuments}`)).toEqual(true); + }); + + it("returns true for the contextual glob", () => { + expect(policies.can(Actions.read)).toEqual(false); + expect(policies.can(Actions.read, allowedContext)).toEqual(true); + }); + + describe("with an allowed actions list provided", () => { + const allowedActions = [Actions.readDocument, Actions.createDocument]; + const policies = PolicyResolver.fromStatements([GlobAllStatement], { + allowedActions, + }); + + it("returns false for unknown actions", () => { + expect(policies.can(Actions.readDocument)).toEqual(true); + expect(policies.can(`${Actions.readDocument}aaaaaaaa`)).toEqual(false); + }); + }); + }); + describe("with a basic contextual resource policy", () => { const policies = PolicyResolver.fromStatements([ BasicAllowStatement, @@ -43,11 +88,11 @@ describe("PolicyParser", () => { ]); it("rejects a subject that matches the deny criteria", () => { - expect(policies.can("create", deniedContext)).toEqual(false); + expect(policies.can(Actions.create, deniedContext)).toEqual(false); }); it("allows a subject that does not match the deny criteria", () => { - expect(policies.can("create", allowedContext)).toEqual(true); + expect(policies.can(Actions.create, allowedContext)).toEqual(true); }); }); @@ -58,15 +103,51 @@ describe("PolicyParser", () => { ]); it("rejects a subject that matches the deny criteria", () => { - expect(policies.can("create", deniedContext)).toEqual(false); + expect(policies.can(Actions.create, deniedContext)).toEqual(false); }); it("allows a subject that does not match the deny criteria but matches the allow criteria", () => { - expect(policies.can("create", allowedContext)).toEqual(true); + expect(policies.can(Actions.create, allowedContext)).toEqual(true); }); it("returns false if the call is made without required context", () => { - expect(policies.can("create")).toEqual(false); + expect(policies.can(Actions.create)).toEqual(false); + }); + }); + + describe("caching", () => { + let store: IndexedStatementsStore; + let resolver: PolicyResolver; + + beforeEach(() => { + const parsed = [ContextualAllowStatement, ContextualDenyStatement].map( + (s) => parsePolicyStatement(s) + ); + store = new IndexedStatementsStore(); + store.addAll(parsed); + + resolver = new PolicyResolver(store); + }); + + it("caches the evaluator for future executions", () => { + const spy = jest.spyOn(store, "findAllByAction"); + + expect(resolver.can(Actions.create, allowedContext)).toEqual(true); + expect(resolver.can(Actions.create, allowedContext)).toEqual(true); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("resets the cache on store updates", () => { + const spy = jest.spyOn(store, "findAllByAction"); + + expect(resolver.can(Actions.create, allowedContext)).toEqual(true); + expect(spy).toHaveBeenCalledTimes(1); + + store.delete("ContextualAllowStatement"); + + expect(resolver.can(Actions.create, allowedContext)).toEqual(false); + expect(spy).toHaveBeenCalledTimes(2); }); }); }); diff --git a/lib/policy-resolver.ts b/lib/policy-resolver.ts index 83a3a85..ad734e2 100644 --- a/lib/policy-resolver.ts +++ b/lib/policy-resolver.ts @@ -1,14 +1,16 @@ import pathExists from "just-has"; import jsonLogic from "json-logic-js"; import LRUCache from "lru-cache"; +import { randomUUID } from "node:crypto"; +import { CachedStatementsStore } from "./store/cached-statements-store"; import { PolicyStatementStore } from "./store/types"; -import { JsonLogicParser, PolicyDocument, PolicyStatement } from "./types"; import { ParsedPolicyStatement, parsePolicyStatement, } from "./parsed-policy-statement"; +import { JsonLogicParser, PolicyDocument, PolicyStatement } from "./types"; +import { arrayify } from "./utils/arr"; import { PolicyDocumentValidator } from "./validator"; -import { CachedStatementsStore } from "./store/cached-statements-store"; interface PolicyResolverOptions { /** restrict valid actions to only this list */ @@ -42,14 +44,20 @@ export class PolicyResolver { opts: PolicyResolverOptions = {} ): PolicyResolver { const validator = new PolicyDocumentValidator(); + const store = new CachedStatementsStore(); - const statements = docs.flatMap((doc) => { + docs.forEach((doc) => { validator.validate(doc); - return doc.statement; + const gid = doc.id ?? randomUUID(); + + const parsed = arrayify(doc.statement).map((statement) => + parsePolicyStatement(statement) + ); + store.setGroup(gid, parsed); }); - return this.fromStatements(statements, opts); + return new PolicyResolver(store, opts); } static fromStatements( diff --git a/lib/store/cached-statements-store.test.ts b/lib/store/cached-statements-store.test.ts new file mode 100644 index 0000000..878710e --- /dev/null +++ b/lib/store/cached-statements-store.test.ts @@ -0,0 +1,65 @@ +import { CachedStatementsStore } from "./cached-statements-store"; +import { IndexedStatementsStore } from "./indexed-statements-store"; +import { + BasicAllowStatement, + ContextualAllowStatement, + ContextualDenyStatement, + GlobAllStatement, + GlobEndStatement, +} from "../__fixtures__/parsed-statements"; +import { Actions } from "../__fixtures__/contexts"; + +describe("CachedStatementsStore", () => { + let source: IndexedStatementsStore; + let store: CachedStatementsStore; + const statements = [ + BasicAllowStatement, + ContextualAllowStatement, + GlobAllStatement, + GlobEndStatement, + ]; + + beforeEach(() => { + source = new IndexedStatementsStore(); + source.addAll(statements); + + store = new CachedStatementsStore(source); + }); + + describe("findAllByAction", () => { + it("caches subsequent requests", () => { + const spy = jest.spyOn(source, "findAllByAction"); + + const res1 = store.findAllByAction(Actions.readDocument); + const res2 = store.findAllByAction(Actions.readDocument); + + expect(spy).toHaveBeenCalledTimes(1); + expect(res1).toEqual(res2); + + const res3 = store.findAllByAction(Actions.read); + + expect(spy).toBeCalledTimes(2); + expect(res3).not.toEqual(res2); + }); + + it("resets the cache after the source has a new statement added", () => { + const spy = jest.spyOn(source, "findAllByAction"); + + store.findAllByAction(Actions.readDocument); + source.set("ContextualDenyStatement", ContextualDenyStatement); + store.findAllByAction(Actions.readDocument); + + expect(spy).toHaveBeenCalledTimes(2); + }); + + it("resets the cache after the source has a statement deleted", () => { + const spy = jest.spyOn(source, "findAllByAction"); + + store.findAllByAction(Actions.readDocument); + source.delete("BasicAllowStatement"); + store.findAllByAction(Actions.readDocument); + + expect(spy).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/lib/store/cached-statements-store.ts b/lib/store/cached-statements-store.ts index e2021ae..5e9233e 100644 --- a/lib/store/cached-statements-store.ts +++ b/lib/store/cached-statements-store.ts @@ -1,6 +1,6 @@ import LRUCache from "lru-cache"; import assert from "node:assert"; -import { ParsedPolicyStatement, SYM_SID } from "../parsed-policy-statement"; +import { ParsedPolicyStatement } from "../parsed-policy-statement"; import { TypedEmitter } from "../utils/events"; import { IndexedStatementsStore } from "./indexed-statements-store"; import { PolicyStatementStore, StoreEvents } from "./types"; @@ -34,14 +34,18 @@ export class CachedStatementsStore }); } - add(statement: ParsedPolicyStatement) { - this.#store.add(statement); + set(sid: string, statement: ParsedPolicyStatement) { + this.#store.set(sid, statement); } addAll(statements: ParsedPolicyStatement[]) { this.#store.addAll(statements); } + setGroup(gid: string, statements: ParsedPolicyStatement[]): void { + this.#store.setGroup(gid, statements); + } + get(sid: string): ParsedPolicyStatement | undefined { return this.#store.get(sid); } @@ -54,13 +58,17 @@ export class CachedStatementsStore if (!this.#cache.get(action)) { this.#cache.set( action, - this.#store.findAllByAction(action).map((p) => p[SYM_SID]) + this.#store.findAllByAction(action).map((p) => p.sid) ); } return this.#cache.get(action)?.map((sid) => this.#mustGet(sid)) ?? []; } + findAllByGID(gid: string): ParsedPolicyStatement[] { + return this.#store.findAllByGID(gid); + } + #mustGet(sid: string): ParsedPolicyStatement { const statement = this.#store.get(sid); diff --git a/lib/store/indexed-statements-store.test.ts b/lib/store/indexed-statements-store.test.ts index 9e1b7ed..eb1cd0d 100644 --- a/lib/store/indexed-statements-store.test.ts +++ b/lib/store/indexed-statements-store.test.ts @@ -1,43 +1,128 @@ +import { Actions } from "../__fixtures__/contexts"; import { BasicAllowStatement, GlobAllStatement, GlobEndStatement, GlobStartStatement, MultipleActionsStatement, -} from "../__fixtures__/statements"; +} from "../__fixtures__/parsed-statements"; import { IndexedStatementsStore } from "./indexed-statements-store"; describe("IndexedStatementsStore", () => { - const store = new IndexedStatementsStore(); - store.addAll([ - BasicAllowStatement, - MultipleActionsStatement, - GlobAllStatement, - GlobEndStatement, - GlobStartStatement, - ]); - - it("returns the expected statements for create", () => { - const res = store.findAllByAction("create"); - expect(res).toEqual([ - GlobAllStatement, + describe("findAllByAction", () => { + const store = new IndexedStatementsStore(); + store.addAll([ BasicAllowStatement, MultipleActionsStatement, + GlobAllStatement, + GlobEndStatement, + GlobStartStatement, ]); - }); - it("returns the expected statements for prefixed actions", () => { - const res = store.findAllByAction("documents:sign"); - expect(res).toEqual([GlobAllStatement, GlobEndStatement]); + it("returns the expected statements for create", () => { + const res = store.findAllByAction(Actions.create); + expect(res).toEqual([ + GlobAllStatement, + BasicAllowStatement, + MultipleActionsStatement, + ]); + }); + + it("returns the expected statements for prefixed actions", () => { + const res = store.findAllByAction(Actions.readDocument); + expect(res).toEqual([GlobAllStatement, GlobEndStatement]); + }); + + it("returns the expected statements for postfixed", () => { + const res = store.findAllByAction(Actions.signDocuments); + expect(res).toEqual([GlobAllStatement, GlobStartStatement]); + }); + + it("returns the expected statements for read", () => { + const res = store.findAllByAction(Actions.read); + expect(res).toEqual([GlobAllStatement, MultipleActionsStatement]); + }); }); - it("returns the expected statements for postfixed", () => { - const res = store.findAllByAction("sign:documents"); - expect(res).toEqual([GlobAllStatement, GlobStartStatement]); + describe("set/delete", () => { + let store: IndexedStatementsStore; + + const sid = GlobEndStatement.sid; + + beforeEach(() => { + store = new IndexedStatementsStore(); + }); + + it("adds the statement to all of the expected store methods", () => { + store.set(sid, GlobEndStatement); + + expect(store.findAllByAction(Actions.createDocument)).toEqual([ + GlobEndStatement, + ]); + expect(store.has(sid)).toEqual(true); + expect(store.get(sid)).toEqual(GlobEndStatement); + }); + + it("removes the statement from all of the expected store methods", () => { + store.set(sid, GlobEndStatement); + store.delete(sid); + + expect(store.findAllByAction(Actions.createDocument)).toEqual([]); + expect(store.has(sid)).toEqual(false); + expect(store.get(sid)).toEqual(undefined); + }); }); - it("returns the expected statements for read", () => { - const res = store.findAllByAction("read"); - expect(res).toEqual([GlobAllStatement, MultipleActionsStatement]); + describe("set/deleteGroup", () => { + let store: IndexedStatementsStore; + + beforeEach(() => { + store = new IndexedStatementsStore(); + }); + + const gid = "test1"; + const toAdd = [GlobAllStatement, MultipleActionsStatement]; + const expected = toAdd.map((s) => ({ + ...s, + gid, + sid: [gid, s.sid].join("/"), + })); + + it("adds the statements to all of the expected store methods", () => { + store.setGroup(gid, toAdd); + store.set(MultipleActionsStatement.sid, MultipleActionsStatement); + expect(store.findAllByAction(Actions.read)).toEqual([ + ...expected, + MultipleActionsStatement, + ]); + expect(store.findAllByGID(gid)).toEqual(expected); + + const sid = expected[0].sid; + expect(store.get(sid)).toEqual(expected[0]); + }); + + it("removes the statements from all of the expected store methods", () => { + store.setGroup(gid, toAdd); + store.set(MultipleActionsStatement.sid, MultipleActionsStatement); + store.deleteGroup(gid); + + expect(store.findAllByAction(Actions.read)).toEqual([ + MultipleActionsStatement, + ]); + expect(store.findAllByGID(gid)).toEqual([]); + + const sid = expected[0].sid; + expect(store.get(sid)).toEqual(undefined); + }); + + it("quietly succeeds if the group is not present", () => { + store.set(MultipleActionsStatement.sid, MultipleActionsStatement); + store.deleteGroup(gid); + + expect(store.findAllByAction(Actions.read)).toEqual([ + MultipleActionsStatement, + ]); + expect(store.findAllByGID(gid)).toEqual([]); + }); }); }); diff --git a/lib/store/indexed-statements-store.ts b/lib/store/indexed-statements-store.ts index 98d3281..ee1151b 100644 --- a/lib/store/indexed-statements-store.ts +++ b/lib/store/indexed-statements-store.ts @@ -1,5 +1,6 @@ +import uniq from "just-unique"; import assert from "node:assert"; -import { ParsedPolicyStatement, SYM_SID } from "../parsed-policy-statement"; +import { ParsedPolicyStatement } from "../parsed-policy-statement"; import { TypedEmitter } from "../utils/events"; import { ParsedStatementsDB, @@ -11,6 +12,7 @@ import { type StatementsDBWithIndex = { statements: ParsedStatementsDB; byAction: ByActionIndex; + byGID: Map; }; const createStatementsDB = (): StatementsDBWithIndex => ({ @@ -20,6 +22,7 @@ const createStatementsDB = (): StatementsDBWithIndex => ({ globAll: [], regex: [], }, + byGID: new Map(), }); /** @@ -31,25 +34,73 @@ export class IndexedStatementsStore { #statements: ParsedStatementsDB; #byAction: ByActionIndex; + #byGID: Map; constructor(params?: StatementsDBWithIndex) { super(); - const { statements, byAction } = params ?? createStatementsDB(); + const { statements, byAction, byGID } = params ?? createStatementsDB(); this.#statements = statements; this.#byAction = byAction; + this.#byGID = byGID; } - add(newStatement: ParsedPolicyStatement) { - this.addAll([newStatement]); + /** + * adds or replaces an individual statement in the store by statement id (sid) + * + * @param newStatement + */ + set(sid: string, newStatement: ParsedPolicyStatement) { + this.addAll([ + { + ...newStatement, + sid, + gid: undefined, + }, + ]); } addAll(statements: ParsedPolicyStatement[]) { - const sids = statements.map((statement) => { - const sid = statement[SYM_SID]; - this.#statements.set(sid, statement); - return sid; - }); + const sids = this.#addAll(statements); + this.#reindexAll(sids); + this.emit("updated", sids); + } + + /** adds or replaces a group of statements in the store by group ID (gid) */ + setGroup(gid: string, statements: ParsedPolicyStatement[]) { + const existingSIDs = this.#byGID.get(gid) ?? []; + this.#deleteAll(existingSIDs); + + const namespacedStatements = statements.map((s) => ({ + ...s, + gid, + sid: [gid, s.sid].join("/"), + })); + + const sids = this.#addAll(namespacedStatements); + const allSIDs = uniq([...existingSIDs, ...sids]); + this.#byGID.set(gid, sids); + this.#reindexAll(allSIDs); + this.emit("updated", allSIDs); + } + + delete(sid: string) { + this.deleteAll([sid]); + } + + deleteAll(sids: string[]) { + this.#deleteAll(sids); + this.#reindexAll(sids); + this.emit("updated", sids); + } + + deleteGroup(gid: string) { + const sids = this.#byGID.get(gid); + if (!sids) { + return; + } + this.#byGID.delete(gid); + this.#deleteAll(sids); this.#reindexAll(sids); this.emit("updated", sids); } @@ -66,6 +117,25 @@ export class IndexedStatementsStore return this.#findSidsByAction(action).map((sid) => this.#mustGet(sid)); } + findAllByGID(gid: string): ParsedPolicyStatement[] { + return this.#byGID.get(gid)?.map((sid) => this.#mustGet(sid)) ?? []; + } + + #addAll(statements: ParsedPolicyStatement[]): string[] { + return statements.map((statement) => { + const sid = statement.sid; + this.#statements.set(sid, statement); + return sid; + }); + } + + /** deleteAll removes all sids without reindexing or sending updates */ + #deleteAll(sids: string[]) { + sids.forEach((sid) => { + this.#statements.delete(sid); + }); + } + #findSidsByAction(action: string): string[] { const statementIds = [...this.#byAction.globAll]; @@ -91,6 +161,9 @@ export class IndexedStatementsStore } #reindexAll(sids: string[]) { + if (sids.length === 0) { + return; + } const statements: ParsedPolicyStatement[] = []; sids.forEach((sid) => { @@ -105,14 +178,14 @@ export class IndexedStatementsStore const globAll = statements .filter((s) => s.actionsByType.globAll) - .map((s) => s[SYM_SID]); + .map((s) => s.sid); this.#byAction.globAll = [ ...this.#byAction.globAll.filter((id) => !sids.includes(id)), ...globAll, ]; const regex = statements.flatMap((s) => - s.actionsByType.regex.map((re): [RegExp, string] => [re, s[SYM_SID]]) + s.actionsByType.regex.map((re): [RegExp, string] => [re, s.sid]) ); this.#byAction.regex = [ ...this.#byAction.regex.filter(([_regex, id]) => !sids.includes(id)), @@ -124,7 +197,7 @@ export class IndexedStatementsStore statements.forEach((statement) => { statement.actionsByType.exact.forEach((action) => { const arr = exact.get(action) ?? []; - arr.push(statement[SYM_SID]); + arr.push(statement.sid); exact.set(action, arr); }); }); @@ -137,10 +210,12 @@ export class IndexedStatementsStore const existing = this.#byAction.exact.get(action) ?? []; const updates = exact.get(action) ?? []; const updated = [ - ...existing.filter((sid) => sids.includes(sid)), + ...existing.filter((sid) => !sids.includes(sid)), ...updates, ]; - this.#byAction.exact.set(action, updated); + updated.length === 0 + ? this.#byAction.exact.delete(action) + : this.#byAction.exact.set(action, updated); } } } diff --git a/lib/store/types.ts b/lib/store/types.ts index ca48264..614348c 100644 --- a/lib/store/types.ts +++ b/lib/store/types.ts @@ -22,10 +22,12 @@ export type StoreEvents = { }; export interface PolicyStatementStore extends Emitter { - add(statement: ParsedPolicyStatement): void; - addAll(statements: ParsedPolicyStatement[]): void; get(sid: string): ParsedPolicyStatement | undefined; has(sid: string): boolean; + set(sid: string, statement: ParsedPolicyStatement): void; + setGroup(gid: string, statements: ParsedPolicyStatement[]): void; + addAll(statements: ParsedPolicyStatement[]): void; findAllByAction(action: string): ParsedPolicyStatement[]; + findAllByGID(gid: string): ParsedPolicyStatement[]; } diff --git a/lib/types.ts b/lib/types.ts index e00b0cd..59da320 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -7,10 +7,18 @@ export interface JsonLogicParser { export type JsonSchema = JSONSchemaType; export interface PolicyDocument { + /** an optional document identifier for replacing/removing a policy */ + id?: string; + /** an optional description */ + description?: string; statement: PolicyStatement | PolicyStatement[]; } export interface PolicyStatement { + /** a statement identifier (document scoped) */ + sid?: string; + /** an optional description */ + description?: string; action: string | string[]; effect: "allow" | "deny"; constraint: RulesLogic; diff --git a/package-lock.json b/package-lock.json index 54735de..1b31a60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "ajv": "^8.11.0", "json-logic-js": "^2.0.2", "just-has": "^2.3.0", + "just-unique": "^4.2.0", "lru-cache": "^7.18.1" }, "devDependencies": { @@ -4108,6 +4109,11 @@ "resolved": "https://registry.npmjs.org/just-has/-/just-has-2.3.0.tgz", "integrity": "sha512-JzxCot/ETqLDullSSC5OtT/PLWiqgRNO5z33gVit6BoCXe/6BHut33o9ZunG5jQSqeY4EyzFnl8Wqc7S8Ci/wQ==" }, + "node_modules/just-unique": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/just-unique/-/just-unique-4.2.0.tgz", + "integrity": "sha512-cxQGGUiit6CGUpuuiezY8N4m1wgF4o7127rXEXDFcxeDUFfdV7gSkwA26Fe2wWBiNQq2SZOgN4gSmMxB/StA8Q==" + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -8399,6 +8405,11 @@ "resolved": "https://registry.npmjs.org/just-has/-/just-has-2.3.0.tgz", "integrity": "sha512-JzxCot/ETqLDullSSC5OtT/PLWiqgRNO5z33gVit6BoCXe/6BHut33o9ZunG5jQSqeY4EyzFnl8Wqc7S8Ci/wQ==" }, + "just-unique": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/just-unique/-/just-unique-4.2.0.tgz", + "integrity": "sha512-cxQGGUiit6CGUpuuiezY8N4m1wgF4o7127rXEXDFcxeDUFfdV7gSkwA26Fe2wWBiNQq2SZOgN4gSmMxB/StA8Q==" + }, "kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", diff --git a/package.json b/package.json index 7f2c5d6..e9ad912 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "prepublishOnly": "npm run build", "pretest": "npm run lint", "test": "jest", + "test:watch": "jest --watch", "posttest": "npm run format" }, "keywords": [], @@ -24,6 +25,7 @@ "ajv": "^8.11.0", "json-logic-js": "^2.0.2", "just-has": "^2.3.0", + "just-unique": "^4.2.0", "lru-cache": "^7.18.1" }, "devDependencies": { diff --git a/schemas/policy-2023-02.schema.json b/schemas/policy-2023-02.schema.json index a348f1a..c577a36 100644 --- a/schemas/policy-2023-02.schema.json +++ b/schemas/policy-2023-02.schema.json @@ -4,6 +4,14 @@ "type": "object", "required": ["statement"], "properties": { + "id": { + "type": "string", + "description": "An optional policy identifier to allow for updating the policy at runtime" + }, + "description": { + "type": "string", + "description": "An optional description of the policy" + }, "statement": { "oneOf": [ { "$ref": "#/$defs/PolicyStatement" }, @@ -20,6 +28,10 @@ "type": "object", "required": ["action", "effect", "constraint"], "properties": { + "sid": { + "description": "An optional statement identifier (SID) for this statement in the policy", + "type": "string" + }, "action": { "oneOf": [ { "$ref": "#/$defs/globAll"},