Externalized authorization
Entra ID (and this emulator) authenticates — it proves who is calling and that the token is genuine. It deliberately does not answer may this principal do this action on this object? That fine-grained, data-dependent question belongs to a separate Policy Decision Point (PDP) — OpenFGA, Casbin, OPA, Cerbos, SpiceDB, Ory Keto, and the like.
The emulator lets you build and test that split offline: it issues standards-compliant RS256 tokens with a real JWKS, so a resource API can validate identity exactly as it would against cloud Entra, then delegate the decision to a PDP — with no cloud tenant and no emulator-specific coupling.
flowchart LR
Client["client"]
subgraph api["resource API"]
direction TB
A["1. validate JWT via JWKS — authN (Entra's job)"]
B["2. ask the PDP for a decision — authZ (the PDP's job)"]
end
PDP["PDP"]
Client -->|"token"| api
api -->|"can user:X read doc:Y?"| PDP
The runnable reference lives in
samples/externalized-authz/.
Why separate authN from authZ
Section titled “Why separate authN from authZ”- Authentication — who is calling and is the token real? Solved by
validating the RS256 signature against the tenant JWKS,
and checking
iss/aud/exp. This is what an IdP is for. - Authorization — may this principal do this on this object? A
relationship- and data-dependent question that changes as your domain data
changes. Baking it into the IdP couples your policy to your login provider and
forces a token re-issue on every permission change. A dedicated PDP models it as
data — e.g. OpenFGA tuples like
user:alice reader doc:readme.
The token carries identity (oid) and coarse context (groups); the PDP
owns the policy. Neither side needs the other’s internals — which is exactly why
the resource API needs no emulator-specific features.
The four roles (PEP · PDP · PIP · PAP)
Section titled “The four roles (PEP · PDP · PIP · PAP)”The classic authorization vocabulary (from XACML) splits the work into four roles. The key thing to internalize: the emulator is none of them. It is the IdP — it authenticates and issues identity claims. All four roles live in your resource application and the PDP; the emulator only feeds them a trustworthy identity.
| Role | Answers | In this sample | Emulator’s part |
|---|---|---|---|
| PEP — Policy Enforcement Point | ”intercept the call, ask, then allow or block” | authz/server.go — the authorize() middleware guarding each route | — |
| PDP — Policy Decision Point | ”given the request + facts + policy: permit or deny?” | authz/pdp.go — the PDP port + InMemoryPDP; real OpenFGA/Casbin in compat/ | — |
| PIP — Policy Information Point | ”supply the attributes the decision needs” | authz/validator.go → Claims, mapped to user:<oid> + group:<gid> in server.go | issues oid / groups in the JWT — the emulator is the upstream attribute source |
| PAP — Policy Administration Point | ”where policy / relationships are authored & stored” | main.go pdp.Write(...) seed; the OpenFGA model + tuples in compat/ | — |
flowchart LR
Emu["emulator = IdP — authentication only, feeds the PIP"]
Client["client"]
subgraph app["your resource app"]
PEP["PEP · server.go"]
PIP["PIP · validator.go"]
PDP["PDP · pdp.go / OpenFGA"]
PAP["PAP · tuples / model"]
PEP -->|"asks"| PDP
PEP -->|"attributes"| PIP
PIP --> PDP
PAP -->|"policy"| PDP
end
Emu -->|"identity — JWT: oid, groups"| Client
Client -->|"token"| PEP
What the emulator provides
Section titled “What the emulator provides”Everything the resource server needs to authenticate, and nothing it needs to authorize (by design):
| Claim / endpoint | Role |
|---|---|
discovery/v2.0/keys (JWKS) | RS256 public keys, with rotation — the validator refreshes its cache on unknown kid |
iss / aud / exp | standard issuer / audience / expiry checks |
oid | stable object id → PDP subject (user:<oid>) |
groups | group membership → PDP usersets (group:<gid>#member) |
Mint the access tokens for tests with the admin token forge — no interactive sign-in required.
The sample
Section titled “The sample”samples/externalized-authz/
is a small Go resource API built around a single seam — the PDP port:
| Piece | Role |
|---|---|
authz/validator.go | JWKS-backed RS256 token validator (authN) |
authz/pdp.go | the PDP port + an in-memory OpenFGA-style tuple checker (InMemoryPDP) |
authz/server.go | protected API: authenticate, then PDP-check per route |
main.go | standalone, env-configured runner |
main_test.go | end-to-end test against the in-process emulator |
It runs with zero external services out of the box (the in-memory PDP), so
go test ./... covers the whole authN→authZ flow: direct-grant allow, no-grant
deny, group-derived allow, missing-token 401, wrong-audience 401.
# start the emulator, then:EMULATOR_JWKS_URL=http://localhost:8080/<tenant>/discovery/v2.0/keys \EMULATOR_ISSUER=http://localhost:8080/<tenant>/v2.0 \RESOURCE_AUDIENCE=api://docs-api \SEED_READER_OID=<alice-oid> \go run ./samples/externalized-authz
curl -H "Authorization: Bearer $TOKEN" http://localhost:9090/documents/readmeWorked example: trace one request
Section titled “Worked example: trace one request”Follow a single call through all four roles. The runner seeds one relationship (the PAP writing to the store):
// main.go — author the policy (PAP)pdp.Write("user:"+readerOID, "reader", "doc:readme") // alice may READ readmeThe routes declare what each needs (server.go): GET /documents/{id} requires
reader, POST requires writer.
Allowed read — alice’s token, GET:
curl -H "Authorization: Bearer $ALICE_TOKEN" http://localhost:9090/documents/readme# → 200 {"id":"readme","action":"read","subject":"<alice-oid>"}What happened, role by role:
- PEP (
authorize("reader", …)) intercepts the request and pulls the bearer token. - PIP (
validator.Validate) verifies the JWT against the emulator’s JWKS and extractsoid+groups— the emulator, as IdP, is the source of those facts. - The PEP shapes the question:
Subject: "user:<alice-oid>",Relation: "reader",Object: "doc:readme",Groups: ["group:<gid>", …]. - PDP (
PDP.Check) matches the seeded tuple → permit. - PEP lets the handler run →
200.
Denied write — same token, POST:
curl -X POST -H "Authorization: Bearer $ALICE_TOKEN" http://localhost:9090/documents/readme# → 403 {"error":"not permitted to writer doc:readme"}Same identity, but the relation is now writer and no writer tuple exists, so
the PDP denies and the PEP returns 403 — the handler never runs.
Changing this is a PAP operation (write a writer tuple), not a token
change — the whole point of externalizing authorization.
Two more paths the sample proves:
- Group-derived allow — seed
group:<gid>#member reader doc:handbook; any token whosegroupsclaim contains<gid>is permitted, because the PEP passes the token’s groups as usersets and the PDP matches on membership. - Rejected at the PEP — a missing token →
401; a token minted for a different audience →401(the PIP’saudcheck fails before the PDP is ever consulted).
Proven against real engines
Section titled “Proven against real engines”InMemoryPDP is a stand-in for teaching. To show the pattern isn’t tied to it,
compat/
is a PDP compatibility suite that runs one canonical decision matrix against
real authorization engines through the same PDP port:
| Engine | Model | Runs as |
|---|---|---|
| OpenFGA | ReBAC (Zanzibar) | container (testcontainers) |
| SpiceDB (Authzed) | ReBAC (Zanzibar) | container (testcontainers) |
| Ory Keto | ReBAC (Zanzibar) | container (testcontainers) |
| Permify | ReBAC (Zanzibar) | container (testcontainers) |
| Casbin | RBAC/ABAC | in-process (library) |
| OPA | Rego policy | in-process (library) |
| Cedar (cedar-go) | Cedar policy | in-process (library) |
It asserts a single invariant: given the same relationship facts and checks,
every adapter returns the same allow/deny matrix, and the full HTTP flow
(emulator token → JWKS validation → PDP → 200/403) behaves identically
regardless of which engine is wired in — which catches oid→subject and
groups→userset mapping bugs. Adding an engine is one PDPHarness; the
canonical matrix and assertions are reused. The container engines are reached
over their HTTP APIs (only OpenFGA uses a Go SDK — SpiceDB/Keto/Permify are
hand-rolled to keep the module’s go.mod lean), while OPA and Cedar embed
directly like Casbin.
All seven are CI-verified on every push by the pdp-compat matrix (one leg
per engine, fail-fast: false): the three policy engines run in-process with no
Docker, the four Zanzibar engines via testcontainers — so the compatibility
claim is checked, not just asserted.
Honest caveat. The canonical facts are ReBAC-shaped (subject / relation / object). For the Zanzibar engines (OpenFGA, SpiceDB, Keto, Permify) the translation is faithful and genuinely proves cross-engine equivalence. For the policy engines (Casbin, OPA, Cedar) we hand-author an equivalent model that yields the same decisions — so those legs prove “our adapter + our authored model reproduce the contract,” not that the engines are semantically identical.
Swapping in a real PDP
Section titled “Swapping in a real PDP”Because everything routes through the PDP port, the resource server code does
not change — you replace InMemoryPDP with an engine adapter:
// import openfga "github.com/openfga/go-sdk/client"type openFGAPDP struct{ c *openfga.OpenFgaClient }
func (p *openFGAPDP) Check(ctx context.Context, req CheckRequest) (bool, error) { res, err := p.c.Check(ctx).Body(openfga.ClientCheckRequest{ User: req.Subject, Relation: req.Relation, Object: req.Object, }).Execute() if err != nil { return false, err } return res.GetAllowed(), nil}Equivalent OpenFGA authorization model (DSL):
model schema 1.1type usertype group relations define member: [user]type doc relations define reader: [user, group#member] define writer: [user, group#member]Authorizing an MCP server
Section titled “Authorizing an MCP server”An MCP server is just another resource server — so the exact same split applies, and the emulator plugs straight in. The only new idea is where the roles sit, because an MCP server lives at a trust boundary: an agent calls it, and it calls downstream systems.
MCP authorization has two layers, and the emulator + authz package already
cover both:
- Connection layer (OAuth 2.1). The MCP server is an OAuth Resource
Server; the emulator is its Authorization Server, minting
audience-bound access tokens (
aud= the MCP server) that carryoid/groups. The server validates them with the sameauthz.TokenValidator— that’s the PIP, unchanged. - Tool layer (fine-grained). OAuth scopes can’t express “may this agent
invoke this tool with these args right now?” — so the server’s
tools/callhandler is the PEP, and it delegates to the PDP through the samePDPport used above.
The mapping onto MCP primitives:
| MCP concept | Maps to | Role |
|---|---|---|
tools/call handler | the guard around tool dispatch | PEP |
| the tool name + its arguments | Relation + Object (e.g. invoke / tool:send_email) | the PDP question |
oid / groups from the agent’s token | Subject + usersets | PIP (emulator-issued) |
| Cerbos / OpenFGA / OPA policy | the decision | PDP |
Concretely, the PEP is the server.go middleware pattern applied to a tool call
— no new machinery, just a different object:
// PEP: gate an MCP tools/call. Reuses authz.TokenValidator (PIP) + authz.PDP.func (s *MCPServer) callTool(ctx context.Context, bearer, tool string, args map[string]any) error { claims, err := s.Validator.Validate(bearer) // Layer 1: authN (PIP) if err != nil || claims.OID == "" { return errUnauthorized } groups := make([]string, len(claims.Groups)) for i, g := range claims.Groups { groups[i] = "group:" + g } allowed, err := s.PDP.Check(ctx, authz.CheckRequest{ // Layer 2: authZ (PDP) Subject: "user:" + claims.OID, Relation: "invoke", Object: "tool:" + tool, // args refine the object Groups: groups, }) if err != nil || !allowed { return errForbidden // PEP denies → no dispatch } return s.dispatch(ctx, tool, args)}Two behaviours are specific to MCP and worth calling out:
- Filter
tools/list, not justtools/call. Run the same PDP check at connection time to decide which tools to even advertise — the PEP shapes the visible capability surface per identity, not only the yes/no on execution. - Re-authorize downstream (the confused-deputy guard). The MCP server is also a client to the systems behind it, so it enforces twice. Audience-bound tokens (the emulator honours OAuth resource indicators, RFC 8707) stop an agent’s token for one server from being replayed against another.
Why this matters here: because the emulator is the Authorization Server, you
can test MCP server authorization entirely offline — forge
an agent token with any tenant/oid/groups/scopes, point the MCP server’s
JWKS at the emulator, and exercise both the OAuth layer and the PEP→PDP tool
gate against the seven CI-verified engines — no
cloud tenant, no live agent. The reusable pieces are exactly the ones the sample
already ships; a standalone runnable MCP sample is a natural next addition.
Data-plane authorization (RLS / CLS)
Section titled “Data-plane authorization (RLS / CLS)”Everything above gates the API plane — “may you call this?” A second, complementary plane gates the data — “which rows and columns may you see?” — using the database’s own Row-Level Security (RLS) and Column-Level Security (CLS). It’s the mirror image of the PDP model: instead of a separate PEP and PDP, the database engine fuses both and enforces inline during query execution — below the app, so no query path (ORM, BI tool, ad-hoc SQL) can bypass it.
The emulator’s role is unchanged — it’s still the IdP feeding the PIP. The
only new step is carrying the token’s tid / groups into the database
session, where the policy predicate reads them:
| Role | Data plane |
|---|---|
| PEP + PDP | the DB engine — the RLS predicate filters rows / the CLS rule masks columns, inline in the query plan |
| PIP | the session variable set from the token (app.tenant_id), plus any entitlement table the predicate joins |
| PAP | the policy DDL — CREATE POLICY (Postgres), CREATE ROW ACCESS POLICY / CREATE MASKING POLICY (Snowflake), policy tags (BigQuery) |
Author the policy once (the PAP):
-- Postgres RLS: every query against documents is filtered by tenant.ALTER TABLE documents ENABLE ROW LEVEL SECURITY;CREATE POLICY tenant_isolation ON documents USING (tenant_id = current_setting('app.tenant_id', true)::uuid);Then the app’s only job is to propagate the validated identity into the
session — reusing the same authz.TokenValidator
(the PIP) from the API-plane story:
// The DB is the PEP/PDP; the app just carries the token's tid into the session.func withTenant(ctx context.Context, db *sql.DB, bearer string, fn func(*sql.Tx) error) error { claims, err := validator.Validate(bearer) // PIP: emulator-issued tid/groups if err != nil { return errUnauthorized } tid, _ := claims.Raw["tid"].(string)
tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() // set_config(..., is_local=true) is SET LOCAL — transaction-scoped, so a // pooled connection never leaks one request's tenant into the next. if _, err := tx.ExecContext(ctx, "SELECT set_config('app.tenant_id', $1, true)", tid); err != nil { return err } if err := fn(tx); err != nil { // every query inside is now RLS-filtered return err } return tx.Commit()}The critical seam is that SET. RLS is only as trustworthy as the identity
the session is told about: with a shared connection pool you must scope it to
the transaction (SET LOCAL / set_config(..., true)), or one request’s tenant
bleeds into the next. Forget the SET entirely and a service-account connection
sees everything. This is exactly where the token’s claims must land — the same
tid the emulator’s multi-tenant tokens already carry.
CLS follows the same shape, keyed on groups: managed warehouses do it
natively (Snowflake CREATE MASKING POLICY, BigQuery policy tags) — e.g. mask
ssn unless the session’s groups include an authorized id. Postgres has no native
CLS; use column privileges, views, or an extension like PostgreSQL Anonymizer.
Use both planes together for defense in depth: the PDP decides “can this agent call the reporting API?”, and RLS/CLS then guarantees “…and even so, only their tenant’s rows, with sensitive columns masked.”
Related
Section titled “Related”- SCIM provisioning — the other enterprise-integration surface: keep the PDP’s subjects in sync by provisioning users/groups into it.
- Testing with forged tokens — how to mint the access tokens the resource API (or MCP server) validates.
- Token service — the claims (
oid,groups) the PDP maps from.