Every SaaS has one bug it fears more than the rest.
Customer A sees customer B’s data.
It rarely comes from a missing login.
- The caller is authenticated.
- The caller holds the right scope.
- They ask for a contact by ID, and the handler fetches it.
- The ID belonged to someone else.
Scopes cannot catch this. A scope says what a caller may do, such as read contacts. It says nothing about whose contacts.
That second question is the tenant boundary. Most codebases answer it in every handler, by hand, with a WHERE location_id = ? someone has to remember.
dedo answers it once, in the proto.
Tenancy is optional. A single-tenant service declares nothing and sees no change.
Two questions on every call
Authorization on a multi-tenant API asks two questions:
| Question | Example | Check | Declared in |
|---|---|---|---|
| May this caller perform this action? | Read contacts | Scope check | dedo.v1.method.scopes |
| May this caller act in this tenant? | Location 1 | Tenant check | dedo.v1.service.tenancy |
Both must say yes.
The model: the tenant is the root
dedo follows the resource hierarchy the AIPs already describe. The tenant sits at the root. Everything else lives under it:
locations/1
locations/1/contacts/2
locations/1/contacts/2/notes/3Code language: plaintext (plaintext)
Every resource name starts with its tenant’s. That one decision does most of the work:
- A request that names a contact names its tenant too.
- A request that lists contacts names a parent, and the parent is the tenant.
There is nothing to look up. The tenant is in the name.
Declaring it
The service declares its tenant collection once:
// ContactService keeps each location's contacts. Every location is a tenant:
// a caller reaches only the locations it belongs to.
service ContactService {
option (dedo.v1.service) = {
audience: AUDIENCE_EXTERNAL_PUBLIC
owner_team: "contacts-team"
tenancy: {collection: "locations"}
};
rpc GetContact(GetContactRequest) returns (GetContactResponse) {
option idempotency_level = NO_SIDE_EFFECTS;
option (google.api.http) = {
get: "/commerce/contacts/v1/{name=locations/*/contacts/*}"
response_body: "contact"
};
option (dedo.v1.method) = {
scopes: "contacts.contact.read"
cache: {visibility: VISIBILITY_PRIVATE, max_age: {seconds: 30}}
errors: {reason: "CONTACT_NOT_FOUND", code: NOT_FOUND}
// tool: { ... }
};
}
}Code language: Protocol Buffers (protobuf)
Look at the HTTP path.
{name=locations/*/contacts/*}binds the request’snamefield.- Its template starts with the tenant collection.
That binding tells dedo where each RPC carries its tenant.
The resources follow the same shape:
message Contact {
option (google.api.resource) = {
type: "contacts.raveesh.me/Contact"
pattern: "locations/{location}/contacts/{contact}"
};
// ...
}Code language: Protocol Buffers (protobuf)
When an RPC spans tenants
Some calls are about no single tenant, such as listing the locations I belong to.
Mark those cross_tenant:
// ListLocations returns the locations the caller belongs to. It spans
// tenants, so it is cross_tenant: the scope guards it, and the server
// lists only the caller's own locations.
rpc ListLocations(ListLocationsRequest) returns (ListLocationsResponse) {
option idempotency_level = NO_SIDE_EFFECTS;
option (google.api.http) = {get: "/commerce/contacts/v1/locations"};
option (dedo.v1.method) = {
scopes: "contacts.location.read"
cross_tenant: true
errors: {reason: "INVALID_PAGE_TOKEN", code: INVALID_ARGUMENT}
};
}Code language: Protocol Buffers (protobuf)
A cross_tenant RPC skips the tenant check. Its scope alone guards it. Its handler filters the results.
These should be few. Each one is visible in the contract, so a reviewer can count them.
The lint
Turn on the opt-in category:
lint:
use:
- DEDO
- ZALANDO
- AIP_NOLANG
- DEDO_MULTI_TENANTCode language: YAML (yaml)
It lives outside DEDO on purpose, so a single-tenant service never sees these rules. dedo init adds the category when a service declares tenancy.
It holds four rules:
| Rule | Checks |
|---|---|
DEDO_MULTI_TENANT_SERVICE_TENANCY | Every service declares its tenant collection |
DEDO_MULTI_TENANT_RESOURCE_PATTERN | Every resource pattern starts with the tenant |
DEDO_MULTI_TENANT_REQUEST_TENANT | Every request names its tenant in a field its HTTP path binds |
DEDO_MULTI_TENANT_CACHE_PRIVATE | No tenant data is cached publicly |
Two of them deserve a closer look:
REQUEST_TENANTis the runtime’s own check. It callspolicy.MethodTenant, the same code the server runs at startup. A contract that lints clean is a contract that starts. It also flagscross_tenanton an RPC whose path names a tenant, which the runtime would then skip.CACHE_PRIVATEcloses a door you might not see.Cache-Control: publiclets a CDN serve a stored response to the next caller of the same URL without asking your server. The tenant check never runs.
In a multi-tenant service, every cache policy is private.
The runtime
Give the interceptor one more function:
guard, err := interceptor.New(interceptor.Config{
Table: table,
Scopes: scopesFromContext,
EnforceScopes: true,
Tenants: tenantAllowed, // may this caller act in this tenant?
Log: log,
})Code language: Go (go)
Tenants has a small signature:
// TenantAccess reports whether the caller may act in a tenant, such as "1"
// for a request naming locations/1/todos/2.
type TenantAccess func(ctx context.Context, tenant string) boolCode language: Go (go)
Your auth interceptor already puts the caller’s scopes in the context. Put their tenants there too:
func tenantAllowed(ctx context.Context, tenant string) bool {
tenants, _ := ctx.Value(tenantsKey{}).([]string)
return slices.Contains(tenants, tenant)
}Code language: Go (go)
dedo init writes this with a development token map when your service declares tenancy. Swap the map for your identity platform.
What the interceptor does
On every call, on every transport, before validation and before your handler:
- It reads the tenant from the field the path binds, which must be set.
- It reads the tenant of every other field that carries a
google.api.resource_referenceand holds a name under the tenant collection, nested messages, lists and map values included. - It asks
Tenantsabout each one. Any no ends the call withPERMISSION_DENIED.

Step 2 matters more than it looks. Picture a move:
name: locations/1/contacts/2
destination: locations/2Code language: plaintext (plaintext)
The caller belongs to location 1. A check on the path alone would pass, and the contact would land in a tenant the caller cannot see.
dedo checks both names, so the move needs access to both tenants.
Give every field that holds a resource name a
resource_reference, as AIP-122 already asks. A field without one goes unchecked.
The guarantees
| Guarantee | What it means |
|---|---|
| Same answer, every time | A denied call gets PERMISSION_DENIED whether or not the resource exists (AIP-211), so a caller cannot probe another tenant’s IDs. |
| Strict names | An empty segment, . or .., a space or a control character gets INVALID_ARGUMENT. locations/1/../2 never reaches code that could clean it into locations/2. |
| A denied request is empty | The interceptor resets the request message, so a handler that ignores the error holds nothing of another tenant’s. |
| Every transport | The check wraps the stream’s Receive, for Connect, gRPC, REST and MCP, and for every message of a stream. An agent meets the same check as a REST client. |
| Fail closed | policy.Build refuses to start when a tenant-scoped RPC names no tenant field; interceptor.New refuses when Tenants is missing. |
No audit mode
Scopes roll out with EnforceScopes: false first, logging what would be denied.
Tenancy has no such switch.
- A scope rollout risks locking out a caller who should get in.
- A tenant rollout in audit mode would let a caller into data that belongs to someone else. That is the incident the feature exists to stop.
The tenant boundary is enforced from the first deploy.
Every denial is logged with the procedure, the tenant and the flow ID, so you can find false positives quickly.
Reading across tenants
AIP-159 lets a list read across parents with a wildcard:
locations/-/contactsCode language: plaintext (plaintext)
dedo passes - to your Tenants function like any other ID.
Grant it only to callers who may see every tenant, such as support staff or internal reporting. Let the handler filter what it returns.
What stays in code
dedo now owns two checks:
- May this caller do this?
- May this caller act in this tenant?
One question remains in your handlers. Inside a tenant, may this caller touch this specific resource? Think of only their own drafts, or only the contacts assigned to them.
That is a relationship question, and it belongs to your domain.
Tenants is a plain function. If tenant membership lives in a relationship store such as SpiceDB or OpenFGA, that function can ask it.
Putting it together
A multi-tenant dedo service needs four things:
- The tenant at the root of every resource:
locations/{location}/.... tenancy: {collection: "locations"}on the service, andcross_tenant: trueon the few RPCs that span tenants.DEDO_MULTI_TENANTinlint.use.- A
Tenantsfunction ininterceptor.Config.
From those, every request on every transport is checked against its tenant before your code runs.
examples/contacts in the dedo repository is a complete multi-tenant contract. CI lints it with every rule a service gets. It is the proof that the tenancy rules agree with the AIP, DEDO and ZALANDO rules.
dedo is open source under Apache 2.0 at github.com/raveesh-me/dedo.
The dedo series
- Part 1: Define Once, Deploy Everywhere: why one proto can carry the contract and its rules.
- Part 2: Getting Started with Todo Good and Todo Bad: two services, one interface, and what each lint rule protects you from.
- Part 3: Adding dedo to an Existing Service: one layer at a time, with an audit mode before you enforce.
- Part 4: House Rules, Your Conventions as Lint: your organisation’s conventions as CEL checks, shared from one repository.
- Part 5: Multi-Tenant SaaS Auth (this post): the tenant boundary, declared in the proto and enforced on every transport.

Leave a Reply