fix: enforce registered authorization permissions
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 11s
CI / migration-smoke (pull_request) Successful in 27s

Two related fail-closed gaps let a well-formed but never-declared
permission id slip through:

- POST /api/v1/grants created explicit grants for any well-formed
  PermissionId without checking the injected permission registry,
  allowing dormant grants for invented identifiers that could become
  effective the moment those identifiers were registered later.
- createAuthorizationEvaluator validated the permission id only inside
  the restricted-baseline branch, so a malformed permission combined
  with the public or authenticated baseline could still return allowed.

Both now fail closed: the grants route checks the target permission
against the trusted registry before calling store.createGrant (sanitized
403, no store or audit write), and the evaluator validates permission
shape before any baseline is allowed to return an allowed decision,
without adding a registry dependency to the evaluator itself.

The live integration lifecycle test switched from an unregistered
calendar.events.read to the registered core.authorization.grants.manage
permission, since module permission registration does not exist yet.
This commit is contained in:
ekklesia
2026-07-23 11:44:45 -05:00
parent 967c275027
commit df2af0a3e0
7 changed files with 226 additions and 27 deletions
@@ -115,6 +115,28 @@ describe("Core authorization evaluator", () => {
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("denies an anonymous principal when the permission id is malformed, without throwing or querying grants or ownership", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission: "not a valid permission id",
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("denies an authenticated principal when the permission id is malformed", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission: "not a valid permission id",
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
});
describe("authenticated baseline", () => {
@@ -145,6 +167,28 @@ describe("Core authorization evaluator", () => {
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("denies an authenticated principal when the permission id is malformed, without throwing or querying grants or ownership", async () => {
const decision = await evaluator().authorize({
baseline: "authenticated",
permission: "not a valid permission id",
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("still denies an anonymous principal when the permission id is malformed too", async () => {
const decision = await evaluator().authorize({
baseline: "authenticated",
permission: "not a valid permission id",
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
});
describe("restricted baseline", () => {