@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.
- package/README.md +335 -5
- package/dist/activations.d.ts +60 -0
- package/dist/activations.js +516 -0
- package/dist/alerts.d.ts +86 -0
- package/dist/alerts.js +132 -0
- package/dist/assignments.d.ts +71 -0
- package/dist/assignments.js +103 -0
- package/dist/audit.d.ts +122 -0
- package/dist/audit.js +194 -0
- package/dist/binding.d.ts +17 -0
- package/dist/binding.js +8 -0
- package/dist/boot.d.ts +54 -0
- package/dist/boot.js +143 -0
- package/dist/bootstrap.d.ts +28 -0
- package/dist/bootstrap.js +83 -0
- package/dist/break-glass.d.ts +61 -0
- package/dist/break-glass.js +247 -0
- package/dist/credentials.d.ts +94 -0
- package/dist/credentials.js +230 -0
- package/dist/denial.d.ts +9 -0
- package/dist/denial.js +49 -0
- package/dist/erase.d.ts +58 -0
- package/dist/erase.js +108 -0
- package/dist/events.d.ts +77 -0
- package/dist/events.js +107 -0
- package/dist/export.d.ts +161 -0
- package/dist/export.js +293 -0
- package/dist/index.d.ts +33 -1
- package/dist/index.js +33 -1
- package/dist/install-owner.d.ts +25 -0
- package/dist/install-owner.js +159 -0
- package/dist/invitations.d.ts +160 -0
- package/dist/invitations.js +685 -0
- package/dist/membership-rows.d.ts +206 -0
- package/dist/membership-rows.js +271 -0
- package/dist/memberships.d.ts +87 -0
- package/dist/memberships.js +272 -0
- package/dist/migrate.js +15 -4
- package/dist/nesting.d.ts +124 -0
- package/dist/nesting.js +515 -0
- package/dist/owners.d.ts +6 -1
- package/dist/owners.js +7 -3
- package/dist/person-records.d.ts +186 -0
- package/dist/person-records.js +263 -0
- package/dist/platform.d.ts +20 -0
- package/dist/platform.js +65 -0
- package/dist/policy-access.d.ts +260 -0
- package/dist/policy-access.js +348 -0
- package/dist/policy-resources.d.ts +5 -0
- package/dist/policy-resources.js +46 -0
- package/dist/policy-schema.d.ts +445 -0
- package/dist/policy-schema.js +63 -0
- package/dist/policy.d.ts +64 -0
- package/dist/policy.js +65 -0
- package/dist/propagate.d.ts +43 -0
- package/dist/propagate.js +47 -0
- package/dist/reconcile.d.ts +78 -0
- package/dist/reconcile.js +94 -0
- package/dist/resource-access.d.ts +344 -0
- package/dist/resource-access.js +656 -0
- package/dist/role-keys.d.ts +9 -0
- package/dist/role-keys.js +9 -0
- package/dist/roles.d.ts +36 -0
- package/dist/roles.js +191 -0
- package/dist/schema.d.ts +18 -1
- package/dist/schema.js +8 -1
- package/dist/startup.d.ts +57 -0
- package/dist/startup.js +113 -0
- package/dist/tenants.d.ts +213 -0
- package/dist/tenants.js +808 -0
- package/dist/tree-writes.d.ts +65 -0
- package/dist/tree-writes.js +201 -0
- package/dist/tree.d.ts +272 -0
- package/dist/tree.js +565 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.js +15 -0
- package/migrations/0003_product_tenant_kind.sql +14 -0
- package/migrations/0004_credential_keys_issued_id.sql +33 -0
- package/migrations/0005_activations.sql +71 -0
- package/migrations/0006_erase_person.sql +134 -0
- 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
|
+
}
|
package/dist/denial.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/erase.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/events.d.ts
ADDED
|
@@ -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>;
|