Skip to main content

Spec: Centralized Authorization Service (CAS) β€” BFF-resident, PDP-agnostic

Status: Draft for review Date: 2026-06-06 Owner: Sri Aradhyula Context: problem-statement.md Β· solution-architecture.md Tracking: epic [#1742]; prerequisite [#1455]; security driver [#1730]

This spec covers only what gets built new: ui/src/lib/authz/ and POST /api/authz/v1/*. It is additive β€” no existing file is modified, no existing PEP is re-wired.


1. Summary​

Introduce one Authorization Decision Core in the BFF (ui/src/lib/authz/) and expose it via a versioned HTTP API (/api/authz/v1). The service lands standalone: existing authz paths (DA openfga_authz.py, RAG rbac.py, BFF resource-authz.ts) keep running unchanged. Migration of those surfaces to consume CAS is a separate, incremental effort.

Principle: One decision core. One PDP-agnostic contract. Zero changes to existing code.


2. Goals / Non-goals​

Goals​

  • G1 Single decision module: one place computes "may subject S do action A on resource R."
  • G2 PDP-agnostic contract: callers see {subject, resource, action} β†’ {decision, reason}; OpenFGA internals are invisible except in the admin explain endpoint.
  • G3 HA OpenFGA adapter: warm store-id, pooled client, decision cache, circuit breaker.
  • G4 HTTP API for out-of-process callers: POST /api/authz/v1/decisions, :batch, explain.
  • G5 Verified caller token at every call: CAS validates JWT via JWKS; subject-binding enforced.
  • G6 Additive only: nothing existing is modified. Existing authz paths are untouched.

Non-goals​

  • Migrating DA, RAG, bots, or the gateway bridge to consume CAS.
  • Modifying openfga_authz.py, rbac.py, resource-authz.ts, or any existing authz file.
  • Replacing OpenFGA as the relationship store.
  • Routing openfga-authz-bridge through CAS (rejected β€” Β§7).
  • Decision tokens / Transport C (future P5, gated on [#1458]).

3. Glossary​

TermMeaning
CASCentralized Authorization Service β€” the decision core + HTTP API.
PDPPolicy Decision Point β€” computes allow/deny. Here: core + OpenFGA adapter.
PEPPolicy Enforcement Point β€” calls PDP and enforces. CAS is a PDP; existing code has its own PEPs.
Coreui/src/lib/authz/ β€” the new module being built.
Subject-bindingA user caller may only evaluate subject == token.sub.

4. Module β€” ui/src/lib/authz/​

ui/src/lib/authz/
index.ts # authorize(), authorizeMany(), authorizeOrThrow(), filterAccessible()
contract.ts # Subject, Resource, Action, Decision, ReasonCode
reasons.ts # ReasonCode β†’ { retriable, userActionHint, httpStatusHint }
engine.ts # PolicyEngine interface
compose.ts # product policy layered over the PDP adapter
engines/
openfga.ts # the only OpenFGA adapter: action→relation map, warm store-id, pooled client, cache
domains/
workflow.ts # workflowDelegatesAgentUse, run-visibility
slack-channel.ts
webex-space.ts
cache.ts # bounded per-replica LRU, TTL-aware
audit.ts # one decision β†’ one structured audit event

4.1 Public API (index.ts)​

// Returns Decision; never throws for DENY
authorize(req: AuthorizeRequest): Promise<Decision>

// Batch: BatchCheck internally; returns one Decision per id
authorizeMany(
subject: Subject,
action: Action,
resourceType: ResourceType,
ids: string[]
): Promise<Map<string, Decision>>

// Guard variant: throws ApiError(403) on DENY, ApiError(503) on AUTHZ_UNAVAILABLE
authorizeOrThrow(req: AuthorizeRequest): Promise<void>

// List filter: returns only ids accessible to the subject
filterAccessible(
subject: Subject,
action: Action,
resourceType: ResourceType,
ids: string[]
): Promise<string[]>

4.2 PolicyEngine interface (engine.ts)​

interface PolicyEngine {
check(req: AuthorizeRequest): Promise<Decision>;
batchCheck(
subject: Subject,
action: Action,
resourceType: ResourceType,
ids: string[]
): Promise<Map<string, Decision>>;
}

compose.ts wraps a PolicyEngine with product policy (workflow delegation, channel scoping) and returns a PolicyEngine. All product rules live here; the OpenFGA adapter has no product knowledge.

4.3 OpenFGA adapter (engines/openfga.ts)​

ConcernDesign
Store idresolved once at boot; cached in module scope; refreshed on 404
HTTP clientone pooled keep-alive client; no per-call instantiation
HAretries idempotent check ≀2Γ—, 100ms between; upstream service-level replication
Decision cachebounded LRU; TTL ≀15s read-class, ≀2s write-class
BatchBatchCheck for ≀50 ids; bounded parallel for overflow
Circuit breakeropen β‡’ fail closed (AUTHZ_UNAVAILABLE); half-open probe every 30s
ConsistencyHIGHER_CONSISTENCY for write-class checks; standard otherwise
Action→relation mapuse → can_use, call → can_call, manage → admin, etc. — internal to adapter only

5. Contract​

5.1 Types​

type SubjectType  = "user" | "service_account";

type ResourceType =
| "agent" | "skill" | "mcp_tool" | "knowledge_base"
| "data_source" | "task" | "slack_channel" | "webex_space";

type Action =
| "discover" | "read" | "read-metadata" | "use"
| "write" | "manage" | "share" | "delete"
| "ingest" | "call" | "invoke" | "audit";

interface AuthorizeRequest {
subject: { type: SubjectType; id: string };
resource: { type: ResourceType; id: string };
action: Action;
context?: Record<string, unknown>; // may only NARROW a grant, never expand
}

type ReasonCode =
| "OK" // ALLOW
| "NO_CAPABILITY" // DENY β€” no relationship β†’ contact_admin
| "NOT_AUTHENTICATED" // caller token missing/invalid β†’ sign_in
| "AUTHZ_UNAVAILABLE" // PDP error (retriable) β†’ retry
| "INVALID_REQUEST"; // bad id / malformed β†’ fix_request

interface Decision {
decision: "ALLOW" | "DENY";
reason: ReasonCode;
retriable: boolean;
ttl_seconds?: number;
}

OpenFGA relation strings (can_use, can_call, etc.) are internal to engines/openfga.ts. They do not appear in Decision, error bodies, or any API response except the admin /explain endpoint.

5.2 HTTP API​

Single decision

POST /api/authz/v1/decisions
Authorization: Bearer <caller-token>
Content-Type: application/json

{
"subject": { "type": "user", "id": "<sub>" },
"resource": { "type": "agent", "id": "platform-engineer" },
"action": "use"
}

200 { "decision": "ALLOW", "reason": "OK", "retriable": false, "ttl_seconds": 15 }
200 { "decision": "DENY", "reason": "NO_CAPABILITY", "retriable": false }

Batch

POST /api/authz/v1/decisions:batch
Authorization: Bearer <caller-token>

{
"subject": { "type": "user", "id": "<sub>" },
"action": "discover",
"resource_type": "agent",
"ids": ["a", "b", "c"]
}

200 {
"results": [
{ "id": "a", "decision": "ALLOW", "reason": "OK" },
{ "id": "b", "decision": "DENY", "reason": "NO_CAPABILITY" }
]
}

Admin explain (only endpoint where OpenFGA detail appears)

POST /api/authz/v1/explain
Authorization: Bearer <caller-token> # requires can_audit scope

200 {
"decision": "DENY",
"reason": "NO_CAPABILITY",
"debug": {
"engine": "openfga",
"relation": "can_use",
"checked": ["user:<sub> can_use agent:platform-engineer"],
"store": "<store-id>"
}
}

5.3 HTTP status semantics​

A DENY is not an error. Evaluation succeeded; the answer is "no" β‡’ 200 with body. HTTP status codes are reserved for meta-failures only.

StatusMeaning
200evaluation succeeded (ALLOW or DENY in body)
401caller token missing or invalid (NOT_AUTHENTICATED)
403subject-binding violation β€” caller may not evaluate this subject
400malformed request (INVALID_REQUEST)
503PDP unavailable (AUTHZ_UNAVAILABLE, retriable: true)

PEPs translate decision: "DENY" into their own 403 responses. Deny-reasoning stays in the body, not in status codes.

5.4 Error envelope​

{
"error": "human-readable message safe to surface",
"code": "REASON_CODE",
"retriable": false
}

No OpenFGA strings (agent#use, pdp_denied, tuple strings) appear in error envelopes. These are adapter-internal and surface only in /explain.


6. Security model​

CAS is a trust boundary. It assumes callers may be hostile.

#ThreatControl
1Forged subjectA user caller may only evaluate subject.id == token.sub. Cross-subject requires can_audit scope. Mismatch β‡’ 403.
2Service account impersonationservice_account callers must present a verified client-credentials token and hold a can_impersonate tuple. No trusted delegated_subject body field.
3Unsigned/forgeable identity ([#1730])CAS validates JWT signature / exp / aud / iss via cached JWKS on every request. Header/body subject is only the target; binding is then checked per #1 and #2.
4PDP outage β†’ silent allowFail closed: open circuit β‡’ DENY + AUTHZ_UNAVAILABLE + retriable: true + 503 + audit event. Break-glass is explicit env flag, time-boxed, and audited per request.
5Cache stale-allowTTL ≀15s read-class, ≀2s write-class; revocation tolerated within TTL (documented bound).
6context expansioncontext may only narrow a grant. Server-evaluated delegation (workflow run-authorizer) lives inside compose.ts, not in caller-supplied context.
7Enumeration / info leakCoarse ReasonCode only in public responses; no tuple strings outside /explain; per-caller rate limit; every decision audited with correlation_id.
8New write surfacev1 API is evaluate only. No tuple writes. Standing up CAS grants no new mutation capability.

7. The openfga-authz-bridge β€” not in scope​

The existing openfga-authz-bridge is the data-plane PEP for MCP routing. AgentGateway fires a per-route ext_authz gRPC Check at a 200ms timeout. Routing those decisions through the BFF would put the BFF in the hot path of every MCP tool call. Rejected.

The bridge is not touched by this spec. Aligning its vocabulary, adapter semantics, and audit schema to match the contract defined here is a separate, incremental step.


8. Audit​

CAS writes one event per decision to audit_events.

{
"audit_event_id": "<uuid>",
"ts": "<iso8601>",
"type": "cas_decision",
"tenant_id": "<tenant>",
"subject_hash": "sha256:<salted-hash>",
"resource_type": "agent",
"resource_id": "<id>",
"action": "use",
"decision": "ALLOW",
"reason_code": "OK",
"pdp": "openfga",
"source": "cas",
"correlation_id": "<uuid>",
"trace_id": "<otel-trace-id>",
"span_id": "<otel-span-id>"
}

Metrics

MetricLabels
authz_decision_totalresource_type, action, decision, reason
authz_pdp_latency_msresource_type, action, cache_hit
authz_cache_hit_ratioresource_type
authz_circuit_statestate: closed|open|half_open

9. Configuration​

Env varDefaultPurpose
OPENFGA_HTTPβ€”OpenFGA HTTP base URL (required)
OPENFGA_STORE_IDβ€”Explicit store id; skips store-list lookup when set
OPENFGA_STORE_NAMEcaipe-openfgaStore name for lookup when id is unset
AUTHZ_DECISION_CACHE_TTL_MS15000Read-class cache TTL
AUTHZ_DECISION_CACHE_WRITE_TTL_MS2000Write-class cache TTL
AUTHZ_BREAK_GLASSunsetFail-open for non-prod emergencies; alarmed + audited per request
AUDIT_SUBJECT_SALTβ€”Salt for subject hash in audit events (required)
AUTHZ_TRACING_ENABLEDfalseOTLP span export for decision calls

10. Tasks​

All new files. No existing file is modified.

Core

  1. contract.ts β€” types: Subject, Resource, Action, Decision, ReasonCode.
  2. reasons.ts β€” ReasonCode β†’ { retriable, userActionHint, httpStatusHint }.
  3. engine.ts β€” PolicyEngine interface (check, batchCheck).
  4. engines/openfga.ts — adapter: warm store-id, pooled client, action→relation map, cache, circuit breaker.
  5. cache.ts β€” bounded LRU; read/write TTL split.
  6. audit.ts β€” structured decision event writer (Β§8 shape).
  7. compose.ts + domains/workflow.ts β€” product policy layer; workflow delegation.
  8. index.ts β€” authorize, authorizeMany, authorizeOrThrow, filterAccessible.

HTTP routes

  1. POST /api/authz/v1/decisions β€” JWKS verification, subject-binding, single decision.
  2. POST /api/authz/v1/decisions:batch β€” subject-binding, batch result.
  3. POST /api/authz/v1/explain β€” can_audit guard; OpenFGA debug block.
  4. Shared v1ErrorResponse builder β€” no OpenFGA strings in public envelopes.
  5. OpenAPI schema for /api/authz/v1.

Guards

  1. ESLint no-restricted-imports: engines/openfga and lib/rbac/openfga are import errors outside lib/authz/**.

Tests

  1. Unit: decision matrix (direct, team-union, deny, PDP error, invalid id); subject-binding accept/reject; cache TTL + write-class bypass; circuit breaker open/close.
  2. Contract: request/response schema; 200-DENY vs 401/403/400/503; no OpenFGA strings outside explain.
  3. Security regression: forged-subject 403; unsigned token rejected ([#1730]); PDP-down β‡’ DENY/503; break-glass audited.

11. Acceptance criteria​

  • ui/src/lib/authz/ exists with all modules in Β§4; engines/openfga imports restricted by lint rule.
  • POST /api/authz/v1/decisions (+ :batch, explain) live; caller JWT verified via JWKS; subject-binding enforced.
  • DENY returns 200 with ReasonCode; meta-failures return correct status codes (Β§5.3).
  • No agent#use, pdp_denied, or FGA tuple strings in any non-explain response.
  • Adapter: store-id cached at boot; pooled client; circuit breaker; read/write TTL split.
  • Every decision produces one audit event with the shape in Β§8.
  • OpenAPI schema published; contract tests green.
  • No existing file was modified; no existing test was broken.