@wtfalch/authz-store 0.2.0 → 0.3.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.
Files changed (81) hide show
  1. package/README.md +335 -5
  2. package/dist/activations.d.ts +60 -0
  3. package/dist/activations.js +516 -0
  4. package/dist/alerts.d.ts +86 -0
  5. package/dist/alerts.js +132 -0
  6. package/dist/assignments.d.ts +71 -0
  7. package/dist/assignments.js +103 -0
  8. package/dist/audit.d.ts +122 -0
  9. package/dist/audit.js +194 -0
  10. package/dist/binding.d.ts +17 -0
  11. package/dist/binding.js +8 -0
  12. package/dist/boot.d.ts +54 -0
  13. package/dist/boot.js +143 -0
  14. package/dist/bootstrap.d.ts +28 -0
  15. package/dist/bootstrap.js +83 -0
  16. package/dist/break-glass.d.ts +61 -0
  17. package/dist/break-glass.js +247 -0
  18. package/dist/credentials.d.ts +94 -0
  19. package/dist/credentials.js +230 -0
  20. package/dist/denial.d.ts +9 -0
  21. package/dist/denial.js +49 -0
  22. package/dist/erase.d.ts +58 -0
  23. package/dist/erase.js +108 -0
  24. package/dist/events.d.ts +77 -0
  25. package/dist/events.js +107 -0
  26. package/dist/export.d.ts +161 -0
  27. package/dist/export.js +293 -0
  28. package/dist/index.d.ts +33 -1
  29. package/dist/index.js +33 -1
  30. package/dist/install-owner.d.ts +25 -0
  31. package/dist/install-owner.js +159 -0
  32. package/dist/invitations.d.ts +160 -0
  33. package/dist/invitations.js +685 -0
  34. package/dist/membership-rows.d.ts +206 -0
  35. package/dist/membership-rows.js +271 -0
  36. package/dist/memberships.d.ts +87 -0
  37. package/dist/memberships.js +272 -0
  38. package/dist/migrate.js +15 -4
  39. package/dist/nesting.d.ts +124 -0
  40. package/dist/nesting.js +515 -0
  41. package/dist/owners.d.ts +6 -1
  42. package/dist/owners.js +7 -3
  43. package/dist/person-records.d.ts +186 -0
  44. package/dist/person-records.js +263 -0
  45. package/dist/platform.d.ts +20 -0
  46. package/dist/platform.js +65 -0
  47. package/dist/policy-access.d.ts +260 -0
  48. package/dist/policy-access.js +348 -0
  49. package/dist/policy-resources.d.ts +5 -0
  50. package/dist/policy-resources.js +46 -0
  51. package/dist/policy-schema.d.ts +445 -0
  52. package/dist/policy-schema.js +63 -0
  53. package/dist/policy.d.ts +64 -0
  54. package/dist/policy.js +65 -0
  55. package/dist/propagate.d.ts +43 -0
  56. package/dist/propagate.js +47 -0
  57. package/dist/reconcile.d.ts +78 -0
  58. package/dist/reconcile.js +94 -0
  59. package/dist/resource-access.d.ts +344 -0
  60. package/dist/resource-access.js +656 -0
  61. package/dist/role-keys.d.ts +9 -0
  62. package/dist/role-keys.js +9 -0
  63. package/dist/roles.d.ts +36 -0
  64. package/dist/roles.js +191 -0
  65. package/dist/schema.d.ts +18 -1
  66. package/dist/schema.js +8 -1
  67. package/dist/startup.d.ts +57 -0
  68. package/dist/startup.js +113 -0
  69. package/dist/tenants.d.ts +213 -0
  70. package/dist/tenants.js +808 -0
  71. package/dist/tree-writes.d.ts +65 -0
  72. package/dist/tree-writes.js +201 -0
  73. package/dist/tree.d.ts +272 -0
  74. package/dist/tree.js +565 -0
  75. package/dist/types.d.ts +87 -0
  76. package/dist/types.js +15 -0
  77. package/migrations/0003_product_tenant_kind.sql +14 -0
  78. package/migrations/0004_credential_keys_issued_id.sql +33 -0
  79. package/migrations/0005_activations.sql +71 -0
  80. package/migrations/0006_erase_person.sql +134 -0
  81. package/package.json +9 -4
@@ -0,0 +1,94 @@
1
+ import { type CredentialKind } from '@wtfalch/authz';
2
+ import type { DbOrTx, Scope } from './scoped.js';
3
+ import { type Access, type Result } from './types.js';
4
+ /**
5
+ * What this store's issued credentials carry as `SignableRow.grants` (authz#95
6
+ * decision 2, moved from Boule's own `ManageKeyGrant`). Opaque to the Worker
7
+ * and to `@wtfalch/keys` itself -- only a host's own bearer-authentication path
8
+ * reads it.
9
+ *
10
+ * A grant carries only the id of the `credentials` row it was minted for.
11
+ * This store's own tenant, role, membership and assignment tables are re-read
12
+ * fresh on every request regardless of what that row says (`loadAccess`), so
13
+ * there is nothing about the row's own columns worth pinning into the signed
14
+ * grant -- only which row a presented secret is allowed to stand for at all.
15
+ */
16
+ export type StoreKeyGrant = {
17
+ readonly credentialId: string;
18
+ };
19
+ /**
20
+ * The store's own structural port over `@wtfalch/keys/issued`'s
21
+ * `CredentialIssuer<TGrant>`. `mintCredential` only ever calls `issue()` and
22
+ * `revokeCredential` only ever calls `revoke()` -- `check()`, `rotate()` and
23
+ * `lineageOf()` belong to a host's own bearer path and rotation console,
24
+ * neither of which lives here -- so the port names only the two, in exactly
25
+ * the shapes those two functions call them.
26
+ *
27
+ * A host builds a real one with `createCredentialIssuer` from
28
+ * `@wtfalch/keys/issued`; that package is a `devDependency` of this one
29
+ * (its type test, `credentials.test.ts`, is the only place this package
30
+ * imports it), never a runtime import here, so there is no default issuer to
31
+ * fall back to. Every caller must pass its own.
32
+ */
33
+ export interface CredentialIssuerPort<TGrant> {
34
+ issue(request: {
35
+ readonly grants: readonly TGrant[];
36
+ /** The id of the credential minting this one, or `null` for a root mint. */
37
+ readonly minter: string | null;
38
+ readonly expiresAt: number;
39
+ }): Promise<{
40
+ readonly ok: true;
41
+ readonly id: string;
42
+ readonly keyPrefix: string;
43
+ /** Shown exactly once; `null` on an idempotent replay. */
44
+ readonly secret: string | null;
45
+ } | {
46
+ readonly ok: false;
47
+ readonly reason: string;
48
+ }>;
49
+ revoke(id: string): Promise<unknown>;
50
+ }
51
+ export interface MintCredentialOptions {
52
+ readonly kind: CredentialKind;
53
+ readonly name: string;
54
+ readonly role: string;
55
+ readonly expiresAt?: Date;
56
+ }
57
+ export interface MintedCredential {
58
+ readonly id: string;
59
+ readonly secret: string;
60
+ readonly secretPrefix: string;
61
+ }
62
+ /**
63
+ * Issuer identity, expiry and scoped assignments are persisted together and
64
+ * rechecked on every request. `issuer` is required: this package mints no
65
+ * credential of its own accord, a host always supplies the one it built.
66
+ */
67
+ export declare function mintCredential(access: Access, db: DbOrTx, scopeColumn: Scope['column'], issuer: Pick<CredentialIssuerPort<StoreKeyGrant>, 'issue'>, options: MintCredentialOptions): Promise<Result<MintedCredential>>;
68
+ /** Every descendant loses authority through fresh lineage evaluation, including across inherited memberships. `issuer` is required, the same as `mintCredential`. */
69
+ export declare function revokeCredential(access: Access, db: DbOrTx, scopeColumn: Scope['column'], issuer: Pick<CredentialIssuerPort<StoreKeyGrant>, 'revoke'>, credentialId: string): Promise<Result<void>>;
70
+ export interface CredentialSummary {
71
+ readonly id: string;
72
+ readonly kind: string;
73
+ readonly name: string;
74
+ readonly secretPrefix: string | null;
75
+ readonly role: string | null;
76
+ readonly createdAt: Date;
77
+ readonly expiresAt: Date | null;
78
+ readonly revokedAt: Date | null;
79
+ readonly lastUsedAt: Date | null;
80
+ /** Who minted it: a person's display name, or -- when a credential minted
81
+ * this one -- that credential's own name. Never the raw `issuerId`. */
82
+ readonly issuer: string;
83
+ }
84
+ /**
85
+ * What a host must supply to name a human issuer. `credentialsFor` no longer
86
+ * joins a host's own `profiles` table directly (that table is the host's,
87
+ * not the store's); it batches every human `issuerId` on the page into one
88
+ * call instead. A host returns a person's display name, falling back to
89
+ * their email itself, the same fallback Boule's own join order gave.
90
+ */
91
+ export interface IssuerNames {
92
+ personNames(ids: readonly string[]): Promise<ReadonlyMap<string, string>>;
93
+ }
94
+ export declare function credentialsFor(access: Access, db: DbOrTx, names: IssuerNames): Promise<readonly CredentialSummary[] | null>;
@@ -0,0 +1,230 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { core } from '@wtfalch/authz';
3
+ import { and, desc, eq, sql } from 'drizzle-orm';
4
+ import { alias } from 'drizzle-orm/pg-core';
5
+ import { identityWhere, primaryAssignment, primaryRoleKey, roleAssignmentRefusal, writePrimaryAssignment, } from './assignments.js';
6
+ import { record } from './audit.js';
7
+ import { permits, permitsRead, refreshAccess } from './policy-access.js';
8
+ import { propagateMembershipChange, writerFromAccess } from './propagate.js';
9
+ import { isCustomRoleKey } from './role-keys.js';
10
+ import { roleDef } from './roles.js';
11
+ import { credentials, memberships } from './schema.js';
12
+ import { childrenOf } from './tree.js';
13
+ import { done, isUuid, refused, tenantTag } from './types.js';
14
+ /**
15
+ * `@wtfalch/keys/issued`'s `issue()` requires a concrete `expiresAt`, unlike
16
+ * this package's own pre-cutover scheme which stored `null` for "does not
17
+ * expire". A hundred years stands in for that: long enough that nothing
18
+ * operational treats it differently from "never", and far enough out that it
19
+ * is never mistaken for a deliberately-chosen near expiry.
20
+ */
21
+ const NO_EXPIRY_MS = 100 * 365 * 24 * 60 * 60 * 1000;
22
+ /**
23
+ * Issuer identity, expiry and scoped assignments are persisted together and
24
+ * rechecked on every request. `issuer` is required: this package mints no
25
+ * credential of its own accord, a host always supplies the one it built.
26
+ */
27
+ export async function mintCredential(access, db, scopeColumn, issuer, options) {
28
+ if (access.context === 'break_glass')
29
+ return refused('break_glass', 'Support sessions cannot create credentials.');
30
+ if (!core.credentialKinds.includes(options.kind))
31
+ return refused('unknown_kind', 'Unknown credential kind.');
32
+ const name = options.name.trim();
33
+ if (!name || name.length > 120)
34
+ return refused('invalid_name', 'Use a name of 1–120 characters.');
35
+ if (options.expiresAt &&
36
+ (!Number.isFinite(options.expiresAt.getTime()) || options.expiresAt.getTime() <= Date.now()))
37
+ return refused('invalid_expiry', 'Choose a future expiry.');
38
+ return db.transaction(async (tx) => {
39
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
40
+ if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'credentials:create'))
41
+ return refused('no_permission', 'You cannot create credentials here.');
42
+ const role = await roleDef(tx, fresh.binding, fresh.tenant, options.role);
43
+ if (!role)
44
+ return refused('unknown_role', 'This role no longer exists.');
45
+ // Not `!role.builtIn`: a starter role (ADR 0016) is `built_in: false` for
46
+ // a tenant created after that change but is still app-defined, not
47
+ // tenant-authored, so it can still cross organisation boundaries.
48
+ if (isCustomRoleKey(role.key) && (await childrenOf(tx, fresh.tenant.id)).length)
49
+ return refused('custom_role_with_children', 'Custom roles cannot be inherited by another organisation.');
50
+ const id = randomUUID();
51
+ const principal = { id, class: options.kind };
52
+ let expiresAt = options.expiresAt ?? new Date(Date.now() + NO_EXPIRY_MS);
53
+ if (fresh.actor.class !== 'human') {
54
+ const [issuerRow] = await tx
55
+ .select({ expiresAt: credentials.expiresAt })
56
+ .from(credentials)
57
+ .where(and(eq(credentials.id, fresh.actor.id), eq(credentials.kind, fresh.actor.class)));
58
+ if (!issuerRow)
59
+ return refused('issuer_missing', 'The issuing credential no longer exists.');
60
+ if (issuerRow.expiresAt && issuerRow.expiresAt.getTime() < expiresAt.getTime())
61
+ expiresAt = issuerRow.expiresAt;
62
+ }
63
+ const reason = roleAssignmentRefusal(fresh, role, primaryAssignment(role, principal, expiresAt.getTime()), 'members:grant');
64
+ if (reason)
65
+ return refused(reason, 'This credential would exceed your delegation authority.');
66
+ // `minter: null` always: this row's own issuerId/issuerClass columns
67
+ // already carry this credential's lineage, and `@wtfalch/keys/issued`'s
68
+ // own minter chain buys nothing on top for a store that re-checks
69
+ // tenant/role/membership fresh on every request anyway (`loadAccess`).
70
+ const issued = await issuer.issue({
71
+ grants: [{ credentialId: id }],
72
+ minter: null,
73
+ expiresAt: expiresAt.getTime(),
74
+ });
75
+ if (!issued.ok || !issued.secret) {
76
+ return refused('mint_failed', 'Could not mint this credential right now.');
77
+ }
78
+ await tx.insert(credentials).values({
79
+ id,
80
+ tenantId: fresh.tenant.id,
81
+ kind: options.kind,
82
+ name,
83
+ secretPrefix: issued.keyPrefix,
84
+ keysIssuedId: issued.id,
85
+ createdBy: fresh.actor.id,
86
+ issuerId: fresh.actor.id,
87
+ issuerClass: fresh.actor.class,
88
+ expiresAt,
89
+ });
90
+ await tx.insert(memberships).values({
91
+ tenantId: fresh.tenant.id,
92
+ principalId: id,
93
+ principalClass: options.kind,
94
+ source: 'direct',
95
+ tenantName: fresh.tenant.name,
96
+ grantedBy: fresh.actor.id,
97
+ });
98
+ await writePrimaryAssignment(tx, fresh.binding, role, principal, {
99
+ createdBy: fresh.actor.id,
100
+ expiresAt,
101
+ });
102
+ await record(tx, fresh, {
103
+ action: 'credential.minted',
104
+ tenantId: fresh.tenant.id,
105
+ targetType: 'credential',
106
+ targetId: id,
107
+ after: {
108
+ kind: options.kind,
109
+ name,
110
+ roleId: role.id,
111
+ issuer: { id: fresh.actor.id, class: fresh.actor.class },
112
+ expiresAt: expiresAt.toISOString(),
113
+ },
114
+ });
115
+ const tags = await propagateMembershipChange(tx, fresh.binding, writerFromAccess(fresh), fresh.tenant.id, id);
116
+ return done({ id, secret: issued.secret, secretPrefix: issued.keyPrefix }, [
117
+ tenantTag(fresh.tenant.id),
118
+ ...tags,
119
+ ]);
120
+ });
121
+ }
122
+ /** Every descendant loses authority through fresh lineage evaluation, including across inherited memberships. `issuer` is required, the same as `mintCredential`. */
123
+ export async function revokeCredential(access, db, scopeColumn, issuer, credentialId) {
124
+ if (access.context === 'break_glass')
125
+ return refused('break_glass', 'Support sessions cannot revoke credentials.');
126
+ if (!isUuid(credentialId))
127
+ return refused('credential_not_found', 'This credential does not exist.');
128
+ let keysIssuedId = null;
129
+ const result = await db.transaction(async (tx) => {
130
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
131
+ if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'credentials:revoke'))
132
+ return refused('no_permission', 'You cannot revoke credentials here.');
133
+ const [row] = await tx
134
+ .select({
135
+ id: credentials.id,
136
+ kind: credentials.kind,
137
+ revokedAt: credentials.revokedAt,
138
+ keysIssuedId: credentials.keysIssuedId,
139
+ })
140
+ .from(credentials)
141
+ .where(and(eq(credentials.id, credentialId), eq(credentials.tenantId, fresh.tenant.id)));
142
+ if (!row)
143
+ return refused('credential_not_found', 'This credential does not belong here.');
144
+ if (row.revokedAt)
145
+ return refused('already_revoked', 'This credential was already revoked.');
146
+ await tx.update(credentials).set({ revokedAt: new Date() }).where(eq(credentials.id, row.id));
147
+ keysIssuedId = row.keysIssuedId;
148
+ await tx
149
+ .delete(memberships)
150
+ .where(identityWhere(fresh.tenant.id, { id: row.id, class: row.kind }));
151
+ await record(tx, fresh, {
152
+ action: 'credential.revoked',
153
+ tenantId: fresh.tenant.id,
154
+ targetType: 'credential',
155
+ targetId: row.id,
156
+ });
157
+ const tags = await propagateMembershipChange(tx, fresh.binding, writerFromAccess(fresh), fresh.tenant.id, row.id);
158
+ return done(undefined, [tenantTag(fresh.tenant.id), ...tags]);
159
+ });
160
+ // After the transaction commits, not inside it: `issuer.revoke()` opens its
161
+ // own transaction against a host's shared `db` handle, a different
162
+ // connection than `tx` above, so nesting it inside `tx` would let it commit
163
+ // a keys-side revoke that this transaction could still roll back.
164
+ // `credentials.revoked_at` is what a host's own bearer path actually gates
165
+ // on, so a crash between the two leaves this credential refused either way
166
+ // -- never leaves it live. Null on a pre-cutover row, which has nothing in
167
+ // `keys_issued_credentials` to revoke. A failure here is logged, not
168
+ // thrown: the revoke above has committed, so the caller's answer is
169
+ // already true.
170
+ if (result.ok && keysIssuedId) {
171
+ try {
172
+ await issuer.revoke(keysIssuedId);
173
+ }
174
+ catch (error) {
175
+ console.error('[credentials] keys-side revoke failed; the credential is refused', error);
176
+ }
177
+ }
178
+ return result;
179
+ }
180
+ /**
181
+ * A second name for `credentials`, so a row's own issuer -- when the issuer
182
+ * is itself a credential rather than a person -- can be joined without
183
+ * colliding with the row it names.
184
+ */
185
+ const issuerCredentials = alias(credentials, 'issuer_credentials');
186
+ export async function credentialsFor(access, db, names) {
187
+ if (!(await permitsRead(access, db, 'credentials:read')))
188
+ return null;
189
+ const rows = await db
190
+ .select({
191
+ id: credentials.id,
192
+ kind: credentials.kind,
193
+ name: credentials.name,
194
+ secretPrefix: credentials.secretPrefix,
195
+ createdAt: credentials.createdAt,
196
+ expiresAt: credentials.expiresAt,
197
+ revokedAt: credentials.revokedAt,
198
+ lastUsedAt: credentials.lastUsedAt,
199
+ role: sql `case when ${memberships.principalId} is null then null else ${primaryRoleKey(access.binding)} end`,
200
+ issuerId: credentials.issuerId,
201
+ issuerClass: credentials.issuerClass,
202
+ // Exactly one of these two resolves, depending on `issuerClass`: a
203
+ // human issuer's id (looked up through `names` below) or the
204
+ // credential that minted this one. Neither can match the other's row
205
+ // (`issuerClass` picks one path), so there is never a fan-out to
206
+ // coalesce away.
207
+ issuerCredentialName: issuerCredentials.name,
208
+ })
209
+ .from(credentials)
210
+ .leftJoin(memberships, and(eq(memberships.tenantId, credentials.tenantId), eq(memberships.principalClass, credentials.kind), sql `${memberships.principalId}=${credentials.id}::text`))
211
+ // `credentials.id` is `uuid`; `issuerId` is `text` because it also has to
212
+ // hold a human's string subject id. Casting the uuid side to text, not
213
+ // the other way round, is the same direction the `memberships` join
214
+ // above already takes.
215
+ .leftJoin(issuerCredentials, and(eq(credentials.issuerClass, issuerCredentials.kind), sql `${issuerCredentials.id}::text = ${credentials.issuerId}`))
216
+ .where(eq(credentials.tenantId, access.tenant.id))
217
+ .orderBy(desc(credentials.createdAt));
218
+ const humanIssuerIds = [
219
+ ...new Set(rows.filter((r) => r.issuerClass === 'human').map((r) => r.issuerId)),
220
+ ];
221
+ const personName = humanIssuerIds.length
222
+ ? await names.personNames(humanIssuerIds)
223
+ : new Map();
224
+ return rows.map(({ issuerId, issuerClass, issuerCredentialName, ...r }) => ({
225
+ ...r,
226
+ issuer: (issuerClass === 'human' ? personName.get(issuerId) : undefined) ??
227
+ issuerCredentialName ??
228
+ 'An issuer no longer on record',
229
+ }));
230
+ }
@@ -0,0 +1,9 @@
1
+ import type { ResourceDenial } from '@wtfalch/authz';
2
+ import type { Access } from './types.js';
3
+ /**
4
+ * Only `explain` moves here. The host's `DENIAL_EVENTS` table maps a permission id to one core
5
+ * event name, keyed to that one host's own permission ids — a table like that belongs to the
6
+ * host that defines the permissions, not to a package with no catalogue of its own — so it stays
7
+ * in the hosts.
8
+ */
9
+ export declare function explain(source: ResourceDenial | null, permission: string, access: Access): string;
package/dist/denial.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Only `explain` moves here. The host's `DENIAL_EVENTS` table maps a permission id to one core
3
+ * event name, keyed to that one host's own permission ids — a table like that belongs to the
4
+ * host that defines the permissions, not to a package with no catalogue of its own — so it stays
5
+ * in the hosts.
6
+ */
7
+ export function explain(source, permission, access) {
8
+ const operation = access.binding.catalogue[permission]?.label ?? permission;
9
+ if (source === null)
10
+ return `You may ${operation}.`;
11
+ switch (source) {
12
+ case 'state':
13
+ return `This organisation is ${access.tenant.state}.`;
14
+ case 'ceiling':
15
+ return `This organisation has not been offered ${operation}.`;
16
+ case 'self_denied':
17
+ return `This organisation has disabled ${operation}.`;
18
+ case 'session':
19
+ return `${operation} is unavailable in this support session.`;
20
+ case 'frozen':
21
+ return `${operation} is unavailable while the platform is frozen.`;
22
+ case 'depth':
23
+ return `${operation} can only be held directly by a person, never delegated.`;
24
+ case 'cosign':
25
+ return `${operation} needs another person's approval before it takes effect.`;
26
+ case 'budget':
27
+ return `${operation} has reached its limit for this period.`;
28
+ case 'purpose':
29
+ return `${operation} needs a stated purpose that matches what this grant allows.`;
30
+ case 'conditions':
31
+ return `${operation} does not meet the conditions this grant requires.`;
32
+ case 'boundary':
33
+ return `${operation} does not reach this organisation.`;
34
+ case 'kind':
35
+ return `${operation} does not apply to this kind of organisation.`;
36
+ case 'denied':
37
+ return `${operation} has been explicitly denied here.`;
38
+ case 'unknown':
39
+ return `${operation} is not a recognised operation.`;
40
+ case 'grant':
41
+ return `Your current assignments do not allow ${operation} here.`;
42
+ case 'guest':
43
+ return `${operation} is not available to a guest.`;
44
+ default: {
45
+ const exhaustive = source;
46
+ return exhaustive;
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,58 @@
1
+ import type { DbOrTx, Scope } from './scoped.js';
2
+ import type { Access, Result } from './types.js';
3
+ /**
4
+ * Stable and derived from the subject id, never random and never a counter:
5
+ * the same person has to read the same way across every row of theirs and
6
+ * across two separate runs of this function, or the log stops being
7
+ * followable at exactly the moment somebody needs to follow it. sha256 of
8
+ * the id, truncated to 16 hex characters purely to keep the column short; a
9
+ * truncated cryptographic hash still makes finding a second id that collides
10
+ * with it infeasible.
11
+ */
12
+ export declare function pseudonymFor(subjectId: string): string;
13
+ export interface ErasePersonResult {
14
+ /** How many `authz_events` rows the sweep touched. Zero on a second call: `erased_at` is already set, so nothing matches. */
15
+ readonly rowsErased: number;
16
+ }
17
+ /**
18
+ * What a host must supply about the person being erased and its own
19
+ * personal-data tables. `profiles` and `reporting_erase_person` were
20
+ * Boule's own reads and writes against tables this package does not own;
21
+ * this port replaces both. `emailOf` replaces the `profiles` read (the
22
+ * email is what almost every audit payload carrying this person's data
23
+ * actually holds, and most of those rows were written by somebody else, so
24
+ * the sweep needs the address to reach rows that are ABOUT this person
25
+ * rather than only rows written BY them). `eraseHostRecords` replaces both
26
+ * the `profiles` scrub and the analytics erase: person rows go, rollups
27
+ * carry no identifier and stay.
28
+ */
29
+ export interface ErasureHost {
30
+ emailOf(tx: DbOrTx, principalId: string): Promise<string | null>;
31
+ eraseHostRecords(tx: DbOrTx, principalId: string, pseudonym: string): Promise<void>;
32
+ }
33
+ /**
34
+ * Erases a person from the record: `actor_id` and every row survive, so
35
+ * "someone with this id did this" still holds; `actor_display` and the
36
+ * personal fields inside `before`/`after` become a stable pseudonym, and
37
+ * whatever personal fields the host's own tables carry are scrubbed the same
38
+ * way through `host.eraseHostRecords` (D9, X6 M3).
39
+ *
40
+ * Gated on the operator tenant's own `people:erase`: operator scope,
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
43
+ * own permission check first, which is what a host wires up to write the
44
+ * denied `person.erased` row on a refusal; this function's own checks are
45
+ * the second net, the same as `installOwner`'s.
46
+ *
47
+ * Refused under a break-glass context (D8): no session ever changes the
48
+ * record of what happened, and rewriting it is exactly that.
49
+ *
50
+ * One transaction, in this order and never the other way round: the sweep
51
+ * through `erase_person` and `host.eraseHostRecords` first, then the
52
+ * `person.erased` audit row. Writing the event first would make it a
53
+ * candidate for its own sweep the moment the erased subject is the
54
+ * operator's own id (self-erasure), since the sweep matches on `actor_id`,
55
+ * and the erasure would then have no record of itself. Written after, it is
56
+ * never touched by the call that just ran.
57
+ */
58
+ export declare function erasePerson(operatorAccess: Access, db: DbOrTx, scopeColumn: Scope['column'], principalId: string, host: ErasureHost): Promise<Result<ErasePersonResult>>;
package/dist/erase.js ADDED
@@ -0,0 +1,108 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { sql } from 'drizzle-orm';
3
+ import { record } from './audit.js';
4
+ import { permitsPlatform, refreshAccess } from './policy-access.js';
5
+ import { done, refused } from './types.js';
6
+ /**
7
+ * The single sanctioned write to `authz_events` (D9, X6 M3, Tests 29).
8
+ *
9
+ * `migrations/0006_erase_person.sql`'s `erase_person` is a `SECURITY
10
+ * DEFINER` function owned by the migration role: the runtime role has no
11
+ * UPDATE on `authz_events` at all (0001's grants), and a trigger refuses any
12
+ * UPDATE on that table touching a column outside `actor_display`, `before`,
13
+ * `after` and `erased_at` (0001). The function passes the first wall
14
+ * because it runs with the owner's privileges, and it passes the second by
15
+ * construction, touching only those four columns, never around the trigger.
16
+ * This module is the only caller; a host wires it up so that nothing else
17
+ * in the app may erase a row, because nothing else holds `people:erase`.
18
+ *
19
+ * This file takes the host's raw handle the same way `install-owner.ts`
20
+ * does: `erasePerson` has no tenant to be scoped to (a person's audit rows
21
+ * span every tenant they ever acted in) and opens the one transaction it
22
+ * runs in itself.
23
+ */
24
+ /**
25
+ * Non-secret prefix on every erasure pseudonym, so a reader recognises one on
26
+ * sight without the prefix itself revealing anything about the id it
27
+ * replaced.
28
+ */
29
+ const PSEUDONYM_PREFIX = 'erased-person-';
30
+ /**
31
+ * Stable and derived from the subject id, never random and never a counter:
32
+ * the same person has to read the same way across every row of theirs and
33
+ * across two separate runs of this function, or the log stops being
34
+ * followable at exactly the moment somebody needs to follow it. sha256 of
35
+ * the id, truncated to 16 hex characters purely to keep the column short; a
36
+ * truncated cryptographic hash still makes finding a second id that collides
37
+ * with it infeasible.
38
+ */
39
+ export function pseudonymFor(subjectId) {
40
+ const digest = createHash('sha256').update(subjectId).digest('hex').slice(0, 16);
41
+ return `${PSEUDONYM_PREFIX}${digest}`;
42
+ }
43
+ /** postgres-js returns the rows as the result; node-postgres and PGlite wrap them in `rows` (migrate.ts's own `rowsOf`, one raw call). */
44
+ function rowsOf(result) {
45
+ return Array.isArray(result) ? result : (result.rows ?? []);
46
+ }
47
+ /**
48
+ * Erases a person from the record: `actor_id` and every row survive, so
49
+ * "someone with this id did this" still holds; `actor_display` and the
50
+ * personal fields inside `before`/`after` become a stable pseudonym, and
51
+ * whatever personal fields the host's own tables carry are scrubbed the same
52
+ * way through `host.eraseHostRecords` (D9, X6 M3).
53
+ *
54
+ * Gated on the operator tenant's own `people:erase`: operator scope,
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
57
+ * own permission check first, which is what a host wires up to write the
58
+ * denied `person.erased` row on a refusal; this function's own checks are
59
+ * the second net, the same as `installOwner`'s.
60
+ *
61
+ * Refused under a break-glass context (D8): no session ever changes the
62
+ * record of what happened, and rewriting it is exactly that.
63
+ *
64
+ * One transaction, in this order and never the other way round: the sweep
65
+ * through `erase_person` and `host.eraseHostRecords` first, then the
66
+ * `person.erased` audit row. Writing the event first would make it a
67
+ * candidate for its own sweep the moment the erased subject is the
68
+ * operator's own id (self-erasure), since the sweep matches on `actor_id`,
69
+ * and the erasure would then have no record of itself. Written after, it is
70
+ * never touched by the call that just ran.
71
+ */
72
+ export async function erasePerson(operatorAccess, db, scopeColumn, principalId, host) {
73
+ if (operatorAccess.context === 'break_glass') {
74
+ return refused('break_glass', 'A support session never erases a person.');
75
+ }
76
+ if (operatorAccess.tenant.kind !== 'operator' ||
77
+ !permitsPlatform(operatorAccess, 'people:erase')) {
78
+ return refused('no_permission', 'You do not have permission to erase a person.');
79
+ }
80
+ if (principalId.length === 0) {
81
+ return refused('invalid_principal', 'A person id is required.');
82
+ }
83
+ const pseudonym = pseudonymFor(principalId);
84
+ return db.transaction(async (tx) => {
85
+ const fresh = await refreshAccess(tx, db, scopeColumn, operatorAccess);
86
+ if (!fresh || fresh.context !== 'operator' || !permitsPlatform(fresh, 'people:erase'))
87
+ return refused('no_permission', 'Your authority changed.');
88
+ const currentAccess = fresh;
89
+ // Read before the sweep clears it; see the `ErasureHost` doc for why
90
+ // this needs the address rather than only the actor id.
91
+ const rawEmail = await host.emailOf(tx, principalId);
92
+ const subjectEmail = rawEmail?.trim().toLowerCase() ?? null;
93
+ const result = await tx.execute(sql `select erase_person(${principalId}, ${pseudonym}, ${subjectEmail}) as touched`);
94
+ const [row] = rowsOf(result);
95
+ const rowsErased = Number(row?.touched ?? 0);
96
+ // The host's own personal-data tables: `profiles` and whatever an
97
+ // analytics package tracks. Called here so it commits with the rest.
98
+ await host.eraseHostRecords(tx, principalId, pseudonym);
99
+ await record(tx, currentAccess, {
100
+ action: 'person.erased',
101
+ tenantId: null,
102
+ targetType: 'person',
103
+ targetId: principalId,
104
+ after: { pseudonym },
105
+ });
106
+ return done({ rowsErased }, []);
107
+ });
108
+ }
@@ -0,0 +1,77 @@
1
+ import type { DbOrTx } from './scoped.js';
2
+ import type { Access } from './types.js';
3
+ /**
4
+ * Reading the audit trail back.
5
+ *
6
+ * Newest first, keyset-paged on `(occurred_at, id)` descending, which is
7
+ * exactly `authz_events_tenant_time_idx`. Offset paging would re-scan the
8
+ * whole prefix on every page of a table that only grows, and this one is
9
+ * appended to by every grant, revoke, state change and break-glass session
10
+ * in the app.
11
+ */
12
+ /** One row of the trail, without its payload. See `EventsForOptions.tenantId` for the scope. */
13
+ export interface EventSummary {
14
+ readonly id: number;
15
+ readonly occurredAt: Date;
16
+ readonly tenantId: string | null;
17
+ readonly actorClass: string;
18
+ readonly actorId: string;
19
+ readonly actorDisplay: string;
20
+ readonly action: string;
21
+ readonly targetType: string;
22
+ readonly targetId: string;
23
+ readonly outcome: string;
24
+ readonly context: string;
25
+ readonly reason: string | null;
26
+ readonly reference: string | null;
27
+ /**
28
+ * `erased_at` is set, so `before` and `after` were nulled by
29
+ * `erasePerson` and the row is a tombstone with its shape intact. Said
30
+ * plainly rather than shown as an empty change, because "nothing changed"
31
+ * and "we no longer keep what changed" are different facts and a reader
32
+ * who cannot tell them apart mis-reads the trail.
33
+ */
34
+ readonly erased: boolean;
35
+ }
36
+ export interface EventPage {
37
+ readonly items: readonly EventSummary[];
38
+ readonly next: {
39
+ readonly occurredAt: Date;
40
+ readonly id: number;
41
+ } | null;
42
+ }
43
+ export interface EventsForOptions {
44
+ /**
45
+ * One tenant's rows. Omitted means this `Access`'s own tenant, unless the
46
+ * actor holds `audit:read-all`, in which case it means every tenant — the
47
+ * operator console's view of the estate.
48
+ */
49
+ readonly tenantId?: string;
50
+ readonly actorId?: string;
51
+ readonly action?: string;
52
+ readonly after?: {
53
+ readonly occurredAt: Date;
54
+ readonly id: number;
55
+ };
56
+ readonly limit?: number;
57
+ }
58
+ /**
59
+ * One page of the trail, or `null` when this `Access` may not read it.
60
+ *
61
+ * **`null`, never an empty page**, for the reason `membersOf` gives: a caller
62
+ * that cannot tell "you may not see this" from "there is nothing to see"
63
+ * draws a false conclusion, and here the false conclusion is "nothing has
64
+ * happened in this organisation", which is the opposite of what an audit
65
+ * surface exists to say.
66
+ *
67
+ * Two permissions, and the difference is not cosmetic. `audit:read` reads
68
+ * one's own tenant. `audit:read-all` reads every tenant, and only it sees
69
+ * rows with `tenant_visible = false` — the operator-only rows, which is where
70
+ * a break-glass session into somebody's organisation is recorded. A tenant
71
+ * admin reading their own log must not be shown those, or the support session
72
+ * they were never told about becomes visible in a list they can already open.
73
+ * So the flag is applied here, not left to the page: a page that forgot it
74
+ * would leak by omission, and this is the only place that knows both the
75
+ * actor's permissions and the row.
76
+ */
77
+ export declare function eventsFor(access: Access, db: DbOrTx, options?: EventsForOptions): Promise<EventPage | null>;