@aiquants/auth-directory-core
v0.2.1
Published
Provider-agnostic core for external directory group synchronization: the read-only DirectoryProvider port, a pure reconciler with a removal circuit breaker, and a pure tenant-membership rule. No network, no database, no vendor SDK.
Readme
@aiquants/auth-directory-core
Provider-agnostic core for synchronizing group membership from an external identity directory (Google Workspace, LDAP, …) into a local RBAC schema.
It contains no network calls, no database access, and no vendor SDK — only the read-only port the provider adapters implement, and the two pure functions that make the dangerous decisions: what to delete, and who may act as a tenant.
Why the pure core exists
A directory sync has exactly one irreversible operation: removal. A partial read, an emptied upstream group, or a misconfigured filter all look identical to "everyone left" — and acting on that revokes access for the whole company. Isolating the set arithmetic here means every one of those cases is reproducible in a unit test, with no directory and no database.
Exports
DirectoryProvider (port)
type DirectoryProvider = {
getGroup(externalId: string): Promise<DirectoryGroup | null>
listGroupMembers(externalId: string, mode: "direct" | "transitive"): Promise<DirectoryMember[]>
}Read-only by contract. There is deliberately no write method, so no code path exists in which a sync can modify the upstream directory.
⚠️
listGroupMembersmust return a complete list or throw. The caller treats what it returns as the whole upstream truth and deletes everything not in it, so a part-way listing becomes a mass revocation. If paging cannot be completed, throw — never return the partial array.
externalId is the stable upstream id, never the group address: an address can be renamed
upstream, and anchoring on it makes the link silently break the moment someone renames the group.
reconcile(local, remote, policy)
Turns "what upstream says" into "what to write locally":
| Output | Meaning |
| --- | --- |
| ledgerUpserts / ledgerDeletes | The member ledger, which records every upstream member — including people who have never signed in and therefore have no local user row |
| projectionAdds / projectionRemoves | The real membership table, which only ever holds resolvable users |
| unresolvedEmails | Upstream members with no local user. Not an error — it is the visible answer to "why doesn't this person have the permission yet?" |
| aborted | Non-null when the plan must not be applied |
Removal is guarded by a circuit breaker (BlastRadiusPolicy) with two independent axes, each
with its own required limits:
| Axis | Counts | Denominator |
| --- | --- | --- |
| membership | people losing real membership, i.e. losing granted permissions | memberships held before the run |
| ledger | ledger rows removed, i.e. people losing the ability to sign in at all | ledger rows before the run |
The axes are separate because the ledger deliberately holds every member observed upstream, including people who have never signed in and hold no membership. Mixing them into one denominator dilutes the ratio in proportion to how many such people a group has: for a group with 1,000 ledger rows and 40 memberships, a run that removes all 40 memberships measures as a 4% blast and sails through — the breaker falls silent in exactly the failure it exists to catch. Separate limits also mean that relaxing one axis for a routine event never quietly relaxes the other.
minimumPopulation exists on each axis because below it the ratio is too twitchy — a five-person
group trips on an ordinary transfer, and a warning nobody can act on is a warning nobody reads.
maxRemovalRatio is measured against the local pre-sync population, never the upstream count:
when upstream returns zero the upstream-based ratio diverges, exactly when the guard matters most.
An abort clears every write, and carries the withheld identities — withheldLedgerDeletes and
withheldProjectionRemoves. Asking an operator to approve a removal while showing only a count
leaves them no way to decide except to raise the ceiling.
evaluateTenantMembership(input)
Decides whether a caller may act as the tenant it named, and returns the reason alongside the verdict — a denial caused by "tenant not served" and one caused by "not in any group" send an investigation in opposite directions.
1. named tenant is not one this deployment serves → deny (tenant-not-served)
2. anonymous caller → allow (anonymous)
3. the tenant's policy is "no-proof-required" → allow (proof-not-required)
4. caller is in one of the proof groups → allow (group-member)
otherwise → deny (not-in-any-membership-group)Rule 2 is load-bearing: @anonymous grants belong to the tenant, so rejecting anonymous callers
here makes every public resource unreadable.
Rule 3 is an explicit policy, never inferred from an empty list. TenantMembershipPolicy is a
discriminated union, so "we require no proof", "somebody forgot to declare the groups" and "the
query returned nothing because of a transient fault" cannot collapse into the same outcome — making
an isolation axis optional is what turns unspecified into everything.
A require-proof policy must also name at least one break-glass group, and every break-glass
group must itself be a proof group. If every proof path is upstream-owned, one accident in the
directory locks out every caller including the people who could repair it. assertTenantMembershipPolicy
refuses such a policy; call it at wiring time so the deployment fails to start rather than at the
first request. Whether a break-glass group is genuinely locally managed is a fact about storage, so
that half of the check belongs to the adapter.
Evidence comes from two disjoint sources — local membership (for locally-managed groups) and the member ledger (for externally-sourced ones). Use the ledger, not the projected membership: on a first sign-in the user row exists before the next sync projects it, and reading the projection would lock a legitimate user out of everything for one sync interval.
Blank tenant ids raise AuthzTenantError rather than denying, and malformed group ids raise
TypeError rather than being dropped — a silently dropped id produces a false "not a member" with
nothing in the logs to explain it.
Tenant identifiers
Validation and comparison are delegated to @aiquants/authz-core
(assertTenantId / isSameTenant). This package deliberately does not reimplement them: the
definition of "blank" is an explicit Unicode set rather than any language's trim, and a second
implementation would drift from the authorization core it has to agree with.
Address comparison
Every address is compared through normalizeAuthEmail from @aiquants/auth-core — the same
function the login allowlist uses. Two normalizers would let an address be present in the ledger
and absent from the allowlist with nothing raising an error.
Install
pnpm add @aiquants/auth-directory-core @aiquants/auth-core @aiquants/authz-core zodBoth peers are pure logic packages with no I/O, and must resolve to the same instance the host uses.
@aiquants/authz-core itself depends on zod, so a host that installs these three by hand needs it
too — pnpm add zod — or an ESM import of the authz core fails with ERR_MODULE_NOT_FOUND.
