@aiquants/auth-directory-drizzle 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AI Quants / fehde-k
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # @aiquants/auth-directory-drizzle
2
+
3
+ Drizzle (SQL Server) adapter for [`@aiquants/auth-directory-core`](../auth-directory-core). It owns
4
+ the **identity-side** tables and the four surfaces that operate on them.
5
+
6
+ ## Why these tables are not in `@aiquants/authz-drizzle`
7
+
8
+ Nothing in the authorization query path reads them — only authentication and user administration do.
9
+ Beyond the separation of concerns there is a practical reason: `@aiquants/authz-drizzle` is a shared,
10
+ published package, and adding a column or a table to it **forces a DDL migration on every deployment
11
+ that uses it**. Drizzle puts every declared column in its `INSERT` column list, so a column the
12
+ physical table does not have fails with `Invalid column name` (SQL Server 207) on the very next
13
+ write. Deployments that never touch an external directory should not be dragged into that migration.
14
+
15
+ ## Tables
16
+
17
+ | Table | `tenant_id` | Notes |
18
+ | --- | --- | --- |
19
+ | `TDGroupExternalLink` | yes (denormalized) | One link per group. The column duplicates `TMGroup.tenant_id` so `UNIQUE (tenant_id, provider, external_id)` is expressible, and is also used as a predicate; writes must prove it matches the parent, and reads re-verify it against the parent |
20
+ | `TDGroupMemberEmail` | no — inherited | Every member observed upstream, **including people with no local user row**. `resolved_user_id` is nullable and carries no foreign key: rows that cannot be resolved are the reason this table exists |
21
+ | `TDTenantMembershipGroup` | yes (it is the key) | Groups a tenant accepts as proof of membership. At least one row must be `is_break_glass` and locally managed |
22
+ | `TDAllowlistGroup` | yes (**owner**, not a filter) | Groups whose members may sign in |
23
+ | `TDDirectorySyncRequest` | yes | The "sync now" queue. `group_id` is `NULL` for a whole-tenant run |
24
+ | `TMAllowlist` | yes (**owner**, not a filter) | Moved here from `@aiquants/authz-drizzle`, now tenant-scoped |
25
+
26
+ No table declares a drizzle `.default()` on `tenant_id`, and the new tables carry no physical
27
+ `DEFAULT` either. A default makes `tenantId` optional in the insert type, so a write that forgot the
28
+ tenant type-checks and lands in whichever tenant the DBA happened to name.
29
+
30
+ ### "Owner" is not a filter
31
+
32
+ `TMAllowlist.tenant_id` and `TDAllowlistGroup.tenant_id` answer *who may edit this row*, not *who may
33
+ sign in*. Authentication happens before a tenant is resolved, so the login decision is the union over
34
+ every tenant this deployment serves. `isEmailAllowedByGroup` deliberately emits **no tenant
35
+ predicate** — a regression test asserts its absence, because adding one silently breaks every first
36
+ sign-in.
37
+
38
+ ## Surfaces
39
+
40
+ | Export | Role |
41
+ | --- | --- |
42
+ | `defineAuthDirectorySchema` | Table factory (schema name and referenced tables injected) |
43
+ | `createDirectoryLinkStore` | Links, tenant membership proof, group allowlist, sync queue, health |
44
+ | `createDirectorySyncRepository` | Storage operations for one sync run; **removals go through the authz lockout guard** |
45
+ | `assertMembershipMode` | The single reader of `membership_mode` — no reader may default it |
46
+ | `promoteLedgerForUser` | First-sign-in promotion of ledger rows to real memberships |
47
+ | `withExternalGroupGuards` / `assertGroupMayBeLinked` | Keep administrative roles off externally-sourced groups, in both directions |
48
+
49
+ ### Removals go through the lockout guard, and one refusal does not stop the group
50
+
51
+ Group membership reaches roles through `TDGroupRole`, so one upstream departure can take a tenant's
52
+ last administrator with it. Every projection removal is written inside
53
+ `createAdminLockoutGuard(...).guardedWrite(...)` — the same serializable transaction the
54
+ authorization admin UI uses. Removals are issued one at a time because `AdminRemoval` takes a single
55
+ descriptor; adding a batch form to the authorization core would breach the package boundary, and the
56
+ count is already bounded by the reconciler's circuit breaker.
57
+
58
+ **A refusal does not abort the run.** Stopping at the first refused removal leaves the additions
59
+ committed, the earlier removals committed and the ledger untouched — and every later run recomputes
60
+ the same plan and dies at the same index, so one legitimately-guarded row blocks the whole group's
61
+ convergence forever. Refusals are collected into `ApplyReport.refusals`, the remaining removals
62
+ proceed, and the caller records that the group has not converged. The refused person keeps both
63
+ their membership **and** their ledger row: revoking sign-in while leaving the role would produce a
64
+ state that makes sense from neither side.
65
+
66
+ Ledger deletions are chunked. SQL Server binds one parameter per value in an `IN` list against a
67
+ 2,100-parameter statement limit; a group large enough to exceed it would fail with error 8003 after
68
+ the memberships were already removed, and every subsequent run would fail identically — the state
69
+ does not self-heal.
70
+
71
+ ### Deactivation is not a one-way door
72
+
73
+ A group missing upstream is deactivated only after `vanishedThreshold` **consecutive** absences, so a
74
+ propagation window or a scope change does not disable a live group. Deactivated groups keep being
75
+ polled, and a successful read reactivates them; `setGroupActive` gives an operator the same exit.
76
+
77
+ Deactivation revokes: `is_active` is part of the login gate and of the tenant-membership evidence, not
78
+ only of the sync query. The reverse — enforced for syncing, ignored for access — leaves a frozen
79
+ ledger granting sign-in to departed members while the only process that could clean it is switched
80
+ off.
81
+
82
+ ### Promotion never reaches the directory
83
+
84
+ `promoteLedgerForUser` matches an identity against the ledger and inserts the missing membership
85
+ rows. That is entirely local work, so the web tier can grant group permissions during a first sign-in
86
+ **without holding the directory credential** — which stays with the sync job alone. Do not import a
87
+ `DirectoryProvider` here; the placement is the point.
88
+
89
+ ## Testing the shape of the SQL
90
+
91
+ This adapter accepts drizzle through structural types, so `tsc` checks neither the order of the
92
+ builder calls nor the presence of a tenant predicate: putting `.innerJoin()` before `.from()`,
93
+ dropping `.output()`, or forgetting `WHERE tenant_id = ?` all type-check. `link-store.spec.ts` and
94
+ `sync-repository.spec.ts` therefore record the actual call sequence against a fake builder and walk
95
+ each condition for the columns it references.
96
+
97
+ `sync-repository.ts` is the only module that deletes rows, so it is tested directly rather than
98
+ through the orchestration's fake repository — an orchestration test proves nothing about whether the
99
+ real writer goes through the lockout guard. Fifteen mutations are each caught by at least one test,
100
+ including: replacing `guardedWrite` with a bare `db.delete`, excluding deactivated groups from the
101
+ sync query, pinning `membership_mode` to a constant, removing the `IN`-list chunking, dropping a
102
+ tenant predicate, skipping reactivation, omitting the audit actor, opening the third escalation
103
+ direction, ignoring `appKey` when deciding what is administrative, dropping `is_active` from the
104
+ login gate, collapsing the identity precedence back to an `OR`, treating a removal-only run as
105
+ "unchanged", and skipping the resolver completeness check.
106
+
107
+ ## Install
108
+
109
+ ```bash
110
+ pnpm add @aiquants/auth-directory-drizzle @aiquants/auth-directory-core @aiquants/auth-core @aiquants/authz-core @aiquants/authz-drizzle drizzle-orm zod
111
+ ```
112
+
113
+ All five `@aiquants/*` peers are **value** imports and every one is listed in this package's tsup
114
+ `external`, so omitting any of them yields `ERR_MODULE_NOT_FOUND` at runtime rather than a type
115
+ error. `zod` is required transitively by `@aiquants/authz-core`.