Skip to main content

Multi-tenancy

Buddora is multi-tenant (clinics, shelters, boarding chains, grooming companies, training orgs, sellers). Individual owners are first-class users who may act with or without a tenant.

Isolation layers​

  1. Authenticated tenant context — established from the verified token / membership, never trusted from a client field.
  2. Application checks — services scope every operation to the actor's context.
  3. SQL constraints — tenant_id on tenant-scoped tables (65 tables, migration 0071).
  4. Row-Level Security — live, not planned: 73 tables carry ENABLE plus FORCE ROW LEVEL SECURITY across five policy shapes (0072, 0073, 0076), and the application connects as buddora_runtime, a NOSUPERUSER NOBYPASSRLS role that owns nothing, so the policies genuinely apply to it. ADR-018's ScopedPool makes an untransacted read carry its caller's scope at 102 wiring sites and fail loudly (ErrUnscopedRead) where it would previously have returned empty. See ADR-013, ADR-017, ADR-018, ADR-026.
  5. OpenFGA relationships — organization membership grants scoped access.
  6. Automated isolation tests — cross-object/cross-tenant denial is tested.

tenant_id is never read directly from a request for authorization.

Status (2026-08-28)​

CreateAnimal grants the creating actor's tenant organization-wide read access via a managing_org relation (OpenFGA's tupleToUserset pattern: "member from managing_org"). Both the in-memory stub authorizer and real OpenFGA resolve this identically.

The Organizations & Membership domain is implemented (internal/modules/organizations): create an organization (creator becomes admin), invite a member by email, accept an invitation (grants the OpenFGA member/admin relation), list members, remove a member (an admin-only action that protects the organization's last admin from being removed). Live-verified end to end through the real HTTP server: created an org, invited and admitted a second member, then created an animal under that tenant — the fellow member (who never touched the animal) could see it, a stranger could not.

Row-level security coverage is the open item, not its existence. The integration suite exercises 33 of the 73 policied tables, so 40 carry a policy that nothing has yet read through under the runtime role. ROADMAP.md's EPIC-52 row tracks this.

Also not yet built: tenant switching in the web/mobile UI, invitation email delivery (invitations currently return their token in the API response), and organization suspension/archival exposed via API.

Two roles beyond the runtime one exist for writes the runtime role must not be able to make — buddora_authoring for platform-reference authoring (ADR-019) and buddora_sweeper for scheduled reconciliation (ADR-025) — and neither PLATFORM_AUTHORING_DATABASE_URL nor RECONCILE_SWEEPER_DATABASE_URL is set in production today. Both fail loudly rather than falling back: the API refuses the authoring endpoints by name, and cmd/reconcile fails at start-up. See docs/architecture/DEPLOYMENT.md §6.