@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 +21 -0
- package/README.md +115 -0
- package/dist/index.d.mts +1862 -0
- package/dist/index.d.ts +1862 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +74 -0
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`.
|