tenant-kit-adapters
Synced from tenant-kit-adapters/README.md — the repo is canonical.
Enterprise connectors for tenant-kit — SSO, directory provisioning, and RBAC-engine bridges — and the contract every one of them conforms to.
tenant-kit's core holds a scope line: it consumes an authenticated UserId,
keeps three roles, and refuses to become an auth product, a user-management
system, or an RBAC engine. This repo is where the systems on the other side
of that line plug in — without moving it.
flowchart LR
subgraph outside["the enterprise's systems"]
idp["IdP<br/>Okta · Entra · Keycloak"]
dir["directory<br/>SCIM client"]
rbac["RBAC engine<br/>OpenFGA · SpiceDB"]
end
subgraph adapters["adapters/* (this repo)"]
sso["sso-oidc<br/>SsoResolver"]
scim["scim<br/>ScimDirectory"]
fga["openfga<br/>RoleBridge"]
end
core["tenant-kit core<br/>directory · resolution · context · isolation"]
idp -->|"verified identity"| sso -->|"ResolvedTenant + JIT"| core
dir -->|"provision / deprovision"| scim -->|"memberships"| core
core -->|"memberships"| fga -->|"relation tuples"| rbac
classDef own fill:#0d9488,stroke:#0f766e,color:#ffffff;
classDef ext fill:#1e293b,stroke:#0f172a,color:#e2e8f0;
class sso,scim,fga,core own;
class idp,dir,rbac ext;
Three seams, each a small interface in contract/index.ts:
| Seam | Contract | Direction | The enterprise ask it answers |
|---|---|---|---|
| Identity in | SsoResolver |
IdP → tenant-kit | "Our people sign in through our Okta, and their AD groups decide their role." |
| Provisioning in | ScimDirectory |
directory → tenant-kit | "When IT offboards someone, they're out of your product the same hour." |
| Roles out | RoleBridge |
tenant-kit → engine | "Our platform team's OpenFGA needs to know who belongs where." |
The one rule
Adapters never verify credentials. Every adapter's input is the output of your verifier — a provider SDK, a JWKS middleware, a SAML library — after signatures, audience and expiry have been checked. An adapter that grew its own verification would be a second security implementation to audit, differently wrong from the first. Adapters normalize and route; verifiers verify. (This is the tenancy version of billing-kit-adapters' "every create is idempotent" — the rule the review checks first.)
Three more, close behind:
- Everything converges under replay. IdPs retry, directories resync, bridges re-run. Same input twice, same state once.
- tenant-kit's invariants win.
last_ownersurfaces (SCIM answers 409; SSO reportsrole_kept) — never swallowed, never forced past. Locking every owner out because the IdP misfiled a group is worse than one stale role. - External identities are namespaced. Okta's
suband Azure'ssubshare no namespace; the default UserId mappings (<connection>|<subject>,scim:<tenant>|<userName>) keep them apart until you prove they meet, by overridingmapUserwith an asserted join key on both sides.
Adapters
| Adapter | Seam | Status | Covers |
|---|---|---|---|
sso-oidc |
identity in | ✅ shipped | Any OIDC IdP: per-tenant connections, exact-issuer routing, group→role mapping (highest wins), JIT provisioning, matches predicate for shared issuers (Azure multi-tenant tid). |
scim |
provisioning in | ✅ shipped | SCIM 2.0 Users subset the major IdPs drive: lookup filter, POST/PUT/PATCH/DELETE, active:false deprovisions, group-attribute role mapping. Groups endpoints deliberately absent in v1 (the file says why). |
openfga |
roles out | ✅ shipped | Zanzibar-style engines via a 3-method TupleStore: convergent desired-state sync, one direct tuple per membership, role inheritance left to the engine's model. |
| your system | — | open a PR | adapters/_template is the starting point. |
SAML note: there is no separate SAML adapter because there doesn't need to
be — a SAML library's verified assertion maps to VerifiedIdentity (entity
id → issuer, NameID → subject, attribute → groups) and feeds
sso-oidc's resolver unchanged. If that mapping ever needs code, it's a
folder here.
Using the shipped adapters
Enterprise SSO with JIT — after your OIDC middleware verifies the token:
import { createOidcSso } from 'tenant-kit-adapters/sso-oidc';
const sso = createOidcSso({
tenancy,
connections: [{
id: 'acme-okta',
tenantId: acme.id,
issuer: 'https://acme.okta.com',
groupToRole: { 'App Admins': 'admin', Everyone: 'member' },
}],
});
const { tenant, membership, provisioning } = await sso.resolve({
issuer: token.iss, subject: token.sub, groups: token.groups, claims: token,
});
if (provisioning === 'role_kept') alertOps('IdP demotion refused: last owner');
tenancy.run({ tenant, membership }, () => next());
SCIM provisioning — mount one handler per tenant behind the bearer token you gave their IT:
import { createScimDirectory } from 'tenant-kit-adapters/scim';
const scim = createScimDirectory({ tenancy, tenantId: acme.id,
groupToRole: { 'App Admins': 'admin' } });
app.all('/scim/v2/*', authenticateScimToken, async (req, res) => {
const out = await scim.handle({
method: req.method, path: req.path.replace('/scim/v2', ''),
query: req.query, body: req.body,
});
res.status(out.status).json(out.body);
});
RBAC mirror — on membership change and on a schedule:
import { createFgaBridge } from 'tenant-kit-adapters/openfga';
const bridge = createFgaBridge({ tenancy, store: myOpenFgaTupleStore });
const report = await bridge.syncTenant(tenantId); // { wrote, deleted }
Testing
testkit/memory-tenancy.ts is an in-memory Tenancy that keeps the core's
behavioural contract — same TenancyError codes, same idempotency, same
last_owner refusals — so adapter tests exercise the glue's decisions
without Postgres. If your adapter needs a behaviour it doesn't model, model
it there faithfully; don't loosen the adapter.
pnpm typecheck # adapters must satisfy the contract types
pnpm test # 21 tests and counting
Licence
Apache-2.0, same as tenant-kit — an adapter is usable anywhere the core is. See LICENSE.