---
id: w4/m4/t004
title: "Checker seam in bex-api Core + enforcement in the three adapters"
worker: worker4
status: done
estimate: 45m
depends_on: [w4/m4/t003]
---

## Objective

Teach bex-api to ask "may this identity do this?" through a narrow injected seam — `Checker` — consulted with `api.IdentityFrom`'s subject after authentication. nil checker ⇒ allow-all (today's behavior, byte-for-byte); wired checker unreachable ⇒ fail closed. Enforcement lives at the Core-verb level so REST, GraphQL and MCP cannot diverge (the parity rule).

## Context

- Seam precedent: `APIKeyStore` / `PodLogSource` in `operator/internal/api/` — interface on `Core`, production impl in its own file, fakes in tests. Follow it exactly: `Checker interface { Check(ctx, subject, relation, object string) (bool, error) }` + `openfgaChecker` (HTTP, preshared key, `oryTransport`-style shared pool, short positive cache like introspection's).
- Subject flows from the auth middleware's `Identity` (auth.go) — Core verbs need the identity; today they don't take one. Cleanest: Core reads `IdentityFrom(ctx)` (the context already carries it through every adapter).
- Verb → permission map (initial): read verbs (`List/Get/Logs/Metrics/…`) → `can_view`; mutating verbs (`Suspend/Resume/Restart/Create*/Delete*`) → `can_manage`; key verbs (`CreateAPIKey/ListAPIKeys/RevokeAPIKey`) → `can_mint_keys` on the tenant. Single default tenant object (`tenant:default`) until w1/m2.
- Denials: new `ErrForbidden` sentinel in core.go → 403 in `writeErr` (and the GraphQL/MCP error paths). 401 stays authentication-only.
- Env: `BEX_OPENFGA_URL` (+ store discovery per t003, preshared key from the mounted Secret); unset ⇒ nil checker. Wire in `cmd/api/main.go` next to `NewHydraAPIKeys`; add to `operator/config/api/deployment.yaml` the way the Hydra/Kratos URLs are.

## Steps

1. `operator/internal/api/authz.go`: `Checker`, `ErrForbidden` (core.go), `openfgaChecker` with cache + fail-closed semantics.
2. Guard Core verbs via one helper (`c.authorize(ctx, relation, object)`) so the map lives in one place.
3. `writeErr` + GraphQL/MCP error mapping for 403; main.go + deployment.yaml env plumbing.
4. Unit tests: nil checker = all verbs pass exactly as today; fake checker deny ⇒ 403 on every adapter; checker error ⇒ 503; subject taken from context identity.

## Files

- `operator/internal/api/authz.go` (new), `core.go`, `rest.go`, `graphql.go`, `mcp.go`
- `operator/cmd/api/main.go`, `operator/config/api/deployment.yaml`

## Acceptance criteria

- [ ] `make test` green with BOTH nil-checker (existing suites untouched) and fake-checker matrices (allow/deny/error × REST/GraphQL/MCP).
- [ ] `BEX_OPENFGA_URL` unset ⇒ zero behavior change; set-but-unreachable ⇒ 503, never a pass-through.

## Out of scope

- Session-user vs machine-key permission differences; per-object tuples for individual Apps (object granularity beyond `tenant:default` comes with w1/m2).
