@wtfalch/authz-store 0.2.1 → 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/grants.d.ts +2 -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/nesting.d.ts +124 -0
- package/dist/nesting.js +515 -0
- 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 +449 -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/roles.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ResourceRole } from '@wtfalch/authz';
|
|
2
|
+
import type { StoreBinding } from './binding.js';
|
|
3
|
+
import type { DbOrTx, Scope } from './scoped.js';
|
|
4
|
+
import { type Access, type Result } from './types.js';
|
|
5
|
+
export declare function roleDef(tx: DbOrTx, binding: StoreBinding, tenant: {
|
|
6
|
+
id: string;
|
|
7
|
+
}, key: string): Promise<ResourceRole | null>;
|
|
8
|
+
export declare function assignableRoles(access: Access): Promise<ResourceRole[]>;
|
|
9
|
+
export interface EditableRole extends ResourceRole {
|
|
10
|
+
readonly custom: boolean;
|
|
11
|
+
readonly editable: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function rolesForEditor(access: Access): Promise<EditableRole[]>;
|
|
14
|
+
/** Definition edits must cover both its full scope and every existing recipient/scope. */
|
|
15
|
+
export declare function canEditDefinition(access: Access, role: ResourceRole): boolean;
|
|
16
|
+
export interface CreateRoleOptions {
|
|
17
|
+
readonly key: string;
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly description?: string;
|
|
20
|
+
readonly entries: ResourceRole['entries'];
|
|
21
|
+
}
|
|
22
|
+
export interface UpdateRoleOptions {
|
|
23
|
+
readonly key: string;
|
|
24
|
+
readonly revision: number;
|
|
25
|
+
readonly name?: string;
|
|
26
|
+
readonly description?: string;
|
|
27
|
+
readonly entries?: ResourceRole['entries'];
|
|
28
|
+
}
|
|
29
|
+
export interface DeleteRoleOptions {
|
|
30
|
+
readonly key: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function createRole(access: Access, db: DbOrTx, scopeColumn: Scope['column'], options: CreateRoleOptions): Promise<Result<{
|
|
33
|
+
key: string;
|
|
34
|
+
}>>;
|
|
35
|
+
export declare function updateRole(access: Access, db: DbOrTx, scopeColumn: Scope['column'], options: UpdateRoleOptions): Promise<Result<void>>;
|
|
36
|
+
export declare function deleteRole(access: Access, db: DbOrTx, scopeColumn: Scope['column'], options: DeleteRoleOptions): Promise<Result<void>>;
|
package/dist/roles.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { core, resourceRoleSchema } from '@wtfalch/authz';
|
|
3
|
+
import { and, eq } from 'drizzle-orm';
|
|
4
|
+
import { primaryAssignment, roleAssignmentRefusal } from './assignments.js';
|
|
5
|
+
import { record } from './audit.js';
|
|
6
|
+
import { permits, policyAssignment, policyRole, refreshAccess } from './policy-access.js';
|
|
7
|
+
import { policyAssignments, policyRoles } from './policy-schema.js';
|
|
8
|
+
import { isCustomRoleKey } from './role-keys.js';
|
|
9
|
+
import { invitations } from './schema.js';
|
|
10
|
+
import { done, refused, tenantTag } from './types.js';
|
|
11
|
+
export async function roleDef(tx, binding, tenant, key) {
|
|
12
|
+
const [row] = await tx
|
|
13
|
+
.select()
|
|
14
|
+
.from(policyRoles)
|
|
15
|
+
.where(and(eq(policyRoles.tenantId, tenant.id), eq(policyRoles.applicationId, binding.applicationId), eq(policyRoles.platformId, binding.platformId), eq(policyRoles.key, key)))
|
|
16
|
+
.limit(1);
|
|
17
|
+
return row ? policyRole(row) : null;
|
|
18
|
+
}
|
|
19
|
+
export async function assignableRoles(access) {
|
|
20
|
+
return access.policyState.roleRows
|
|
21
|
+
.map(policyRole)
|
|
22
|
+
.filter((role) => role.key !== 'self_service')
|
|
23
|
+
.filter((role) => !roleAssignmentRefusal(access, role, primaryAssignment(role, { id: 'prospective-person', class: 'human' }), 'members:grant'));
|
|
24
|
+
}
|
|
25
|
+
export async function rolesForEditor(access) {
|
|
26
|
+
if (!permits(access, 'roles:read') || access.context === 'break_glass')
|
|
27
|
+
return [];
|
|
28
|
+
return access.policyState.roleRows.map(policyRole).map((role) => ({
|
|
29
|
+
...role,
|
|
30
|
+
custom: !role.builtIn,
|
|
31
|
+
editable: !role.builtIn && permits(access, 'roles:update') && canEditDefinition(access, role),
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
/** Definition edits must cover both its full scope and every existing recipient/scope. */
|
|
35
|
+
export function canEditDefinition(access, role) {
|
|
36
|
+
if (roleAssignmentRefusal(access, role, primaryAssignment(role, { id: 'prospective-person', class: 'human' }), null))
|
|
37
|
+
return false;
|
|
38
|
+
return access.policyState.assignmentRows
|
|
39
|
+
.filter((a) => a.roleId === role.id && (!a.expiresAt || a.expiresAt.getTime() > Date.now()))
|
|
40
|
+
.every((a) => !roleAssignmentRefusal(access, role, policyAssignment(a), null));
|
|
41
|
+
}
|
|
42
|
+
const invalid = () => refused('invalid_role', 'Choose valid operations, boundaries and scopes within your authority.');
|
|
43
|
+
export async function createRole(access, db, scopeColumn, options) {
|
|
44
|
+
if (access.context === 'break_glass')
|
|
45
|
+
return refused('break_glass', 'Support sessions cannot change access.');
|
|
46
|
+
return db.transaction(async (tx) => {
|
|
47
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, access);
|
|
48
|
+
if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'roles:create'))
|
|
49
|
+
return refused('no_permission', 'You cannot create roles here.');
|
|
50
|
+
if (!/^c_[a-z0-9_-]{1,61}$/.test(options.key))
|
|
51
|
+
return refused('invalid_key', 'Choose a custom role key starting with c_.');
|
|
52
|
+
const candidate = resourceRoleSchema.safeParse({
|
|
53
|
+
id: randomUUID(),
|
|
54
|
+
key: options.key,
|
|
55
|
+
applicationId: fresh.binding.applicationId,
|
|
56
|
+
platformId: fresh.binding.platformId,
|
|
57
|
+
organisationId: fresh.tenant.id,
|
|
58
|
+
label: options.name,
|
|
59
|
+
description: options.description || options.name,
|
|
60
|
+
revision: 1,
|
|
61
|
+
builtIn: false,
|
|
62
|
+
entries: options.entries,
|
|
63
|
+
guards: [],
|
|
64
|
+
});
|
|
65
|
+
if (!candidate.success || !canEditDefinition(fresh, candidate.data))
|
|
66
|
+
return invalid();
|
|
67
|
+
if (fresh.policyState.roleRows.some((r) => r.key === options.key))
|
|
68
|
+
return refused('key_taken', 'A role with that key already exists.');
|
|
69
|
+
// Not `!r.builtIn`: a starter role (ADR 0016) is `built_in: false` for a
|
|
70
|
+
// tenant created after that change, but it is not tenant-authored, and
|
|
71
|
+
// must not eat into the custom-role cap the way a real `c_*` role does.
|
|
72
|
+
if (fresh.policyState.roleRows.filter((r) => isCustomRoleKey(r.key)).length >=
|
|
73
|
+
core.roleKey.maxCustomPerTenant)
|
|
74
|
+
return refused('cap_reached', 'This organisation has reached its custom role limit.');
|
|
75
|
+
const role = candidate.data;
|
|
76
|
+
await tx.insert(policyRoles).values({
|
|
77
|
+
id: role.id,
|
|
78
|
+
tenantId: role.organisationId,
|
|
79
|
+
applicationId: fresh.binding.applicationId,
|
|
80
|
+
platformId: fresh.binding.platformId,
|
|
81
|
+
key: role.key,
|
|
82
|
+
name: role.label,
|
|
83
|
+
description: role.description,
|
|
84
|
+
revision: role.revision,
|
|
85
|
+
builtIn: false,
|
|
86
|
+
entries: role.entries,
|
|
87
|
+
guards: [],
|
|
88
|
+
createdBy: fresh.actor.id,
|
|
89
|
+
});
|
|
90
|
+
await record(tx, fresh, {
|
|
91
|
+
action: 'role.created',
|
|
92
|
+
tenantId: fresh.tenant.id,
|
|
93
|
+
targetType: 'role',
|
|
94
|
+
targetId: role.id,
|
|
95
|
+
after: role,
|
|
96
|
+
});
|
|
97
|
+
return done({ key: role.key }, [tenantTag(fresh.tenant.id)]);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export async function updateRole(access, db, scopeColumn, options) {
|
|
101
|
+
if (access.context === 'break_glass')
|
|
102
|
+
return refused('break_glass', 'Support sessions cannot change access.');
|
|
103
|
+
return db.transaction(async (tx) => {
|
|
104
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, access);
|
|
105
|
+
if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'roles:update'))
|
|
106
|
+
return refused('no_permission', 'You cannot update roles here.');
|
|
107
|
+
const before = await roleDef(tx, fresh.binding, fresh.tenant, options.key);
|
|
108
|
+
if (!before)
|
|
109
|
+
return refused('unknown_role', 'This role no longer exists.');
|
|
110
|
+
if (before.builtIn)
|
|
111
|
+
return refused('built_in', 'Built-in roles are versioned application policy.');
|
|
112
|
+
if (before.revision !== options.revision)
|
|
113
|
+
return refused('stale_revision', 'This role changed. Reload it before saving.');
|
|
114
|
+
const parsed = resourceRoleSchema.safeParse({
|
|
115
|
+
...before,
|
|
116
|
+
label: options.name ?? before.label,
|
|
117
|
+
description: options.description ?? before.description,
|
|
118
|
+
entries: options.entries ?? before.entries,
|
|
119
|
+
revision: before.revision + 1,
|
|
120
|
+
});
|
|
121
|
+
if (!parsed.success ||
|
|
122
|
+
!canEditDefinition(fresh, before) ||
|
|
123
|
+
!canEditDefinition(fresh, parsed.data))
|
|
124
|
+
return invalid();
|
|
125
|
+
const after = parsed.data;
|
|
126
|
+
await tx
|
|
127
|
+
.update(policyRoles)
|
|
128
|
+
.set({
|
|
129
|
+
name: after.label,
|
|
130
|
+
description: after.description,
|
|
131
|
+
entries: after.entries,
|
|
132
|
+
revision: after.revision,
|
|
133
|
+
updatedAt: new Date(),
|
|
134
|
+
// ADR 0016's Aged-and-Independent approver rule reads this to exclude
|
|
135
|
+
// an approver whose eligibility the requester granted by editing a
|
|
136
|
+
// role. `created_by` cannot tell an edit's actor apart from the role's
|
|
137
|
+
// original author, which is why drizzle/0014_activations.sql added the
|
|
138
|
+
// column. Left unwritten, the exclusion never fires: a NULL is
|
|
139
|
+
// distinct from every requester id.
|
|
140
|
+
updatedBy: fresh.actor.id,
|
|
141
|
+
})
|
|
142
|
+
.where(eq(policyRoles.id, after.id));
|
|
143
|
+
await record(tx, fresh, {
|
|
144
|
+
action: 'role.updated',
|
|
145
|
+
tenantId: fresh.tenant.id,
|
|
146
|
+
targetType: 'role',
|
|
147
|
+
targetId: after.id,
|
|
148
|
+
before,
|
|
149
|
+
after,
|
|
150
|
+
});
|
|
151
|
+
return done(undefined, [tenantTag(fresh.tenant.id)]);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
export async function deleteRole(access, db, scopeColumn, options) {
|
|
155
|
+
if (access.context === 'break_glass')
|
|
156
|
+
return refused('break_glass', 'Support sessions cannot change access.');
|
|
157
|
+
return db.transaction(async (tx) => {
|
|
158
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, access);
|
|
159
|
+
if (!fresh || fresh.context === 'break_glass' || !permits(fresh, 'roles:delete'))
|
|
160
|
+
return refused('no_permission', 'You cannot delete roles here.');
|
|
161
|
+
const role = await roleDef(tx, fresh.binding, fresh.tenant, options.key);
|
|
162
|
+
if (!role)
|
|
163
|
+
return refused('unknown_role', 'This role no longer exists.');
|
|
164
|
+
if (role.builtIn)
|
|
165
|
+
return refused('built_in', 'Built-in roles are versioned application policy.');
|
|
166
|
+
if (!canEditDefinition(fresh, role))
|
|
167
|
+
return invalid();
|
|
168
|
+
const [assignment] = await tx
|
|
169
|
+
.select({ id: policyAssignments.id })
|
|
170
|
+
.from(policyAssignments)
|
|
171
|
+
.where(eq(policyAssignments.roleId, role.id))
|
|
172
|
+
.limit(1);
|
|
173
|
+
const [invitation] = await tx
|
|
174
|
+
.select({ id: invitations.id })
|
|
175
|
+
.from(invitations)
|
|
176
|
+
.where(eq(invitations.roleId, role.id))
|
|
177
|
+
.limit(1);
|
|
178
|
+
if (assignment || invitation)
|
|
179
|
+
return refused('role_in_use', 'This role is referenced by assignments or invitation history.');
|
|
180
|
+
await tx.delete(policyRoles).where(eq(policyRoles.id, role.id));
|
|
181
|
+
await record(tx, fresh, {
|
|
182
|
+
action: 'role.deleted',
|
|
183
|
+
tenantId: fresh.tenant.id,
|
|
184
|
+
targetType: 'role',
|
|
185
|
+
targetId: role.id,
|
|
186
|
+
before: role,
|
|
187
|
+
after: null,
|
|
188
|
+
});
|
|
189
|
+
return done(undefined, [tenantTag(fresh.tenant.id)]);
|
|
190
|
+
});
|
|
191
|
+
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -1268,7 +1268,7 @@ export declare const credentials: import("drizzle-orm/pg-core").PgTableWithColum
|
|
|
1268
1268
|
columnType: "PgText";
|
|
1269
1269
|
data: string;
|
|
1270
1270
|
driverParam: string;
|
|
1271
|
-
notNull:
|
|
1271
|
+
notNull: false;
|
|
1272
1272
|
hasDefault: false;
|
|
1273
1273
|
isPrimaryKey: false;
|
|
1274
1274
|
isAutoincrement: false;
|
|
@@ -1295,6 +1295,23 @@ export declare const credentials: import("drizzle-orm/pg-core").PgTableWithColum
|
|
|
1295
1295
|
identity: undefined;
|
|
1296
1296
|
generated: undefined;
|
|
1297
1297
|
}, {}, {}>;
|
|
1298
|
+
keysIssuedId: import("drizzle-orm/pg-core").PgColumn<{
|
|
1299
|
+
name: "keys_issued_id";
|
|
1300
|
+
tableName: "credentials";
|
|
1301
|
+
dataType: "string";
|
|
1302
|
+
columnType: "PgText";
|
|
1303
|
+
data: string;
|
|
1304
|
+
driverParam: string;
|
|
1305
|
+
notNull: false;
|
|
1306
|
+
hasDefault: false;
|
|
1307
|
+
isPrimaryKey: false;
|
|
1308
|
+
isAutoincrement: false;
|
|
1309
|
+
hasRuntimeDefault: false;
|
|
1310
|
+
enumValues: [string, ...string[]];
|
|
1311
|
+
baseColumn: never;
|
|
1312
|
+
identity: undefined;
|
|
1313
|
+
generated: undefined;
|
|
1314
|
+
}, {}, {}>;
|
|
1298
1315
|
createdBy: import("drizzle-orm/pg-core").PgColumn<{
|
|
1299
1316
|
name: "created_by";
|
|
1300
1317
|
tableName: "credentials";
|
package/dist/schema.js
CHANGED
|
@@ -141,10 +141,17 @@ export const credentials = pgTable('credentials', {
|
|
|
141
141
|
issuerId: text('issuer_id').notNull(),
|
|
142
142
|
issuerClass: text('issuer_class').notNull(),
|
|
143
143
|
name: text('name').notNull(),
|
|
144
|
-
|
|
144
|
+
// Nullable since 0004: a credential minted through `@wtfalch/keys/issued`
|
|
145
|
+
// carries no hash of its own here (its secret is checked against that
|
|
146
|
+
// package's own signed row instead); a pre-cutover row still has one.
|
|
147
|
+
secretHash: text('secret_hash').unique(),
|
|
145
148
|
// The first characters of the secret's random half, kept in clear so a
|
|
146
149
|
// list can tell one credential from another (0006). Not a secret.
|
|
147
150
|
secretPrefix: text('secret_prefix'),
|
|
151
|
+
// The matching `keys_issued_credentials.id` (0004), so a revoke can
|
|
152
|
+
// revoke it there too. Null on a pre-cutover row and on every row
|
|
153
|
+
// `credential-secret.ts` still mints.
|
|
154
|
+
keysIssuedId: text('keys_issued_id'),
|
|
148
155
|
createdBy: text('created_by'),
|
|
149
156
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().default(sql `now()`),
|
|
150
157
|
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type AuthzReporting } from './alerts.js';
|
|
2
|
+
import type { StoreBinding } from './binding.js';
|
|
3
|
+
import type { StorePolicy } from './policy.js';
|
|
4
|
+
import type { DbOrTx } from './scoped.js';
|
|
5
|
+
/**
|
|
6
|
+
* The authorisation checks a host runs once when its server starts, the
|
|
7
|
+
* store's half of Boule's own `src/lib/authz/startup.ts` (`src/instrumentation.ts`
|
|
8
|
+
* called `runStartupChecks()` with no arguments there; this version takes
|
|
9
|
+
* `db`, `policy`, `reporting` and `housekeeping` explicitly, the same split
|
|
10
|
+
* every other moved module makes).
|
|
11
|
+
*
|
|
12
|
+
* **One of these refuses to serve.** `checkSystemRoles` verifies the policy
|
|
13
|
+
* schema version and the built-in role revisions against the database. A v2
|
|
14
|
+
* binary in front of a database a maintenance migration has not converted,
|
|
15
|
+
* or the other way round, must not answer a single request, so the error is
|
|
16
|
+
* reported and rethrown.
|
|
17
|
+
*
|
|
18
|
+
* Boule's own version returned early with no `DATABASE_URL` (a public
|
|
19
|
+
* preview build with nothing to check). That check is dropped here: this
|
|
20
|
+
* function takes `db` as a required parameter, so a host that has no
|
|
21
|
+
* database to check simply does not call it, rather than this package
|
|
22
|
+
* inspecting `process.env` (which it never does, being framework-neutral)
|
|
23
|
+
* to decide the same thing for itself.
|
|
24
|
+
*
|
|
25
|
+
* **Nothing else does.** The orphaned-role-key scan lists membership and
|
|
26
|
+
* invitation rows naming a role this build cannot resolve, the shape a
|
|
27
|
+
* rollback leaves behind; those rows already resolve to nothing, so a
|
|
28
|
+
* failure here is a finding, never an outage. Findings go to `reporting`,
|
|
29
|
+
* and the daily alerts and the nightly drift check are registered on
|
|
30
|
+
* `housekeeping` at the end.
|
|
31
|
+
*/
|
|
32
|
+
export declare function runStartupChecks(db: DbOrTx, policy: StorePolicy, reporting: AuthzReporting, housekeeping: HousekeepingRegistry): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* The structural subset of a host's housekeeping registry (Boule's own
|
|
35
|
+
* `@/lib/reporting/core`'s `housekeeping`) that `registerHousekeeping`
|
|
36
|
+
* calls: one job registration, run on a lease so two containers never both
|
|
37
|
+
* run the same tick at once.
|
|
38
|
+
*/
|
|
39
|
+
export interface HousekeepingRegistry {
|
|
40
|
+
register(job: {
|
|
41
|
+
name: string;
|
|
42
|
+
every: number;
|
|
43
|
+
lease: number;
|
|
44
|
+
retry: number;
|
|
45
|
+
run(ctx: {
|
|
46
|
+
db: DbOrTx;
|
|
47
|
+
}): Promise<void>;
|
|
48
|
+
}): void;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The four alerts daily and the drift check nightly, on the housekeeping
|
|
52
|
+
* tick: claimed with a row lock, so two containers never both run one, and
|
|
53
|
+
* reported through `reportAlert`, so a finding is a row in the host's event
|
|
54
|
+
* log. Idempotent to register, because `runStartupChecks` is called once per
|
|
55
|
+
* process and the registry keys on the name.
|
|
56
|
+
*/
|
|
57
|
+
export declare function registerHousekeeping(housekeeping: HousekeepingRegistry, binding: StoreBinding, reporting: AuthzReporting): void;
|
package/dist/startup.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { reportAlert, runAlerts } from './alerts.js';
|
|
2
|
+
import { checkSystemRoles, orphanedRoleKeys } from './boot.js';
|
|
3
|
+
import { reconcileDerived } from './reconcile.js';
|
|
4
|
+
/**
|
|
5
|
+
* The authorisation checks a host runs once when its server starts, the
|
|
6
|
+
* store's half of Boule's own `src/lib/authz/startup.ts` (`src/instrumentation.ts`
|
|
7
|
+
* called `runStartupChecks()` with no arguments there; this version takes
|
|
8
|
+
* `db`, `policy`, `reporting` and `housekeeping` explicitly, the same split
|
|
9
|
+
* every other moved module makes).
|
|
10
|
+
*
|
|
11
|
+
* **One of these refuses to serve.** `checkSystemRoles` verifies the policy
|
|
12
|
+
* schema version and the built-in role revisions against the database. A v2
|
|
13
|
+
* binary in front of a database a maintenance migration has not converted,
|
|
14
|
+
* or the other way round, must not answer a single request, so the error is
|
|
15
|
+
* reported and rethrown.
|
|
16
|
+
*
|
|
17
|
+
* Boule's own version returned early with no `DATABASE_URL` (a public
|
|
18
|
+
* preview build with nothing to check). That check is dropped here: this
|
|
19
|
+
* function takes `db` as a required parameter, so a host that has no
|
|
20
|
+
* database to check simply does not call it, rather than this package
|
|
21
|
+
* inspecting `process.env` (which it never does, being framework-neutral)
|
|
22
|
+
* to decide the same thing for itself.
|
|
23
|
+
*
|
|
24
|
+
* **Nothing else does.** The orphaned-role-key scan lists membership and
|
|
25
|
+
* invitation rows naming a role this build cannot resolve, the shape a
|
|
26
|
+
* rollback leaves behind; those rows already resolve to nothing, so a
|
|
27
|
+
* failure here is a finding, never an outage. Findings go to `reporting`,
|
|
28
|
+
* and the daily alerts and the nightly drift check are registered on
|
|
29
|
+
* `housekeeping` at the end.
|
|
30
|
+
*/
|
|
31
|
+
export async function runStartupChecks(db, policy, reporting, housekeeping) {
|
|
32
|
+
try {
|
|
33
|
+
const results = await checkSystemRoles(db, policy);
|
|
34
|
+
const updated = results.filter((r) => r.status === 'updated');
|
|
35
|
+
if (updated.length > 0) {
|
|
36
|
+
// Not an alert: a deploy changing a bundle is a thing somebody did on
|
|
37
|
+
// purpose, and the durable record is the `role.updated` row the check
|
|
38
|
+
// has already written. This line is for whoever is watching the
|
|
39
|
+
// deploy.
|
|
40
|
+
reporting.log.info({ roles: updated.map((r) => `${r.kind}/${r.roleKey}`) }, `authz: ${updated.length} system role bundle(s) changed and were recorded`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
reporting.log.error({ err: error instanceof Error ? error.message : String(error) }, 'authz: the policy check refused this database; not serving');
|
|
45
|
+
reporting.captureError(error, { kind: 'authz-v2-policy' });
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const orphans = await orphanedRoleKeys(db, policy);
|
|
50
|
+
if (orphans.length > 0) {
|
|
51
|
+
reportAlert(reporting, {
|
|
52
|
+
check: 'orphaned_role_keys',
|
|
53
|
+
message: `${orphans.length} membership or invitation row(s) name a role this build does not know.`,
|
|
54
|
+
detail: orphans,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
reporting.log.error({ err: error instanceof Error ? error.message : String(error) }, 'authz: the orphaned role key scan failed');
|
|
60
|
+
reporting.alert({
|
|
61
|
+
check: 'orphaned_role_keys_scan_failed',
|
|
62
|
+
message: 'the orphaned role key scan failed; the error is in the container log',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
registerHousekeeping(housekeeping, policy, reporting);
|
|
66
|
+
}
|
|
67
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
68
|
+
/**
|
|
69
|
+
* The four alerts daily and the drift check nightly, on the housekeeping
|
|
70
|
+
* tick: claimed with a row lock, so two containers never both run one, and
|
|
71
|
+
* reported through `reportAlert`, so a finding is a row in the host's event
|
|
72
|
+
* log. Idempotent to register, because `runStartupChecks` is called once per
|
|
73
|
+
* process and the registry keys on the name.
|
|
74
|
+
*/
|
|
75
|
+
export function registerHousekeeping(housekeeping, binding, reporting) {
|
|
76
|
+
housekeeping.register({
|
|
77
|
+
name: 'authz.alerts',
|
|
78
|
+
every: DAY,
|
|
79
|
+
lease: 5 * 60 * 1000,
|
|
80
|
+
retry: 60 * 60 * 1000,
|
|
81
|
+
async run(ctx) {
|
|
82
|
+
// `runAlerts` reports each finding itself; the event here is the run.
|
|
83
|
+
const findings = await runAlerts(ctx.db, binding, reporting);
|
|
84
|
+
reporting.event({
|
|
85
|
+
kind: 'authz.alerts_ran',
|
|
86
|
+
message: `${findings.length} alert(s) fired`,
|
|
87
|
+
data: { findings: findings.length },
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
housekeeping.register({
|
|
92
|
+
name: 'authz.reconcile',
|
|
93
|
+
every: DAY,
|
|
94
|
+
lease: 10 * 60 * 1000,
|
|
95
|
+
retry: 60 * 60 * 1000,
|
|
96
|
+
async run(ctx) {
|
|
97
|
+
const report = await reconcileDerived(ctx.db, binding);
|
|
98
|
+
const drift = report.orphans.length + report.missing.length + report.wrongRole.length;
|
|
99
|
+
reporting.event({
|
|
100
|
+
kind: 'authz.reconciled',
|
|
101
|
+
level: drift > 0 ? 'warn' : 'info',
|
|
102
|
+
message: drift > 0
|
|
103
|
+
? `derived memberships drifted: ${drift} row(s)`
|
|
104
|
+
: 'derived memberships agree with the tenant tree',
|
|
105
|
+
data: {
|
|
106
|
+
orphans: report.orphans.length,
|
|
107
|
+
missing: report.missing.length,
|
|
108
|
+
wrongRole: report.wrongRole.length,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { TenantKind, TenantState } from '@wtfalch/authz';
|
|
2
|
+
import type { OwnerActivityLookup } from './install-owner.js';
|
|
3
|
+
import type { StorePolicy } from './policy.js';
|
|
4
|
+
import { type Tenant } from './schema.js';
|
|
5
|
+
import type { DbOrTx, Scope } from './scoped.js';
|
|
6
|
+
import { type Access, type Principal, type Result } from './types.js';
|
|
7
|
+
/** Who may create a new tenant, mirroring Boule's own `TenantCreation` (`config.ts`). */
|
|
8
|
+
export type TenantCreationMode = 'anyone' | 'operator' | 'off';
|
|
9
|
+
export interface CreateTenantConfig {
|
|
10
|
+
/** The host's own `TENANT_CREATION` (decision 3: no longer a package constant). */
|
|
11
|
+
readonly creation: TenantCreationMode;
|
|
12
|
+
/** The host's own `TENANT_CREATION_CAP_PER_DAY`. */
|
|
13
|
+
readonly capPerDay: number;
|
|
14
|
+
}
|
|
15
|
+
export interface CreateTenantOptions {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly slug: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The self-serve path (D10): closed by default (`config.creation !==
|
|
21
|
+
* 'anyone'`), and capped per principal per rolling day when open, refused
|
|
22
|
+
* inside the same transaction that would otherwise create the row so the
|
|
23
|
+
* count and the insert never race. The creator becomes the tenant's first
|
|
24
|
+
* owner in the same transaction, which is also its own audited event, so a
|
|
25
|
+
* tenant is never observed to exist without someone who can run it.
|
|
26
|
+
*
|
|
27
|
+
* `profiles` is the host's own port over its `profiles` (or equivalent)
|
|
28
|
+
* table's one question this function asks -- reusing `Pick<
|
|
29
|
+
* OwnerActivityLookup, 'personKnown'>` from install-owner.ts (decision 6)
|
|
30
|
+
* rather than declaring a second port for the same "has this id ever signed
|
|
31
|
+
* in" question. `policy` is the full `StorePolicy`, not the narrower pick
|
|
32
|
+
* decision 4 names for `setSelfDenied`/`setCeiling`: this function also
|
|
33
|
+
* drives `ensureBuiltInRoles`/`seedStarterRoles`/`roleDef`, which need the
|
|
34
|
+
* role-template methods and `applicationId`/`platformId` a bare
|
|
35
|
+
* `Pick<StorePolicy, 'defaultCeiling' | 'offered'>` does not carry.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createTenant(principal: Principal, db: DbOrTx, policy: StorePolicy, config: CreateTenantConfig, profiles: Pick<OwnerActivityLookup, 'personKnown'>, options: CreateTenantOptions): Promise<Result<{
|
|
38
|
+
id: string;
|
|
39
|
+
}>>;
|
|
40
|
+
export interface RenameTenantOptions {
|
|
41
|
+
readonly name?: string;
|
|
42
|
+
readonly slug?: string;
|
|
43
|
+
}
|
|
44
|
+
/** Renames or re-slugs a tenant, keeping `memberships.tenant_name` in step (D5) when the name changes. */
|
|
45
|
+
export declare function renameTenant(access: Access, db: DbOrTx, scopeColumn: Scope['column'], options: RenameTenantOptions): Promise<Result<void>>;
|
|
46
|
+
/**
|
|
47
|
+
* Sets what this tenant has switched off for itself, within what it was
|
|
48
|
+
* offered (D22). Checked before the transaction opens means a bad list never
|
|
49
|
+
* reaches a lock at all; nothing at read time trips over a stored value that
|
|
50
|
+
* should never have been written.
|
|
51
|
+
*/
|
|
52
|
+
export declare function setSelfDenied(access: Access, db: DbOrTx, scopeColumn: Scope['column'], policy: Pick<StorePolicy, 'defaultCeiling' | 'offered'>, list: readonly string[]): Promise<Result<void>>;
|
|
53
|
+
export interface SetTenantStateOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Apply the same state to every descendant in the same transaction,
|
|
56
|
+
* writing one event per tenant actually changed (D19's fourth rule).
|
|
57
|
+
* Without it, tenant state stays per tenant: a suspended parent does not
|
|
58
|
+
* suspend its children, because the reasons differ.
|
|
59
|
+
*/
|
|
60
|
+
readonly cascade?: boolean;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Moves a tenant between active, read-only, suspended and archived (D11).
|
|
64
|
+
* `operatorAccess` must be resolved against the operator tenant, which is
|
|
65
|
+
* how this refuses a signed-in customer who somehow reaches the call: their
|
|
66
|
+
* `Access` was never built from the operator tenant, so `kind !== 'operator'`
|
|
67
|
+
* catches it before the permission check even runs. The operator tenant
|
|
68
|
+
* itself can never be the target, or every operator locks themselves out.
|
|
69
|
+
*
|
|
70
|
+
* Archiving is refused while the tenant holds children (D19's fifth rule),
|
|
71
|
+
* unless `cascade` is asked for: cascading archives every descendant in the
|
|
72
|
+
* same breath, which is what satisfies "detach or archive them first" in one
|
|
73
|
+
* transaction rather than requiring it to have already happened.
|
|
74
|
+
*/
|
|
75
|
+
export declare function setTenantState(operatorAccess: Access, db: DbOrTx, scopeColumn: Scope['column'], tenantId: string, state: TenantState, options?: SetTenantStateOptions): Promise<Result<void>>;
|
|
76
|
+
/**
|
|
77
|
+
* Sets what a tenant may use (D22), recorded as `tenant.ceiling_changed`.
|
|
78
|
+
* `platform.tenants:ceiling` governs operator reach; `tenants:ceiling`
|
|
79
|
+
* governs a parent's own children. Both require explicit canonical grants.
|
|
80
|
+
* The target's parent relationship is read from the locked row rather than
|
|
81
|
+
* trusted from the caller. A ceiling that would exceed the target's own
|
|
82
|
+
* parent's is refused with the offenders named regardless of which route
|
|
83
|
+
* authorised the call, and narrowing clips every descendant in the same
|
|
84
|
+
* transaction, nearest level first, skipping a descendant whose ceiling was
|
|
85
|
+
* already inside the new one (no change, no event).
|
|
86
|
+
*/
|
|
87
|
+
export declare function setCeiling(access: Access, db: DbOrTx, scopeColumn: Scope['column'], policy: Pick<StorePolicy, 'defaultCeiling' | 'offered'>, tenantId: string, list: readonly string[]): Promise<Result<void>>;
|
|
88
|
+
/** A tenant by id, or null. Takes whatever handle the caller already has open, raw or scoped-out. */
|
|
89
|
+
export declare function tenantById(handle: DbOrTx, id: string): Promise<Tenant | null>;
|
|
90
|
+
/** A tenant by slug, or null. Used to resolve `/org/<slug>` before a membership is known. */
|
|
91
|
+
export declare function tenantBySlug(handle: DbOrTx, slug: string): Promise<Tenant | null>;
|
|
92
|
+
/** One row of the operator console's tenant list. */
|
|
93
|
+
export interface TenantSummary {
|
|
94
|
+
readonly id: string;
|
|
95
|
+
readonly name: string;
|
|
96
|
+
readonly slug: string;
|
|
97
|
+
readonly kind: TenantKind;
|
|
98
|
+
readonly state: TenantState;
|
|
99
|
+
readonly parentId: string | null;
|
|
100
|
+
readonly ceiling: readonly string[];
|
|
101
|
+
readonly selfDenied: readonly string[];
|
|
102
|
+
}
|
|
103
|
+
export interface AllTenantsPage {
|
|
104
|
+
readonly items: readonly TenantSummary[];
|
|
105
|
+
readonly next: {
|
|
106
|
+
readonly name: string;
|
|
107
|
+
readonly id: string;
|
|
108
|
+
} | null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Every organisation, for the operator console (D12).
|
|
112
|
+
*
|
|
113
|
+
* **`tenants:read-all`, which is an operator-scoped permission**, so this is
|
|
114
|
+
* the estate looking at its customers rather than a customer looking at
|
|
115
|
+
* itself. `null` when the actor does not hold it, never an empty page, the
|
|
116
|
+
* distinction `membersOf` and `securityLogFor` both draw: an operator with a
|
|
117
|
+
* narrowed role seeing "no organisations" would conclude the estate was
|
|
118
|
+
* empty.
|
|
119
|
+
*
|
|
120
|
+
* **Archived tenants are included here, unlike in `membershipsFor`.** A
|
|
121
|
+
* member's own switcher hides them because there is nothing they can do with
|
|
122
|
+
* one; an operator's console is exactly where somebody needs to see that a
|
|
123
|
+
* tenant was archived and when. The state is a column on the row rather than
|
|
124
|
+
* a filter on the query.
|
|
125
|
+
*
|
|
126
|
+
* Keyset-paged by `(name, id)`, the same shape and for the same reason as the
|
|
127
|
+
* chooser: an estate's tenant list is the one that grows without bound.
|
|
128
|
+
*
|
|
129
|
+
* `db` is the host's root handle, taken explicitly rather than imported: the
|
|
130
|
+
* store has no database of its own to reach for.
|
|
131
|
+
*/
|
|
132
|
+
export declare function allTenants(access: Access, db: DbOrTx, options?: {
|
|
133
|
+
readonly q?: string;
|
|
134
|
+
readonly after?: {
|
|
135
|
+
name: string;
|
|
136
|
+
id: string;
|
|
137
|
+
};
|
|
138
|
+
readonly limit?: number;
|
|
139
|
+
}): Promise<AllTenantsPage | null>;
|
|
140
|
+
export interface CreateTenantAsOperatorOptions {
|
|
141
|
+
readonly name: string;
|
|
142
|
+
readonly slug: string;
|
|
143
|
+
readonly ownerEmail: string;
|
|
144
|
+
/**
|
|
145
|
+
* Born attached (D10, D19): the tenant is created standalone
|
|
146
|
+
* (`parent_id` null) and this parent is written onto the pending owner
|
|
147
|
+
* invitation instead. The attachment itself happens when the first owner
|
|
148
|
+
* accepts, in the same transaction as their membership; that branch is
|
|
149
|
+
* `acceptInvitation`'s (the propagation agent's), not this function's.
|
|
150
|
+
*/
|
|
151
|
+
readonly attachTo?: string;
|
|
152
|
+
/** The host's own acceptance route (wave 8's decision 3, the same shape `invite` takes): the store never hard-codes one. */
|
|
153
|
+
readonly link: (token: string) => string;
|
|
154
|
+
}
|
|
155
|
+
export interface CreateTenantAsOperatorResult {
|
|
156
|
+
readonly id: string;
|
|
157
|
+
readonly token: string;
|
|
158
|
+
readonly link: string;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The onboarding path valet and lokessmie use every day: the customer does
|
|
162
|
+
* not have an account yet, so the tenant is born with a pending owner
|
|
163
|
+
* invitation and no membership at all (M7). The operator never becomes a
|
|
164
|
+
* member of it, which is what keeps D8's rule (no standing operator
|
|
165
|
+
* membership in a customer tenant) true through onboarding; the last-owner
|
|
166
|
+
* guard tolerates the resulting zero-owner tenant because exactly one owner
|
|
167
|
+
* invitation is pending (`ownerSeatCovered` in `owners.ts`).
|
|
168
|
+
*
|
|
169
|
+
* `policy` is the full `StorePolicy`, the same as `createTenant`: this also
|
|
170
|
+
* drives `ensureBuiltInRoles`/`seedStarterRoles`/`roleDef` and
|
|
171
|
+
* `attachProblem`, none of which a bare `Pick<StorePolicy, 'defaultCeiling'
|
|
172
|
+
* | 'offered'>` would carry.
|
|
173
|
+
*/
|
|
174
|
+
export declare function createTenantAsOperator(operatorAccess: Access, db: DbOrTx, scopeColumn: Scope['column'], policy: StorePolicy, options: CreateTenantAsOperatorOptions): Promise<Result<CreateTenantAsOperatorResult>>;
|
|
175
|
+
/**
|
|
176
|
+
* Undoes `createTenantAsOperator` when a caller's own second write fails
|
|
177
|
+
* afterward and cannot be retried (issue #40: the billing area creates the
|
|
178
|
+
* tenant first and then a `@wtfalch/foundry` `DeclaredOrg` carrying its id,
|
|
179
|
+
* in a different database, so the two can never share one transaction). This
|
|
180
|
+
* is the compensating half -- hard deletes rather than archiving, unlike the
|
|
181
|
+
* ordinary meaning of `tenant:delete` (`denial.ts`'s `tenant.state_changed`):
|
|
182
|
+
* a tenant this refuses to touch is never one that has ever been usable, and
|
|
183
|
+
* archiving would leave a name on the operator's tenant list for something
|
|
184
|
+
* that does not exist. Refuses once anyone has joined -- the same signal
|
|
185
|
+
* `createTenantAsOperator` relies on for a tenant that is still only a
|
|
186
|
+
* pending invitation (M7) -- so this can never reach a tenant somebody may
|
|
187
|
+
* already be looking at.
|
|
188
|
+
*
|
|
189
|
+
* Writes no audit row: `tenant.deleted` is not among `@wtfalch/authz`'s
|
|
190
|
+
* `core.events` (`schema-check.test.ts` holds the database's closed set
|
|
191
|
+
* equal to exactly `core.events` plus a host's own `APP_EVENTS`), and nothing
|
|
192
|
+
* here may widen that package's vocabulary. The `tenant.created` row the
|
|
193
|
+
* failed attempt already wrote stands on the operator log as the record of
|
|
194
|
+
* it; every child row this deletes (`invitations`, `roles`,
|
|
195
|
+
* `role_permissions`) cascades with the tenant, since none of it ever
|
|
196
|
+
* mattered without it.
|
|
197
|
+
*
|
|
198
|
+
* Moves as an ordinary exported store function, copied from archon with its
|
|
199
|
+
* comment. Its own guards make it operator-only; there is no hook or
|
|
200
|
+
* extension interface, since it has no archon-only dependency (issue #99
|
|
201
|
+
* decision 4: "an optional extension" is this, with less machinery, because
|
|
202
|
+
* only archon ever calls it).
|
|
203
|
+
*/
|
|
204
|
+
export declare function deleteJustCreatedTenant(operatorAccess: Access, db: DbOrTx, scopeColumn: Scope['column'], tenantId: string): Promise<Result<void>>;
|
|
205
|
+
/**
|
|
206
|
+
* Archives a tenant from inside it, by whoever holds `tenant:delete` there
|
|
207
|
+
* (the owner: the permission is not assignable, so no other system role
|
|
208
|
+
* carries it). Refused while the tenant holds children (D19's fifth rule):
|
|
209
|
+
* an archived parent's own state says nothing about the organisations
|
|
210
|
+
* hanging off it, so those must be detached or archived on purpose first,
|
|
211
|
+
* not silently along for the ride.
|
|
212
|
+
*/
|
|
213
|
+
export declare function archiveTenant(access: Access, db: DbOrTx, scopeColumn: Scope['column']): Promise<Result<void>>;
|