Skip to content

ADR 008: Slug-Based Multitenancy on Partner Entity

Date: 2025-12-12 Status: Accepted (currently single-tenant in production)

Note: Retrospective ADR written 2026-04-26. The columns landed during partner-service hardening (6fea978, 2025-12-12) and have remained the multitenancy primitive since. The decision was made implicitly; this ADR captures the rationale ex-post.

Context

Portugal Odyssey is operated by a single client company (Cristina Meireles) but the architecture has, from day one, anticipated the platform being licensed or sold as a white-label to additional tourism organizations. The pragmatic question at MVP time: do we need tenant isolation now, and if so, at what level?

Three options were considered:

  1. Database-per-tenant. Maximum isolation; one Postgres database per tenant. Painful to operate at the platform's single-VPS scale and overkill for a foreseeable future of 1–5 tenants.
  2. Schema-per-tenant. One public_* schema per tenant inside a shared Postgres. Less operational cost than separate databases, but every service must construct schema names dynamically, and migrations multiply per-tenant.
  3. Row-level scoping via a tenant column. Every tenant-scoped row carries a tenant_slug (or tenant_id). Queries filter by it; isolation is enforced at the application layer (and optionally Postgres RLS later).

The platform is not yet multi-tenant in production — partners.tenant_slug is populated for every partner but maps to a single platform tenant. The decision was to make the schema multi-tenant-shaped without paying the operational cost of true isolation until a second tenant materializes.

Decision

Adopt row-level multitenancy with two parallel keys on the Partner entity:

Column Type Source Audience
tenant_slug TEXT (UNIQUE WHERE NOT NULL) Human-set, lower-cased / kebabish URLs, logs, glossary, human discovery
iam_tenant_id UUID FK-shaped reference into iam.tenants(id) Tokens, joins, foreign-key integrity

tenant_slug is the canonical handle referenced across documentation, the glossary, and the project overview. iam_tenant_id is the stable identifier the IAM schema uses internally (iam.tenants(id), iam.user_tenants, iam.user_roles.tenant_id).

Why both?

The slug is human-readable and stable in URLs (https://partner.<tenant>.portugalodyssey.pt is on the future roadmap). The UUID is the right primary-key shape for IAM joins and JWT claims. Keeping both means:

  • a tenant can rename its slug without breaking IAM joins,
  • IAM tokens carry the UUID (tenant_id claim → x-tenant-id header in services/auth-service/src/shared/express-jwt.ts),
  • non-IAM tables index on tenant_slug for human-friendly query plans.

JWT propagation

Auth-service's expressJwt middleware verifies the Keycloak-issued JWT, extracts tenant_id (UUID) and tenant_ids[] (the multi-tenant case), and forwards them as x-tenant-id and x-roles headers to downstream services via the api-gateway. The TenantGuard (services/auth-service/src/shared/nest/tenant.guard.ts) checks the requested x-tenant-id against the user's allowed list.

Why slug-based and not UUID-only

Pure-UUID multitenancy works, but everywhere a human reads or types a tenant identifier (URL paths, log lines, support tickets, the glossary, partner-onboarding scripts) UUIDs are friction. Slugs cost one extra column and a UNIQUE constraint to keep them disambiguated. The trade was judged worth it.

Backfill

A backfill in partner-service's database.service.ts derives tenant_slug from short_name for partners that pre-date the column:

UPDATE partners
SET tenant_slug = COALESCE(tenant_slug, short_name)
WHERE tenant_slug IS NULL AND short_name IS NOT NULL;

short_name was a pre-existing 5-char uppercase code; using it as the seed slug avoided generating placeholder values.

Consequences

Positive

  • One physical database, N logical tenants. Operational footprint stays tiny; no per-tenant migration multiplication.
  • Slug in URLs and logs. Future tenant onboarding can publish at partner.<slug>.portugalodyssey.pt without coordinating UUID-to-name mappings.
  • IAM is FK-correct. iam_tenant_id is the joinable identifier; renaming a slug doesn't cascade through token-issued claims.
  • JWT-driven scoping. Downstream services receive x-tenant-id from the gateway and don't need to re-resolve tenancy from the request body.
  • Cheap to defer real isolation. When a second tenant arrives, the codebase already filters by tenant_slug; tightening to RLS or schema-per-tenant is incremental, not a rewrite.

Negative

  • Two keys for one concept. New developers ask "is the tenant the slug or the UUID?" The answer is "both, depending on which boundary you're at." Glossary entry mitigates but doesn't eliminate the confusion.
  • No row-level enforcement yet. Application code is responsible for adding WHERE tenant_slug = $1 to every relevant query. A missing predicate leaks data across tenants. Postgres RLS would catch this; not adopted yet because the production tenant count is 1.
  • Slug uniqueness scope is global. The UNIQUE index on partners.tenant_slug makes tenant slugs globally unique — fine for white-label tenants but constrains naming if "tenant" semantics ever expand.
  • short_name backfill assumption. The slug-from-short_name backfill collides if two partners had the same short_name (the field is VARCHAR(5) and not historically unique). Pre-launch state means this hasn't bitten, but the backfill is one-shot and idempotent only because the production set is small.
  • Cross-service propagation discipline. Most non-partner services don't carry their own tenant_slug columns yet; they rely on Partner-derived joins and the gateway's x-tenant-id header. Adding RLS would require backfilling tenant columns on calendar/booking/contract/experience entities.

Open follow-ups

  • Decide whether to add tenant_slug columns to calendar / booking / experience entities for direct row-level filtering, or keep deriving via partner joins.
  • When (and if) Postgres RLS gets adopted, decide whether the policy keys on iam_tenant_id (UUID, stable) or tenant_slug (text, human-friendly).
  • The glossary entry describes the intent; an explanatory doc (Diataxis "explanation") on the multitenancy surface would help when a second tenant lands.

References

  • services/partner-service/src/common/database/database.service.tspartners.tenant_slug + iam_tenant_id columns + backfill
  • services/partner-service/src/modules/partners/entities/partner.entity.ts — TS shape
  • infrastructure/migrations/iam/00001_initial_schema.sqliam.tenants(id, slug) + iam.user_tenants
  • services/auth-service/src/shared/express-jwt.ts — JWT → x-tenant-id header propagation
  • services/auth-service/src/shared/nest/tenant.guard.ts — guard checking requested vs allowed tenants
  • docs/reference/glossary.md §"Tenant Slug" — terminology reference
  • docs/project-overview.md §"Partner" — domain model