@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
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
|
+
}
|