Skip to content

Latest commit

 

History

History
604 lines (487 loc) · 20.3 KB

File metadata and controls

604 lines (487 loc) · 20.3 KB

opa

OPA (Open Policy Agent) plugin for AuthBridge. Downloads policy bundles from a Rossoctl Bundle Server based on the agent's identity and evaluates requests and responses against the loaded policy using four fixed decision paths.

How it works

  1. At startup the plugin reads the agent's client ID from /shared/client-id.txt (mounted by the operator from a Keycloak-credentials Secret).
  2. It creates an embedded OPA engine via the OPA Go SDK and configures it to fetch bundles?spiffe=<url-encoded-spiffe-id> from the bundle server.
  3. The SDK downloads the bundle, activates the policy, and begins periodic polling for updates (respecting ETag / If-None-Match for lightweight 304 responses).
  4. On every request and response the plugin queries the appropriate decision path based on traffic direction and phase. If the policy denies, the plugin returns HTTP 403. If the path is undefined (rule not present in the bundle), the plugin skips evaluation — treating the absence of a rule as "no opinion".

The plugin reports not-ready (Ready() == false) until the first bundle is successfully loaded. The Kubernetes readiness probe holds traffic off the pod until then, so requests never arrive before the policy is active.

Decision paths

The plugin uses four fixed OPA decision paths, one for each evaluation point:

Path Phase Purpose
authbridge/inbound/request Inbound request Primary authorization — validate caller identity, enforce access control
authbridge/inbound/response Inbound response Fine-grained response evaluation (rare) — inspect response status, headers, and parsed protocol content via include
authbridge/outbound/request Outbound request Control outgoing requests to external systems — ensure delegated tokens are used in line with task intent
authbridge/outbound/response Outbound response Protect the agent from data attacks in responses (rare)

Each path is independent. A bundle only needs to include the rules it cares about — undefined paths are skipped (treated as allow). Most deployments will only define authbridge/inbound/request.

Configuration

Default configuration

Most deployments only need bundle_url. The lean default input is sufficient for tool-access-control and model-restriction policies:

pipeline:
  inbound:
    plugins:
      - name: jwt-validation
        config: { ... }
      - name: opa
        config:
          bundle_url: "http://bundle-server.rossoctl.svc:8080"
  outbound:
    plugins:
      - name: token-exchange
        config: { ... }
      - name: opa
        config:
          bundle_url: "http://bundle-server.rossoctl.svc:8080"

With this config, policies can decide based on caller identity, tool names, model names, hosts, and methods — without any bulk content crossing the OPA evaluation boundary.

Outbound ordering matters. Place opa after token-exchange on the outbound leg. token-exchange records the target audience and granted scopes of the token it mints into the delegation chain, which OPA then exposes as both input.delegation and a synthesized input.identity (see below). If opa runs first, that signal is not yet populated. On inbound, input.identity comes from a validated JWT; on outbound no JWT is validated, so input.identity is synthesized from the delegation hop — letting policies branch on input.identity on either leg — while input.delegation remains available for full-chain detail.

Example: content-filtering policy

If a policy needs to inspect the actual user prompt (e.g., block tasks containing sensitive keywords), extend the input with a2a.content:

- name: opa
  config:
    bundle_url: "http://bundle-server.rossoctl.svc:8080"
    include:
      - "a2a.content"

This adds a2a.parts[].content, a2a.artifact, and a2a.error_message to the OPA input so the policy can match against user-submitted text.

Example: full MCP arguments + LLM conversation audit

A deployment that needs to write policies against tool argument values and the full conversation history:

- name: opa
  config:
    bundle_url: "http://bundle-server.rossoctl.svc:8080"
    include:
      - "mcp.params"            # full tool arguments (not just name/uri)
      - "mcp.result"            # tool response data (response path)
      - "inference.messages"    # full conversation history
      - "inference.tools.detail" # tool descriptions and JSON schemas

Bundle server transport

The current implementation fetches bundles over plaintext HTTP with no client authentication. This is suitable for deployments where the bundle server is co-located in the same Kubernetes cluster and in-cluster HTTP is considered sufficient. A future enhancement will add mTLS support with SPIFFE certificate. Additionally a future enhancement will add TLS with service account token.

Config fields

Field Required Default Description
bundle_url yes Base URL of the Rossoctl Bundle Server (HTTP, in-cluster)
agent_id_file no /shared/client-id.txt Path to the file containing the agent's client ID
agent_id no Inline agent ID; when set, agent_id_file is ignored
polling_min_delay no 10 Minimum bundle polling interval in seconds
polling_max_delay no 120 Maximum bundle polling interval in seconds
include no [] List of optional field groups to expose in the OPA input (see below)

The include mechanism

The OPA input document is lean by default — it contains only structural metadata needed for authorization decisions (method, path, host, identity, tool names, model names). Bulk content fields (conversation history, full tool arguments, prompt text) are excluded unless explicitly requested via the include config list.

Each entry in include is a named group key that unlocks additional fields:

Key What it unlocks Default Size concern
mcp.params.name params.name in MCP input ON Tiny
mcp.params.uri params.uri in MCP input ON Tiny
mcp.params.<key> Any specific params key (e.g., mcp.params.cursor) OFF Varies
mcp.params Full params map (all keys) OFF Can be large
mcp.result result map (response path) OFF Can be very large
mcp.error error object (code + message + data) OFF Usually small
a2a.content parts[].content, artifact, error_message OFF KBs (prompts/responses)
inference.messages Full messages[] array OFF Tens of KBs
inference.completion completion text OFF Can be large
inference.tools.detail Tool description + parameters OFF Moderate
inference.tool_calls tool_calls[] with arguments OFF Can be large

Default-on keys (mcp.params.name, mcp.params.uri) are always included even with an empty include list.

OPA input document

Default (lean) input

{
  "direction": "inbound",
  "method": "POST",
  "path": "/api/v1/invoke",
  "host": "my-agent",
  "headers": {
    "content-type": "application/json",
    "x-request-id": "abc-123"
  },
  "identity": {
    "subject": "user-123",
    "client_id": "caller-agent",
    "scopes": ["openid", "profile"]
  },
  "agent": {
    "client_id": "my-agent"
  },
  "a2a": {
    "method": "invoke",
    "session_id": "sess-123",
    "task_id": "task-789",
    "role": "user"
  },
  "mcp": {
    "method": "tools/call",
    "params": { "name": "create_issue", "uri": "file:///workspace/main.go" }
  },
  "inference": {
    "model": "gpt-4",
    "stream": false,
    "max_tokens": 4000,
    "tools": ["create_issue", "list_issues"]
  }
}

Notes:

  • mcp.params contains only the default-on keys (name, uri) that are present in the original params. Add more via include.
  • inference.tools is a string array of tool names only (not full objects). Use inference.tools.detail in include for descriptions and parameters.

With include: ["a2a.content", "mcp.params", "inference.messages", "inference.tools.detail"]

{
  "a2a": {
    "method": "invoke",
    "session_id": "sess-123",
    "task_id": "task-789",
    "role": "user",
    "parts": [
      { "kind": "text", "content": "Create a GitHub issue for bug XYZ" }
    ],
    "artifact": "Issue #42 created",
    "error_message": ""
  },
  "mcp": {
    "method": "tools/call",
    "params": { "name": "create_issue", "arguments": {"title": "Bug XYZ", "body": "..."} }
  },
  "inference": {
    "model": "gpt-4",
    "stream": false,
    "max_tokens": 4000,
    "messages": [
      {"role": "user", "content": "Help me create an issue"}
    ],
    "tools": [
      {"name": "create_issue", "description": "Creates a GitHub issue", "parameters": {"type": "object"}}
    ]
  }
}

On the response path the document also includes:

{
  "response": {
    "status_code": 200,
    "headers": {
      "content-type": "application/json"
    }
  }
}

Input field reference

Field Present Description
direction always "inbound" or "outbound"
method always HTTP method (GET, POST, ...)
path always Request URL path
host always HTTP Host header value
headers always Flattened request headers (lowercase keys, multi-values joined with ,). Credential headers (authorization, proxy-authorization, cookie, set-cookie) are redacted — use input.identity for auth decisions.
identity when jwt-validation ran, or synthesized outbound from the delegation hop Inbound: subject, client_id, and scopes from the validated JWT. Outbound: the same shape synthesized from the token-exchange hop (subject = delegated caller, client_id = the agent's own client, scopes = the exchanged scopes) so policies can read input.identity uniformly on both legs — see the outbound section below.
agent when agent identity is set The agent's own client ID
delegation when token-exchange minted/served a token on this leg The RFC 8693 delegation chain: origin, actor, depth, and chain[] of {subject_id, audience, scopes, strategy, from_cache, timestamp}. This is the outbound identity signal — see below.
a2a when a2a-parser ran A2A protocol metadata (method, session_id, task_id, role)
mcp when mcp-parser ran MCP method + filtered params
inference when inference-parser ran Model, stream, max_tokens, tool names
response response path only Status code and response headers

Outbound identity + delegation input

On the outbound leg there is no inbound JWT to validate. When token-exchange mints (or cache-serves) a downstream token it records a hop in the delegation chain, and OPA surfaces that hop two ways:

  1. input.identity — synthesized in the same shape as the inbound identity so a policy can branch on input.identity.subject / input.identity.client_id / input.identity.scopes uniformly on both legs:

    {
      "identity": {
        "subject": "dev-user",
        "client_id": "github-agent",
        "scopes": ["openid", "agent-team1-github-tool-aud"]
      }
    }
    • subject = the delegated caller (the delegation chain's origin).
    • client_id = the agent's own client (the party performing the exchange), read from /shared/client-id.txtnot the target audience.
    • scopes = the scopes the downstream token was minted with (the last hop); omitted when the hop recorded none.

    A validated inbound input.identity always takes precedence: this synthesized form only appears when no JWT was validated on the leg.

  2. input.delegation — the full RFC 8693 chain, kept alongside input.identity for policies that need per-hop detail (multi-hop depth, per-hop audience/strategy/cache):

{
  "delegation": {
    "origin": "agent-team1",
    "actor": "agent-team1",
    "depth": 1,
    "chain": [
      {
        "subject_id": "agent-team1",
        "audience": "github-tool",
        "scopes": ["openid", "github-tool-aud", "github-full-access"],
        "strategy": "token-exchange",
        "from_cache": false,
        "timestamp": "2026-08-03T12:00:00Z"
      }
    ]
  }
}
  • origin — subject of the first hop (the original caller).
  • actor — subject of the most recent hop.
  • depth — number of hops recorded so far.
  • chain[] — one entry per exchange. audience and scopes are what the token was minted for; strategy is "token-exchange" today; from_cache reports whether the token was served from the exchange cache.

subject_id (and therefore origin / actor and the synthesized input.identity.subject) is best-effort: the outbound leg has no validated identity, so token-exchange decodes the sub claim of the incoming bearer — the token it uses as the RFC 8693 subject_tokenwithout signature verification, purely to enrich policy input. It falls back to empty when no usable bearer is present. The audience / scopes / strategy / from_cache fields are always known from the exchange result. Requires opa to run after token-exchange (see the ordering note above); a host that matches no route carries no hop, so both input.delegation and the synthesized input.identity are absent for passthrough traffic.

Policy contract

Each decision path evaluates to an allow rule. The plugin supports two return shapes:

Boolean -- the simplest form:

package authbridge.inbound.request

default allow := false

allow if {
    input.identity.subject != ""
}

Object with reason -- for detailed deny messages:

package authbridge.inbound.request

default allow := false

allow if {
    input.identity.subject != ""
}

reason := "anonymous access not permitted" if {
    not allow
}

Example policies

Tool access control (MCP) — inbound request

package authbridge.inbound.request

default allow := false

# Define allowed tools per client
allowed_tools := {
    "github-agent": ["create_issue", "list_issues", "get_issue"],
    "admin-agent": ["create_issue", "list_issues", "get_issue", "delete_issue"],
}

# Allow tool calls only if the tool is in the allowed list
allow if {
    input.mcp.method == "tools/call"
    input.identity.client_id
    tool_name := input.mcp.params.name
    allowed_tools[input.identity.client_id][_] == tool_name
}

# Allow tool listing for all authenticated users
allow if {
    input.mcp.method == "tools/list"
    input.identity.subject != ""
}

LLM model restriction (Inference) — outbound request

package authbridge.outbound.request

default allow := {"allow": false, "reason": "default deny"}

approved_models := ["gpt-4", "gpt-3.5-turbo", "claude-3-sonnet"]

allow := {"allow": true} if {
    input.inference.model
    approved_models[_] == input.inference.model
    not excessive_token_request
}

excessive_token_request if {
    input.inference.max_tokens
    input.inference.max_tokens > 4000
}

allow := {"allow": false, "reason": "token limit exceeds policy"} if {
    excessive_token_request
}

Dangerous tool combination block (Inference) — outbound request

package authbridge.outbound.request

default allow := false

allow if {
    input.inference.model
    not has_dangerous_combo
}

has_dangerous_combo if {
    tools := input.inference.tools
    has_value(tools, "write_file")
    has_value(tools, "execute_command")
}

has_value(arr, val) if {
    arr[_] == val
}

Scope-of-delegation control — outbound request (uses input.delegation)

Restrict which exchanged scopes an agent may use against a downstream target. Because OPA runs after token-exchange, the minted token's audience and scopes are visible in input.delegation.chain:

package authbridge.outbound.request

default allow := {"allow": false, "reason": "default deny"}

# Agents allowed to wield elevated GitHub scopes.
privileged_agents := {"admin-agent", "release-bot"}

# No exchange happened (passthrough egress, e.g. LLM inference) — allow.
allow := {"allow": true} if {
    not input.delegation
}

# An exchange happened: enforce scope policy on the minted token.
allow := {"allow": true} if {
    hop := input.delegation.chain[_]
    hop.audience == "github-tool"
    not uses_full_access(hop)
}

allow := {"allow": true} if {
    hop := input.delegation.chain[_]
    hop.audience == "github-tool"
    uses_full_access(hop)
    privileged_agents[input.delegation.actor]
}

uses_full_access(hop) if {
    hop.scopes[_] == "github-full-access"
}

allow := {"allow": false, "reason": "github-full-access requires a privileged agent"} if {
    hop := input.delegation.chain[_]
    hop.audience == "github-tool"
    uses_full_access(hop)
    not privileged_agents[input.delegation.actor]
}

Task content filtering (A2A) — requires include: ["a2a.content"]

package authbridge.inbound.request

default allow := {"allow": false, "reason": "default deny"}

allow := {"allow": true} if {
    input.identity.subject != ""
    not contains_sensitive_keywords
}

contains_sensitive_keywords if {
    input.a2a.parts[_].content
    task := lower(input.a2a.parts[_].content)
    sensitive := ["delete database", "drop table", "rm -rf", "sudo"]
    some keyword in sensitive
    contains(task, keyword)
}

allow := {"allow": false, "reason": "task contains sensitive keyword"} if {
    contains_sensitive_keywords
}

Multi-path bundle example

A single bundle can contain rules for multiple decision paths:

bundles/my-agent.tar.gz
  authbridge/
    inbound/
      request.rego      # package authbridge.inbound.request
    outbound/
      request.rego      # package authbridge.outbound.request

The plugin interprets the decision as follows:

Result Action
true Allow
false Deny with "policy denied"
{"allow": true} Allow
{"allow": false} Deny with "policy denied"
{"allow": false, "reason": "..."} Deny with the provided reason
Anything else Deny (safe default)

Bundle layout

The bundle server must serve a standard OPA bundle at bundles?spiffe=<url-encoded-spiffe-id>. The SPIFFE ID is read from /shared/client-id.txt (or the agent_id config field), stripped of the spiffe:// prefix, and URL-encoded. A minimal bundle contains a single .rego file for the inbound request path:

bundles/my-agent.tar.gz
  authbridge/
    inbound/
      request.rego

A full bundle covering all four decision paths:

bundles/my-agent.tar.gz
  authbridge/
    inbound/
      request.rego       # package authbridge.inbound.request
      response.rego      # package authbridge.inbound.response (optional)
    outbound/
      request.rego       # package authbridge.outbound.request (optional)
      response.rego      # package authbridge.outbound.response (optional)

Only include the paths you need — the plugin skips evaluation for any undefined path.

See the OPA bundle documentation for the full specification.

Pipeline ordering

The plugin declares After: ["jwt-validation", "a2a-parser", "mcp-parser", "inference-parser"] (soft ordering). When these plugins are present in the same pipeline, OPA runs after them so input.identity, input.a2a, input.mcp, and input.inference are populated. If any are absent, OPA still runs — the corresponding input fields will be missing and the policy must handle that case.

Deny behavior

  • Request path: OPA not initialized or decision error -> 503. Policy deny -> 403. Path undefined -> skip.
  • Response path: OPA not initialized -> 503. Policy deny -> 403. Path undefined -> skip.
  • Before bundle loads: the readiness probe holds traffic off the pod. If a request or response arrives anyway (e.g. in tests), the plugin denies with 503.

Session events

The plugin records Invocation entries for every decision:

Action Reason When
allow policy_allowed / response_policy_allowed OPA returned allow
deny policy_denied / response_policy_denied OPA returned deny
deny decision_error / response_decision_error OPA evaluation failed
deny opa_not_ready OPA not yet initialized (bundle not loaded)
skip no_policy_rule Decision path undefined in bundle (no rule for this phase)

These appear in the session events API (:9094) and in abctl.