Last updated 2026-06-29
Projects & Multi-Tenancy
This page explains how the service isolates tenants. The
project_id is the isolation shard (identity
ADR-0002):
every workspace, group, invitation, and relation tuple is scoped to a
project, and data in different projects never sees each other.
Everything is project-scoped
project_id is not a field you check against — it is the boundary
the whole store is partitioned by. A Check in one project can
never match a tuple in another, even with identical object ids.
# Two projects, same object id — fully isolatedproject=acme → workspace:team1#member@user:aliceproject=globex → workspace:team1#member@user:bob
# Check(workspace, team1, member, alice) in project=globex → allowed:false
An empty project_id on a request means the
default project, configured by
GATEWAY_DEFAULT_PROJECT_ID (built-in fallback
"default"). The default project is a real project like any
other — it is just the one you land in when you send nothing.
How project_id flows on a request
project_id is an explicit, optional field on every RPC — it is
data, set by the calling product backend, not inferred from
a token (end-user auth happens at the product edge; see
Security & Service Auth).
Management RPCs carry it alongside acting_user_id; authz RPCs
carry it alongside the tuple fields.
| Request sends | Project used |
|---|---|
project_id: "acme" | acme |
project_id empty / omitted | GATEWAY_DEFAULT_PROJECT_ID |
| (env unset too) | default (built-in fallback) |
curl -X POST http://localhost:8080/workspace.v1.AuthzService/Check \ -H "Authorization: Bearer $WS_SERVICE_TOKEN" -H "Content-Type: application/json" \ -d '{"project_id":"acme","namespace":"workspace","object_id":"W","relation":"admin","subject_user_id":"bob"}'Two deployment shapes
B2C — one project, many personal workspaces
A consumer product (a personal-assistant app, a note-taker) runs as a
single project. Every end user gets their own auto-provisioned
personal workspace within it, and the few who collaborate create team
workspaces. You typically leave project_id empty everywhere and
let it resolve to the default project. Isolation between users comes from
workspace membership tuples, not from project sharding.
B2B — a project shard per customer
A B2B platform shards per customer: each customer
organization is its own project_id. One workspaces deployment
(and one Postgres) serves them all, but a tuple, workspace, or group created
for acme is invisible to globex. The product backend
sets project_id on every call from the authenticated tenant it
resolved at its own edge. This gives hard, row-level tenant isolation without
a database per customer.
Storage implications
project_id leads every index. The relation-tuple store is keyed
(project_id, namespace, object_id, relation), and the same
leading-key discipline applies to workspaces, groups, and invitations. Both
storage drivers behind service.Repository — the in-memory
reference store and the Postgres driver — enforce this identically, and a
conformance suite pins them to the same semantics. Because the shard is the
leading key, a Check is always scoped to a single project and
never scans across the boundary.
Where to next
- Security & Service Auth — who sets
project_idand how callers authenticate. - Configuration —
GATEWAY_DEFAULT_PROJECT_IDand the Postgres DSN. - Authorization Model — the tuple store the shard partitions.