@dtfoundry/app-tenancy
v0.6.0
Published
Multi-tenant request layer for Digital Trust Foundry applications
Keywords
Readme
@dtfoundry/app-tenancy
One deployment, many customers.
@dtfoundry/foundry-sdk is the client for one tenant's
Foundry Platform. This package is the layer in front of it: it works out which
tenant a request belongs to, checks that tenant has this application enabled and
is not suspended, and hands the request that tenant's client — and no one
else's.
Four pieces:
tenantMiddleware— resolves the tenant and putsreq.tenantId,req.tenantandreq.foundryon the request.TenantRegistry— oneFoundryClientper tenant, keyed by tenant and credential version, so a rotated key drops the old client with no restart.createProvisionRouter—/internal/tenants, the contract Foundry calls when a tenant enables, suspends or re-credentials this application.createCredentialCipher— AES-256-GCM, so the stored Platform key is encrypted at rest and bound to the tenant it belongs to.
One rule: an application never constructs a FoundryClient itself. It asks
the registry. A client built by hand is a client built from whatever credential
was nearest, which is how one customer ends up reading another's data.
Install
pnpm add @dtfoundry/app-tenancy @dtfoundry/foundry-sdkNode 20+ is an engine requirement. Express 4 or 5 and @dtfoundry/foundry-sdk
are peer dependencies — both are things the application already has, and this
package bundles neither.
The SDK is a peer for a reason worth knowing. req.foundry is a client this
package built. If the SDK were an ordinary dependency pinned to a caret range,
an application that upgraded past that range would install a second copy and
keep being handed a client from the older one — reading responses typed without
the fields it upgraded for, with nothing in the install saying so. As a peer,
the package manager resolves the application's own SDK whenever it satisfies
>=0.1.0 <1, so the client you are handed is built from the version you chose.
Wiring it up
import {
createCredentialCipher,
createProvisionRouter,
TenantRegistry,
tenantMiddleware,
tenantOf,
} from "@dtfoundry/app-tenancy";
const registry = new TenantRegistry({
store, // your TenantStore — see below
cipher: createCredentialCipher(process.env.CREDENTIAL_KEY!),
});
// Service-to-service. Mount it before the tenant middleware: Foundry calls it
// with a service token, not as a tenant.
app.use(
"/internal/tenants",
createProvisionRouter({
registry,
productSlug: "files",
serviceToken: process.env.SERVICE_TOKEN,
}),
);
app.use(
tenantMiddleware(registry, {
baseDomain: "digitaltrustfoundry.com",
edgeSecret: process.env.EDGE_SECRET,
skip: (req) => req.path === "/health",
}),
);
app.get("/api/documents", async (req, res) => {
const { tenant, foundry } = tenantOf(req);
const trustables = await foundry.listTrustables({ limit: 20 });
res.json({ tenant: tenant.tenantSlug, trustables });
});Generate the credential key once per deployment and keep it out of the database the ciphertext lives in:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"Who is signed in
requireSsoSession puts the person on req.ssoUser:
app.get("/api/documents", sso.requireSsoSession, (req, res) => {
const { userId, email, role, startingRole } = req.ssoUser!;
});role is the workspace role Global Auth carries — admin or user. Your
application's own role taxonomy is yours, and it belongs in your database.
startingRole is optional and advisory: it is the role the admin who granted
this person access chose, from the vocabulary your application declared in the
catalog. Apply it once, when you create the local profile, and never again —
re-applying it on every sign-on would undo any role change an administrator made
inside your application. It is absent when nobody chose one, so test for its
presence rather than comparing against a default:
function initialRole(session: SsoUser): Role {
if (session.startingRole && ROLES.includes(session.startingRole)) {
return session.startingRole as Role;
}
return session.role === "admin" ? "admin" : "viewer";
}The store
The one interface an application implements. Each app keeps this in its own database, next to its own data.
interface TenantStore {
findById(tenantId: string): Promise<StoredTenant | null>;
findBySlug(tenantSlug: string): Promise<StoredTenant | null>;
upsert(record: StoredTenant): Promise<StoredTenant>;
update(tenantId: string, patch: TenantPatch): Promise<StoredTenant | null>;
delete(tenantId: string): Promise<boolean>;
}upsert must be idempotent on tenantId, and tenantSlug must be unique —
both are lookup keys on the request path. encryptedApiKey is opaque: it
arrives already encrypted and the store never sees the credential in plaintext.
How a tenant is recognised
X-DTF-Tenant (stamped by the edge router after it resolves the route) →
subdomain → a dev-only override header.
The subdomain step needs baseDomain and reads X-DTF-Tenant-Host before
Host, because the proxy chain rewrites Host and X-Forwarded-Host on the
way through. files.uni.example.com and uni.example.com both name the tenant
uni. A hostname outside the configured zone resolves to nothing.
The dev override (X-DTF-Dev-Tenant) is ignored unless allowDevOverride is
explicitly true, so shipping it enabled takes a deliberate act.
Every step of that ladder is a request header. If the application's origin
is reachable without going through the edge router — a platform-assigned
hostname, an instance-prefixed name the router does not see — a caller can
stamp X-DTF-Tenant themselves and be handed any provisioned tenant's client.
Set edgeSecret to the shared secret the router stamps as
X-DTF-Edge-Secret, and a request that does not present it resolves to no
tenant at all. Leave it unset only where the origin genuinely cannot be reached
except through the router.
edgeSecret also takes a list, which is what makes rotation possible: the
router stamps one value at a time, so origins accept both across the window
(edgeSecret: [current, next]), the router switches, then the old value comes
out. Reversing that order takes down every origin that has not yet accepted the
new value, until it does.
Leaving edgeSecret unset skips the check. Setting it to something with no
usable value in it — "", [], a pair of environment variables that turned
out to be empty — resolves no tenant at all, so a guard that was configured
wrongly refuses requests rather than waving them through.
A request that resolves to no tenant is 400 TENANT_NOT_RESOLVED; one for a
tenant this instance does not serve is 404 TENANT_NOT_FOUND; a suspended
tenant is 403 TENANT_SUSPENDED. All in the standard envelope.
Provisioning
createProvisionRouter implements the contract Foundry calls:
| Route | Effect |
| ------------------------------------ | ------------------------------------------------------------ |
| POST / | Bind a tenant. Idempotent — a repeat re-binds and resumes. |
| PATCH /:tenantId | Suspend, resume, or replace the config. |
| DELETE /:tenantId | Unbind. Idempotent; the application's own data is untouched. |
| POST /:tenantId/rotate-credentials | Replace the key. Caches are dropped before it answers 200. |
| GET /:tenantId/health | Per-tenant database and Platform checks. |
Auth is a shared service token in X-Service-Token, compared in constant time.
It fails closed: an instance started without one answers 503 to every route
rather than accepting anything.
Licence
Apache-2.0
