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
- Authenticated tenant context — established from the verified token / membership, never trusted from a client field.
- Application checks — services scope every operation to the actor's context.
- SQL constraints —
tenant_idon tenant-scoped tables (65 tables, migration0071). - Row-Level Security — live, not planned: 73 tables carry
ENABLEplusFORCE ROW LEVEL SECURITYacross five policy shapes (0072,0073,0076), and the application connects asbuddora_runtime, aNOSUPERUSER NOBYPASSRLSrole that owns nothing, so the policies genuinely apply to it. ADR-018'sScopedPoolmakes 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. - OpenFGA relationships — organization membership grants scoped access.
- 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.