diff --git a/delegated-agent-authorization/0-setup.md b/delegated-agent-authorization/0-setup.md new file mode 100644 index 0000000..74476bc --- /dev/null +++ b/delegated-agent-authorization/0-setup.md @@ -0,0 +1,149 @@ +# Introduction + +In this workshop you will build a DevOps deploy Agent with fine-grained authorization: scoped grants, time-bound windows, instant +revocation, and a permission hierarchy where revoking a base grant cascades to everything that +depends on it. The purpose of the workshop is to understand why fine-grained authorization is required for AI Agents, +and how it can be implemented using ReBAC. + +The `starter/` folder in this repo is stubbed on purpose: the plumbing (MCP +extension, docker-compose, the seed script, the web UI) is already there, and you'll +write the schema and the decision engine yourself across the parts to learn each of the concepts. + +![Architecture diagram of the project](/delegated-agent-authorization/images/fig1-permission-check.svg) + +## Two ways to drive the agent + +As a reminder, you can complete every part in this workshop with just the web UI — no LLM key, no goose +install required. That's the primary path, and it's all you need. + +Each part page also ends with an optional *drive it with goose* step: the same requests in natural language, through a real +LLM, hitting the exact same authorization boundary. Use this if you want to watch the agent work with a live LLM; skip it and you miss nothing, because the authorization decision is identical either way. + +## Get the code + +```bash +git clone https://github.com/authzed/workshops.git +cd workshops/delegated-agent-authorization/starter +``` + +## Installation + +#### Option A - Run locally with Docker + +1. Copy the example `.env` file: + +```bash +cp .env.example .env +``` + +`.env` holds the SpiceDB connection details the app itself needs: endpoint, and preshared-token, +and which agent identity the deploy bot acts as. + +2. Start the infrastructure: + +```bash +docker compose up -d --wait +``` + +This brings up two containers, `postgres` (SpiceDB's datastore) and `spicedb`, plus a +short-lived `spicedb-migrate` container that runs SpiceDB's own datastore migration (setting up +its Postgres tables, not the `schema.zed` you'll write later) and exits. SpiceDB serves +on `localhost:50051` with a preshared key of `devtoken` (not recommended for prod, obviously). + +3. Create a virtual environment and install dependencies: + +```bash +python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt +``` + +#### Option B - Run in GitHub Codespaces + + + +For anyone who can't run Docker locally, Codespaces is the path. This repo's devcontainer config +lives at `delegated-agent-authorization/starter/.devcontainer/devcontainer.json` (nested under +`starter/`, not at the repo root), which most Codespaces-creation flows don't auto-detect. Be +explicit about which folder you're opening: + +1. On the repo page, click **Code ▸ Codespaces ▸ Create codespace on main**. Because the + devcontainer config isn't at the repo root, this may open a plain Codespace at the repo root + with no devcontainer applied, rather than the Python image this workshop expects. +2. Once the Codespace is up, check whether `delegated-agent-authorization/starter/.venv` already + exists. If it does, the devcontainer ran and did its job — skip to step 4. +3. If it doesn't, the devcontainer wasn't picked up automatically. In VS Code, open the Command + Palette and run **Dev Containers: Reopen in Container**, pointing it at + `delegated-agent-authorization/starter` (or open that folder directly and let VS Code prompt + you to reopen in its container). That runs the same `postCreateCommand` — creating `.venv`, + installing dependencies, and running `docker compose up -d --wait` — so the `.venv/bin/python` + path the goose step below relies on exists. +4. Once dependencies are installed and infra is up, `cd delegated-agent-authorization/starter` (if + you're not already there) and copy `.env.example` to `.env` as in Option A. + +Codespaces gets you most of the way there. It isn't zero-config: confirm `.venv` and +`docker compose ps` both look right before moving on, and fall back to the manual Dev Containers +step above if they don't. + +## Install goose and register the extension (optional) + +Installing goose is optional for this workshop. Install goose if you want to drive the agent with natural language +("Deploy checkout to staging") and watch its tool calls resolve through SpiceDB live. + +If you do want the goose path: + +1. Install goose by following the [Agentic AI Foundation goose](https://github.com/aaif-goose/goose) + project's own install instructions, then run `goose configure` to pick an LLM provider and set + its API key — this is where the "LLM key" lives, in goose's own config, not in this project's + `.env`. +2. Register the `deploybot` MCP extension so goose can call this repo's deploy tools. goose launches + `deploybot_server.py` with your virtualenv's Python, so it needs the **absolute path** to both. + From `starter/`, print that path once: + + ```bash + pwd + ``` + + Then run `goose configure` and answer the prompts (exact wording varies slightly by goose + version): + + - **What would you like to configure?** → `Add Extension` + - **What type of extension would you like to add?** → `Command-line Extension` + - **What would you like to call this extension?** → `deploybot` + - **What command should be run?** → your venv Python and the server script, both as absolute + paths — take the `pwd` output above and append `/.venv/bin/python` and `/deploybot_server.py`: + + ``` + /ABSOLUTE/PATH/to/starter/.venv/bin/python /ABSOLUTE/PATH/to/starter/deploybot_server.py + ``` + + - **Please set the timeout for this tool (in secs):** → `300` + - **Would you like to add a description?** → `No` + - **Would you like to add environment variables?** → `Yes`, then add these three (goose asks for + a name, then a value, then "add another?" after each): + + | Name | Value | + | --- | --- | + | `SPICEDB_ENDPOINT` | `localhost:50051` | + | `SPICEDB_TOKEN` | `devtoken` | + | `AGENT_SUBJECT` | `agent:goose_alice` | + + `AGENT_SUBJECT` pins the agent's identity: every authorization check goose triggers runs as + `agent:goose_alice`. goose writes all of this into `~/.config/goose/config.yaml` — see + `goose-extension.md` for the equivalent YAML if you'd rather edit it by hand. + +`goose-extension.md` also has a manual verification checklist for once goose is wired up. Worth +skimming now, but there's nothing to verify yet: SpiceDB has no authorization schema until +Part 2, where you write it and the agent's decisions (via goose or the web UI) first come +online. Part 1 is next, and it drives the agent from the web UI to watch it over-reach. + +--- + +## Completion Milestone: Setup + +- [ ] Cloned the repo +- [ ] Infrastructure is up — Docker (`docker compose up -d --wait`) or Codespaces +- [ ] `.venv` created and dependencies installed — manually in Option A, automatically by the + devcontainer in Option B +- [ ] (Goose path only) goose installed with an LLM provider configured, and the `deploybot` + extension registered per `goose-extension.md` + +Next: [Part 1 — Run the agent](1-run-the-agent.md) diff --git a/delegated-agent-authorization/1-run-the-agent.md b/delegated-agent-authorization/1-run-the-agent.md new file mode 100644 index 0000000..9e7eedb --- /dev/null +++ b/delegated-agent-authorization/1-run-the-agent.md @@ -0,0 +1,127 @@ +# Part 1 — Run the Agent (and Watch It Over-Reach) + +In this section we'll get the deploy agent running, try a few actions and catch it doing something it should +never be allowed to do. For example: this agent can tear down production servers since there are no permission checks to stop it from doing so. + +--- + +## Going Live + +The web UI is how you drive the agent throughout this workshop. It needs no LLM key: it turns your +text into the same tool calls goose would, and hands them to the same gated backend. From +`starter/`, with SpiceDB up: + +```bash +python web.py +``` + +Open `http://127.0.0.1:8000`. Type something reasonable into the request box (or click one of the buttons) + +> Deploy checkout to production. + +The UI calls `deploy(service="checkout", environment="production")`. It comes back +**✅ ALLOWED**, and the version bumps. So far so good. + +Now ask it to do something the agent should **not** be able to decide on its own: + +> Tear down the production environment. + +The UI calls `destroy(environment="production")`. It comes back **✅ ALLOWED**, and production environment is +gone. The tool's own docstring says destroying "requires elevated authority" — but there's no permission check to enforce it. +The stub doesn't look at `permission` or `environment_id` at all, so it can't tell a deploy from a destroy any more than it +can tell staging from production. + +Nothing about the *prompt* told the agent to be reckless. Nothing about the *user's* request was +malicious: "deploy checkout to production" and "tear down the production environment" are both +plausible things an operator might type into a chat window, adversarially or by mistake, or an +agent might decide to do on its own mid-task. This is the consequence of a lack of permission checks before performing an action. + +--- + +## Or drive it with goose (optional) + +If you installed goose and registered the `deploybot` extension in setup, open a session and give +it the same two requests: + +```bash +goose session +``` +And type the following and see what happens: + +> Deploy checkout to production. +> +> Tear down the production environment. + +goose calls the identical `deploy` / `destroy` tools the web UI calls, gated by the identical +stubbed `decide()`, so you get the identical **✅ ALLOWED** both times. Same error caused by a real +LLM instead of the request box. + +--- + +## The backend + +In the backend, the agent calls deploybot. `deploybot_server.py` is a goose MCP extension — MCP (the Model Context Protocol) is the open +standard goose uses to call out to external tools. It exposes three tools: + +- **`list_environments`** — lists every environment and the service versions deployed to it. + It's read-only and, by design, not authorization-checked for this workshop. +- **`deploy(service, environment)`** — deploys a service to an environment. +- **`destroy(environment)`** — tears down an entire environment. Its own docstring says + *"Destructive; requires elevated authority."* No rollback tool exists; destroy is a one-way + door. + +`deploy` and `destroy` are mutating, and both are gated: before either touches anything, it calls +`authz.decide()` to get a ruling, and only proceeds on `ALLOWED`. That's the boundary this +workshop is about. + +--- + +## `decide()` is a deliberate stub + +Open `authz.py` and look for the `decide()` method. It's the one function every mutating tool call goes through, and right +now there are no permission checks. + +```python +async def decide(client, agent_id, permission, environment_id) -> AuthzResult: + # WORKSHOP STUB — Part 1. + # Returns ALLOWED for everything. This is exactly why + # the agent over-reaches in Part 1. You implement the real, SpiceDB-backed + # three-way decision in Part 2. + # TODO(Part 2): replace this stub. + return AuthzResult(Decision.ALLOWED, "no authorization configured (workshop stub)") +``` + +It takes a `client` — a live connection to SpiceDB — and ignores it. Every argument that should +matter (which agent, which permission, which environment) is ignored too. `decide()` always +returns `ALLOWED`. This should obviously not be the case for any production Agent. + +--- + +## Why this happens: ambient authority + +The agent process holds one set of credentials - the `SPICEDB_TOKEN` and `AGENT_SUBJECT` in its +environment. Every tool call runs with the full weight of those credentials behind it. +There's no notion of *this specific action, for this specific reason, scoped to this specific +window*. The agent can do anything its host process could do, because as far as the code is +concerned, there's no difference between "deploy a service" and "destroy production." Both are +just tool calls that return `ALLOWED`. + +This is **ambient authority**: authority that comes along for free with the environment an agent +runs in, rather than being granted for a specific act. + +You might be tempted to fix this by editing the tool's docstring, or telling the agent in its +system prompt "never destroy production without approval." This is an anti-pattern. An authorization boundary has to live *outside* the model's judgment, +in code that runs whether or not the agent "remembers" the rule. That boundary is what Part 2 builds. + +--- + +## Completion Milestone: Part 1 + +- [ ] Ran the agent — via the web UI (`python web.py`), a `goose session`, or both +- [ ] Reproduced the over-reach: `destroy production` returns `ALLOWED` with no schema, no check, + no pause +- [ ] Can point to the exact line in `authz.py` that makes this happen +- [ ] Can explain why ambient authority is the problem, and why fixing it in the prompt wouldn't + be enough + +Next: [Part 2 — Delegated authorization](2-delegated-authorization.md) diff --git a/delegated-agent-authorization/2-delegated-authorization.md b/delegated-agent-authorization/2-delegated-authorization.md new file mode 100644 index 0000000..47dec73 --- /dev/null +++ b/delegated-agent-authorization/2-delegated-authorization.md @@ -0,0 +1,256 @@ +# Part 2 — Delegated Authorization + +Part 1 ended with `authz.decide()` returning `ALLOWED` for everything, so +the agent could destroy production on request. In this section, we replace the stub with the real thing — a +SpiceDB schema that models *who an agent acts for*, and a `decide()` that turns a permission +question into one of three answers: + +- the agent can do this +- a human needs to say yes first +- nobody involved is allowed to do this at all. + +--- + +## Permissions are a graph, not a table + +In the past, the problem of authorization has been solved with techniques such as Role-Based Access Control (permissions granted through roles like “admin” or “editor.”) and Attribute-Based Access Control (decisions based on attributes such as department, geography). However, these techniques are too broad-scoped and don't scale to the requirements of modern AI applications. That's where ReBAC comes in. + +**ReBAC** is relationship-based access control. Instead of a table of roles or attributes, ReBAC models permissions as a graph: subjects, +resources, and the relations between them, with permissions defined as graph traversals over those +relations. This is the model popularized by Google in their 2019 whitepaper [**Zanzibar**](https://research.google/pubs/pub48190/). This is the system that authorizes Google Drive, Docs, and Calendar. In today's workshop we'll use SpiceDB - an open-source implementation of the same ideas. + +SpiceDB stores access relationships as a graph, where nodes represent entities (users, agents, documents) and edges represent relationships (like “viewer,” “editor,” or “owner”). Fundamentally, authorization logic can be reduced to asking a single question: + +> Is this **actor** allowed to perform this **action** on this **resource**? + +Here's the graph you'll build in this part — objects joined by the relations you'll write, with the agent's delegated grant on `staging` highlighted: + +![Relationship graph](/delegated-agent-authorization/images/fig2-relationship-graph.svg) +--- + +## Write the schema + +In SpiceDB parlance, this actor and this resource are both Objects and this action is a Permission or Relation. Any usecase can be represented by using a schema that defines the different objects and the relations between them. + +Open `schema.zed`. Right now it's the Part 1 stub — `environment` has no relations or +permissions at all, so there's nothing for `decide()` to consult even if it wanted to. Replace the +whole file with: + +```zed +definition user {} +definition agent { + relation delegator: user +} +definition environment { + relation direct_deployer: user + relation agent_deployer: agent + relation approver: user + relation destroyer: user + permission deploy = direct_deployer + agent_deployer + permission approve = approver + permission destroy = destroyer +} +``` + +Before walking through it, two bits of SpiceDB syntax. A `relation name: type` line declares a +relation whose *subjects* are of that type — `agent_deployer: agent` reads as "an `agent` may be +wired up as an `agent_deployer` here," not "`agent_deployer` is an agent." And a **relation** differs +from a **permission**: a relation is a stored fact you write into the graph (an edge), while a +permission is computed from relations on every check (with `+`, and later `&` and `->`). + +Let's go through each line: + +- `user` is an empty definition. Humans don't need relations of their own here; they're + subjects that other things point to. +- `agent { relation delegator: user }` is the delegation edge. An agent doesn't hold + authority on its own; it has exactly one relation, `delegator`, pointing at the human it acts + for. Everything about "the agent may do only what its human could" flows from this one edge. +- `environment` is the resource being deployed to, approved for, or destroyed. It has four + relations (`direct_deployer`, `agent_deployer`, `approver`, `destroyer`) and three permissions + computed from them: + - `permission deploy = direct_deployer + agent_deployer` is a **union**. A human wired up as + `direct_deployer` can deploy, and separately, an agent wired up as `agent_deployer` can + deploy. Same permission, two independent ways to earn it — that's the delegation: granting + `agent_deployer` on an environment is what lets an *agent* deploy there without a human doing + it directly. + - `permission approve = approver` is a human-only relation. Nothing gives an agent `approve`, + so agents can never approve their own grants. + - `permission destroy = destroyer` is deliberately its own relation, not folded into `deploy`. + Deploying a service and tearing down an entire environment are different levels of + consequence, so they get different permissions with different holders. + +Notice what's *not* here: nothing grants `agent_deployer` or `destroyer` to anyone yet. The schema +defines what delegation and destruction *mean*; the relationships you write next decide who +actually has them. + +--- + +## Seed the delegation graph + +In a ReBAC system, whenever there's a change to the state of a system (ex: something has been created, updated or deleted), a new relationship is written. Since we're starting the workshop with few assumptions, we will write those relationships to SpiceDB. These are the relationships that are defined in `bootstrap.py`: + +Note that each relationship write is just a simple API call. Run: + +```bash +python bootstrap.py +``` + +`bootstrap.py` writes this `schema.zed` to SpiceDB, then writes the relationships that make the +graph mean something for this workshop: + +- `alice` is `direct_deployer` on **both** `staging` and `production` — she can deploy either + herself, right now, no agent involved. +- `alice` is `approver` on `production`. She's the one who can sign off on the agent's production + requests. +- `sre_admin` is `destroyer` on **both** environments, and is the *only* one. Not even Alice can + destroy — tearing down an environment is scoped to a separate role entirely. +- `goose_alice` (the agent) has `delegator: alice`. It acts for Alice specifically. `decide()` + will read this edge to find out whose authority to fall back on. +- `goose_alice` is `agent_deployer` on `staging` only. That's the one delegated grant this + part hands the agent directly. Staging autonomy, nothing more. + +Nobody made the agent `agent_deployer` on `production`, and nobody made it (or Alice) a +`destroyer` anywhere. Those gaps are intentional — they're what `decide()` is about to expose as +`NEEDS_APPROVAL` and `BLOCKED` instead of silently failing. + +--- + +## Implement `decide()` + +Open `authz.py`. The Part 1 stub ignored every argument and returned `ALLOWED`. Replace it +with the real three-way decision: + +```python +async def decide(client, agent_id, permission, environment_id) -> AuthzResult: + # 1. Agent holds the permission directly → autonomous action allowed + if await check(client, "agent", agent_id, permission, "environment", environment_id): + return AuthzResult( + Decision.ALLOWED, + f"agent:{agent_id} holds delegated '{permission}' on environment:{environment_id}" + ) + + # 2. Agent lacks permission — check if the delegator (human) could do it + delegator = await read_delegator(client, agent_id) + if delegator and await check(client, "user", delegator, permission, "environment", environment_id): + return AuthzResult( + Decision.NEEDS_APPROVAL, + f"agent:{agent_id} lacks '{permission}'; " + f"delegator user:{delegator} holds it — human approval required" + ) + + # 3. Neither agent nor delegator may perform this action + return AuthzResult( + Decision.BLOCKED, + f"neither agent:{agent_id} nor its delegator may '{permission}' environment:{environment_id}" + ) +``` + +`check()` and `read_delegator()` are already provided at the top of `authz.py` — one wraps +`CheckPermission`, the other reads the `agent#delegator` relationship straight off the graph. The +logic is a strict fallthrough, evaluated in this order: + +1. Does the agent itself hold the permission? `check(client, "agent", agent_id, permission, + "environment", environment_id)` asks SpiceDB the same question `deploy = direct_deployer + + agent_deployer` was built to answer: is there a path from `agent:goose_alice` to `deploy` on + `environment:staging`? For staging, yes — `ALLOWED`, and the agent never has to ask anyone. + +2. If not, could the human it acts for do this? `read_delegator()` walks the `delegator` + edge to find `alice`, then runs the identical check as `user:alice`. For production, Alice + holds `deploy` directly (`direct_deployer`) but the agent doesn't, so this branch fires: + `NEEDS_APPROVAL`. The agent isn't blocked outright, because its human unambiguously *could* do + this; it's paused until that human says so explicitly. + +3. If neither holds it, `BLOCKED`. For `destroy` on any environment, the agent has no + `agent_deployer`-style grant (there's no such relation for destroy in this schema, so `check` + on the agent fails), and Alice isn't a `destroyer` either — only `sre_admin` is. Neither side + of the delegation chain has the permission, so there's nothing to escalate to. `BLOCKED` is + final for this call; no amount of retrying changes the answer without someone writing a new + relationship. + +Three inputs, one `CheckPermission`-shaped question asked at most twice, and every branch returns +a reason string that names exactly which relationship justified the answer. + +--- + +## See the three-way decision in action + +### The web UI + +```bash +python web.py +``` + +Open `http://127.0.0.1:8000`. This is a small FastAPI front end. The front end holds no authorization logic of its own - what you see is exactly what SpiceDB decides. It turns your text into a tool call (`deploy(service, environment)` or +`destroy(environment)`) and hands off to the exact same `deploybot_server.do_deploy` / +`do_destroy` functions goose calls, which call your new `decide()` before touching anything. + +The version numbers you see at first paint (`staging: checkout v3, payments v5`, `production: checkout +v2, payments v4`) come from `infra_state.json`, checked into the starter repo as the baseline the +UI resets to. + +Try the three cases: + +- **"Deploy checkout to staging"** → ✅ **ALLOWED**. The agent's own `agent_deployer` grant + covers it; the version bumps immediately. +- **"Deploy checkout to production"** → ⏸️ **NEEDS APPROVAL**. The agent doesn't hold `deploy` on + production, but Alice does — nothing is applied, and the reason names her as the delegator who'd + have to sign off. +- **"Tear down production"** → 🚫 **BLOCKED**. Neither the agent nor Alice is a `destroyer` — + only `sre_admin` is. Nothing to escalate to; production stays up. + +Click **Approve prod · 10m**, then retry the production deploy — it flips to ✅ **ALLOWED**. +Let's see how that works: + +### Approve — the human in the loop + +**Approve prod · 10m** is the approval path. Before writing anything, it runs two checks of its own: is `alice` actually an `approver` on +`production`, and does her `delegator` relationship to the agent still hold `deploy` there? Only if +both pass does it write one relationship: +`environment:production#agent_deployer@agent:goose_alice`. That's SpiceDB's notation for a +relationship — `resource#relation@subject` — and it reads: on `environment:production`, the subject +`agent:goose_alice` holds the `agent_deployer` relation. It's the same relation your schema's +`deploy` permission already unions over. + +That single write is the entire "approval": no new code path, no special-cased branch in `decide()`. The next `decide()` call for +`("goose_alice", "deploy", "production")` hits branch 1 straight away and returns `ALLOWED`, +because the graph now has a direct path. + +### goose (optional, live-LLM path) + +If you registered the goose `deploybot` extension in setup: + +```bash +goose session +``` + +Drive the same three requests in natural language — "Deploy checkout to staging", "Deploy checkout +to production", "Tear down the production environment" — and watch the identical +✅ / ⏸️ / 🚫 verdicts come back, because goose is calling the same `deploybot_server.py` tools the +web UI calls, gated by the same `decide()`. + +--- + +## Why the check — not the prompt — is the boundary + +Notice that there's no system-prompt instruction that says "you may deploy staging but not +production, and never destroy anything." The agent's tool call runs through `deploybot_server._decide_and_mutate`, which calls `decide()` before it ever touches +`infra_state.json` — and `decide()`'s answer is fully determined by `CheckPermission` calls +against a relationship graph the agent has no way to write to. Ask the same question a thousand +different ways, through goose or the web UI or a hand-written script, and you will get the same +`ALLOWED` / `NEEDS_APPROVAL` / `BLOCKED` back every time, because the agent never gets to +*compute* the answer — it only ever receives one that SpiceDB already computed. + +--- + +## Completion Milestone: Part 2 + +- [ ] Wrote `schema.zed` — `agent`, `environment`, and the `deploy` / `approve` / `destroy` + permissions +- [ ] Seeded the graph with `python bootstrap.py` +- [ ] Implemented the three-way `decide()` in `authz.py` +- [ ] Saw all three decisions — `ALLOWED`, `NEEDS_APPROVAL`, `BLOCKED` — in the web UI (and/or + goose), and watched **Approve prod · 10m** flip production to `ALLOWED` +- [ ] Can explain ReBAC in your own words, and why `agent { relation delegator: user }` is what + makes delegation a graph edge instead of a special case in code + +Next: [Part 3 — Time-bound and revocable](3-time-bound-and-revocable.md) diff --git a/delegated-agent-authorization/3-time-bound-and-revocable.md b/delegated-agent-authorization/3-time-bound-and-revocable.md new file mode 100644 index 0000000..47f5b64 --- /dev/null +++ b/delegated-agent-authorization/3-time-bound-and-revocable.md @@ -0,0 +1,177 @@ +# Part 3 — Time-Bound and Revocable + +Part 2 gave the agent a real grant: `goose_alice` is `agent_deployer` on `staging`, forever, +until someone edits the graph by hand. That's already better than ambient authority, but typically you need +time-bound delegation. An incident responder pulled in at 2am should get staging access for the duration of the incident, not a permanent grant nobody +remembers to revoke. Here you make that grant expire on its own, and give an operator a way to kill +it early. + +--- + +## Temporary access for incident windows + +The shape you want is: "this agent may deploy staging, but only for the next 60 minutes." There are two ways +to build that: + +- **A caveat**: SpiceDB lets you attach a boolean expression to a relationship (`agent_deployer: + agent with expiry_check`) and pass in context like `now < grant_expiry` at check time. It works, + but it's you re-deriving "is this timestamp in the past" by hand, on every check. + +- **Relationship expiration**: SpiceDB has a built-in `optional_expires_at` field on a + relationship. You write the expiry once, at grant time, and `CheckPermission` treats an expired + relationship as if it were never written. + +Expiration is the preferred method as it's evaluated **server-side, +inside the same consistent snapshot as the rest of the check**. There's no window where a +just-expired grant still reads as valid because a caveat context object was stale, and no separate +process has to notice the expiry and act on it. SpiceDB's own datastore garbage-collects expired +relationships in the background. The grant simply stops existing, on schedule, without anything +external polling for it. + +--- + +## Schema edit: opt in, then mark the relation + +To add time-bound delegation, first make two changes to `schema.zed`. First, expiration is an opt-in schema feature. Declare it at the top +of the file: + +```zed +use expiration +``` + +Second, mark `agent_deployer` as a relation that can carry an expiration: + +```zed +definition environment { + relation direct_deployer: user + relation agent_deployer: agent with expiration + relation approver: user + relation destroyer: user + permission deploy = direct_deployer + agent_deployer + permission approve = approver + permission destroy = destroyer +} +``` + +The diff is one line at the top of the file and one word — `with expiration` — on the +`agent_deployer` relation. `direct_deployer`, `approver`, and `destroyer` stay exactly as they +were: those grants are still meant to be standing, not temporary, so nothing about them changes. +`permission deploy = direct_deployer + agent_deployer` doesn't change either. The union doesn't +know or care that one side of it can expire. That's the point: expiration is a property of the +*relationship*, not a new code path `decide()` has to special-case. + +--- + +## Update the relationships + +`bootstrap.py` already takes a `--window-minutes` flag and threads it into `seed()`. Part 2 +left the actual staging grant unexpiring; this is where that gets fixed. Import +`expiry_from_now` from `authz` (it builds the protobuf `Timestamp` that `optional_expires_at` +expects) and pass it into the staging `agent_deployer` write: + +```python +# Add this import at the top of bootstrap.py +from authz import expiry_from_now + +# Add this line in seed() method +rel("environment", "staging", "agent_deployer", "agent", AGENT_ID, + expires_at=expiry_from_now(window_minutes)), +``` + +`rel()` (in `relationships.py`) already accepts an `expires_at` keyword and threads it into +`optional_expires_at`. Now every `bootstrap.py` run grants staging autonomy for exactly `window_minutes` from *now*, not +forever. + +--- + +## Update `approve.py` + +The human-in-the-loop path needs the same fix. In `approve.py` import `expiry_from_now` alongside the checks it already runs, and carry it into the write: + +```python +# Add this import at the top of approve.py +from authz import check, read_delegator, expiry_from_now + +# Add this line in the approve() method +update = rel("environment", environment, "agent_deployer", "agent", agent_id, + expires_at=expiry_from_now(minutes)) +``` + +Now clicking **Approve prod · 10m** in the web UI writes a grant that expires 10 minutes later on +its own. There's no follow-up step or a step to undo. + +--- + +## See expiry without waiting + +The web UI has a **Grant staging · 30s** button that hands the agent a 30-second staging window you +can watch expire live. + +> **Reset the datastore first, once.** You just changed `agent_deployer`'s allowed subject type +> from `agent` to `agent with expiration`. SpiceDB won't narrow a relation's allowed types while +> relationships in the old shape — the plain `agent` grants Part 2 seeded — still exist, so +> the first `bootstrap.py` run this part fails on `WriteSchema` before it ever gets to +> reseed. Reset the datastore once, then re-seed against the new schema: +> +> ```bash +> docker compose down -v && docker compose up -d --wait +> python bootstrap.py +> ``` + +Start the web UI (`python web.py`) and open `http://127.0.0.1:8000`. Click **Grant staging · 30s**. +The **grants** panel shows staging's card with a live countdown and a shrinking bar. Watch it hit +zero, then ask the agent to "deploy checkout to staging" — the one request that was unconditionally +✅ **ALLOWED** in Part 2 — and it now comes back ⏸️ **NEEDS APPROVAL** instead, with Alice +named as the delegator who'd have to approve it. Nothing about `decide()` changed. The agent's own +`agent_deployer` check on staging fails because the relationship it was reading simply isn't there +anymore, as far as SpiceDB is concerned: it expired on schedule, server-side, with nothing external +polling for it. + +Now the other lever, instant revocation, for when you don't want to wait for even a 30-second window +to run out. Click **Revoke staging**. The `agent_deployer` relationship on `staging` is deleted +outright, and the staging card disappears from the panel on the next 5-second poll, because +`/api/state`'s `ReadRelationships` no longer returns the grant. Ask the agent to deploy staging +again and you get the identical ⏸️ **NEEDS APPROVAL**, the same outcome as letting the window lapse, +just on your schedule instead of the clock's. An operator who sees something wrong mid-incident +doesn't wait for a TTL; they click one button and the grant is gone on the very next check. + +--- + +## Drive it with goose (optional) + +If you're running the goose path, it also works in natural language. Click **Grant staging · +30s** (or **Revoke staging**), then in a `goose session` ask it to "deploy checkout to staging." +Before the window runs out, ✅ **ALLOWED**; after it expires — or once you've revoked — +⏸️ **NEEDS APPROVAL**. goose is calling the same gated `deploy` tool, so it sees exactly what the +web UI sees. + +--- + +## Why contingent evaluation beats a cron job + +The obvious fix looks like this: keep the grant unexpiring, and run a cron job that deletes +`agent_deployer` relationships older than an hour. This is an anti-pattern. + +A cron-based cleanup means the grant is *actually* valid — checkable, usable, real — for however long it takes the cron job to +notice and catch up, which is never zero. For example: someone deploys at minute 59:58 using a grant that's +"supposed to" be gone. You've also now got a second system that has to run, has to not fail silently, and has to agree with SpiceDB +about what "expired" means. + +Relationship expiration collapses that back to one as the expiry is evaluated *at check time*, inside the same call that's already asking "does this +relationship exist and hold." An expired grant isn't cleaned up later. It was never a valid answer +to begin with, the moment the clock passed `expires_at`. Garbage collection still runs in the +background to reclaim storage. + +--- + +## Completion Milestone: Part 3 + +- [ ] Added `use expiration` to `schema.zed` and marked `agent_deployer: agent with expiration` +- [ ] Updated `bootstrap.py`'s staging seed to carry `expires_at=expiry_from_now(window_minutes)` +- [ ] Updated `approve.py`'s grant write to carry `expires_at=expiry_from_now(minutes)` +- [ ] Clicked **Grant staging · 30s**, watched the countdown hit zero, and saw the agent's staging + autonomy drop to `NEEDS_APPROVAL` on its own — then **Revoke staging** do the same instantly +- [ ] Can explain why expiration evaluated inside `CheckPermission` beats a cron job that deletes + old relationships + +Next: [Part 4 — Relationship-based hierarchy](4-relationship-based-hierarchy.md) diff --git a/delegated-agent-authorization/4-relationship-based-hierarchy.md b/delegated-agent-authorization/4-relationship-based-hierarchy.md new file mode 100644 index 0000000..eef48aa --- /dev/null +++ b/delegated-agent-authorization/4-relationship-based-hierarchy.md @@ -0,0 +1,167 @@ +# Part 4 — Relationship-Based Hierarchy + +Part 3 made every grant time-bound and revocable, but it left each environment answering +for itself. Staging autonomy and production autonomy live as two unrelated facts in the graph — +revoking one has no opinion about the other. That's not how a real deploy pipeline works. + +If the whole point of staging is to catch a bad build before it reaches production, then an agent that's +lost its staging privileges has lost the thing that made its production privileges trustworthy in +the first place. Here you make that dependency real: production's autonomy is *contingent* on +staging's, enforced by the graph. ReBAC makes this pattern very straightforward. + +--- + +## Contingent authority — why RBAC can't express this + +The idea you want is: "the agent may deploy production on its own only while it can also deploy +staging on its own." RBAC can only ever hear the first half of that sentence: "the agent has role +X." What you actually need is "the agent has role X *and* a second, independent fact about a +different resource currently holds." + +ReBAC computes permissions based off relationships expressed in a graph. Expressing a *relation* between the staging and prod server access is just about adding a new relationship and an associated permission. All permissions checks fall into place accordingly. This is one of the strengths of ReBAC - hierarchies and nested permissions are easy to compute. + +--- + +## Schema edit: a relation and an intersection + +Open `schema.zed`. Inside `definition environment`, add one relation and one permission — +`gated_by` and `agent_deploy` — then rewire `deploy` to go through it: + +```zed +definition environment { + relation direct_deployer: user + relation agent_deployer: agent with expiration + relation approver: user + relation destroyer: user + relation gated_by: environment + permission agent_deploy = agent_deployer & gated_by->agent_deployer + permission deploy = direct_deployer + agent_deploy + permission approve = approver + permission destroy = destroyer +} +``` + +Two new pieces, and one rewrite of a permission you already wrote in Part 2: + +- **`relation gated_by: environment`** — an edge from one environment to another. This is new: every + other relation on `environment` so far has pointed at a `user` or `agent`. `gated_by` points at + another `environment` entirely — it's how one resource says "my autonomy answers to that resource + over there." +- **`permission agent_deploy = agent_deployer & gated_by->agent_deployer`** — Read + the right-hand side as two separate questions joined by `&`, an **intersection**: both must hold. + - `agent_deployer` — does the agent hold *this* environment's own delegated grant? Same relation + as before, checked the same way. + - `gated_by->agent_deployer` — the **arrow**. `gated_by` is a relation, not a permission, so the + arrow means: follow every `gated_by` edge from this environment to whatever environment it + points at, and re-ask `agent_deployer` — the *relation*, not `deploy` — over there, for the same + subject. It's a one-hop traversal to a different resource, evaluated fresh on every check. + - Put together: the agent may `agent_deploy` here only if it holds *this* environment's own grant + **and** it holds `agent_deployer` on whatever environment gates this one. `&` is what makes + it contingent instead of additive — either side going empty collapses the whole permission to + empty, immediately, without anyone deleting the other side. +- **`permission deploy = direct_deployer + agent_deploy`** — the Part 2 line was + `direct_deployer + agent_deployer`; the agent's whole way in now runs through `agent_deploy` + instead of the bare relation. A human's `direct_deployer` grant is untouched — Alice can still + deploy either environment herself, gate or no gate. The gate only ever constrains the *agent's* + path. + +Now look at what the seed is about to wire up: staging's `gated_by` will point at *itself*, and +production's `gated_by` will point at *staging*. For staging, `agent_deploy` becomes +`agent_deployer & agent_deployer` on the same environment — redundant with itself, which is exactly +right, because staging is the base of the hierarchy; nothing gates it but its own grant. For +production, `agent_deploy` becomes `agent_deployer(production) & agent_deployer(staging)` — two +independent relationships, on two different resources, both required. + +--- + +## Seed the hierarchy + +Add two relationships to `bootstrap.seed()`, alongside the ones already there: + +```python +# Add this to the seed() method in bootstrap.py +rel("environment", "staging", "gated_by", "environment", "staging"), +rel("environment", "production", "gated_by", "environment", "staging"), +``` + +Re-run it: + +```bash +python bootstrap.py +``` + +The graph now has an edge from `production` to `staging` and a self-edge on `staging`. Nothing +about `direct_deployer`, `approver`, or `destroyer` changes — the gate is scoped entirely to the +agent's delegated path, `agent_deploy`, which is exactly the piece `deploy` now includes instead of +the bare `agent_deployer` relation. + +--- + +## See the cascade + +Start the web UI (`python web.py`) and grant the agent both environments the way Parts 2 and 3 +taught you. Staging is already autonomous from the seed; click **Approve prod · 10m** to give +production its own `agent_deployer` write. + +Confirm both are live, in the web UI or by asking goose to deploy each +environment: staging ✅ **ALLOWED** from its standing grant, production ✅ **ALLOWED** from the +approval you just ran. Two independent relationships, both satisfying `agent_deploy` on their +respective environments. + +Now pull the base out from under it by clicking **Revoke staging**. + +That deletes exactly one relationship, +`environment:staging#agent_deployer@agent:goose_alice`, and nothing else. It does not touch +production. And yet: ask for "deploy checkout to production" again, and it comes back +⏸️ **NEEDS APPROVAL** — the same verdict as if someone had revoked production directly, except +nobody did. `agent_deploy` on production still needs `gated_by->agent_deployer`, that arrow still +points at staging, and staging's `agent_deployer` relationship is gone, so the intersection goes +empty, and `deploy` falls back to the delegator check, same as any other lost grant. One delete, +two environments affected, because the second one was never independent to begin with. + +The web UI shows this precisely: production's grant card stays on screen but turns dashed, tagged +**"suspended · gated by staging"** — the relationship itself is untouched, only what it computes to +has changed. + +--- + +## Drive it with goose (optional) + +If you're running the goose path, watch the cascade in natural language. With both grants live, ask +goose to "deploy checkout to production" — ✅ **ALLOWED**. Click **Revoke staging**, then ask again: +⏸️ **NEEDS APPROVAL**, even though you never touched production. goose deploys through the same gated +`deploy` permission, so `gated_by` suspends its production autonomy exactly as it does for the web +UI. + +--- + +## Suspend, not erase — why this is contingent evaluation + +Look again at what **Revoke staging** actually deleted: + +`environment:staging#agent_deployer@agent:goose_alice`. + +That's just one relationship. The relationship +`environment:production#agent_deployer@agent:goose_alice` — the one **Approve prod · 10m** wrote — is +still sitting in the graph, completely untouched. `agent_deploy` on production went from `ALLOWED` to +`NEEDS_APPROVAL` without a single write touching production's own relationships. Nothing was +deleted there; a permission that used to evaluate `true` now evaluates `false`, because one of the +two facts it depends on changed underneath it. + +--- + +## Completion Milestone: Part 4 + +- [ ] Added `relation gated_by: environment`, `permission agent_deploy = agent_deployer & + gated_by->agent_deployer`, and rewired `permission deploy = direct_deployer + agent_deploy` + in `schema.zed` +- [ ] Seeded the hierarchy in `bootstrap.py` — staging gated by itself, production gated by staging +- [ ] Clicked **Approve prod · 10m**, confirmed both environments `ALLOWED`, then clicked + **Revoke staging** and watched production fall back to `NEEDS_APPROVAL` with no second delete +- [ ] Saw production's grant go dashed/"suspended" in the web UI while the underlying relationship + stayed in the graph +- [ ] Can explain why this is contingent evaluation rather than a cascading delete, and why RBAC + can't express "this role's authority depends on that other role currently holding" without a + role explosion and a synchronization job to keep it honest + +Next: [Next steps](5-nextsteps.md) diff --git a/delegated-agent-authorization/5-nextsteps.md b/delegated-agent-authorization/5-nextsteps.md new file mode 100644 index 0000000..d18a9a9 --- /dev/null +++ b/delegated-agent-authorization/5-nextsteps.md @@ -0,0 +1,59 @@ +# Next Steps + +We started the project with an agent that could destroy production on a whim. Now every call it +makes resolves through a relationship graph: delegated authority, a three-way decision, expiring +grants, and a hierarchy that suspends dependents automatically. This is what Authorization in the world of AI looks like. +What's left is scaling this — how the same shape holds up once you scale to many +agents across many resources. + +--- + +## One schema, not one authorization system per resource + +Nothing about `schema.zed` is specific to deploy agents. `agent { relation delegator: user }` is +the entire idea of delegation, expressed once, as a graph edge: not a column on a `deploys` table or a separate microservice +that re-derives on its own. If you add a new resource type to the platform such as a database or a CI pipeline, you can reuse `direct_deployer` / `agent_deployer` / `gated_by` directly or extend the same pattern by a line or two. + +If you add a new agent it's just one `delegator` edge. You don't have to create a new permission system because the platform added new +resource types. The graph just gains one more kind of node. + +That's the payoff of modeling authority as relationships instead of scattering `if user.role == +"admin"` checks through the codebase: the schema is the single place authority is defined, and +every service that needs an answer asks the same graph the same kind of question. + +## `CheckBulkPermissions` — asking about many resources at once + +The `decide()` method in this workshop asks one question at a time: can this agent do *this* thing to *this* +environment. That's the right shape for a mutating call: `deploy` and `destroy` each act on one +resource. But an agent with a bigger surface area often needs a different question, one worth +asking before it decides what to do next or before a UI renders a list of options: *which of +these N resources may I touch at all?* + +Looping a `CheckPermission` call per resource works, but it's N round trips to answer one +question, and nothing guarantees they're all evaluated against the same snapshot of the graph. +SpiceDB's `CheckBulkPermissions` RPC takes a batch of `(resource, permission, subject)` tuples and +answers all of them in a single call, against one consistent read. `deploybot`'s +`list_environments` (deliberately left ungated in this workshop) is exactly the tool that would +reach for this at scale: instead of "list every environment" as an unchecked read, it becomes "of +these N environments, which does this agent hold `view` on," answered in one round trip instead of +N. + +Note: If you don't already have the candidate list, and want *every* resource a subject can reach +rather than a filter over a known set, `LookupResources` is the streaming counterpart to reach for +instead. + +## Scaling ReBAC with SpiceDB + +The SpiceDB you've been running is in-memory via Docker Compose. This works for a workshop or a proof of concept, but not for production, where you want a durable datastore and a deployment you can lean on. You've got three ways to get there: + +- **SpiceDB, self-hosted (open source)** — run it yourself. You have full control, and it runs on your infrastructure and ops. Works if you're happy managing the database and have specific deployment requirements. +- **[AuthZed Cloud](https://authzed.com/products/authzed-cloud)** — managed, self-service, pay-as-you-go SpiceDB. Provision a permissions system on demand and get enterprise features like audit logging without running anything yourself. The easy on-ramp for startups and growing teams. +- **[AuthZed Dedicated](https://authzed.com/products/authzed-dedicated)** — a fully private, single-tenant deployment in the cloud provider and regions you choose, sold annually. For enterprises that need dedicated infrastructure and geographic or compliance guarantees while offloading the ops. + +--- + +## Resources + +- **SpiceDB documentation**: [authzed.com/docs](https://authzed.com/docs) +- **AuthZed Cloud**: [authzed.com/products/authzed-cloud](https://authzed.com/products/authzed-cloud) +- **Full reference implementation**: [Source Code here](https://github.com/sohanmaheshwar/goose-spicedb-delegation) diff --git a/delegated-agent-authorization/README.md b/delegated-agent-authorization/README.md new file mode 100644 index 0000000..1870f48 --- /dev/null +++ b/delegated-agent-authorization/README.md @@ -0,0 +1,82 @@ +# Delegated Authorization for AI Agents: Build an Agent with Fine-Grained Permissions + +DevOps and Platform teams now work with hundreds of AI Agents in their internal systems. These +Agents usually run with credentials that can touch everything, including your production servers, which is less than ideal. + +This workshop teaches you how to add delegated authorization to your AI Agents, at scale. + +We'll build a DevOps deploy agent on goose, the open-source agent from the Agentic AI Foundation, +and give it fine-grained permissions using Relationship-Based Access Control (ReBAC). Along the +way you'll get hands-on with the Google Zanzibar model behind it, and why it fits AI agents: +scoped delegation, expiring grants, instant revocation, and hierarchical permissions where +revoking staging access automatically suspends production too, something role-based systems +can't express cleanly. + +It's self-guided and hands-on, and everything runs locally with open-source tooling. Here's a high-level diagram of the workshop. + +![Architecture diagram of the project](/delegated-agent-authorization/images/fig1-permission-check.svg) + +--- + +## Why this matters + +An agent process holds one set of credentials, and every tool call it makes runs with the full +weight of those credentials behind it — there's no built-in notion of *this specific action, for +this specific reason, scoped to this specific window*. That's **ambient authority**: authority +that comes along with the environment an agent runs in, rather than being granted for a +specific act. It's the same failure mode as a script running as root because it happened to be +launched by root, not because anyone decided it should have root. + +The instinct to fix this in the prompt (example: "never destroy production without approval") but this is an anti-pattern. +A prompt is a suggestion to a language model, not a control that a system enforces; +it lives in the same channel as everything else the model reads, which means it can be argued +with, reworded around, or bypassed via prompt injection. An authorization +boundary has to live *outside* the model's judgment, in code that runs whether or not the agent +"remembers" the rule. That's what this workshop builds: a decision that's fully determined by a +relationship graph the agent has no way to write to, so the same question gets the same answer no +matter how many different ways — or how many different agents — ask it. + +## What you'll build + +- A goose MCP extension (`deploybot`) exposing three tools — `list_environments`, `deploy`, + `destroy` — against a small deploy-agent backend +- A SpiceDB [ReBAC](https://authzed.com/blog/exploring-rebac) schema + that models delegation directly: an `agent` acts for a `user` through one relation, `delegator` +- A three-way decision engine, `decide()`, that turns every mutating tool call into `ALLOWED`, + `NEEDS_APPROVAL`, or `BLOCKED` +- Time-bound grants that expire on their own, for incident-style access windows, plus instant, + on-demand revocation for when you need to pull a grant early +- A relationship hierarchy (`gated_by`) where revoking one environment's autonomy automatically + suspends what depends on it — contingent evaluation, not a cascading delete +- A web UI that drives every action — request, approve, revoke, watch a grant expire live — and + shows exactly what SpiceDB decides, so every part is confirmable without an LLM in the loop + +## Prerequisites + +- **Docker**, or a GitHub Codespace — the repo ships a `.devcontainer/` that handles setup for you +- **Python 3.10+** +- **goose, plus an API key for any LLM it supports** — only needed if you want to drive the agent + in natural language. The web UI drives every part without an LLM, so goose is optional + throughout. + +No prior authorization background is assumed. Comfort with a terminal, Python, and Docker is +enough. + +## The full reference solution + +Everything you write in this workshop — the schema, `decide()`, the part progression — is a +guided version of a complete, tested implementation: +[`github.com/sohanmaheshwar/goose-spicedb-delegation`](https://github.com/sohanmaheshwar/goose-spicedb-delegation). +Use it if you get stuck, or to see the production-hardening this workshop deliberately skips (more +on that in [Next steps](5-nextsteps.md)). + +## Modules + +0. [Setup](0-setup.md) +1. [Part 1 — Run the agent](1-run-the-agent.md) +2. [Part 2 — Delegated authorization](2-delegated-authorization.md) +3. [Part 3 — Time-bound and revocable](3-time-bound-and-revocable.md) +4. [Part 4 — Relationship-based hierarchy](4-relationship-based-hierarchy.md) +5. [Next steps](5-nextsteps.md) + +Let's get started with [Setup](0-setup.md). diff --git a/delegated-agent-authorization/images/fig1-permission-check.svg b/delegated-agent-authorization/images/fig1-permission-check.svg new file mode 100644 index 0000000..d9e0d14 --- /dev/null +++ b/delegated-agent-authorization/images/fig1-permission-check.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HUMAN OPERATOR + Alice + + + + + + DEVOPS AGENT + acts for Alice + + + + + DELEGATES + + + + PERMISSION CHECK + + + SPICEDB + + + + AUTHORIZATION LAYER + + + + + + + + + + ALLOWED + the agent has this permission + + + + + NEEDS APPROVAL + only its human has this permission + + + + + BLOCKED + no one has this permission + \ No newline at end of file diff --git a/delegated-agent-authorization/images/fig2-relationship-graph.svg b/delegated-agent-authorization/images/fig2-relationship-graph.svg new file mode 100644 index 0000000..1249c13 --- /dev/null +++ b/delegated-agent-authorization/images/fig2-relationship-graph.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + user:alice + + agent:goose_alice + + environment:production + + environment:staging + + + + + + + + + + + + delegator + direct_deployer + agent_deployer + \ No newline at end of file diff --git a/delegated-agent-authorization/starter/.devcontainer/devcontainer.json b/delegated-agent-authorization/starter/.devcontainer/devcontainer.json new file mode 100644 index 0000000..7d6fd11 --- /dev/null +++ b/delegated-agent-authorization/starter/.devcontainer/devcontainer.json @@ -0,0 +1,7 @@ +{ + "name": "delegated-agent-authorization", + "image": "mcr.microsoft.com/devcontainers/python:3.12", + "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {} }, + "postCreateCommand": "cd delegated-agent-authorization/starter && python -m venv .venv && .venv/bin/pip install -r requirements.txt && docker compose up -d --wait", + "forwardPorts": [8000, 50051] +} diff --git a/delegated-agent-authorization/starter/.env.example b/delegated-agent-authorization/starter/.env.example new file mode 100644 index 0000000..28e0e97 --- /dev/null +++ b/delegated-agent-authorization/starter/.env.example @@ -0,0 +1,3 @@ +SPICEDB_ENDPOINT=localhost:50051 +SPICEDB_TOKEN=devtoken +AGENT_SUBJECT=agent:goose_alice diff --git a/delegated-agent-authorization/starter/.gitignore b/delegated-agent-authorization/starter/.gitignore new file mode 100644 index 0000000..d328f57 --- /dev/null +++ b/delegated-agent-authorization/starter/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.env +.venv/ diff --git a/delegated-agent-authorization/starter/approve.py b/delegated-agent-authorization/starter/approve.py new file mode 100644 index 0000000..7f58e66 --- /dev/null +++ b/delegated-agent-authorization/starter/approve.py @@ -0,0 +1,35 @@ +"""approve.py — human-in-the-loop: grant the agent deploy on an environment.""" +import argparse +import asyncio + +from authzed.api.v1 import WriteRelationshipsRequest + +from authz import check, read_delegator +from relationships import rel +from spicedb_client import make_client + + +async def approve(approver: str, environment: str, agent_id: str, minutes: int) -> int: + client = make_client() + if not await check(client, "user", approver, "approve", "environment", environment): + print(f"❌ Refused: user:{approver} is not an approver on environment:{environment}") + return 1 + delegator = await read_delegator(client, agent_id) + if not (delegator and await check(client, "user", delegator, "deploy", "environment", environment)): + who = f"user:{delegator}" if delegator else "(no delegator)" + print(f"❌ Refused: agent:{agent_id}'s delegator {who} may not deploy environment:{environment}") + return 1 + update = rel("environment", environment, "agent_deployer", "agent", agent_id) + await client.WriteRelationships(WriteRelationshipsRequest(updates=[update])) + print(f"✅ Approved: agent:{agent_id} may deploy environment:{environment}") + return 0 + + +if __name__ == "__main__": + p = argparse.ArgumentParser(description="Approve an agent deploy grant.") + p.add_argument("--approver", default="alice") + p.add_argument("--env", default="production") + p.add_argument("--agent", default="goose_alice") + p.add_argument("--minutes", type=int, default=10) + a = p.parse_args() + raise SystemExit(asyncio.run(approve(a.approver, a.env, a.agent, a.minutes))) diff --git a/delegated-agent-authorization/starter/authz.py b/delegated-agent-authorization/starter/authz.py new file mode 100644 index 0000000..44acd4c --- /dev/null +++ b/delegated-agent-authorization/starter/authz.py @@ -0,0 +1,75 @@ +"""authz.py — SpiceDB helpers (provided) + the decision engine (you implement).""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from enum import Enum + +from google.protobuf.timestamp_pb2 import Timestamp +from authzed.api.v1 import ( + CheckPermissionRequest, + CheckPermissionResponse, + Consistency, + ObjectReference, + ReadRelationshipsRequest, + RelationshipFilter, + SubjectReference, +) + + +class Decision(str, Enum): + ALLOWED = "ALLOWED" + NEEDS_APPROVAL = "NEEDS_APPROVAL" + BLOCKED = "BLOCKED" + + +@dataclass +class AuthzResult: + decision: Decision + reason: str + + +def expiry_from_now(minutes: int) -> Timestamp: + """A protobuf Timestamp `minutes` from now, for a relationship's optional_expires_at.""" + ts = Timestamp() + ts.FromDatetime(datetime.now(timezone.utc) + timedelta(minutes=minutes)) + return ts + + +async def check(client, sub_type, sub_id, permission, res_type, res_id) -> bool: + """Does `subject` have `permission` on `resource`? A single SpiceDB CheckPermission.""" + resp = await client.CheckPermission( + CheckPermissionRequest( + consistency=Consistency(fully_consistent=True), + resource=ObjectReference(object_type=res_type, object_id=res_id), + permission=permission, + subject=SubjectReference( + object=ObjectReference(object_type=sub_type, object_id=sub_id) + ), + ) + ) + return resp.permissionship == CheckPermissionResponse.PERMISSIONSHIP_HAS_PERMISSION + + +async def read_delegator(client, agent_id) -> str | None: + """The user this agent acts for (agent:#delegator), or None.""" + req = ReadRelationshipsRequest( + consistency=Consistency(fully_consistent=True), + relationship_filter=RelationshipFilter( + resource_type="agent", + optional_resource_id=agent_id, + optional_relation="delegator", + ), + ) + async for resp in client.ReadRelationships(req): + return resp.relationship.subject.object.object_id + return None + + +async def decide(client, agent_id, permission, environment_id) -> AuthzResult: + # WORKSHOP STUB — Part 1. + # Returns ALLOWED for everything WITHOUT consulting SpiceDB. This is exactly why + # the agent over-reaches in Part 1. You implement the real, SpiceDB-backed + # three-way decision in Part 2. + # TODO(Part 2): replace this stub. + return AuthzResult(Decision.ALLOWED, "no authorization configured (workshop stub)") diff --git a/delegated-agent-authorization/starter/bootstrap.py b/delegated-agent-authorization/starter/bootstrap.py new file mode 100644 index 0000000..c95e6e0 --- /dev/null +++ b/delegated-agent-authorization/starter/bootstrap.py @@ -0,0 +1,58 @@ +"""bootstrap.py — write the schema and seed the delegation graph.""" +import argparse +import asyncio +from pathlib import Path + +from authzed.api.v1 import ( + DeleteRelationshipsRequest, + WriteRelationshipsRequest, + WriteSchemaRequest, +) + +from relationships import agent_deployer_filter, rel +from spicedb_client import make_client + +SCHEMA_PATH = Path(__file__).parent / "schema.zed" +AGENT_ID = "goose_alice" + + +async def write_schema(client) -> None: + await client.WriteSchema(WriteSchemaRequest(schema=SCHEMA_PATH.read_text())) + + +async def _reset_agent_grants(client) -> None: + await client.DeleteRelationships( + DeleteRelationshipsRequest(relationship_filter=agent_deployer_filter(AGENT_ID)) + ) + + +async def seed(client, window_minutes: int = 60) -> None: + await _reset_agent_grants(client) + updates = [ + rel("environment", "staging", "direct_deployer", "user", "alice"), + rel("environment", "production", "direct_deployer", "user", "alice"), + rel("environment", "production", "approver", "user", "alice"), + rel("environment", "staging", "destroyer", "user", "sre_admin"), + rel("environment", "production", "destroyer", "user", "sre_admin"), + rel("agent", AGENT_ID, "delegator", "user", "alice"), + # Part 2: staging-only delegation (no expiration yet). + rel("environment", "staging", "agent_deployer", "agent", AGENT_ID), + ] + await client.WriteRelationships(WriteRelationshipsRequest(updates=updates)) + + +async def main() -> None: + parser = argparse.ArgumentParser(description="Bootstrap the delegated-agent-authorization workshop.") + parser.add_argument("--window-minutes", type=int, default=60, + help="Minutes the agent's staging delegation stays valid (Part 3+).") + args = parser.parse_args() + client = make_client() + print("Writing schema...") + await write_schema(client) + print(f"Seeding delegation graph (window = {args.window_minutes} min)...") + await seed(client, window_minutes=args.window_minutes) + print("Done.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/delegated-agent-authorization/starter/deploybot_server.py b/delegated-agent-authorization/starter/deploybot_server.py new file mode 100644 index 0000000..743e58b --- /dev/null +++ b/delegated-agent-authorization/starter/deploybot_server.py @@ -0,0 +1,98 @@ +"""deploybot_server.py — goose MCP extension; every mutating action is authorized by SpiceDB.""" +import json +import os +from pathlib import Path + +from mcp.server.fastmcp import FastMCP + +from authz import Decision, decide +from spicedb_client import make_client + +AGENT_SUBJECT = os.getenv("AGENT_SUBJECT", "agent:goose_alice") +AGENT_ID = AGENT_SUBJECT.split(":", 1)[-1] +STATE_PATH = Path(os.getenv("INFRA_STATE_PATH", str(Path(__file__).parent / "infra_state.json"))) + +mcp = FastMCP("deploybot") + + +def _load_state() -> dict: + # No infra_state.json yet (fresh checkout, before the web UI's /api/reset has + # ever run) -> treat as "no environments deployed" instead of crashing. + if not STATE_PATH.exists(): + return {} + return json.loads(STATE_PATH.read_text()) + + +def _save_state(state: dict) -> None: + STATE_PATH.write_text(json.dumps(state, indent=2)) + + +def _format(result, action: str) -> str: + icon = { + Decision.ALLOWED: "✅ ALLOWED", + Decision.NEEDS_APPROVAL: "⏸️ NEEDS APPROVAL", + Decision.BLOCKED: "🚫 BLOCKED", + }[result.decision] + return f"{icon} — {action}\n{result.reason}" + + +async def do_list_environments() -> str: + # UNGATED in this workshop: list_environments is not authorization-checked. + state = _load_state() + lines = [ + f"{env}: " + ", ".join(f"{svc} v{ver}" for svc, ver in svcs.items()) + for env, svcs in state.items() + ] + return "\n".join(lines) if lines else "(no environments)" + + +async def _decide_and_mutate(permission, environment, label, apply): + """Authorize `permission` on the environment; on ALLOWED run apply(state) -> (changed, detail), + persist iff changed, and format. On denial, format the reason against `label`.""" + result = await decide(make_client(), AGENT_ID, permission, environment) + if result.decision is not Decision.ALLOWED: + return _format(result, label) + state = _load_state() + changed, detail = apply(state) + if changed: + _save_state(state) + return _format(result, f"{label} ({detail})" if changed else f"{label} — {detail}") + + +async def do_deploy(service: str, environment: str) -> str: + def apply(state): + state.setdefault(environment, {}) + state[environment][service] = state[environment].get(service, 0) + 1 + return True, f"now v{state[environment][service]}" + + return await _decide_and_mutate("deploy", environment, f"deploy {service} -> {environment}", apply) + + +async def do_destroy(environment: str) -> str: + def apply(state): + state.pop(environment, None) + return True, "removed" + + return await _decide_and_mutate("destroy", environment, f"destroy {environment}", apply) + + +@mcp.tool() +async def list_environments() -> str: + """List all deployment environments and their current service versions.""" + return await do_list_environments() + + +@mcp.tool() +async def deploy(service: str, environment: str) -> str: + """Deploy a service to an environment (e.g. 'staging' or 'production').""" + return await do_deploy(service, environment) + + +@mcp.tool() +async def destroy(environment: str) -> str: + """Tear down an entire environment. Destructive; requires elevated authority.""" + return await do_destroy(environment) + + +if __name__ == "__main__": + mcp.run() diff --git a/delegated-agent-authorization/starter/docker-compose.yml b/delegated-agent-authorization/starter/docker-compose.yml new file mode 100644 index 0000000..68dcdc0 --- /dev/null +++ b/delegated-agent-authorization/starter/docker-compose.yml @@ -0,0 +1,49 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: spicedb + POSTGRES_PASSWORD: spicedb + POSTGRES_DB: spicedb + healthcheck: + test: ["CMD-SHELL", "pg_isready -U spicedb"] + interval: 5s + timeout: 5s + retries: 10 + volumes: + - postgres_data:/var/lib/postgresql/data + + spicedb-migrate: + image: authzed/spicedb:latest + command: "migrate head" + environment: + SPICEDB_DATASTORE_ENGINE: postgres + SPICEDB_DATASTORE_CONN_URI: "postgres://spicedb:spicedb@postgres:5432/spicedb?sslmode=disable" + depends_on: + postgres: + condition: service_healthy + restart: on-failure + + spicedb: + image: authzed/spicedb:latest + # Relationship expiration (used by `agent_deployer: agent with expiration`) is a + # built-in feature — no flag needed. + command: "serve" + environment: + SPICEDB_GRPC_PRESHARED_KEY: "${SPICEDB_TOKEN:-devtoken}" + SPICEDB_DATASTORE_ENGINE: postgres + SPICEDB_DATASTORE_CONN_URI: "postgres://spicedb:spicedb@postgres:5432/spicedb?sslmode=disable" + SPICEDB_GRPC_NO_TLS: "true" + SPICEDB_LOG_LEVEL: "info" + ports: + - "50051:50051" + - "8443:8443" + depends_on: + postgres: + condition: service_healthy + spicedb-migrate: + condition: service_completed_successfully + restart: unless-stopped + +volumes: + postgres_data: diff --git a/delegated-agent-authorization/starter/goose-extension.md b/delegated-agent-authorization/starter/goose-extension.md new file mode 100644 index 0000000..9e34c6e --- /dev/null +++ b/delegated-agent-authorization/starter/goose-extension.md @@ -0,0 +1,61 @@ +# Registering the deploybot extension in goose + +## Option A — edit `~/.config/goose/config.yaml` + +Add under `extensions:` (use ABSOLUTE paths to this repo's venv python and server): + +```yaml +extensions: + deploybot: + type: stdio + name: deploybot + enabled: true + cmd: /ABSOLUTE/PATH/delegated-agent-authorization/starter/.venv/bin/python + args: + - /ABSOLUTE/PATH/delegated-agent-authorization/starter/deploybot_server.py + env_keys: [] + envs: + SPICEDB_ENDPOINT: localhost:50051 + SPICEDB_TOKEN: devtoken + AGENT_SUBJECT: agent:goose_alice + timeout: 300 +``` + +## Option B — interactive + +```bash +goose configure +# -> Add Extension -> Command-line Extension +# name: deploybot +# command: /ABSOLUTE/PATH/delegated-agent-authorization/starter/.venv/bin/python /ABSOLUTE/PATH/delegated-agent-authorization/starter/deploybot_server.py +# add env vars: SPICEDB_ENDPOINT, SPICEDB_TOKEN, AGENT_SUBJECT +``` + +> Verify the exact key names (`envs` vs `env_keys`, `type: stdio`) against your installed +> goose version with `goose configure` — the schema has been stable but confirm once. + +## Manually verifying the goose integration + +This step is inherently manual: it drives goose through a live LLM-backed session, which is +outside what an automated test can exercise. Running goose is optional for this workshop — you +do not need it (or an LLM API key) installed. The same decisions are available without goose in the +web UI (`python web.py`), which needs no LLM key: it drives the identical tools through the +identical `decide()`. + +If you do have goose installed and an LLM key configured, here is the checklist to confirm the +wiring end to end: + +```bash +# Ensure SpiceDB is seeded and the extension is registered, then: +goose session +``` + +Drive these prompts and confirm the deploybot tool output: +1. "Deploy checkout to staging." → **✅ ALLOWED**, version bumps. +2. "Deploy checkout to production." → **⏸️ NEEDS APPROVAL**. +3. In the web UI, click **Approve prod · 10m** → then in goose "try the production deploy again" → **✅ ALLOWED**. +4. "Tear down the production environment." → **🚫 BLOCKED**. +5. In the web UI, click **Revoke staging** → then in goose "deploy checkout to staging again" → **⏸️ NEEDS APPROVAL**. + +If goose is not installed / no LLM key is available, drive the identical arc from the web UI +instead (`python web.py`) — same tools, same buttons, same decisions, no LLM. diff --git a/delegated-agent-authorization/starter/infra_state.json b/delegated-agent-authorization/starter/infra_state.json new file mode 100644 index 0000000..214fd22 --- /dev/null +++ b/delegated-agent-authorization/starter/infra_state.json @@ -0,0 +1 @@ +{"staging": {"checkout": 3, "payments": 5}, "production": {"checkout": 2, "payments": 4}} diff --git a/delegated-agent-authorization/starter/relationships.py b/delegated-agent-authorization/starter/relationships.py new file mode 100644 index 0000000..c0a8668 --- /dev/null +++ b/delegated-agent-authorization/starter/relationships.py @@ -0,0 +1,40 @@ +"""relationships.py — shared SpiceDB relationship builders. + +Used by bootstrap (seed + reset), approve (grant), and revoke (delete) so the +TOUCH-relationship shape and the agent_deployer filter live in exactly one place. +""" +from authzed.api.v1 import ( + ObjectReference, + Relationship, + RelationshipFilter, + RelationshipUpdate, + SubjectFilter, + SubjectReference, +) + + +def rel(res_type, res_id, relation, sub_type, sub_id, expires_at=None): + """A TOUCH RelationshipUpdate, optionally carrying a relationship expiration.""" + return RelationshipUpdate( + operation=RelationshipUpdate.OPERATION_TOUCH, + relationship=Relationship( + resource=ObjectReference(object_type=res_type, object_id=res_id), + relation=relation, + subject=SubjectReference( + object=ObjectReference(object_type=sub_type, object_id=sub_id) + ), + optional_expires_at=expires_at, + ), + ) + + +def agent_deployer_filter(agent_id, environment=None): + """Filter matching an agent's agent_deployer grants — one environment, or all if None.""" + return RelationshipFilter( + resource_type="environment", + optional_resource_id=environment or "", + optional_relation="agent_deployer", + optional_subject_filter=SubjectFilter( + subject_type="agent", optional_subject_id=agent_id + ), + ) diff --git a/delegated-agent-authorization/starter/requirements.txt b/delegated-agent-authorization/starter/requirements.txt new file mode 100644 index 0000000..a135f29 --- /dev/null +++ b/delegated-agent-authorization/starter/requirements.txt @@ -0,0 +1,14 @@ +# grpcutil (insecure_bearer_token_credentials) ships inside the authzed wheel — +# it is NOT a separate PyPI package, so do not list it here. +authzed>=0.7.0 +grpcio>=1.50.0 +# mcp 2.0 renamed FastMCP -> MCPServer and changed other APIs; pin to the 1.x +# line so `from mcp.server.fastmcp import FastMCP` (used by deploybot_server.py) +# keeps working. +mcp>=1.2.0,<2.0.0 +# Web demo front end (web.py). uvicorn ships with fastapi's standard extra. +fastapi>=0.110.0 +uvicorn>=0.29.0 +python-dotenv>=1.0.0 +pytest>=8.0.0 +pytest-asyncio>=0.23.0 diff --git a/delegated-agent-authorization/starter/revoke.py b/delegated-agent-authorization/starter/revoke.py new file mode 100644 index 0000000..59a554c --- /dev/null +++ b/delegated-agent-authorization/starter/revoke.py @@ -0,0 +1,27 @@ +"""revoke.py — delete an agent's deploy grant on an environment (operator CLI).""" +import argparse +import asyncio + +from authzed.api.v1 import DeleteRelationshipsRequest + +from relationships import agent_deployer_filter +from spicedb_client import make_client + + +async def revoke(environment: str, agent_id: str) -> int: + client = make_client() + await client.DeleteRelationships( + DeleteRelationshipsRequest( + relationship_filter=agent_deployer_filter(agent_id, environment) + ) + ) + print(f"✅ Revoked: agent:{agent_id} agent_deployer on environment:{environment}") + return 0 + + +if __name__ == "__main__": + p = argparse.ArgumentParser(description="Revoke an agent deploy grant.") + p.add_argument("--env", default="staging") + p.add_argument("--agent", default="goose_alice") + a = p.parse_args() + raise SystemExit(asyncio.run(revoke(a.env, a.agent))) diff --git a/delegated-agent-authorization/starter/schema.zed b/delegated-agent-authorization/starter/schema.zed new file mode 100644 index 0000000..49e2554 --- /dev/null +++ b/delegated-agent-authorization/starter/schema.zed @@ -0,0 +1,12 @@ +// WORKSHOP STUB — you will build this schema up across Parts 2–4. +// It defines the object types but grants the agent nothing. Combined with the +// authz.decide() stub, the deploy agent runs UNGATED in Part 1. +// TODO(Part 2): model delegated authorization here. + +definition user {} + +definition agent { + relation delegator: user +} + +definition environment {} diff --git a/delegated-agent-authorization/starter/spicedb_client.py b/delegated-agent-authorization/starter/spicedb_client.py new file mode 100644 index 0000000..bb3b044 --- /dev/null +++ b/delegated-agent-authorization/starter/spicedb_client.py @@ -0,0 +1,11 @@ +"""spicedb_client.py — construct an async SpiceDB v1 client from the environment.""" +import os + +from authzed.api.v1 import Client +from grpcutil import insecure_bearer_token_credentials + + +def make_client() -> Client: + endpoint = os.getenv("SPICEDB_ENDPOINT", "localhost:50051") + token = os.getenv("SPICEDB_TOKEN", "devtoken") + return Client(endpoint, insecure_bearer_token_credentials(token)) diff --git a/delegated-agent-authorization/starter/static/index.html b/delegated-agent-authorization/starter/static/index.html new file mode 100644 index 0000000..c1d9894 --- /dev/null +++ b/delegated-agent-authorization/starter/static/index.html @@ -0,0 +1,617 @@ + + + + + +goose × SpiceDB — deploybot + + + +
+
+
+ 🪿 + goose × SpiceDB +
+
+
+ + + + + +
+
+ +
+
+ 🪿 + Ask the agent to deploy something.
+ It runs as you — SpiceDB decides what it may actually do. +
+
+ +
+
+
+ + +
+
+
+ + + + diff --git a/delegated-agent-authorization/starter/web.py b/delegated-agent-authorization/starter/web.py new file mode 100644 index 0000000..c7d3706 --- /dev/null +++ b/delegated-agent-authorization/starter/web.py @@ -0,0 +1,206 @@ +"""web.py — a goose-style chat front end for the SpiceDB delegation demo. + +A thin FastAPI shell: it parses a natural-language request into a tool call and +delegates to the SAME code the MCP server uses (deploybot_server.do_*, approve, +revoke, bootstrap) against the live SpiceDB. The front end holds no authorization +logic of its own — what you see is exactly what SpiceDB decides. + +Run: python web.py (then open http://127.0.0.1:8000) +""" +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from fastapi import FastAPI +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from google.protobuf.timestamp_pb2 import Timestamp +from authzed.api.v1 import Consistency, ReadRelationshipsRequest, WriteRelationshipsRequest + +import bootstrap +import deploybot_server +from approve import approve +from authz import check, read_delegator +from relationships import agent_deployer_filter, rel +from revoke import revoke +from spicedb_client import make_client + +BASE = Path(__file__).parent +AGENT_ID = deploybot_server.AGENT_ID +DELEGATOR = "alice" # the human the demo agent acts for +BASELINE_STATE = { + "staging": {"checkout": 3, "payments": 5}, + "production": {"checkout": 2, "payments": 4}, +} + +app = FastAPI(title="deploybot") + +SERVICES = ("checkout", "payments") + + +def parse_intent(text: str): + """Map a natural-language request to (op, service, environment). No LLM needed.""" + t = text.lower() + env = ( + "production" if "prod" in t + else "staging" if ("staging" in t or "stage" in t) + else None + ) + service = next((s for s in SERVICES if s in t), "checkout") + if any(w in t for w in ("destroy", "tear down", "teardown", "delete", "nuke")): + return "destroy", None, env or "production" + if any(w in t for w in ("deploy", "ship", "release", "push")): + return "deploy", service, env or "staging" + if any(w in t for w in ("list", "environment", "status", "what can", "show", "see")): + return "list", None, None + return None, None, None + + +def _decision_of(out: str): + if "NEEDS APPROVAL" in out: + return "NEEDS_APPROVAL" + if "BLOCKED" in out: + return "BLOCKED" + if "ALLOWED" in out: + return "ALLOWED" + return None + + +class RequestBody(BaseModel): + text: str + + +class ApproveBody(BaseModel): + environment: str = "production" + minutes: int = 10 + + +class RevokeBody(BaseModel): + environment: str = "staging" + + +class GrantBody(BaseModel): + environment: str = "staging" + seconds: int = 30 + + +@app.get("/") +async def index(): + return FileResponse(BASE / "static" / "index.html") + + +@app.post("/api/request") +async def request_action(body: RequestBody): + op, service, env = parse_intent(body.text) + if op is None: + return { + "understood": False, + "reply": "I can deploy or destroy a service in staging or " + 'production — or list what you can see. Try "Deploy checkout to staging".', + } + if op == "list": + listing = await deploybot_server.do_list_environments() + return {"understood": True, "op": "list", "tool_call": "list_environments()", + "decision": None, "reply": listing} + + if op == "deploy": + out = await deploybot_server.do_deploy(service, env) + tool_call = f"deploy({service}, {env})" + else: # destroy + out = await deploybot_server.do_destroy(env) + tool_call = f"destroy({env})" + + head, _, reason = out.partition("\n") + _, sep, action = head.partition("— ") + return { + "understood": True, + "op": op, + "tool_call": tool_call, + "decision": _decision_of(out), + "action": action.strip() if sep else head.strip(), + "reason": reason.strip(), + } + + +@app.get("/api/state") +async def state(): + # Defensive throughout: in Part 1 no schema exists yet, so the delegator + # read / relationship read / permission checks below all error. We degrade to an + # empty "no delegation configured" view so the web UI still loads and the chat + # (which routes through the stubbed decide()) shows the agent over-reaching. + client = make_client() + try: + delegator = await read_delegator(client, AGENT_ID) + except Exception: + delegator = None + grants = [] + try: + req = ReadRelationshipsRequest( + consistency=Consistency(fully_consistent=True), + relationship_filter=agent_deployer_filter(AGENT_ID), + ) + async for resp in client.ReadRelationships(req): + r = resp.relationship + env = r.resource.object_id + expires_at = None + if r.HasField("optional_expires_at"): + dt = r.optional_expires_at.ToDatetime().replace(tzinfo=timezone.utc) + expires_at = dt.isoformat() + # A grant tuple can exist yet be suspended by the cascade (e.g. a prod grant + # after staging was revoked). `effective` is the actual deploy verdict. + try: + effective = await check(client, "agent", AGENT_ID, "deploy", "environment", env) + except Exception: + effective = False + grants.append({"environment": env, "expires_at": expires_at, "effective": effective}) + except Exception: + grants = [] # no schema yet (Part 1) + try: + versions = deploybot_server._load_state() + except Exception: + versions = {} + return {"agent": AGENT_ID, "delegator": delegator, "grants": grants, "versions": versions} + + +@app.post("/api/approve") +async def approve_action(body: ApproveBody): + code = await approve(DELEGATOR, body.environment, AGENT_ID, body.minutes) + return {"ok": code == 0, "environment": body.environment, "minutes": body.minutes} + + +@app.post("/api/revoke") +async def revoke_action(body: RevokeBody): + code = await revoke(body.environment, AGENT_ID) + return {"ok": code == 0, "environment": body.environment} + + +@app.post("/api/grant-short") +async def grant_short(body: GrantBody): + """Grant the agent a short-lived deploy window so you can watch it expire live in the + authority bar (used in Part 3). An operator/demo action: it writes the + agent_deployer grant directly with a seconds-scale expiration. Requires the schema to + allow expiration (`agent_deployer: agent with expiration`, from Part 3).""" + client = make_client() + ts = Timestamp() + ts.FromDatetime(datetime.now(timezone.utc) + timedelta(seconds=body.seconds)) + update = rel("environment", body.environment, "agent_deployer", "agent", AGENT_ID, expires_at=ts) + try: + await client.WriteRelationships(WriteRelationshipsRequest(updates=[update])) + return {"ok": True, "environment": body.environment, "seconds": body.seconds} + except Exception as e: + return {"ok": False, "error": str(e)[:200]} + + +@app.post("/api/reset") +async def reset(): + client = make_client() + await bootstrap.write_schema(client) + await bootstrap.seed(client, window_minutes=60) + deploybot_server._save_state(dict(BASELINE_STATE)) + return {"ok": True} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="127.0.0.1", port=8000)