@wtfalch/authz-store 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,10 +15,24 @@ package rather than a subpath on the engine.
15
15
 
16
16
  ## Status
17
17
 
18
- **Published.** `@wtfalch/authz-store` is on npm at 0.2.1. "Depend" here means a
19
- pin in that host's `package.json`, not a verified deployment: Boule (`manage`)
20
- pins 0.2.1, `people` pins 0.2.1, and otf's `web/` pins 0.2.0. archon still carries its own copy of
21
- this layer, unmoved.
18
+ **Published at 0.4.0; host adoption is incomplete.** Checked on 2026-09-30
19
+ against the npm registry (latest 0.4.0, shasum
20
+ `9305f37cc20d79a469cbaacde80990e295ebc2df`) and the repository manifest.
21
+ The registry exports both the main entry and the client-safe `./policy` entry.
22
+ Extraction waves 1–11 are implemented in this package; publishing issues
23
+ [#123](https://github.com/wtfalch/authz/issues/123) and
24
+ [#128](https://github.com/wtfalch/authz/issues/128) are closed.
25
+
26
+ Current host pins and remaining adoption work are recorded in the
27
+ [shared persistence map](../../docs/shared-persistence-map.md#current-status-2026-09-30).
28
+ A manifest pin establishes a source dependency, not a deployment or full
29
+ adoption. Archon pins 0.4.0 and has merged wave 1, with a local access-loader
30
+ exception pending [#130](https://github.com/wtfalch/authz/issues/130). Boule
31
+ pins 0.2.1, OTF `web/` pins 0.2.0, and the people package pins 0.2.1.
32
+
33
+ The following wave notes describe the extraction steps in order. References
34
+ to modules being host-side at a particular wave describe that step, not the
35
+ current adoption status of every host.
22
36
 
23
37
  Moved so far: credential secrets, the `ScopedDb` seam, every table definition,
24
38
  `ownerCoverage`, wave 1 (authz#83): `StoreBinding` (the `applicationId`,
@@ -235,9 +249,10 @@ cycle; a later issue in the people repo makes it reuse this copy rather than
235
249
  carry its own. Every row helper is exported under a name distinct from its
236
250
  policy-layer counterpart (`insertMembershipRow`, `deleteMembershipRow`,
237
251
  `changeRoleRow`, `guardStaysHeldRow`, `membersOfRow`), since the two layers
238
- would otherwise claim the same five names on one package surface; none of
239
- the five is part of this package's public surface, since nothing outside
240
- `src/memberships.ts` needs them. `identityWhere` is not duplicated: people's
252
+ would otherwise claim the same five names on one package surface. As of
253
+ 0.4.0 (https://github.com/wtfalch/people/issues/16) all five are exported
254
+ from the main entry, so `@wtfalch/people` can call this copy instead of
255
+ carrying its own — see the 0.4.0 note below. `identityWhere` is not duplicated: people's
241
256
  own copy tests the same three columns as `src/assignments.ts`'s, in a
242
257
  different argument order `and()` doesn't care about, so `membership-rows.ts`
243
258
  imports that one. `roster.js`'s `profiles` left join (a member with no
@@ -326,7 +341,7 @@ otf, not moved.
326
341
  Wave 11d (authz#83) moves Boule's own `src/lib/authz/erase.ts`, `alerts.ts`
327
342
  and `startup.ts` into `src/erase.ts`, `src/alerts.ts` and `src/startup.ts`.
328
343
  `erase.ts` moves `pseudonymFor` and `erasePerson`, the one sanctioned write
329
- to `authz_events`; the SQL function it calls (`erase_person`, `SECURITY
344
+ to `authz_events`; the SQL function it calls (`authz_erase_person`, `SECURITY
330
345
  DEFINER`, a pinned `search_path`) ships as `migrations/0006_erase_person.sql`,
331
346
  sweeping `authz_events` and `invitations` the way Boule's
332
347
  `drizzle/0008_erasure_and_boot.sql` did. Boule's own function also scrubbed
@@ -346,8 +361,29 @@ to check simply does not call it, rather than this framework-neutral package
346
361
  reading `process.env` to decide the same thing for itself — and its
347
362
  `housekeeping` becomes a second structural port, `HousekeepingRegistry`.
348
363
 
349
- The rest of the host layer has not moved — tracked in
350
- https://github.com/wtfalch/authz/issues/83.
364
+ 0.4.0 (authz#83 round 2, files#112): `loadAccess`'s credential-chain walk in
365
+ `src/policy-access.ts` now selects only `issuerId`, `issuerClass`, `expiresAt`
366
+ and `revokedAt` off `credentials`, instead of every column. files and ai
367
+ dropped `secret_hash`, `secret_prefix` and `keys_issued_id` on purpose, and a
368
+ plain `select()` there named them anyway, failing every service-credential
369
+ request with "column does not exist". `export.ts`, `person-records.ts` and
370
+ `credentials.ts` already select explicit column lists and were left as they
371
+ are: each genuinely needs the secret/keys column it names (an export row's
372
+ `secretPrefix`, a revoke's `keysIssuedId`, `credentialsFor`'s display
373
+ `secretPrefix`), so narrowing further would break them, not fix them.
374
+
375
+ 0.4.0 also renames the SQL function `migrations/0006_erase_person.sql`
376
+ creates, from `erase_person` to `authz_erase_person`. The old name collided
377
+ with each host's own erasure function of the same name and signature, which
378
+ also scrubs a host's `profiles`; `migrateStore`'s `CREATE OR REPLACE FUNCTION`
379
+ replaced a host's function outright, silently dropping that scrub. 0.3.0
380
+ published an hour before this fix and no persistent database had applied
381
+ 0006 yet, so this is a same-file rename, not a new numbered migration.
382
+ `src/erase.ts` calls `authz_erase_person`.
383
+
384
+ Extraction is complete through wave 11. Remaining host adoption and the
385
+ resource-resolution extension are tracked in
386
+ https://github.com/wtfalch/authz/issues/83 and the current-status map above.
351
387
 
352
388
  New here, not moved: `authz_grants`, grants stored per person rather than
353
389
  compiled from a role, and `guestGrants`, which reads a tenant's guest grants
@@ -361,6 +397,43 @@ the tables from the drizzle definitions. `src/migrate.test.ts` applies the
361
397
  shipped SQL instead and fails when the two disagree on a column, a type,
362
398
  nullability or an index name.
363
399
 
400
+ ## 0.4.0
401
+
402
+ `StoreBinding` (`src/binding.ts`) gains `hostEvents?: readonly string[]`: a host's own audit
403
+ event names (archon: `flag.changed`, `cms.published`, `key.rotated`; files/ai/integrations:
404
+ `mail.*`, `storage.*`, `integrations.*`), beyond `@wtfalch/authz`'s core events, so a host writes
405
+ its own events through the same `record`/`recordAs`/`writeServiceEvent` path core events use,
406
+ never a second one of its own (authz#83, archon#72). `definePolicy(data, binding)` accepts
407
+ `hostEvents` in `binding` and carries it onto the returned `StorePolicy`; a `hostEvents` name that
408
+ repeats a core event name is a configuration error and `definePolicy` throws. `recordAs` and
409
+ `writeServiceEvent` each gain an optional trailing `binding` parameter, since neither had one to
410
+ read `hostEvents` off before.
411
+
412
+ `EventInput.action` (on `record`, `recordAs` and `writeServiceEvent`) is now `EventName |
413
+ (string & {})`: core names still autocomplete, and any other string is accepted only when it is
414
+ listed in the binding's `hostEvents`; anything else throws before the row is built any further,
415
+ naming the action. **The store's own baseline `authz_events_action_check` constraint still lists
416
+ core events only** — this package's migrations are unchanged. A host that adopts `hostEvents` must
417
+ widen that CHECK in its own migration first (hosts that don't adopt keep their existing
418
+ constraint as-is).
419
+
420
+ 0.4.0 (authz#83 round 2) adds a `./policy` subpath export
421
+ (https://github.com/wtfalch/boule/pull/162): `definePolicy` plus `StorePolicy`,
422
+ `StoreBinding`, `PolicyData` and `PolicyRoleTemplate`, and nothing else, for a
423
+ client component that only needs the policy catalogue for permission labels.
424
+ The main entry (`.`) still exports everything it does today — this is
425
+ additive — but it also drags in `migrateStore`, which imports
426
+ `node:fs/promises` and breaks a production client build; `./policy`'s import
427
+ graph is proved free of any `node:` module, `drizzle-orm` or a database handle
428
+ by `scripts/tests/package-contract.test.mjs`. 0.4.0 also exports the five
429
+ membership row helpers from `src/membership-rows.ts` off the main entry —
430
+ `insertMembershipRow`, `deleteMembershipRow`, `changeRoleRow`,
431
+ `guardStaysHeldRow`, `membersOfRow` — so `@wtfalch/people` can call this
432
+ package's copy instead of carrying its own
433
+ (https://github.com/wtfalch/people/issues/16). No behaviour change: each
434
+ helper writes an audit row only when a `MembershipRowAuditOptions` writer is
435
+ passed, exactly as before.
436
+
364
437
  ## Migrations
365
438
 
366
439
  The package ships its own SQL in `migrations/`, and `migrateStore(db)` applies
@@ -377,18 +450,28 @@ app owns:
377
450
  list that app's offered permissions.
378
451
  - The foreign key from `break_glass_sessions.operator_id` to `profiles`.
379
452
 
380
- The baseline is for a new database. manage and otf `web/` already have these
381
- tables, so each needs a one-off reconciliation: bring the schema to the
382
- baseline, then insert `0001_baseline.sql` into `authz_store_migrations` by
383
- hand. otf also lacks `authz_roles.updated_by` and the `'activation'`
384
- assignment source.
385
-
386
- What a host must supply is growing, and it is all explicit: a module that needs
387
- the host's `APPLICATION_ID` and `PLATFORM_ID` takes them as a `PolicyBinding`
388
- argument rather than importing them, because the two applications differ there.
389
-
390
- The remaining ~12,800 lines, and the one-off migration each application needs to
391
- adopt them, are not started.
453
+ The baseline is for a new database. An existing host must verify its schema
454
+ and migration history before adopting it. Its reconciliation brings the
455
+ schema to the baseline before recording `0001_baseline.sql` in
456
+ `authz_store_migrations`; it must preserve runtime-role privileges and the
457
+ host-specific constraints above. The historical map records OTF's missing
458
+ `authz_roles.updated_by` and `'activation'` assignment source; verify these
459
+ against the host being migrated rather than assuming that snapshot is current.
460
+
461
+ Version 0.4.0 creates `authz_erase_person`, keeping host erasure separate.
462
+ A host whose database already recorded an older `0006_erase_person.sql` must
463
+ check which function was installed: changing a dependency pin does not replay
464
+ an applied migration. Reconcile with a new additive host migration and retain
465
+ host-owned profile/reporting scrubs. Boule's upgrade prerequisite is tracked
466
+ in [boule#163](https://github.com/wtfalch/boule/issues/163). No deployed migration
467
+ history was inspected for this status update.
468
+
469
+ Hosts pass their application id, platform id and catalogue through
470
+ `StoreBinding`/`StorePolicy`; the store does not import host constants.
471
+
472
+ The remaining work is adoption of the extracted modules, host resource-resolution
473
+ support and each host's verified reconciliation. Follow the current-status map
474
+ and host issues; the historical line count is not a current migration estimate.
392
475
 
393
476
  ## Why it is not part of `@wtfalch/authz`
394
477
 
@@ -407,3 +490,43 @@ framework-neutral package cannot import it, so that import is dropped here. A
407
490
  host that wants the guard re-exports these functions through its own
408
491
  `server-only` module. `generateCredentialSecret` reaching a client bundle is the
409
492
  thing worth preventing.
493
+
494
+
495
+ ## Host resource lookups
496
+
497
+ `StoreBinding.resolveResource` extends `policyResource`, `loadAccess` and `refreshAccess`
498
+ with authoritative metadata for host-owned resource types (authz#130). The callback receives
499
+ the caller's database transaction and an immutable target containing `applicationId`,
500
+ `platformId`, `organisationId`, `type` and `id`. It returns an `AccessResource` or `undefined`:
501
+
502
+ ```ts
503
+ const policy = definePolicy(catalogueData, {
504
+ applicationId: APPLICATION_ID,
505
+ platformId: PLATFORM_ID,
506
+ resolveResource: async (tx, target) => {
507
+ if (target.type !== 'files.file') return undefined;
508
+ // This host function queries its own table using BOTH organisationId and id.
509
+ const row = await findFile(tx, target.organisationId, target.id);
510
+ if (!row) return undefined;
511
+ return {
512
+ ...target,
513
+ teamId: row.teamId,
514
+ owner: { id: row.ownerId, class: row.ownerClass },
515
+ };
516
+ },
517
+ });
518
+ ```
519
+
520
+ This callback is trusted server configuration. The host must query authoritative rows
521
+ in the supplied transaction, constrain them to the requested organisation and resource,
522
+ and return `undefined` for missing resources or optional modules. The store rejects a
523
+ result with a different application, platform, organisation, type or id. Lookup errors
524
+ propagate. An unresolved resource cannot establish the team containment needed to
525
+ delegate a team-scoped grant to one resource. Core `team` and `audit`
526
+ lookups keep their store checks and never fall through to the callback.
527
+
528
+ `definePolicy` retains the callback on the binding, so `refreshAccess` uses it when
529
+ re-reading credential lineage before a mutation. Keep callbacks and their database
530
+ imports in server modules; the `./policy` export remains safe to import for client
531
+ catalogue labels. This API requires the next store release; published 0.4.0 does not
532
+ include it. Host adoption and release remain separate steps under authz#83.
@@ -108,7 +108,8 @@ function entryBoundary(catalogue, permission, tenantId) {
108
108
  * calling `compileRoleGrants`: eligibility only needs to know a permission is
109
109
  * *named*, not perform a full grant compile, and a standing custom role can
110
110
  * never contain a non-assignable permission in the first place (creating one
111
- * would already have failed `roleAssignmentRefusal`), so nothing here can
111
+ * would already have failed `roleAssignmentRefusal`: a custom role has no
112
+ * guards and is not keyed `owner`), so nothing here can
112
113
  * throw the way minting a fresh role below can.
113
114
  */
114
115
  async function eligibleElevatedPermissions(tx, binding, tenantId, elevated, principal) {
@@ -231,10 +232,10 @@ export async function requestActivation(access, db, scopeColumn, policy, options
231
232
  }
232
233
  if (refusal !== null) {
233
234
  // compileRoleGrants throws for any entry whose permission is
234
- // `assignable: false` on a non-built-in role -- see Boule's own
235
- // comment (activations.ts): every coApproval permission was one of
236
- // these on the package version this file was ported from, so none of
237
- // them could be minted into an activation's role either.
235
+ // `assignable: false` on a role that is neither keyed `owner` nor
236
+ // guarded by an owner-only permission for an `ownerGuarded` entry. An
237
+ // activation's minted role is neither, so a permission like that
238
+ // cannot be minted into one.
238
239
  return refused('invalid_role', 'One or more of these permissions cannot yet be granted through a time-boxed activation.');
239
240
  }
240
241
  await tx.insert(policyRoles).values({
package/dist/audit.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type ActorClass, type Context, type EventName, type Outcome } from '@wtfalch/authz';
2
+ import type { StoreBinding } from './binding.js';
2
3
  import type { DbOrTx } from './scoped.js';
3
4
  import type { Access, Principal } from './types.js';
4
5
  /**
@@ -6,6 +7,10 @@ import type { Access, Principal } from './types.js';
6
7
  * context, the session, the timestamp, the schema version) is filled in by
7
8
  * `record`, `recordAs` or `writeServiceEvent` below, never by the caller, so
8
9
  * a feature module cannot sign a row as someone it is not (D9).
10
+ *
11
+ * `action` accepts a core `EventName` (still autocompletes) or any other
12
+ * string (0.4.0): a host's own event, checked at write time against the
13
+ * `hostEvents` on the binding reachable from the call — see `insertEvent`.
9
14
  */
10
15
  export interface EventInput {
11
16
  readonly teamId?: string;
@@ -13,7 +18,7 @@ export interface EventInput {
13
18
  readonly id: string;
14
19
  readonly class: ActorClass;
15
20
  };
16
- readonly action: EventName;
21
+ readonly action: EventName | (string & {});
17
22
  readonly tenantId: string | null;
18
23
  readonly targetType: string;
19
24
  readonly targetId: string;
@@ -50,20 +55,28 @@ export declare function record(tx: DbOrTx, access: Access, input: EventInput): P
50
55
  * resolves nothing before the tenant row exists, so there is no membership
51
56
  * to build one from. Never carries a break-glass session, because there is
52
57
  * no session without an `Access` to hold it.
58
+ *
59
+ * `binding` (0.4.0) is optional because every call inside this package
60
+ * writes a core event; it exists so a caller recording its own host event
61
+ * through `recordAs` (there is no `Access` here to read one from, unlike
62
+ * `record`) can pass whatever `StoreBinding` it has in scope.
53
63
  */
54
- export declare function recordAs(tx: DbOrTx, actor: Principal, context: Context, input: EventInput): Promise<void>;
64
+ export declare function recordAs(tx: DbOrTx, actor: Principal, context: Context, input: EventInput, binding?: StoreBinding): Promise<void>;
55
65
  /**
56
66
  * The bootstrap, migration, deploy and job rows: written as `service`, never
57
67
  * as a person or a credential, because nothing signed in to cause them.
58
68
  * `context` defaults to `standard`; pass `'operator'` for a row about the
59
69
  * operator tenant specifically (an operator-only system role changing, say).
70
+ *
71
+ * `binding` (0.4.0): this function had no binding parameter before, so one
72
+ * is added here, optional, for a caller recording its own host event.
60
73
  */
61
74
  export declare function writeServiceEvent(tx: DbOrTx, actor: {
62
75
  readonly id: string;
63
76
  readonly display: string;
64
77
  }, input: EventInput & {
65
78
  readonly context?: 'standard' | 'operator';
66
- }): Promise<void>;
79
+ }, binding?: StoreBinding): Promise<void>;
67
80
  /** One row of the tenant's own security log, already narrowed to what the tenant may see. */
68
81
  export interface SecurityLogEntry {
69
82
  readonly id: number;
package/dist/audit.js CHANGED
@@ -2,6 +2,14 @@ import { authzEventSchema, core, tenantVisibleByDefault, } from '@wtfalch/authz'
2
2
  import { and, desc, eq, lt, or } from 'drizzle-orm';
3
3
  import { permitsRead } from './policy-access.js';
4
4
  import { authzEvents } from './schema.js';
5
+ /** The core event list as a plain set, for a fast membership check against a caller's action. */
6
+ const CORE_EVENTS = new Set(core.events);
7
+ /**
8
+ * Any one core event name, used only to stand in for a validated host action
9
+ * when `insertEvent` asks `authzEventSchema` to check every other field
10
+ * (see the comment there) — never written to a row.
11
+ */
12
+ const FIRST_CORE_EVENT = core.events[0];
5
13
  /**
6
14
  * The one place a row actually gets built and inserted. Every field the
7
15
  * schema requires is filled in here, the candidate row is validated with
@@ -13,8 +21,25 @@ import { authzEvents } from './schema.js';
13
21
  * inside a transaction; callers that need this row atomic with another
14
22
  * write (which is every authority write except a service log) pass their
15
23
  * own transaction through.
24
+ *
25
+ * `hostEvents` (0.4.0) is the calling binding's own event names, beyond the
26
+ * core list. `action` is accepted when it is a core event or listed in
27
+ * `hostEvents`; anything else throws here, before `row` is built any
28
+ * further and before anything reaches `tx.insert`, naming the action in the
29
+ * message. `authzEventSchema`'s own `action` field only knows the core
30
+ * list (it lives in `@wtfalch/authz`, which has no notion of a host's
31
+ * vocabulary), so once a host action has passed the membership check above,
32
+ * the schema is asked to validate a copy of `row` with `action` swapped for
33
+ * an arbitrary core event — every other field is still validated exactly as
34
+ * `authzEventSchema` does today, and the swapped value is never the one
35
+ * written; `row.action` (the real value) is what actually reaches the insert
36
+ * below.
16
37
  */
17
- async function insertEvent(tx, actor, context, sessionId, input) {
38
+ async function insertEvent(tx, actor, context, sessionId, input, hostEvents) {
39
+ const isCoreAction = CORE_EVENTS.has(input.action);
40
+ if (!isCoreAction && !hostEvents?.includes(input.action)) {
41
+ throw new Error(`unknown audit action "${input.action}": not a core event and not listed in this binding's hostEvents`);
42
+ }
18
43
  const occurredAt = new Date();
19
44
  const row = {
20
45
  occurred_at: occurredAt.toISOString(),
@@ -33,13 +58,17 @@ async function insertEvent(tx, actor, context, sessionId, input) {
33
58
  request_id: input.request?.id ?? null,
34
59
  ip: input.request?.ip ?? null,
35
60
  user_agent: input.request?.userAgent ?? null,
61
+ // A host event is outside the core `tenantVisible` list by construction
62
+ // (`tenantVisibleByDefault` only knows core `EventName`s), so it defaults
63
+ // to false unless the caller says otherwise; the cast is safe because the
64
+ // function only ever does a `Set.has` lookup against a plain string.
36
65
  tenant_visible: input.tenantVisible ?? tenantVisibleByDefault(input.action),
37
66
  before: input.before ?? null,
38
67
  after: input.after ?? null,
39
68
  erased_at: null,
40
69
  schema_version: core.version,
41
70
  };
42
- authzEventSchema.parse(row);
71
+ authzEventSchema.parse(isCoreAction ? row : { ...row, action: FIRST_CORE_EVENT });
43
72
  await tx.insert(authzEvents).values({
44
73
  occurredAt,
45
74
  tenantId: row.tenant_id,
@@ -93,26 +122,34 @@ export async function record(tx, access, input) {
93
122
  reference: input.reference ?? grant.reference,
94
123
  }
95
124
  : input;
96
- await insertEvent(tx, { class: access.actor.class, id: access.actor.id, display: access.actor.display }, access.context, access.breakGlass?.id ?? null, withSession);
125
+ await insertEvent(tx, { class: access.actor.class, id: access.actor.id, display: access.actor.display }, access.context, access.breakGlass?.id ?? null, withSession, access.binding.hostEvents);
97
126
  }
98
127
  /**
99
128
  * The same insert for a write with no `Access` yet: self-serve `createTenant`
100
129
  * resolves nothing before the tenant row exists, so there is no membership
101
130
  * to build one from. Never carries a break-glass session, because there is
102
131
  * no session without an `Access` to hold it.
132
+ *
133
+ * `binding` (0.4.0) is optional because every call inside this package
134
+ * writes a core event; it exists so a caller recording its own host event
135
+ * through `recordAs` (there is no `Access` here to read one from, unlike
136
+ * `record`) can pass whatever `StoreBinding` it has in scope.
103
137
  */
104
- export async function recordAs(tx, actor, context, input) {
105
- await insertEvent(tx, { class: actor.class, id: actor.id, display: actor.display }, context, null, input);
138
+ export async function recordAs(tx, actor, context, input, binding) {
139
+ await insertEvent(tx, { class: actor.class, id: actor.id, display: actor.display }, context, null, input, binding?.hostEvents);
106
140
  }
107
141
  /**
108
142
  * The bootstrap, migration, deploy and job rows: written as `service`, never
109
143
  * as a person or a credential, because nothing signed in to cause them.
110
144
  * `context` defaults to `standard`; pass `'operator'` for a row about the
111
145
  * operator tenant specifically (an operator-only system role changing, say).
146
+ *
147
+ * `binding` (0.4.0): this function had no binding parameter before, so one
148
+ * is added here, optional, for a caller recording its own host event.
112
149
  */
113
- export async function writeServiceEvent(tx, actor, input) {
150
+ export async function writeServiceEvent(tx, actor, input, binding) {
114
151
  const { context = 'standard', ...rest } = input;
115
- await insertEvent(tx, { class: 'service', id: actor.id, display: actor.display }, context, null, rest);
152
+ await insertEvent(tx, { class: 'service', id: actor.id, display: actor.display }, context, null, rest, binding?.hostEvents);
116
153
  }
117
154
  /**
118
155
  * The tenant's own security log: what happened to this organisation's
package/dist/binding.d.ts CHANGED
@@ -1,5 +1,12 @@
1
- import type { ResourceCatalogue, ResourcePermission } from '@wtfalch/authz';
1
+ import type { AccessResource, ResourceCatalogue, ResourcePermission } from '@wtfalch/authz';
2
2
  import type { PolicyBinding } from './owners.js';
3
+ import type { DbOrTx } from './scoped.js';
4
+ /** Exact tenant resource identity requested by the shared access loader. */
5
+ export type ResourceTarget = Pick<AccessResource, 'applicationId' | 'platformId' | 'type' | 'id'> & {
6
+ readonly organisationId: string;
7
+ };
8
+ /** A trusted host lookup, using the caller's transaction and tenant predicate. */
9
+ export type HostResourceResolver = (tx: DbOrTx, target: ResourceTarget) => Promise<AccessResource | undefined>;
3
10
  /**
4
11
  * `PolicyBinding` (`applicationId`, `platformId`) plus the host's own resource catalogue. The
5
12
  * store has no catalogue of its own — each host defines its own permissions — so every moved
@@ -8,6 +15,23 @@ import type { PolicyBinding } from './owners.js';
8
15
  */
9
16
  export interface StoreBinding extends PolicyBinding {
10
17
  readonly catalogue: ResourceCatalogue;
18
+ /**
19
+ * Resolve host-owned resource types for delegation and credential lineage. Return undefined
20
+ * for an absent module or resource. Query authoritative rows in `tx`, constrained to the
21
+ * requested organisation and id. Team and audit resources remain store-owned. A result
22
+ * whose application, platform, organisation, type or id differs from the target is rejected.
23
+ * Lookup errors propagate; they never confer access.
24
+ */
25
+ readonly resolveResource?: HostResourceResolver;
26
+ /**
27
+ * The host's own audit event names, beyond `@wtfalch/authz`'s core events (0.4.0). A host
28
+ * records its own events (archon: `flag.changed`, `cms.published`, `key.rotated`; files/ai/
29
+ * integrations: `mail.*`, `storage.*`, `integrations.*`) through the same `record`/`recordAs`/
30
+ * `writeServiceEvent` write path core events use, rather than a second one of its own. The
31
+ * store's own baseline `authz_events_action_check` constraint lists core events only; a host
32
+ * that lists names here must widen that check in its own migration.
33
+ */
34
+ readonly hostEvents?: readonly string[];
11
35
  }
12
36
  /**
13
37
  * The one lookup every moved module did as `catalogue[permission]`. A tiny helper rather than
package/dist/boot.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { TenantKind } from '@wtfalch/authz';
1
+ import { type TenantKind } from '@wtfalch/authz';
2
2
  import type { StoreBinding } from './binding.js';
3
3
  import type { StorePolicy } from './policy.js';
4
4
  import type { DbOrTx } from './scoped.js';
@@ -35,9 +35,11 @@ export declare function ensureBuiltInRoles(tx: DbOrTx, policy: StorePolicy, tena
35
35
  * exist from before this change shipped) without duplicating or throwing.
36
36
  *
37
37
  * Stored `built_in: true`, not the `false` `starterRoles()` itself declares,
38
- * for the reasons the host's own boot.ts documents (`@wtfalch/authz`'s
39
- * `compileRoleGrants` refuses a non-`assignable` entry unless the role is
40
- * `built_in`, and today's owner/admin templates carry several).
38
+ * for the reasons the host's own boot.ts documents. `built_in` does not let a
39
+ * role hold a locked power: `@wtfalch/authz`'s `compileRoleGrants` lets a
40
+ * non-`assignable` entry onto the role keyed `owner`, or, for an
41
+ * `ownerGuarded` permission, onto a role guarded by an owner-only permission,
42
+ * whatever the row's `built_in` flag says.
41
43
  */
42
44
  export declare function seedStarterRoles(tx: DbOrTx, policy: StorePolicy, tenant: {
43
45
  id: string;
package/dist/boot.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { compileRoleGrants, resourceRoleSchema, } from '@wtfalch/authz';
2
3
  import { and, eq } from 'drizzle-orm';
4
+ import { primaryAssignment } from './assignments.js';
3
5
  import { writeServiceEvent } from './audit.js';
4
6
  import { policyRole, requirePolicySchema } from './policy-access.js';
5
7
  import { policyRoles } from './policy-schema.js';
@@ -27,14 +29,49 @@ export const content = (role) => canonical({
27
29
  label: role.label,
28
30
  description: role.description,
29
31
  });
32
+ /**
33
+ * A stored role that does not compile makes `loadPolicyState` throw for every access check in the
34
+ * tenant, and an unchanged revision is never rewritten, so refuse such a template before anything
35
+ * is written. The schema is checked first, then each entry alone so the error can name the
36
+ * permission.
37
+ */
38
+ function assertTemplateCompiles(policy, template) {
39
+ const parsed = resourceRoleSchema.safeParse({ id: 'template', ...template });
40
+ if (!parsed.success)
41
+ throw new Error(`Role template ${template.key} is invalid: ${parsed.error.issues
42
+ .map((i) => `${i.path.join('.') || '(role)'}: ${i.message}`)
43
+ .join('; ')}`);
44
+ const role = parsed.data;
45
+ const assignment = primaryAssignment(role, { id: 'template', class: 'human' });
46
+ for (const entry of role.entries) {
47
+ try {
48
+ compileRoleGrants({ ...role, entries: [entry] }, assignment, policy.catalogue);
49
+ }
50
+ catch {
51
+ const p = Object.hasOwn(policy.catalogue, entry.permission)
52
+ ? policy.catalogue[entry.permission]
53
+ : undefined;
54
+ const shapeOk = p?.scopes.includes(entry.scope.kind) &&
55
+ p.boundaries.includes(entry.boundary.kind) &&
56
+ p.relations.includes(entry.relation);
57
+ throw new Error(p && shapeOk && !p.assignable
58
+ ? `Role template ${template.key} cannot hold ${entry.permission}: it is a locked (non-assignable) permission this role may not hold. Mark it ownerGuarded in the catalogue and guard the role with an owner-only permission (non-assignable, not ownerGuarded).`
59
+ : `Role template ${template.key} has an entry for ${entry.permission} that does not fit the catalogue: unknown permission, or a scope, boundary or relation it does not allow.`);
60
+ }
61
+ }
62
+ }
30
63
  /** Called in the tenant creation/deploy transaction while its tenant row is locked. */
31
64
  export async function ensureBuiltInRoles(tx, policy, tenant) {
65
+ // Validate every template before the first write, so a bad one never leaves a partial write.
66
+ const templates = policy.builtInRoles(tenant.id, tenant.kind);
67
+ for (const template of templates)
68
+ assertTemplateCompiles(policy, template);
32
69
  const rows = await tx
33
70
  .select()
34
71
  .from(policyRoles)
35
72
  .where(and(eq(policyRoles.tenantId, tenant.id), eq(policyRoles.applicationId, policy.applicationId), eq(policyRoles.platformId, policy.platformId)));
36
73
  const result = [];
37
- for (const template of policy.builtInRoles(tenant.id, tenant.kind)) {
74
+ for (const template of templates) {
38
75
  const existing = rows.find((r) => r.key === template.key);
39
76
  let status = 'unchanged';
40
77
  if (existing) {
@@ -99,12 +136,17 @@ export async function ensureBuiltInRoles(tx, policy, tenant) {
99
136
  * exist from before this change shipped) without duplicating or throwing.
100
137
  *
101
138
  * Stored `built_in: true`, not the `false` `starterRoles()` itself declares,
102
- * for the reasons the host's own boot.ts documents (`@wtfalch/authz`'s
103
- * `compileRoleGrants` refuses a non-`assignable` entry unless the role is
104
- * `built_in`, and today's owner/admin templates carry several).
139
+ * for the reasons the host's own boot.ts documents. `built_in` does not let a
140
+ * role hold a locked power: `@wtfalch/authz`'s `compileRoleGrants` lets a
141
+ * non-`assignable` entry onto the role keyed `owner`, or, for an
142
+ * `ownerGuarded` permission, onto a role guarded by an owner-only permission,
143
+ * whatever the row's `built_in` flag says.
105
144
  */
106
145
  export async function seedStarterRoles(tx, policy, tenant) {
107
- const rows = policy.starterRoles(tenant.id, tenant.kind).map((template) => ({
146
+ const templates = policy.starterRoles(tenant.id, tenant.kind);
147
+ for (const template of templates)
148
+ assertTemplateCompiles(policy, template);
149
+ const rows = templates.map((template) => ({
108
150
  id: randomUUID(),
109
151
  tenantId: tenant.id,
110
152
  applicationId: policy.applicationId,
package/dist/erase.d.ts CHANGED
@@ -39,7 +39,7 @@ export interface ErasureHost {
39
39
  *
40
40
  * Gated on the operator tenant's own `people:erase`: operator scope,
41
41
  * sensitive and unassignable, the same shape `owners:install` has, so only
42
- * the operator tenant's owner holds it. A caller reaches this through its
42
+ * the role keyed `owner` in the operator tenant can hold it. A caller reaches this through its
43
43
  * own permission check first, which is what a host wires up to write the
44
44
  * denied `person.erased` row on a refusal; this function's own checks are
45
45
  * the second net, the same as `installOwner`'s.
@@ -48,7 +48,7 @@ export interface ErasureHost {
48
48
  * record of what happened, and rewriting it is exactly that.
49
49
  *
50
50
  * One transaction, in this order and never the other way round: the sweep
51
- * through `erase_person` and `host.eraseHostRecords` first, then the
51
+ * through `authz_erase_person` and `host.eraseHostRecords` first, then the
52
52
  * `person.erased` audit row. Writing the event first would make it a
53
53
  * candidate for its own sweep the moment the erased subject is the
54
54
  * operator's own id (self-erasure), since the sweep matches on `actor_id`,
package/dist/erase.js CHANGED
@@ -6,7 +6,7 @@ import { done, refused } from './types.js';
6
6
  /**
7
7
  * The single sanctioned write to `authz_events` (D9, X6 M3, Tests 29).
8
8
  *
9
- * `migrations/0006_erase_person.sql`'s `erase_person` is a `SECURITY
9
+ * `migrations/0006_erase_person.sql`'s `authz_erase_person` is a `SECURITY
10
10
  * DEFINER` function owned by the migration role: the runtime role has no
11
11
  * UPDATE on `authz_events` at all (0001's grants), and a trigger refuses any
12
12
  * UPDATE on that table touching a column outside `actor_display`, `before`,
@@ -53,7 +53,7 @@ function rowsOf(result) {
53
53
  *
54
54
  * Gated on the operator tenant's own `people:erase`: operator scope,
55
55
  * sensitive and unassignable, the same shape `owners:install` has, so only
56
- * the operator tenant's owner holds it. A caller reaches this through its
56
+ * the role keyed `owner` in the operator tenant can hold it. A caller reaches this through its
57
57
  * own permission check first, which is what a host wires up to write the
58
58
  * denied `person.erased` row on a refusal; this function's own checks are
59
59
  * the second net, the same as `installOwner`'s.
@@ -62,7 +62,7 @@ function rowsOf(result) {
62
62
  * record of what happened, and rewriting it is exactly that.
63
63
  *
64
64
  * One transaction, in this order and never the other way round: the sweep
65
- * through `erase_person` and `host.eraseHostRecords` first, then the
65
+ * through `authz_erase_person` and `host.eraseHostRecords` first, then the
66
66
  * `person.erased` audit row. Writing the event first would make it a
67
67
  * candidate for its own sweep the moment the erased subject is the
68
68
  * operator's own id (self-erasure), since the sweep matches on `actor_id`,
@@ -90,7 +90,7 @@ export async function erasePerson(operatorAccess, db, scopeColumn, principalId,
90
90
  // this needs the address rather than only the actor id.
91
91
  const rawEmail = await host.emailOf(tx, principalId);
92
92
  const subjectEmail = rawEmail?.trim().toLowerCase() ?? null;
93
- const result = await tx.execute(sql `select erase_person(${principalId}, ${pseudonym}, ${subjectEmail}) as touched`);
93
+ const result = await tx.execute(sql `select authz_erase_person(${principalId}, ${pseudonym}, ${subjectEmail}) as touched`);
94
94
  const [row] = rowsOf(result);
95
95
  const rowsErased = Number(row?.touched ?? 0);
96
96
  // The host's own personal-data tables: `profiles` and whatever an
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { attachProposals, authzEvents, breakGlassSessions, credentials, invitati
6
6
  export { ownerCoverage, ownerSeatCovered, principalIsReachable, type OwnerCoverage, type OwnerCoverageOptions, type PolicyBinding, } from './owners.js';
7
7
  export { migrateStore } from './migrate.js';
8
8
  export { guestGrants, policyGrants, type GrantStatus, type GuestGrant } from './grants.js';
9
- export { permissionOf, type StoreBinding } from './binding.js';
9
+ export { permissionOf, type StoreBinding, type HostResourceResolver, type ResourceTarget, } from './binding.js';
10
10
  export { isCustomRoleKey } from './role-keys.js';
11
11
  export { type Access, type BreakGlassGrant, type Context, type Principal, type Result, type TenantRow, done, isUuid, refused, tenantTag, } from './types.js';
12
12
  export { policyResource } from './policy-resources.js';
@@ -38,3 +38,4 @@ export { archiveTeam, assignAppRole, changeTeamParticipant, deleteAppRole, remov
38
38
  export { exportTenant, type ExportActor, type ExportedAttachProposal, type ExportedAuditRow, type ExportedCredential, type ExportedInvitation, type ExportedMembership, type ExportedRole, type ExportedTenant, } from './export.js';
39
39
  export { eventsFor, type EventPage, type EventsForOptions, type EventSummary, } from './events.js';
40
40
  export { platformFrozen, setPlatformFrozen } from './platform.js';
41
+ export { changeRoleRow, deleteMembershipRow, guardStaysHeldRow, insertMembershipRow, membersOfRow, type ChangeRoleRowInput, type ChangeRoleRowResult, type DeleteMembershipRowInput, type DeleteMembershipRowResult, type InsertMembershipRowInput, type InsertMembershipRowResult, type MembershipRosterRow, type MembershipRowAuditActor, type MembershipRowAuditEvent, type MembershipRowAuditOptions, } from './membership-rows.js';
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ export { attachProposals, authzEvents, breakGlassSessions, credentials, invitati
6
6
  export { ownerCoverage, ownerSeatCovered, principalIsReachable, } from './owners.js';
7
7
  export { migrateStore } from './migrate.js';
8
8
  export { guestGrants, policyGrants } from './grants.js';
9
- export { permissionOf } from './binding.js';
9
+ export { permissionOf, } from './binding.js';
10
10
  export { isCustomRoleKey } from './role-keys.js';
11
11
  export { done, isUuid, refused, tenantTag, } from './types.js';
12
12
  export { policyResource } from './policy-resources.js';
@@ -38,3 +38,4 @@ export { archiveTeam, assignAppRole, changeTeamParticipant, deleteAppRole, remov
38
38
  export { exportTenant, } from './export.js';
39
39
  export { eventsFor, } from './events.js';
40
40
  export { platformFrozen, setPlatformFrozen } from './platform.js';
41
+ export { changeRoleRow, deleteMembershipRow, guardStaysHeldRow, insertMembershipRow, membersOfRow, } from './membership-rows.js';
@@ -176,6 +176,10 @@ export async function changeRole(access, db, scopeColumn, options) {
176
176
  const reason = roleAssignmentRefusal(fresh, role, primaryAssignment(role, principal, previous?.expiresAt?.getTime()), self ? null : 'members:grant');
177
177
  if (reason)
178
178
  return refused(self ? 'self_promotion' : reason, 'You cannot delegate that role.');
179
+ // A self-change skips the `members:grant` operation (and with it the guard check) above, but a
180
+ // guarded role must still only reach someone who holds every guard.
181
+ if (self && role.guards.some((guard) => !permits(fresh, guard)))
182
+ return refused('self_promotion', 'You cannot delegate that role.');
179
183
  if (before &&
180
184
  before.id !== role.id &&
181
185
  !(await guardStaysHeld(tx, fresh.binding, fresh.tenant.id, [policyRole(before)], principal)))
@@ -277,8 +277,17 @@ export async function loadAccess(tx, binding, db, scopeColumn, principal, tenant
277
277
  if (visited.has(`${cursor.class}:${cursor.id}`) || visited.size >= 32)
278
278
  return null;
279
279
  visited.add(`${cursor.class}:${cursor.id}`);
280
+ // Narrow to the columns this walk reads: a service-credential chain
281
+ // never needs `secretHash`/`secretPrefix`/`keysIssuedId`, and a host
282
+ // that dropped those columns (files, ai) would otherwise fail every
283
+ // request through this path with "column does not exist".
280
284
  const [credential] = await tx
281
- .select()
285
+ .select({
286
+ issuerId: credentials.issuerId,
287
+ issuerClass: credentials.issuerClass,
288
+ expiresAt: credentials.expiresAt,
289
+ revokedAt: credentials.revokedAt,
290
+ })
282
291
  .from(credentials)
283
292
  .where(and(eq(credentials.id, cursor.id), eq(credentials.kind, cursor.class)))
284
293
  .limit(1);
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@wtfalch/authz-store/policy` — a client-safe subpath for `definePolicy` and the types its
3
+ * signature needs. The main entry (`.`) re-exports `migrateStore`, which imports
4
+ * `node:fs/promises`, so a client component that only reads the policy catalogue for permission
5
+ * labels cannot import from `.` without pulling Node into a production browser build
6
+ * (https://github.com/wtfalch/boule/pull/162). Nothing this module imports, directly or
7
+ * transitively, may import a `node:` module, `drizzle-orm`, or a database handle — proved by
8
+ * `scripts/tests/package-contract.test.mjs`.
9
+ */
10
+ export { definePolicy, type PolicyData, type PolicyRoleTemplate, type StorePolicy, } from './policy.js';
11
+ export type { StoreBinding, HostResourceResolver, ResourceTarget } from './binding.js';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@wtfalch/authz-store/policy` — a client-safe subpath for `definePolicy` and the types its
3
+ * signature needs. The main entry (`.`) re-exports `migrateStore`, which imports
4
+ * `node:fs/promises`, so a client component that only reads the policy catalogue for permission
5
+ * labels cannot import from `.` without pulling Node into a production browser build
6
+ * (https://github.com/wtfalch/boule/pull/162). Nothing this module imports, directly or
7
+ * transitively, may import a `node:` module, `drizzle-orm`, or a database handle — proved by
8
+ * `scripts/tests/package-contract.test.mjs`.
9
+ */
10
+ export { definePolicy, } from './policy.js';
@@ -4,14 +4,16 @@ import { authzEvents } from './schema.js';
4
4
  import { isUuid } from './types.js';
5
5
  /** Authoritative resource metadata, shared by delegation and credential lineage evaluation. */
6
6
  export async function policyResource(tx, binding, organisationId, type, id) {
7
- const base = {
7
+ const base = Object.freeze({
8
8
  applicationId: binding.applicationId,
9
9
  platformId: binding.platformId,
10
10
  organisationId,
11
11
  type,
12
12
  id,
13
- };
14
- if (type === 'team' && isUuid(id)) {
13
+ });
14
+ if (type === 'team') {
15
+ if (!isUuid(id))
16
+ return undefined;
15
17
  const [row] = await tx
16
18
  .select({ id: accessTeams.id })
17
19
  .from(accessTeams)
@@ -19,7 +21,9 @@ export async function policyResource(tx, binding, organisationId, type, id) {
19
21
  .limit(1);
20
22
  return row ? { ...base, teamId: id } : undefined;
21
23
  }
22
- if (type === 'audit' && /^\d{1,15}$/.test(id)) {
24
+ if (type === 'audit') {
25
+ if (!/^\d{1,15}$/.test(id))
26
+ return undefined;
23
27
  const [row] = await tx
24
28
  .select({
25
29
  teamId: authzEvents.teamId,
@@ -42,5 +46,13 @@ export async function policyResource(tx, binding, organisationId, type, id) {
42
46
  : {}),
43
47
  };
44
48
  }
45
- return undefined;
49
+ const resource = await binding.resolveResource?.(tx, base);
50
+ if (!resource ||
51
+ resource.applicationId !== base.applicationId ||
52
+ resource.platformId !== base.platformId ||
53
+ resource.organisationId !== base.organisationId ||
54
+ resource.type !== base.type ||
55
+ resource.id !== base.id)
56
+ return undefined;
57
+ return resource;
46
58
  }
package/dist/policy.d.ts CHANGED
@@ -57,8 +57,16 @@ export interface StorePolicy extends StoreBinding {
57
57
  * Builds a `StorePolicy` from a host's own `policy-catalogue.json` data and its
58
58
  * `{ applicationId, platformId }`. Generic over the host's data because every host's catalogue
59
59
  * differs; this function is the one piece of that shape both applications wrote identically.
60
+ *
61
+ * `binding.hostEvents` (0.4.0), when given, is carried onto the returned `StorePolicy` unchanged
62
+ * so `record`/`recordAs`/`writeServiceEvent` can validate a host's own audit actions against it.
63
+ * A name in `hostEvents` that repeats a core event name is a configuration error — it would make
64
+ * an audit row ambiguous about which vocabulary it came from — so this throws rather than let it
65
+ * pass silently.
60
66
  */
61
67
  export declare function definePolicy(data: PolicyData, binding: {
62
68
  readonly applicationId: string;
63
69
  readonly platformId: string;
70
+ readonly hostEvents?: readonly string[];
71
+ readonly resolveResource?: StoreBinding['resolveResource'];
64
72
  }): StorePolicy;
package/dist/policy.js CHANGED
@@ -1,10 +1,22 @@
1
- import { defineResourceCatalogue, } from '@wtfalch/authz';
1
+ import { core, defineResourceCatalogue, } from '@wtfalch/authz';
2
2
  /**
3
3
  * Builds a `StorePolicy` from a host's own `policy-catalogue.json` data and its
4
4
  * `{ applicationId, platformId }`. Generic over the host's data because every host's catalogue
5
5
  * differs; this function is the one piece of that shape both applications wrote identically.
6
+ *
7
+ * `binding.hostEvents` (0.4.0), when given, is carried onto the returned `StorePolicy` unchanged
8
+ * so `record`/`recordAs`/`writeServiceEvent` can validate a host's own audit actions against it.
9
+ * A name in `hostEvents` that repeats a core event name is a configuration error — it would make
10
+ * an audit row ambiguous about which vocabulary it came from — so this throws rather than let it
11
+ * pass silently.
6
12
  */
7
13
  export function definePolicy(data, binding) {
14
+ const coreEvents = new Set(core.events);
15
+ for (const name of binding.hostEvents ?? []) {
16
+ if (coreEvents.has(name)) {
17
+ throw new Error(`definePolicy: hostEvents "${name}" collides with a core event name; a host event must not reuse one`);
18
+ }
19
+ }
8
20
  const namespaces = [
9
21
  ...new Set(Object.keys(data.permissions).map((key) => key.split(/[.:]/)[0])),
10
22
  ];
@@ -46,6 +58,8 @@ export function definePolicy(data, binding) {
46
58
  applicationId: binding.applicationId,
47
59
  platformId: binding.platformId,
48
60
  catalogue,
61
+ hostEvents: binding.hostEvents,
62
+ resolveResource: binding.resolveResource,
49
63
  defaultCeiling: data.defaultCeiling,
50
64
  maxDepth: data.maxDepth,
51
65
  elevated: new Set(data.elevated),
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Custom role keys always start with `c_` (enforced at creation, the host's `createRole`); every
3
3
  * other key — including a starter role's, whether or not its row is still `built_in` — names an
4
- * app-defined role. Several modules need "is this key one this app controls" rather than "is this
4
+ * app-defined role. `built_in` says nothing about which permissions a role may hold: that is
5
+ * the role key `owner` or an owner-only guard (`compileRoleGrants`). Several modules need "is this key one this app controls" rather than "is this
5
6
  * row currently marked built-in", because a starter role's `built_in` flag can differ between an
6
7
  * old tenant (`true`) and a new one (`false`) while the key means the same thing in both (ADR
7
8
  * 0016). No imports, so any module can depend on it without risking a cycle.
package/dist/role-keys.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Custom role keys always start with `c_` (enforced at creation, the host's `createRole`); every
3
3
  * other key — including a starter role's, whether or not its row is still `built_in` — names an
4
- * app-defined role. Several modules need "is this key one this app controls" rather than "is this
4
+ * app-defined role. `built_in` says nothing about which permissions a role may hold: that is
5
+ * the role key `owner` or an owner-only guard (`compileRoleGrants`). Several modules need "is this key one this app controls" rather than "is this
5
6
  * row currently marked built-in", because a starter role's `built_in` flag can differ between an
6
7
  * old tenant (`true`) and a new one (`false`) while the key means the same thing in both (ADR
7
8
  * 0016). No imports, so any module can depend on it without risking a cycle.
package/dist/roles.d.ts CHANGED
@@ -11,13 +11,19 @@ export interface EditableRole extends ResourceRole {
11
11
  readonly editable: boolean;
12
12
  }
13
13
  export declare function rolesForEditor(access: Access): Promise<EditableRole[]>;
14
- /** Definition edits must cover both its full scope and every existing recipient/scope. */
14
+ /**
15
+ * Definition edits must cover both its full scope and every existing recipient/scope, and the
16
+ * editor must hold every guard of the role (an owner-guarded role can only be edited by someone
17
+ * the guards admit, i.e. an owner). Callers check the role both before and after an edit.
18
+ */
15
19
  export declare function canEditDefinition(access: Access, role: ResourceRole): boolean;
16
20
  export interface CreateRoleOptions {
17
21
  readonly key: string;
18
22
  readonly name: string;
19
23
  readonly description?: string;
20
24
  readonly entries: ResourceRole['entries'];
25
+ /** Catalogue permissions the editor must hold to assign or edit the role; default none. */
26
+ readonly guards?: readonly string[];
21
27
  }
22
28
  export interface UpdateRoleOptions {
23
29
  readonly key: string;
@@ -25,6 +31,8 @@ export interface UpdateRoleOptions {
25
31
  readonly name?: string;
26
32
  readonly description?: string;
27
33
  readonly entries?: ResourceRole['entries'];
34
+ /** Replaces the guards; the editor must hold every old and every new guard. */
35
+ readonly guards?: readonly string[];
28
36
  }
29
37
  export interface DeleteRoleOptions {
30
38
  readonly key: string;
package/dist/roles.js CHANGED
@@ -31,14 +31,21 @@ export async function rolesForEditor(access) {
31
31
  editable: !role.builtIn && permits(access, 'roles:update') && canEditDefinition(access, role),
32
32
  }));
33
33
  }
34
- /** Definition edits must cover both its full scope and every existing recipient/scope. */
34
+ /**
35
+ * Definition edits must cover both its full scope and every existing recipient/scope, and the
36
+ * editor must hold every guard of the role (an owner-guarded role can only be edited by someone
37
+ * the guards admit, i.e. an owner). Callers check the role both before and after an edit.
38
+ */
35
39
  export function canEditDefinition(access, role) {
40
+ if (role.guards.some((guard) => !permits(access, guard)))
41
+ return false;
36
42
  if (roleAssignmentRefusal(access, role, primaryAssignment(role, { id: 'prospective-person', class: 'human' }), null))
37
43
  return false;
38
44
  return access.policyState.assignmentRows
39
45
  .filter((a) => a.roleId === role.id && (!a.expiresAt || a.expiresAt.getTime() > Date.now()))
40
46
  .every((a) => !roleAssignmentRefusal(access, role, policyAssignment(a), null));
41
47
  }
48
+ const knownGuards = (access, guards) => guards.every((g) => Object.hasOwn(access.binding.catalogue, g));
42
49
  const invalid = () => refused('invalid_role', 'Choose valid operations, boundaries and scopes within your authority.');
43
50
  export async function createRole(access, db, scopeColumn, options) {
44
51
  if (access.context === 'break_glass')
@@ -49,6 +56,9 @@ export async function createRole(access, db, scopeColumn, options) {
49
56
  return refused('no_permission', 'You cannot create roles here.');
50
57
  if (!/^c_[a-z0-9_-]{1,61}$/.test(options.key))
51
58
  return refused('invalid_key', 'Choose a custom role key starting with c_.');
59
+ const guards = [...new Set(options.guards ?? [])];
60
+ if (!knownGuards(fresh, guards))
61
+ return invalid();
52
62
  const candidate = resourceRoleSchema.safeParse({
53
63
  id: randomUUID(),
54
64
  key: options.key,
@@ -60,7 +70,7 @@ export async function createRole(access, db, scopeColumn, options) {
60
70
  revision: 1,
61
71
  builtIn: false,
62
72
  entries: options.entries,
63
- guards: [],
73
+ guards,
64
74
  });
65
75
  if (!candidate.success || !canEditDefinition(fresh, candidate.data))
66
76
  return invalid();
@@ -84,7 +94,7 @@ export async function createRole(access, db, scopeColumn, options) {
84
94
  revision: role.revision,
85
95
  builtIn: false,
86
96
  entries: role.entries,
87
- guards: [],
97
+ guards: role.guards,
88
98
  createdBy: fresh.actor.id,
89
99
  });
90
100
  await record(tx, fresh, {
@@ -116,9 +126,11 @@ export async function updateRole(access, db, scopeColumn, options) {
116
126
  label: options.name ?? before.label,
117
127
  description: options.description ?? before.description,
118
128
  entries: options.entries ?? before.entries,
129
+ guards: options.guards ? [...new Set(options.guards)] : before.guards,
119
130
  revision: before.revision + 1,
120
131
  });
121
132
  if (!parsed.success ||
133
+ !knownGuards(fresh, parsed.data.guards) ||
122
134
  !canEditDefinition(fresh, before) ||
123
135
  !canEditDefinition(fresh, parsed.data))
124
136
  return invalid();
@@ -129,6 +141,7 @@ export async function updateRole(access, db, scopeColumn, options) {
129
141
  name: after.label,
130
142
  description: after.description,
131
143
  entries: after.entries,
144
+ guards: after.guards,
132
145
  revision: after.revision,
133
146
  updatedAt: new Date(),
134
147
  // ADR 0016's Aged-and-Independent approver rule reads this to exclude
package/dist/schema.d.ts CHANGED
@@ -1457,6 +1457,23 @@ export declare const authzEvents: import("drizzle-orm/pg-core").PgTableWithColum
1457
1457
  identity: undefined;
1458
1458
  generated: undefined;
1459
1459
  }, {}, {}>;
1460
+ tenantDisplay: import("drizzle-orm/pg-core").PgColumn<{
1461
+ name: "tenant_display";
1462
+ tableName: "authz_events";
1463
+ dataType: "string";
1464
+ columnType: "PgText";
1465
+ data: string;
1466
+ driverParam: string;
1467
+ notNull: false;
1468
+ hasDefault: false;
1469
+ isPrimaryKey: false;
1470
+ isAutoincrement: false;
1471
+ hasRuntimeDefault: false;
1472
+ enumValues: [string, ...string[]];
1473
+ baseColumn: never;
1474
+ identity: undefined;
1475
+ generated: undefined;
1476
+ }, {}, {}>;
1460
1477
  teamId: import("drizzle-orm/pg-core").PgColumn<{
1461
1478
  name: "team_id";
1462
1479
  tableName: "authz_events";
@@ -1610,6 +1627,23 @@ export declare const authzEvents: import("drizzle-orm/pg-core").PgTableWithColum
1610
1627
  identity: undefined;
1611
1628
  generated: undefined;
1612
1629
  }, {}, {}>;
1630
+ targetDisplay: import("drizzle-orm/pg-core").PgColumn<{
1631
+ name: "target_display";
1632
+ tableName: "authz_events";
1633
+ dataType: "string";
1634
+ columnType: "PgText";
1635
+ data: string;
1636
+ driverParam: string;
1637
+ notNull: false;
1638
+ hasDefault: false;
1639
+ isPrimaryKey: false;
1640
+ isAutoincrement: false;
1641
+ hasRuntimeDefault: false;
1642
+ enumValues: [string, ...string[]];
1643
+ baseColumn: never;
1644
+ identity: undefined;
1645
+ generated: undefined;
1646
+ }, {}, {}>;
1613
1647
  outcome: import("drizzle-orm/pg-core").PgColumn<{
1614
1648
  name: "outcome";
1615
1649
  tableName: "authz_events";
package/dist/schema.js CHANGED
@@ -169,6 +169,10 @@ export const authzEvents = pgTable('authz_events', {
169
169
  id: bigint('id', { mode: 'number' }).generatedAlwaysAsIdentity().primaryKey(),
170
170
  occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().default(sql `now()`),
171
171
  tenantId: uuid('tenant_id'),
172
+ // What the tenant was CALLED when this happened, beside its id so the row still reads once
173
+ // the organisation is closed. Null on a row written before this column existed, or when
174
+ // tenantId itself is null. Never rewritten once set (authz_events_guard enforces it).
175
+ tenantDisplay: text('tenant_display'),
172
176
  teamId: uuid('team_id'),
173
177
  subjectId: text('subject_id'),
174
178
  subjectClass: text('subject_class'),
@@ -178,6 +182,9 @@ export const authzEvents = pgTable('authz_events', {
178
182
  action: text('action').notNull(),
179
183
  targetType: text('target_type').notNull(),
180
184
  targetId: text('target_id').notNull(),
185
+ // Same as tenantDisplay, for the target: what it was CALLED when this happened. Null on a row
186
+ // written before this column existed.
187
+ targetDisplay: text('target_display'),
181
188
  outcome: text('outcome').notNull(),
182
189
  context: text('context').notNull(),
183
190
  sessionId: text('session_id'),
package/dist/tenants.d.ts CHANGED
@@ -204,8 +204,8 @@ export declare function createTenantAsOperator(operatorAccess: Access, db: DbOrT
204
204
  export declare function deleteJustCreatedTenant(operatorAccess: Access, db: DbOrTx, scopeColumn: Scope['column'], tenantId: string): Promise<Result<void>>;
205
205
  /**
206
206
  * Archives a tenant from inside it, by whoever holds `tenant:delete` there
207
- * (the owner: the permission is not assignable, so no other system role
208
- * carries it). Refused while the tenant holds children (D19's fifth rule):
207
+ * (the owner: the permission is not assignable and not owner-guarded, so no
208
+ * role other than the one keyed `owner` can carry it). Refused while the tenant holds children (D19's fifth rule):
209
209
  * an archived parent's own state says nothing about the organisations
210
210
  * hanging off it, so those must be detached or archived on purpose first,
211
211
  * not silently along for the ride.
package/dist/tenants.js CHANGED
@@ -761,8 +761,8 @@ export async function deleteJustCreatedTenant(operatorAccess, db, scopeColumn, t
761
761
  }
762
762
  /**
763
763
  * Archives a tenant from inside it, by whoever holds `tenant:delete` there
764
- * (the owner: the permission is not assignable, so no other system role
765
- * carries it). Refused while the tenant holds children (D19's fifth rule):
764
+ * (the owner: the permission is not assignable and not owner-guarded, so no
765
+ * role other than the one keyed `owner` can carry it). Refused while the tenant holds children (D19's fifth rule):
766
766
  * an archived parent's own state says nothing about the organisations
767
767
  * hanging off it, so those must be detached or archived on purpose first,
768
768
  * not silently along for the ride.
@@ -9,8 +9,17 @@
9
9
  --
10
10
  -- Additive, per every other file in this directory: never edit this file
11
11
  -- once shipped, a change is a new numbered one.
12
+ --
13
+ -- 0.4.0 note: this function was originally named `erase_person`, the same
14
+ -- name and signature as each host's own erasure function (the one that also
15
+ -- scrubs `profiles`). `migrateStore` replaces a same-named function outright,
16
+ -- so applying this migration silently dropped a host's own `profiles` scrub.
17
+ -- Renamed here to `authz_erase_person` before any persistent database ever
18
+ -- applied it: 0.3.0 published an hour before this fix, and no host had run
19
+ -- this migration against a real database yet, so this is a same-file rename,
20
+ -- not a new numbered migration.
12
21
 
13
- -- ------------------------------------------------------- erase_person(...)
22
+ -- ----------------------------------------------- authz_erase_person(...)
14
23
  -- The single sanctioned write to `authz_events` (D9, M3).
15
24
  --
16
25
  -- Two walls stand between the running app and its own audit log, and this
@@ -40,7 +49,7 @@
40
49
  -- unqualified name through the caller's `search_path` runs whatever they put
41
50
  -- in front of it, with the owner's privileges, which is the standard way
42
51
  -- this feature becomes a privilege escalation.
43
- CREATE OR REPLACE FUNCTION "erase_person"("subject" text, "pseudonym" text, "subject_email" text)
52
+ CREATE OR REPLACE FUNCTION "authz_erase_person"("subject" text, "pseudonym" text, "subject_email" text)
44
53
  RETURNS integer
45
54
  LANGUAGE plpgsql
46
55
  SECURITY DEFINER
@@ -50,10 +59,10 @@ DECLARE
50
59
  touched integer;
51
60
  BEGIN
52
61
  IF "subject" IS NULL OR length("subject") = 0 THEN
53
- RAISE EXCEPTION 'erase_person: a subject id is required';
62
+ RAISE EXCEPTION 'authz_erase_person: a subject id is required';
54
63
  END IF;
55
64
  IF "pseudonym" IS NULL OR length("pseudonym") = 0 OR length("pseudonym") > 256 THEN
56
- RAISE EXCEPTION 'erase_person: a pseudonym of 1 to 256 characters is required';
65
+ RAISE EXCEPTION 'authz_erase_person: a pseudonym of 1 to 256 characters is required';
57
66
  END IF;
58
67
 
59
68
  -- Rows this person WROTE, and rows written ABOUT them.
@@ -115,7 +124,7 @@ $$;
115
124
  -- runtime role may be handed this one without gaining any other access to
116
125
  -- the table. Revoked from PUBLIC first, since Postgres grants EXECUTE on a
117
126
  -- new function to PUBLIC by default.
118
- REVOKE ALL ON FUNCTION "erase_person"(text, text, text) FROM PUBLIC;
127
+ REVOKE ALL ON FUNCTION "authz_erase_person"(text, text, text) FROM PUBLIC;
119
128
  --> statement-breakpoint
120
129
 
121
130
  -- The store has no runtime-role convention of its own, unlike Boule's fixed
@@ -128,7 +137,7 @@ DECLARE
128
137
  rt text := current_database() || '_rt';
129
138
  BEGIN
130
139
  IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = rt) THEN
131
- EXECUTE format('GRANT EXECUTE ON FUNCTION "erase_person"(text, text, text) TO %I', rt);
140
+ EXECUTE format('GRANT EXECUTE ON FUNCTION "authz_erase_person"(text, text, text) TO %I', rt);
132
141
  END IF;
133
142
  END
134
143
  $$;
@@ -0,0 +1,52 @@
1
+ -- authz_events gains target_display and tenant_display, matching how
2
+ -- actor_display already works: written once by whoever signs the row, never
3
+ -- rewritten afterward. See https://github.com/wtfalch/authz/issues/69.
4
+ ALTER TABLE "authz_events" ADD COLUMN "tenant_display" text;
5
+ --> statement-breakpoint
6
+ ALTER TABLE "authz_events" ADD COLUMN "target_display" text;
7
+ --> statement-breakpoint
8
+ ALTER TABLE "authz_events" ADD CONSTRAINT "authz_events_tenant_display_check"
9
+ CHECK (((tenant_display IS NULL) OR ((length(tenant_display) >= 1) AND (length(tenant_display) <= 256))));
10
+ --> statement-breakpoint
11
+ ALTER TABLE "authz_events" ADD CONSTRAINT "authz_events_target_display_check"
12
+ CHECK (((target_display IS NULL) OR ((length(target_display) >= 1) AND (length(target_display) <= 256))));
13
+ --> statement-breakpoint
14
+ -- Extend the append-only guard: target_display and tenant_display join the immutable set (they are
15
+ -- never rewritten, unlike actor_display/before/after/erased_at, which an erasure may still touch).
16
+ CREATE OR REPLACE FUNCTION authz_events_guard()
17
+ RETURNS trigger
18
+ LANGUAGE plpgsql
19
+ AS $function$
20
+ BEGIN
21
+ IF TG_OP = 'DELETE' THEN
22
+ RAISE EXCEPTION 'authz_events is append-only: delete refused';
23
+ ELSIF TG_OP = 'TRUNCATE' THEN
24
+ RAISE EXCEPTION 'authz_events is append-only: truncate refused';
25
+ ELSIF TG_OP = 'UPDATE' THEN
26
+ IF NEW."id" IS DISTINCT FROM OLD."id"
27
+ OR NEW."occurred_at" IS DISTINCT FROM OLD."occurred_at"
28
+ OR NEW."tenant_id" IS DISTINCT FROM OLD."tenant_id"
29
+ OR NEW."tenant_display" IS DISTINCT FROM OLD."tenant_display"
30
+ OR NEW."actor_class" IS DISTINCT FROM OLD."actor_class"
31
+ OR NEW."actor_id" IS DISTINCT FROM OLD."actor_id"
32
+ OR NEW."action" IS DISTINCT FROM OLD."action"
33
+ OR NEW."target_type" IS DISTINCT FROM OLD."target_type"
34
+ OR NEW."target_id" IS DISTINCT FROM OLD."target_id"
35
+ OR NEW."target_display" IS DISTINCT FROM OLD."target_display"
36
+ OR NEW."outcome" IS DISTINCT FROM OLD."outcome"
37
+ OR NEW."context" IS DISTINCT FROM OLD."context"
38
+ OR NEW."session_id" IS DISTINCT FROM OLD."session_id"
39
+ OR NEW."reason" IS DISTINCT FROM OLD."reason"
40
+ OR NEW."reference" IS DISTINCT FROM OLD."reference"
41
+ OR NEW."request_id" IS DISTINCT FROM OLD."request_id"
42
+ OR NEW."ip" IS DISTINCT FROM OLD."ip"
43
+ OR NEW."user_agent" IS DISTINCT FROM OLD."user_agent"
44
+ OR NEW."tenant_visible" IS DISTINCT FROM OLD."tenant_visible"
45
+ OR NEW."schema_version" IS DISTINCT FROM OLD."schema_version"
46
+ THEN
47
+ RAISE EXCEPTION 'authz_events is append-only: only actor_display, before, after and erased_at may change';
48
+ END IF;
49
+ END IF;
50
+ RETURN NEW;
51
+ END
52
+ $function$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/authz-store",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Persistence and lifecycle for @wtfalch/authz: the storage a host would otherwise write itself.",
5
5
  "type": "module",
6
6
  "files": [
@@ -13,10 +13,14 @@
13
13
  "types": "./dist/index.d.ts",
14
14
  "default": "./dist/index.js"
15
15
  },
16
+ "./policy": {
17
+ "types": "./dist/policy-entry.d.ts",
18
+ "default": "./dist/policy-entry.js"
19
+ },
16
20
  "./package.json": "./package.json"
17
21
  },
18
22
  "peerDependencies": {
19
- "@wtfalch/authz": "^0.16.0",
23
+ "@wtfalch/authz": "^0.17.0",
20
24
  "@wtfalch/db": "0.4.0",
21
25
  "drizzle-orm": ">=0.39.3 <1.0.0",
22
26
  "zod": "^4.1.13"