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:

  1. Everything converges under replay. IdPs retry, directories resync, bridges re-run. Same input twice, same state once.
  2. tenant-kit's invariants win. last_owner surfaces (SCIM answers 409; SSO reports role_kept) — never swallowed, never forced past. Locking every owner out because the IdP misfiled a group is worse than one stale role.
  3. External identities are namespaced. Okta's sub and Azure's sub share no namespace; the default UserId mappings (<connection>|<subject>, scim:<tenant>|<userName>) keep them apart until you prove they meet, by overriding mapUser with 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.