@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/boot.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { TenantKind } from '@wtfalch/authz';
2
+ import type { StoreBinding } from './binding.js';
3
+ import type { StorePolicy } from './policy.js';
4
+ import type { DbOrTx } from './scoped.js';
5
+ export type SystemRoleCheckStatus = 'first_seen' | 'unchanged' | 'updated';
6
+ export interface SystemRoleCheckResult {
7
+ readonly kind: TenantKind;
8
+ readonly roleKey: string;
9
+ readonly status: SystemRoleCheckStatus;
10
+ }
11
+ /**
12
+ * The exact string `ensureBuiltInRoles` compares a stored row against. Exported
13
+ * for a rollback test that pins what a released catalogue holds: a rollback runs
14
+ * the PREVIOUS release's copy of this function against rows the current one
15
+ * seeded, so the two must agree character for character.
16
+ */
17
+ export declare const content: (role: {
18
+ entries: unknown;
19
+ guards: readonly string[];
20
+ label: string;
21
+ description: string;
22
+ }) => string;
23
+ /** Called in the tenant creation/deploy transaction while its tenant row is locked. */
24
+ export declare function ensureBuiltInRoles(tx: DbOrTx, policy: StorePolicy, tenant: {
25
+ id: string;
26
+ kind: TenantKind;
27
+ }): Promise<SystemRoleCheckResult[]>;
28
+ /**
29
+ * The starter role set (ADR 0016): copied into a brand-new tenant exactly
30
+ * once, as ordinary rows nothing here resyncs afterward — unlike
31
+ * `ensureBuiltInRoles`, this never updates an existing row. Guarded by
32
+ * `onConflictDoNothing` on the same `(tenant, application, key)` uniqueness
33
+ * `ensureBuiltInRoles` relies on, so a caller may call it more than once for
34
+ * the same tenant (a retried transaction, or a tenant whose roles already
35
+ * exist from before this change shipped) without duplicating or throwing.
36
+ *
37
+ * Stored `built_in: true`, not the `false` `starterRoles()` itself declares,
38
+ * for the reasons the host's own boot.ts documents (`@wtfalch/authz`'s
39
+ * `compileRoleGrants` refuses a non-`assignable` entry unless the role is
40
+ * `built_in`, and today's owner/admin templates carry several).
41
+ */
42
+ export declare function seedStarterRoles(tx: DbOrTx, policy: StorePolicy, tenant: {
43
+ id: string;
44
+ kind: TenantKind;
45
+ }): Promise<void>;
46
+ export declare function checkSystemRoles(handle: DbOrTx, policy: StorePolicy): Promise<readonly SystemRoleCheckResult[]>;
47
+ export interface OrphanedRoleKey {
48
+ readonly source: 'membership' | 'invitation';
49
+ readonly tenantId: string;
50
+ readonly rowKey: string;
51
+ readonly roleKey: string;
52
+ }
53
+ /** Referential integrity rejects orphan roles at write time; boot also validates the policy version. */
54
+ export declare function orphanedRoleKeys(handle: DbOrTx, binding: StoreBinding): Promise<readonly OrphanedRoleKey[]>;
package/dist/boot.js ADDED
@@ -0,0 +1,143 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { and, eq } from 'drizzle-orm';
3
+ import { writeServiceEvent } from './audit.js';
4
+ import { policyRole, requirePolicySchema } from './policy-access.js';
5
+ import { policyRoles } from './policy-schema.js';
6
+ import { tenants } from './schema.js';
7
+ /** JSONB changes object key order; compare policy content independently of serialization. */
8
+ function canonical(value) {
9
+ if (Array.isArray(value))
10
+ return `[${value.map(canonical).join(',')}]`;
11
+ if (value && typeof value === 'object')
12
+ return `{${Object.entries(value)
13
+ .sort(([a], [b]) => a.localeCompare(b))
14
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`)
15
+ .join(',')}}`;
16
+ return JSON.stringify(value);
17
+ }
18
+ /**
19
+ * The exact string `ensureBuiltInRoles` compares a stored row against. Exported
20
+ * for a rollback test that pins what a released catalogue holds: a rollback runs
21
+ * the PREVIOUS release's copy of this function against rows the current one
22
+ * seeded, so the two must agree character for character.
23
+ */
24
+ export const content = (role) => canonical({
25
+ entries: [...role.entries].map(canonical).sort(),
26
+ guards: [...role.guards].sort(),
27
+ label: role.label,
28
+ description: role.description,
29
+ });
30
+ /** Called in the tenant creation/deploy transaction while its tenant row is locked. */
31
+ export async function ensureBuiltInRoles(tx, policy, tenant) {
32
+ const rows = await tx
33
+ .select()
34
+ .from(policyRoles)
35
+ .where(and(eq(policyRoles.tenantId, tenant.id), eq(policyRoles.applicationId, policy.applicationId), eq(policyRoles.platformId, policy.platformId)));
36
+ const result = [];
37
+ for (const template of policy.builtInRoles(tenant.id, tenant.kind)) {
38
+ const existing = rows.find((r) => r.key === template.key);
39
+ let status = 'unchanged';
40
+ if (existing) {
41
+ if (!existing.builtIn)
42
+ throw new Error(`Reserved role key collision: ${template.key}`);
43
+ if (existing.revision > template.revision)
44
+ throw new Error('Policy rollback requires a maintenance migration');
45
+ if (content(policyRole(existing)) !== content(template)) {
46
+ if (existing.revision === template.revision)
47
+ throw new Error(`Built-in policy ${template.key} changed without a revision`);
48
+ await tx
49
+ .update(policyRoles)
50
+ .set({
51
+ name: template.label,
52
+ description: template.description,
53
+ entries: template.entries,
54
+ guards: template.guards,
55
+ revision: template.revision,
56
+ updatedAt: new Date(),
57
+ })
58
+ .where(eq(policyRoles.id, existing.id));
59
+ await writeServiceEvent(tx, { id: 'deploy', display: 'deploy' }, {
60
+ action: 'role.updated',
61
+ tenantId: tenant.id,
62
+ targetType: 'role',
63
+ targetId: existing.id,
64
+ context: tenant.kind === 'operator' ? 'operator' : 'standard',
65
+ before: policyRole(existing),
66
+ after: { id: existing.id, ...template },
67
+ });
68
+ status = 'updated';
69
+ }
70
+ }
71
+ else {
72
+ const id = randomUUID();
73
+ await tx.insert(policyRoles).values({
74
+ id,
75
+ tenantId: tenant.id,
76
+ applicationId: policy.applicationId,
77
+ platformId: policy.platformId,
78
+ key: template.key,
79
+ name: template.label,
80
+ description: template.description,
81
+ entries: template.entries,
82
+ guards: template.guards,
83
+ revision: template.revision,
84
+ builtIn: true,
85
+ });
86
+ status = 'first_seen';
87
+ }
88
+ result.push({ kind: tenant.kind, roleKey: template.key, status });
89
+ }
90
+ return result;
91
+ }
92
+ /**
93
+ * The starter role set (ADR 0016): copied into a brand-new tenant exactly
94
+ * once, as ordinary rows nothing here resyncs afterward — unlike
95
+ * `ensureBuiltInRoles`, this never updates an existing row. Guarded by
96
+ * `onConflictDoNothing` on the same `(tenant, application, key)` uniqueness
97
+ * `ensureBuiltInRoles` relies on, so a caller may call it more than once for
98
+ * the same tenant (a retried transaction, or a tenant whose roles already
99
+ * exist from before this change shipped) without duplicating or throwing.
100
+ *
101
+ * Stored `built_in: true`, not the `false` `starterRoles()` itself declares,
102
+ * for the reasons the host's own boot.ts documents (`@wtfalch/authz`'s
103
+ * `compileRoleGrants` refuses a non-`assignable` entry unless the role is
104
+ * `built_in`, and today's owner/admin templates carry several).
105
+ */
106
+ export async function seedStarterRoles(tx, policy, tenant) {
107
+ const rows = policy.starterRoles(tenant.id, tenant.kind).map((template) => ({
108
+ id: randomUUID(),
109
+ tenantId: tenant.id,
110
+ applicationId: policy.applicationId,
111
+ platformId: policy.platformId,
112
+ key: template.key,
113
+ name: template.label,
114
+ description: template.description,
115
+ entries: template.entries,
116
+ guards: template.guards,
117
+ revision: template.revision,
118
+ builtIn: true,
119
+ }));
120
+ if (!rows.length)
121
+ return;
122
+ await tx
123
+ .insert(policyRoles)
124
+ .values(rows)
125
+ .onConflictDoNothing({
126
+ target: [policyRoles.tenantId, policyRoles.applicationId, policyRoles.key],
127
+ });
128
+ }
129
+ export async function checkSystemRoles(handle, policy) {
130
+ await requirePolicySchema(handle, policy);
131
+ return handle.transaction(async (tx) => {
132
+ const rows = await tx.select().from(tenants).orderBy(tenants.id).for('update');
133
+ const results = [];
134
+ for (const tenant of rows)
135
+ results.push(...(await ensureBuiltInRoles(tx, policy, tenant)));
136
+ return results;
137
+ });
138
+ }
139
+ /** Referential integrity rejects orphan roles at write time; boot also validates the policy version. */
140
+ export async function orphanedRoleKeys(handle, binding) {
141
+ await requirePolicySchema(handle, binding);
142
+ return [];
143
+ }
@@ -0,0 +1,28 @@
1
+ import type { StorePolicy } from './policy.js';
2
+ import type { DbOrTx } from './scoped.js';
3
+ /**
4
+ * The first operator (D10, D12). A host resolves its own bootstrap subject
5
+ * (an environment variable naming a signed-in subject id, never an email: a
6
+ * subject id is what the issuer hands back on every sign-in, so there is no
7
+ * address to normalise or spoof) and passes it in as `options.subject`; this
8
+ * package reads no environment variable and imports no host's own auth
9
+ * package. `options.ensureProfile` is the host's own person-row write --
10
+ * `profiles` is the host's table, not the store's -- generic over the user
11
+ * type `U` so a host's own richer `User` flows straight through to its own
12
+ * `ensureProfile` without this function knowing its shape beyond `id`.
13
+ *
14
+ * Nothing in this function reaches for a request, a cookie or a session
15
+ * itself; it only ever acts on the `user` its caller already resolved, and
16
+ * on whether that user's id is the one `options.subject` names.
17
+ *
18
+ * Idempotent and inert once the operator tenant has a direct owner, and inert
19
+ * for anyone but the named subject, so leaving a host's own bootstrap
20
+ * variable set in the environment after first use is harmless: every later
21
+ * call is a fast no-op that changes nothing.
22
+ */
23
+ export declare function bootstrapOperator<U extends {
24
+ id: string;
25
+ }>(db: DbOrTx, policy: StorePolicy, user: U, options: {
26
+ readonly subject: string | undefined;
27
+ ensureProfile(user: U): Promise<void>;
28
+ }): Promise<boolean>;
@@ -0,0 +1,83 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { writeParticipationPolicy, writePrimaryAssignment } from './assignments.js';
3
+ import { writeServiceEvent } from './audit.js';
4
+ import { ensureBuiltInRoles, seedStarterRoles } from './boot.js';
5
+ import { ownerCoverage } from './owners.js';
6
+ import { roleDef } from './roles.js';
7
+ import { memberships, tenants } from './schema.js';
8
+ /**
9
+ * The first operator (D10, D12). A host resolves its own bootstrap subject
10
+ * (an environment variable naming a signed-in subject id, never an email: a
11
+ * subject id is what the issuer hands back on every sign-in, so there is no
12
+ * address to normalise or spoof) and passes it in as `options.subject`; this
13
+ * package reads no environment variable and imports no host's own auth
14
+ * package. `options.ensureProfile` is the host's own person-row write --
15
+ * `profiles` is the host's table, not the store's -- generic over the user
16
+ * type `U` so a host's own richer `User` flows straight through to its own
17
+ * `ensureProfile` without this function knowing its shape beyond `id`.
18
+ *
19
+ * Nothing in this function reaches for a request, a cookie or a session
20
+ * itself; it only ever acts on the `user` its caller already resolved, and
21
+ * on whether that user's id is the one `options.subject` names.
22
+ *
23
+ * Idempotent and inert once the operator tenant has a direct owner, and inert
24
+ * for anyone but the named subject, so leaving a host's own bootstrap
25
+ * variable set in the environment after first use is harmless: every later
26
+ * call is a fast no-op that changes nothing.
27
+ */
28
+ export async function bootstrapOperator(db, policy, user, options) {
29
+ if (!options.subject || options.subject !== user.id)
30
+ return false;
31
+ // `ensureProfile` always writes through a host's own raw handle rather
32
+ // than taking a transaction parameter, so it cannot join the locked
33
+ // transaction below. It runs first, as its own statement: the tenant row
34
+ // lock below is what actually serialises two concurrent bootstrap attempts
35
+ // against each other (the owner count is read only after the lock is
36
+ // held), so the profile row existing a moment earlier changes nothing
37
+ // about that race.
38
+ await options.ensureProfile(user);
39
+ return db.transaction(async (tx) => {
40
+ const [operatorTenant] = await tx
41
+ .select({ id: tenants.id, name: tenants.name, kind: tenants.kind })
42
+ .from(tenants)
43
+ .where(eq(tenants.kind, 'operator'))
44
+ .for('update');
45
+ if (!operatorTenant) {
46
+ throw new Error('bootstrapOperator: no operator tenant found; has the store’s baseline migration run?');
47
+ }
48
+ if ((await ownerCoverage(tx, policy, operatorTenant.id)).directOwners > 0)
49
+ return false;
50
+ await ensureBuiltInRoles(tx, policy, operatorTenant);
51
+ await seedStarterRoles(tx, policy, operatorTenant);
52
+ const owner = await roleDef(tx, policy, operatorTenant, 'owner');
53
+ if (!owner)
54
+ throw new Error('Missing versioned owner policy');
55
+ const inserted = await tx
56
+ .insert(memberships)
57
+ .values({
58
+ tenantId: operatorTenant.id,
59
+ principalId: user.id,
60
+ principalClass: 'human',
61
+ source: 'direct',
62
+ tenantName: operatorTenant.name,
63
+ grantedBy: null,
64
+ })
65
+ .onConflictDoNothing()
66
+ .returning({ id: memberships.principalId });
67
+ await writePrimaryAssignment(tx, policy, owner, { id: user.id, class: 'human' });
68
+ if (inserted.length)
69
+ await writeParticipationPolicy(tx, policy, operatorTenant.id, {
70
+ id: user.id,
71
+ class: 'human',
72
+ });
73
+ await writeServiceEvent(tx, { id: 'bootstrap', display: 'bootstrap' }, {
74
+ action: 'operator.bootstrapped',
75
+ tenantId: operatorTenant.id,
76
+ targetType: 'membership',
77
+ targetId: user.id,
78
+ context: 'operator',
79
+ after: { principalId: user.id, role: 'owner' },
80
+ });
81
+ return true;
82
+ });
83
+ }
@@ -0,0 +1,61 @@
1
+ import type { BreakGlassReason } from '@wtfalch/authz';
2
+ import { type BreakGlassSession } from './schema.js';
3
+ import type { DbOrTx, Scope } from './scoped.js';
4
+ import type { Access, Result } from './types.js';
5
+ export interface StartBreakGlassOptions {
6
+ readonly tenantId: string;
7
+ readonly reasonCode: BreakGlassReason;
8
+ readonly reference: string;
9
+ readonly minutes?: number;
10
+ readonly readOnly?: boolean;
11
+ /** Which customer-tenant role the session acts as. The target is always a customer tenant, never the operator one. */
12
+ readonly role?: string;
13
+ }
14
+ /**
15
+ * Opens a support session. Every check below runs before a row is written or
16
+ * a query touches the database that does not have to, so a malformed request
17
+ * never reaches the transaction that would otherwise lock the wrong tenant
18
+ * for no reason.
19
+ */
20
+ export declare function startBreakGlass(operatorAccess: Access, db: DbOrTx, scopeColumn: Scope['column'], options: StartBreakGlassOptions): Promise<Result<{
21
+ id: string;
22
+ expiresAt: Date;
23
+ }>>;
24
+ /**
25
+ * Ends one session: the operator who holds it may always end their own, no
26
+ * matter what they hold now, because that is a safety valve, not a power;
27
+ * ending someone else's needs `break_glass:end-any`.
28
+ */
29
+ export declare function endBreakGlass(operatorAccess: Access, db: DbOrTx, scopeColumn: Scope['column'], sessionId: string): Promise<Result<void>>;
30
+ /**
31
+ * Force-ends every session one operator holds, inside whatever transaction
32
+ * the caller already has open: a host's own `memberships.ts` calls this the
33
+ * moment an operator-tenant membership is removed or changed to a role
34
+ * without `break_glass:start`, so a session never outlives the authority
35
+ * that opened it (M9). Written as the `service:authz` actor rather than
36
+ * through `record`, because there is no `Access` for the operator whose
37
+ * session this is inside a transaction that is busy ending their
38
+ * membership, not building one.
39
+ */
40
+ export declare function endSessionsFor(tx: DbOrTx, operatorId: string, reason: string): Promise<void>;
41
+ /**
42
+ * The session that would currently grant access, or null: not yet ended and
43
+ * not past its expiry. A host's own `access.ts` reads this to resolve a
44
+ * session `Access`, and `startBreakGlass` reads it to refuse a second
45
+ * session on top of one that is already live; an expired-but-unended row
46
+ * counts as gone for both.
47
+ */
48
+ export declare function activeSession(tx: DbOrTx, operatorId: string, tenantId: string): Promise<BreakGlassSession | null>;
49
+ export interface SessionsForOptions {
50
+ /** Every session on this tenant (its console page); omitted for the operator's own history instead. */
51
+ readonly tenantId?: string;
52
+ }
53
+ /**
54
+ * Sessions for the console: one tenant's history when `tenantId` is given,
55
+ * or the calling operator's own history otherwise. A page-rendering read
56
+ * with no transaction to join, the same as `assignableRoles`; the caller is
57
+ * expected to have already resolved `operatorAccess` through its own
58
+ * `requireOperator` with whatever permission its own page needs. `db` is the
59
+ * host's root handle, taken explicitly rather than imported.
60
+ */
61
+ export declare function sessionsFor(operatorAccess: Access, db: DbOrTx, options?: SessionsForOptions): Promise<BreakGlassSession[]>;
@@ -0,0 +1,247 @@
1
+ import { core } from '@wtfalch/authz';
2
+ import { and, desc, eq, gt, isNull, sql } from 'drizzle-orm';
3
+ import { record, writeServiceEvent } from './audit.js';
4
+ import { permits, refreshAccess } from './policy-access.js';
5
+ import { isCustomRoleKey } from './role-keys.js';
6
+ import { roleDef } from './roles.js';
7
+ import { breakGlassSessions, memberships, tenants } from './schema.js';
8
+ import { done, isUuid, refused, tenantTag } from './types.js';
9
+ /**
10
+ * An operator's time-boxed, reasoned, audited way into a tenant they do not
11
+ * belong to (D8). This is the one place a session is opened, ended or force-
12
+ * ended; `access.ts` (the host's own) reads the row this writes to build the
13
+ * `Access` a session resolves to, and every authority write in this module
14
+ * refuses a `break_glass` context outright, so nothing here can turn into a
15
+ * standing membership no matter how these functions are combined.
16
+ */
17
+ const MIN_MINUTES = 1;
18
+ const MAX_MINUTES = core.breakGlass.maxMinutes;
19
+ const REASON_CODES = new Set(core.breakGlass.reasonCodes);
20
+ const MIN_REFERENCE = 1;
21
+ // Matches bg_reference_check in the migration; kept as a constant here
22
+ // rather than read from the package, which has no field for it (the
23
+ // invitation and break-glass row shapes differ enough that this is a
24
+ // column limit on this table, not a vocabulary rule).
25
+ const MAX_REFERENCE = 512;
26
+ /**
27
+ * Opens a support session. Every check below runs before a row is written or
28
+ * a query touches the database that does not have to, so a malformed request
29
+ * never reaches the transaction that would otherwise lock the wrong tenant
30
+ * for no reason.
31
+ */
32
+ export async function startBreakGlass(operatorAccess, db, scopeColumn, options) {
33
+ if (operatorAccess.context === 'break_glass') {
34
+ return refused('break_glass', 'A support session never opens another one.');
35
+ }
36
+ if (operatorAccess.tenant.kind !== 'operator' || !permits(operatorAccess, 'break_glass:start')) {
37
+ return refused('no_permission', 'You do not have permission to open a support session.');
38
+ }
39
+ const readOnlySession = options.readOnly ?? true;
40
+ if (!readOnlySession && !permits(operatorAccess, 'break_glass:start-write')) {
41
+ return refused('no_permission', 'You do not have permission to open a read-write support session.');
42
+ }
43
+ if (!isUuid(options.tenantId)) {
44
+ return refused('tenant_not_found', 'This organisation no longer exists.');
45
+ }
46
+ if (options.tenantId === operatorAccess.tenant.id) {
47
+ return refused('operator_tenant', 'The operator organisation is not reached through a support session.');
48
+ }
49
+ const minutes = options.minutes ?? 30;
50
+ if (!Number.isInteger(minutes) || minutes < MIN_MINUTES || minutes > MAX_MINUTES) {
51
+ return refused('invalid_minutes', `A support session lasts between ${MIN_MINUTES} and ${MAX_MINUTES} minutes.`);
52
+ }
53
+ if (!REASON_CODES.has(options.reasonCode)) {
54
+ return refused('invalid_reason', `"${options.reasonCode}" is not a reason this app knows.`);
55
+ }
56
+ if (options.reference.length < MIN_REFERENCE || options.reference.length > MAX_REFERENCE) {
57
+ return refused('invalid_reference', `The reference must be between ${MIN_REFERENCE} and ${MAX_REFERENCE} characters.`);
58
+ }
59
+ const role = options.role ?? 'owner';
60
+ return db.transaction(async (tx) => {
61
+ const fresh = await refreshAccess(tx, db, scopeColumn, operatorAccess);
62
+ if (!fresh ||
63
+ fresh.actor.class !== 'human' ||
64
+ fresh.context !== 'operator' ||
65
+ !permits(fresh, 'break_glass:start') ||
66
+ (!readOnlySession && !permits(fresh, 'break_glass:start-write')))
67
+ return refused('no_permission', 'Your support authority changed.');
68
+ const currentAccess = fresh;
69
+ // Locked though nothing here writes to the row: this is what serialises
70
+ // a session starting against a membership write landing on the same
71
+ // tenant at the same instant, the same reason every other authority
72
+ // write in this module locks the tenant first, and it is what turns a
73
+ // non-existent tenant into a clean refusal instead of a foreign-key
74
+ // error from the insert below.
75
+ const [targetTenant] = await tx
76
+ .select({ id: tenants.id })
77
+ .from(tenants)
78
+ .where(eq(tenants.id, options.tenantId))
79
+ .for('update');
80
+ if (!targetTenant)
81
+ return refused('tenant_not_found', 'This organisation no longer exists.');
82
+ const supportRole = await roleDef(tx, currentAccess.binding, { id: options.tenantId }, role);
83
+ // Not `.builtIn`: a starter role (ADR 0016) is `built_in: false` for a
84
+ // tenant created after that change, but a support session must still
85
+ // never run as a tenant-authored custom role — `isCustomRoleKey` is the
86
+ // check that actually distinguishes the two.
87
+ if (!supportRole || isCustomRoleKey(supportRole.key))
88
+ return refused('invalid_role', 'Support requires an application-defined role in the target organisation.');
89
+ // A direct row always wins (D5): if the operator already belongs here,
90
+ // a session would be a second, weaker path into a tenant they can
91
+ // already reach properly, and accessFor never looks for one anyway.
92
+ const [direct] = await tx
93
+ .select({ principalId: memberships.principalId })
94
+ .from(memberships)
95
+ .where(and(eq(memberships.tenantId, options.tenantId), eq(memberships.principalId, currentAccess.actor.id), eq(memberships.principalClass, currentAccess.actor.class)))
96
+ .limit(1);
97
+ if (direct) {
98
+ return refused('direct_member', 'You already belong to this organisation; a support session is not how you reach it.');
99
+ }
100
+ if (await activeSession(tx, currentAccess.actor.id, options.tenantId)) {
101
+ return refused('session_active', 'You already have an open support session on this organisation.');
102
+ }
103
+ const [row] = await tx
104
+ .insert(breakGlassSessions)
105
+ .values({
106
+ operatorId: currentAccess.actor.id,
107
+ tenantId: options.tenantId,
108
+ roleId: supportRole.id,
109
+ readOnly: readOnlySession,
110
+ reasonCode: options.reasonCode,
111
+ reference: options.reference,
112
+ expiresAt: new Date(Date.now() + minutes * 60_000),
113
+ })
114
+ .returning({ id: breakGlassSessions.id, expiresAt: breakGlassSessions.expiresAt });
115
+ if (!row)
116
+ throw new Error('startBreakGlass: no row returned from the insert');
117
+ // Written as the operator's own act, not under the session it opens: the
118
+ // row about starting a session belongs to context 'operator', and a row
119
+ // written once the session is live carries context 'break_glass' instead.
120
+ await record(tx, currentAccess, {
121
+ action: 'break_glass.started',
122
+ tenantId: options.tenantId,
123
+ targetType: 'break_glass_session',
124
+ targetId: row.id,
125
+ reason: options.reasonCode,
126
+ reference: options.reference,
127
+ });
128
+ return done({ id: row.id, expiresAt: row.expiresAt }, [tenantTag(options.tenantId)]);
129
+ });
130
+ }
131
+ /**
132
+ * Ends one session: the operator who holds it may always end their own, no
133
+ * matter what they hold now, because that is a safety valve, not a power;
134
+ * ending someone else's needs `break_glass:end-any`.
135
+ */
136
+ export async function endBreakGlass(operatorAccess, db, scopeColumn, sessionId) {
137
+ if (operatorAccess.context === 'break_glass') {
138
+ return refused('break_glass', 'A support session never ends a session as an authority act.');
139
+ }
140
+ if (operatorAccess.tenant.kind !== 'operator') {
141
+ return refused('no_permission', 'You do not have permission to end a support session.');
142
+ }
143
+ return db.transaction(async (tx) => {
144
+ const fresh = await refreshAccess(tx, db, scopeColumn, operatorAccess);
145
+ if (!fresh)
146
+ return refused('no_permission', 'Your operator membership ended.');
147
+ const currentAccess = fresh;
148
+ const [session] = await tx
149
+ .select()
150
+ .from(breakGlassSessions)
151
+ .where(eq(breakGlassSessions.id, sessionId))
152
+ .for('update');
153
+ if (!session || session.endedAt) {
154
+ return refused('session_not_found', 'This support session is not open.');
155
+ }
156
+ const isOwnSession = session.operatorId === currentAccess.actor.id;
157
+ if (!isOwnSession && !permits(currentAccess, 'break_glass:end-any')) {
158
+ return refused('no_permission', "You do not have permission to end another operator's support session.");
159
+ }
160
+ await tx
161
+ .update(breakGlassSessions)
162
+ // `now()` and not a Date from this process. `started_at` defaults to
163
+ // the database's clock, and `bg_ended_check` requires `ended_at` not to
164
+ // precede it; taking one end from Node and the other from Postgres
165
+ // makes that CHECK a race on the few milliseconds between two clocks,
166
+ // which is exactly how it failed intermittently in the integration
167
+ // tier. One clock decides both ends of a session.
168
+ .set({ endedAt: sql `now()`, endedBy: currentAccess.actor.id })
169
+ .where(eq(breakGlassSessions.id, sessionId));
170
+ await record(tx, currentAccess, {
171
+ action: 'break_glass.ended',
172
+ tenantId: session.tenantId,
173
+ targetType: 'break_glass_session',
174
+ targetId: session.id,
175
+ reason: session.reasonCode,
176
+ reference: session.reference,
177
+ });
178
+ return done(undefined, [tenantTag(session.tenantId)]);
179
+ });
180
+ }
181
+ /**
182
+ * Force-ends every session one operator holds, inside whatever transaction
183
+ * the caller already has open: a host's own `memberships.ts` calls this the
184
+ * moment an operator-tenant membership is removed or changed to a role
185
+ * without `break_glass:start`, so a session never outlives the authority
186
+ * that opened it (M9). Written as the `service:authz` actor rather than
187
+ * through `record`, because there is no `Access` for the operator whose
188
+ * session this is inside a transaction that is busy ending their
189
+ * membership, not building one.
190
+ */
191
+ export async function endSessionsFor(tx, operatorId, reason) {
192
+ const sessions = await tx
193
+ .select()
194
+ .from(breakGlassSessions)
195
+ .where(and(eq(breakGlassSessions.operatorId, operatorId), isNull(breakGlassSessions.endedAt)));
196
+ for (const session of sessions) {
197
+ await tx
198
+ .update(breakGlassSessions)
199
+ // The same one-clock rule as endBreakGlass above.
200
+ .set({ endedAt: sql `now()`, endedBy: null })
201
+ .where(eq(breakGlassSessions.id, session.id));
202
+ await writeServiceEvent(tx, { id: 'authz', display: 'authz' }, {
203
+ action: 'break_glass.ended',
204
+ tenantId: session.tenantId,
205
+ targetType: 'break_glass_session',
206
+ targetId: session.id,
207
+ reason,
208
+ reference: session.reference,
209
+ context: 'operator',
210
+ });
211
+ }
212
+ }
213
+ /**
214
+ * The session that would currently grant access, or null: not yet ended and
215
+ * not past its expiry. A host's own `access.ts` reads this to resolve a
216
+ * session `Access`, and `startBreakGlass` reads it to refuse a second
217
+ * session on top of one that is already live; an expired-but-unended row
218
+ * counts as gone for both.
219
+ */
220
+ export async function activeSession(tx, operatorId, tenantId) {
221
+ const [row] = await tx
222
+ .select()
223
+ .from(breakGlassSessions)
224
+ .where(and(eq(breakGlassSessions.operatorId, operatorId), eq(breakGlassSessions.tenantId, tenantId), isNull(breakGlassSessions.endedAt), gt(breakGlassSessions.expiresAt, sql `now()`)))
225
+ .limit(1);
226
+ return row ?? null;
227
+ }
228
+ /**
229
+ * Sessions for the console: one tenant's history when `tenantId` is given,
230
+ * or the calling operator's own history otherwise. A page-rendering read
231
+ * with no transaction to join, the same as `assignableRoles`; the caller is
232
+ * expected to have already resolved `operatorAccess` through its own
233
+ * `requireOperator` with whatever permission its own page needs. `db` is the
234
+ * host's root handle, taken explicitly rather than imported.
235
+ */
236
+ export async function sessionsFor(operatorAccess, db, options = {}) {
237
+ if (operatorAccess.tenant.kind !== 'operator')
238
+ return [];
239
+ const condition = options.tenantId
240
+ ? eq(breakGlassSessions.tenantId, options.tenantId)
241
+ : eq(breakGlassSessions.operatorId, operatorAccess.actor.id);
242
+ return db
243
+ .select()
244
+ .from(breakGlassSessions)
245
+ .where(condition)
246
+ .orderBy(desc(breakGlassSessions.startedAt));
247
+ }