---
title: "Building AI Agents for DevOps Part 3: RBAC Who Can Do What"
url: "https://tirraflow.com/posts/building-ai-agents-for-devops-part-3-rbac-who-can-do-what"
author: "taoufiq"
published: "2026-03-17T23:22:25Z"
updated: "2026-10-22T09:38:11Z"
category: "Security"
difficulty: "intermediate"
series: "Building AI Agents for DevOps"
series_part: 3
series_url: "https://tirraflow.com/series/building-ai-agents-for-devops"
tags: ["Agents", "ADK", "Security"]
---

# Building AI Agents for DevOps Part 3: RBAC Who Can Do What

> **Updated for Orrery 0.6.0 (October 2026).** Roles are still inferred from `@confirm` and `@destructive`, exactly as described here. Three layers now sit around them: autonomy levels L2 to L4 (what this deployment may do at all), protected namespaces (opt-in: where non-admins may change things) and, since 0.6.0, impersonation, so Kubernetes and Elasticsearch also enforce their own permissions for the person who asked. Tools without a Python function, such as MCP tools, carry the same labels: see [Part 17](https://tirraflow.com/posts/building-ai-agents-for-devops-part-17-opening-the-platform-without-opening-the-gates).

In [Part 2](https://tirraflow.com/posts/building-ai-agents-for-devops-part-2-from-terminal-to-slack), I brought the DevOps agent into Slack. Now any team member can chat with the agent from a channel. But that raises a question: should a junior engineer be able to delete a Kafka topic? Should an on-call viewer be able to restart pods?

The guardrails from Part 1 ask "are you sure?", but they don't ask "are you _allowed_?". This post adds that missing layer — a role-based access control system that reuses the guardrail decorators I already had.

---

## The Problem

Before RBAC, every user had the same power. The confirmation prompt was the only gate:

```
User: "delete topic events"

Agent: ⚠️ This is a destructive operation. Are you sure?
User: yes
Agent: ✅ Topic deleted.
```

That's fine for a solo developer. In a shared Slack channel with 20 people, it's a liability. I needed a way to say: **viewers can read, operators can mutate, admins can destroy**.

---

## The Key Insight: I Already Classified Every Tool

The guardrail decorators from Part 1 already tell me the risk level of every tool:

```python
# Read-only — no decorator
def list_kafka_topics() -> dict: ...

# Mutating — @confirm
@confirm("creates a new topic on the cluster")
def create_kafka_topic(topic_name: str) -> dict: ...

# Irreversible — @destructive
@destructive("permanently deletes the topic and all its data")
def delete_kafka_topic(topic_name: str) -> dict: ...
```

So instead of creating a separate permission system, I **derived roles from decorators**:

```
No decorator  →  VIEWER can call it    (read-only)
@confirm      →  OPERATOR can call it  (mutating)
@destructive  →  ADMIN can call it     (irreversible)
```

Zero re-annotation. Every existing tool automatically got the right permission level.

---

## Three Roles, One Hierarchy

The role system is intentionally simple — an integer enum:

```python
class Role(IntEnum):
    VIEWER = 0    # read-only tools
    OPERATOR = 1  # + mutating tools (@confirm)
    ADMIN = 2     # + destructive tools (@destructive)
```

Higher roles inherit lower permissions. An ADMIN can do everything an OPERATOR can, which can do everything a VIEWER can. The check is a single comparison:

```python
if user_role >= required_role:
    return None  # authorized, proceed
```

I considered more granular models (per-tool ACLs, resource-level permissions) but they would have added complexity without solving a real problem yet. Three roles cover 90% of DevOps team structures.

---

## How It Works

The entire RBAC system is one callback — `authorize()` — that plugs into the same `before_tool_callback` pipeline as the guardrails:

```python
root_agent = create_agent(
    name="kafka_health_agent",
    tools=[list_kafka_topics, create_kafka_topic, delete_kafka_topic],
    before_tool_callback=[authorize(), require_confirmation()],
    #                      ▲ RBAC first    ▲ then guardrails
)
```

Order matters. When a viewer tries to delete a topic:

```mermaid
flowchart TB
    invoke["User (viewer) calls:<br/>delete_kafka_topic(topic=&quot;events&quot;)"]
    invoke --> auth["Step 1: authorize()"]
    auth --> t1["tool is @destructive → requires ADMIN"]
    t1 --> t2["user is VIEWER"]
    t2 --> blocked["VIEWER &lt; ADMIN → ❌ BLOCKED<br/>“Access denied. 'delete_kafka_topic' requires<br/>'admin' role, but current user has 'viewer' role”"]
    blocked -.->|never reached| confirm["Step 2: require_confirmation()"]

    classDef denied stroke:#dc2626,stroke-width:3px
    classDef skipped stroke:#9ca3af,stroke-dasharray:5 4
    class blocked denied
    class confirm skipped
```

_A viewer calling a destructive tool is blocked at the authorize step, so the confirmation step is never reached_

An operator trying the same thing also gets blocked — `@destructive` requires ADMIN. But an operator _can_ create a topic (`@confirm` → OPERATOR), after confirming:

```mermaid
flowchart TB
    invoke["User (operator) calls:<br/>create_kafka_topic(topic=&quot;orders&quot;)"]
    invoke --> auth["Step 1: authorize()"]
    auth --> t1["tool is @confirm → requires OPERATOR"]
    t1 --> t2["user is OPERATOR"]
    t2 --> pass["OPERATOR ≥ OPERATOR → ✅ pass"]
    pass --> confirm["Step 2: require_confirmation()"]
    confirm --> ask["tool is @confirm → “Are you sure?”"]
    ask --> exec["user confirms → tool executes"]

    classDef ok stroke:#16a34a,stroke-width:3px
    class pass,exec ok
```

_An operator calling a confirm-guarded tool passes the authorize step, is prompted to confirm, and the tool then executes_

---

## Where Does the Role Come From?

The user's role is a simple string in ADK's session state:

```python
session.state["user_role"] = "operator"  # set at session creation
```

The `authorize()` callback reads it on every tool call:

```python
def get_user_role(tool_context: Context) -> Role:
    role_name = tool_context.state.get("user_role", "viewer")
    try:
        return Role[role_name.upper()]
    except (KeyError, AttributeError):
        return Role.VIEWER  # unknown role → least privilege
```

The **integration layer** — not the agent — sets the role. This keeps the agent transport-agnostic. The Slack bot resolves roles from Slack user IDs:

```python
class SlackBotConfig(AgentConfig):
    slack_admin_users: str = ""      # comma-separated Slack user IDs
    slack_operator_users: str = ""

    def resolve_role(self, user_id: str) -> str:
        admins = {u.strip() for u in self.slack_admin_users.split(",") if u.strip()}
        operators = {u.strip() for u in self.slack_operator_users.split(",") if u.strip()}
        if user_id in admins:
            return "admin"
        if user_id in operators:
            return "operator"
        return "viewer"
```

When a new Slack thread starts, the role is injected into the session:

```python
role = self._config.resolve_role(user_id)
session = await self.session_service.create_session(
    app_name="slack_devops",
    user_id=user_id,
    state={"user_role": role},  # ← role set once per thread
)
```

Configuration is two env vars in `agents/slack-bot/.env`:

```bash
SLACK_ADMIN_USERS=U01ABC123,U02DEF456
SLACK_OPERATOR_USERS=U04XYZ789
# everyone else → viewer
```

---

## What It Looks Like in Slack

Here's a real interaction. An operator checks Kafka health (read-only, allowed), then creates a topic (mutating, allowed after confirmation):

![Slack thread: an operator checks Kafka health, then creates a topic after confirming](https://tirraflow.com/uploads/883d394f-3595-4f90-9030-786ba44081ac.jpg)

If a viewer tried the same topic creation, they'd get an access denied message before the confirmation prompt even fires:

![Slack thread: a viewer is denied a topic creation before the confirmation prompt appears](https://tirraflow.com/uploads/108e2429-eabc-4b4c-b828-f519ee4b4ecd.jpg)

---

## A Subtlety: Callbacks Don't Propagate

One thing caught me off guard: ADK's `before_tool_callback` does **not** propagate from parent to child agents. When the root `devops_assistant` delegates to `kafka_health_agent`, only the Kafka agent's own callbacks fire.

This means `authorize()` must be registered on **every agent that has tools**:

```python
# In devops-assistant/agent.py — shared callback instances
_authorize = authorize()

# Every tool-bearing agent gets authorize()
kafka_health_checker = create_agent(
    name="kafka_health_checker",
    tools=[get_kafka_cluster_health, list_kafka_topics, ...],
    before_tool_callback=[_authorize],  # ← must be explicit
    ...
)

docker_agent = create_agent(
    name="docker_agent",
    tools=[list_containers, inspect_container, ...],
    before_tool_callback=[_authorize],  # ← same
    ...
)
```

This is actually a good design — each agent controls its own security boundary. But it's easy to forget when adding new agents, so I documented it as a rule: **when adding a new agent with tools, always include** `authorize()` **in its** `before_tool_callback`.

---

## Overrides: When Inference Isn't Enough

Sometimes the decorator-based inference doesn't match what you want. A `RolePolicy` lets you override per tool:

```python
# This read-only tool contains sensitive data — restrict to operators
policy = RolePolicy(overrides={"get_secrets": Role.OPERATOR})

create_agent(
    ...,
    before_tool_callback=[authorize(policy), require_confirmation()],
)
```

And for tools that don't use `@destructive`/`@confirm` at all, the `@requires_role` decorator sets an explicit minimum:

```python
@requires_role(Role.ADMIN)
def manage_users() -> dict:
    """Admin-only tool that doesn't modify infrastructure."""
    ...
```

---

## What This Doesn't Do (On Purpose)

I intentionally scoped RBAC to be minimal:

| Not included | Why |
| --- | --- |
| **Authentication** | Slack handles identity (OAuth). The agent only cares about the role string, not who set it. |
| **User-role database** | Roles are configured via env vars. A central store would add infrastructure for minimal gain at this scale. |
| **Per-resource permissions** | "User X can delete topic A but not topic B" adds significant complexity. Three roles cover the real-world need. |
| **Audit trail for denials** | Denials are logged via `logging.warning()`. They could feed into the audit system later. |

Each of these could be added later without changing the core design. The `authorize()` callback, the `RolePolicy`, and the session state contract are all extension points.

---

## Testing

The RBAC module has 25 tests covering every combination of role × tool type:

```python
def test_viewer_blocked_from_destructive(self, fake_tool, fake_ctx):
    callback = authorize()
    tool = fake_tool(name="delete_topic", func=_make_func(guard="destructive"))
    ctx = fake_ctx(state={"user_role": "viewer"})

    result = callback(tool=tool, args={}, tool_context=ctx)

    assert result["status"] == "access_denied"
    assert "admin" in result["message"]
    assert "viewer" in result["message"]

def test_admin_can_call_everything(self, fake_tool, fake_ctx):
    callback = authorize()
    for guard in [None, "confirm", "destructive"]:
        tool = fake_tool(name="any_tool", func=_make_func(guard=guard))
        ctx = fake_ctx(state={"user_role": "admin"})
        result = callback(tool=tool, args={}, tool_context=ctx)
        assert result is None  # all allowed
```

Edge cases covered: unknown role strings default to VIEWER, missing state defaults to VIEWER, role comparison is case-insensitive.

---

## The Full Picture

Here's how RBAC fits into the callback pipeline alongside guardrails, audit logging, and activity tracking:

```mermaid
flowchart TB
    invoke["Tool call:<br/>create_kafka_topic(topic=&quot;orders&quot;)"]
    invoke --> auth["authorize()<br/>“Is this user allowed?”<br/>VIEWER ❌ · OPERATOR ✅ · ADMIN ✅"]
    auth --> confirm["require_confirmation()<br/>“Are you sure?”<br/>Posts prompt → user confirms"]
    confirm --> exec["tool executes<br/>create_kafka_topic()"]
    exec --> track["activity_tracker()<br/>Records call in session state"]
    track --> audit["audit_logger()<br/>Logs tool, args, result as JSON"]

    classDef run stroke:#2563eb,stroke-width:3px
    class exec run
```

_The five stages every tool call passes through: authorize, require\_confirmation, execution, activity tracking and audit logging_

Four independent callbacks, each doing one thing, composed in a list. No inheritance, no middleware chain, no framework magic.

---

## What's Next

With RBAC in place, the platform now has identity-aware access control. Next, I turn the monitoring inward: [Part 4](https://tirraflow.com/posts/building-ai-agents-for-devops-part-4-prometheus-metrics) instruments every agent with Prometheus metrics so the platform can observe itself.

---

_The full source code is on_ [_GitHub_](https://github.com/BAHALLA/orrery)_. The RBAC design is documented in_ [_ADR-001_](https://bahalla.github.io/orrery/adr/001-rbac/)_._
