ADR-001: Role-Based Access Control for Agent Tools¶
Status: Accepted Date: 2026-03-17 Author: Taoufiq
Context¶
The platform's agents expose tools that range from read-only queries (list_topics, get_nodes) to irreversible operations (delete_kafka_topic, restart_pod). The existing guardrail system (@destructive, @confirm) gates execution but does not gate who can execute. Any user interacting with the agent — via the ADK web UI, CLI, or Slack — can trigger any tool, including destructive ones (after confirmation).
As we move toward multi-user environments (Slack channels, shared web UI), we need a way to restrict what each user is allowed to do based on their role.
Decision¶
Implement a three-role hierarchy that reuses the existing guardrail decorator metadata:
VIEWER (0) → can call unguarded (read-only) tools
OPERATOR (1) → can also call @confirm tools (mutating)
ADMIN (2) → can also call @destructive tools (irreversible)
Key design choices¶
-
Reuse guardrail metadata — The
@destructiveand@confirmdecorators already classify tools by risk. RBAC derives minimum roles from these decorators automatically viainfer_minimum_role(). No need to re-annotate every tool. -
Role stored in session state — The user's role is read from
session.state["user_role"](a string:"viewer","operator", or"admin"). This is set by the integration layer (Slack bot, web UI, CLI) at session creation. Default is"viewer"(least privilege). -
authorize()viaGuardrailsPlugin— RBAC is enforced by theGuardrailsPlugin, registered once on theRunnerviadefault_plugins(). The plugin applies globally to every agent and tool:RBAC blocks unauthorized users. Tool confirmation is handled natively by ADK'sfrom orrery_core import default_plugins, RolePolicy plugins = default_plugins(role_policy=RolePolicy(overrides={"sensitive_read": Role.OPERATOR})) runner = Runner(agent=root_agent, ..., plugins=plugins)FunctionTool(require_confirmation=True)— see AEP-001. -
RolePolicyfor overrides — A policy object allows per-tool overrides when the inferred role isn't appropriate: -
@requires_roledecorator for explicit annotation — For tools that don't use@destructive/@confirmbut still need access control:
What this does NOT include (deferred)¶
- Authentication — Verifying who the user is. The integration layer (Slack OAuth, web UI auth) handles identity. RBAC only checks the role string already in state.
- User-to-role mapping store — No database of users and roles. The Slack bot or web UI sets
user_rolebased on its own auth system. A future ADR may add aRoleStore. - Per-resource permissions — No "this user can delete topic X but not topic Y". Roles apply uniformly to tool types.
- Audit trail for denials — Denials are logged via Python
logging.warning(). A future iteration could feed these into the audit trail.
Consequences¶
Positive¶
- Zero re-annotation — Existing
@destructive/@confirmtools automatically get the correct role requirements. - Composable — Plugs into the existing callback pipeline alongside guardrails, audit logging, and activity tracking.
- Least-privilege default — Users with no role assignment get
VIEWER, which is read-only. - Integration-agnostic — Any frontend (Slack, web, CLI) can set
user_rolein session state.
Negative¶
- Coarse-grained — Three roles may not be enough for all scenarios. Mitigated by
RolePolicyoverrides and@requires_role. - Trust the integration layer — If the Slack bot or web UI doesn't set
user_role, all users default toVIEWER. If it sets it incorrectly, RBAC is bypassed. This is acceptable because the alternative (embedding auth in the agent framework) would couple concerns.
Neutral¶
- No breaking changes —
authorize()is opt-in. Agents without it behave exactly as before.
Implementation¶
core/orrery_core/security/rbac.py—Roleenum,RolePolicy,authorize(),@requires_role,infer_minimum_role(),NamespaceScopeGuardcore/tests/test_rbac.py— role inference, policy overrides, the authorize callback, default-role enforcement, and namespace scoping
Plugin-based enforcement (replaces per-agent callbacks)¶
Update (2026-03-31): RBAC is now enforced globally via the GuardrailsPlugin, registered once on the Runner through default_plugins(). This replaces the previous approach of wiring authorize() as a before_tool_callback on every individual agent.
ADK Plugins apply to every agent, tool, and LLM call managed by the Runner — including sub-agents. This eliminates the need to remember to add authorize() to each new agent.
from orrery_core import default_plugins
from google.adk.runners import Runner
runner = Runner(
agent=root_agent,
app_name="orrery_assistant",
session_service=session_service,
plugins=default_plugins(), # includes GuardrailsPlugin with RBAC
)
Rule: When adding a new agent, no RBAC wiring is needed — just mark tools with @confirm or @destructive and the GuardrailsPlugin handles enforcement automatically.
Plugin execution order¶
GuardrailsPlugin.before_agent_callback → ensures default viewer role if not server-set
GuardrailsPlugin.before_tool_callback → authorize() blocks if user role < tool's required role
FunctionTool(require_confirmation=True) → ADK natively asks "are you sure?" for guarded tools
A viewer requesting create_kafka_topic (@confirm → requires OPERATOR) gets denied by authorize() before reaching the confirmation prompt. An operator gets past authorize() but is then asked to confirm by ADK's native confirmation flow.
Namespace scope: restricting where, not just what¶
Update (2026-07-24): the role check answers which tool a caller may run. It does not answer where, and on a Kubernetes platform that gap matters: restart_deployment is the same @confirm tool whether it targets payments or kube-system, but the blast radius is not remotely the same. An operator who is trusted to bounce an application pod is not thereby trusted to bounce the cluster's DNS.
NamespaceScopeGuard adds that second axis, opt-in via ORRERY_PROTECTED_NAMESPACES (comma-separated fnmatch globs — unset leaves it inert, so existing deployments are unchanged):
The rules, in order:
adminis unrestricted — the role that may already delete things platform-wide gains nothing from a namespace fence.- Reads are never scoped. Diagnosing an incident means looking at infrastructure namespaces; a guard that blocked
describe_podinkube-systemwould break triage while preventing nothing. - A mutating call (
@confirm/@destructive) by a non-admin is checked against the effective namespace: the call'snamespaceargument, or — when omitted — the tool's own signature default, since that is where the call will actually land. - Fail closed when the namespace cannot be resolved: a tool that declares
namespaceas a required parameter but arrives without one, or with a non-string (a list, say, that no glob could match), is refused rather than guessed at.
The two axes compose and are checked in order — role first, then scope — so a denial always names the reason that applies. This is deliberately not modelled as extra roles: adding a "namespace-limited operator" tier would multiply the role table by every scoping dimension, while a guard keeps one question ("may this role act here?") in one place.
Related how-tos¶
- Guardrails & RBAC — author-level reference: decorators, tiers, per-tool overrides, dry-run mode.
- Testing RBAC across surfaces — drive each role from ADK Web, the CLI, Slack, Google Chat, and raw
Runnercode.