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 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; read-only for every other module. Cross-module reads of identity.users / identity.organisations use raw SQL (no Prisma cross-schema joins) per module-isolation.

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".
user_tenant_membershipsActive-tenant resolution. withTenantAuth validates membership before any tenant-scoped call.
roles, permissionsRBAC. role_permissions and user_roles are the join tables.
organisation_certificationsCompliance state with expiry → drives the credential.expired event.
organisation_competence_domainsCapability tags for organisation matching (used by Catalog supplier offer matching).

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.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.

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.

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 is revoked in DIR-79).
  • Dependency on INF-60. All of the above only enforces isolation once the app connects as a non-bypass role. While production connects as postgres (rolbypassrls = true) via Supavisor, identity RLS is inert and these functions are a no-op for isolation — INF-60 switches DATABASE_URL to constellation_app.

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 origin against MCP_REDIRECT_ORIGIN_ALLOWLIST before any session 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.

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://constellation-pt-mcp.vercel.app).
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.

See also