Skip to main content

Contract: RBAC Authorization Check v1

Internal authorization contract for the 098 Enterprise RBAC feature. All CAIPE enforcement points use one of two PDPs — both enforce the same 098 permission matrix.

Contract overview​

PropertyValue
Version1
StatusDraft
FR coverageFR-001, FR-002, FR-008, FR-013, FR-014, FR-022
Enforcement pointsBFF (Next.js API routes), Slack bot middleware, Agent Gateway

Dual-PDP model​

┌─────────────────────┐
UI / Slack path │ Keycloak AuthZ │ PDP-1: resources, scopes, role-based policies
│ (Authorization │ Protocol: Keycloak token endpoint or UMA
│ Services) │ Latency target: < 5 ms (in-process RPT cache)
└─────────────────────┘
┌─────────────────────┐
MCP / A2A path │ Agent Gateway │ PDP-2: JWT validation + CEL policy
│ (Data-plane) │ Protocol: HTTP(S) proxy
│ │ Fail-closed if AG unreachable
└─────────────────────┘

Both PDPs enforce the 098 permission matrix. Matrix rows are defined per component and capability; columns are roles.

PDP-1: Keycloak Authorization Services (UI / Slack)​

Request format (Keycloak token endpoint — permission evaluation)​

POST /realms/{realm}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:uma-ticket
&audience={resource-server-client-id}
&permission={resource}#{scope}
&subject_token={access_token}

Alternative: direct evaluation (RPT introspection)​

POST /realms/{realm}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:uma-ticket
&audience={resource-server-client-id}
&permission={resource}#{scope}
&response_mode=decision

Response​

{
"result": true
}

Or 403 with:

{
"error": "access_denied",
"error_description": "not_authorized"
}

Parameters​

ParameterTypeRequiredDescription
resourcestringyesComponent from permission matrix: admin_ui, slack, supervisor, rag, tool, mcp, a2a, sub_agent, skill
scopestringyesCapability from matrix: view, create, delete, invoke, admin, configure, etc.
access_tokenstringyesKeycloak JWT (user session or OBO token)

BFF integration (TypeScript)​

interface RbacCheckRequest {
resource: string;
scope: string;
accessToken: string;
}

interface RbacCheckResult {
allowed: boolean;
reason?: string;
}

async function checkPermission(req: RbacCheckRequest): Promise<RbacCheckResult>;

Slack bot integration (Python)​

class RbacCheckRequest:
resource: str
scope: str
access_token: str

class RbacCheckResult:
allowed: bool
reason: str | None

async def check_permission(request: RbacCheckRequest) -> RbacCheckResult: ...

PDP-2: Agent Gateway (MCP / A2A / Agent)​

How it works​

AG acts as a reverse proxy. When a request arrives:

  1. AG extracts the Authorization: Bearer <JWT> header.
  2. AG validates the JWT against Keycloak's JWKS endpoint.
  3. AG evaluates CEL authorization rules using JWT claims (sub, realm_access.roles, scope, org).
  4. Allow: request is proxied to the backend MCP/A2A/agent server.
  5. Deny: AG returns 403 Forbidden.

CEL policy rule format​

AG uses CEL (Common Expression Language) for all authorization decisions. Rules are embedded inline in the AG config.yaml under mcpAuthorization.rules (for MCP traffic) and authorization.rules (for HTTP traffic).

# MCP-level authorization (attached to backend)
mcpAuthorization:
rules:
# chat_user can invoke any MCP tool
- '"chat_user" in jwt.realm_access.roles && has(mcp.tool)'
# kb_admin can invoke RAG ingest tools
- '"kb_admin" in jwt.realm_access.roles && mcp.tool.name.startsWith("rag_ingest")'
# admin can invoke admin tools
- '"admin" in jwt.realm_access.roles && mcp.tool.name.startsWith("admin_")'
# HTTP-level authorization (attached to route)
authorization:
rules:
- deny: '!has(jwt.sub)'
- allow: 'has(jwt.sub)'

AG configuration reference​

# yaml-language-server: $schema=https://agentgateway.dev/schema/config
binds:
- port: 4000
listeners:
- policies:
jwtAuth:
mode: strict
issuer: https://keycloak.example.com/realms/caipe
audiences: [caipe-platform]
jwks:
file: /etc/agentgateway/jwks.json
routes:
- backends:
- policies:
mcpAuthorization:
rules:
- '"chat_user" in jwt.realm_access.roles && has(mcp.tool)'

Default deny (FR-002)​

Both PDPs enforce default deny:

  • Keycloak AuthZ: decision strategy UNANIMOUS at resource server level; no matching permission → deny.
  • Agent Gateway: if no mcpAuthorization rule matches, the request is denied; unreachable AG → deny (fail-closed).

Audit integration (FR-005)​

Every authorization decision (allow or deny) is logged as a structured event:

{
"ts": "2026-04-03T12:34:56Z",
"tenant_id": "acme",
"subject_hash": "sha256:abc...",
"capability": "rag#invoke",
"component": "mcp",
"outcome": "deny",
"reason_code": "DENY_NO_CAPABILITY",
"pdp": "agent_gateway",
"correlation_id": "req-12345"
}

Logged by the enforcement point (BFF middleware, Slack bot middleware, or AG sidecar) after PDP evaluation.

Error responses (FR-006)​

Enforcement points return a structured, actionable denial message to the user:

ChannelDenied feedback
Admin UIToast/banner: "You do not have permission to action. Contact your admin." Denied controls hidden/disabled.
SlackEphemeral message: "Sorry, you don't have permission to action. Ask your workspace admin for access."
Agent / MCP403 JSON body: {"error": "access_denied", "capability": "{resource}#{scope}"}

OBO token exchange (FR-018)​

For Slack/Webex bot delegation, the bot performs OBO token exchange before calling Keycloak AuthZ or forwarding to AG:

POST /realms/{realm}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token={user_access_token}
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&requested_token_type=urn:ietf:params:oauth:token-type:access_token
&client_id={bot_client_id}
&client_secret={bot_client_secret}

The resulting OBO JWT contains sub (user) + act.sub (bot). Scope is the intersection of user entitlements and bot scope ceiling. This OBO JWT is forwarded to AG and downstream agents.