@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
package/dist/events.js ADDED
@@ -0,0 +1,107 @@
1
+ import { and, desc, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm';
2
+ import { record } from './audit.js';
3
+ import { auditedReadGate, permitsPlatform } from './policy-access.js';
4
+ import { authzEvents } from './schema.js';
5
+ /**
6
+ * One page of the trail, or `null` when this `Access` may not read it.
7
+ *
8
+ * **`null`, never an empty page**, for the reason `membersOf` gives: a caller
9
+ * that cannot tell "you may not see this" from "there is nothing to see"
10
+ * draws a false conclusion, and here the false conclusion is "nothing has
11
+ * happened in this organisation", which is the opposite of what an audit
12
+ * surface exists to say.
13
+ *
14
+ * Two permissions, and the difference is not cosmetic. `audit:read` reads
15
+ * one's own tenant. `audit:read-all` reads every tenant, and only it sees
16
+ * rows with `tenant_visible = false` — the operator-only rows, which is where
17
+ * a break-glass session into somebody's organisation is recorded. A tenant
18
+ * admin reading their own log must not be shown those, or the support session
19
+ * they were never told about becomes visible in a list they can already open.
20
+ * So the flag is applied here, not left to the page: a page that forgot it
21
+ * would leak by omission, and this is the only place that knows both the
22
+ * actor's permissions and the row.
23
+ */
24
+ export async function eventsFor(access, db, options = {}) {
25
+ const all = permitsPlatform(access, 'platform.audit:read');
26
+ const clauses = access.authority
27
+ .clauses('platform.audit:read')
28
+ .filter((c) => c.applicationId === access.binding.applicationId &&
29
+ c.platformId === access.binding.platformId &&
30
+ c.resourceType === 'audit' &&
31
+ c.relation === 'any');
32
+ if (!(await auditedReadGate(access, db, 'platform.audit:read', clauses.length > 0)))
33
+ return null;
34
+ // A named tenant that is not one's own is an estate-wide read whatever the
35
+ // page thought it was asking for.
36
+ const scope = options.tenantId ?? (all ? null : access.tenant.id);
37
+ if (scope !== null && scope !== access.tenant.id && !all)
38
+ return null;
39
+ const limit = Math.min(options.limit ?? 50, 200);
40
+ const conditions = [
41
+ or(...clauses.map((c) => and(c.organisationId === null
42
+ ? isNull(authzEvents.tenantId)
43
+ : eq(authzEvents.tenantId, c.organisationId), c.teamIds === null
44
+ ? sql `true`
45
+ : c.teamIds.length
46
+ ? inArray(authzEvents.teamId, [...c.teamIds])
47
+ : sql `false`, c.resourceId === undefined
48
+ ? sql `true`
49
+ : /^\d{1,15}$/.test(c.resourceId)
50
+ ? eq(authzEvents.id, Number(c.resourceId))
51
+ : sql `false`))) ?? sql `false`,
52
+ ];
53
+ if (scope !== null)
54
+ conditions.push(eq(authzEvents.tenantId, scope));
55
+ if (!all)
56
+ conditions.push(eq(authzEvents.tenantVisible, true));
57
+ if (options.actorId)
58
+ conditions.push(eq(authzEvents.actorId, options.actorId));
59
+ if (options.action)
60
+ conditions.push(eq(authzEvents.action, options.action));
61
+ if (options.after) {
62
+ const { occurredAt, id } = options.after;
63
+ // Strictly older than the cursor. `id` breaks the tie because two rows
64
+ // can share a timestamp — `now()` is per-statement, so one transaction
65
+ // writing a membership and its event twice lands both in the same
66
+ // microsecond, and a cursor on time alone would skip or repeat one.
67
+ const keyset = or(lt(authzEvents.occurredAt, occurredAt), and(eq(authzEvents.occurredAt, occurredAt), lt(authzEvents.id, id)));
68
+ if (keyset)
69
+ conditions.push(keyset);
70
+ }
71
+ const rows = await db
72
+ .select({
73
+ id: authzEvents.id,
74
+ occurredAt: authzEvents.occurredAt,
75
+ tenantId: authzEvents.tenantId,
76
+ actorClass: authzEvents.actorClass,
77
+ actorId: authzEvents.actorId,
78
+ actorDisplay: authzEvents.actorDisplay,
79
+ action: authzEvents.action,
80
+ targetType: authzEvents.targetType,
81
+ targetId: authzEvents.targetId,
82
+ outcome: authzEvents.outcome,
83
+ context: authzEvents.context,
84
+ reason: authzEvents.reason,
85
+ reference: authzEvents.reference,
86
+ erasedAt: authzEvents.erasedAt,
87
+ })
88
+ .from(authzEvents)
89
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
90
+ .orderBy(desc(authzEvents.occurredAt), desc(authzEvents.id))
91
+ .limit(limit + 1);
92
+ const page = rows.slice(0, limit);
93
+ const last = page.at(-1);
94
+ const next = rows.length > limit && last ? { occurredAt: last.occurredAt, id: last.id } : null;
95
+ // A read of the audit trail is itself audited (0.9.0), like `securityLogFor`;
96
+ // an estate-wide read is a platform row.
97
+ await record(db, access, {
98
+ action: 'audit_log.viewed',
99
+ tenantId: scope,
100
+ targetType: 'audit',
101
+ targetId: scope ?? 'platform',
102
+ });
103
+ return {
104
+ items: page.map(({ erasedAt, ...row }) => ({ ...row, erased: erasedAt !== null })),
105
+ next,
106
+ };
107
+ }
@@ -0,0 +1,161 @@
1
+ import { type ActorClass } from '@wtfalch/authz';
2
+ import { policyAssignments, policyRoles } from './policy-schema.js';
3
+ import { accessTeamMembers, accessTeams } from './resource-schema.js';
4
+ import type { DbOrTx } from './scoped.js';
5
+ /**
6
+ * Who is exporting, for the `tenant.exported` row `exportTenant` writes on
7
+ * success. Not an `Access`: a caller with no signed-in session (a script
8
+ * running as its own service identity) has no `Access` to build one from,
9
+ * and a caller with a real session can still narrow to this shape. This
10
+ * package's own exports (`authzEventSchema`, `core`, `tenantVisibleByDefault`)
11
+ * are plain data/functions, so building the row here (rather than through
12
+ * `record`/`recordAs` from `./audit.js`, which expects a full `Access` or
13
+ * `Principal`) is what keeps this function free of that requirement.
14
+ */
15
+ export interface ExportActor {
16
+ readonly class: ActorClass;
17
+ readonly id: string;
18
+ readonly display: string;
19
+ }
20
+ export interface ExportedTenant {
21
+ readonly id: string;
22
+ readonly kind: string;
23
+ readonly slug: string;
24
+ readonly name: string;
25
+ readonly state: string;
26
+ readonly parentId: string | null;
27
+ readonly ceiling: readonly string[];
28
+ readonly selfDenied: readonly string[];
29
+ readonly createdBy: string | null;
30
+ readonly createdAt: string;
31
+ readonly updatedAt: string;
32
+ }
33
+ export interface ExportedMembership {
34
+ readonly principalId: string;
35
+ readonly principalClass: string;
36
+ readonly source: string;
37
+ readonly viaTenantId: string | null;
38
+ readonly tenantName: string;
39
+ readonly grantedBy: string | null;
40
+ readonly createdAt: string;
41
+ readonly updatedAt: string;
42
+ }
43
+ /** Never `secretHash`: the column is not even named in the `select`, so it is never read out of the database in the first place. */
44
+ export interface ExportedCredential {
45
+ readonly id: string;
46
+ readonly kind: string;
47
+ readonly name: string;
48
+ readonly secretPrefix: string | null;
49
+ readonly createdBy: string | null;
50
+ readonly createdAt: string;
51
+ readonly expiresAt: string | null;
52
+ readonly revokedAt: string | null;
53
+ readonly lastUsedAt: string | null;
54
+ }
55
+ export type ExportedRole = Omit<typeof policyRoles.$inferSelect, 'createdAt' | 'updatedAt'> & {
56
+ createdAt: string;
57
+ updatedAt: string;
58
+ };
59
+ /** Never `tokenHash`, for the same reason as `ExportedCredential`. */
60
+ export interface ExportedInvitation {
61
+ readonly id: string;
62
+ readonly email: string;
63
+ readonly roleId: string;
64
+ readonly status: string;
65
+ readonly mailState: string;
66
+ readonly invitedBy: string | null;
67
+ readonly expiresAt: string;
68
+ readonly acceptedBy: string | null;
69
+ readonly acceptedAt: string | null;
70
+ readonly revokedBy: string | null;
71
+ readonly revokedAt: string | null;
72
+ readonly attachParentId: string | null;
73
+ readonly createdAt: string;
74
+ }
75
+ export interface ExportedAttachProposal {
76
+ readonly id: string;
77
+ readonly parentId: string;
78
+ readonly childId: string;
79
+ readonly status: string;
80
+ readonly reason: string;
81
+ readonly reference: string;
82
+ readonly proposedBy: string;
83
+ readonly parentAcceptedBy: string | null;
84
+ readonly parentAcceptedAt: string | null;
85
+ readonly decidedBy: string | null;
86
+ readonly decidedAt: string | null;
87
+ readonly createdAt: string;
88
+ }
89
+ export interface ExportedAuditRow {
90
+ readonly teamId: string | null;
91
+ readonly subjectId: string | null;
92
+ readonly subjectClass: string | null;
93
+ readonly id: number;
94
+ readonly occurredAt: string;
95
+ readonly tenantId: string | null;
96
+ readonly actorClass: string;
97
+ readonly actorId: string;
98
+ readonly actorDisplay: string;
99
+ readonly action: string;
100
+ readonly targetType: string;
101
+ readonly targetId: string;
102
+ readonly outcome: string;
103
+ readonly context: string;
104
+ readonly sessionId: string | null;
105
+ readonly reason: string | null;
106
+ readonly reference: string | null;
107
+ readonly requestId: string | null;
108
+ readonly ip: string | null;
109
+ readonly userAgent: string | null;
110
+ readonly tenantVisible: boolean;
111
+ readonly before: unknown;
112
+ readonly after: unknown;
113
+ readonly erasedAt: string | null;
114
+ readonly schemaVersion: number;
115
+ }
116
+ /**
117
+ * A tenant's whole record, as a plain JSON-serialisable object (D9, D12,
118
+ * X6 M3). `null` for an id that does not resolve to a tenant, never an empty
119
+ * shell with every collection blank, so a caller cannot mistake "nothing to
120
+ * export" for "no such tenant".
121
+ *
122
+ * `generatedAt` is supplied by the caller rather than read from the clock in
123
+ * here, so two calls against an unchanged database, given the same instant,
124
+ * produce byte-identical JSON: a diff between two exports then means the
125
+ * data actually changed, not that time passed between them.
126
+ *
127
+ * Every collection is read with an explicit `ORDER BY` on a column that is
128
+ * unique for the rows in play (a primary key, or `(tenant, key)` for roles,
129
+ * which `roles_tenant_key_unique` already guarantees), never left to
130
+ * whatever order Postgres happens to return without one. That is what makes
131
+ * the ordering deterministic across runs, not just plausible.
132
+ *
133
+ * `credentials.secret_hash` and `invitations.token_hash` are never named in
134
+ * either `select`, so they are never read out of the database into this
135
+ * process at all; nothing here spreads a full row or nests one inside
136
+ * another, which is the shape that would let either slip back in by
137
+ * accident. Every other table this module owns is scoped to `tenantId`
138
+ * alone; `attach_proposals` is the one exception, scoped to either side of a
139
+ * proposal, since a tenant that has proposed to hold another or been asked
140
+ * to be held by one is still part of that tenant's own record.
141
+ */
142
+ export declare function exportTenant(handle: DbOrTx, tenantId: string, generatedAt: Date, actor: ExportActor): Promise<null | {
143
+ readonly policyVersion: 2;
144
+ readonly generatedAt: string;
145
+ readonly tenant: ExportedTenant;
146
+ readonly memberships: readonly ExportedMembership[];
147
+ readonly credentials: readonly ExportedCredential[];
148
+ readonly roles: readonly ExportedRole[];
149
+ readonly invitations: readonly ExportedInvitation[];
150
+ readonly attachProposals: readonly ExportedAttachProposal[];
151
+ readonly auditRows: readonly ExportedAuditRow[];
152
+ readonly teams: readonly (Omit<typeof accessTeams.$inferSelect, 'createdAt' | 'archivedAt'> & {
153
+ createdAt: string;
154
+ archivedAt: string | null;
155
+ })[];
156
+ readonly teamParticipants: readonly (typeof accessTeamMembers.$inferSelect)[];
157
+ readonly assignments: readonly (Omit<typeof policyAssignments.$inferSelect, 'createdAt' | 'expiresAt'> & {
158
+ createdAt: string;
159
+ expiresAt: string | null;
160
+ })[];
161
+ }>;
package/dist/export.js ADDED
@@ -0,0 +1,293 @@
1
+ // The permission gate is deliberately NOT in this file. `exportTenant` takes
2
+ // a handle and a tenant id and only ever reads; it does not know or care who
3
+ // is asking. A caller gates it (`requirePermission(tenantId, 'tenant:export')`
4
+ // or equivalent) before calling this function, the same shape every other
5
+ // gated write in this package follows -- this module opens no connection of
6
+ // its own and holds no permission logic to duplicate that caller's decision.
7
+ import { authzEventSchema, core, tenantVisibleByDefault } from '@wtfalch/authz';
8
+ import { asc, eq, or } from 'drizzle-orm';
9
+ import { policyAssignments, policyRoles } from './policy-schema.js';
10
+ import { accessTeamMembers, accessTeams } from './resource-schema.js';
11
+ import { attachProposals, authzEvents, credentials, invitations, memberships, tenants, } from './schema.js';
12
+ import { isUuid } from './types.js';
13
+ /**
14
+ * A tenant's whole record, as a plain JSON-serialisable object (D9, D12,
15
+ * X6 M3). `null` for an id that does not resolve to a tenant, never an empty
16
+ * shell with every collection blank, so a caller cannot mistake "nothing to
17
+ * export" for "no such tenant".
18
+ *
19
+ * `generatedAt` is supplied by the caller rather than read from the clock in
20
+ * here, so two calls against an unchanged database, given the same instant,
21
+ * produce byte-identical JSON: a diff between two exports then means the
22
+ * data actually changed, not that time passed between them.
23
+ *
24
+ * Every collection is read with an explicit `ORDER BY` on a column that is
25
+ * unique for the rows in play (a primary key, or `(tenant, key)` for roles,
26
+ * which `roles_tenant_key_unique` already guarantees), never left to
27
+ * whatever order Postgres happens to return without one. That is what makes
28
+ * the ordering deterministic across runs, not just plausible.
29
+ *
30
+ * `credentials.secret_hash` and `invitations.token_hash` are never named in
31
+ * either `select`, so they are never read out of the database into this
32
+ * process at all; nothing here spreads a full row or nests one inside
33
+ * another, which is the shape that would let either slip back in by
34
+ * accident. Every other table this module owns is scoped to `tenantId`
35
+ * alone; `attach_proposals` is the one exception, scoped to either side of a
36
+ * proposal, since a tenant that has proposed to hold another or been asked
37
+ * to be held by one is still part of that tenant's own record.
38
+ */
39
+ export async function exportTenant(handle, tenantId, generatedAt, actor) {
40
+ if (!isUuid(tenantId))
41
+ return null;
42
+ const [tenantRow] = await handle
43
+ .select({
44
+ id: tenants.id,
45
+ kind: tenants.kind,
46
+ slug: tenants.slug,
47
+ name: tenants.name,
48
+ state: tenants.state,
49
+ parentId: tenants.parentId,
50
+ ceiling: tenants.ceiling,
51
+ selfDenied: tenants.selfDenied,
52
+ createdBy: tenants.createdBy,
53
+ createdAt: tenants.createdAt,
54
+ updatedAt: tenants.updatedAt,
55
+ })
56
+ .from(tenants)
57
+ .where(eq(tenants.id, tenantId))
58
+ .limit(1);
59
+ if (!tenantRow)
60
+ return null;
61
+ const membershipRows = await handle
62
+ .select({
63
+ principalId: memberships.principalId,
64
+ principalClass: memberships.principalClass,
65
+ source: memberships.source,
66
+ viaTenantId: memberships.viaTenantId,
67
+ tenantName: memberships.tenantName,
68
+ grantedBy: memberships.grantedBy,
69
+ createdAt: memberships.createdAt,
70
+ updatedAt: memberships.updatedAt,
71
+ })
72
+ .from(memberships)
73
+ .where(eq(memberships.tenantId, tenantId))
74
+ // principal_id is the other half of this table's primary key, so it is
75
+ // already unique within one tenant: no tiebreaker column is needed.
76
+ .orderBy(asc(memberships.principalClass), asc(memberships.principalId));
77
+ const credentialRows = await handle
78
+ .select({
79
+ id: credentials.id,
80
+ kind: credentials.kind,
81
+ name: credentials.name,
82
+ secretPrefix: credentials.secretPrefix,
83
+ createdBy: credentials.createdBy,
84
+ issuerId: credentials.issuerId,
85
+ issuerClass: credentials.issuerClass,
86
+ createdAt: credentials.createdAt,
87
+ expiresAt: credentials.expiresAt,
88
+ revokedAt: credentials.revokedAt,
89
+ lastUsedAt: credentials.lastUsedAt,
90
+ })
91
+ .from(credentials)
92
+ .where(eq(credentials.tenantId, tenantId))
93
+ .orderBy(asc(credentials.id));
94
+ const invitationRows = await handle
95
+ .select({
96
+ id: invitations.id,
97
+ email: invitations.email,
98
+ roleId: invitations.roleId,
99
+ status: invitations.status,
100
+ mailState: invitations.mailState,
101
+ invitedBy: invitations.invitedBy,
102
+ expiresAt: invitations.expiresAt,
103
+ acceptedBy: invitations.acceptedBy,
104
+ acceptedAt: invitations.acceptedAt,
105
+ revokedBy: invitations.revokedBy,
106
+ revokedAt: invitations.revokedAt,
107
+ attachParentId: invitations.attachParentId,
108
+ createdAt: invitations.createdAt,
109
+ })
110
+ .from(invitations)
111
+ .where(eq(invitations.tenantId, tenantId))
112
+ .orderBy(asc(invitations.id));
113
+ const attachProposalRows = await handle
114
+ .select({
115
+ id: attachProposals.id,
116
+ parentId: attachProposals.parentId,
117
+ childId: attachProposals.childId,
118
+ status: attachProposals.status,
119
+ reason: attachProposals.reason,
120
+ reference: attachProposals.reference,
121
+ proposedBy: attachProposals.proposedBy,
122
+ parentAcceptedBy: attachProposals.parentAcceptedBy,
123
+ parentAcceptedAt: attachProposals.parentAcceptedAt,
124
+ decidedBy: attachProposals.decidedBy,
125
+ decidedAt: attachProposals.decidedAt,
126
+ createdAt: attachProposals.createdAt,
127
+ })
128
+ .from(attachProposals)
129
+ .where(or(eq(attachProposals.parentId, tenantId), eq(attachProposals.childId, tenantId)))
130
+ .orderBy(asc(attachProposals.id));
131
+ const auditRowsRaw = await handle
132
+ .select({
133
+ id: authzEvents.id,
134
+ teamId: authzEvents.teamId,
135
+ subjectId: authzEvents.subjectId,
136
+ subjectClass: authzEvents.subjectClass,
137
+ occurredAt: authzEvents.occurredAt,
138
+ tenantId: authzEvents.tenantId,
139
+ actorClass: authzEvents.actorClass,
140
+ actorId: authzEvents.actorId,
141
+ actorDisplay: authzEvents.actorDisplay,
142
+ action: authzEvents.action,
143
+ targetType: authzEvents.targetType,
144
+ targetId: authzEvents.targetId,
145
+ outcome: authzEvents.outcome,
146
+ context: authzEvents.context,
147
+ sessionId: authzEvents.sessionId,
148
+ reason: authzEvents.reason,
149
+ reference: authzEvents.reference,
150
+ requestId: authzEvents.requestId,
151
+ ip: authzEvents.ip,
152
+ userAgent: authzEvents.userAgent,
153
+ tenantVisible: authzEvents.tenantVisible,
154
+ before: authzEvents.before,
155
+ after: authzEvents.after,
156
+ erasedAt: authzEvents.erasedAt,
157
+ schemaVersion: authzEvents.schemaVersion,
158
+ })
159
+ .from(authzEvents)
160
+ .where(eq(authzEvents.tenantId, tenantId))
161
+ // The table's own ordering (drizzle/0003_authz.sql's comment on `id`):
162
+ // occurred_at first, the identity column as the tiebreaker.
163
+ .orderBy(asc(authzEvents.occurredAt), asc(authzEvents.id));
164
+ const [teamRows, participantRows, roleRows, assignmentRows] = await Promise.all([
165
+ handle
166
+ .select()
167
+ .from(accessTeams)
168
+ .where(eq(accessTeams.tenantId, tenantId))
169
+ .orderBy(asc(accessTeams.id)),
170
+ handle
171
+ .select()
172
+ .from(accessTeamMembers)
173
+ .where(eq(accessTeamMembers.tenantId, tenantId))
174
+ .orderBy(asc(accessTeamMembers.teamId), asc(accessTeamMembers.principalId)),
175
+ handle
176
+ .select()
177
+ .from(policyRoles)
178
+ .where(eq(policyRoles.tenantId, tenantId))
179
+ .orderBy(asc(policyRoles.id)),
180
+ handle
181
+ .select()
182
+ .from(policyAssignments)
183
+ .where(eq(policyAssignments.tenantId, tenantId))
184
+ .orderBy(asc(policyAssignments.id)),
185
+ ]);
186
+ // Written after every read above, so this row about the export never
187
+ // appears inside the export it is about. Hand-built rather than through
188
+ // `record`/`recordAs`, for the reason `ExportActor`'s own comment gives.
189
+ {
190
+ const occurredAt = new Date();
191
+ const row = {
192
+ occurred_at: occurredAt.toISOString(),
193
+ tenant_id: tenantId,
194
+ actor_class: actor.class,
195
+ actor_id: actor.id,
196
+ actor_display: actor.display,
197
+ action: 'tenant.exported',
198
+ target_type: 'tenant',
199
+ target_id: tenantId,
200
+ outcome: 'success',
201
+ context: tenantRow.kind === 'operator' ? 'operator' : 'standard',
202
+ session_id: null,
203
+ reason: null,
204
+ reference: null,
205
+ request_id: null,
206
+ ip: null,
207
+ user_agent: null,
208
+ tenant_visible: tenantVisibleByDefault('tenant.exported'),
209
+ before: null,
210
+ after: null,
211
+ erased_at: null,
212
+ schema_version: core.version,
213
+ };
214
+ authzEventSchema.parse(row);
215
+ await handle.insert(authzEvents).values({
216
+ occurredAt,
217
+ tenantId: row.tenant_id,
218
+ actorClass: row.actor_class,
219
+ actorId: row.actor_id,
220
+ actorDisplay: row.actor_display,
221
+ action: row.action,
222
+ targetType: row.target_type,
223
+ targetId: row.target_id,
224
+ outcome: row.outcome,
225
+ context: row.context,
226
+ sessionId: row.session_id,
227
+ reason: row.reason,
228
+ reference: row.reference,
229
+ requestId: row.request_id,
230
+ ip: row.ip,
231
+ userAgent: row.user_agent,
232
+ tenantVisible: row.tenant_visible,
233
+ before: row.before,
234
+ after: row.after,
235
+ schemaVersion: row.schema_version,
236
+ });
237
+ }
238
+ return {
239
+ policyVersion: 2,
240
+ generatedAt: generatedAt.toISOString(),
241
+ teams: teamRows.map((row) => ({
242
+ ...row,
243
+ createdAt: row.createdAt.toISOString(),
244
+ archivedAt: row.archivedAt?.toISOString() ?? null,
245
+ })),
246
+ teamParticipants: participantRows,
247
+ assignments: assignmentRows.map((row) => ({
248
+ ...row,
249
+ createdAt: row.createdAt.toISOString(),
250
+ expiresAt: row.expiresAt?.toISOString() ?? null,
251
+ })),
252
+ tenant: {
253
+ ...tenantRow,
254
+ createdAt: tenantRow.createdAt.toISOString(),
255
+ updatedAt: tenantRow.updatedAt.toISOString(),
256
+ },
257
+ memberships: membershipRows.map((row) => ({
258
+ ...row,
259
+ createdAt: row.createdAt.toISOString(),
260
+ updatedAt: row.updatedAt.toISOString(),
261
+ })),
262
+ credentials: credentialRows.map((row) => ({
263
+ ...row,
264
+ createdAt: row.createdAt.toISOString(),
265
+ expiresAt: row.expiresAt ? row.expiresAt.toISOString() : null,
266
+ revokedAt: row.revokedAt ? row.revokedAt.toISOString() : null,
267
+ lastUsedAt: row.lastUsedAt ? row.lastUsedAt.toISOString() : null,
268
+ })),
269
+ roles: roleRows.map((row) => ({
270
+ ...row,
271
+ createdAt: row.createdAt.toISOString(),
272
+ updatedAt: row.updatedAt.toISOString(),
273
+ })),
274
+ invitations: invitationRows.map((row) => ({
275
+ ...row,
276
+ expiresAt: row.expiresAt.toISOString(),
277
+ acceptedAt: row.acceptedAt ? row.acceptedAt.toISOString() : null,
278
+ revokedAt: row.revokedAt ? row.revokedAt.toISOString() : null,
279
+ createdAt: row.createdAt.toISOString(),
280
+ })),
281
+ attachProposals: attachProposalRows.map((row) => ({
282
+ ...row,
283
+ parentAcceptedAt: row.parentAcceptedAt ? row.parentAcceptedAt.toISOString() : null,
284
+ decidedAt: row.decidedAt ? row.decidedAt.toISOString() : null,
285
+ createdAt: row.createdAt.toISOString(),
286
+ })),
287
+ auditRows: auditRowsRaw.map((row) => ({
288
+ ...row,
289
+ occurredAt: row.occurredAt.toISOString(),
290
+ erasedAt: row.erasedAt ? row.erasedAt.toISOString() : null,
291
+ })),
292
+ };
293
+ }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,40 @@
1
1
  export { ReadOnlyAccessError, ScopedDb, UnscopedTableError, openScopedDb, scopeColumnOf, type DbOrTx, type Scope, } from './scoped.js';
2
2
  export { CREDENTIAL_PREFIX, CREDENTIAL_PREFIX_LENGTH, CREDENTIAL_SECRET_BYTES, bearerFromHeader, generateCredentialSecret, hashCredentialSecret, looksLikeCredentialSecret, type GeneratedCredentialSecret, } from './credential-secret.js';
3
- export { policyAssignments, policyRoles, policyVersion, } from './policy-schema.js';
3
+ export { policyActivationApprovals, policyActivations, policyAssignments, policyRoles, policyVersion, } from './policy-schema.js';
4
4
  export { accessTeamMembers, accessTeams, type AppRolePermission, } from './resource-schema.js';
5
5
  export { attachProposals, authzEvents, breakGlassSessions, credentials, invitations, memberships, tenants, type AttachProposal, type AuthzEvent, type BreakGlassSession, type Credential, type Invitation, type Membership, type NewAttachProposal, type NewAuthzEvent, type NewBreakGlassSession, type NewCredential, type NewInvitation, type NewMembership, type NewTenant, type Tenant, } from './schema.js';
6
6
  export { ownerCoverage, ownerSeatCovered, principalIsReachable, type OwnerCoverage, type OwnerCoverageOptions, type PolicyBinding, } from './owners.js';
7
7
  export { migrateStore } from './migrate.js';
8
8
  export { guestGrants, policyGrants, type GrantStatus, type GuestGrant } from './grants.js';
9
+ export { permissionOf, type StoreBinding } from './binding.js';
10
+ export { isCustomRoleKey } from './role-keys.js';
11
+ export { type Access, type BreakGlassGrant, type Context, type Principal, type Result, type TenantRow, done, isUuid, refused, tenantTag, } from './types.js';
12
+ export { policyResource } from './policy-resources.js';
13
+ export { auditedReadGate, loadAccess, loadPolicyState, permits, permitsPlatform, permitsPlatformRead, permitsRead, policyAssignment, policyRole, policyTarget, refreshAccess, requirePolicySchema, restriction, type PolicyState, } from './policy-access.js';
14
+ export { explain } from './denial.js';
15
+ export { identityWhere, primaryAssignment, primaryRoleKey, roleAssignmentRefusal, roleById, writeParticipationPolicy, writePrimaryAssignment, } from './assignments.js';
16
+ export { record, recordAs, securityLogFor, writeServiceEvent, type EventInput, type SecurityLogEntry, type SecurityLogPage, } from './audit.js';
17
+ export { definePolicy, type PolicyData, type PolicyRoleTemplate, type StorePolicy, } from './policy.js';
18
+ export { assignableRoles, canEditDefinition, createRole, deleteRole, roleDef, rolesForEditor, updateRole, type CreateRoleOptions, type DeleteRoleOptions, type EditableRole, type UpdateRoleOptions, } from './roles.js';
19
+ export { checkSystemRoles, content, ensureBuiltInRoles, orphanedRoleKeys, seedStarterRoles, type OrphanedRoleKey, type SystemRoleCheckResult, type SystemRoleCheckStatus, } from './boot.js';
20
+ export { TenantCycleError, ancestorsOf, attachCustomRoleBlockers, attachProblem, childrenOf, customRoleBlockers, depthAbove, derivedEventAction, descendantsOf, detachReport, expectedDerivedFor, heightBelow, lockAttachScope, lockSubtreeForUpdate, lockTenantsForUpdate, nodeById, principalsAtOrAbove, type AttachProblem, type CustomRoleBlocker, type DerivedChange, type DetachReportRow, type ExpectedDerived, type TenantNode, } from './tree.js';
21
+ export { DerivedCustomRoleError, attachInTx, detachInTx, recomputeDerived, } from './tree-writes.js';
22
+ export { type PropagationWriter, propagateMembershipChange, writerFromAccess, writerFromPrincipal, } from './propagate.js';
23
+ export { type ReconcileMissing, type ReconcileOrphan, type ReconcileReport, type ReconcileWrongRole, reconcileDerived, } from './reconcile.js';
24
+ export { type InstallOwnerOptions, type OwnerActivityLookup, installOwner, } from './install-owner.js';
25
+ export { type ErasePersonResult, type ErasureHost, erasePerson, pseudonymFor, } from './erase.js';
26
+ export { type AlertCheck, type AlertFinding, type AuthzReporting, checkBreakGlassBurst, checkOrphanMemberships, checkOrphanedRoleKeys, checkStuckInvitations, reportAlert, runAlerts, } from './alerts.js';
27
+ export { type HousekeepingRegistry, registerHousekeeping, runStartupChecks, } from './startup.js';
28
+ export { type SessionsForOptions, type StartBreakGlassOptions, activeSession, endBreakGlass, endSessionsFor, sessionsFor, startBreakGlass, } from './break-glass.js';
29
+ export { type CredentialIssuerPort, type CredentialSummary, type IssuerNames, type MintCredentialOptions, type MintedCredential, type StoreKeyGrant, credentialsFor, mintCredential, revokeCredential, } from './credentials.js';
30
+ export { bootstrapOperator } from './bootstrap.js';
31
+ export { ATTACH_REASONS, acceptAttach, declineAttach, detach, pendingProposalsForChild, pendingProposalsForParent, proposalsFromParent, proposeAttach, type AttachProposalForChild, type AttachProposalForParent, type AttachProposalFromParent, type AttachReason, type DetachOptions, type DetachResult, type ProposeAttachOptions, } from './nesting.js';
32
+ export { acceptInvitation, generateInvitationToken, invite, invitationPreview, invitationTenantId, markInvitationMail, pendingInvitations, resendInvitation, revokeInvitation, type AcceptInvitationResult, type AttachRefusal, type GeneratedInvitationToken, type InvitationHandle, type InvitationPreview, type InviteOptions, type PendingInvitationSummary, } from './invitations.js';
33
+ export { personRecordFor, teamRecordFor, type GrantPermission, type PeopleDirectory, type PersonGrant, type PersonRecord, type PersonRecordResult, type PersonTeam, type TeamGrant, type TeamParent, type TeamRecord, type TeamRecordResult, } from './person-records.js';
34
+ export { changeRole, grantMembership, guardStaysHeld, membersOf, removeMembership, selfStanding, type ChangeRoleOptions, type GrantMembershipOptions, type Member, type RemoveMembershipOptions, type SelfStanding, } from './memberships.js';
35
+ export { approveActivation, endActivation, needsCoApproval, requestActivation, selfApproveActivation, type ApproveActivationOptions, type RequestActivationOptions, } from './activations.js';
36
+ export { allTenants, archiveTenant, createTenant, createTenantAsOperator, deleteJustCreatedTenant, renameTenant, setCeiling, setSelfDenied, setTenantState, tenantById, tenantBySlug, type AllTenantsPage, type CreateTenantAsOperatorOptions, type CreateTenantAsOperatorResult, type CreateTenantConfig, type CreateTenantOptions, type RenameTenantOptions, type SetTenantStateOptions, type TenantCreationMode, type TenantSummary, } from './tenants.js';
37
+ export { archiveTeam, assignAppRole, changeTeamParticipant, deleteAppRole, removeAppAssignment, resourceAccessFor, resourceOverview, saveAppRole, saveTeam, scopedAuditLog, } from './resource-access.js';
38
+ export { exportTenant, type ExportActor, type ExportedAttachProposal, type ExportedAuditRow, type ExportedCredential, type ExportedInvitation, type ExportedMembership, type ExportedRole, type ExportedTenant, } from './export.js';
39
+ export { eventsFor, type EventPage, type EventsForOptions, type EventSummary, } from './events.js';
40
+ export { platformFrozen, setPlatformFrozen } from './platform.js';
package/dist/index.js CHANGED
@@ -1,8 +1,40 @@
1
1
  export { ReadOnlyAccessError, ScopedDb, UnscopedTableError, openScopedDb, scopeColumnOf, } from './scoped.js';
2
2
  export { CREDENTIAL_PREFIX, CREDENTIAL_PREFIX_LENGTH, CREDENTIAL_SECRET_BYTES, bearerFromHeader, generateCredentialSecret, hashCredentialSecret, looksLikeCredentialSecret, } from './credential-secret.js';
3
- export { policyAssignments, policyRoles, policyVersion, } from './policy-schema.js';
3
+ export { policyActivationApprovals, policyActivations, policyAssignments, policyRoles, policyVersion, } from './policy-schema.js';
4
4
  export { accessTeamMembers, accessTeams, } from './resource-schema.js';
5
5
  export { attachProposals, authzEvents, breakGlassSessions, credentials, invitations, memberships, tenants, } from './schema.js';
6
6
  export { ownerCoverage, ownerSeatCovered, principalIsReachable, } from './owners.js';
7
7
  export { migrateStore } from './migrate.js';
8
8
  export { guestGrants, policyGrants } from './grants.js';
9
+ export { permissionOf } from './binding.js';
10
+ export { isCustomRoleKey } from './role-keys.js';
11
+ export { done, isUuid, refused, tenantTag, } from './types.js';
12
+ export { policyResource } from './policy-resources.js';
13
+ export { auditedReadGate, loadAccess, loadPolicyState, permits, permitsPlatform, permitsPlatformRead, permitsRead, policyAssignment, policyRole, policyTarget, refreshAccess, requirePolicySchema, restriction, } from './policy-access.js';
14
+ export { explain } from './denial.js';
15
+ export { identityWhere, primaryAssignment, primaryRoleKey, roleAssignmentRefusal, roleById, writeParticipationPolicy, writePrimaryAssignment, } from './assignments.js';
16
+ export { record, recordAs, securityLogFor, writeServiceEvent, } from './audit.js';
17
+ export { definePolicy, } from './policy.js';
18
+ export { assignableRoles, canEditDefinition, createRole, deleteRole, roleDef, rolesForEditor, updateRole, } from './roles.js';
19
+ export { checkSystemRoles, content, ensureBuiltInRoles, orphanedRoleKeys, seedStarterRoles, } from './boot.js';
20
+ export { TenantCycleError, ancestorsOf, attachCustomRoleBlockers, attachProblem, childrenOf, customRoleBlockers, depthAbove, derivedEventAction, descendantsOf, detachReport, expectedDerivedFor, heightBelow, lockAttachScope, lockSubtreeForUpdate, lockTenantsForUpdate, nodeById, principalsAtOrAbove, } from './tree.js';
21
+ export { DerivedCustomRoleError, attachInTx, detachInTx, recomputeDerived, } from './tree-writes.js';
22
+ export { propagateMembershipChange, writerFromAccess, writerFromPrincipal, } from './propagate.js';
23
+ export { reconcileDerived, } from './reconcile.js';
24
+ export { installOwner, } from './install-owner.js';
25
+ export { erasePerson, pseudonymFor, } from './erase.js';
26
+ export { checkBreakGlassBurst, checkOrphanMemberships, checkOrphanedRoleKeys, checkStuckInvitations, reportAlert, runAlerts, } from './alerts.js';
27
+ export { registerHousekeeping, runStartupChecks, } from './startup.js';
28
+ export { activeSession, endBreakGlass, endSessionsFor, sessionsFor, startBreakGlass, } from './break-glass.js';
29
+ export { credentialsFor, mintCredential, revokeCredential, } from './credentials.js';
30
+ export { bootstrapOperator } from './bootstrap.js';
31
+ export { ATTACH_REASONS, acceptAttach, declineAttach, detach, pendingProposalsForChild, pendingProposalsForParent, proposalsFromParent, proposeAttach, } from './nesting.js';
32
+ export { acceptInvitation, generateInvitationToken, invite, invitationPreview, invitationTenantId, markInvitationMail, pendingInvitations, resendInvitation, revokeInvitation, } from './invitations.js';
33
+ export { personRecordFor, teamRecordFor, } from './person-records.js';
34
+ export { changeRole, grantMembership, guardStaysHeld, membersOf, removeMembership, selfStanding, } from './memberships.js';
35
+ export { approveActivation, endActivation, needsCoApproval, requestActivation, selfApproveActivation, } from './activations.js';
36
+ export { allTenants, archiveTenant, createTenant, createTenantAsOperator, deleteJustCreatedTenant, renameTenant, setCeiling, setSelfDenied, setTenantState, tenantById, tenantBySlug, } from './tenants.js';
37
+ export { archiveTeam, assignAppRole, changeTeamParticipant, deleteAppRole, removeAppAssignment, resourceAccessFor, resourceOverview, saveAppRole, saveTeam, scopedAuditLog, } from './resource-access.js';
38
+ export { exportTenant, } from './export.js';
39
+ export { eventsFor, } from './events.js';
40
+ export { platformFrozen, setPlatformFrozen } from './platform.js';