Skip to main content

Directory module

Identity, organisations, users, roles, and permissions. Every other module reads identity.* for the principal performing a request — this module is the foundation everything else depends on.

  • Source: apps/directory/
  • Schema: identity
  • Project Tracker prefix: DIR-*
  • Hosting: root zone in the multi-zone topology — constellation.planetb2b.com is served by Directory, which rewrites /projects/* and /catalog/* to the sub-zones.

1. Purpose

Directory is the platform's identity backbone. It answers who is asking, and what are they allowed to do for every request in every module — no search, project update, or wiki read happens without Directory's say-so.

Key capabilities

  • Tenants, member organisations (with hierarchy and verification workflows), and users
  • Per-tenant roles and permissions — hybrid RBAC/ABAC, evaluated on every request
  • Organisation certifications with expiry tracking, and capability tags used for supplier matching
  • Tenant policy glue: locale, time zone, currency

Who uses it

  • Tenant administrators managing organisations, users, and roles
  • Compliance staff keeping supplier certifications current
  • Every other module, implicitly, on every authenticated request

For product context, see What is Constellation? and the Tenant types reference. The rest of this page is the technical reference.

Directory owns who in Constellation: tenants, organisations, users, memberships, roles, permissions, and the small slice of policy-glue (locale, time zone, currency) that the platform reads in every request. Every authenticated request flows through Directory's middleware — createConstellationAuthMiddleware — even when the destination is a sub-zone like Catalog or Project Tracker. Tenant locale resolution, the active-membership lookup that withTenantAuth performs, and the user/role/permission tables every other module joins to are all owned here.

2. Component diagram (C4 L3)

Call direction is strict: API Route → Tool → Service → Repository. Routes never call services or repositories directly. Workflows and event handlers must call tools or services, never repositories. See route-wrapping and module-isolation.

3. Schema

identity — owned by Directory. Other modules may read it directly (raw SQL, no Prisma cross-schema joins, per module-isolation) but may not issue direct DML against it.

Where another module needs a transactional identity mutation, Directory exposes it as a SECURITY DEFINER function in the identity schema, created and versioned by a Directory migration. The caller invokes it as ordinary SQL, so the write joins the caller's own transaction — which is what makes an atomic cross-schema operation possible at all (invitation acceptance, for instance, must commit the membership and the invitation status together, and an HTTP call cannot enlist in the caller's transaction).

The boundary is about ownership, not privilege stripping: the shared runtime role still holds direct DML on identity.* (Directory itself needs it), so what keeps a consuming module's identity mutations routed through Directory-owned, Directory-versioned code is convention plus CI, not revoked table grants. The CI gate is npm run check:identity-writes, and today it scans apps/project-tracker/src only — that is where the debt was. Catalog and wiki issue no identity writes at present; a module that starts to should be added to the gate's scanned roots in the same change. Each function re-establishes the tenant guard that RLS cannot apply to an RLS-exempt owner, and each derives its EXECUTE grant from the full set of privileges it exercises, so calling it can never achieve more than the caller could do directly. That guarantee is time-bounded: the conjunction is evaluated when the migration RUNS, so the grant is never ISSUED to a role that could not already do it, but it does not follow the grantee afterwards — narrowing a role's identity.* access must re-apply these migrations (which revoke every non-owner and re-derive) or revoke the function grant, in the same change. That derivation is preceded by a full EXECUTE reset — every non-owner grantee is revoked, not just PUBLIC — because ALTER DEFAULT PRIVILEGES grants the runtime role EXECUTE on each function the moment it is created, and a role-specific grant left in place would let a role that fails the privilege conjunction reach the function anyway. Current functions: grant_invitation_access, provision_user, merge_user_metadata, update_organisation_profile, bootstrap_tenant. Rationale and constraints: ADR-025.

4. Entities

AggregatePurpose
tenantsTop-level isolation boundary. Owns default_locale, enabled_locales, time_zone, currency.
organisationsMember companies inside a tenant. Hierarchical via parent_id.
usersPrincipals. tenant_id is the user's "home tenant". clearance holds the platform ladder (§4.1).
user_tenant_membershipsActive-tenant resolution. withTenantAuth validates membership before any tenant-scoped call.
roles, permissionsRBAC. role_permissions and user_roles are the join tables.
tenant_appsPer-tenant app enablement — the tenant half of the app-entitlement seam (§12).
api_keysIssued programmatic credentials, resolved by jti on every request. ephemeral separates machine-minted OAuth bridge tokens from user-issued keys (§13).
organisation_certificationsCompliance state with expiry → drives the credential.expired event.
organisation_competence_domainsCapability tags for organisation matching (used by Catalog supplier offer matching).

4.1 Stored clearance vocabulary

identity.users.clearance is the stored clearance on a user's Directory profile. The ladder it draws from is owned by @constellation-platform/auth-core and by public.clearance_rank(), which RLS policies evaluate inside the database.

Two things read a clearance, and they are not the same value. Request-time authorization uses the validated clearance claim on the caller's JWT, supplied by the auth provider (hasSufficientClearance(jwt.clearance, …) in auth-core, and the RLS session GUC). Nothing synchronises this column into issued tokens, so updating the row does not change what the token-driven path allows.

A second class of consumer reads the stored row directly. Today there is exactly one that makes an access decision from it (Directory's own admin user page merely displays the value): Project Tracker's coordinator-consult masking (PLT-212, coordinator-consults.service.ts), which loads the viewer's identity.users.clearance and redacts consult excerpts classified above it. For that surface, updating the row can change what the user sees — with a caveat worth knowing: that read is tenant-scoped by RLS against the user's home tenant, so for a viewer acting through a membership in a different tenant it can return no row, and the viewer is then treated as UNCLASSIFIED. That direction over-masks rather than over-discloses, but it means the stored value is not always the value that surface uses.

So: same vocabulary, two independently-sourced values. When you change one, say which. A new consumer should prefer the JWT claim unless it genuinely needs the stored profile value, and should say why in its own code.

The stored vocabulary is five values, ascending:

UNCLASSIFIED < INTERNAL < RESTRICTED < CONFIDENTIAL < SECRET

INTERNAL was inserted at rank 1 (PLT-497 for the platform ladder, PLT-498 for this column). The relative order of the original four is deliberately unchanged: that order is what every stored classification means relative to every stored clearance, so reordering it would silently reinterpret live data in both directions with no accompanying data migration.

Enforcement is a single CHECK constraint, users_clearance_check. Widening it means dropping and restating the complete list — PostgreSQL cannot add a value to a CHECK, and a second constraint would leave the older, narrower one active and the new value rejected.

Directory stores and reads the value; it does not yet expose a way to assign one. Every user is created UNCLASSIFIED, and the admin user page renders the clearance read-only.

5. Domain events

Published from src/server/events/directory.events.ts via the transactional outbox under the directory.* namespace. The full payload schemas live in @constellation/contracts and are catalogued in the Domain events index.

directory.tenant.created, directory.tenant.updated, directory.organisation.created, directory.organisation.verified, directory.organisation.verification.requested, directory.organisation.verification.rejected, directory.organisation.verification.completed, directory.user.created, directory.user.suspended, directory.user_credential.updated, directory.role.assigned, directory.role.unassigned, directory.role.permission_changed, directory.role.deleted, directory.credential.expired, directory.credential.expiry.approaching, directory.credential.expiry.imminent, directory.qualification.updated.

6. Public API

The Directory API reference is not yet wired into this site — adding Zod→OpenAPI to apps/directory/ is tracked under INF-27. Illustrative routes:

  • POST /api/organisations — create a new organisation in the active tenant. Worked-example of the full request lifecycle is in Request lifecycle.
  • GET /api/users/:userId — read a user with role assignments.
  • POST /api/auth/memberships — bootstrap call after login; resolves the user's set of { tenantId, organisationId } pairs for the org switcher.
  • GET /api/organisations/switchable — organisations the signed-in user can switch into. Distinct from GET /api/organisations, which is the tenant-wide paginated admin list behind the /organisations management page. See Which organisations a user can switch into below.
  • GET /api/search?q=<query> — tenant-scoped search over organisations + users (DIR-85, PT-625 Stage 1), returning grouped hits for the shared GlobalSearch bar. Synthetic agent accounts (agent+<class>@constellation.local) are excluded from user hits; each group is capped by limit (1–25, default 10).
  • POST /api/auth/api-keys / GET /api/auth/api-keys / DELETE /api/auth/api-keys/:id — issue, list, and revoke the caller's own API keys (DIR-1). Issue and revoke are session-only (assertSessionCaller): an API key may not mint or revoke another — that is the no-chaining property. Listing is open to any authenticated token kind, since reading one's own key metadata is not an escalation. The list returns the caller's user-issued keys — active, expired and revoked — but excludes machine-minted OAuth bridge tokens (§13). This is the endpoint the Project Tracker settings page reads.

Which organisations a user can switch into

This is a property of the user's profile, not of the app they are looking from: if a user can reach an organisation, they can reach it from Directory, catalog, wiki and project-tracker alike. All four zones therefore answer it from one shared implementation — listSwitchableOrganisations in @constellation-platform/auth-next — even though each serves it from its own route, because every zone must also work as a standalone preview deployment where Directory is not reachable at the root.

Two properties are easy to get wrong and are worth stating explicitly:

  • Membership status is not enough — and the gate is in the resolver, not the list. Both list_active_memberships and resolve_active_membership require the membership's tenant to be ACTIVE, not just the membership row (migration 032, PLT-477), so an organisation in a SUSPENDED or DECOMMISSIONED tenant is not offered here and cannot be entered by any other id source either. The helper itself no longer filters tenant status: with the resolvers gated, its own clause could not fire, and a per-request query that reads as a security control while doing nothing is worse than its absence.

    Superseding PLT-476's acting-tenant exemption. Until PLT-477 this list carried an exemption for the caller's own acting tenant. That was not a design choice — resolve_active_membership still resolved a membership in a disabled tenant, so hiding the current organisation would have emptied the acting-tenant slot and made the first entry a FOREIGN tenant the switcher would reseed x-active-org from, i.e. a silent tenant switch. Gating the resolvers removed the condition the exemption existed for, and it was deleted. It must not be re-added: doing so would re-admit an acting tenant the auth boundary now refuses. Project-tracker used its own copy of this logic until PLT-483 and applied no tenant gate whatsoever.

  • The acting organisation affects ORDERING, not membership. The list spans every tenant the caller belongs to; the acting organisation decides which entry sorts first (and seeds x-active-org when no cookie is set).

The rationale and the deferred alternative — a single Directory-owned endpoint consumed cross-zone — are recorded in ADR-023.

Creating an organisation, and why the switcher offers it in one zone only

Two different things are both called "creating an organisation", and only one of them produces something you can switch into.

Directory POST /api/organisations (/organisations/new)Project Tracker POST /api/organisations
Createsone organisation inside your current tenanta new tenant + its organisation
Grants you a membershipnoyes, ACTIVE
Appears in the switch list abovenoyes, immediately
What it is forrecording an organisation in your tenant's directory — a partner, supplier or sub-tier entity, with a type, an optional parent, and a verification workflowstarting a new workspace you are the Admin of

Because the switch list is membership-scoped, an organisation created through Directory's form never appears in it. That is not a defect in the form — it is what a tenant-local register is — but it does mean the form cannot back a switcher action. So Directory, Catalog and Wiki show no "Create Organisation" entry in the switcher; Project Tracker does (PLT-627). Directory's form remains available and unchanged, from the Organisations management page.

It also cannot be repaired by having that form grant the creator a membership. identity.user_tenant_memberships is UNIQUE (user_id, tenant_id), so a caller who already belongs to an organisation in that tenant can hold no second membership there, and repointing the existing row would move them out of their current organisation. One membership per tenant is the reason "switch organisation" is in practice "switch tenant", and the reason a create action in the switcher can only mean "create a new tenant".

7. Layers + call direction

LayerPathMay import from
API Routessrc/app/api/Tools only
Toolssrc/server/tools/Services, Policies, Events
Servicessrc/server/services/Repositories, Policies, @constellation-platform/db
Repositoriessrc/server/repositories/@constellation-platform/db (Prisma client + raw SQL)
Policiessrc/server/policies/@constellation-platform/auth-core
Workflowssrc/server/workflows/Tools or Services (never repositories)
Eventssrc/server/events/@constellation-platform/events publish() + outbox

Enforced at PR time by scripts/check-route-wrapping.ts — invoked via npm run check:routes, which scans apps/directory/src/app/api and apps/catalog/src/app/api. Every withAuth must be paired with withTenantAuth. Plus ESLint import boundaries.

8. Code entry points

9. Known exceptions / pitfalls

  • Directory is the root zone in production. It serves constellation.planetb2b.com and rewrites /projects/*constellation-platform.vercel.app and /catalog/*constellation-catalog.vercel.app. Multi-zone wiring lives in apps/directory/next.config.ts.
  • Tenant locale policy lives here. tenants.default_locale ∈ tenants.enabled_locales is enforced by a DB CHECK; the form validates client-side too. users.preferred_locale = NULL means "inherit tenant default". The locale resolver wires into the platform auth middleware via the localeResolver option — only Directory wires it today; Catalog and Project Tracker stay on the existing path until they opt in. See Multilanguage (i18n).
  • No Prisma models for cross-module identity reads. Other apps that need to read identity.users / identity.organisations use raw SQL via repositories like Project Tracker's IdentityUserRepo — never Prisma cross-schema joins.
  • (admin) route group is platform-operator-only. Tenant admins use /settings, not /tenants/[id]. The two pages enforce different RBAC scopes.

Pre-tenant-context identity resolution (RLS bootstrap)

/api/auth/me and the org switcher must read identity.users / identity.user_tenant_memberships before app.tenant_id is set — the membership row is precisely what validates the tenant. Under a non-bypass runtime role those tables are filtered to zero rows by FORCE RLS, so the lookups go through SECURITY DEFINER functions owned by the NOLOGIN identity_bootstrap role: resolve_active_membership / list_active_memberships (migration 016) and resolve_identity_user (migration 024, DIR-72). The bootstrap RLS policies (USING pg_has_role(current_user, 'identity_bootstrap', 'USAGE')) match only inside those functions; outside them tenant isolation is unaffected.

Both membership resolvers require the membership's tenant to be ACTIVE, not just the membership row (migration 032, PLT-477). See Tenant status semantics below.

resolve_identity_user is id-first with a fail-closed email fallback: it matches by primary key first and only falls back to email when exactly one non-deleted row matches. identity.users.email is unique only per tenant (and the synthetic agent emails exist in every tenant), so a shared address resolves to no row rather than hydrating an arbitrary tenant.

  • Invariant: the app runtime role (constellation_app) must NOT be a member of identity_bootstrap. That membership makes the bootstrap "see-all" policies fire for every direct app query, leaking users/memberships cross-tenant (DIR-72; the residual INF-60 grant was revoked by migration 028 — DIR-79).
  • Runtime role (INF-60, cut over). All of the above only enforces isolation once the app connects as a non-bypass role: under a rolbypassrls = true role PostgreSQL silently disables every policy, even with FORCE ROW LEVEL SECURITY, and these functions become a no-op for isolation. Production now connects as constellation_app (NOBYPASSRLS), and that is enforced rather than assumed — all four apps call assertNonBypassRole() from @constellation-platform/db in src/instrumentation.ts on startup, so a DATABASE_URL repointed at a BYPASSRLS role fails the boot instead of silently serving cross-tenant data. DIRECT_URL deliberately stays on a BYPASSRLS role, because migrations need it. See Deployment.

Tenant status semantics

identity.tenants.status is one of ACTIVE, SUSPENDED, DECOMMISSIONED. The ladder is a lifecycle distinction, not an access tier — any non-ACTIVE status is a full deny at the auth boundary, and the specific non-ACTIVE value carries no access meaning.

StatusAccess at the auth boundaryReversibleData
ACTIVEfulllive
SUSPENDEDnone (403)yes — flipping back to ACTIVE restores it exactlypreserved untouched
DECOMMISSIONEDnone (403)terminal by conventionpreserved but abandoned

Enforced in three places, all with the same predicate (PLT-477):

  • identity.resolve_active_membership and identity.list_active_memberships (migration 032) — the resolvers withTenantAuth and every other membership consumer run. This gates the tenant a request is scoped into, whichever id source named it: the x-active-org cookie, the x-act-as-org header on the API-key path, or the JWT tenant_id claim.
  • createPrincipalCheck in @constellation-platform/auth-next — returns TENANT_DISABLED. This gates the attested claims.tenant_id, which is not always the tenant finally scoped into, so it is a complement to the above rather than a substitute (and apps/wiki wires no principal check at all).
  • identity.mcp_refresh_principal_live (migration 029) — refuses to re-mint an MCP token for a disabled tenant.

Read-only-but-visible for SUSPENDED is deliberately not implemented. A read-only tier is not something a membership resolver can express — it would need a capability threaded onto PlatformJWT, honoured by every mutating route in four modules, and enforced at the DB layer to be worth anything. Shipping the deny first does not foreclose it.

Suspending a tenant now revokes access at the auth boundary on the caller's next request — every tenant-scoped route reached through withTenantAuth, plus PT's own acting-org resolution. (Access reached by addressing a RESOURCE directly by id is gated on the paths PLT-477 audited; the sweep of the rest is PLT-638.) Before PLT-477 it did not: only revoking every user_tenant_memberships row did, which is a different and destructive operation. A user whose only membership is in a disabled tenant lands in the pre-existing zero-membership state — authentication still works (/api/auth/* is a public path) and no x-active-org cookie is seeded from the dead tenant; only tenant-scoped routes 403. Recovery is an operator action with no data loss.

10. MCP OAuth authorize endpoint (DIR-75)

Directory acts as the identity provider for the MCP OAuth authorization code grant flow. This enables Claude Connectors, ChatGPT OAuth mode, Codex, and other MCP clients to authenticate users and receive identity claims via a standards-compliant OAuth handshake.

Endpoints

MethodPathPurpose
GET/api/auth/mcp/authorizeBrowser-facing authorize redirect handler. Validates the redirect_uri client-aware (INF-286) before any session check: the first-party pt CLI client (constellation-pt-cli) may use RFC 8252 loopback redirects (http://127.0.0.1:<port>/callback or http://[::1]:<port>/callback — loopback literals only, any port); every other client stays on the MCP_REDIRECT_ORIGIN_ALLOWLIST exact-origin check. Writes mcp_authz_pending signed cookie; redirects to /login?redirect=/api/auth/mcp/authorize if unauthenticated.
POST/api/auth/mcp/authorizeConsent form submission. Validates CSRF double-submit cookie; on Allow issues the authorization code; on Deny records a denial audit entry.
GET/mcp/authorizeConsent page. Server component that reads the mcp_authz_pending and mcp_csrf cookies (both set by the GET Route Handler) and renders the consent UI (client_id, redirect_uri host, scopes, Allow/Deny) with the CSRF nonce embedded in the form.
POST/api/internal/auth/mcp-tokenServer-to-server only. The MCP AS (INF-164) sends this to exchange the one-time code for identity claims. Protected by X-Mcp-Client-Secret (constant-time check); no browser session required.
POST/api/auth/mcp/cli-tokenPublic client (INF-286). Token endpoint for the pt CLI / local MCP (client_id: constellation-pt-cli). No client secret: authorization_code is PKCE-authenticated (S256, verified inside the SECURITY DEFINER consume), refresh_token/revoke by possession of the rotating refresh token. Responses set Cache-Control: no-store + Pragma: no-cache (RFC 6749 §5.1).

Active CLI sessions (DIR-104)

Self-service management of the refresh-token families above: one entry per npx pt login (no machine identifier is stored, so two logins from one machine are two sessions), letting a user end a session on a machine they no longer control. Both endpoints accept browser session tokens only (tokenKind === 'session') — a CLI-minted bridge token must not be able to enumerate or end the sessions of the credential chain it belongs to, the same "no chaining" property DIR-1 applies to API-key management.

MethodPathPurpose
GET/api/auth/cli-sessionsThe caller's own live sessions, aggregated from identity.mcp_cli_refresh_tokens by family_id: createdAt (consent), lastRotatedAt (newest rotation), expiresAt (absolute family expiry — rotation copies it rather than extending it) and rotationCount. Never hash material — migration 030 column-restricts the runtime role's SELECT so token_hash / rotated_from are unreachable.
DELETE/api/auth/cli-sessions/:familyEnds one session. Calls identity.revoke_mcp_cli_refresh_family and, in the same transaction, revokes that family's live bridge access tokens. Returns { familyId, refreshTokensRevoked, accessTokensRevoked }. A family that is unknown, already ended, owned by a tenant peer, or the caller's own in a different tenant all return 404 — never 403, so the endpoint is not an existence oracle.

identity.api_keys.cli_family_id (migration 035) is what makes the second write possible: mintPtAccessToken stamps it on the backing row for the two CLI paths (initial exchange and refresh), leaving it NULL for user-issued keys and the connector paths, which belong to no family. Without that link, revoking a bridge token never ended the session — the CLI minted a replacement at its next refresh — and revoking the family alone left the current access token usable for the rest of its TTL.

Propagation is the platform-standard ~30 seconds (the 30 s principal-liveness cache in createPrincipalCheck), not instant. A CLI access token is also now capped to its family's absolute expiry, and a refresh in the family's final ~44 s is refused as invalid_grant, so a bridge token can never outlive the session that produced it and become unrevokable from this surface. That 44 s is derived, not chosen: the CLI treats a token with 30 s or less left as unusable, up to ~10 s can elapse between the refresh being authorised and the token being signed, a further ~2 s is held back for the audit write and the commit that follow, and 2 s more covers the two separate points at which the remaining lifetime is floored to whole seconds. A session in that final window is still listed and still endable — the refusal is about minting a replacement, not about hiding the session.

pt CLI public-client flow (INF-286)

The first-party CLI client uses the same consent screen with an RFC 8252 loopback redirect_uri (http://127.0.0.1:<port>/callback, any port — allowed only for constellation-pt-cli) plus PKCE. Consent-time roles are resolved server-side from the authoritative provider (AuthProvider.getUserInfo — Supabase app_metadata.roles in prod), never the session JWT. The exchange mints a PT bridge token (label pt-cli-oauth) and a rotating refresh token family (identity.mcp_cli_refresh_tokens, hashes only, ~30-day absolute lifetime). Refresh re-runs the DIR-87 fail-closed chain behind a family row lock; reuse outside a one-shot ~60 s grace window revokes the family. Role or membership changes through Directory's mutation tools invalidate the user's grants + refresh families (mcp_authority_invalidated, audit-critical), forcing re-consent under current roles.

Consented grants and the S2S re-mint (DIR-80 / DIR-87)

The token exchange also returns a short-lived PT bridge token (ptAccessToken, DIR-80) and — since DIR-87 — persists the consented identity (tenant_id, user_id, client_id, org_id, roles, scope) in identity.mcp_grants (FORCE-RLS, unique per (tenant_id, user_id, client_id)), inside the same transaction as the exchange.

The refresh mode (grant_type: "refresh" on the same endpoint) re-mints the bridge token for a connector-held grant with no browser session. It re-verifies the user's ACTIVE membership and signs the fresh token exclusively with the stored consent-time roles — the refresh request carries no roles. Missing grant record, offboarded user, or a cross-tenant lookup all fail closed with invalid_grant. The mode is enabled by default; MCP_PT_REMINT_ENABLED=false (or 0) is the explicit kill-switch (403 refresh_disabled). Each re-mint emits a routine auditAction (mcp_pt_token_reminted) with the request correlation id.

The authorization-code exchange accepts an optional expected_scope request field — the scope the connector displayed and validated at /authorize. Directory compares it against the consumed code's scope at the top of the exchange transaction (before any consent audit, grant persist, or bridge-token mint) and fails closed with 400 { "error": "invalid_scope" } when it differs or is absent. Because the check throws inside the same transaction, a mismatch rolls the whole exchange back — the one-time code is not consumed, no mcp_oauth_consent audit is written, and no identity.api_keys bridge token is minted. This binds the issued grant to the scope the user actually saw, atomically, so a tampered intermediate authorize URL cannot leave behind an orphaned (unusable) bridge token. The connector maps invalid_scope to access_denied (unchanged client UX). The re-mint mode has no consent screen to echo, so it never carries expected_scope. Reject-on-absent means Directory must deploy in lockstep with (or after) the connector that forwards the field.

On a successful token exchange, auditCritical() is called with:

  • action = 'mcp_oauth_consent'
  • resourceType = 'mcp_client'
  • resourceId = client_id
  • module = 'directory'
  • changes = { scope, redirect_uri, client_id }

On a denied consent, auditAction() is called with action = 'mcp_oauth_consent_denied'.

Environment variables

VariablePurpose
MCP_CLIENT_SECRETShared secret validated with timingSafeEqual on POST /api/internal/auth/mcp-token.
MCP_REDIRECT_ORIGIN_ALLOWLISTComma-separated exact scheme+host allowlist (e.g. https://mcp.planetb2b.com,https://constellation-pt-mcp.vercel.app). Editing it requires a Directory redeploy to recompile.
MCP_COOKIE_SECRETHMAC-SHA256 key with a dual use: it signs the mcp_authz_pending cookie and keys the code_hash HMAC for identity.mcp_authorization_codes. Rotating it invalidates outstanding authorization codes as well as in-flight cookies. mcp_csrf is an unsigned random double-submit nonce and is NOT signed with this secret.

Trust model

The real trust anchor is the redirect_uri allowlist — the one-time authorization code is only ever 302'd to an allowlisted origin. A spoofed client_id cannot exfiltrate the code because it is always delivered to the already-verified origin. This is stated explicitly on the consent screen UI.

11. Default role seeding (PT-564)

Directory owns the authoritative, audited capability for seeding the six platform default roles on every tenant. This replaces the earlier Project Tracker–side bootstrapTenant call, which was non-compliant with the constitution (identity writes must be Directory-owned, per-tenant, and auditCritical-emitting).

The six default roles

RolePermission selector
AdminAll permissions in the catalog.
Project ManagerAll permissions whose resource is in the project-management set (fields, gates, initiatives, projects, tasks, etc.).
Viewerprojects.read, tasks.read, initiatives.read, comments.create.
Guest Customerprojects.read.own, tasks.read, initiatives.read, comments.create, files.read.
Project Customerprojects.read, tasks.read, initiatives.read, comments.create, files.read.
Project ApproverProject Customer set + tasks.approve.

Every role above additionally carries the three app-access permissions described in §12. That is load-bearing: ensureDefaultRoles replaces a role's permission set, so a spec omitting them would revoke them on the next run.

The three customer-collaboration roles (Guest Customer, Project Customer, Project Approver) gained initiatives.read in PT-564 — the previous PT-seeded defaults omitted it, which was the root cause of the bug. (Viewer already carried it.)

These six exact names at GLOBAL scope with no scopeId are canonical system roles. Directory exposes them as isSystem: true and rejects generic creation, rename, permission-replacement, and delete operations. Custom roles, including same-named roles at a non-global scope, remain mutable. Canonical permission changes flow only through ensureDefaultRoles.

Tool: ensureDefaultRoles

import { ensureDefaultRoles } from '@/server/tools/index';

const result = await ensureDefaultRoles(ctx, {
adminUserId: 'uuid-of-user', // optional — assign Admin role
actor: { id: 'system:backfill', type: 'SYSTEM' }, // optional — audit actor
});
// → { created: string[], updated: string[], adminAssigned: boolean }

Idempotent. Runs inside a single withTenantContext. Emits auditAction CREATE per new role, and auditCritical UPDATE_PERMISSIONS + role.permission_changed only for roles whose permission set actually changes (an unchanged re-run emits neither — no audit/event noise), plus optionally auditCritical ASSIGN_ROLE + role.assigned for the admin assignment.

Endpoint

POST /api/v1/tenants/:id/seed-default-roles

  • Requires create:role + assign:permission + assign:role — the full set the operation performs, so a holder of only create:role can't self-assign Admin via adminUserId.
  • The caller may only seed their own tenant (params.id === user.tenant_id).
  • Optional body: { adminUserId?: string (uuid) }.
  • Response: { data: { created: string[], updated: string[], adminAssigned: boolean } }.

Called by Project Tracker on org creation (cross-module client call to Directory).

Backfill script

Existing tenants that pre-date this change can be backfilled with:

# Dry run — writes its report to ./default-role-plan.json
DATABASE_URL=postgresql://... npm run db:backfill-default-roles -- --tenant <uuid>
DATABASE_URL=postgresql://... npm run db:backfill-default-roles -- --all-active

# Repair — only ever executes the plan that was reviewed
DATABASE_URL=postgresql://... npm run db:backfill-default-roles -- \
--tenant <uuid> --write --plan default-role-plan.json

Both commands are read-only by default and print a human plan plus a JSON report. --write is rejected without --plan <dry-run report path>: the repair replays the reviewed report and aborts if the set of ACTIVE tenants, any tenant's canonical drift, or the permission catalog moved since it was produced. Write mode calls ensureDefaultRoles per ACTIVE tenant as a SYSTEM actor, re-inspects after each write, exits non-zero on residual drift or tenant failure, and is idempotent. The manual Default role backfill GitHub workflow preserves the plan and result as artifacts and requires a protected Staging or Production environment before loading a write credential. The full staging/production procedure is in .ai/runbooks/default-role-production-repair.md.

Source locations

12. App entitlement (DIR-98)

Directory owns the data behind app-level entitlement — "may this (tenant, user) use this application at all?" — which is independent of, and enforced in front of, the row-level tenant RLS + classification every module already applies. Both dimensions are mandatory; neither substitutes for the other.

Two halves, ANDed by the shared resolver:

HalfSource of truthMeaning
Tenantidentity.tenant_apps(tenant_id, app) unique, enabled booleanThe tenant has provisioned the app.
UserA per-app permission held through the user's rolesThe user may enter the app.

identity.tenant_apps is RLS-forced with a single tenant_apps_tenant_isolation FOR ALL policy on identity.row_in_current_tenant(tenant_id). app is CHECK-constrained to the four known applications, so adding a fifth is deliberately a migration; the TypeScript mirror is apps/directory/src/lib/apps.ts.

Every tenant gets its rows automatically. An AFTER INSERT trigger on identity.tenants (trg_tenants_seed_apps) seeds all four apps enabled, so no tenant-creation path — Directory's own, project-tracker's bootstrapTenant, the provisioning script, seed scripts — has to know about entitlement. The trigger is SECURITY INVOKER and is RLS-safe by construction: tenants_insert already requires app.tenant_id to equal the row's own id, which is exactly what tenant_apps' WITH CHECK needs. The FK is ON DELETE CASCADE, so deleting a tenant removes its entitlement rows.

Permission namespace

AppPermission
Wikiapp.wiki.read
Catalogapp.catalog.read
Projectsapp.projects.read
Directorydirectory.read

The app.* namespace is deliberately disjoint from every module's data permissions. In particular the Projects entitlement is app.projects.read, never projects.read — the latter already exists as Project Tracker's "View all projects" data permission, which Guest Customer must not hold. app.* also works as a domain wildcard meaning "all apps". Directory is the one exception: it keeps the pre-existing directory.read (its original app-access permission, granted to admin roles only) rather than gaining a duplicate under the new namespace.

Fail-closed contract

The consuming resolver treats an unresolved entitlement as denied, and omits the app silently rather than returning an error that would reveal whether it is provisioned. That is why the introducing migration is default-enable: it records the access every principal already had — all four apps enabled for every existing tenant, and the three app.* permissions granted to every existing role — so enabling enforcement changes nobody's access. A role created outside ensureDefaultRoles (e.g. hand-created in role management) starts with no permissions, so a user whose only role is such a role will not resolve entitled; grant the app permissions explicitly in that case.

Source locations

13. API-key lifecycle: user-issued vs ephemeral bridge tokens (DIR-1 / DIR-103)

identity.api_keys holds two populations that look alike and must be treated differently. Both are api_key-kind JWTs signed with API_KEY_JWT_SECRET, and both require a row here — PT's createPrincipalCheck resolves the token's jti against this table and rejects a token with no row as KEY_NOT_FOUND.

User-issued keyEphemeral bridge token
Created byPOST /api/auth/api-keys (a human, in Settings)mintPtAccessToken — the OAuth flows
ephemeralfalsetrue
Labeluser-chosen, free textpt-cli-oauth (CLI) / mcp-pt-bridge (connector)
TTL1–365 days, capped by role — see belowMCP_PT_TOKEN_TTL, default 1 h, hard cap 24 h
Listed by GET /api/auth/api-keysyes — active, expired and revokedno
Deleted automaticallynevereligible 7 days past expiry, swept by a later mint

TTL caps by role (DIR-105)

A user-issued key's lifetime depends on whether the issuing principal is administrative:

PrincipalDefault TTLMaximum TTLEnv override
Administrative14 days30 daysAPI_KEY_TTL_MAX_ADMIN_DAYS
Everyone else90 days365 daysAPI_KEY_TTL_MAX_USER_DAYS

Administrative keys are short-lived precisely because they carry the issuing user's full authority — token-bound role narrowing is deferred to PLT-78. Requesting more than the applicable maximum is a ValidationError, not a silent truncation.

Both maxima are deployment-tunable and each is clamped to the 365-day platform ceiling (API_KEY_MAX_TTL_DAYS), so 30 is the administrative default maximum, not an absolute limit. A default is clamped down whenever an operator sets the matching maximum below it, so lowering a cap shortens keys rather than rejecting every request that omits expiresInDays.

"Administrative" is decided by one predicate — hasAdministrativeRole in @constellation-platform/auth-core. It matches Directory's canonical full-access role Admin plus the five historic variants admin, tenant_admin, TENANT_ADMIN, platform_admin, PLATFORM_ADMIN. Matching is case-sensitive: Admin and admin are distinct role identities here, and ADMIN is neither.

DIR-105. Until 2026-08-01 this gate used a private three-name list that omitted canonical Admin — the role granted the entire permission catalog — so a full-access principal received the 365-day ceiling. The predicate now lives in auth-core and is derived from ADMIN_EQUIVALENT_ROLES so the two cannot drift apart again.

That auth-core set is deliberately narrower: it grants blanket access in the simplified permission path, and a JWT roles claim carries names only, so a tenant-created role merely named Admin must not buy access. The TTL predicate may carry the name precisely because it restricts.

A custom role holding full permissions under some other name still receives the ordinary user caps — name matching is a proxy, tracked for replacement in DIR-106.

Why the split (DIR-103). A bridge token is minted per npx pt login and per access-token rotation, so an active CLI session produces roughly one row per hour. Before DIR-103 those rows were listed as if they were credentials the user managed, and never removed — the settings page filled with pt-cli-oauth entries badged "Expired" and the table grew without bound. They are OAuth session artifacts, so they are now excluded from the list and pruned.

Revoking a bridge token from a key list would also have been misleading: it does not end the CLI session. The refresh family in identity.mcp_cli_refresh_tokens is untouched, and the revocation's 401 drives PTClient into CredentialManager.onAuthError, which forces a refresh regardless of the stored token's exp and retries — so the row is replaced on the user's very next command. Ending a session means revoking the family (identity.revoke_mcp_cli_refresh_family, what pt logout does).

The sweep is audited. Deleting rows from an authentication table is a security-sensitive mutation, so the prune primitive returns the identities it removed and mintPtAccessToken writes an auditCritical entry (action: DELETE, resourceType: api_key, actorType: SYSTEM) naming them — id, jti, user, label and expiry — inside the same savepoint as the delete, so the two commit or roll back together. A sweep that deletes nothing writes no entry, so an idle mint adds no audit noise.

Pruning is opportunistic, not scheduled. Each mint sweeps up to 50 of its own tenant's ephemeral rows that are more than 7 days past expires_at — the same self-cleaning shape identity.rotate_mcp_cli_refresh_token uses for refresh-token tombstones, so no cron is involved and a tenant that stops minting stops accumulating. A row therefore becomes eligible at 7 days and is removed by whichever mint comes next; a tenant that never mints again keeps its remaining rows indefinitely, which is harmless because nothing lists or authenticates them.

What protects a live token is the expires_at bound, not the retention window: an unexpired row is never a candidate at any retention. The seven days are for forensics — they keep last_used_at readable for a while after a token dies, which the audit trail does not carry (it records the mint, not subsequent use). The sweep never matches a user-issued key, and it runs inside a SAVEPOINT, degrading to a logged no-op on failure — cleanup can never be the reason a pt login or a token refresh fails.

The floor and the cap are enforced inside the function, not supplied by the caller: holding EXECUTE cannot waive the forensic window by asking for zero retention, nor widen the bounded sweep into a tenant-scale delete.

The delete is least-privilege. It lives in the identity.prune_ephemeral_api_keys SECURITY DEFINER primitive, and the runtime role's table-level DELETE on identity.api_keys is revoked — the same design migration 030 uses for mcp_cli_refresh_tokens. A table grant would have been tenant-scoped by api_keys_delete but not scoped to ephemeral, to expiry, or to any row bound, so it would have let any bug in Directory hard-delete a live user-issued key. The four invariants (ephemeral-only, tenant-bound, past expiry + retention, bounded row count) are enforced in the database rather than only in app SQL, and the two thresholds are clamped inside the function so holding EXECUTE cannot choose the policy.

UPDATE is column-scoped to last_used_at, revoked_at, revoked_by, revoke_reason — the only columns the runtime writes after insert. That is what makes the DELETE revoke meaningful: with a table-wide UPDATE grant the protection was bypassable in two steps, by setting a user-issued key to ephemeral = true with a backdated expires_at and then invoking the primitive, at which point the row satisfies every invariant.

Deploy-window repair — the only path that classifies existing rows. The migration defines the classifier but deliberately does not invoke it: a blanket sweep would reclassify rows across every tenant with neither an explicit tenant_id nor an auditCritical() emission, and that helper is not reachable from SQL (it writes the audit row and publishes to the events outbox). So classification happens here, and running it is not optional — until it does, pre-existing bridge rows keep ephemeral = false and stay listed and unpruned.

After the deploy is also the correct moment for it. Migrations are applied to staging and production before the release PR opens, so for a period the new column exists while the old mintPtAccessToken — which does not set ephemeral — is still serving; a sweep at migrate time could not see the rows that code is about to mint. Once after the deploy is live, run:

DIRECT_URL=postgresql://… npm run db:backfill-ephemeral-api-keys -w @constellation/directory

It sweeps per tenant, inside each tenant's own transaction, emitting an auditCritical record for every tenant it actually changes — constitution §1 requires a cross-tenant operation to carry an explicit tenant_id and an audit entry, and ephemeral is a classification with security meaning. It calls the migration's own classifier (identity.backfill_ephemeral_api_keys), so the repair and the migration cannot drift; that function is withheld from the runtime role. It then verifies no candidates remain in any tenant and exits non-zero if any do, or if any tenant errored. It requires the privileged DIRECT_URL role and preflights for it, refusing a role that can neither bypass RLS nor act as superuser. Two independent reasons, neither of them a silent no-op: the runtime role does not hold EXECUTE on the classifier at all (the same withholding described above), so it would raise permission denied for function; and the final verification is deliberately tenant-agnostic, so under RLS it would see nothing and could report clean while candidates remained. What confines a privileged run to one tenant at a time is the explicit tenant_id predicate on each write, not RLS. The step is registered in the release runbook's post-deploy repair table, which is what a release runner actually follows.

Per-jti and per-id lookups are deliberately not filtered by ephemeral: hiding a row from a list must never change whether its token authenticates or can be revoked.

Source locations

See also