Skip to content

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.

The runnable reference lives in samples/externalized-authz/.

  • Authenticationwho 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.
  • Authorizationmay 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 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.

RoleAnswersIn this sampleEmulator’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.goClaims, mapped to user:<oid> + group:<gid> in server.goissues 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/

Everything the resource server needs to authenticate, and nothing it needs to authorize (by design):

Claim / endpointRole
discovery/v2.0/keys (JWKS)RS256 public keys, with rotation — the validator refreshes its cache on unknown kid
iss / aud / expstandard issuer / audience / expiry checks
oidstable object id → PDP subject (user:<oid>)
groupsgroup membership → PDP usersets (group:<gid>#member)

Mint the access tokens for tests with the admin token forge — no interactive sign-in required.

samples/externalized-authz/ is a small Go resource API built around a single seam — the PDP port:

PieceRole
authz/validator.goJWKS-backed RS256 token validator (authN)
authz/pdp.gothe PDP port + an in-memory OpenFGA-style tuple checker (InMemoryPDP)
authz/server.goprotected API: authenticate, then PDP-check per route
main.gostandalone, env-configured runner
main_test.goend-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.

Terminal window
# 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/readme

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 readme

The routes declare what each needs (server.go): GET /documents/{id} requires reader, POST requires writer.

Allowed read — alice’s token, GET:

Terminal window
curl -H "Authorization: Bearer $ALICE_TOKEN" http://localhost:9090/documents/readme
# → 200 {"id":"readme","action":"read","subject":"<alice-oid>"}

What happened, role by role:

  1. PEP (authorize("reader", …)) intercepts the request and pulls the bearer token.
  2. PIP (validator.Validate) verifies the JWT against the emulator’s JWKS and extracts oid + groups — the emulator, as IdP, is the source of those facts.
  3. The PEP shapes the question: Subject: "user:<alice-oid>", Relation: "reader", Object: "doc:readme", Groups: ["group:<gid>", …].
  4. PDP (PDP.Check) matches the seeded tuple → permit.
  5. PEP lets the handler run → 200.

Denied write — same token, POST:

Terminal window
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 whose groups claim 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’s aud check fails before the PDP is ever consulted).

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:

EngineModelRuns as
OpenFGAReBAC (Zanzibar)container (testcontainers)
SpiceDB (Authzed)ReBAC (Zanzibar)container (testcontainers)
Ory KetoReBAC (Zanzibar)container (testcontainers)
PermifyReBAC (Zanzibar)container (testcontainers)
CasbinRBAC/ABACin-process (library)
OPARego policyin-process (library)
Cedar (cedar-go)Cedar policyin-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.

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.1
type user
type group
relations
define member: [user]
type doc
relations
define reader: [user, group#member]
define writer: [user, group#member]

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:

  1. 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 carry oid/groups. The server validates them with the same authz.TokenValidator — that’s the PIP, unchanged.
  2. 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/call handler is the PEP, and it delegates to the PDP through the same PDP port used above.

The mapping onto MCP primitives:

MCP conceptMaps toRole
tools/call handlerthe guard around tool dispatchPEP
the tool name + its argumentsRelation + Object (e.g. invoke / tool:send_email)the PDP question
oid / groups from the agent’s tokenSubject + usersetsPIP (emulator-issued)
Cerbos / OpenFGA / OPA policythe decisionPDP

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 just tools/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 offlineforge 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.

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:

RoleData plane
PEP + PDPthe DB engine — the RLS predicate filters rows / the CLS rule masks columns, inline in the query plan
PIPthe session variable set from the token (app.tenant_id), plus any entitlement table the predicate joins
PAPthe 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.”

  • 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.