This post is part of a series on the full Ory auth stack: Kratos handles identity, Hydra handles OAuth2/OIDC delegation, and Keto — the subject of this post — handles fine-grained authorization. The three are deliberately separate: Keto knows nothing about credentials or tokens; it receives a subject ID, an object, a relation, and answers yes or no.

The Problem with Classic RBAC

Most authorization starts with a role column on the users table:

-- The typical starting point
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'viewer';
-- check: WHERE user_id = $1 AND role = 'admin'

This is enough until one role per user isn’t enough — at which point you reach for a junction table. A user_roles table, a query, a JWT claim: "roles": ["admin", "editor"]. Middleware unwraps the token and compares. Fast, simple, correct — for the right problem.

The problems start as the product scales. Either you need permissions scoped to individual resources — not just “is this user an admin?” but “can this user edit this document?” — or new resource types accumulate (reports, transactions, billing accounts, project workspaces) until roles start multiplying out of control. Usually both.

Problem 1: Resource-level permissions require a different schema.

If a document can be owned by one user, edited by another, and viewed by a group, a roles table cannot express this. You need a permissions table scoped to each document:

CREATE TABLE document_permissions (
  document_id UUID,
  subject_id  TEXT,  -- user or group
  role        TEXT,  -- viewer | editor | owner
  PRIMARY KEY (document_id, subject_id, role)
);

This works until you need groups. Now subject_id might be a group, so checking whether a user can read a document requires resolving group membership first. The single-line check becomes a join. Add folder inheritance — a document inside a folder should inherit the folder’s viewer list — and the join becomes a recursive CTE. Each new sharing rule is a schema change or a query rewrite.

Problem 2: “Who has access to this resource?” is hard to answer.

With role tables, the question goes in one direction: “does user X have role Y?” Answering the reverse — “list every user who can read document Z” — requires traversing every path that might grant access: direct assignments, group memberships, inherited permissions. There is no single query. Auditing becomes a custom report written once and never maintained.

Problem 3: Roles proliferate.

A product starts with two roles: admin and user. Then comes billing_admin, then project_admin, then read_only_admin, then support_agent, then support_agent_tier2. Each new role encodes both who you are and what you can see, mixing identity with access policy. As the product grows, these two concerns diverge: the same person might need admin access on billing but read-only access on user management.

The result is a combinatorial explosion. Roles accumulate. Nobody can audit them — “admin” means different things to different teams and different code paths. The only fix is more roles, or bespoke if role == 'billing_admin' || role == 'project_admin' checks scattered across handlers.

The pattern that emerges is that you are building a graph database out of SQL tables. The Zanzibar model is the principled version of that pattern — a purpose-built primitive for storing and querying exactly these relationships.

In 2019, Google published a paper describing how they had been solving this problem since 2016 across Drive, Gmail, and YouTube. The system is called Zanzibar — it remains Google-internal, but the paper describes the model in enough detail that others have built implementations of it.


Google Zanzibar: The Mental Model

Zanzibar is Google’s internal authorization service — described in a 2019 research paper but never open-sourced. The paper describes a system that stores relationships between entities and answers “does this subject have this permission on this object?” consistently, at scale. The design is built on one primitive.

Relation Tuples

Every fact in Zanzibar is a relation tuple:

object#relation@subject
  • object — the resource (doc:readme, folder:engineering, group:backend-team)
  • relation — the named relationship (owner, editor, viewer, member)
  • subject — a user ID, or another object-relation pair

Examples:

doc:readme#owner@alice
doc:readme#viewer@bob
group:engineering#member@carol
folder:docs#viewer@group:engineering#member

The last tuple is the key concept: the subject is group:engineering#member — meaning “all members of the engineering group.” This is a subject set: a reference to an entire set of subjects through another tuple. Instead of copying every group member into every document’s viewer list, you write one tuple and Keto resolves the membership at check time.

The authorization check is graph traversal. check(doc:readme, viewer, alice) walks the tuple graph — expanding subject sets, following inheritance chains — and returns true if any path leads to alice. Any path is sufficient.

Namespaces (Document, Folder, Group) categorize objects and constrain which relations are valid. Userset rewrites inside each namespace express inheritance: viewer might be the union of direct viewers, editors, and owners. The three core APIs: Check (yes/no on a single permission), Read (list tuples matching a filter), and Expand (return the full userset tree for auditing).

At production scale Zanzibar handles 12.4 million checks per second at p50 latency of 3ms, across trillions of stored relationships. Several open-source implementations of the paper’s model exist; Ory Keto is one of them.


Ory Keto: Concepts

Keto is Ory’s implementation of the Zanzibar model. It stores relation tuples, evaluates check requests against your permission model, and exposes both a REST API and a gRPC API.

The Ory Permission Language

Keto’s permission model is defined in OPL — the Ory Permission Language. OPL is TypeScript syntax used as a configuration language. You define namespace classes, declare relations between them, and write permission functions that combine relations into access decisions.

A minimal example:

import { Namespace, SubjectSet, Context } from "@ory/permission-namespace-types"

class User implements Namespace {}

class Document implements Namespace {
  related: {
    owner: User[]
    editor: (User | SubjectSet<Group, "member">)[]
    viewer: (User | SubjectSet<Group, "member">)[]
  }

  permits = {
    read: (ctx: Context): boolean =>
      this.related.owner.includes(ctx.subject) ||
      this.related.editor.includes(ctx.subject) ||
      this.related.viewer.includes(ctx.subject),

    write: (ctx: Context): boolean =>
      this.related.owner.includes(ctx.subject) ||
      this.related.editor.includes(ctx.subject),

    delete: (ctx: Context): boolean =>
      this.related.owner.includes(ctx.subject),
  }
}

class Group implements Namespace {
  related: {
    member: User[]
  }
}

Yes, related and permits are OPL’s two reserved properties — the only ones Keto recognizes. Everything else is ignored. Keto does not run this TypeScript as code; it parses it as configuration.

related — the relationship schema

related declares which named relationships this namespace participates in. Each key (owner, editor, viewer) becomes a valid relation name that can appear in a relation tuple stored in Keto. The TypeScript type annotation is not just documentation — it constrains what kinds of subjects can hold that relation:

  • owner: User[] — only a User can be an owner
  • editor: (User | SubjectSet<Group, "member">)[] — an editor can be a User directly, or a SubjectSet<Group, "member">, which means “all members of some Group”

SubjectSet<Group, "member"> is how you express group-based permissions without copying every group member into the document’s viewer list. When Keto evaluates a check, it resolves the subject set by looking up who holds the member relation on that Group at that moment — so group membership changes take effect immediately.

related is the data layer: the only things you ever write to Keto’s database are related entries — tuples like doc:readme#owner@alice or doc:readme#viewer@group:engineering#member.

permits — the access rules

permits defines computed access decisions. These are never stored — they are evaluated on demand every time you call the Check API. Each function receives ctx.subject (the subject ID from the check request) and returns a boolean.

this.related.owner.includes(ctx.subject) is OPL’s way of asking: “is ctx.subject in the set of subjects holding the owner relation on this object?” Keto evaluates this by traversing the stored relation tuples, expanding any subject sets it finds.

The access rules compose naturally:

read:   owner || editor || viewer   // any of the three can read
write:  owner || editor             // viewers cannot write
delete: owner                       // only owners can delete

You never write a read permission into the database. You write an owner relationship, and the read function handles the implication. Change the rule — say, add contributor to read — and the change takes effect for all existing data without a migration.

This separation of stored relationships (data) from computed permissions (policy) is what makes the model powerful. Authorization logic lives in OPL under version control, not scattered across application code or baked into database rows.


keto-rebac-demo: Fine-Grained API Authorization

To make this concrete, here is a working demo that ships with a task runner, a local Keto instance, and a small Go HTTP server.

Source: github.com/martavoi/keto-rebac-demo

The Permission Model

The demo models route-level API access — a common real-world need where different users hold different permissions on different API endpoints.

Two namespaces, defined in keto/namespaces.ts:

import { Namespace, SubjectSet, Context } from "@ory/keto-namespace-types"

class User implements Namespace {}

class Group implements Namespace {
  related: {
    members: User[]
  }
}

class Route implements Namespace {
  related: {
    viewer:  (User | SubjectSet<Group, "members">)[]
    editor:  (User | SubjectSet<Group, "members">)[]
    manager: (User | SubjectSet<Group, "members">)[]
  }

  permits = {
    read: (ctx: Context): boolean =>
      this.related.viewer.includes(ctx.subject) || this.permits.write(ctx),

    write: (ctx: Context): boolean =>
      this.related.editor.includes(ctx.subject) || this.permits.manage(ctx),

    manage: (ctx: Context): boolean =>
      this.related.manager.includes(ctx.subject),
  }
}

permits are computed — only viewer, editor, and manager relation tuples are ever written to the database. The cascading manager ⊃ write ⊃ read is defined once in OPL, not stored as redundant tuples.

Seeding Relations

task seed writes the initial relation tuples:

→ Group:admins#members@User:alice

→ Group:editors#members@User:bob

→ Route:reports#viewer@User:carol  (direct ACL)

→ Route:reports#editor@Group:editors#members  (SubjectSet)

→ Route:reports#manager@Group:admins#members  (SubjectSet)

→ Route:admin-panel#manager@Group:admins#members  (SubjectSet)

→ Route:billing#manager@Group:admins#members  (SubjectSet)

✓ Seed complete.

Three patterns are visible:

  • Direct ACLRoute:reports#viewer@User:carol binds carol directly to reports, no group involved.
  • Subject setRoute:reports#editor@Group:editors#members binds the entire editors group as editors of reports. Keto resolves this at check time.
  • Manager subject set — alice’s admin access flows through Group:admins#members to manager on every sensitive route.

Running the Demo

task demo:all exercises all permission boundaries with real HTTP calls:

╔══════════════════════════════════════════════════════════════╗
║        keto-playground  ·  full demo sequence                ║
╚══════════════════════════════════════════════════════════════╝

GET /api/reports  X-User-ID: alice
{"reports":[{"id":"1","title":"Q1 Revenue"},{"id":"2","title":"Q2 Forecast"}]}
→ HTTP 200

POST /api/reports  X-User-ID: alice
{"id":"3","title":"New Report"}
→ HTTP 201

GET /api/reports  X-User-ID: bob
{"reports":[{"id":"1","title":"Q1 Revenue"},{"id":"2","title":"Q2 Forecast"}]}
→ HTTP 200

POST /api/reports  X-User-ID: bob
{"id":"3","title":"New Report"}
→ HTTP 201

GET /api/admin/users  X-User-ID: bob
{"error":"forbidden"}
→ HTTP 403

GET /api/reports  X-User-ID: carol
{"reports":[{"id":"1","title":"Q1 Revenue"},{"id":"2","title":"Q2 Forecast"}]}
→ HTTP 200

POST /api/reports  X-User-ID: carol
{"error":"forbidden"}
→ HTTP 403

GET /api/admin/users  X-User-ID: carol
{"error":"forbidden"}
→ HTTP 403

GET /api/admin/users  X-User-ID: alice
{"users":[{"id":"User:alice","role":"admin"},{"id":"User:bob","role":"editor"},{"id":"User:carol","role":"viewer"}]}
→ HTTP 200

GET /api/reports  X-User-ID: dave  (no relations seeded)
{"error":"forbidden"}
→ HTTP 403

── Dynamic grant ──────────────────────────────────────────────
PUT /admin/relation-tuples  Group:editors#members@User:dave
✓ dave is now in Group:editors

GET /api/reports  X-User-ID: dave  (after adding to editors)
{"reports":[{"id":"1","title":"Q1 Revenue"},{"id":"2","title":"Q2 Forecast"}]}
→ HTTP 200

✓ Demo complete.

Walking through the scenarios:

  • alice — admin via Group:admins. Gets manager rights on reports and admin-panel. Full read/write on reports, access to /api/admin/users.
  • bob — editor via Group:editors. Read and write on reports, but forbidden on admin routes.
  • carol — direct ACL viewer on reports only. Read allowed, write and admin both return 403.
  • dave — starts with no relations. Reports returns 403. After PUT /admin/relation-tuples adds him to Group:editors, reports returns 200 immediately.

The last scenario is the key one: dynamic permission grants take effect instantly. No restart, no cache invalidation, no migration. Write the tuple; the next check sees it.

The Guard Middleware

The core pattern in the demo is a single Guard(route, permit, ketoClient) middleware that wraps any HTTP handler:

func Guard(route, permit string, keto *ory.APIClient) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            userID := r.Header.Get("X-User-ID")
            result, _, err := keto.PermissionAPI.CheckPermission(r.Context()).
                Namespace("Route").Object(route).Relation(permit).
                SubjectId("User:" + userID).Execute()
            if err != nil || !result.GetAllowed() {
                http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

Routes are registered with explicit permission requirements:

mux.Handle("GET /api/reports",     middleware.Guard("reports",     "read",   keto)(handler))
mux.Handle("POST /api/reports",    middleware.Guard("reports",     "write",  keto)(handler))
mux.Handle("GET /api/admin/users", middleware.Guard("admin-panel", "manage", keto)(handler))

The application code knows nothing about permissions. The middleware extracts X-User-ID (a stand-in for a JWT sub claim your auth layer would validate), calls Keto’s Check API, and either passes through or returns 403.


Integration Patterns

Policy as Code

OPL namespace definitions belong in your source repository. Treat them like migrations: version-controlled, reviewed, deployed via CI/CD. A change that grants the viewer relation on a new namespace should go through the same review process as any other access control change.

Keto’s admin API accepts namespace configuration at startup or via dynamic reload, which means your deployment pipeline can apply namespace changes alongside application code changes.

Revoking Access

Revocation is a delete on the Write API — remove the tuple. The change takes effect on the next check call immediately; no restart or migration needed.

Further reading: Multi-tenancy patterns (tenant-scoped object IDs, group-based tenant isolation) and RBAC migration strategies (mapping existing roles to Keto subject sets) are covered in the Ory Keto documentation.


Trade-offs and When Not to Use This

The Complexity Cost

Keto adds a network hop to every authorization check. For a small monolith where authorization is simple, this is overhead without benefit. RBAC with a well-structured roles table is easier to operate, easier to reason about, and plenty fast enough for most applications.

A fine-grained authorization system makes sense when:

  • Your permission rules involve chains of relationships (user → group → folder → document)
  • You need to answer “who has access to this resource?” not just “does this user have this role?”
  • You have multiple resource types with different ownership and sharing models
  • Access patterns change at runtime (sharing, revoking, delegating)

If your permission model is “admins can do everything, users can edit their own data,” you do not need Zanzibar. A middleware check against a role claim in a JWT is the right tool.


The Complete Auth Story

This post is the third part of a series covering the full Ory authorization stack:

  • Ory Hydra — OAuth2.1 and OIDC token issuance. Handles delegation: “grant this client access to act on behalf of this user.”
  • Ory Kratos — Identity and user management. Handles authentication: “who is this user?”
  • Ory Keto (this post) — Fine-grained authorization. Handles the access decision: “is this user allowed to perform this action on this resource?”

Each component has a clear boundary. Kratos or Hydra resolve the identity; Keto makes the authorization decision. The three together cover authentication, delegation, and authorization without overlap.


Further Reading


Questions or feedback? Reach out: