@wtfalch/authz-store 0.4.0 → 0.6.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`,
@@ -114,7 +128,8 @@ same helper otf already used, in place of the `(error as {code?}).code ===
114
128
  '23505'` duck-typing Boule's and archon's copies both wrote — declared as a
115
129
  peer dependency, matching `drizzle-orm`'s role (a host-supplied instance),
116
130
  pinned exact at `0.4.0` (the estate's `@wtfalch/*` pin rule), plus a matching
117
- exact `devDependency` for this package's own build and test.
131
+ exact `devDependency` for this package's own build and test. From 0.6.0 the
132
+ peer is the range `>=0.5.2 <0.6.0` and the `devDependency` is exact `0.5.2`.
118
133
 
119
134
  Wave 7 (authz#83, authz#95 decision 2, authz#98) moves `credentials.ts`
120
135
  (`mintCredential`, `revokeCredential`, `credentialsFor`) and `bootstrap.ts`
@@ -361,14 +376,15 @@ are: each genuinely needs the secret/keys column it names (an export row's
361
376
  0.4.0 also renames the SQL function `migrations/0006_erase_person.sql`
362
377
  creates, from `erase_person` to `authz_erase_person`. The old name collided
363
378
  with each host's own erasure function of the same name and signature, which
364
- also scrubs a host's `profiles`; `migrateStore`'s `CREATE OR REPLACE FUNCTION`
379
+ also scrubs a host's `profiles`; the migration's `CREATE OR REPLACE FUNCTION`
365
380
  replaced a host's function outright, silently dropping that scrub. 0.3.0
366
381
  published an hour before this fix and no persistent database had applied
367
382
  0006 yet, so this is a same-file rename, not a new numbered migration.
368
383
  `src/erase.ts` calls `authz_erase_person`.
369
384
 
370
- The rest of the host layer has not moved — tracked in
371
- https://github.com/wtfalch/authz/issues/83.
385
+ Extraction is complete through wave 11. Remaining host adoption and the
386
+ resource-resolution extension are tracked in
387
+ https://github.com/wtfalch/authz/issues/83 and the current-status map above.
372
388
 
373
389
  New here, not moved: `authz_grants`, grants stored per person rather than
374
390
  compiled from a role, and `guestGrants`, which reads a tenant's guest grants
@@ -377,10 +393,13 @@ have `expires_at`. Chat reads this to act on expiry; see
377
393
  https://github.com/wtfalch/authz/issues/42.
378
394
 
379
395
  Tests run against a real Postgres — PGlite, compiled to wasm and started in the
380
- test process — so no container, service or network is needed. Most tests build
381
- the tables from the drizzle definitions. `src/migrate.test.ts` applies the
382
- shipped SQL instead and fails when the two disagree on a column, a type,
383
- nullability or an index name.
396
+ test process — so no container, service or network is needed. Every test applies
397
+ the shipped SQL through `@wtfalch/db`'s `runMigrationSources`, and
398
+ `src/migrate.test.ts` fails when that SQL and the drizzle definitions disagree on
399
+ a column, a type, nullability or an index name. Set `TEST_DATABASE_URL` to run the
400
+ same suite on a real Postgres, each test file in its own uniquely named schema,
401
+ dropped afterwards; roles, grants and the nested-transaction savepoint path are
402
+ tested only there.
384
403
 
385
404
  ## 0.4.0
386
405
 
@@ -407,8 +426,8 @@ constraint as-is).
407
426
  `StoreBinding`, `PolicyData` and `PolicyRoleTemplate`, and nothing else, for a
408
427
  client component that only needs the policy catalogue for permission labels.
409
428
  The main entry (`.`) still exports everything it does today — this is
410
- additive — but it also drags in `migrateStore`, which imports
411
- `node:fs/promises` and breaks a production client build; `./policy`'s import
429
+ additive — but it also drags in `node:crypto` and `drizzle-orm`, which break
430
+ a production client build; `./policy`'s import
412
431
  graph is proved free of any `node:` module, `drizzle-orm` or a database handle
413
432
  by `scripts/tests/package-contract.test.mjs`. 0.4.0 also exports the five
414
433
  membership row helpers from `src/membership-rows.ts` off the main entry —
@@ -421,11 +440,87 @@ passed, exactly as before.
421
440
 
422
441
  ## Migrations
423
442
 
424
- The package ships its own SQL in `migrations/`, and `migrateStore(db)` applies
425
- it. Run it before the app's own migrations, so every table the package owns
426
- exists before an app migration references it. Each file runs once, in one
427
- transaction, and is recorded in `authz_store_migrations`. Never edit a shipped
428
- file; a change is a new numbered one.
443
+ The package ships its own SQL in `migrations/` and applies none of it: the host
444
+ does, with `runMigrationSources` from `@wtfalch/db/migrate` (0.5.2 or later), before its
445
+ own migrations, so every table the package owns exists before a host migration
446
+ references it. The directory comes from the subpath
447
+ `@wtfalch/authz-store/migrations-dir`, which imports only `node:url`, so a
448
+ migration image or a bundler never loads the main entry for it:
449
+
450
+ ```ts
451
+ import { runMigrationSources } from '@wtfalch/db/migrate';
452
+ import { migrationsDir } from '@wtfalch/authz-store/migrations-dir';
453
+
454
+ const ownerUrl = process.env.DATABASE_URL_OWNER;
455
+ if (!ownerUrl) throw new Error('DATABASE_URL_OWNER is required');
456
+
457
+ await runMigrationSources({
458
+ url: ownerUrl,
459
+ schema: 'myservice',
460
+ sources: [
461
+ { name: 'authz-store', dir: migrationsDir },
462
+ { name: 'app', dir: 'drizzle' },
463
+ ],
464
+ });
465
+ ```
466
+
467
+ Each file runs once, in one transaction, and is recorded with its checksum in
468
+ `_migrations_sources` in the host's schema. Never edit a shipped file; a change
469
+ is a new numbered one. The SQL names no schema: the host's `schema` decides
470
+ where the tables land. The runtime connection puts that schema on its
471
+ `search_path`, and `ensureRuntimeRole({ schemas: ['myservice'] })` covers it.
472
+ `authz_erase_person` is a `SECURITY DEFINER` function that pins the
473
+ `search_path` of the migration run; the runtime role needs EXECUTE on it, which
474
+ the host grants (see below). Migrate with `@wtfalch/db` 0.5.2 or later: 0.5.0
475
+ and 0.5.1 let a temp table shadow the store's tables inside that function.
476
+
477
+ ### Moving from 0.5
478
+
479
+ Before, `migrateStore(db)` applied the SQL on the host's one database and
480
+ recorded it in `authz_store_migrations`, and the host ran a grant for
481
+ `authz_erase_person` (or named its runtime role `<database>_rt`):
482
+
483
+ ```ts
484
+ import { migrateStore } from '@wtfalch/authz-store';
485
+
486
+ await migrateStore(db);
487
+ ```
488
+
489
+ After, with the owner credential, a schema, the store's source first, and the
490
+ grant on `ensureRuntimeRole`:
491
+
492
+ ```ts
493
+ import { runMigrationSources } from '@wtfalch/db/migrate';
494
+ import { ensureRuntimeRole } from '@wtfalch/db/runtime-role';
495
+ import { migrationsDir } from '@wtfalch/authz-store/migrations-dir';
496
+
497
+ const ownerUrl = process.env.DATABASE_URL_OWNER;
498
+ const runtimeUrl = process.env.DATABASE_URL;
499
+ if (!ownerUrl || !runtimeUrl) throw new Error('DATABASE_URL_OWNER and DATABASE_URL are required');
500
+
501
+ await runMigrationSources({
502
+ url: ownerUrl,
503
+ schema: 'myservice',
504
+ sources: [
505
+ { name: 'authz-store', dir: migrationsDir },
506
+ { name: 'app', dir: 'drizzle' },
507
+ ],
508
+ });
509
+ await ensureRuntimeRole({
510
+ ownerUrl,
511
+ runtimeUrl,
512
+ schemas: ['myservice'],
513
+ grants: ['myservice.authz_erase_person(text,text,text)'],
514
+ });
515
+ // Runtime connection: createDatabase({ url, searchPath: ['myservice', 'public'] })
516
+ ```
517
+
518
+ `migrateStore` and its `authz_store_migrations` table are gone; a database that
519
+ used them keeps a stale table that nothing reads and nothing adopts. 0.6.0
520
+ assumes databases are recreated empty. Pass the native Drizzle handle,
521
+ `withDrizzle(...).orm` or `tx.orm`, to every function, with no cast. Install
522
+ `@wtfalch/db` `>=0.5.2 <0.6.0` yourself: it is a peer dependency (the runtime
523
+ code imports `sqlState` from it).
429
524
 
430
525
  `0001_baseline.sql` is every store table as manage's live schema had it on
431
526
  2026-09-22. Three things stay with each app, because each lists something the
@@ -435,18 +530,28 @@ app owns:
435
530
  list that app's offered permissions.
436
531
  - The foreign key from `break_glass_sessions.operator_id` to `profiles`.
437
532
 
438
- The baseline is for a new database. manage and otf `web/` already have these
439
- tables, so each needs a one-off reconciliation: bring the schema to the
440
- baseline, then insert `0001_baseline.sql` into `authz_store_migrations` by
441
- hand. otf also lacks `authz_roles.updated_by` and the `'activation'`
442
- assignment source.
443
-
444
- What a host must supply is growing, and it is all explicit: a module that needs
445
- the host's `APPLICATION_ID` and `PLATFORM_ID` takes them as a `PolicyBinding`
446
- argument rather than importing them, because the two applications differ there.
447
-
448
- The remaining ~12,800 lines, and the one-off migration each application needs to
449
- adopt them, are not started.
533
+ The baseline is for a new database. An existing host must verify its schema
534
+ and migration history before adopting it. Its reconciliation brings the
535
+ schema to the baseline before recording `0001_baseline.sql` in
536
+ `_migrations_sources`; it must preserve runtime-role privileges and the
537
+ host-specific constraints above. The historical map records OTF's missing
538
+ `authz_roles.updated_by` and `'activation'` assignment source; verify these
539
+ against the host being migrated rather than assuming that snapshot is current.
540
+
541
+ Version 0.4.0 creates `authz_erase_person`, keeping host erasure separate.
542
+ A host whose database already recorded an older `0006_erase_person.sql` must
543
+ check which function was installed: changing a dependency pin does not replay
544
+ an applied migration. Reconcile with a new additive host migration and retain
545
+ host-owned profile/reporting scrubs. Boule's upgrade prerequisite is tracked
546
+ in [boule#163](https://github.com/wtfalch/boule/issues/163). No deployed migration
547
+ history was inspected for this status update.
548
+
549
+ Hosts pass their application id, platform id and catalogue through
550
+ `StoreBinding`/`StorePolicy`; the store does not import host constants.
551
+
552
+ The remaining work is adoption of the extracted modules, host resource-resolution
553
+ support and each host's verified reconciliation. Follow the current-status map
554
+ and host issues; the historical line count is not a current migration estimate.
450
555
 
451
556
  ## Why it is not part of `@wtfalch/authz`
452
557
 
@@ -465,3 +570,43 @@ framework-neutral package cannot import it, so that import is dropped here. A
465
570
  host that wants the guard re-exports these functions through its own
466
571
  `server-only` module. `generateCredentialSecret` reaching a client bundle is the
467
572
  thing worth preventing.
573
+
574
+
575
+ ## Host resource lookups
576
+
577
+ `StoreBinding.resolveResource` extends `policyResource`, `loadAccess` and `refreshAccess`
578
+ with authoritative metadata for host-owned resource types (authz#130). The callback receives
579
+ the caller's database transaction and an immutable target containing `applicationId`,
580
+ `platformId`, `organisationId`, `type` and `id`. It returns an `AccessResource` or `undefined`:
581
+
582
+ ```ts
583
+ const policy = definePolicy(catalogueData, {
584
+ applicationId: APPLICATION_ID,
585
+ platformId: PLATFORM_ID,
586
+ resolveResource: async (tx, target) => {
587
+ if (target.type !== 'files.file') return undefined;
588
+ // This host function queries its own table using BOTH organisationId and id.
589
+ const row = await findFile(tx, target.organisationId, target.id);
590
+ if (!row) return undefined;
591
+ return {
592
+ ...target,
593
+ teamId: row.teamId,
594
+ owner: { id: row.ownerId, class: row.ownerClass },
595
+ };
596
+ },
597
+ });
598
+ ```
599
+
600
+ This callback is trusted server configuration. The host must query authoritative rows
601
+ in the supplied transaction, constrain them to the requested organisation and resource,
602
+ and return `undefined` for missing resources or optional modules. The store rejects a
603
+ result with a different application, platform, organisation, type or id. Lookup errors
604
+ propagate. An unresolved resource cannot establish the team containment needed to
605
+ delegate a team-scoped grant to one resource. Core `team` and `audit`
606
+ lookups keep their store checks and never fall through to the callback.
607
+
608
+ `definePolicy` retains the callback on the binding, so `refreshAccess` uses it when
609
+ re-reading credential lineage before a mutation. Keep callbacks and their database
610
+ imports in server modules; the `./policy` export remains safe to import for client
611
+ catalogue labels. This API requires the next store release; published 0.4.0 does not
612
+ 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/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,14 @@ 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;
11
26
  /**
12
27
  * The host's own audit event names, beyond `@wtfalch/authz`'s core events (0.4.0). A host
13
28
  * records its own events (archon: `flag.changed`, `cms.published`, `key.rotated`; files/ai/
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.
package/dist/erase.js CHANGED
@@ -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.
package/dist/index.d.ts CHANGED
@@ -4,9 +4,8 @@ export { policyActivationApprovals, policyActivations, policyAssignments, policy
4
4
  export { accessTeamMembers, accessTeams, type AppRolePermission, } from './resource-schema.js';
5
5
  export { attachProposals, authzEvents, breakGlassSessions, credentials, invitations, memberships, tenants, type AttachProposal, type AuthzEvent, type BreakGlassSession, type Credential, type Invitation, type Membership, type NewAttachProposal, type NewAuthzEvent, type NewBreakGlassSession, type NewCredential, type NewInvitation, type NewMembership, type NewTenant, type Tenant, } from './schema.js';
6
6
  export { ownerCoverage, ownerSeatCovered, principalIsReachable, type OwnerCoverage, type OwnerCoverageOptions, type PolicyBinding, } from './owners.js';
7
- export { migrateStore } from './migrate.js';
8
7
  export { guestGrants, policyGrants, type GrantStatus, type GuestGrant } from './grants.js';
9
- export { permissionOf, type StoreBinding } from './binding.js';
8
+ export { permissionOf, type StoreBinding, type HostResourceResolver, type ResourceTarget, } from './binding.js';
10
9
  export { isCustomRoleKey } from './role-keys.js';
11
10
  export { type Access, type BreakGlassGrant, type Context, type Principal, type Result, type TenantRow, done, isUuid, refused, tenantTag, } from './types.js';
12
11
  export { policyResource } from './policy-resources.js';
package/dist/index.js CHANGED
@@ -4,9 +4,8 @@ export { policyActivationApprovals, policyActivations, policyAssignments, policy
4
4
  export { accessTeamMembers, accessTeams, } from './resource-schema.js';
5
5
  export { attachProposals, authzEvents, breakGlassSessions, credentials, invitations, memberships, tenants, } from './schema.js';
6
6
  export { ownerCoverage, ownerSeatCovered, principalIsReachable, } from './owners.js';
7
- export { migrateStore } from './migrate.js';
8
7
  export { guestGrants, policyGrants } from './grants.js';
9
- export { permissionOf } from './binding.js';
8
+ export { permissionOf, } from './binding.js';
10
9
  export { isCustomRoleKey } from './role-keys.js';
11
10
  export { done, isUuid, refused, tenantTag, } from './types.js';
12
11
  export { policyResource } from './policy-resources.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)))
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Absolute path of the directory holding this package's numbered `.sql`
3
+ * migrations. The host passes it to `runMigrationSources` as
4
+ * `{ name: 'authz-store', dir: migrationsDir }`.
5
+ *
6
+ * Its own subpath (`@wtfalch/authz-store/migrations-dir`) and no other
7
+ * imports: a migration image imports this without Drizzle and authz, and a
8
+ * bundler never meets this file's `import.meta.url` through the main entry.
9
+ * `migrations/` sits next to `dist/` in the package and next to `src/` in
10
+ * this repository, so one relative path is right in both.
11
+ */
12
+ export declare const migrationsDir: string;
@@ -0,0 +1,13 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ /**
3
+ * Absolute path of the directory holding this package's numbered `.sql`
4
+ * migrations. The host passes it to `runMigrationSources` as
5
+ * `{ name: 'authz-store', dir: migrationsDir }`.
6
+ *
7
+ * Its own subpath (`@wtfalch/authz-store/migrations-dir`) and no other
8
+ * imports: a migration image imports this without Drizzle and authz, and a
9
+ * bundler never meets this file's `import.meta.url` through the main entry.
10
+ * `migrations/` sits next to `dist/` in the package and next to `src/` in
11
+ * this repository, so one relative path is right in both.
12
+ */
13
+ export const migrationsDir = fileURLToPath(new URL('../migrations/', import.meta.url));
@@ -1,11 +1,11 @@
1
1
  /**
2
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
3
+ * signature needs. The main entry (`.`) imports `node:crypto` and
4
+ * `drizzle-orm`, so a client component that only reads the policy catalogue for permission
5
5
  * labels cannot import from `.` without pulling Node into a production browser build
6
6
  * (https://github.com/wtfalch/boule/pull/162). Nothing this module imports, directly or
7
7
  * transitively, may import a `node:` module, `drizzle-orm`, or a database handle — proved by
8
8
  * `scripts/tests/package-contract.test.mjs`.
9
9
  */
10
10
  export { definePolicy, type PolicyData, type PolicyRoleTemplate, type StorePolicy, } from './policy.js';
11
- export type { StoreBinding } from './binding.js';
11
+ export type { StoreBinding, HostResourceResolver, ResourceTarget } from './binding.js';
@@ -1,7 +1,7 @@
1
1
  /**
2
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
3
+ * signature needs. The main entry (`.`) imports `node:crypto` and
4
+ * `drizzle-orm`, so a client component that only reads the policy catalogue for permission
5
5
  * labels cannot import from `.` without pulling Node into a production browser build
6
6
  * (https://github.com/wtfalch/boule/pull/162). Nothing this module imports, directly or
7
7
  * transitively, may import a `node:` module, `drizzle-orm`, or a database handle — proved by
@@ -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
@@ -68,4 +68,5 @@ export declare function definePolicy(data: PolicyData, binding: {
68
68
  readonly applicationId: string;
69
69
  readonly platformId: string;
70
70
  readonly hostEvents?: readonly string[];
71
+ readonly resolveResource?: StoreBinding['resolveResource'];
71
72
  }): StorePolicy;
package/dist/policy.js CHANGED
@@ -59,6 +59,7 @@ export function definePolicy(data, binding) {
59
59
  platformId: binding.platformId,
60
60
  catalogue,
61
61
  hostEvents: binding.hostEvents,
62
+ resolveResource: binding.resolveResource,
62
63
  defaultCeiling: data.defaultCeiling,
63
64
  maxDepth: data.maxDepth,
64
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
@@ -111,7 +111,9 @@ export async function createTenant(principal, db, policy, config, profiles, opti
111
111
  return refused('slug_taken', 'That address is already taken.');
112
112
  let tenantRow;
113
113
  try {
114
- [tenantRow] = await tx
114
+ // A savepoint: a unique violation aborts a Postgres transaction, and the rest of this
115
+ // function runs in it. Rolling back to the savepoint lets the catch below return normally.
116
+ [tenantRow] = await tx.transaction((sp) => sp
115
117
  .insert(tenants)
116
118
  .values({
117
119
  kind: 'customer',
@@ -121,7 +123,7 @@ export async function createTenant(principal, db, policy, config, profiles, opti
121
123
  selfDenied: [],
122
124
  createdBy: principal.id,
123
125
  })
124
- .returning({ id: tenants.id });
126
+ .returning({ id: tenants.id }));
125
127
  }
126
128
  catch (error) {
127
129
  if (isUniqueViolation(error))
@@ -197,10 +199,11 @@ export async function renameTenant(access, db, scopeColumn, options) {
197
199
  return refused('slug_taken', 'That address is already taken.');
198
200
  }
199
201
  try {
200
- await tx
202
+ // A savepoint, for the same reason as in `createTenant`.
203
+ await tx.transaction((sp) => sp
201
204
  .update(tenants)
202
205
  .set({ name: nextName, slug: nextSlug, updatedAt: new Date() })
203
- .where(eq(tenants.id, tenantRow.id));
206
+ .where(eq(tenants.id, tenantRow.id)));
204
207
  }
205
208
  catch (error) {
206
209
  if (isUniqueViolation(error))
@@ -611,7 +614,8 @@ export async function createTenantAsOperator(operatorAccess, db, scopeColumn, po
611
614
  }
612
615
  let tenantRow;
613
616
  try {
614
- [tenantRow] = await tx
617
+ // A savepoint, for the same reason as in `createTenant`.
618
+ [tenantRow] = await tx.transaction((sp) => sp
615
619
  .insert(tenants)
616
620
  .values({
617
621
  kind: 'customer',
@@ -621,7 +625,7 @@ export async function createTenantAsOperator(operatorAccess, db, scopeColumn, po
621
625
  selfDenied: [],
622
626
  createdBy: currentAccess.actor.id,
623
627
  })
624
- .returning({ id: tenants.id });
628
+ .returning({ id: tenants.id }));
625
629
  }
626
630
  catch (error) {
627
631
  if (isUniqueViolation(error))
@@ -761,8 +765,8 @@ export async function deleteJustCreatedTenant(operatorAccess, db, scopeColumn, t
761
765
  }
762
766
  /**
763
767
  * 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):
768
+ * (the owner: the permission is not assignable and not owner-guarded, so no
769
+ * role other than the one keyed `owner` can carry it). Refused while the tenant holds children (D19's fifth rule):
766
770
  * an archived parent's own state says nothing about the organisations
767
771
  * hanging off it, so those must be detached or archived on purpose first,
768
772
  * not silently along for the ride.
@@ -12,12 +12,17 @@
12
12
  --
13
13
  -- 0.4.0 note: this function was originally named `erase_person`, the same
14
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,
15
+ -- scrubs `profiles`). `CREATE OR REPLACE FUNCTION` replaces a same-named function outright,
16
16
  -- so applying this migration silently dropped a host's own `profiles` scrub.
17
17
  -- Renamed here to `authz_erase_person` before any persistent database ever
18
18
  -- applied it: 0.3.0 published an hour before this fix, and no host had run
19
19
  -- this migration against a real database yet, so this is a same-file rename,
20
20
  -- not a new numbered migration.
21
+ --
22
+ -- 0.6.0 note: edited in place, again before any persistent database applied
23
+ -- it (databases were recreated empty when migrations moved to the host's
24
+ -- runner, in the host's own schema). The function now pins the `search_path`
25
+ -- of the session that creates it, and the runtime-role grant moved to the host.
21
26
 
22
27
  -- ----------------------------------------------- authz_erase_person(...)
23
28
  -- The single sanctioned write to `authz_events` (D9, M3).
@@ -45,15 +50,17 @@
45
50
  -- a list to keep in step forever; what a row meant is in `action`,
46
51
  -- `target_*` and `reason`, none of which this touches.
47
52
  --
48
- -- `search_path` is pinned. A SECURITY DEFINER function that resolves an
49
- -- unqualified name through the caller's `search_path` runs whatever they put
50
- -- in front of it, with the owner's privileges, which is the standard way
51
- -- this feature becomes a privilege escalation.
53
+ -- `search_path` is pinned to the one the host's migration run set, which is
54
+ -- the host's schema first, so the unqualified names below resolve there. A
55
+ -- SECURITY DEFINER function that resolves an unqualified name through the
56
+ -- caller's `search_path` runs whatever they put in front of it, with the
57
+ -- owner's privileges, which is the standard way this feature becomes a
58
+ -- privilege escalation.
52
59
  CREATE OR REPLACE FUNCTION "authz_erase_person"("subject" text, "pseudonym" text, "subject_email" text)
53
60
  RETURNS integer
54
61
  LANGUAGE plpgsql
55
62
  SECURITY DEFINER
56
- SET search_path = pg_catalog, public
63
+ SET search_path FROM CURRENT
57
64
  AS $$
58
65
  DECLARE
59
66
  touched integer;
@@ -126,18 +133,3 @@ $$;
126
133
  -- new function to PUBLIC by default.
127
134
  REVOKE ALL ON FUNCTION "authz_erase_person"(text, text, text) FROM PUBLIC;
128
135
  --> statement-breakpoint
129
-
130
- -- The store has no runtime-role convention of its own, unlike Boule's fixed
131
- -- `<database>_rt`, granted unconditionally. A host whose runtime role
132
- -- happens to be named that way gets EXECUTE for free; a host with no such
133
- -- role gets nothing granted here and grants EXECUTE itself, rather than
134
- -- this migration failing outright for naming a role that does not exist.
135
- DO $$
136
- DECLARE
137
- rt text := current_database() || '_rt';
138
- BEGIN
139
- IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = rt) THEN
140
- EXECUTE format('GRANT EXECUTE ON FUNCTION "authz_erase_person"(text, text, text) TO %I', rt);
141
- END IF;
142
- END
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.4.0",
3
+ "version": "0.6.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": [
@@ -17,11 +17,15 @@
17
17
  "types": "./dist/policy-entry.d.ts",
18
18
  "default": "./dist/policy-entry.js"
19
19
  },
20
+ "./migrations-dir": {
21
+ "types": "./dist/migrations-dir.d.ts",
22
+ "default": "./dist/migrations-dir.js"
23
+ },
20
24
  "./package.json": "./package.json"
21
25
  },
22
26
  "peerDependencies": {
23
- "@wtfalch/authz": "^0.16.0",
24
- "@wtfalch/db": "0.4.0",
27
+ "@wtfalch/authz": "^0.17.0",
28
+ "@wtfalch/db": ">=0.5.2 <0.6.0",
25
29
  "drizzle-orm": ">=0.39.3 <1.0.0",
26
30
  "zod": "^4.1.13"
27
31
  },
@@ -35,12 +39,10 @@
35
39
  "devDependencies": {
36
40
  "@electric-sql/pglite": "^0.5.8",
37
41
  "@types/node": "^22",
38
- "@wtfalch/db": "0.4.0",
42
+ "@wtfalch/db": "0.5.2",
39
43
  "@wtfalch/keys": "0.4.2",
40
- "drizzle-kit": "^0.31.10",
41
- "drizzle-orm": "^0.39.0",
44
+ "drizzle-orm": "0.39.3",
42
45
  "fast-check": "^4.9.0",
43
- "postgres": "^3.4.5",
44
46
  "typescript": "^5.9.0",
45
47
  "vitest": "^4.1.6",
46
48
  "zod": "^4.5.4"
package/dist/migrate.d.ts DELETED
@@ -1,9 +0,0 @@
1
- import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
- type HostDb = PgDatabase<PgQueryResultHKT, any, any>;
3
- /**
4
- * Applies the package's migrations that `authz_store_migrations` has no row for, in file-name
5
- * order, one transaction per file. Returns the names it applied. The host runs this before its own
6
- * migrations, so everything the package owns exists before a host migration references it.
7
- */
8
- export declare function migrateStore(db: HostDb): Promise<string[]>;
9
- export {};
package/dist/migrate.js DELETED
@@ -1,52 +0,0 @@
1
- import { readFile, readdir } from 'node:fs/promises';
2
- import { dirname, join } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { sql } from 'drizzle-orm';
5
- /**
6
- * The shipped SQL, next to `dist/` in the package and next to `src/` in this repository. Computed
7
- * lazily inside `migrateStore` rather than as a module-level `new URL('../migrations/',
8
- * import.meta.url)`: Turbopack resolves that literal statically when an app imports the package
9
- * barrel, and fails with "Can't resolve '../migrations/'" because the directory holds no importable
10
- * module.
11
- */
12
- function migrationsDir() {
13
- return join(dirname(fileURLToPath(import.meta.url)), '..', 'migrations');
14
- }
15
- /** Files split on this line, the marker drizzle-kit writes, so any driver can run them one statement at a time. */
16
- const BREAKPOINT = '--> statement-breakpoint';
17
- /** One key for every host, so two containers starting at once apply each file once between them. */
18
- const LOCK = 7_310_422_001;
19
- /**
20
- * Applies the package's migrations that `authz_store_migrations` has no row for, in file-name
21
- * order, one transaction per file. Returns the names it applied. The host runs this before its own
22
- * migrations, so everything the package owns exists before a host migration references it.
23
- */
24
- export async function migrateStore(db) {
25
- const dir = migrationsDir();
26
- const names = (await readdir(dir)).filter((n) => n.endsWith('.sql')).sort();
27
- const applied = [];
28
- for (const name of names) {
29
- const text = await readFile(join(dir, name), 'utf8');
30
- const ran = await db.transaction(async (tx) => {
31
- await tx.execute(sql `select pg_advisory_xact_lock(${LOCK})`);
32
- // Created under the lock: two runners creating it at once collide in pg_type.
33
- await tx.execute(sql `create table if not exists authz_store_migrations (name text primary key, applied_at timestamptz not null default now())`);
34
- const done = await tx.execute(sql `select 1 from authz_store_migrations where name = ${name}`);
35
- if (rowsOf(done).length > 0)
36
- return false;
37
- for (const statement of text.split(BREAKPOINT)) {
38
- if (statement.trim())
39
- await tx.execute(sql.raw(statement));
40
- }
41
- await tx.execute(sql `insert into authz_store_migrations (name) values (${name})`);
42
- return true;
43
- });
44
- if (ran)
45
- applied.push(name);
46
- }
47
- return applied;
48
- }
49
- /** postgres-js returns the rows as the result; node-postgres and PGlite wrap them in `rows`. */
50
- function rowsOf(result) {
51
- return Array.isArray(result) ? result : (result.rows ?? []);
52
- }