·

[dedo] 5/5: Multi-Tenant SaaS Auth

Raveesh Agarwal

·

·

6–9 minutes
A caller of tenant 1 reaches locations/1; a request for locations/2 is stopped with PERMISSION_DENIED.

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:

QuestionExampleCheckDeclared in
May this caller perform this action?Read contactsScope checkdedo.v1.method.scopes
May this caller act in this tenant?Location 1Tenant checkdedo.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’s name field.
  • 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:

RuleChecks
DEDO_MULTI_TENANT_SERVICE_TENANCYEvery service declares its tenant collection
DEDO_MULTI_TENANT_RESOURCE_PATTERNEvery resource pattern starts with the tenant
DEDO_MULTI_TENANT_REQUEST_TENANTEvery request names its tenant in a field its HTTP path binds
DEDO_MULTI_TENANT_CACHE_PRIVATENo tenant data is cached publicly

Two of them deserve a closer look:

  • REQUEST_TENANT is the runtime’s own check. It calls policy.MethodTenant, the same code the server runs at startup. A contract that lints clean is a contract that starts. It also flags cross_tenant on an RPC whose path names a tenant, which the runtime would then skip.
  • CACHE_PRIVATE closes a door you might not see. Cache-Control: public lets 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:

  1. It reads the tenant from the field the path binds, which must be set.
  2. It reads the tenant of every other field that carries a google.api.resource_reference and holds a name under the tenant collection, nested messages, lists and map values included.
  3. It asks Tenants about each one. Any no ends the call with PERMISSION_DENIED.
The tenant check: scope check, read every tenant a request names, ask Tenants(ctx, id) for each; any no returns PERMISSION_DENIED, a malformed name returns INVALID_ARGUMENT, all yes continues to validation and the handler.

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

GuaranteeWhat it means
Same answer, every timeA denied call gets PERMISSION_DENIED whether or not the resource exists (AIP-211), so a caller cannot probe another tenant’s IDs.
Strict namesAn 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 emptyThe interceptor resets the request message, so a handler that ignores the error holds nothing of another tenant’s.
Every transportThe 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 closedpolicy.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:

  1. The tenant at the root of every resource: locations/{location}/....
  2. tenancy: {collection: "locations"} on the service, and cross_tenant: true on the few RPCs that span tenants.
  3. DEDO_MULTI_TENANT in lint.use.
  4. A Tenants function in interceptor.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

Raveesh Agarwal is a full stack builder writing about Kubernetes, agentic AI and distributed systems. He runs a four-node Kubernetes cluster on his desk. More about me · Follow via RSS

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *