@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
@@ -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: true;
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
- secretHash: text('secret_hash').notNull().unique(),
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;
@@ -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>>;