@wtfalch/authz-store 0.3.0 → 0.4.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
@@ -235,9 +235,10 @@ cycle; a later issue in the people repo makes it reuse this copy rather than
235
235
  carry its own. Every row helper is exported under a name distinct from its
236
236
  policy-layer counterpart (`insertMembershipRow`, `deleteMembershipRow`,
237
237
  `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
238
+ would otherwise claim the same five names on one package surface. As of
239
+ 0.4.0 (https://github.com/wtfalch/people/issues/16) all five are exported
240
+ from the main entry, so `@wtfalch/people` can call this copy instead of
241
+ carrying its own — see the 0.4.0 note below. `identityWhere` is not duplicated: people's
241
242
  own copy tests the same three columns as `src/assignments.ts`'s, in a
242
243
  different argument order `and()` doesn't care about, so `membership-rows.ts`
243
244
  imports that one. `roster.js`'s `profiles` left join (a member with no
@@ -326,7 +327,7 @@ otf, not moved.
326
327
  Wave 11d (authz#83) moves Boule's own `src/lib/authz/erase.ts`, `alerts.ts`
327
328
  and `startup.ts` into `src/erase.ts`, `src/alerts.ts` and `src/startup.ts`.
328
329
  `erase.ts` moves `pseudonymFor` and `erasePerson`, the one sanctioned write
329
- to `authz_events`; the SQL function it calls (`erase_person`, `SECURITY
330
+ to `authz_events`; the SQL function it calls (`authz_erase_person`, `SECURITY
330
331
  DEFINER`, a pinned `search_path`) ships as `migrations/0006_erase_person.sql`,
331
332
  sweeping `authz_events` and `invitations` the way Boule's
332
333
  `drizzle/0008_erasure_and_boot.sql` did. Boule's own function also scrubbed
@@ -346,6 +347,26 @@ to check simply does not call it, rather than this framework-neutral package
346
347
  reading `process.env` to decide the same thing for itself — and its
347
348
  `housekeeping` becomes a second structural port, `HousekeepingRegistry`.
348
349
 
350
+ 0.4.0 (authz#83 round 2, files#112): `loadAccess`'s credential-chain walk in
351
+ `src/policy-access.ts` now selects only `issuerId`, `issuerClass`, `expiresAt`
352
+ and `revokedAt` off `credentials`, instead of every column. files and ai
353
+ dropped `secret_hash`, `secret_prefix` and `keys_issued_id` on purpose, and a
354
+ plain `select()` there named them anyway, failing every service-credential
355
+ request with "column does not exist". `export.ts`, `person-records.ts` and
356
+ `credentials.ts` already select explicit column lists and were left as they
357
+ are: each genuinely needs the secret/keys column it names (an export row's
358
+ `secretPrefix`, a revoke's `keysIssuedId`, `credentialsFor`'s display
359
+ `secretPrefix`), so narrowing further would break them, not fix them.
360
+
361
+ 0.4.0 also renames the SQL function `migrations/0006_erase_person.sql`
362
+ creates, from `erase_person` to `authz_erase_person`. The old name collided
363
+ 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`
365
+ replaced a host's function outright, silently dropping that scrub. 0.3.0
366
+ published an hour before this fix and no persistent database had applied
367
+ 0006 yet, so this is a same-file rename, not a new numbered migration.
368
+ `src/erase.ts` calls `authz_erase_person`.
369
+
349
370
  The rest of the host layer has not moved — tracked in
350
371
  https://github.com/wtfalch/authz/issues/83.
351
372
 
@@ -361,6 +382,43 @@ the tables from the drizzle definitions. `src/migrate.test.ts` applies the
361
382
  shipped SQL instead and fails when the two disagree on a column, a type,
362
383
  nullability or an index name.
363
384
 
385
+ ## 0.4.0
386
+
387
+ `StoreBinding` (`src/binding.ts`) gains `hostEvents?: readonly string[]`: a host's own audit
388
+ event names (archon: `flag.changed`, `cms.published`, `key.rotated`; files/ai/integrations:
389
+ `mail.*`, `storage.*`, `integrations.*`), beyond `@wtfalch/authz`'s core events, so a host writes
390
+ its own events through the same `record`/`recordAs`/`writeServiceEvent` path core events use,
391
+ never a second one of its own (authz#83, archon#72). `definePolicy(data, binding)` accepts
392
+ `hostEvents` in `binding` and carries it onto the returned `StorePolicy`; a `hostEvents` name that
393
+ repeats a core event name is a configuration error and `definePolicy` throws. `recordAs` and
394
+ `writeServiceEvent` each gain an optional trailing `binding` parameter, since neither had one to
395
+ read `hostEvents` off before.
396
+
397
+ `EventInput.action` (on `record`, `recordAs` and `writeServiceEvent`) is now `EventName |
398
+ (string & {})`: core names still autocomplete, and any other string is accepted only when it is
399
+ listed in the binding's `hostEvents`; anything else throws before the row is built any further,
400
+ naming the action. **The store's own baseline `authz_events_action_check` constraint still lists
401
+ core events only** — this package's migrations are unchanged. A host that adopts `hostEvents` must
402
+ widen that CHECK in its own migration first (hosts that don't adopt keep their existing
403
+ constraint as-is).
404
+
405
+ 0.4.0 (authz#83 round 2) adds a `./policy` subpath export
406
+ (https://github.com/wtfalch/boule/pull/162): `definePolicy` plus `StorePolicy`,
407
+ `StoreBinding`, `PolicyData` and `PolicyRoleTemplate`, and nothing else, for a
408
+ client component that only needs the policy catalogue for permission labels.
409
+ 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
412
+ graph is proved free of any `node:` module, `drizzle-orm` or a database handle
413
+ by `scripts/tests/package-contract.test.mjs`. 0.4.0 also exports the five
414
+ membership row helpers from `src/membership-rows.ts` off the main entry —
415
+ `insertMembershipRow`, `deleteMembershipRow`, `changeRoleRow`,
416
+ `guardStaysHeldRow`, `membersOfRow` — so `@wtfalch/people` can call this
417
+ package's copy instead of carrying its own
418
+ (https://github.com/wtfalch/people/issues/16). No behaviour change: each
419
+ helper writes an audit row only when a `MembershipRowAuditOptions` writer is
420
+ passed, exactly as before.
421
+
364
422
  ## Migrations
365
423
 
366
424
  The package ships its own SQL in `migrations/`, and `migrateStore(db)` applies
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
@@ -8,6 +8,15 @@ import type { PolicyBinding } from './owners.js';
8
8
  */
9
9
  export interface StoreBinding extends PolicyBinding {
10
10
  readonly catalogue: ResourceCatalogue;
11
+ /**
12
+ * The host's own audit event names, beyond `@wtfalch/authz`'s core events (0.4.0). A host
13
+ * records its own events (archon: `flag.changed`, `cms.published`, `key.rotated`; files/ai/
14
+ * integrations: `mail.*`, `storage.*`, `integrations.*`) through the same `record`/`recordAs`/
15
+ * `writeServiceEvent` write path core events use, rather than a second one of its own. The
16
+ * store's own baseline `authz_events_action_check` constraint lists core events only; a host
17
+ * that lists names here must widen that check in its own migration.
18
+ */
19
+ readonly hostEvents?: readonly string[];
11
20
  }
12
21
  /**
13
22
  * The one lookup every moved module did as `catalogue[permission]`. A tiny helper rather than
package/dist/erase.d.ts CHANGED
@@ -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`,
@@ -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
@@ -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
@@ -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';
@@ -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 } 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';
package/dist/policy.d.ts CHANGED
@@ -57,8 +57,15 @@ 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[];
64
71
  }): 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,7 @@ export function definePolicy(data, binding) {
46
58
  applicationId: binding.applicationId,
47
59
  platformId: binding.platformId,
48
60
  catalogue,
61
+ hostEvents: binding.hostEvents,
49
62
  defaultCeiling: data.defaultCeiling,
50
63
  maxDepth: data.maxDepth,
51
64
  elevated: new Set(data.elevated),
@@ -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
  $$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/authz-store",
3
- "version": "0.3.0",
3
+ "version": "0.4.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,6 +13,10 @@
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": {