@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/README.md
CHANGED
|
@@ -9,15 +9,345 @@ credentials, break-glass, the boot checks — and arrived at the same one. Of th
|
|
|
9
9
|
lagging the other or a stale comment. This package is where that code goes so it
|
|
10
10
|
exists once.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
[docs/shared-persistence-map.md](../../docs/shared-persistence-map.md) is the
|
|
13
13
|
map: what moves here, what stays per-application, and why this is a second
|
|
14
14
|
package rather than a subpath on the engine.
|
|
15
15
|
|
|
16
16
|
## Status
|
|
17
17
|
|
|
18
|
-
**
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
**Published.** `@wtfalch/authz-store` is on npm at 0.2.1. "Depend" here means a
|
|
19
|
+
pin in that host's `package.json`, not a verified deployment: Boule (`manage`)
|
|
20
|
+
pins 0.2.1, `people` pins 0.2.1, and otf's `web/` pins 0.2.0. archon still carries its own copy of
|
|
21
|
+
this layer, unmoved.
|
|
22
|
+
|
|
23
|
+
Moved so far: credential secrets, the `ScopedDb` seam, every table definition,
|
|
24
|
+
`ownerCoverage`, wave 1 (authz#83): `StoreBinding` (the `applicationId`,
|
|
25
|
+
`platformId` and `catalogue` a host now passes in explicitly rather than the
|
|
26
|
+
store importing its own), `isCustomRoleKey`, the shared `types` (`Principal`,
|
|
27
|
+
`TenantRow`, `Access`, `Result`), `policyResource`, `loadAccess` and the rest
|
|
28
|
+
of `policy-access` (`loadPolicyState`, `requirePolicySchema`, `permits`,
|
|
29
|
+
`permitsPlatform`, `policyTarget`, `refreshAccess`, `policyRole`,
|
|
30
|
+
`policyAssignment`, `restriction`), and `denial`'s `explain`; and wave 2:
|
|
31
|
+
`auditedReadGate`, `permitsRead` and `permitsPlatformRead` (now in
|
|
32
|
+
`policy-access.ts`, reaching `audit.ts`'s `record()` through a dynamic import
|
|
33
|
+
to avoid a static import cycle), `assignments` (`identityWhere`,
|
|
34
|
+
`primaryRoleKey`, `primaryAssignment`, `roleAssignmentRefusal`,
|
|
35
|
+
`writePrimaryAssignment`, `roleById`, `writeParticipationPolicy`), and `audit`
|
|
36
|
+
(`record`, `recordAs`, `writeServiceEvent`, `securityLogFor`) — the write path
|
|
37
|
+
is Boule's own: validated rows straight into `authz_events`, not
|
|
38
|
+
`@wtfalch/audit`'s `ledger.sign()` (decided 2026-09-29, authz#94); and wave 3:
|
|
39
|
+
`policy.ts`'s `definePolicy`, which builds a `StorePolicy` (a `StoreBinding`
|
|
40
|
+
plus `catalogue`, `builtInRoles`, `starterRoles`, `elevated`,
|
|
41
|
+
`requiresCoApproval`, `defaultCeiling`, `maxDepth`, `offered`) from a host's
|
|
42
|
+
own policy-catalogue JSON — generic over that JSON's shape (`PolicyData`),
|
|
43
|
+
since only the function that builds a catalogue from it was identical between
|
|
44
|
+
hosts; `roles` (`roleDef`, `assignableRoles`, `rolesForEditor`,
|
|
45
|
+
`canEditDefinition`, `createRole`, `updateRole`, `deleteRole`); and `boot`
|
|
46
|
+
(`ensureBuiltInRoles`, `seedStarterRoles`, `checkSystemRoles`,
|
|
47
|
+
`orphanedRoleKeys`, `content`) — ADR 0016 kept exactly: `builtInRoles` names
|
|
48
|
+
only `self_service`, resynced on every boot; every other `systemRoles` entry
|
|
49
|
+
is a starter role, seeded once by `seedStarterRoles`. Not moved:
|
|
50
|
+
`DENIAL_EVENTS`, keyed to one host's own permission ids and staying in the
|
|
51
|
+
hosts for that reason.
|
|
52
|
+
|
|
53
|
+
Wave 4a (authz#83) moves the tenant tree's read and query half, from
|
|
54
|
+
`tree.ts`: `nodeById`, `lockTenantsForUpdate`, `lockSubtreeForUpdate`,
|
|
55
|
+
`lockAttachScope`, `ancestorsOf`, `childrenOf`, `descendantsOf`,
|
|
56
|
+
`depthAbove`, `heightBelow`, `attachProblem` (now takes `policy: StorePolicy`
|
|
57
|
+
for `maxDepth`/`offered`, in place of the host's own imported constants),
|
|
58
|
+
`expectedDerivedFor`, `customRoleBlockers`, `attachCustomRoleBlockers`,
|
|
59
|
+
`derivedEventAction`, `principalsAtOrAbove` and `detachReport`.
|
|
60
|
+
|
|
61
|
+
Wave 4b (authz#83) moves the write and inheritance-propagation half, from
|
|
62
|
+
`tree-writes.ts`: `recomputeDerived` (the one writer that makes a tenant's
|
|
63
|
+
derived rows match the derivation rule), and the two callers that change the
|
|
64
|
+
tree's shape, `attachInTx` and `detachInTx`. `DerivedCustomRoleError` is
|
|
65
|
+
thrown, never returned, when a caller reaches `recomputeDerived` without
|
|
66
|
+
having checked `customRoleBlockers` first (D19: a derived membership never
|
|
67
|
+
carries a custom role).
|
|
68
|
+
|
|
69
|
+
Wave 5a (authz#83) moves `propagate.ts`, `reconcile.ts` and
|
|
70
|
+
`install-owner.ts`:
|
|
71
|
+
|
|
72
|
+
- `propagate.ts`: `propagateMembershipChange`, the one place every direct
|
|
73
|
+
membership write (`memberships.ts`, `invitations.ts`, `install-owner.ts`,
|
|
74
|
+
`credentials.ts`, all still host-side except the second) recomputes the
|
|
75
|
+
derived rows it can have moved and writes one audit row per change.
|
|
76
|
+
`writerFromAccess`/`writerFromPrincipal` build the `PropagationWriter` it
|
|
77
|
+
signs the rows with, for a write that already has an `Access` and one
|
|
78
|
+
(invitation acceptance) that does not yet.
|
|
79
|
+
- `reconcile.ts`: `reconcileDerived`, D19's drift check — every `inherited`
|
|
80
|
+
membership row compared, read-only, against what `expectedDerivedFor`
|
|
81
|
+
(`tree.ts`) says the tree calls for.
|
|
82
|
+
- `install-owner.ts`: `installOwner`, the one exception to D8's rule that no
|
|
83
|
+
session or standing operator reach ever changes tenant membership. Boule's
|
|
84
|
+
copy read the host's own `profiles` table directly for two things — whether
|
|
85
|
+
the target person has ever signed in, and the tenant's current owners'
|
|
86
|
+
`lastSeenAt` — and that table is the host's, not the store's, so both
|
|
87
|
+
become an `OwnerActivityLookup` the host passes in: `personKnown(id)` and
|
|
88
|
+
`lastSeenAt(ids)`, batched over a tenant's direct owners rather than one
|
|
89
|
+
join. Every host adopting this wave implements that interface over its own
|
|
90
|
+
`profiles` (or equivalent) table.
|
|
91
|
+
|
|
92
|
+
Every function in this wave that used to read `db` from a host's own
|
|
93
|
+
`@/lib/db/root` now takes it as an explicit `db: DbOrTx` parameter, the same
|
|
94
|
+
as `securityLogFor` and `createRole`/`updateRole`/`deleteRole` in earlier
|
|
95
|
+
waves.
|
|
96
|
+
|
|
97
|
+
Wave 5b (authz#83) moves `break-glass.ts`: `startBreakGlass`, `endBreakGlass`,
|
|
98
|
+
`endSessionsFor`, `activeSession`, `sessionsFor` — the one place a support
|
|
99
|
+
session opens, ends or force-ends. The break-glass support role is found by
|
|
100
|
+
`isCustomRoleKey`, not `.builtIn` (ADR 0016: a starter role is `built_in:
|
|
101
|
+
false` but still an application-defined role, never a tenant-authored one).
|
|
102
|
+
`db` is again an explicit parameter rather than a host import, the same as
|
|
103
|
+
wave 5a.
|
|
104
|
+
|
|
105
|
+
Wave 6 (authz#83) moves `nesting.ts`, the consent flow over the tree:
|
|
106
|
+
`proposeAttach`, `acceptAttach`, `declineAttach`, `detach`, and the three
|
|
107
|
+
pending-proposal readers (`pendingProposalsForChild`,
|
|
108
|
+
`pendingProposalsForParent`, `proposalsFromParent`). `proposeAttach` and
|
|
109
|
+
`acceptAttach` take `policy: Pick<StorePolicy, 'maxDepth' | 'offered'>`
|
|
110
|
+
explicitly, since `attachProblem` (wave 4a) needs it and the store has no
|
|
111
|
+
`StorePolicy` of its own in scope. Unique-violation detection on the
|
|
112
|
+
proposal's partial index (authz#96) uses `@wtfalch/db`'s `sqlState()`, the
|
|
113
|
+
same helper otf already used, in place of the `(error as {code?}).code ===
|
|
114
|
+
'23505'` duck-typing Boule's and archon's copies both wrote — declared as a
|
|
115
|
+
peer dependency, matching `drizzle-orm`'s role (a host-supplied instance),
|
|
116
|
+
pinned exact at `0.4.0` (the estate's `@wtfalch/*` pin rule), plus a matching
|
|
117
|
+
exact `devDependency` for this package's own build and test.
|
|
118
|
+
|
|
119
|
+
Wave 7 (authz#83, authz#95 decision 2, authz#98) moves `credentials.ts`
|
|
120
|
+
(`mintCredential`, `revokeCredential`, `credentialsFor`) and `bootstrap.ts`
|
|
121
|
+
(`bootstrapOperator`).
|
|
122
|
+
|
|
123
|
+
Minting and revoking cut over from this package's own `credential-secret.ts`
|
|
124
|
+
scheme to a host's `@wtfalch/keys` issuer: `mintCredential` and
|
|
125
|
+
`revokeCredential` now take an `issuer` parameter, typed by the store's own
|
|
126
|
+
`CredentialIssuerPort` (`issue`/`revoke`, in exactly the shapes the two
|
|
127
|
+
functions call them), with no default. `@wtfalch/keys` is a `devDependency`
|
|
128
|
+
only, pinned exact at `0.4.2`; a type test in `credentials.test.ts` proves
|
|
129
|
+
`CredentialIssuer<StoreKeyGrant>` from `@wtfalch/keys/issued` satisfies the
|
|
130
|
+
port, and nothing outside a test imports `@wtfalch/keys` from this package.
|
|
131
|
+
`credential-secret.ts` is unchanged and stays: it verifies every row minted
|
|
132
|
+
before a host adopts this wave, and a host's own bearer path keeps checking
|
|
133
|
+
against it for those rows. Migration `0004_credential_keys_issued_id.sql`
|
|
134
|
+
loosens `credentials.secret_hash` to nullable, adds `keys_issued_id`, and
|
|
135
|
+
widens `credentials_secret_prefix_check` to 64 characters, matching
|
|
136
|
+
`@wtfalch/keys`'s own bound. `credentialsFor` no longer joins a host's own
|
|
137
|
+
`profiles` table; it takes an `IssuerNames` port instead
|
|
138
|
+
(`personNames(ids)`, batched over every human issuer on the page), a host's
|
|
139
|
+
display name falling back to email the same way the join order did.
|
|
140
|
+
|
|
141
|
+
`bootstrapOperator` no longer reads `AUTHZ_BOOTSTRAP_OPERATOR` or imports
|
|
142
|
+
`@wtfalch/auth`: a host resolves its own bootstrap subject and passes it as
|
|
143
|
+
`options.subject`, along with `options.ensureProfile`, generic over the
|
|
144
|
+
host's own `User` type. Boule's order is unchanged (`ensureProfile` first,
|
|
145
|
+
then the tenant row lock, the owner count read only after the lock) and so
|
|
146
|
+
is its `seedStarterRoles` call, which otf's copy of this function had
|
|
147
|
+
dropped — a bug this move does not repeat.
|
|
148
|
+
|
|
149
|
+
Wave 8a (authz#83, issue #98 decision 3) moves the sending and management
|
|
150
|
+
half of `invitations.ts`: `generateInvitationToken`, `invite`,
|
|
151
|
+
`markInvitationMail`, `resendInvitation`, `revokeInvitation`,
|
|
152
|
+
`pendingInvitations`, `invitationPreview` and `invitationTenantId`.
|
|
153
|
+
`INVITATION_CAP_PER_HOUR` is no longer a package constant; `invite` and
|
|
154
|
+
`resendInvitation` take `capPerHour` as an option. The store never
|
|
155
|
+
hard-codes an acceptance route either: `invite` takes a `link: (token:
|
|
156
|
+
string) => string` builder, and Boule's own `invitationLink` (`/org/invite/
|
|
157
|
+
${token}`) stays in the host, unmoved, as its caller. `acceptInvitation`
|
|
158
|
+
is wave 8b, not this wave: it needs `acceptInvitationForUser`'s host-only
|
|
159
|
+
`requireUser`/`ensureProfile`/`principalFromUser`, which stay in Boule as a
|
|
160
|
+
non-goal (see wave 8b below for the boundary).
|
|
161
|
+
|
|
162
|
+
Wave 8b (authz#83, issue #98 decisions 3–5) moves `acceptInvitation`,
|
|
163
|
+
`AcceptInvitationResult`, `AttachRefusal` and the born-attach savepoint
|
|
164
|
+
(`attemptBornAttach`, `AttachAttemptRefused`), staying in `invitations.ts`
|
|
165
|
+
alongside wave 8a. `acceptInvitationForUser` does **not** move: it is the
|
|
166
|
+
one caller of Boule's own `requireUser`, `ensureProfile` and
|
|
167
|
+
`principalFromUser` from `access.ts`, none of which this package can reach,
|
|
168
|
+
so it stays in Boule as this wave's one host-only function. `acceptInvitation`
|
|
169
|
+
takes `binding`, `policy: Pick<StorePolicy, 'maxDepth' | 'offered'>` (for the
|
|
170
|
+
born-attach path's `attachProblem`) and `profiles: Pick<OwnerActivityLookup,
|
|
171
|
+
'personKnown'>` (install-owner.ts's own port, reused rather than a second one
|
|
172
|
+
for the same "has this id ever signed in" question) as explicit parameters.
|
|
173
|
+
`@wtfalch/people` is not imported: it depends on this package, so importing
|
|
174
|
+
it back would be a cycle; the membership-row half of its `grantMembership` is
|
|
175
|
+
inlined directly in `acceptInvitation` instead, with the role assignment and
|
|
176
|
+
participation-policy writes staying this file's own, exactly as Boule's
|
|
177
|
+
comment already described the split.
|
|
178
|
+
|
|
179
|
+
Wave 9a (authz#83, issue #99 decision 4) moves the member-side writes and
|
|
180
|
+
reads from `tenants.ts`: `createTenant`, `renameTenant`, `setSelfDenied`,
|
|
181
|
+
`setTenantState`, `setCeiling`, `tenantById`, `tenantBySlug`, `allTenants`.
|
|
182
|
+
`TENANT_CREATION` and `TENANT_CREATION_CAP_PER_DAY` are no longer package
|
|
183
|
+
constants: a host passes both through `CreateTenantConfig` (decision 3), the
|
|
184
|
+
same option shape as every other wave's config parameter. `defaultCeiling`
|
|
185
|
+
and `offered` come off the `policy: StorePolicy` (or the narrower
|
|
186
|
+
`Pick<StorePolicy, 'defaultCeiling' | 'offered'>` where that is all a
|
|
187
|
+
function needs) a caller passes in, rather than an import (decision 4). The
|
|
188
|
+
person check `createTenant` needs before it lets someone self-serve a
|
|
189
|
+
tenant reuses `OwnerActivityLookup.personKnown` (wave 5a's own port), the
|
|
190
|
+
same "has this id ever signed in" question, rather than a second port for
|
|
191
|
+
it. A slug race is read the same way wave 6's proposal collision is: a real
|
|
192
|
+
unique-violation, via `@wtfalch/db`'s `sqlState()`, not
|
|
193
|
+
`(error as {code?}).code` duck-typing.
|
|
194
|
+
|
|
195
|
+
Wave 9b (authz#83, issue #99 decision 4) moves the operator side from the
|
|
196
|
+
same file: `createTenantAsOperator`, `deleteJustCreatedTenant`,
|
|
197
|
+
`archiveTenant`. `createTenantAsOperator` takes a `link: (token: string) =>
|
|
198
|
+
string` builder for its pending owner invitation, the same shape wave 8a's
|
|
199
|
+
`invite` takes, so the store never hard-codes an acceptance route.
|
|
200
|
+
`deleteJustCreatedTenant` moves as archon's compensating rollback for its
|
|
201
|
+
own second, non-transactional write (issue #40) — a plain exported
|
|
202
|
+
function only archon calls, operator-only by its own guards, with no hook
|
|
203
|
+
or extension interface, since nothing else needs one. `openBearerScope`
|
|
204
|
+
does **not** move: it only wraps the host's own db and tenant tables with
|
|
205
|
+
the store's `openScopedDb`/`scopeColumnOf`, so it has nothing this package
|
|
206
|
+
doesn't already export, and stays in archon.
|
|
207
|
+
|
|
208
|
+
Wave 10a (authz#83) moves the grants compiler from Boule's own
|
|
209
|
+
`src/lib/authz/grants.ts` (496 lines): `personRecordFor`, the person page's
|
|
210
|
+
read of every grant a human principal holds in one organisation, with its
|
|
211
|
+
provenance and the teams they participate in, and `teamRecordFor`, the team
|
|
212
|
+
page's own read of one team, its place in the hierarchy, who is in it, and
|
|
213
|
+
its scoped access. Both land in a new `src/person-records.ts`, not this
|
|
214
|
+
package's existing `src/grants.ts` — that file is the `authz_grants` table
|
|
215
|
+
and `guestGrants`, a different thing, and stays untouched. Boule's one
|
|
216
|
+
`z.uuid()` check becomes the store's own `isUuid` from `types.ts`; the store
|
|
217
|
+
gained no zod dependency. `APPLICATION_ID`, `PLATFORM_ID` and `catalogue`
|
|
218
|
+
become `access.binding`, the same as every other moved module. `profiles`
|
|
219
|
+
becomes a `PeopleDirectory` port, reused for both the grant-provenance name
|
|
220
|
+
lookup (`resolveActorNames`) and `personRecordFor`'s own display fields —
|
|
221
|
+
Boule's inner join against `profiles`, which excluded a member with no
|
|
222
|
+
profile row from the result, becomes the directory's map coming up empty for
|
|
223
|
+
that id, read the same way.
|
|
224
|
+
|
|
225
|
+
Wave 10b (authz#83, William 2026-09-29) moves Boule's own
|
|
226
|
+
`src/lib/authz/memberships.ts` (407 lines) into a new `src/memberships.ts`:
|
|
227
|
+
`membersOf`, `selfStanding`, `guardStaysHeld`, `grantMembership`,
|
|
228
|
+
`changeRole` and `removeMembership`, keeping Boule's own names. The
|
|
229
|
+
row-level work four of those six delegated to `@wtfalch/people` 0.2.3 —
|
|
230
|
+
`grantMembership`, `removeMembership`, `changeRole` and `guardStaysHeld`
|
|
231
|
+
from its `membership.js`, and `membersOf` from its `roster.js` — is copied
|
|
232
|
+
into a new `src/membership-rows.ts` instead of imported, because
|
|
233
|
+
`@wtfalch/people` depends on this package and importing it back would be a
|
|
234
|
+
cycle; a later issue in the people repo makes it reuse this copy rather than
|
|
235
|
+
carry its own. Every row helper is exported under a name distinct from its
|
|
236
|
+
policy-layer counterpart (`insertMembershipRow`, `deleteMembershipRow`,
|
|
237
|
+
`changeRoleRow`, `guardStaysHeldRow`, `membersOfRow`), since the two layers
|
|
238
|
+
would otherwise claim the same five names on one package surface; none of
|
|
239
|
+
the five is part of this package's public surface, since nothing outside
|
|
240
|
+
`src/memberships.ts` needs them. `identityWhere` is not duplicated: people's
|
|
241
|
+
own copy tests the same three columns as `src/assignments.ts`'s, in a
|
|
242
|
+
different argument order `and()` doesn't care about, so `membership-rows.ts`
|
|
243
|
+
imports that one. `roster.js`'s `profiles` left join (a member with no
|
|
244
|
+
profile row is kept, with null display fields) becomes the same
|
|
245
|
+
`PeopleDirectory` port wave 10a introduced, read the same left-join way —
|
|
246
|
+
a human principal missing from the directory keeps its row, a non-human one
|
|
247
|
+
never reaches the directory at all, same as the original join's predicate.
|
|
248
|
+
|
|
249
|
+
Wave 11a (authz#83) moves Boule's own `src/lib/authz/resource-access.ts`
|
|
250
|
+
(814 lines) into a new `src/resource-access.ts`: `resourceOverview`,
|
|
251
|
+
`saveAppRole`, `assignAppRole`, `removeAppAssignment`, `deleteAppRole`,
|
|
252
|
+
`saveTeam`, `archiveTeam`, `changeTeamParticipant`, `scopedAuditLog`, and
|
|
253
|
+
the shared `resourceAccessFor` request-time evaluator every one of them
|
|
254
|
+
reads through. Boule's copy was the newest of the two hosts' — it alone
|
|
255
|
+
has `resourceOverview`'s `canReadPeople` field, which archon and otf never
|
|
256
|
+
grew — and is the one kept. `resourceAccessFor` itself reloads access on
|
|
257
|
+
every call, a deliberate re-read Boule's own code already made, not a
|
|
258
|
+
redundancy introduced here; it now takes the same `tx`/`db`/`scopeColumn`
|
|
259
|
+
triple `loadAccess` does, since the store has no root handle of its own to
|
|
260
|
+
default it onto the way Boule's `db` import did. Every exported function
|
|
261
|
+
gained `db: DbOrTx` and `scopeColumn: Scope['column']` parameters for the
|
|
262
|
+
same reason, the same convention every earlier wave followed.
|
|
263
|
+
|
|
264
|
+
`appRolePermissionsSchema` was a module-level zod schema closed over
|
|
265
|
+
Boule's own `catalogue` constant; it is now a function of
|
|
266
|
+
`ResourceCatalogue` (`access.binding.catalogue`), as is the private
|
|
267
|
+
`roleInputSchema` built from it — every validation rule inside both is
|
|
268
|
+
unchanged. This is the store's first zod dependency: `zod` is now a peer
|
|
269
|
+
and dev dependency of `@wtfalch/authz-store`, `^4.1.13`/`^4.5.4`, the same
|
|
270
|
+
pins `@wtfalch/authz` itself carries. Boule's `import { db } from
|
|
271
|
+
'@/lib/db/root'` and `import 'server-only'` are gone, the same as every
|
|
272
|
+
earlier wave; its `asc`, `memberships` (table), `tenants` (table),
|
|
273
|
+
`TenantRow` and `policyAssignment` imports — five names in all — were
|
|
274
|
+
already unused in the original file and were not carried over.
|
|
275
|
+
|
|
276
|
+
At 814 lines of source, this module is over the 800-line PR-size limit
|
|
277
|
+
with its tests added, but it stays one PR: its functions share private
|
|
278
|
+
helpers (`writer`, `resourceAccessFor`, `lookupResource`, `delegable`,
|
|
279
|
+
`auditClause`, `target`) too tightly to split without duplicating them or
|
|
280
|
+
introducing a cross-file dependency this wave's split would then have to
|
|
281
|
+
undo.
|
|
282
|
+
|
|
283
|
+
Wave 11b (authz#83) moves Boule's own `src/lib/authz/activations.ts` (694
|
|
284
|
+
lines, ADR 0016 "No standing authority") into a new `src/activations.ts`:
|
|
285
|
+
`needsCoApproval`, `requestActivation`, `approveActivation`,
|
|
286
|
+
`selfApproveActivation` and `endActivation`. `elevated` and
|
|
287
|
+
`requiresCoApproval` — ADR 0016 sets, not part of `StoreBinding` — come off a
|
|
288
|
+
`policy: Pick<StorePolicy, ...>` parameter each function needs, the same
|
|
289
|
+
shape wave 6's `proposeAttach`/`acceptAttach` and wave 9a's tenant writes
|
|
290
|
+
take theirs; unlike Boule's own module-level constants, `StorePolicy.elevated`
|
|
291
|
+
and `.requiresCoApproval` are already `ReadonlySet<string>`, so no widening
|
|
292
|
+
cast is needed. The tenant-only `select ... for update` Boule's three
|
|
293
|
+
write functions each repeated right after `refreshAccess` is dropped: the
|
|
294
|
+
store's own `refreshAccess` already takes that lock inside itself. The two
|
|
295
|
+
new tables (`authz_activations`, `authz_activation_approvals`) land in
|
|
296
|
+
`migrations/0005_activations.sql`; `authz_roles.updated_by` and the
|
|
297
|
+
`'activation'` `authz_assignments.source` value, and all six `activation.*`
|
|
298
|
+
names in `authz_events_action_check`, were already in `0001_baseline.sql`
|
|
299
|
+
before this wave. No FK from `principal_id`/`approver_id` to a host's own
|
|
300
|
+
person table, the same as `break_glass_sessions.operator_id`. No audit event
|
|
301
|
+
is written anywhere in this file, kept exactly as Boule had it, even though
|
|
302
|
+
this package's own `authz_events_action_check` and pinned `@wtfalch/authz`
|
|
303
|
+
already accept every name Boule's own comment said its installed version
|
|
304
|
+
did not yet — turning the writes on is a behaviour change this move does
|
|
305
|
+
not make on its own judgement.
|
|
306
|
+
|
|
307
|
+
Wave 11c (authz#83) moves three of Boule's own modules into new files here,
|
|
308
|
+
keeping every guard and comment: `src/lib/authz/export.ts` (464 lines) into
|
|
309
|
+
`src/export.ts` (`exportTenant`), `src/lib/authz/events.ts` (189 lines) into
|
|
310
|
+
`src/events.ts` (`eventsFor`), and `src/lib/authz/platform.ts` (71 lines)
|
|
311
|
+
into `src/platform.ts` (`platformFrozen`, `setPlatformFrozen`). All three
|
|
312
|
+
copies of `export.ts` (archon, Boule, otf) differed only cosmetically; Boule's
|
|
313
|
+
was the source for all three. `exportTenant` takes this package's own
|
|
314
|
+
`DbOrTx` instead of Boule's root `Db`; its own hand-built `tenant.exported`
|
|
315
|
+
row (never through `record`/`recordAs`, since its caller has no full `Access`
|
|
316
|
+
to build one from) is unchanged. `eventsFor` takes `db: DbOrTx` explicitly,
|
|
317
|
+
with `APPLICATION_ID`/`PLATFORM_ID` now `access.binding`'s and its
|
|
318
|
+
`auditedReadGate` call now this package's own (`policy-access.ts`, which
|
|
319
|
+
takes `(access, db, permission, allowed)`). `platformFrozen` and
|
|
320
|
+
`setPlatformFrozen` both take `db: DbOrTx`; `setPlatformFrozen` also takes
|
|
321
|
+
`scopeColumn: Scope['column']` for the `refreshAccess` call inside its
|
|
322
|
+
transaction, the same shape `tenants.ts` uses. otf's throw-based, host-bound
|
|
323
|
+
`setPlatformFrozen` (it wraps a session-bound `requireOperator`) stays in
|
|
324
|
+
otf, not moved.
|
|
325
|
+
|
|
326
|
+
Wave 11d (authz#83) moves Boule's own `src/lib/authz/erase.ts`, `alerts.ts`
|
|
327
|
+
and `startup.ts` into `src/erase.ts`, `src/alerts.ts` and `src/startup.ts`.
|
|
328
|
+
`erase.ts` moves `pseudonymFor` and `erasePerson`, the one sanctioned write
|
|
329
|
+
to `authz_events`; the SQL function it calls (`erase_person`, `SECURITY
|
|
330
|
+
DEFINER`, a pinned `search_path`) ships as `migrations/0006_erase_person.sql`,
|
|
331
|
+
sweeping `authz_events` and `invitations` the way Boule's
|
|
332
|
+
`drizzle/0008_erasure_and_boot.sql` did. Boule's own function also scrubbed
|
|
333
|
+
`profiles` and called a reporting package's `reporting_erase_person`; both
|
|
334
|
+
are host tables/packages, so they become one `ErasureHost` port
|
|
335
|
+
(`emailOf`, `eraseHostRecords`) `erasePerson` calls inside its transaction,
|
|
336
|
+
after the sweep and before the `person.erased` row, the same order rule
|
|
337
|
+
Boule's own comment states. `otf`'s own `eraseOperatorPerson`, which sweeps
|
|
338
|
+
otf's own tables, stays in otf and calls the store's `erasePerson`. `alerts.ts`
|
|
339
|
+
moves the four D9 checks (`checkOrphanMemberships`, `checkStuckInvitations`,
|
|
340
|
+
`checkBreakGlassBurst`, `checkOrphanedRoleKeys`), `reportAlert` and
|
|
341
|
+
`runAlerts`; Boule's `@/lib/reporting/core` `reporting` becomes a structural
|
|
342
|
+
`AuthzReporting` port. `startup.ts` moves `runStartupChecks` and
|
|
343
|
+
`registerHousekeeping`; Boule's own version returned early with no
|
|
344
|
+
`DATABASE_URL` to check, which this version drops — a host with no database
|
|
345
|
+
to check simply does not call it, rather than this framework-neutral package
|
|
346
|
+
reading `process.env` to decide the same thing for itself — and its
|
|
347
|
+
`housekeeping` becomes a second structural port, `HousekeepingRegistry`.
|
|
348
|
+
|
|
349
|
+
The rest of the host layer has not moved — tracked in
|
|
350
|
+
https://github.com/wtfalch/authz/issues/83.
|
|
21
351
|
|
|
22
352
|
New here, not moved: `authz_grants`, grants stored per person rather than
|
|
23
353
|
compiled from a role, and `guestGrants`, which reads a tenant's guest grants
|
|
@@ -68,7 +398,7 @@ The engine has no runtime dependencies, and a test scans its whole published
|
|
|
68
398
|
imports `node:crypto`, which is precisely what that scan rejects. The separation
|
|
69
399
|
is asserted, not intended: see `the store package exports exactly what it
|
|
70
400
|
promises, and is Node-only by nature` in
|
|
71
|
-
|
|
401
|
+
`scripts/tests/package-contract.test.mjs`.
|
|
72
402
|
|
|
73
403
|
## A note for a Next.js host
|
|
74
404
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { StorePolicy } from './policy.js';
|
|
2
|
+
import type { DbOrTx, Scope } from './scoped.js';
|
|
3
|
+
import { type Access, type Result } from './types.js';
|
|
4
|
+
/** Whether a requested set of permissions needs a second approver at all (ADR 0016). */
|
|
5
|
+
export declare function needsCoApproval(permissions: readonly string[], policy: Pick<StorePolicy, 'requiresCoApproval'>): boolean;
|
|
6
|
+
export interface RequestActivationOptions {
|
|
7
|
+
readonly permissions: readonly string[];
|
|
8
|
+
readonly reason: string;
|
|
9
|
+
readonly reference: string;
|
|
10
|
+
/** Defaults to 60; a working session, not a fixed package ceiling. */
|
|
11
|
+
readonly minutes?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Opens an activation. Every static check runs before the transaction, the
|
|
15
|
+
* same ordering `startBreakGlass` uses, so a malformed request never reaches
|
|
16
|
+
* the lock. `principalClass` is always `'human'`: agents and services cannot
|
|
17
|
+
* open one (no inbox for the self-approve-wait email, nothing eligible to
|
|
18
|
+
* approve on their behalf), and a credential is not a principal at all
|
|
19
|
+
* under D10.
|
|
20
|
+
*/
|
|
21
|
+
export declare function requestActivation(access: Access, db: DbOrTx, scopeColumn: Scope['column'], policy: Pick<StorePolicy, 'elevated' | 'requiresCoApproval'>, options: RequestActivationOptions): Promise<Result<{
|
|
22
|
+
id: string;
|
|
23
|
+
status: 'pending' | 'active';
|
|
24
|
+
expiresAt: Date;
|
|
25
|
+
}>>;
|
|
26
|
+
export interface ApproveActivationOptions {
|
|
27
|
+
readonly activationId: string;
|
|
28
|
+
readonly decision: 'approved' | 'denied';
|
|
29
|
+
readonly reason?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A second person's decision on someone else's pending activation. Refuses
|
|
33
|
+
* a same-principal row unconditionally, in every branch -- there is no path
|
|
34
|
+
* here that lets a requester sign their own activation; that is
|
|
35
|
+
* `selfApproveActivation`, a different action (ADR, "The two-person
|
|
36
|
+
* approval is Boule's own check").
|
|
37
|
+
*/
|
|
38
|
+
export declare function approveActivation(access: Access, db: DbOrTx, scopeColumn: Scope['column'], policy: Pick<StorePolicy, 'requiresCoApproval'>, options: ApproveActivationOptions): Promise<Result<{
|
|
39
|
+
status: 'active' | 'denied';
|
|
40
|
+
}>>;
|
|
41
|
+
/**
|
|
42
|
+
* The solo path: the requester approves their own activation once no
|
|
43
|
+
* Aged-and-Independent person besides them is eligible, and only after a
|
|
44
|
+
* 24-hour wait re-checked inside this locked transaction (ADR, "The
|
|
45
|
+
* self-approve wait, enforced lazily like everything else here"). A
|
|
46
|
+
* manufactured second account never counts toward "another eligible
|
|
47
|
+
* approver exists" here, because `eligibleApprovers` already excludes
|
|
48
|
+
* anyone whose eligibility traces back to this same requester.
|
|
49
|
+
*/
|
|
50
|
+
export declare function selfApproveActivation(access: Access, db: DbOrTx, scopeColumn: Scope['column'], policy: Pick<StorePolicy, 'requiresCoApproval'>, activationId: string): Promise<Result<{
|
|
51
|
+
status: 'active';
|
|
52
|
+
}>>;
|
|
53
|
+
/**
|
|
54
|
+
* Ends one's own activation, pending or active -- a safety valve, not a
|
|
55
|
+
* power, the same framing `endBreakGlass` gives ending your own session.
|
|
56
|
+
* The ADR names no permission for ending someone *else's* (unlike
|
|
57
|
+
* `break_glass:end-any`), so that branch does not exist here; see this
|
|
58
|
+
* wave's report.
|
|
59
|
+
*/
|
|
60
|
+
export declare function endActivation(access: Access, db: DbOrTx, scopeColumn: Scope['column'], activationId: string): Promise<Result<void>>;
|