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 isolated
project=acme → workspace:team1#member@user:alice
project=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 sendsProject used
project_id: "acme"acme
project_id empty / omittedGATEWAY_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