From 8232aecd779df68a756d78b1d7d5f89561309347 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:07:36 +0200 Subject: [PATCH 01/27] Design spec: Delegated Authorization for AI Agents workshop --- ...ted-agent-authorization-workshop-design.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-delegated-agent-authorization-workshop-design.md diff --git a/docs/superpowers/specs/2026-08-04-delegated-agent-authorization-workshop-design.md b/docs/superpowers/specs/2026-08-04-delegated-agent-authorization-workshop-design.md new file mode 100644 index 0000000..18c1c8f --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-delegated-agent-authorization-workshop-design.md @@ -0,0 +1,194 @@ +# Delegated Authorization for AI Agents — Workshop Design + +**Date:** 2026-08-04 +**Status:** Approved design, ready for implementation plan +**Folder:** `delegated-agent-authorization/` (in `authzed/workshops`) +**Branch:** `workshop/delegated-agent-authorization` + +## What this is + +A self-guided, hands-on workshop that teaches technical attendees how to add +**delegated, fine-grained authorization** to AI agents. Attendees build a DevOps +deploy agent on **goose** (the open-source agent from the Agentic AI Foundation) and +gate its every action with **SpiceDB** using **Relationship-Based Access Control +(ReBAC)**. By the end they understand scoped delegation, time-bound (expiring) grants, +instant revocation, and hierarchical permissions where revoking a base grant cascades +to dependents — something role-based systems can't express cleanly. + +Delivered first at a conference (Fri Sep 11, 2026, JST), then published to +`authzed/workshops`. + +## Audience, duration, learning goals + +- **Audience:** technical — AI engineers and programmers. Comfortable with a terminal, + Python, Docker. No prior authorization background assumed. +- **Duration:** 90 minutes, self-guided (works both live and after the fact). +- **Learning goals:** by the end, an attendee can explain and has hands-on built: + 1. **Delegated authorization** — an agent acts on a human's behalf with a scoped + subset of their authority; decisions resolve to ALLOWED / NEEDS APPROVAL / BLOCKED. + 2. **Time-bound expiration** — grants that expire on their own (incident windows), + plus instant revocation. + 3. **ReBAC / hierarchy** — relationship- and hierarchy-aware permissions, incl. a + cascade (revoking staging autonomy suspends production autonomy automatically). + +## Format (follows the reference workshop) + +Matches `agentic-rag-authorization`: a `starter/` app with intentionally-stubbed +authorization pieces, checkpoint markdown files that go *run it → watch it fail → +implement the fix → re-run → why this is the right design*, each ending in a +**Completion Milestone** checklist, plus a setup doc (local Docker **and** a Codespaces +devcontainer) and a web UI to see decisions live. + +**Pedagogy — guided implement:** attendees write the two pedagogically valuable things +themselves from `# TODO(Checkpoint N):` stubs — **the SpiceDB schema** (grown across +CP2→CP4) and **the `decide()` decision engine** (CP2). Everything else is provided +plumbing: the goose MCP extension, docker-compose, seed harness, approve/revoke scripts, +web UI, devcontainer. The exact code-to-write is embedded in each checkpoint (the +reference's own pattern). The complete reference solution is the published +`github.com/sohanmaheshwar/goose-spicedb-delegation` repo, linked from the workshop. + +**Two ways to see every checkpoint:** goose is the headline — attendees register the +MCP extension and talk to it in natural language ("Deploy checkout to production"). The +**web UI** + `python scripts/verify.py` are the deterministic verifier, so every +checkpoint is confirmable in a room without depending on an LLM key. The deterministic +path never needs an LLM. + +## Artifact structure + +``` +delegated-agent-authorization/ + README.md # overview, "what you'll build", prereqs, 90-min module map, solution link + 0-setup.md # Docker + Codespaces devcontainer, register goose extension, seed, verify + 1-run-the-agent.md # CP1: run the UNGATED agent, watch it over-reach + 2-delegated-authorization.md # CP2: write delegation schema + implement decide() (3-way); ReBAC concepts + 3-time-bound-and-revocable.md # CP3: expiring grants (incident window) + instant revoke + 4-relationship-based-hierarchy.md # CP4: gated_by cascade — revoke staging suspends prod + 5-nextsteps.md # scale: ReBAC in prod, CheckBulk, on-behalf-of, real platform teams + starter/ + docker-compose.yml # postgres + spicedb (+ expiration flag) + schema.zed # STUB — grown by learners across CP2→CP4 + authz.py # decide() + helpers STUB — implemented in CP2 + bootstrap.py # seed harness (grant-writing evolves with the schema) + deploybot_server.py # goose MCP extension — provided plumbing; calls decide() + spicedb_client.py # provided + relationships.py # provided TOUCH/filter helpers + approve.py, revoke.py # provided lifecycle scripts + web.py, static/index.html # provided web UI (chat + authority bar + decision states) + scripts/verify.py # per-checkpoint deterministic verifier + requirements.txt, .env.example + goose-extension.md # how to register the extension in goose + .devcontainer/devcontainer.json # Codespaces + images/ # authority-chain + decision-flow diagrams +``` + +## The checkpoint arc (the schema grows as they learn) + +Each checkpoint adds exactly one concept by editing the schema and re-running. The +naive → fix → why loop drives every one. + +### CP1 — Run it, watch it over-reach (~10 min) +`decide()` is a stub returning ALLOWED for everything (clear `# TODO(Checkpoint 2)` +docstring warning, mirroring the reference). Attendees register the extension, talk to +goose, and watch it deploy to production and tear down environments with no guardrail — +all green in the web UI. Takeaway: an agent runs with its host's ambient authority; with +no authorization boundary it can do anything the credentials can. + +### CP2 — Delegated authorization (~25 min, includes ReBAC concepts) +Attendees **write `schema.zed`**: +```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 +} +``` +and **implement `decide()`**: check the agent for the permission → ALLOWED; else read the +agent's `delegator` and check the delegator → NEEDS APPROVAL (a human could authorize); +else BLOCKED. Seed grants the agent staging-only deploy. Results: staging ✅, production +⏸ (with the `approve.py` human-in-the-loop flow), destroy 🚫. ReBAC and the Google +Zanzibar model are taught inline here (relationships, not flat roles; why the decision +lives in a deterministic check, not the prompt). + +### CP3 — Time-bound & revocable (~15 min) +Attendees add expiration to the schema (`use expiration`; +`agent_deployer: agent with expiration`) and grants carry `optional_expires_at`. +Demonstrate the incident window and — because it's contingent evaluation — show expiry +without waiting (`bootstrap.py --window-minutes 0`), then instant `revoke.py`. Note this +uses SpiceDB's built-in relationship expiration (preferred over a caveat for expiry). + +### CP4 — Relationship-based hierarchy (~20 min) +Attendees add the cascade to the schema: +```zed +relation gated_by: environment // production -> staging; staging -> itself +permission agent_deploy = agent_deployer & gated_by->agent_deployer +permission deploy = direct_deployer + agent_deploy +``` +Production autonomy becomes contingent on staging autonomy. Revoke staging → production +`agent_deploy` evaluates false automatically, no second delete. This is the payoff: the +thing RBAC can't express cleanly. The web UI shows the suspended production grant as a +dashed chip. + +### 5 — Next steps (~5 min) +How this maps to a real platform team: modeling authority once as relationships, +`CheckBulk` for "which of these N resources may this agent touch", on-behalf-of vs. +advisory enforcement, and scaling ReBAC with SpiceDB (Zanzibar lineage). Links to the +full solution repo and SpiceDB docs. + +## Verification + +- `starter/scripts/verify.py` asserts the expected decision matrix for the **current** + checkpoint (so a learner confirms their schema/`decide()` is correct before moving on), + mirroring the reference's `verify_permissions.py`. It runs against live SpiceDB, no LLM. +- The web UI shows each decision (ALLOWED / NEEDS APPROVAL / BLOCKED) and the live + authority state (delegation chain + expiry countdown). +- goose provides the natural-language "for real" path. + +## Prerequisites (stated in README/setup) + +- Docker (or a GitHub Codespace via the provided devcontainer) +- Python 3.10+ +- goose installed, plus an API key for any LLM goose supports (only needed for the + goose path; the deterministic verifier + web UI need no LLM) + +## Decisions & defaults + +- **Language:** Python (matches the demo and the authzed/langchain workshop stack). +- **SpiceDB:** local via Docker Compose, `authzed/spicedb:latest`, insecure preshared key + (`somerandomkeyhere` or `devtoken`), expiration enabled via + `--enable-experimental-relationship-expiration`. +- **Solution source:** adapt the tested `goose-spicedb-delegation` repo — the `starter/` + is that code with `schema.zed` and `authz.decide()` stubbed; checkpoints contain the + exact code to fill in. +- **Provided vs. implemented:** implemented by learner = schema + `decide()`; provided = + everything else. + +## Non-goals + +- Not a goose internals or prompt-engineering workshop. +- Not production deployment of SpiceDB (Kubernetes/Operator) — that's a different + workshop; touched only in Next Steps. +- No cloud accounts or paid services beyond an optional LLM key. +- Not multi-agent fleets (mentioned in Next Steps only). + +## To verify at build time + +- Exact goose custom-extension config (config.yaml stanza / `goose configure`) — reuse + the demo's verified `goose-extension.md`. +- Codespaces devcontainer that runs `docker compose up -d` + installs deps on create. +- `scripts/verify.py` output format and the per-checkpoint assertions matching each + schema state. + +## Success criteria + +- A learner starting from a clean clone completes setup, then all four checkpoints, with + `scripts/verify.py` passing at each, on Docker or Codespaces. +- CP1 visibly over-reaches; CP2 produces the three-way decision; CP3 shows expiry + + revoke; CP4 shows the staging→production cascade. +- The goose path and the web-UI/CLI path both demonstrate each checkpoint's behavior. +- Total content paces to ~90 minutes. From 5a53d9ef454263b2a1752919e6f47b16c8f7f004 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:18:38 +0200 Subject: [PATCH 02/27] Implementation plan: Delegated Authorization for AI Agents workshop --- ...026-08-04-delegated-agent-authorization.md | 620 ++++++++++++++++++ 1 file changed, 620 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-delegated-agent-authorization.md diff --git a/docs/superpowers/plans/2026-08-04-delegated-agent-authorization.md b/docs/superpowers/plans/2026-08-04-delegated-agent-authorization.md new file mode 100644 index 0000000..22e034e --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-delegated-agent-authorization.md @@ -0,0 +1,620 @@ +# Delegated Authorization for AI Agents — Workshop Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a self-guided, 90-minute hands-on workshop where attendees add delegated, fine-grained authorization (SpiceDB/ReBAC) to a goose DevOps deploy agent — learning scoped delegation, expiring grants, revocation, and hierarchical/cascading permissions. + +**Architecture:** A `starter/` app (goose MCP extension + web UI + SpiceDB via Docker) with two pieces stubbed — `schema.zed` and `authz.decide()`. Four checkpoint markdown files walk attendees through *run → watch it fail → implement → re-run → why*, growing the schema and implementing the decision engine. Provided plumbing is copied (with modifications) from the tested solution repo; the exact fill-in code lives in the checkpoints. + +**Tech Stack:** Python 3.10+, goose (MCP), SpiceDB (Docker Compose), FastAPI web UI, `authzed` async client, pytest-style `verify.py`. + +**Spec:** `docs/superpowers/specs/2026-08-04-delegated-agent-authorization-workshop-design.md` + +**Solution source (tested, on disk):** `/Users/sohan/code-samples/goose-spicedb-delegation/` — the finished reference. Provided files are copied from here with the modifications each task specifies. + +## Global Constraints + +- **Location:** everything under `delegated-agent-authorization/` in the `authzed/workshops` repo, on branch `workshop/delegated-agent-authorization`. +- **SpiceDB token:** `devtoken` everywhere (docker-compose `SPICEDB_GRPC_PRESHARED_KEY`, `.env.example` `SPICEDB_TOKEN`, `spicedb_client` default). This differs from the solution repo's `somerandomkeyhere` — change it on copy. +- **SpiceDB image/flags:** `authzed/spicedb:latest`, `serve --enable-experimental-relationship-expiration`, `SPICEDB_GRPC_NO_TLS=true`, endpoint `localhost:50051`, postgres datastore. +- **Python 3.10+.** Provided modules keep the solution's async `authzed.api.v1.Client` patterns (all calls awaited). +- **Agent identity:** `agent:goose_alice`, delegator `user:alice`. +- **Tools exposed by the extension (trimmed for teaching):** `list_environments`, `deploy`, `destroy`. (The solution also has `rollback`; omit it here to reduce surface — note this in Next Steps.) +- **`decide()` signature (workshop, simpler than the solution):** `async def decide(client, agent_id, permission, environment_id) -> AuthzResult` — no `action` parameter. +- **`revoke.py` is an ungated operator CLI in the workshop** (no `manage`/`view` permissions — those are solution-only hardening). Call this out in Next Steps as a production follow-up. +- **`list_environments` is ungated** in the workshop (no `view` permission). Next-Steps follow-up. +- **Build-time:** only one SpiceDB can bind `localhost:50051`. Before testing, ensure no other local SpiceDB (e.g. the solution repo's) is running: `docker ps --filter publish=50051`. +- **Checkpoint stub convention:** stubbed files carry a `WORKSHOP STUB` docstring and `# TODO(Checkpoint N):` markers, matching the reference workshop. +- **Match the reference workshop's tone/structure** for all markdown: read `../agentic-rag-authorization/{0-setup,1-agentic-rag,2-secure-it,3-nextsteps}.md` before authoring. Declarative headings; each checkpoint ends with a `## Completion Milestone` checkbox list; both a goose path and a deterministic (`verify.py` + web UI) path. + +--- + +### Task 1: Scaffold folder, provided plumbing, CP1 stubs, and verify.py + +**Files:** +- Create dir: `delegated-agent-authorization/starter/` +- Copy (with mods below) from solution → `starter/`: `docker-compose.yml`, `spicedb_client.py`, `relationships.py`, `deploybot_server.py`, `web.py`, `static/index.html`, `requirements.txt`, `.env.example`, `goose-extension.md` +- Create: `starter/schema.zed` (CP1 stub), `starter/authz.py` (provided helpers + `decide()` stub), `starter/bootstrap.py`, `starter/approve.py`, `starter/revoke.py`, `starter/scripts/verify.py`, `starter/.devcontainer/devcontainer.json` + +**Interfaces:** +- Produces (provided, used by later tasks and the extension): + - `authz.check(client, sub_type, sub_id, permission, res_type, res_id) -> bool` + - `authz.read_delegator(client, agent_id) -> str | None` + - `authz.expiry_from_now(minutes) -> Timestamp` + - `authz.Decision` (str Enum ALLOWED/NEEDS_APPROVAL/BLOCKED), `authz.AuthzResult(decision, reason)` + - `authz.decide(client, agent_id, permission, environment_id) -> AuthzResult` (STUB in CP1) + - `bootstrap.write_schema(client)`, `bootstrap.seed(client, window_minutes=60)`, `bootstrap.AGENT_ID="goose_alice"` + - `deploybot_server.do_list_environments/do_deploy/do_destroy`, `STATE_PATH`, `AGENT_ID` + - `spicedb_client.make_client()` + +- [ ] **Step 1: Create the folder and copy provided files with modifications** + +Copy these from `/Users/sohan/code-samples/goose-spicedb-delegation/` into `starter/`, applying mods: +- `spicedb_client.py` — change token default `somerandomkeyhere` → `devtoken`. +- `relationships.py` — copy verbatim (`rel(...)`, `agent_deployer_filter(...)`). +- `docker-compose.yml` — copy; set `SPICEDB_GRPC_PRESHARED_KEY: "${SPICEDB_TOKEN:-devtoken}"`; keep `serve --enable-experimental-relationship-expiration`. +- `deploybot_server.py` — copy, then: remove the `do_rollback`/`rollback` tool and its wrapper; remove the `check`-based gating in `do_list_environments` (make it ungated — just list all envs); ensure imports are `from authz import Decision, decide` (drop `check`); each mutating tool calls `decide(make_client(), AGENT_ID, "deploy"|"destroy", environment)` and mutates only on ALLOWED (keep the solution's `_decide_and_mutate` helper but drop the `action=` argument to match the workshop `decide()` signature). +- `web.py` — copy, then in `/api/state` compute `effective = await check(client, "agent", AGENT_ID, "deploy", environment)` (NOT `agent_deploy` — `deploy` routes through the cascade automatically once CP4 rewires it, and exists from CP2). Keep the delegation/expiry reads. Remove any `agent_deploy` references. +- `static/index.html` — copy verbatim (includes the walking-goose indicator and authority bar). +- `requirements.txt` — copy (authzed, grpcio, mcp, fastapi, uvicorn, python-dotenv, pytest, pytest-asyncio). +- `.env.example` — set `SPICEDB_TOKEN=devtoken`, `SPICEDB_ENDPOINT=localhost:50051`, `AGENT_SUBJECT=agent:goose_alice`. +- `goose-extension.md` — copy; update the absolute-path examples to `.../delegated-agent-authorization/starter/...`. + +- [ ] **Step 2: Write `starter/schema.zed` (CP1 stub)** + +```zed +// WORKSHOP STUB — you will build this schema up across Checkpoints 2–4. +// It defines the object types but grants the agent nothing. Combined with the +// authz.decide() stub, the deploy agent runs UNGATED in Checkpoint 1. +// TODO(Checkpoint 2): model delegated authorization here. + +definition user {} + +definition agent { + relation delegator: user +} + +definition environment {} +``` + +- [ ] **Step 3: Write `starter/authz.py` (provided helpers + `decide()` stub)** + +```python +"""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 — Checkpoint 1. + # Returns ALLOWED for everything WITHOUT consulting SpiceDB. This is exactly why + # the agent over-reaches in Checkpoint 1. You implement the real, SpiceDB-backed + # three-way decision in Checkpoint 2. + # TODO(Checkpoint 2): replace this stub. + return AuthzResult(Decision.ALLOWED, "no authorization configured (workshop stub)") +``` + +- [ ] **Step 4: Write `starter/bootstrap.py`** + +Copy the solution's `bootstrap.py` structure, but the seed is the CP2 set with NO expiration and NO gated_by yet (those are added by CP3/CP4 checkpoints): + +```python +"""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"), + # Checkpoint 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 (Checkpoint 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()) +``` + +Note: `seed()` ignores `window_minutes` until Checkpoint 3 (added there). `_reset_agent_grants` makes reseeding idempotent. + +- [ ] **Step 5: Write `starter/approve.py` (CP2 version — no expiry) and `starter/revoke.py` (ungated)** + +`approve.py` (CP2 — writes an `agent_deployer` grant with NO expiration; Checkpoint 3 upgrades it): +```python +"""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))) +``` + +`revoke.py` (ungated operator CLI, unchanged across checkpoints): +```python +"""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))) +``` + +- [ ] **Step 6: Write `starter/scripts/verify.py` (per-checkpoint deterministic verifier)** + +```python +"""verify.py — deterministic checks for the current checkpoint. No LLM needed. + +Usage: python scripts/verify.py --checkpoint N +Run from the starter/ directory with SpiceDB up. +""" +import argparse +import asyncio +import sys + +from authz import Decision, decide +import bootstrap +from approve import approve +from revoke import revoke +from spicedb_client import make_client + +AGENT = "goose_alice" + + +def _ok(label, got, want): + mark = "✅" if got == want else "❌" + print(f" {mark} {label}: got {got}, want {want}") + return got == want + + +async def checkpoint_1(): + # The stub decides ALLOWED for everything — the over-reach. + r = await decide(make_client(), AGENT, "destroy", "production") + return _ok("stub allows destroy production (over-reach)", r.decision, Decision.ALLOWED) + + +async def checkpoint_2(client): + await bootstrap.write_schema(client) + await bootstrap.seed(client) + passed = True + passed &= _ok("agent deploy staging", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.ALLOWED) + passed &= _ok("agent deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.NEEDS_APPROVAL) + passed &= _ok("agent destroy production", (await decide(client, AGENT, "destroy", "production")).decision, Decision.BLOCKED) + await approve("alice", "production", AGENT, 10) + passed &= _ok("after approve: deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.ALLOWED) + return passed + + +async def checkpoint_3(client): + # Expired window -> staging autonomy gone (falls back to NEEDS_APPROVAL). + await bootstrap.write_schema(client) + await bootstrap.seed(client, window_minutes=0) + passed = _ok("expired staging grant", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.NEEDS_APPROVAL) + # Revocation on a fresh window. + await bootstrap.seed(client, window_minutes=60) + await revoke("staging", AGENT) + passed &= _ok("after revoke: deploy staging", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.NEEDS_APPROVAL) + return passed + + +async def checkpoint_4(client): + await bootstrap.write_schema(client) + await bootstrap.seed(client, window_minutes=60) + await approve("alice", "production", AGENT, 10) + passed = _ok("with both grants: deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.ALLOWED) + await revoke("staging", AGENT) # revoke the BASE + passed &= _ok("cascade: deploy staging", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.NEEDS_APPROVAL) + passed &= _ok("cascade: deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.NEEDS_APPROVAL) + return passed + + +async def main(n): + print(f"Verifying Checkpoint {n}...") + if n == 1: + passed = await checkpoint_1() + else: + client = make_client() + passed = await {2: checkpoint_2, 3: checkpoint_3, 4: checkpoint_4}[n](client) + print("PASS ✅" if passed else "FAIL ❌") + return 0 if passed else 1 + + +if __name__ == "__main__": + p = argparse.ArgumentParser() + p.add_argument("--checkpoint", type=int, required=True, choices=[1, 2, 3, 4]) + raise SystemExit(asyncio.run(main(p.parse_args().checkpoint))) +``` + +- [ ] **Step 7: Write `starter/.devcontainer/devcontainer.json`** + +```json +{ + "name": "delegated-agent-authorization", + "image": "mcr.microsoft.com/devcontainers/python:3.12", + "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {} }, + "postCreateCommand": "pip install -r requirements.txt && docker compose up -d", + "forwardPorts": [8000, 50051] +} +``` + +- [ ] **Step 8: Smoke test — infra up + CP1 over-reach** + +Run from `starter/` (ensure nothing else holds :50051): +```bash +python -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt +docker compose up -d --wait +python -c "import deploybot_server, web, authz, bootstrap, approve, revoke" # imports resolve +python scripts/verify.py --checkpoint 1 +``` +Expected: imports succeed; verify prints `✅ stub allows destroy production (over-reach)` and `PASS ✅`. + +- [ ] **Step 9: Commit** + +```bash +cd ~/authzed-branches/workshops +git add delegated-agent-authorization/starter +git commit -m "feat(workshop): starter scaffolding, provided plumbing, CP1 stubs, verify.py" +``` + +--- + +### Task 2: `0-setup.md` + +**Files:** Create `delegated-agent-authorization/0-setup.md` + +- [ ] **Step 1: Author the setup doc** — model it on `../agentic-rag-authorization/0-setup.md`. Cover, in this order: + 1. One-paragraph framing: build a goose deploy agent, then add delegated authorization with SpiceDB; the `starter/` is stubbed on purpose. + 2. Get the code: `git clone https://github.com/authzed/workshops.git` → `cd workshops/delegated-agent-authorization/starter`. + 3. Option A — local Docker: `cp .env.example .env`, add an LLM key for goose (note: only needed for the goose path; the `verify.py`/web-UI path needs no LLM), `docker compose up -d` (brings up postgres + SpiceDB with the expiration flag on `devtoken`), venv + `pip install -r requirements.txt`. + 4. Option B — Codespaces: the `.devcontainer/` runs compose + installs deps on create. + 5. Install goose and register the deploy-bot extension: reference `goose-extension.md` (absolute paths to `.venv/bin/python` and `deploybot_server.py`, env `SPICEDB_ENDPOINT`/`SPICEDB_TOKEN=devtoken`/`AGENT_SUBJECT=agent:goose_alice`). + 6. `## Completion Milestone: Setup` checklist: repo cloned; infra up (Docker or Codespaces); `.env` has an LLM key (goose path); extension registered in goose. + 7. Ends: `Next: [Checkpoint 1 — Run the agent](1-run-the-agent.md)`. + +- [ ] **Step 2: Verify** — from a clean read, the commands are copy-pasteable and consistent with Task 1's files (token `devtoken`, port 50051, paths). Confirm `docker compose up -d --wait` succeeds and goose config keys match `goose-extension.md`. + +- [ ] **Step 3: Commit** `docs(workshop): 0-setup`. + +--- + +### Task 3: `1-run-the-agent.md` (Checkpoint 1 — watch it over-reach) + +**Files:** Create `delegated-agent-authorization/1-run-the-agent.md` + +- [ ] **Step 1: Author the checkpoint** — model on `../agentic-rag-authorization/1-agentic-rag.md`. Cover: + 1. The flow: goose calls the deploy-bot MCP extension; every tool routes through `authz.decide()` before acting. + 2. Show the `decide()` stub (quote the `WORKSHOP STUB` block from `authz.py`) and explain: it returns ALLOWED without consulting SpiceDB. + 3. **Watch it over-reach (goose path):** in a `goose session`, ask *"Deploy checkout to production"*, then *"Tear down the production environment."* Both execute — the agent has no authorization boundary. + 4. **Deterministic path:** `python scripts/verify.py --checkpoint 1` → shows the stub allows `destroy production`. + 5. Why: an agent runs with its host's ambient authority; with no authorization boundary, it can do anything the credentials can. Putting the rule in the prompt is not enough — a prompt can be bypassed. + 6. `## Completion Milestone: Checkpoint 1` — ran the agent; reproduced the over-reach via goose and/or `verify.py`; can explain why ambient authority is the problem. + 7. `Next: [Checkpoint 2 — Delegated authorization](2-delegated-authorization.md)`. + +- [ ] **Step 2: Verify** — the quoted stub matches `authz.py` exactly; `verify.py --checkpoint 1` output matches what the doc claims. + +- [ ] **Step 3: Commit** `docs(workshop): CP1 run-the-agent`. + +--- + +### Task 4: `2-delegated-authorization.md` (Checkpoint 2 — the core) + +**Files:** Create `delegated-agent-authorization/2-delegated-authorization.md` + +**Interfaces:** the fill-in code here must reproduce the CP2 state that `verify.py --checkpoint 2` asserts (staging ALLOWED, prod NEEDS_APPROVAL, destroy BLOCKED, post-approve prod ALLOWED). + +- [ ] **Step 1: Author the checkpoint** — model on `../agentic-rag-authorization/2-secure-it.md`. Cover: + 1. **Concepts (inline):** ReBAC vs. RBAC; Google Zanzibar; why the decision must be a deterministic check, not the prompt. + 2. **Write the schema** — attendee replaces `schema.zed` with (this exact block): + ```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 + } + ``` + Explain each relation/permission and the delegation idea (`agent.delegator`). + 3. **Seed it:** `python bootstrap.py` (writes schema + relationships: alice deploys both envs + approves prod; sre_admin is the only destroyer; the agent is delegated staging-only). + 4. **Implement `decide()`** — attendee replaces the stub with (this exact block): + ```python + async def decide(client, agent_id, permission, environment_id) -> AuthzResult: + 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}") + 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}'; delegator user:{delegator} holds it — human approval required") + return AuthzResult(Decision.BLOCKED, + f"neither agent:{agent_id} nor its delegator may '{permission}' environment:{environment_id}") + ``` + Explain the three-way logic: agent's own grant → ALLOWED; else the human it acts for could → NEEDS APPROVAL; else BLOCKED. + 5. **See it (both paths):** start the web UI (`python web.py`, open `http://127.0.0.1:8000`) — deploy staging ✅, deploy prod ⏸, destroy 🚫; run `python approve.py --approver alice --env production` then retry prod → ✅. And in goose, the same prompts. Deterministic: `python scripts/verify.py --checkpoint 2` → `PASS ✅`. + 6. Why the check (not the prompt) is the boundary — deterministic, unbypassable, the agent only ever gets an answer SpiceDB computed. + 7. `## Completion Milestone: Checkpoint 2` — wrote the schema; seeded; implemented `decide()`; `verify.py --checkpoint 2` passes; saw the three-way decision in the UI and/or goose; can explain ReBAC. + 8. `Next: [Checkpoint 3 — Time-bound and revocable](3-time-bound-and-revocable.md)`. + +- [ ] **Step 2: Verify the fill-ins reach the CP2 state** — from `starter/`, apply the schema + `decide()` blocks from the doc, then: + ```bash + python scripts/verify.py --checkpoint 2 + ``` + Expected `PASS ✅`. (Restore the stubs afterward so `starter/` ships stubbed: `git checkout starter/schema.zed starter/authz.py`.) + +- [ ] **Step 3: Commit** `docs(workshop): CP2 delegated-authorization`. + +--- + +### Task 5: `3-time-bound-and-revocable.md` (Checkpoint 3) + +**Files:** Create `delegated-agent-authorization/3-time-bound-and-revocable.md` + +**Interfaces:** fill-ins must reach the CP3 state `verify.py --checkpoint 3` asserts (window 0 → NEEDS_APPROVAL; revoke → NEEDS_APPROVAL). Requires the schema `agent_deployer: agent with expiration`, `seed()`/`approve.py` writing `optional_expires_at`. + +- [ ] **Step 1: Author the checkpoint.** Cover: + 1. Concept: temporary access — incident windows. SpiceDB's built-in relationship expiration (preferred over a caveat; evaluated server-side; garbage-collected). + 2. **Schema edit:** add `use expiration` at the top and change the relation to `relation agent_deployer: agent with expiration`. (Show the exact diff.) + 3. **Update the seed** — in `bootstrap.py`, change the staging grant line to carry an expiry: + ```python + from authz import expiry_from_now + # ... + rel("environment", "staging", "agent_deployer", "agent", AGENT_ID, + expires_at=expiry_from_now(window_minutes)), + ``` + 4. **Update `approve.py`** — write the grant with an expiry: + ```python + from authz import check, read_delegator, expiry_from_now + # ... + update = rel("environment", environment, "agent_deployer", "agent", agent_id, + expires_at=expiry_from_now(minutes)) + ``` + 5. **See expiry without waiting:** `python bootstrap.py --window-minutes 0` seeds an already-expired staging grant → the agent's autonomous staging deploy drops to NEEDS APPROVAL. Then **revocation:** `python revoke.py --env staging` → same effect, instantly. Both visible in the web UI (grant disappears / countdown) and via `python scripts/verify.py --checkpoint 3`. + 6. Why contingent evaluation beats a cron job that deletes grants. + 7. `## Completion Milestone: Checkpoint 3` — added expiration to schema + seed + approve; demoed the window and revoke; `verify.py --checkpoint 3` passes. + 8. `Next: [Checkpoint 4 — Relationship-based hierarchy](4-relationship-based-hierarchy.md)`. + +- [ ] **Step 2: Verify the fill-ins** — apply CP2 fill-ins + CP3 edits, then `python scripts/verify.py --checkpoint 3` → `PASS ✅`. Restore stubs after (`git checkout starter/`). + +- [ ] **Step 3: Commit** `docs(workshop): CP3 time-bound-and-revocable`. + +--- + +### Task 6: `4-relationship-based-hierarchy.md` (Checkpoint 4 — the payoff) + +**Files:** Create `delegated-agent-authorization/4-relationship-based-hierarchy.md` + +**Interfaces:** fill-ins must reach the CP4 state `verify.py --checkpoint 4` asserts (both grants → prod ALLOWED; revoke staging → staging AND production NEEDS_APPROVAL — the cascade). Requires `gated_by`, `agent_deploy = agent_deployer & gated_by->agent_deployer`, `deploy = direct_deployer + agent_deploy`, and gated_by seed. + +- [ ] **Step 1: Author the checkpoint.** Cover: + 1. Concept: hierarchical/contingent authority — production autonomy should depend on staging autonomy. RBAC can't express this without a role-per-combination explosion. + 2. **Schema edit** (show exact additions): + ```zed + // inside definition environment: + relation gated_by: environment + permission agent_deploy = agent_deployer & gated_by->agent_deployer + permission deploy = direct_deployer + agent_deploy + ``` + Explain the intersection + arrow: the agent may deploy here only if it holds this env's grant AND the gating env's grant. Staging gates itself (base); production is gated by staging. + 3. **Seed gated_by** — add to `bootstrap.seed()`: + ```python + rel("environment", "staging", "gated_by", "environment", "staging"), + rel("environment", "production", "gated_by", "environment", "staging"), + ``` + 4. **See the cascade:** re-seed; `python approve.py --approver alice --env production` (agent now deploys both). Then `python revoke.py --env staging` — production autonomy suspends automatically, no second delete. Web UI shows production as a dashed *suspended* grant. `python scripts/verify.py --checkpoint 4` → `PASS ✅`. + 5. Why: it's contingent *evaluation*, not a delete — so it's suspend-not-erase (re-granting staging revives production while its window lasts). This is the ReBAC superpower. + 6. `## Completion Milestone: Checkpoint 4` — added `gated_by` + `agent_deploy`; seeded the hierarchy; demoed the staging→production cascade; `verify.py --checkpoint 4` passes; can explain why RBAC can't do this. + 7. `Next: [Next steps](5-nextsteps.md)`. + +- [ ] **Step 2: Verify the fill-ins** — apply CP2+CP3+CP4 edits, then `python scripts/verify.py --checkpoint 4` → `PASS ✅`. Confirm the resulting schema matches the tested solution's `schema.zed` behavior. Restore stubs after (`git checkout starter/`). + +- [ ] **Step 3: Commit** `docs(workshop): CP4 relationship-based-hierarchy`. + +--- + +### Task 7: `README.md`, `5-nextsteps.md`, and full end-to-end validation + +**Files:** Create `delegated-agent-authorization/README.md`, `delegated-agent-authorization/5-nextsteps.md` + +- [ ] **Step 1: Author `README.md`** — model on `../agentic-rag-authorization/README.md`. Include: the title + abstract (from the conference listing); "Why this matters" (agents run with credentials that touch everything); "What you'll build" (bulleted); prerequisites (Docker/Codespaces, Python 3.10+, goose + an LLM key for the goose path); the **90-minute module map** with the per-checkpoint timing from the spec (Setup 15 · CP1 10 · CP2 25 · CP3 15 · CP4 20 · Next Steps 5); a link to the full reference solution `https://github.com/sohanmaheshwar/goose-spicedb-delegation`; and the ordered module links. End with "Let's get started with [Setup](0-setup.md)." + +- [ ] **Step 2: Author `5-nextsteps.md`.** Cover: modeling authority once as relationships for a whole platform; `CheckBulk` for "which of these N resources may this agent touch"; on-behalf-of vs. advisory enforcement; production hardening the workshop deliberately skipped (gate `revoke`/admin actions behind a `manage` permission; gate `list_environments` behind a `view` permission — point to the solution repo which does both); scaling ReBAC with SpiceDB (Zanzibar lineage, Kubernetes — link the other AuthZed workshop). Keep it a short zoom-out. + +- [ ] **Step 3: Full end-to-end validation (the integration test).** From a fresh `starter/` (stubs in place), walk the entire workshop in order, applying each checkpoint's fill-in code from the markdown and running its verifier: + ```bash + cd delegated-agent-authorization/starter + docker compose up -d --wait + python scripts/verify.py --checkpoint 1 # stub over-reach: PASS + # apply CP2 schema + decide() from 2-delegated-authorization.md + python scripts/verify.py --checkpoint 2 # PASS + # apply CP3 edits from 3-time-bound-and-revocable.md + python scripts/verify.py --checkpoint 3 # PASS + # apply CP4 edits from 4-relationship-based-hierarchy.md + python scripts/verify.py --checkpoint 4 # PASS + git checkout schema.zed authz.py bootstrap.py approve.py # restore ship-state stubs + ``` + All four must PASS. This proves the checkpoints' embedded code is correct and complete. Fix any checkpoint whose fill-ins don't reach its verifier state. + +- [ ] **Step 4: Confirm `starter/` ships stubbed** — `git status` clean; `schema.zed` and `authz.decide()` are the CP1 stubs; `bootstrap.py`/`approve.py` are the CP2 versions (no expiry). + +- [ ] **Step 5: Commit** `docs(workshop): README, next steps, end-to-end validated`. + +--- + +## Self-Review + +**Spec coverage:** +- Folder/structure → Task 1 + doc tasks. ✓ +- Guided-implement (schema + decide) → CP2 (Task 4), grown in CP3/CP4 (Tasks 5/6); everything else provided in Task 1. ✓ +- goose headline + deterministic verifier → both paths in every checkpoint; `verify.py` (Task 1) + web UI (copied Task 1). ✓ +- CP1 over-reach → Task 3 + `decide()` stub + `verify.py --checkpoint 1`. ✓ +- Delegation / 3-way → CP2 (Task 4). ✓ +- Time-bound + revoke → CP3 (Task 5). ✓ +- ReBAC hierarchy/cascade → CP4 (Task 6). ✓ +- Setup (Docker + Codespaces) → Task 2 + devcontainer (Task 1). ✓ +- README + module map + timing + next steps → Task 7. ✓ +- Prereqs, non-goals (no manage/view/rollback; revoke ungated) → Global Constraints + Next Steps follow-ups. ✓ +- Success criteria (clean clone → all checkpoints pass) → Task 7 Step 3 end-to-end validation. ✓ + +**Placeholder scan:** All code blocks are complete and exact. `# TODO(Checkpoint N):` markers are intentional workshop-stub content, not plan placeholders. Doc-authoring steps specify exact embedded code + the reference file to match for tone (not "write prose"). + +**Type consistency:** `decide(client, agent_id, permission, environment_id)` (no `action`) is consistent across authz.py, deploybot_server.py (Task 1 mod drops `action=`), verify.py, and the CP2 fill-in. `check`/`read_delegator`/`expiry_from_now`/`rel`/`agent_deployer_filter` signatures match the solution's and are used consistently. `deploy` (not `agent_deploy`) is the permission checked by web.py `/api/state` and verify.py — works CP2→CP4 because CP4 rewires `deploy` through `agent_deploy`. From c1867be37047c434a9c3688f0a68cb8c83ce6bae Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:35:38 +0200 Subject: [PATCH 03/27] feat(workshop): starter scaffolding, provided plumbing, CP1 stubs, verify.py --- .../starter/.devcontainer/devcontainer.json | 7 + .../starter/.env.example | 3 + .../starter/.gitignore | 4 + .../starter/approve.py | 35 + .../starter/authz.py | 75 +++ .../starter/bootstrap.py | 58 ++ .../starter/deploybot_server.py | 94 +++ .../starter/docker-compose.yml | 49 ++ .../starter/goose-extension.md | 62 ++ .../starter/relationships.py | 40 ++ .../starter/requirements.txt | 14 + .../starter/revoke.py | 27 + .../starter/schema.zed | 12 + .../starter/scripts/verify.py | 85 +++ .../starter/spicedb_client.py | 11 + .../starter/static/index.html | 604 ++++++++++++++++++ delegated-agent-authorization/starter/web.py | 167 +++++ 17 files changed, 1347 insertions(+) create mode 100644 delegated-agent-authorization/starter/.devcontainer/devcontainer.json create mode 100644 delegated-agent-authorization/starter/.env.example create mode 100644 delegated-agent-authorization/starter/.gitignore create mode 100644 delegated-agent-authorization/starter/approve.py create mode 100644 delegated-agent-authorization/starter/authz.py create mode 100644 delegated-agent-authorization/starter/bootstrap.py create mode 100644 delegated-agent-authorization/starter/deploybot_server.py create mode 100644 delegated-agent-authorization/starter/docker-compose.yml create mode 100644 delegated-agent-authorization/starter/goose-extension.md create mode 100644 delegated-agent-authorization/starter/relationships.py create mode 100644 delegated-agent-authorization/starter/requirements.txt create mode 100644 delegated-agent-authorization/starter/revoke.py create mode 100644 delegated-agent-authorization/starter/schema.zed create mode 100644 delegated-agent-authorization/starter/scripts/verify.py create mode 100644 delegated-agent-authorization/starter/spicedb_client.py create mode 100644 delegated-agent-authorization/starter/static/index.html create mode 100644 delegated-agent-authorization/starter/web.py diff --git a/delegated-agent-authorization/starter/.devcontainer/devcontainer.json b/delegated-agent-authorization/starter/.devcontainer/devcontainer.json new file mode 100644 index 0000000..2506c65 --- /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": "pip install -r requirements.txt && docker compose up -d", + "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..4aca33d --- /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 — Checkpoint 1. + # Returns ALLOWED for everything WITHOUT consulting SpiceDB. This is exactly why + # the agent over-reaches in Checkpoint 1. You implement the real, SpiceDB-backed + # three-way decision in Checkpoint 2. + # TODO(Checkpoint 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..1cb8d15 --- /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"), + # Checkpoint 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 (Checkpoint 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..9c378ac --- /dev/null +++ b/delegated-agent-authorization/starter/deploybot_server.py @@ -0,0 +1,94 @@ +"""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: + 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..2204222 --- /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 + # --enable-experimental-relationship-expiration turns on the built-in + # relationship expiration feature used by `agent_deployer: agent with expiration`. + command: "serve --enable-experimental-relationship-expiration" + 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..67a8810 --- /dev/null +++ b/delegated-agent-authorization/starter/goose-extension.md @@ -0,0 +1,62 @@ +# 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. **We did not install goose or run this checklist +as part of building this repo — no goose install and no LLM API key were used.** The identical +decision sequence is instead proven deterministically and repeatably by `tests/test_arc.py`, +which calls `authz.decide` (the same function `deploybot_server.py` calls on every tool +invocation) directly against a real SpiceDB instance. + +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 another terminal: `python approve.py --approver alice --env production` → then in goose "try the production deploy again" → **✅ ALLOWED**. +4. "Tear down the production environment." → **🚫 BLOCKED**. +5. In another terminal: `python revoke.py --env staging` → then in goose "deploy checkout to staging again" → **⏸️ NEEDS APPROVAL**. + +If goose is not installed / no LLM key is available, this step is skipped and the arc is +covered by `tests/test_arc.py` (which exercises the identical decision sequence deterministically). 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..046bbe8 --- /dev/null +++ b/delegated-agent-authorization/starter/schema.zed @@ -0,0 +1,12 @@ +// WORKSHOP STUB — you will build this schema up across Checkpoints 2–4. +// It defines the object types but grants the agent nothing. Combined with the +// authz.decide() stub, the deploy agent runs UNGATED in Checkpoint 1. +// TODO(Checkpoint 2): model delegated authorization here. + +definition user {} + +definition agent { + relation delegator: user +} + +definition environment {} diff --git a/delegated-agent-authorization/starter/scripts/verify.py b/delegated-agent-authorization/starter/scripts/verify.py new file mode 100644 index 0000000..c8462f2 --- /dev/null +++ b/delegated-agent-authorization/starter/scripts/verify.py @@ -0,0 +1,85 @@ +"""verify.py — deterministic checks for the current checkpoint. No LLM needed. + +Usage: python scripts/verify.py --checkpoint N +Run from the starter/ directory with SpiceDB up. +""" +import argparse +import asyncio +import sys +from pathlib import Path + +# Invoked as `python scripts/verify.py`, so Python puts scripts/ (not starter/) on +# sys.path — add the parent directory so the top-level modules below resolve. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from authz import Decision, decide +import bootstrap +from approve import approve +from revoke import revoke +from spicedb_client import make_client + +AGENT = "goose_alice" + + +def _ok(label, got, want): + mark = "✅" if got == want else "❌" + print(f" {mark} {label}: got {got}, want {want}") + return got == want + + +async def checkpoint_1(): + # The stub decides ALLOWED for everything — the over-reach. + r = await decide(make_client(), AGENT, "destroy", "production") + return _ok("stub allows destroy production (over-reach)", r.decision, Decision.ALLOWED) + + +async def checkpoint_2(client): + await bootstrap.write_schema(client) + await bootstrap.seed(client) + passed = True + passed &= _ok("agent deploy staging", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.ALLOWED) + passed &= _ok("agent deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.NEEDS_APPROVAL) + passed &= _ok("agent destroy production", (await decide(client, AGENT, "destroy", "production")).decision, Decision.BLOCKED) + await approve("alice", "production", AGENT, 10) + passed &= _ok("after approve: deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.ALLOWED) + return passed + + +async def checkpoint_3(client): + # Expired window -> staging autonomy gone (falls back to NEEDS_APPROVAL). + await bootstrap.write_schema(client) + await bootstrap.seed(client, window_minutes=0) + passed = _ok("expired staging grant", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.NEEDS_APPROVAL) + # Revocation on a fresh window. + await bootstrap.seed(client, window_minutes=60) + await revoke("staging", AGENT) + passed &= _ok("after revoke: deploy staging", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.NEEDS_APPROVAL) + return passed + + +async def checkpoint_4(client): + await bootstrap.write_schema(client) + await bootstrap.seed(client, window_minutes=60) + await approve("alice", "production", AGENT, 10) + passed = _ok("with both grants: deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.ALLOWED) + await revoke("staging", AGENT) # revoke the BASE + passed &= _ok("cascade: deploy staging", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.NEEDS_APPROVAL) + passed &= _ok("cascade: deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.NEEDS_APPROVAL) + return passed + + +async def main(n): + print(f"Verifying Checkpoint {n}...") + if n == 1: + passed = await checkpoint_1() + else: + client = make_client() + passed = await {2: checkpoint_2, 3: checkpoint_3, 4: checkpoint_4}[n](client) + print("PASS ✅" if passed else "FAIL ❌") + return 0 if passed else 1 + + +if __name__ == "__main__": + p = argparse.ArgumentParser() + p.add_argument("--checkpoint", type=int, required=True, choices=[1, 2, 3, 4]) + raise SystemExit(asyncio.run(main(p.parse_args().checkpoint))) 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..62e4d57 --- /dev/null +++ b/delegated-agent-authorization/starter/static/index.html @@ -0,0 +1,604 @@ + + + + + +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..7bdf4f9 --- /dev/null +++ b/delegated-agent-authorization/starter/web.py @@ -0,0 +1,167 @@ +"""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 timezone +from pathlib import Path + +from fastapi import FastAPI +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from authzed.api.v1 import Consistency, ReadRelationshipsRequest + +import bootstrap +import deploybot_server +from approve import approve +from authz import check, read_delegator +from relationships import agent_deployer_filter +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" + + +@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(): + client = make_client() + delegator = await read_delegator(client, AGENT_ID) + grants = [] + 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. + effective = await check(client, "agent", AGENT_ID, "deploy", "environment", env) + grants.append({"environment": env, "expires_at": expires_at, "effective": effective}) + versions = deploybot_server._load_state() + 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/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) From f48c93d48e891505bde6c95364127db1a30c5633 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:46:52 +0200 Subject: [PATCH 04/27] fix(workshop): drop rollback preset, point goose-extension at verify.py --- .../starter/goose-extension.md | 13 +++++++------ .../starter/static/index.html | 1 - 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/delegated-agent-authorization/starter/goose-extension.md b/delegated-agent-authorization/starter/goose-extension.md index 67a8810..c0794b2 100644 --- a/delegated-agent-authorization/starter/goose-extension.md +++ b/delegated-agent-authorization/starter/goose-extension.md @@ -37,11 +37,11 @@ goose configure ## 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. **We did not install goose or run this checklist -as part of building this repo — no goose install and no LLM API key were used.** The identical -decision sequence is instead proven deterministically and repeatably by `tests/test_arc.py`, -which calls `authz.decide` (the same function `deploybot_server.py` calls on every tool -invocation) directly against a real SpiceDB instance. +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 decision sequence is proven +deterministically and repeatably by `scripts/verify.py`, which calls `authz.decide` (the same +function `deploybot_server.py` calls on every tool invocation) directly against a real SpiceDB +instance. Run it per checkpoint, e.g. `python scripts/verify.py --checkpoint 2`. If you do have goose installed and an LLM key configured, here is the checklist to confirm the wiring end to end: @@ -59,4 +59,5 @@ Drive these prompts and confirm the deploybot tool output: 5. In another terminal: `python revoke.py --env staging` → then in goose "deploy checkout to staging again" → **⏸️ NEEDS APPROVAL**. If goose is not installed / no LLM key is available, this step is skipped and the arc is -covered by `tests/test_arc.py` (which exercises the identical decision sequence deterministically). +covered by `scripts/verify.py` (which exercises the identical decision sequence +deterministically — run `python scripts/verify.py --checkpoint 2` and up). diff --git a/delegated-agent-authorization/starter/static/index.html b/delegated-agent-authorization/starter/static/index.html index 62e4d57..66ff276 100644 --- a/delegated-agent-authorization/starter/static/index.html +++ b/delegated-agent-authorization/starter/static/index.html @@ -359,7 +359,6 @@ const PRESETS = [ "Deploy checkout to staging", "Deploy checkout to production", - "Roll back checkout in staging", "Tear down production", "What can I see?", ]; From 4e1258965ae3f87bfc456f434069ce78b47f92a3 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:54:03 +0200 Subject: [PATCH 05/27] docs(workshop): 0-setup --- delegated-agent-authorization/0-setup.md | 93 ++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 delegated-agent-authorization/0-setup.md diff --git a/delegated-agent-authorization/0-setup.md b/delegated-agent-authorization/0-setup.md new file mode 100644 index 0000000..4a87d12 --- /dev/null +++ b/delegated-agent-authorization/0-setup.md @@ -0,0 +1,93 @@ +# Setup + +In this workshop you build a DevOps deploy agent on [goose](https://github.com/aaif-goose/goose) +(the open-source agent from the Agentic AI Foundation), then gate its every action with +delegated, fine-grained authorization from SpiceDB — scoped grants, time-bound windows, instant +revocation, and a permission hierarchy where revoking a base grant cascades to what depends on +it. The `starter/` folder in this repo is stubbed on purpose: the plumbing (MCP extension, +docker-compose, seed/approve/revoke scripts, web UI) is provided, and you'll write the schema and +the decision engine yourself across the checkpoints. + +## Get the code + +```bash +git clone https://github.com/authzed/workshops.git +cd workshops/delegated-agent-authorization/starter +``` + +## Option A — Run locally with Docker + +Copy the example `.env` file: + +```bash +cp .env.example .env +``` + +`.env` holds the SpiceDB connection details the app itself needs — endpoint, preshared token, +and which agent identity the deploy bot acts as. Nothing in this repo talks to an LLM directly, +so there's no LLM key in here. The LLM key only comes into play later, and only if you use the +goose path (see [Install goose](#install-goose-and-register-the-extension) below) — the +deterministic path (`scripts/verify.py` and the web UI, introduced in later checkpoints) needs no +LLM at all. + +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 the schema migration and exits. SpiceDB serves +on `localhost:50051` with a preshared key of `devtoken` (not recommended for prod, obviously) and +`--enable-experimental-relationship-expiration` turned on, which later checkpoints use for +time-bound grants. + +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. The repo ships with a +`.devcontainer/` config that handles everything automatically. + +1. On the repo page, click **Code ▸ Codespaces ▸ Create codespace on main** +2. The devcontainer installs dependencies and runs `docker compose up -d` on startup +3. Once the Codespace is ready, `cd delegated-agent-authorization/starter` and copy `.env.example` + to `.env` as in Option A + +## Install goose and register the extension + +Installing goose is optional for this workshop. Every checkpoint has a second, deterministic way +to see the same decisions — `scripts/verify.py` plus a web UI, neither of which needs goose or an +LLM key. 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 into this repo's deploy tools. Follow + `goose-extension.md` — it walks through editing `~/.config/goose/config.yaml` (or running + `goose configure` interactively) with **absolute paths** to this repo's `.venv/bin/python` and + `deploybot_server.py`, plus three env vars the extension needs to reach SpiceDB: + `SPICEDB_ENDPOINT=localhost:50051`, `SPICEDB_TOKEN=devtoken`, `AGENT_SUBJECT=agent:goose_alice`. + +`goose-extension.md` also has a manual verification checklist for once goose is wired up — worth +skimming now, but there's nothing to run yet: SpiceDB has no schema until Checkpoint 1. + +--- + +## Completion Milestone: Setup + +- [ ] Cloned the repo +- [ ] Infrastructure is up — Docker (`docker compose up -d --wait`) or Codespaces +- [ ] Virtual environment created and `pip install -r requirements.txt` succeeded +- [ ] (Goose path only) goose installed with an LLM provider configured, and the `deploybot` + extension registered per `goose-extension.md` + +Next: [Checkpoint 1 — Run the agent](1-run-the-agent.md) From d6a2d8707f5bda3c1ab305ac5dc5ab3d7f59f4b4 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:13:42 +0200 Subject: [PATCH 06/27] =?UTF-8?q?docs(workshop):=200-setup=20fix=20round?= =?UTF-8?q?=201=20=E2=80=94=20CP2=20schema=20wording,=20venv=20in=20devcon?= =?UTF-8?q?tainer,=20header=20hyphens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- delegated-agent-authorization/0-setup.md | 18 ++++++++++++------ .../starter/.devcontainer/devcontainer.json | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/delegated-agent-authorization/0-setup.md b/delegated-agent-authorization/0-setup.md index 4a87d12..90660cb 100644 --- a/delegated-agent-authorization/0-setup.md +++ b/delegated-agent-authorization/0-setup.md @@ -15,7 +15,7 @@ git clone https://github.com/authzed/workshops.git cd workshops/delegated-agent-authorization/starter ``` -## Option A — Run locally with Docker +## Option A - Run locally with Docker Copy the example `.env` file: @@ -37,7 +37,8 @@ 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 the schema migration and exits. SpiceDB serves +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) and `--enable-experimental-relationship-expiration` turned on, which later checkpoints use for time-bound grants. @@ -48,13 +49,15 @@ Create a virtual environment and install dependencies: python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt ``` -## Option B — Run in GitHub Codespaces +## Option B - Run in GitHub Codespaces For anyone who can't run Docker locally, Codespaces is the path. The repo ships with a `.devcontainer/` config that handles everything automatically. 1. On the repo page, click **Code ▸ Codespaces ▸ Create codespace on main** -2. The devcontainer installs dependencies and runs `docker compose up -d` on startup +2. On startup the devcontainer creates the `.venv`, installs dependencies into it, and runs + `docker compose up -d` — so the `.venv/bin/python` path the goose step below relies on exists + here too 3. Once the Codespace is ready, `cd delegated-agent-authorization/starter` and copy `.env.example` to `.env` as in Option A @@ -78,7 +81,9 @@ If you do want the goose path: `SPICEDB_ENDPOINT=localhost:50051`, `SPICEDB_TOKEN=devtoken`, `AGENT_SUBJECT=agent:goose_alice`. `goose-extension.md` also has a manual verification checklist for once goose is wired up — worth -skimming now, but there's nothing to run yet: SpiceDB has no schema until Checkpoint 1. +skimming now, but there's nothing to run yet: SpiceDB has no authorization schema until +Checkpoint 2, where you write it and the agent's decisions (via goose or the web UI) first come +online. --- @@ -86,7 +91,8 @@ skimming now, but there's nothing to run yet: SpiceDB has no schema until Checkp - [ ] Cloned the repo - [ ] Infrastructure is up — Docker (`docker compose up -d --wait`) or Codespaces -- [ ] Virtual environment created and `pip install -r requirements.txt` succeeded +- [ ] `.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` diff --git a/delegated-agent-authorization/starter/.devcontainer/devcontainer.json b/delegated-agent-authorization/starter/.devcontainer/devcontainer.json index 2506c65..b767231 100644 --- a/delegated-agent-authorization/starter/.devcontainer/devcontainer.json +++ b/delegated-agent-authorization/starter/.devcontainer/devcontainer.json @@ -2,6 +2,6 @@ "name": "delegated-agent-authorization", "image": "mcr.microsoft.com/devcontainers/python:3.12", "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {} }, - "postCreateCommand": "pip install -r requirements.txt && docker compose up -d", + "postCreateCommand": "python -m venv .venv && .venv/bin/pip install -r requirements.txt && docker compose up -d", "forwardPorts": [8000, 50051] } From a0818abc8c99b9911c48b0d6b0042b41f14a17bf Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:04:56 +0200 Subject: [PATCH 07/27] docs(workshop): CP1 run-the-agent Author 1-run-the-agent.md: quotes the authz.decide() WORKSHOP STUB, walks the goose over-reach (deploy + destroy production both ALLOWED with no boundary), the deterministic scripts/verify.py --checkpoint 1 path, and the ambient- authority takeaway. No web UI in this checkpoint (no schema until CP2). Also fixes a real bug found while verifying the goose path: deploybot_server's _load_state() crashed with FileNotFoundError on a fresh checkout because infra_state.json is only ever created by the web UI's /api/reset (CP2+), never by bootstrap.py or on first run. Treat a missing file as "no environments" so list_environments/deploy/destroy work before the web UI has ever run. --- .../1-run-the-agent.md | 146 ++++++++++++++++++ .../starter/deploybot_server.py | 4 + 2 files changed, 150 insertions(+) create mode 100644 delegated-agent-authorization/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..0f956ee --- /dev/null +++ b/delegated-agent-authorization/1-run-the-agent.md @@ -0,0 +1,146 @@ +# Checkpoint 1 — Run the Agent (and Watch It Over-Reach) + +The goal here is simple: get the deploy agent running, then catch it doing something it should +never have been allowed to do — tearing down production — because right now nothing stops it. + +--- + +## The flow — goose calls deploybot + +`deploybot_server.py` is a goose MCP extension. It exposes three tools: + +- **`list_environments`** — lists every environment and the service versions deployed to it. + Read-only, and — by design, for this workshop — not authorization-checked. The code says so + directly: + + ```python + # UNGATED in this workshop: list_environments is not authorization-checked. + ``` + +- **`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. In Checkpoint 1, the boundary is a stub that never says no. + +--- + +## `decide()` is a deliberate stub + +Open `authz.py`. `decide()` is the one function every mutating tool call goes through, and right +now it is honest about doing nothing: + +```python +async def decide(client, agent_id, permission, environment_id) -> AuthzResult: + # WORKSHOP STUB — Checkpoint 1. + # Returns ALLOWED for everything WITHOUT consulting SpiceDB. This is exactly why + # the agent over-reaches in Checkpoint 1. You implement the real, SpiceDB-backed + # three-way decision in Checkpoint 2. + # TODO(Checkpoint 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`. There is no schema yet for it to consult even if it wanted to — that arrives in +Checkpoint 2. Right now, an authorization call is just a formality the code performs on its way to +doing whatever it was asked. + +--- + +## Watch it over-reach (goose path) + +If you registered the `deploybot` extension in setup, open a session: + +```bash +goose session +``` + +Ask it to do something reasonable: + +> Deploy checkout to production. + +goose calls `deploy(service="checkout", environment="production")`. It comes back +**✅ ALLOWED**, and the version bumps. Fine so far — that's a real deploy engineer's job. + +Now ask it to do something no agent should be able to decide on its own: + +> Tear down the production environment. + +goose calls `destroy(environment="production")`. It comes back **✅ ALLOWED**, and production is +gone. No pause, no approval step, no distinction between "deploy a service" and "delete an entire +environment." The tool's own docstring says destroying "requires elevated authority" — but that's +just a comment for humans reading the code. Nothing enforces 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. The problem isn't the prompt. It's that nothing +downstream of the prompt is checking. + +--- + +## Deterministic path + +Don't have goose installed, or want a repeatable check instead of a live LLM session? From +`starter/`, with SpiceDB up: + +```bash +python scripts/verify.py --checkpoint 1 +``` + +Output: + +``` +Verifying Checkpoint 1... + ✅ stub allows destroy production (over-reach): got Decision.ALLOWED, want Decision.ALLOWED +PASS ✅ +``` + +This calls the exact same `authz.decide()` that `deploybot_server.py` calls on every tool +invocation — `decide(client, "goose_alice", "destroy", "production")` — no LLM involved, no goose +required. It asks the stub the most dangerous question in the workshop, "can this agent destroy +production," and the stub says yes. That's the whole bug, isolated to one deterministic assertion. + +--- + +## Why this happens: ambient authority + +The agent process holds one set of credentials — the `SPICEDB_TOKEN` and `AGENT_SUBJECT` in its +environment — and 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 is 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. It's the same failure mode as a script +running with root because it happened to be launched by root — not because anyone decided it +should have root. + +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." Don't reach for that — a prompt is a +suggestion to a language model, not a control the system enforces. Nothing stops a differently +worded request, a longer conversation that talks the model out of its own guardrail, or a chain of +reasoning that convinces the agent this particular destroy is the exception. The instruction lives +in the same channel as everything else the model reads, which means it can be argued with. 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 Checkpoint 2 builds. + +--- + +## Completion Milestone: Checkpoint 1 + +- [ ] Ran the agent — via `goose session`, `scripts/verify.py --checkpoint 1`, 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: [Checkpoint 2 — Delegated authorization](2-delegated-authorization.md) diff --git a/delegated-agent-authorization/starter/deploybot_server.py b/delegated-agent-authorization/starter/deploybot_server.py index 9c378ac..743e58b 100644 --- a/delegated-agent-authorization/starter/deploybot_server.py +++ b/delegated-agent-authorization/starter/deploybot_server.py @@ -16,6 +16,10 @@ 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()) From 5387aeb3c7f0b5b0b9fd476b490576b866203a18 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:35:23 +0200 Subject: [PATCH 08/27] docs(workshop): CP2 delegated-authorization --- .../2-delegated-authorization.md | 255 ++++++++++++++++++ .../starter/infra_state.json | 1 + 2 files changed, 256 insertions(+) create mode 100644 delegated-agent-authorization/2-delegated-authorization.md create mode 100644 delegated-agent-authorization/starter/infra_state.json diff --git a/delegated-agent-authorization/2-delegated-authorization.md b/delegated-agent-authorization/2-delegated-authorization.md new file mode 100644 index 0000000..a8ea8c2 --- /dev/null +++ b/delegated-agent-authorization/2-delegated-authorization.md @@ -0,0 +1,255 @@ +# Checkpoint 2 — Delegated Authorization + +Checkpoint 1 ended with a working exploit: `authz.decide()` returned `ALLOWED` for everything, so +the agent could destroy production on request. Here you 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 honest answers: the agent can do this itself, a human needs to say yes +first, or nobody involved is allowed to do this at all. + +--- + +## ReBAC, in one paragraph + +Most authorization systems people reach for first are **RBAC** — role-based access control. You +assign `alice` the role `deploy-engineer`, and a table somewhere says that role can deploy. It +works until you need "the person `alice` is deploying *for*" or "whoever approved this specific +change" — relationships between subjects and resources that a flat role list can't express without +exploding into a role per case. + +**ReBAC** — relationship-based access control — models permissions as a graph instead: subjects, +resources, and the relations between them, with permissions defined as graph traversals over those +relations. This is the model Google published in 2019 as +[**Zanzibar**](https://research.google/pubs/pub48190/), the system that authorizes Google Drive, +Docs, and Calendar. SpiceDB is an open-source implementation of the same ideas. A permission check +in SpiceDB isn't a table lookup — it's "is there a path through the relationship graph from this +subject to this resource with this permission." That graph-walk is exactly what lets you model +delegation directly: an `agent` node with a `delegator` edge to the `user` it acts for, and +permissions that consider both. + +--- + +## Write the schema + +Open `schema.zed`. Right now it's the Checkpoint 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 +} +``` + +Walking it: + +- **`user`** — an empty definition. Humans don't need relations of their own here; they're + subjects that other things point to. +- **`agent { relation delegator: user }`** — this 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`** — 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` — 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` — a human-only relation. Nothing gives an agent `approve`, + so agents can never approve their own grants. + - `permission destroy = destroyer` — 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 + +```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 + checkpoint 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 Checkpoint 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: + 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}") + 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}'; delegator user:{delegator} holds it — human approval required") + 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 + +### The web UI + +```bash +python web.py +``` + +Open `http://127.0.0.1:8000`. This is a small FastAPI front end — its own docstring says it plainly: +*"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** (or run the CLI directly, see below), then retry the production +deploy — it flips to ✅ **ALLOWED**. Nothing about `decide()` changed; the relationship graph did. + +### `approve.py` — the human in the loop + +```bash +python approve.py --approver alice --env production +``` + +Before writing anything, `approve.py` 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` — 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 `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()`. + +### Deterministic path + +```bash +python scripts/verify.py --checkpoint 2 +``` + +``` +Verifying Checkpoint 2... + ✅ agent deploy staging: got Decision.ALLOWED, want Decision.ALLOWED + ✅ agent deploy production: got Decision.NEEDS_APPROVAL, want Decision.NEEDS_APPROVAL + ✅ agent destroy production: got Decision.BLOCKED, want Decision.BLOCKED + ✅ after approve: deploy production: got Decision.ALLOWED, want Decision.ALLOWED +PASS ✅ +``` + +No LLM, no goose — this calls your `decide()` directly against a live SpiceDB, runs `approve.py` +in the middle, and checks the exact same four outcomes you just watched in the UI or goose. + +--- + +## Why the check — not the prompt — is the boundary + +Nothing in this checkpoint told the agent, in English, "you may deploy staging but not +production, and never destroy anything." There's no system-prompt instruction to argue with, talk +around, or forget three turns into a conversation. 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 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. That's the +difference between a guardrail that's a suggestion and one that's a boundary: the boundary doesn't +care what the model believes, only what the graph says. + +--- + +## Completion Milestone: Checkpoint 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` +- [ ] `python scripts/verify.py --checkpoint 2` prints `PASS ✅` +- [ ] Saw all three decisions — `ALLOWED`, `NEEDS_APPROVAL`, `BLOCKED` — in the web UI and/or + goose, and watched `approve.py` 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: [Checkpoint 3 — Time-bound and revocable](3-time-bound-and-revocable.md) 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}} From b3b8cbe35ba822a5949deba082dc375d34af5010 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:07:15 +0200 Subject: [PATCH 09/27] docs(workshop): CP3 time-bound-and-revocable --- .../3-time-bound-and-revocable.md | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 delegated-agent-authorization/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..6d68163 --- /dev/null +++ b/delegated-agent-authorization/3-time-bound-and-revocable.md @@ -0,0 +1,195 @@ +# Checkpoint 3 — Time-Bound and Revocable + +Checkpoint 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 "forever" +is still a bigger blast radius than most delegation actually needs. An incident responder pulled in +at 2am should get staging access for the duration of the incident, not a standing 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 — incident windows + +The shape you want is: "this agent may deploy staging, but only for the next 60 minutes." 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, forever. +- **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 — no caveat context to thread through, no expression to + get subtly wrong. + +Expiration wins here for a reason that matters beyond convenience: 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 + +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 seed + +`bootstrap.py` already takes a `--window-minutes` flag and threads it into `seed()` — Checkpoint 2 +left the actual staging grant unexpiring, and this is where that gets fixed. Import +`expiry_from_now` from `authz` — it builds the protobuf `Timestamp` `optional_expires_at` expects +— and pass it into the staging `agent_deployer` write: + +```python +from authz import expiry_from_now +# ... +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` — that plumbing was there from the start, waiting for the schema to allow it. +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. `approve.py` already takes `--minutes` +(default 10) and ignored it — Checkpoint 2's version wrote the grant with no expiry at all. Import +`expiry_from_now` alongside the checks it already runs, and carry it into the write: + +```python +from authz import check, read_delegator, expiry_from_now +# ... +update = rel("environment", environment, "agent_deployer", "agent", agent_id, + expires_at=expiry_from_now(minutes)) +``` + +Run `python approve.py --approver alice --env production` (or click **Approve prod · 10m** in the +web UI) and the resulting grant now expires 10 minutes later on its own — no follow-up step, no +second script to remember to run. An approval that never expires was really a standing grant with +extra steps; this makes "approved for now" mean what it says. + +--- + +## See expiry without waiting + +Waiting out a real timer to prove this works isn't worth your time, so `bootstrap.py` gives you a +faster lever: seed a grant that's already expired. + +```bash +python bootstrap.py --window-minutes 0 +``` + +This writes the staging `agent_deployer` relationship with `expires_at` set to *right now* — by +the time `CheckPermission` runs, it's already in the past. The agent's own `agent_deployer` check +on staging fails, `decide()` falls through to branch 2, and "deploy checkout to staging" — the one +request that was unconditionally ✅ **ALLOWED** in Checkpoint 2 — now comes back +⏸️ **NEEDS APPROVAL** instead, with Alice named as the delegator who'd have to approve it. Nothing +about `decide()` changed. The relationship it was reading simply isn't there anymore, as far as +SpiceDB is concerned. + +Now the other lever — instant revocation, for when you don't want to wait for any window to run +out: + +```bash +python revoke.py --env staging +``` + +`revoke.py` deletes the `agent_deployer` relationship on `staging` outright, using the same +`agent_deployer_filter` helper `bootstrap.py` uses to reset it between runs. Re-run the check (or +ask the agent to deploy staging again) and you get the identical ⏸️ **NEEDS APPROVAL** — 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 run one command and the grant is +gone on the very next check. + +Both are visible in the web UI: reset the demo, approve or bootstrap a grant, and watch its card in +the **grants** panel — a live countdown with a shrinking bar. Let it hit zero, or hit **Revoke**, +and the card either disappears or flips to a dashed "suspended" state on the next 5-second poll, +because `/api/state` is calling the same `check()` you are. + +--- + +## Why contingent evaluation beats a cron job + +A more obvious-looking fix might be: keep the grant unexpiring, and run a cron job that deletes +`agent_deployer` relationships older than an hour. Don't do that. 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. Someone deploys at minute 59:58 using a grant that's +"supposed to" be gone; whether that succeeds depends on scheduler jitter, not policy. 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 — two sources of truth for one fact. + +Relationship expiration collapses that back to one. There's no janitor process to keep alive: 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, but it's a housekeeping detail, not part of the authorization +decision. The boundary is exactly as tight as `CheckPermission` itself. + +--- + +## Deterministic path + +```bash +python scripts/verify.py --checkpoint 3 +``` + +``` +Verifying Checkpoint 3... + ✅ expired staging grant: got Decision.NEEDS_APPROVAL, want Decision.NEEDS_APPROVAL + ✅ after revoke: deploy staging: got Decision.NEEDS_APPROVAL, want Decision.NEEDS_APPROVAL +PASS ✅ +``` + +This seeds a `--window-minutes 0` grant and confirms `decide()` falls back to `NEEDS_APPROVAL`, then +seeds a fresh 60-minute window, calls `revoke.py` against it, and confirms the same fallback — +proving both paths, expiry and revocation, land on the identical honest answer. + +--- + +## Completion Milestone: Checkpoint 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)` +- [ ] Saw `python bootstrap.py --window-minutes 0` drop the agent's staging autonomy to + `NEEDS_APPROVAL`, and `python revoke.py --env staging` do the same instantly +- [ ] Watched a grant's countdown and revocation in the web UI +- [ ] `python scripts/verify.py --checkpoint 3` prints `PASS ✅` +- [ ] Can explain why expiration evaluated inside `CheckPermission` beats a cron job that deletes + old relationships + +Next: [Checkpoint 4 — Relationship-based hierarchy](4-relationship-based-hierarchy.md) From ea479885c25b8e4b16ad148f6c357367eda127a6 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:22:38 +0200 Subject: [PATCH 10/27] =?UTF-8?q?docs(workshop):=20CP3=20fix=20round=201?= =?UTF-8?q?=20=E2=80=94=20web-UI=20grant=20card=20disappears=20(drop=20CP4?= =?UTF-8?q?=20suspended-state=20over-claim)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- delegated-agent-authorization/3-time-bound-and-revocable.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/delegated-agent-authorization/3-time-bound-and-revocable.md b/delegated-agent-authorization/3-time-bound-and-revocable.md index 6d68163..bd01d8e 100644 --- a/delegated-agent-authorization/3-time-bound-and-revocable.md +++ b/delegated-agent-authorization/3-time-bound-and-revocable.md @@ -137,8 +137,8 @@ gone on the very next check. Both are visible in the web UI: reset the demo, approve or bootstrap a grant, and watch its card in the **grants** panel — a live countdown with a shrinking bar. Let it hit zero, or hit **Revoke**, -and the card either disappears or flips to a dashed "suspended" state on the next 5-second poll, -because `/api/state` is calling the same `check()` you are. +and the staging card disappears from the panel on the next 5-second poll, because `/api/state`'s +`ReadRelationships` no longer returns the grant — expired or deleted, it's simply gone. --- From b175857e7c78c1bfd9fb72a31605ebe0f573da65 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:30:59 +0200 Subject: [PATCH 11/27] docs(workshop): CP4 relationship-based-hierarchy --- .../4-relationship-based-hierarchy.md | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 delegated-agent-authorization/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..0c506e5 --- /dev/null +++ b/delegated-agent-authorization/4-relationship-based-hierarchy.md @@ -0,0 +1,205 @@ +# Checkpoint 4 — Relationship-Based Hierarchy + +Checkpoint 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, not by anyone remembering to check. + +--- + +## Contingent authority — why RBAC can't express this + +The policy you want is: "the agent may deploy production on its own only while it can also deploy +staging on its own." That's not "the agent has role X," it's "the agent has role X *and* a second, +independent fact about a different resource currently holds." RBAC assigns roles to subjects and +stops there — a role is a static label, not a live query against another resource. To fake this +dependency in a role system you'd need a role that means "agent-with-current-staging-autonomy," and +you'd need to *maintain* it — write code somewhere that watches for staging revocation and +downgrades the production role in lockstep, by hand, every time. Miss one code path and the two +facts drift out of sync: production autonomy silently outlives the staging autonomy it was supposed +to depend on. + +ReBAC doesn't need a synchronization job, because the dependency isn't a copy of a fact — it's a +graph traversal that reads the live fact every time. You add one relation that says "this +environment is gated by that one," and a permission that walks it. There's nothing to keep in sync, +because there's nothing duplicated to begin with. + +--- + +## 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 Checkpoint 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`** — the payoff line. 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 Checkpoint 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 +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 + +Grant the agent both environments the way Checkpoint 2 and 3 taught you: + +```bash +python approve.py --approver alice --env production +``` + +Staging is already autonomous from the seed; production just got its own `agent_deployer` write +from `approve.py`, same as always — nothing about `approve.py` changed. Confirm both are live, in +the web UI (`python web.py`) 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: + +```bash +python revoke.py --env staging +``` + +`revoke.py` hasn't changed since Checkpoint 3 — it 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. A system message spells out why: *"alice revoked the staging delegation — production +autonomy is gated by it, so it suspends too."* + +Confirm it deterministically: + +```bash +python scripts/verify.py --checkpoint 4 +``` + +``` +Verifying Checkpoint 4... +✅ Approved: agent:goose_alice may deploy environment:production + ✅ with both grants: deploy production: got Decision.ALLOWED, want Decision.ALLOWED +✅ Revoked: agent:goose_alice agent_deployer on environment:staging + ✅ cascade: deploy staging: got Decision.NEEDS_APPROVAL, want Decision.NEEDS_APPROVAL + ✅ cascade: deploy production: got Decision.NEEDS_APPROVAL, want Decision.NEEDS_APPROVAL +PASS ✅ +``` + +The first check approves production and confirms both environments are live. The second revokes +only staging and confirms *both* fall back to `NEEDS_APPROVAL` — the cascade, asserted the same way +every other checkpoint's behavior was: by calling `decide()` directly against a live SpiceDB, no UI +or LLM required. + +--- + +## Suspend, not erase — why this is contingent evaluation + +Look again at what `revoke.py --env staging` actually deleted: +`environment:staging#agent_deployer@agent:goose_alice`. That's one tuple. The relationship +`environment:production#agent_deployer@agent:goose_alice` — the one `approve.py` 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. + +That's the distinction worth sitting with: this is **evaluation**, not **deletion**. A cascading +delete — the RBAC-shaped fix, where revoking a base role fires code that goes and deletes every +dependent grant — would have to walk every environment gated by staging and remove their +`agent_deployer` relationships one by one, and it would have to get that walk exactly right or leave +orphaned grants behind. `gated_by->agent_deployer` doesn't walk anything at write time. It's read at +*check* time, against whatever the graph currently says, every single time — the same way expiration +in Checkpoint 3 didn't need a cron job to notice a grant had gone stale. + +The proof is the revive. Re-grant staging — run `bootstrap.py` again, or write the relationship +directly — and, without touching production at all, `agent_deploy` on production goes straight back +to `ALLOWED`, for whatever's left of the window `approve.py` originally gave it. The production +relationship was never wrong; it was only ever asking a question whose answer depends on staging. +Suspend, not erase, is what lets a fact come back to life just by the thing it depends on coming +back — no re-approval, no re-run of `approve.py`, because the grant itself never went anywhere. + +--- + +## Completion Milestone: Checkpoint 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 +- [ ] Approved production, confirmed both environments `ALLOWED`, then revoked 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 +- [ ] `python scripts/verify.py --checkpoint 4` prints `PASS ✅` +- [ ] 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) From b27c200725eeb6eadd1d28930b11b419faad908b Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:24:54 +0200 Subject: [PATCH 12/27] docs(workshop): README, next steps, end-to-end validated --- delegated-agent-authorization/5-nextsteps.md | 120 +++++++++++++++++++ delegated-agent-authorization/README.md | 92 ++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 delegated-agent-authorization/5-nextsteps.md create mode 100644 delegated-agent-authorization/README.md diff --git a/delegated-agent-authorization/5-nextsteps.md b/delegated-agent-authorization/5-nextsteps.md new file mode 100644 index 0000000..e526e5d --- /dev/null +++ b/delegated-agent-authorization/5-nextsteps.md @@ -0,0 +1,120 @@ +# Next Steps + +Four checkpoints ago the agent could destroy production on a whim. Now every mutating call it +makes resolves through a relationship graph: delegated authority, a three-way decision, expiring +grants, and a hierarchy that suspends dependents automatically. That's the whole mechanism. What's +left is zooming out — how the same shape holds up once "one agent, two environments" becomes "many +agents, many resources, a real platform team on the other end of the pager." + +--- + +## 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, +not a special case in a `DeployService.deploy()` method, not something a second microservice +re-derives on its own. Add a new resource type to the platform — a database, a feature-flag +service, a CI pipeline — and it either reuses `direct_deployer` / `agent_deployer` / `gated_by` +directly or extends the same pattern by a line or two. Add a new agent and it's one `delegator` +edge. Nobody stands up a tenth bespoke permission system because the platform grew a tenth +resource type; the graph just gets one more kind of node in it. + +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 + +`decide()` 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: **"which of +these N resources may I touch at all?"** — before it decides what to do next, or before a UI +renders a list of options. + +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. (If you don't already have the candidate list — you want *every* resource a subject can reach, +not a filter over a known set — `LookupResources` is the streaming counterpart to reach for +instead.) + +## On-behalf-of vs. advisory enforcement + +Every check in this workshop is **on-behalf-of, blocking enforcement**: `decide()`'s answer isn't +a suggestion, it's the gate itself — `deploybot_server.py` calls it *before* touching +`infra_state.json`, and an `ALLOWED`/`NEEDS_APPROVAL`/`BLOCKED` verdict is the only way anything +happens. The agent never gets to act first and ask forgiveness later. + +That's the right default for anything that mutates state, but it's not the only mode worth +knowing. **Advisory enforcement** is when a check informs a decision without being the gate on +it — logged for audit, surfaced to a human reviewer, used to rank or filter results — while the +actual authority to act sits somewhere else. This workshop's own `list_environments` is an +advisory-shaped tool by design (its code comment says so directly: *"UNGATED in this workshop"*): +reads are lower stakes than a destroy, so it was left open to keep the checkpoints focused on the +mutating path. A real platform team draws that line deliberately, tool by tool, action class by +action class — not by defaulting everything to advisory because blocking enforcement takes more +wiring. + +## Where this workshop deliberately stopped short + +Two things were left ungated on purpose, to keep every checkpoint's diff small and centered on one +new idea at a time: + +- **`list_environments` has no permission check.** Every environment in `infra_state.json` is + visible to every agent, always. A production version gates it behind a `view` permission and + filters the listing to what the caller can actually see — exactly the `CheckBulkPermissions` + shape described above. +- **`revoke.py` has no permission check.** Anyone who can run the script can delete anyone's + delegation on any environment. A production version gates revocation behind a `manage` + permission, so only an environment's own operators can pull an agent's access. + +The [full reference solution](https://github.com/sohanmaheshwar/goose-spicedb-delegation) does +both. Its schema adds two permissions this workshop never defines: + +```zed +permission view = direct_deployer + approver + destroyer + agent_deploy +permission manage = direct_deployer +``` + +`view` includes `agent_deploy` deliberately — an agent that's lost its deploy autonomy (staging +revoked, cascade in effect) also loses visibility into the environment it can no longer touch, so +the same `gated_by` cascade from Checkpoint 4 hides a suspended environment from `list_environments` +as a side effect, with no extra code. `revoke.py` in the solution checks `manage` before it deletes +anything: + +```python +if not await check(client, "user", revoker, "manage", "environment", environment): + print(f"❌ Refused: user:{revoker} may not manage environment:{environment}") + return 1 +``` + +Same pattern as everything you built in this workshop — one more permission, one more `check()` +call, no new architecture. + +## Scaling ReBAC with SpiceDB + +SpiceDB is an open-source implementation of the model Google described in its 2019 +[Zanzibar paper](https://research.google/pubs/pub48190/) — the system that has authorized Google +Drive, Docs, and Calendar at global scale for years. The graph-walk you used for `gated_by` and +`delegator` in this workshop is the same primitive Zanzibar uses for Drive's folder-sharing +inheritance; deploy agents and shared documents turn out to be the same authorization problem +wearing different resource names. + +Everything here ran against a single local `spicedb serve` container with a Postgres datastore — +fine for a workshop, not how you'd run this for a real platform. In production, SpiceDB is +typically deployed via the +[SpiceDB Operator](https://authzed.com/docs/spicedb/ops/operator) on Kubernetes, which manages +the cluster, datastore migrations, and rolling upgrades as a `SpiceDBCluster` resource instead of +a docker-compose file you manage by hand. If you want to go from "SpiceDB on my laptop" to "SpiceDB +as a platform dependency," that's the next thing worth spending 90 minutes on — and more self-guided +workshops, including this one, live at +[`github.com/authzed/workshops`](https://github.com/authzed/workshops). + +--- + +That's the whole arc: one schema, one `decide()`, and a graph that scales by adding relationships +instead of adding code. diff --git a/delegated-agent-authorization/README.md b/delegated-agent-authorization/README.md new file mode 100644 index 0000000..ffc61ba --- /dev/null +++ b/delegated-agent-authorization/README.md @@ -0,0 +1,92 @@ +# Delegated Authorization for AI Agents: Build an Agent with Fine-Grained Permissions + +An AI agent doesn't ask permission before it acts — it inherits whatever its host process can +do. Point an agent at a set of credentials and "deploy checkout to staging" and "tear down +production" look identical to it: both are just tool calls that return `ALLOWED`, because nothing +downstream of the prompt is checking. In this hands-on workshop you build a DevOps deploy agent on +[goose](https://github.com/aaif-goose/goose) (the open-source agent from the Agentic AI +Foundation), watch it over-reach with no guardrail in place, then gate its every action with +delegated, fine-grained authorization from SpiceDB. By the end you'll have scoped grants, a +three-way decision (`ALLOWED` / `NEEDS_APPROVAL` / `BLOCKED`), time-bound windows with instant +revocation, and a relationship hierarchy where revoking one grant cascades to what depends on it — +the kind of policy a role-based system can't express without a synchronization job to keep it +honest. + +--- + +## 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 for free 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 system prompt — "never destroy production without approval" — +doesn't hold up. A prompt is a suggestion to a language model, not a control the system enforces; +it lives in the same channel as everything else the model reads, which means it can be argued +with, reworded around, or forgotten three turns into a longer conversation. 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` — never a silent yes +- Time-bound grants that expire on their own, for incident-style access windows, plus a + `revoke.py` for instant, on-demand revocation +- 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 and a deterministic CLI verifier (`scripts/verify.py`) so every checkpoint 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. Every checkpoint also has a deterministic path + (`scripts/verify.py` and a web UI) that needs no LLM at all. + +No prior authorization background is assumed. Comfort with a terminal, Python, and Docker is +enough. + +## The 90-minute module map + +| Module | Time | What you do | +| --- | --- | --- | +| [Setup](0-setup.md) | 15 min | Bring up Docker (or Codespaces), install dependencies, optionally register the goose extension | +| [Checkpoint 1 — Run the agent](1-run-the-agent.md) | 10 min | Run the agent ungated and watch it destroy production on request | +| [Checkpoint 2 — Delegated authorization](2-delegated-authorization.md) | 25 min | Write the ReBAC schema and implement the three-way `decide()` | +| [Checkpoint 3 — Time-bound and revocable](3-time-bound-and-revocable.md) | 15 min | Add expiring grants for incident windows, plus instant revocation | +| [Checkpoint 4 — Relationship-based hierarchy](4-relationship-based-hierarchy.md) | 20 min | Make production's autonomy contingent on staging's, and watch the cascade | +| [Next steps](5-nextsteps.md) | 5 min | Zoom out to a real platform: bulk checks, on-behalf-of enforcement, scaling ReBAC | + +That's 90 minutes end to end, self-guided — it works equally well live or worked through on your +own afterward. + +## The full reference solution + +Everything you write in this workshop — the schema, `decide()`, the checkpoint 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. [Checkpoint 1 — Run the agent](1-run-the-agent.md) +2. [Checkpoint 2 — Delegated authorization](2-delegated-authorization.md) +3. [Checkpoint 3 — Time-bound and revocable](3-time-bound-and-revocable.md) +4. [Checkpoint 4 — Relationship-based hierarchy](4-relationship-based-hierarchy.md) +5. [Next steps](5-nextsteps.md) + +Let's get started with [Setup](0-setup.md). From 8a4b7cb42d7c9636f8124f782b37242aa647ab1e Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:12:29 +0200 Subject: [PATCH 13/27] =?UTF-8?q?fix(workshop):=20final=20review=20?= =?UTF-8?q?=E2=80=94=20CP3=20reset=20note,=20Codespaces=20devcontainer,=20?= =?UTF-8?q?verbatim=20abstract,=20nextsteps=20fidelity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- delegated-agent-authorization/0-setup.md | 33 ++++++++++++++----- .../3-time-bound-and-revocable.md | 13 ++++++-- delegated-agent-authorization/5-nextsteps.md | 2 +- delegated-agent-authorization/README.md | 25 +++++++------- .../starter/.devcontainer/devcontainer.json | 2 +- 5 files changed, 50 insertions(+), 25 deletions(-) diff --git a/delegated-agent-authorization/0-setup.md b/delegated-agent-authorization/0-setup.md index 90660cb..08cda07 100644 --- a/delegated-agent-authorization/0-setup.md +++ b/delegated-agent-authorization/0-setup.md @@ -51,15 +51,30 @@ python3 -m venv .venv && source .venv/bin/activate && pip install -r requirement ## Option B - Run in GitHub Codespaces -For anyone who can't run Docker locally, Codespaces is the path. The repo ships with a -`.devcontainer/` config that handles everything automatically. - -1. On the repo page, click **Code ▸ Codespaces ▸ Create codespace on main** -2. On startup the devcontainer creates the `.venv`, installs dependencies into it, and runs - `docker compose up -d` — so the `.venv/bin/python` path the goose step below relies on exists - here too -3. Once the Codespace is ready, `cd delegated-agent-authorization/starter` and copy `.env.example` - to `.env` as in Option A + + +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. + +In short: Codespaces gets you most of the way there, but don't assume it's 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 diff --git a/delegated-agent-authorization/3-time-bound-and-revocable.md b/delegated-agent-authorization/3-time-bound-and-revocable.md index bd01d8e..a7df978 100644 --- a/delegated-agent-authorization/3-time-bound-and-revocable.md +++ b/delegated-agent-authorization/3-time-bound-and-revocable.md @@ -109,9 +109,16 @@ extra steps; this makes "approved for now" mean what it says. Waiting out a real timer to prove this works isn't worth your time, so `bootstrap.py` gives you a faster lever: seed a grant that's already expired. -```bash -python bootstrap.py --window-minutes 0 -``` +> **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 Checkpoint 2 seeded — still exist, so +> the very first `bootstrap.py` run this checkpoint 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 --window-minutes 0 +> ``` This writes the staging `agent_deployer` relationship with `expires_at` set to *right now* — by the time `CheckPermission` runs, it's already in the past. The agent's own `agent_deployer` check diff --git a/delegated-agent-authorization/5-nextsteps.md b/delegated-agent-authorization/5-nextsteps.md index e526e5d..54f7914 100644 --- a/delegated-agent-authorization/5-nextsteps.md +++ b/delegated-agent-authorization/5-nextsteps.md @@ -88,7 +88,7 @@ anything: ```python if not await check(client, "user", revoker, "manage", "environment", environment): - print(f"❌ Refused: user:{revoker} may not manage environment:{environment}") + print(f"❌ Refused: user:{revoker} may not manage environment:{environment} (revocation requires an env operator)") return 1 ``` diff --git a/delegated-agent-authorization/README.md b/delegated-agent-authorization/README.md index ffc61ba..7ea95b7 100644 --- a/delegated-agent-authorization/README.md +++ b/delegated-agent-authorization/README.md @@ -1,16 +1,19 @@ # Delegated Authorization for AI Agents: Build an Agent with Fine-Grained Permissions -An AI agent doesn't ask permission before it acts — it inherits whatever its host process can -do. Point an agent at a set of credentials and "deploy checkout to staging" and "tear down -production" look identical to it: both are just tool calls that return `ALLOWED`, because nothing -downstream of the prompt is checking. In this hands-on workshop you build a DevOps deploy agent on -[goose](https://github.com/aaif-goose/goose) (the open-source agent from the Agentic AI -Foundation), watch it over-reach with no guardrail in place, then gate its every action with -delegated, fine-grained authorization from SpiceDB. By the end you'll have scoped grants, a -three-way decision (`ALLOWED` / `NEEDS_APPROVAL` / `BLOCKED`), time-bound windows with instant -revocation, and a relationship hierarchy where revoking one grant cascades to what depends on it — -the kind of policy a role-based system can't express without a synchronization job to keep it -honest. +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. +This could lead to problems. + +This workshop will teach 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 then give it fine-grained permissions using Relationship-Based Access Control (ReBAC). Along +the way you'll get hands-on with the Google Zanzibar model of fine-grained authorization (ReBAC) +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. + +This is a self-guided, hands-on workshop, and everything runs locally with open-source tooling. --- diff --git a/delegated-agent-authorization/starter/.devcontainer/devcontainer.json b/delegated-agent-authorization/starter/.devcontainer/devcontainer.json index b767231..7d6fd11 100644 --- a/delegated-agent-authorization/starter/.devcontainer/devcontainer.json +++ b/delegated-agent-authorization/starter/.devcontainer/devcontainer.json @@ -2,6 +2,6 @@ "name": "delegated-agent-authorization", "image": "mcr.microsoft.com/devcontainers/python:3.12", "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {} }, - "postCreateCommand": "python -m venv .venv && .venv/bin/pip install -r requirements.txt && docker compose up -d", + "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] } From ed0ac53af68a26ce784b49db6d9821a0b2ac55ac Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:55:56 +0200 Subject: [PATCH 14/27] docs(workshop): humanizer + my-writing prose polish across all checkpoints Prose-only pass over README + 0-5 checkpoint docs: stripped AI-writing tells (em-dash overuse, rule-of-three, negative parallelism, -ing pileups, promotional words, inline-header bold lists, hedging) and brought the voice toward Sohan's (direct claims, analogy-first concept intros, contractions, dry restrained asides). All fenced code, commands, paths, Next links, and Completion Milestone checkboxes verified unchanged; starter code untouched. --- delegated-agent-authorization/0-setup.md | 34 ++++++------- .../1-run-the-agent.md | 31 +++++------ .../2-delegated-authorization.md | 51 +++++++++++-------- .../3-time-bound-and-revocable.md | 48 ++++++++--------- .../4-relationship-based-hierarchy.md | 9 ++-- delegated-agent-authorization/5-nextsteps.md | 28 +++++----- delegated-agent-authorization/README.md | 19 ++++--- 7 files changed, 114 insertions(+), 106 deletions(-) diff --git a/delegated-agent-authorization/0-setup.md b/delegated-agent-authorization/0-setup.md index 08cda07..628c187 100644 --- a/delegated-agent-authorization/0-setup.md +++ b/delegated-agent-authorization/0-setup.md @@ -1,12 +1,12 @@ # Setup In this workshop you build a DevOps deploy agent on [goose](https://github.com/aaif-goose/goose) -(the open-source agent from the Agentic AI Foundation), then gate its every action with -delegated, fine-grained authorization from SpiceDB — scoped grants, time-bound windows, instant -revocation, and a permission hierarchy where revoking a base grant cascades to what depends on -it. The `starter/` folder in this repo is stubbed on purpose: the plumbing (MCP extension, -docker-compose, seed/approve/revoke scripts, web UI) is provided, and you'll write the schema and -the decision engine yourself across the checkpoints. +(the open-source agent from the Agentic AI Foundation), then gate every action it takes with +delegated, fine-grained authorization from SpiceDB: scoped grants, time-bound windows, instant +revocation, and a permission hierarchy where revoking a base grant cascades to everything that +depends on it. The `starter/` folder in this repo is stubbed on purpose: the plumbing (MCP +extension, docker-compose, seed/approve/revoke scripts, web UI) is already there, and you'll +write the schema and the decision engine yourself across the checkpoints. ## Get the code @@ -23,10 +23,10 @@ Copy the example `.env` file: cp .env.example .env ``` -`.env` holds the SpiceDB connection details the app itself needs — endpoint, preshared token, +`.env` holds the SpiceDB connection details the app itself needs: endpoint, preshared token, and which agent identity the deploy bot acts as. Nothing in this repo talks to an LLM directly, so there's no LLM key in here. The LLM key only comes into play later, and only if you use the -goose path (see [Install goose](#install-goose-and-register-the-extension) below) — the +goose path (see [Install goose](#install-goose-and-register-the-extension) below). The deterministic path (`scripts/verify.py` and the web UI, introduced in later checkpoints) needs no LLM at all. @@ -36,9 +36,9 @@ Start the infrastructure: docker compose up -d --wait ``` -This brings up two containers — `postgres` (SpiceDB's datastore) and `spicedb` — plus a +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 +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) and `--enable-experimental-relationship-expiration` turned on, which later checkpoints use for time-bound grants. @@ -54,8 +54,8 @@ python3 -m venv .venv && source .venv/bin/activate && pip install -r requirement 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 +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 @@ -72,14 +72,14 @@ explicit about which folder you're opening: 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. -In short: Codespaces gets you most of the way there, but don't assume it's 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. +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 Installing goose is optional for this workshop. Every checkpoint has a second, deterministic way -to see the same decisions — `scripts/verify.py` plus a web UI, neither of which needs goose or an +to see the same decisions: `scripts/verify.py` plus a web UI, neither of which needs goose or an LLM key. 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. @@ -95,7 +95,7 @@ If you do want the goose path: `deploybot_server.py`, plus three env vars the extension needs to reach SpiceDB: `SPICEDB_ENDPOINT=localhost:50051`, `SPICEDB_TOKEN=devtoken`, `AGENT_SUBJECT=agent:goose_alice`. -`goose-extension.md` also has a manual verification checklist for once goose is wired up — worth +`goose-extension.md` also has a manual verification checklist for once goose is wired up. Worth skimming now, but there's nothing to run yet: SpiceDB has no authorization schema until Checkpoint 2, where you write it and the agent's decisions (via goose or the web UI) first come online. diff --git a/delegated-agent-authorization/1-run-the-agent.md b/delegated-agent-authorization/1-run-the-agent.md index 0f956ee..f3116d5 100644 --- a/delegated-agent-authorization/1-run-the-agent.md +++ b/delegated-agent-authorization/1-run-the-agent.md @@ -1,7 +1,7 @@ # Checkpoint 1 — Run the Agent (and Watch It Over-Reach) The goal here is simple: get the deploy agent running, then catch it doing something it should -never have been allowed to do — tearing down production — because right now nothing stops it. +never be allowed to do — tearing down production — because right now nothing stops it. --- @@ -10,7 +10,7 @@ never have been allowed to do — tearing down production — because right now `deploybot_server.py` is a goose MCP extension. It exposes three tools: - **`list_environments`** — lists every environment and the service versions deployed to it. - Read-only, and — by design, for this workshop — not authorization-checked. The code says so + It's read-only and, by design, not authorization-checked for this workshop. The code says so directly: ```python @@ -31,7 +31,7 @@ workshop is about. In Checkpoint 1, the boundary is a stub that never says no. ## `decide()` is a deliberate stub Open `authz.py`. `decide()` is the one function every mutating tool call goes through, and right -now it is honest about doing nothing: +now it's honest about doing nothing: ```python async def decide(client, agent_id, permission, environment_id) -> AuthzResult: @@ -45,7 +45,7 @@ async def decide(client, agent_id, permission, environment_id) -> AuthzResult: 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`. There is no schema yet for it to consult even if it wanted to — that arrives in +returns `ALLOWED`. There's no schema yet for it to consult even if it wanted to. That arrives in Checkpoint 2. Right now, an authorization call is just a formality the code performs on its way to doing whatever it was asked. @@ -64,7 +64,7 @@ Ask it to do something reasonable: > Deploy checkout to production. goose calls `deploy(service="checkout", environment="production")`. It comes back -**✅ ALLOWED**, and the version bumps. Fine so far — that's a real deploy engineer's job. +**✅ ALLOWED**, and the version bumps. Fine so far. That's a real deploy engineer's job. Now ask it to do something no agent should be able to decide on its own: @@ -78,7 +78,7 @@ just a comment for humans reading the code. Nothing enforces it. The stub doesn' 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 +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. The problem isn't the prompt. It's that nothing downstream of the prompt is checking. @@ -103,7 +103,7 @@ PASS ✅ ``` This calls the exact same `authz.decide()` that `deploybot_server.py` calls on every tool -invocation — `decide(client, "goose_alice", "destroy", "production")` — no LLM involved, no goose +invocation (`decide(client, "goose_alice", "destroy", "production")`) — no LLM involved, no goose required. It asks the stub the most dangerous question in the workshop, "can this agent destroy production," and the stub says yes. That's the whole bug, isolated to one deterministic assertion. @@ -115,7 +115,7 @@ The agent process holds one set of credentials — the `SPICEDB_TOKEN` and `AGEN environment — and 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 is no difference between "deploy a service" and "destroy production." Both are +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 @@ -124,13 +124,14 @@ running with root because it happened to be launched by root — not because any should have root. 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." Don't reach for that — a prompt is a -suggestion to a language model, not a control the system enforces. Nothing stops a differently -worded request, a longer conversation that talks the model out of its own guardrail, or a chain of -reasoning that convinces the agent this particular destroy is the exception. The instruction lives -in the same channel as everything else the model reads, which means it can be argued with. 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 Checkpoint 2 builds. +system prompt "never destroy production without approval." Don't reach for that. The prompt is not +a boundary — it's a suggestion to a language model, not a control the system enforces. Nothing +stops a differently worded request, a longer conversation that talks the model out of its own +guardrail, or a chain of reasoning that convinces the agent this particular destroy is the +exception. The instruction lives in the same channel as everything else the model reads, which +means it can be argued with. 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 Checkpoint 2 +builds. --- diff --git a/delegated-agent-authorization/2-delegated-authorization.md b/delegated-agent-authorization/2-delegated-authorization.md index a8ea8c2..d192497 100644 --- a/delegated-agent-authorization/2-delegated-authorization.md +++ b/delegated-agent-authorization/2-delegated-authorization.md @@ -8,20 +8,27 @@ first, or nobody involved is allowed to do this at all. --- -## ReBAC, in one paragraph +## Permissions are a graph, not a table -Most authorization systems people reach for first are **RBAC** — role-based access control. You -assign `alice` the role `deploy-engineer`, and a table somewhere says that role can deploy. It -works until you need "the person `alice` is deploying *for*" or "whoever approved this specific -change" — relationships between subjects and resources that a flat role list can't express without -exploding into a role per case. +Think about how a corporate card works when you hand it to an assistant. They can book flights and +cover the team's lunch, because you can — the card doesn't grant some separate blanket authority, +it borrows yours. A role called `has-corporate-card` can't express that; you'd need a new role for +every person an assistant might be borrowing authority from. Swap "assistant" for "AI agent" and +"corporate card" for "who gets to touch production," and that's this checkpoint's actual problem. + +**RBAC** — role-based access control — is what most systems reach for first, and it's a fine +choice when permissions really do attach to a role rather than a relationship: assign `alice` the +role `deploy-engineer`, a table somewhere says that role can deploy, done. It breaks down exactly +where the corporate-card problem lives — "the person `alice` is deploying *for*" or "whoever +approved this specific change" are relationships between subjects and resources that a flat role +list can't express without exploding into a role per case. **ReBAC** — relationship-based access control — models permissions as a graph instead: subjects, resources, and the relations between them, with permissions defined as graph traversals over those relations. This is the model Google published in 2019 as [**Zanzibar**](https://research.google/pubs/pub48190/), the system that authorizes Google Drive, Docs, and Calendar. SpiceDB is an open-source implementation of the same ideas. A permission check -in SpiceDB isn't a table lookup — it's "is there a path through the relationship graph from this +in SpiceDB isn't a table lookup. It's "is there a path through the relationship graph from this subject to this resource with this permission." That graph-walk is exactly what lets you model delegation directly: an `agent` node with a `delegator` edge to the `user` it acts for, and permissions that consider both. @@ -52,22 +59,22 @@ definition environment { Walking it: -- **`user`** — an empty definition. Humans don't need relations of their own here; they're +- `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 }`** — this is the delegation edge. An agent doesn't hold +- `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`** — the resource being deployed to, approved for, or destroyed. It has four +- `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` — a **union**. A human wired up as + - `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` — a human-only relation. Nothing gives an agent `approve`, + - `permission approve = approver` is a human-only relation. Nothing gives an agent `approve`, so agents can never approve their own grants. - - `permission destroy = destroyer` — deliberately its own relation, not folded into `deploy`. + - `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. @@ -88,14 +95,14 @@ 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 +- `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()` +- `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 - checkpoint hands the agent directly — staging autonomy, nothing more. + checkpoint 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 @@ -125,16 +132,16 @@ async def decide(client, agent_id, permission, environment_id) -> AuthzResult: `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, +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` +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: + 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 +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 @@ -228,8 +235,8 @@ in the middle, and checks the exact same four outcomes you just watched in the U Nothing in this checkpoint told the agent, in English, "you may deploy staging but not production, and never destroy anything." There's no system-prompt instruction to argue with, talk -around, or forget three turns into a conversation. The agent's tool call runs through -`deploybot_server._decide_and_mutate`, which calls `decide()` before it ever touches +around, or forget three turns into a conversation (the usual jailbreak playbook). 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 get the same diff --git a/delegated-agent-authorization/3-time-bound-and-revocable.md b/delegated-agent-authorization/3-time-bound-and-revocable.md index a7df978..05c3d9b 100644 --- a/delegated-agent-authorization/3-time-bound-and-revocable.md +++ b/delegated-agent-authorization/3-time-bound-and-revocable.md @@ -9,23 +9,23 @@ it early. --- -## Temporary access — incident windows +## Temporary access for incident windows The shape you want is: "this agent may deploy staging, but only for the next 60 minutes." Two ways to build that: -- **A caveat** — SpiceDB lets you attach a boolean expression to a relationship (`agent_deployer: +- **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, forever. -- **Relationship expiration** — SpiceDB has a built-in `optional_expires_at` field on a +- **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 — no caveat context to thread through, no expression to + relationship as if it were never written. No caveat context to thread through, no expression to get subtly wrong. Expiration wins here for a reason that matters beyond convenience: 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 +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. @@ -33,7 +33,7 @@ external polling for it. ## Schema edit: opt in, then mark the relation -Two changes to `schema.zed`. First, expiration is an opt-in schema feature — declare it at the top +Two changes to `schema.zed`. First, expiration is an opt-in schema feature. Declare it at the top of the file: ```zed @@ -57,7 +57,7 @@ definition environment { 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 +`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. @@ -65,10 +65,10 @@ know or care that one side of it can expire. That's the point: expiration is a p ## Update the seed -`bootstrap.py` already takes a `--window-minutes` flag and threads it into `seed()` — Checkpoint 2 -left the actual staging grant unexpiring, and this is where that gets fixed. Import -`expiry_from_now` from `authz` — it builds the protobuf `Timestamp` `optional_expires_at` expects -— and pass it into the staging `agent_deployer` write: +`bootstrap.py` already takes a `--window-minutes` flag and threads it into `seed()`. Checkpoint 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 from authz import expiry_from_now @@ -78,7 +78,7 @@ rel("environment", "staging", "agent_deployer", "agent", AGENT_ID, ``` `rel()` (in `relationships.py`) already accepts an `expires_at` keyword and threads it into -`optional_expires_at` — that plumbing was there from the start, waiting for the schema to allow it. +`optional_expires_at`. That plumbing was there from the start, waiting for the schema to allow it. Now every `bootstrap.py` run grants staging autonomy for exactly `window_minutes` from *now*, not forever. @@ -87,7 +87,7 @@ forever. ## Update `approve.py` The human-in-the-loop path needs the same fix. `approve.py` already takes `--minutes` -(default 10) and ignored it — Checkpoint 2's version wrote the grant with no expiry at all. Import +(default 10) and ignored it. Checkpoint 2's version wrote the grant with no expiry at all. Import `expiry_from_now` alongside the checks it already runs, and carry it into the write: ```python @@ -98,7 +98,7 @@ update = rel("environment", environment, "agent_deployer", "agent", agent_id, ``` Run `python approve.py --approver alice --env production` (or click **Approve prod · 10m** in the -web UI) and the resulting grant now expires 10 minutes later on its own — no follow-up step, no +web UI) and the resulting grant now expires 10 minutes later on its own. No follow-up step, no second script to remember to run. An approval that never expires was really a standing grant with extra steps; this makes "approved for now" mean what it says. @@ -120,7 +120,7 @@ faster lever: seed a grant that's already expired. > python bootstrap.py --window-minutes 0 > ``` -This writes the staging `agent_deployer` relationship with `expires_at` set to *right now* — by +This writes the staging `agent_deployer` relationship with `expires_at` set to *right now*. By the time `CheckPermission` runs, it's already in the past. The agent's own `agent_deployer` check on staging fails, `decide()` falls through to branch 2, and "deploy checkout to staging" — the one request that was unconditionally ✅ **ALLOWED** in Checkpoint 2 — now comes back @@ -128,7 +128,7 @@ request that was unconditionally ✅ **ALLOWED** in Checkpoint 2 — now comes b about `decide()` changed. The relationship it was reading simply isn't there anymore, as far as SpiceDB is concerned. -Now the other lever — instant revocation, for when you don't want to wait for any window to run +Now the other lever, instant revocation, for when you don't want to wait for any window to run out: ```bash @@ -137,31 +137,31 @@ python revoke.py --env staging `revoke.py` deletes the `agent_deployer` relationship on `staging` outright, using the same `agent_deployer_filter` helper `bootstrap.py` uses to reset it between runs. Re-run the check (or -ask the agent to deploy staging again) and you get the identical ⏸️ **NEEDS APPROVAL** — same +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 run one command and the grant is gone on the very next check. Both are visible in the web UI: reset the demo, approve or bootstrap a grant, and watch its card in -the **grants** panel — a live countdown with a shrinking bar. Let it hit zero, or hit **Revoke**, +the **grants** panel, a live countdown with a shrinking bar. Let it hit zero, or hit **Revoke**, and the staging card disappears from the panel on the next 5-second poll, because `/api/state`'s -`ReadRelationships` no longer returns the grant — expired or deleted, it's simply gone. +`ReadRelationships` no longer returns the grant. Expired or deleted, it's simply gone. --- ## Why contingent evaluation beats a cron job -A more obvious-looking fix might be: keep the grant unexpiring, and run a cron job that deletes -`agent_deployer` relationships older than an hour. Don't do that. A cron-based cleanup means the +The obvious fix looks like this: keep the grant unexpiring, and run a cron job that deletes +`agent_deployer` relationships older than an hour. Don't. 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. Someone deploys at minute 59:58 using a grant that's "supposed to" be gone; whether that succeeds depends on scheduler jitter, not policy. 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 — two sources of truth for one fact. +about what "expired" means. Two sources of truth for one fact. Relationship expiration collapses that back to one. There's no janitor process to keep alive: 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 +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, but it's a housekeeping detail, not part of the authorization decision. The boundary is exactly as tight as `CheckPermission` itself. @@ -182,7 +182,7 @@ PASS ✅ ``` This seeds a `--window-minutes 0` grant and confirms `decide()` falls back to `NEEDS_APPROVAL`, then -seeds a fresh 60-minute window, calls `revoke.py` against it, and confirms the same fallback — +seeds a fresh 60-minute window, calls `revoke.py` against it, and confirms the same fallback, proving both paths, expiry and revocation, land on the identical honest answer. --- diff --git a/delegated-agent-authorization/4-relationship-based-hierarchy.md b/delegated-agent-authorization/4-relationship-based-hierarchy.md index 0c506e5..fdebfb9 100644 --- a/delegated-agent-authorization/4-relationship-based-hierarchy.md +++ b/delegated-agent-authorization/4-relationship-based-hierarchy.md @@ -13,9 +13,10 @@ staging's, enforced by the graph, not by anyone remembering to check. ## Contingent authority — why RBAC can't express this The policy you want is: "the agent may deploy production on its own only while it can also deploy -staging on its own." That's not "the agent has role X," it's "the agent has role X *and* a second, -independent fact about a different resource currently holds." RBAC assigns roles to subjects and -stops there — a role is a static label, not a live query against another resource. To fake this +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." RBAC assigns roles to subjects and stops there — a role is a +static label, not a live query against another resource. To fake this dependency in a role system you'd need a role that means "agent-with-current-staging-autonomy," and you'd need to *maintain* it — write code somewhere that watches for staging revocation and downgrades the production role in lockstep, by hand, every time. Miss one code path and the two @@ -128,7 +129,7 @@ python revoke.py --env staging 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 +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. diff --git a/delegated-agent-authorization/5-nextsteps.md b/delegated-agent-authorization/5-nextsteps.md index 54f7914..40e2503 100644 --- a/delegated-agent-authorization/5-nextsteps.md +++ b/delegated-agent-authorization/5-nextsteps.md @@ -11,13 +11,13 @@ agents, many resources, a real platform team on the other end of the pager." ## 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, +the entire idea of delegation, expressed once, as a graph edge: not a column on a `deploys` table, not a special case in a `DeployService.deploy()` method, not something a second microservice re-derives on its own. Add a new resource type to the platform — a database, a feature-flag service, a CI pipeline — and it either reuses `direct_deployer` / `agent_deployer` / `gated_by` directly or extends the same pattern by a line or two. Add a new agent and it's one `delegator` edge. Nobody stands up a tenth bespoke permission system because the platform grew a tenth -resource type; the graph just gets one more kind of node in it. +resource type; 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 @@ -26,10 +26,10 @@ every service that needs an answer asks the same graph the same kind of question ## `CheckBulkPermissions` — asking about many resources at once `decide()` 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: **"which of -these N resources may I touch at all?"** — before it decides what to do next, or before a UI -renders a list of options. +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. @@ -38,14 +38,14 @@ 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. (If you don't already have the candidate list — you want *every* resource a subject can reach, -not a filter over a known set — `LookupResources` is the streaming counterpart to reach for +N. (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.) ## On-behalf-of vs. advisory enforcement -Every check in this workshop is **on-behalf-of, blocking enforcement**: `decide()`'s answer isn't -a suggestion, it's the gate itself — `deploybot_server.py` calls it *before* touching +Every check in this workshop is **on-behalf-of, blocking enforcement**. `decide()`'s answer isn't +a suggestion; it's the gate itself: `deploybot_server.py` calls it *before* touching `infra_state.json`, and an `ALLOWED`/`NEEDS_APPROVAL`/`BLOCKED` verdict is the only way anything happens. The agent never gets to act first and ask forgiveness later. @@ -64,11 +64,11 @@ wiring. Two things were left ungated on purpose, to keep every checkpoint's diff small and centered on one new idea at a time: -- **`list_environments` has no permission check.** Every environment in `infra_state.json` is +- `list_environments` has no permission check. Every environment in `infra_state.json` is visible to every agent, always. A production version gates it behind a `view` permission and filters the listing to what the caller can actually see — exactly the `CheckBulkPermissions` shape described above. -- **`revoke.py` has no permission check.** Anyone who can run the script can delete anyone's +- `revoke.py` has no permission check. Anyone who can run the script can delete anyone's delegation on any environment. A production version gates revocation behind a `manage` permission, so only an environment's own operators can pull an agent's access. @@ -104,8 +104,8 @@ Drive, Docs, and Calendar at global scale for years. The graph-walk you used for inheritance; deploy agents and shared documents turn out to be the same authorization problem wearing different resource names. -Everything here ran against a single local `spicedb serve` container with a Postgres datastore — -fine for a workshop, not how you'd run this for a real platform. In production, SpiceDB is +Everything here ran against a single local `spicedb serve` container with a Postgres datastore. +That's fine for a workshop, not how you'd run this for a real platform. In production, SpiceDB is typically deployed via the [SpiceDB Operator](https://authzed.com/docs/spicedb/ops/operator) on Kubernetes, which manages the cluster, datastore migrations, and rolling upgrades as a `SpiceDBCluster` resource instead of diff --git a/delegated-agent-authorization/README.md b/delegated-agent-authorization/README.md index 7ea95b7..8cc635e 100644 --- a/delegated-agent-authorization/README.md +++ b/delegated-agent-authorization/README.md @@ -4,16 +4,16 @@ DevOps and Platform teams now work with hundreds of AI Agents in their internal Agents usually run with credentials that can touch everything, including your production servers. This could lead to problems. -This workshop will teach you how to add delegated authorization to your AI Agents, at scale. +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 then give it fine-grained permissions using Relationship-Based Access Control (ReBAC). Along -the way you'll get hands-on with the Google Zanzibar model of fine-grained authorization (ReBAC) -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. +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. -This is a self-guided, hands-on workshop, and everything runs locally with open-source tooling. +It's self-guided and hands-on, and everything runs locally with open-source tooling. --- @@ -72,8 +72,7 @@ enough. | [Checkpoint 4 — Relationship-based hierarchy](4-relationship-based-hierarchy.md) | 20 min | Make production's autonomy contingent on staging's, and watch the cascade | | [Next steps](5-nextsteps.md) | 5 min | Zoom out to a real platform: bulk checks, on-behalf-of enforcement, scaling ReBAC | -That's 90 minutes end to end, self-guided — it works equally well live or worked through on your -own afterward. +That's 90 minutes end to end, self-guided — it works equally well live or self-paced afterward. ## The full reference solution From 51df6c8151af7dfef9c1e1e17e8d4eb1c250ab98 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:42:21 +0200 Subject: [PATCH 15/27] Web-UI-only operation; relationship expiration is GA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operate the deploybot entirely through the web UI (plus the optional goose session). Remove the deterministic CLI verifier and the CLI approve/revoke paths so learners drive every checkpoint from one surface. - Delete starter/scripts/verify.py; drop all `scripts/verify.py --checkpoint N` references and "deterministic path" framing across every checkpoint. - Replace `python approve.py ...` / `python revoke.py ...` steps with the web UI buttons (Approve prod · 10m, Revoke staging, Revoke prod). - CP3: replace `bootstrap.py --window-minutes 0` with a new "Grant staging · 30s" button (web.py /api/grant-short) so learners watch a grant expire live in the authority bar instead of seeding an already-expired grant. - CP1: the web UI is now the primary over-reach path; goose is optional. - CP4: fix the revive step — re-grant staging via "Grant staging · 30s" (which writes only staging); `bootstrap.py` would wipe the prod grant via _reset_agent_grants and break the suspend-not-erase demo. - Relationship expiration is a built-in SpiceDB feature as of v1.56: drop the `--enable-experimental-relationship-expiration` flag from docker-compose (plain `serve`) and the flag note from 0-setup. - Make /api/state fully defensive so the UI loads in CP1 (no schema yet). Verified end to end on SpiceDB v1.56.1: CP1 no-schema tolerance + over-reach, the 3-way decision, short-grant expiry, approve/revoke, and the gated_by cascade (revoke staging suspends production). --- delegated-agent-authorization/0-setup.md | 24 +++--- .../1-run-the-agent.md | 41 +++++---- .../2-delegated-authorization.md | 40 ++------- .../3-time-bound-and-revocable.md | 83 ++++++------------ .../4-relationship-based-hierarchy.md | 74 +++++----------- delegated-agent-authorization/5-nextsteps.md | 7 +- delegated-agent-authorization/README.md | 12 +-- .../starter/docker-compose.yml | 6 +- .../starter/goose-extension.md | 16 ++-- .../starter/scripts/verify.py | 85 ------------------- .../starter/static/index.html | 14 +++ delegated-agent-authorization/starter/web.py | 79 ++++++++++++----- 12 files changed, 181 insertions(+), 300 deletions(-) delete mode 100644 delegated-agent-authorization/starter/scripts/verify.py diff --git a/delegated-agent-authorization/0-setup.md b/delegated-agent-authorization/0-setup.md index 628c187..f5a4d8e 100644 --- a/delegated-agent-authorization/0-setup.md +++ b/delegated-agent-authorization/0-setup.md @@ -5,7 +5,7 @@ In this workshop you build a DevOps deploy agent on [goose](https://github.com/a delegated, fine-grained authorization from SpiceDB: scoped grants, time-bound windows, instant revocation, and a permission hierarchy where revoking a base grant cascades to everything that depends on it. The `starter/` folder in this repo is stubbed on purpose: the plumbing (MCP -extension, docker-compose, seed/approve/revoke scripts, web UI) is already there, and you'll +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 checkpoints. ## Get the code @@ -26,9 +26,8 @@ cp .env.example .env `.env` holds the SpiceDB connection details the app itself needs: endpoint, preshared token, and which agent identity the deploy bot acts as. Nothing in this repo talks to an LLM directly, so there's no LLM key in here. The LLM key only comes into play later, and only if you use the -goose path (see [Install goose](#install-goose-and-register-the-extension) below). The -deterministic path (`scripts/verify.py` and the web UI, introduced in later checkpoints) needs no -LLM at all. +goose path (see [Install goose](#install-goose-and-register-the-extension) below). The web UI you +drive every checkpoint from (introduced in Checkpoint 1) needs no LLM at all. Start the infrastructure: @@ -39,9 +38,9 @@ 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) and -`--enable-experimental-relationship-expiration` turned on, which later checkpoints use for -time-bound grants. +on `localhost:50051` with a preshared key of `devtoken` (not recommended for prod, obviously). +Relationship expiration — which Checkpoint 3 uses for time-bound grants — is built into SpiceDB, so +there's no flag to enable. Create a virtual environment and install dependencies: @@ -78,10 +77,9 @@ step above if they don't. ## Install goose and register the extension -Installing goose is optional for this workshop. Every checkpoint has a second, deterministic way -to see the same decisions: `scripts/verify.py` plus a web UI, neither of which needs goose or an -LLM key. 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. +Installing goose is optional for this workshop. Every checkpoint runs through a web UI that needs +neither goose nor an LLM key. 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: @@ -96,9 +94,9 @@ If you do want the goose path: `SPICEDB_ENDPOINT=localhost:50051`, `SPICEDB_TOKEN=devtoken`, `AGENT_SUBJECT=agent:goose_alice`. `goose-extension.md` also has a manual verification checklist for once goose is wired up. Worth -skimming now, but there's nothing to run yet: SpiceDB has no authorization schema until +skimming now, but there's nothing to verify yet: SpiceDB has no authorization schema until Checkpoint 2, where you write it and the agent's decisions (via goose or the web UI) first come -online. +online. Checkpoint 1 is next, and it drives the agent from the web UI to watch it over-reach. --- diff --git a/delegated-agent-authorization/1-run-the-agent.md b/delegated-agent-authorization/1-run-the-agent.md index f3116d5..9c935a8 100644 --- a/delegated-agent-authorization/1-run-the-agent.md +++ b/delegated-agent-authorization/1-run-the-agent.md @@ -51,26 +51,28 @@ doing whatever it was asked. --- -## Watch it over-reach (goose path) +## Watch it over-reach — the web UI -If you registered the `deploybot` extension in setup, open a session: +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 -goose session +python web.py ``` -Ask it to do something reasonable: +Open `http://127.0.0.1:8000`. Type something reasonable into the request box: > Deploy checkout to production. -goose calls `deploy(service="checkout", environment="production")`. It comes back +The UI calls `deploy(service="checkout", environment="production")`. It comes back **✅ ALLOWED**, and the version bumps. Fine so far. That's a real deploy engineer's job. Now ask it to do something no agent should be able to decide on its own: > Tear down the production environment. -goose calls `destroy(environment="production")`. It comes back **✅ ALLOWED**, and production is +The UI calls `destroy(environment="production")`. It comes back **✅ ALLOWED**, and production is gone. No pause, no approval step, no distinction between "deploy a service" and "delete an entire environment." The tool's own docstring says destroying "requires elevated authority" — but that's just a comment for humans reading the code. Nothing enforces it. The stub doesn't look at @@ -85,27 +87,22 @@ downstream of the prompt is checking. --- -## Deterministic path +## Or drive it with goose (optional) -Don't have goose installed, or want a repeatable check instead of a live LLM session? From -`starter/`, with SpiceDB up: +If you installed goose and registered the `deploybot` extension in setup, open a session and give +it the same two requests: ```bash -python scripts/verify.py --checkpoint 1 +goose session ``` -Output: - -``` -Verifying Checkpoint 1... - ✅ stub allows destroy production (over-reach): got Decision.ALLOWED, want Decision.ALLOWED -PASS ✅ -``` +> Deploy checkout to production. +> +> Tear down the production environment. -This calls the exact same `authz.decide()` that `deploybot_server.py` calls on every tool -invocation (`decide(client, "goose_alice", "destroy", "production")`) — no LLM involved, no goose -required. It asks the stub the most dangerous question in the workshop, "can this agent destroy -production," and the stub says yes. That's the whole bug, isolated to one deterministic assertion. +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 bug, driven by a real +LLM instead of the request box. --- @@ -137,7 +134,7 @@ builds. ## Completion Milestone: Checkpoint 1 -- [ ] Ran the agent — via `goose session`, `scripts/verify.py --checkpoint 1`, or both +- [ ] 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 diff --git a/delegated-agent-authorization/2-delegated-authorization.md b/delegated-agent-authorization/2-delegated-authorization.md index d192497..7abfcc4 100644 --- a/delegated-agent-authorization/2-delegated-authorization.md +++ b/delegated-agent-authorization/2-delegated-authorization.md @@ -180,18 +180,15 @@ Try the three cases: - **"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** (or run the CLI directly, see below), then retry the production -deploy — it flips to ✅ **ALLOWED**. Nothing about `decide()` changed; the relationship graph did. +Click **Approve prod · 10m**, then retry the production deploy — it flips to ✅ **ALLOWED**. +Nothing about `decide()` changed; the relationship graph did. -### `approve.py` — the human in the loop +### Approve — the human in the loop -```bash -python approve.py --approver alice --env production -``` - -Before writing anything, `approve.py` 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 — +**Approve prod · 10m** is the approval path, and it's deliberately dull under the hood. 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` — 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 @@ -211,24 +208,6 @@ to production", "Tear down the production environment" — and watch the identic ✅ / ⏸️ / 🚫 verdicts come back, because goose is calling the same `deploybot_server.py` tools the web UI calls, gated by the same `decide()`. -### Deterministic path - -```bash -python scripts/verify.py --checkpoint 2 -``` - -``` -Verifying Checkpoint 2... - ✅ agent deploy staging: got Decision.ALLOWED, want Decision.ALLOWED - ✅ agent deploy production: got Decision.NEEDS_APPROVAL, want Decision.NEEDS_APPROVAL - ✅ agent destroy production: got Decision.BLOCKED, want Decision.BLOCKED - ✅ after approve: deploy production: got Decision.ALLOWED, want Decision.ALLOWED -PASS ✅ -``` - -No LLM, no goose — this calls your `decide()` directly against a live SpiceDB, runs `approve.py` -in the middle, and checks the exact same four outcomes you just watched in the UI or goose. - --- ## Why the check — not the prompt — is the boundary @@ -253,9 +232,8 @@ care what the model believes, only what the graph says. permissions - [ ] Seeded the graph with `python bootstrap.py` - [ ] Implemented the three-way `decide()` in `authz.py` -- [ ] `python scripts/verify.py --checkpoint 2` prints `PASS ✅` -- [ ] Saw all three decisions — `ALLOWED`, `NEEDS_APPROVAL`, `BLOCKED` — in the web UI and/or - goose, and watched `approve.py` flip production to `ALLOWED` +- [ ] 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 diff --git a/delegated-agent-authorization/3-time-bound-and-revocable.md b/delegated-agent-authorization/3-time-bound-and-revocable.md index 05c3d9b..ee43030 100644 --- a/delegated-agent-authorization/3-time-bound-and-revocable.md +++ b/delegated-agent-authorization/3-time-bound-and-revocable.md @@ -97,55 +97,45 @@ update = rel("environment", environment, "agent_deployer", "agent", agent_id, expires_at=expiry_from_now(minutes)) ``` -Run `python approve.py --approver alice --env production` (or click **Approve prod · 10m** in the -web UI) and the resulting grant now expires 10 minutes later on its own. No follow-up step, no -second script to remember to run. An approval that never expires was really a standing grant with -extra steps; this makes "approved for now" mean what it says. +Now clicking **Approve prod · 10m** in the web UI writes a grant that expires 10 minutes later on +its own. No follow-up step, nothing to remember to undo. An approval that never expires was really +a standing grant with extra steps; this makes "approved for now" mean what it says. --- ## See expiry without waiting -Waiting out a real timer to prove this works isn't worth your time, so `bootstrap.py` gives you a -faster lever: seed a grant that's already expired. +Waiting out a 60-minute window to prove this works isn't worth your time, so the web UI gives you a +faster lever: **Grant staging · 30s**, a 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 Checkpoint 2 seeded — still exist, so -> the very first `bootstrap.py` run this checkpoint fails on `WriteSchema` before it ever gets to +> the first `bootstrap.py` run this checkpoint 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 --window-minutes 0 +> python bootstrap.py > ``` -This writes the staging `agent_deployer` relationship with `expires_at` set to *right now*. By -the time `CheckPermission` runs, it's already in the past. The agent's own `agent_deployer` check -on staging fails, `decide()` falls through to branch 2, and "deploy checkout to staging" — the one -request that was unconditionally ✅ **ALLOWED** in Checkpoint 2 — now comes back -⏸️ **NEEDS APPROVAL** instead, with Alice named as the delegator who'd have to approve it. Nothing -about `decide()` changed. The relationship it was reading simply isn't there anymore, as far as -SpiceDB is concerned. - -Now the other lever, instant revocation, for when you don't want to wait for any window to run -out: - -```bash -python revoke.py --env staging -``` - -`revoke.py` deletes the `agent_deployer` relationship on `staging` outright, using the same -`agent_deployer_filter` helper `bootstrap.py` uses to reset it between runs. Re-run the check (or -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 run one command and the grant is -gone on the very next check. - -Both are visible in the web UI: reset the demo, approve or bootstrap a grant, and watch its card in -the **grants** panel, a live countdown with a shrinking bar. Let it hit zero, or hit **Revoke**, -and the staging card disappears from the panel on the next 5-second poll, because `/api/state`'s -`ReadRelationships` no longer returns the grant. Expired or deleted, it's simply gone. +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 Checkpoint 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. --- @@ -168,34 +158,13 @@ decision. The boundary is exactly as tight as `CheckPermission` itself. --- -## Deterministic path - -```bash -python scripts/verify.py --checkpoint 3 -``` - -``` -Verifying Checkpoint 3... - ✅ expired staging grant: got Decision.NEEDS_APPROVAL, want Decision.NEEDS_APPROVAL - ✅ after revoke: deploy staging: got Decision.NEEDS_APPROVAL, want Decision.NEEDS_APPROVAL -PASS ✅ -``` - -This seeds a `--window-minutes 0` grant and confirms `decide()` falls back to `NEEDS_APPROVAL`, then -seeds a fresh 60-minute window, calls `revoke.py` against it, and confirms the same fallback, -proving both paths, expiry and revocation, land on the identical honest answer. - ---- - ## Completion Milestone: Checkpoint 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)` -- [ ] Saw `python bootstrap.py --window-minutes 0` drop the agent's staging autonomy to - `NEEDS_APPROVAL`, and `python revoke.py --env staging` do the same instantly -- [ ] Watched a grant's countdown and revocation in the web UI -- [ ] `python scripts/verify.py --checkpoint 3` prints `PASS ✅` +- [ ] 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 diff --git a/delegated-agent-authorization/4-relationship-based-hierarchy.md b/delegated-agent-authorization/4-relationship-based-hierarchy.md index fdebfb9..e7bf9c3 100644 --- a/delegated-agent-authorization/4-relationship-based-hierarchy.md +++ b/delegated-agent-authorization/4-relationship-based-hierarchy.md @@ -106,25 +106,17 @@ the bare `agent_deployer` relation. ## See the cascade -Grant the agent both environments the way Checkpoint 2 and 3 taught you: +Start the web UI (`python web.py`) and grant the agent both environments the way Checkpoints 2 and 3 +taught you. Staging is already autonomous from the seed; click **Approve prod · 10m** to give +production its own `agent_deployer` write — same button, same one-relationship approval as always, +nothing about it changed. 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. -```bash -python approve.py --approver alice --env production -``` - -Staging is already autonomous from the seed; production just got its own `agent_deployer` write -from `approve.py`, same as always — nothing about `approve.py` changed. Confirm both are live, in -the web UI (`python web.py`) 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: +Now pull the base out from under it: click **Revoke staging**. -```bash -python revoke.py --env staging -``` - -`revoke.py` hasn't changed since Checkpoint 3 — it deletes exactly one relationship, +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 @@ -136,37 +128,17 @@ two environments affected, because the second one was never independent to begin 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. A system message spells out why: *"alice revoked the staging delegation — production -autonomy is gated by it, so it suspends too."* - -Confirm it deterministically: - -```bash -python scripts/verify.py --checkpoint 4 -``` - -``` -Verifying Checkpoint 4... -✅ Approved: agent:goose_alice may deploy environment:production - ✅ with both grants: deploy production: got Decision.ALLOWED, want Decision.ALLOWED -✅ Revoked: agent:goose_alice agent_deployer on environment:staging - ✅ cascade: deploy staging: got Decision.NEEDS_APPROVAL, want Decision.NEEDS_APPROVAL - ✅ cascade: deploy production: got Decision.NEEDS_APPROVAL, want Decision.NEEDS_APPROVAL -PASS ✅ -``` - -The first check approves production and confirms both environments are live. The second revokes -only staging and confirms *both* fall back to `NEEDS_APPROVAL` — the cascade, asserted the same way -every other checkpoint's behavior was: by calling `decide()` directly against a live SpiceDB, no UI -or LLM required. +autonomy is gated by it, so it suspends too."* One click on **Revoke staging**, two environments +affected, and no write ever touched production's own relationships. --- ## Suspend, not erase — why this is contingent evaluation -Look again at what `revoke.py --env staging` actually deleted: +Look again at what **Revoke staging** actually deleted: `environment:staging#agent_deployer@agent:goose_alice`. That's one tuple. The relationship -`environment:production#agent_deployer@agent:goose_alice` — the one `approve.py` wrote — is still -sitting in the graph, completely untouched. `agent_deploy` on production went from `ALLOWED` to +`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. @@ -179,12 +151,13 @@ orphaned grants behind. `gated_by->agent_deployer` doesn't walk anything at writ *check* time, against whatever the graph currently says, every single time — the same way expiration in Checkpoint 3 didn't need a cron job to notice a grant had gone stale. -The proof is the revive. Re-grant staging — run `bootstrap.py` again, or write the relationship -directly — and, without touching production at all, `agent_deploy` on production goes straight back -to `ALLOWED`, for whatever's left of the window `approve.py` originally gave it. The production -relationship was never wrong; it was only ever asking a question whose answer depends on staging. -Suspend, not erase, is what lets a fact come back to life just by the thing it depends on coming -back — no re-approval, no re-run of `approve.py`, because the grant itself never went anywhere. +The proof is the revive. Re-grant staging — click **Grant staging · 30s**, which writes only +staging's `agent_deployer` relationship — and, without touching production at all, `agent_deploy` on +production goes straight back to `ALLOWED`, for whatever's left of the window **Approve prod · 10m** +originally gave it. The production relationship was never wrong; it was only ever asking a question +whose answer depends on staging. Suspend, not erase, is what lets a fact come back to life just by +the thing it depends on coming back — no re-approval, no second click of **Approve prod · 10m**, +because the grant itself never went anywhere. --- @@ -194,11 +167,10 @@ back — no re-approval, no re-run of `approve.py`, because the grant itself nev 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 -- [ ] Approved production, confirmed both environments `ALLOWED`, then revoked staging and watched - production fall back to `NEEDS_APPROVAL` with no second delete +- [ ] 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 -- [ ] `python scripts/verify.py --checkpoint 4` prints `PASS ✅` - [ ] 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 diff --git a/delegated-agent-authorization/5-nextsteps.md b/delegated-agent-authorization/5-nextsteps.md index 40e2503..4af588e 100644 --- a/delegated-agent-authorization/5-nextsteps.md +++ b/delegated-agent-authorization/5-nextsteps.md @@ -68,9 +68,10 @@ new idea at a time: visible to every agent, always. A production version gates it behind a `view` permission and filters the listing to what the caller can actually see — exactly the `CheckBulkPermissions` shape described above. -- `revoke.py` has no permission check. Anyone who can run the script can delete anyone's - delegation on any environment. A production version gates revocation behind a `manage` - permission, so only an environment's own operators can pull an agent's access. +- Revocation has no permission check. `revoke.py` — the helper behind the web UI's **Revoke** + buttons — deletes a delegation without checking who's asking, so anyone who can reach the UI can + pull any agent's access on any environment. A production version gates revocation behind a + `manage` permission, so only an environment's own operators can pull an agent's access. The [full reference solution](https://github.com/sohanmaheshwar/goose-spicedb-delegation) does both. Its schema adds two permissions this workshop never defines: diff --git a/delegated-agent-authorization/README.md b/delegated-agent-authorization/README.md index 8cc635e..613d4df 100644 --- a/delegated-agent-authorization/README.md +++ b/delegated-agent-authorization/README.md @@ -43,20 +43,20 @@ matter how many different ways — or how many different agents — ask it. 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` — never a silent yes -- Time-bound grants that expire on their own, for incident-style access windows, plus a - `revoke.py` for instant, on-demand revocation +- 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 and a deterministic CLI verifier (`scripts/verify.py`) so every checkpoint is - confirmable without an LLM in the loop +- A web UI that drives every action — request, approve, revoke, watch a grant expire live — and + shows exactly what SpiceDB decides, so every checkpoint 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. Every checkpoint also has a deterministic path - (`scripts/verify.py` and a web UI) that needs no LLM at all. + in natural language. The web UI drives every checkpoint without an LLM, so goose is optional + throughout. No prior authorization background is assumed. Comfort with a terminal, Python, and Docker is enough. diff --git a/delegated-agent-authorization/starter/docker-compose.yml b/delegated-agent-authorization/starter/docker-compose.yml index 2204222..68dcdc0 100644 --- a/delegated-agent-authorization/starter/docker-compose.yml +++ b/delegated-agent-authorization/starter/docker-compose.yml @@ -26,9 +26,9 @@ services: spicedb: image: authzed/spicedb:latest - # --enable-experimental-relationship-expiration turns on the built-in - # relationship expiration feature used by `agent_deployer: agent with expiration`. - command: "serve --enable-experimental-relationship-expiration" + # 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 diff --git a/delegated-agent-authorization/starter/goose-extension.md b/delegated-agent-authorization/starter/goose-extension.md index c0794b2..9e34c6e 100644 --- a/delegated-agent-authorization/starter/goose-extension.md +++ b/delegated-agent-authorization/starter/goose-extension.md @@ -38,10 +38,9 @@ goose configure 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 decision sequence is proven -deterministically and repeatably by `scripts/verify.py`, which calls `authz.decide` (the same -function `deploybot_server.py` calls on every tool invocation) directly against a real SpiceDB -instance. Run it per checkpoint, e.g. `python scripts/verify.py --checkpoint 2`. +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: @@ -54,10 +53,9 @@ 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 another terminal: `python approve.py --approver alice --env production` → then in goose "try the production deploy again" → **✅ ALLOWED**. +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 another terminal: `python revoke.py --env staging` → then in goose "deploy checkout to staging again" → **⏸️ NEEDS APPROVAL**. +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, this step is skipped and the arc is -covered by `scripts/verify.py` (which exercises the identical decision sequence -deterministically — run `python scripts/verify.py --checkpoint 2` and up). +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/scripts/verify.py b/delegated-agent-authorization/starter/scripts/verify.py deleted file mode 100644 index c8462f2..0000000 --- a/delegated-agent-authorization/starter/scripts/verify.py +++ /dev/null @@ -1,85 +0,0 @@ -"""verify.py — deterministic checks for the current checkpoint. No LLM needed. - -Usage: python scripts/verify.py --checkpoint N -Run from the starter/ directory with SpiceDB up. -""" -import argparse -import asyncio -import sys -from pathlib import Path - -# Invoked as `python scripts/verify.py`, so Python puts scripts/ (not starter/) on -# sys.path — add the parent directory so the top-level modules below resolve. -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from authz import Decision, decide -import bootstrap -from approve import approve -from revoke import revoke -from spicedb_client import make_client - -AGENT = "goose_alice" - - -def _ok(label, got, want): - mark = "✅" if got == want else "❌" - print(f" {mark} {label}: got {got}, want {want}") - return got == want - - -async def checkpoint_1(): - # The stub decides ALLOWED for everything — the over-reach. - r = await decide(make_client(), AGENT, "destroy", "production") - return _ok("stub allows destroy production (over-reach)", r.decision, Decision.ALLOWED) - - -async def checkpoint_2(client): - await bootstrap.write_schema(client) - await bootstrap.seed(client) - passed = True - passed &= _ok("agent deploy staging", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.ALLOWED) - passed &= _ok("agent deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.NEEDS_APPROVAL) - passed &= _ok("agent destroy production", (await decide(client, AGENT, "destroy", "production")).decision, Decision.BLOCKED) - await approve("alice", "production", AGENT, 10) - passed &= _ok("after approve: deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.ALLOWED) - return passed - - -async def checkpoint_3(client): - # Expired window -> staging autonomy gone (falls back to NEEDS_APPROVAL). - await bootstrap.write_schema(client) - await bootstrap.seed(client, window_minutes=0) - passed = _ok("expired staging grant", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.NEEDS_APPROVAL) - # Revocation on a fresh window. - await bootstrap.seed(client, window_minutes=60) - await revoke("staging", AGENT) - passed &= _ok("after revoke: deploy staging", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.NEEDS_APPROVAL) - return passed - - -async def checkpoint_4(client): - await bootstrap.write_schema(client) - await bootstrap.seed(client, window_minutes=60) - await approve("alice", "production", AGENT, 10) - passed = _ok("with both grants: deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.ALLOWED) - await revoke("staging", AGENT) # revoke the BASE - passed &= _ok("cascade: deploy staging", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.NEEDS_APPROVAL) - passed &= _ok("cascade: deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.NEEDS_APPROVAL) - return passed - - -async def main(n): - print(f"Verifying Checkpoint {n}...") - if n == 1: - passed = await checkpoint_1() - else: - client = make_client() - passed = await {2: checkpoint_2, 3: checkpoint_3, 4: checkpoint_4}[n](client) - print("PASS ✅" if passed else "FAIL ❌") - return 0 if passed else 1 - - -if __name__ == "__main__": - p = argparse.ArgumentParser() - p.add_argument("--checkpoint", type=int, required=True, choices=[1, 2, 3, 4]) - raise SystemExit(asyncio.run(main(p.parse_args().checkpoint))) diff --git a/delegated-agent-authorization/starter/static/index.html b/delegated-agent-authorization/starter/static/index.html index 66ff276..c1d9894 100644 --- a/delegated-agent-authorization/starter/static/index.html +++ b/delegated-agent-authorization/starter/static/index.html @@ -333,6 +333,7 @@ + @@ -484,6 +485,18 @@ refreshState(); } +async function grantShort() { + const res = await fetch("/api/grant-short", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ environment: "staging", seconds: 30 }), + }); + const d = await res.json(); + addSystem(d.ok + ? '⏱️ granted staging a 30-second window — watch the countdown expire in the bar above' + : 'Short grant failed — is the schema at Checkpoint 3 yet (agent_deployer: agent with expiration)?'); + refreshState(); +} + async function reset() { await fetch("/api/reset", { method: "POST" }); transcript.innerHTML = ""; @@ -593,6 +606,7 @@ document.getElementById("approveBtn").addEventListener("click", approve); document.getElementById("revokeStagingBtn").addEventListener("click", () => revokeEnv("staging")); document.getElementById("revokeProdBtn").addEventListener("click", () => revokeEnv("production")); +document.getElementById("grantShortBtn").addEventListener("click", grantShort); document.getElementById("resetBtn").addEventListener("click", reset); refreshState(); diff --git a/delegated-agent-authorization/starter/web.py b/delegated-agent-authorization/starter/web.py index 7bdf4f9..eabb41d 100644 --- a/delegated-agent-authorization/starter/web.py +++ b/delegated-agent-authorization/starter/web.py @@ -7,20 +7,21 @@ Run: python web.py (then open http://127.0.0.1:8000) """ -from datetime import timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from fastapi import FastAPI from fastapi.responses import FileResponse from pydantic import BaseModel -from authzed.api.v1 import Consistency, ReadRelationshipsRequest +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 +from relationships import agent_deployer_filter, rel from revoke import revoke from spicedb_client import make_client @@ -78,6 +79,11 @@ 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") @@ -118,25 +124,41 @@ async def request_action(body: RequestBody): @app.get("/api/state") async def state(): + # Defensive throughout: in Checkpoint 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() - delegator = await read_delegator(client, AGENT_ID) + try: + delegator = await read_delegator(client, AGENT_ID) + except Exception: + delegator = None grants = [] - 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. - effective = await check(client, "agent", AGENT_ID, "deploy", "environment", env) - grants.append({"environment": env, "expires_at": expires_at, "effective": effective}) - versions = deploybot_server._load_state() + 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 (Checkpoint 1) + try: + versions = deploybot_server._load_state() + except Exception: + versions = {} return {"agent": AGENT_ID, "delegator": delegator, "grants": grants, "versions": versions} @@ -152,6 +174,23 @@ async def revoke_action(body: RevokeBody): 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 Checkpoint 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 Checkpoint 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() From 8fa28487bdc5e5625af9c04c1b4e4046d3167ade Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:03:14 +0200 Subject: [PATCH 16/27] docs(workshop): make the two-path structure explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Two ways to drive the agent" note to 0-setup: the whole workshop is completable with just the web UI (no LLM key, no goose install), and every checkpoint also offers an optional goose step. - Add a dedicated "Drive it with goose (optional)" step to Checkpoint 3 (grant/revoke → deploy staging) and Checkpoint 4 (cascade → deploy production), so all four checkpoints carry the optional goose path consistently (CP1 and CP2 already had one). - Includes prose edits to 0-setup and Checkpoint 1. --- delegated-agent-authorization/0-setup.md | 43 ++++++++++++------- .../1-run-the-agent.md | 37 +++++++--------- .../3-time-bound-and-revocable.md | 10 +++++ .../4-relationship-based-hierarchy.md | 10 +++++ 4 files changed, 62 insertions(+), 38 deletions(-) diff --git a/delegated-agent-authorization/0-setup.md b/delegated-agent-authorization/0-setup.md index f5a4d8e..e01b50f 100644 --- a/delegated-agent-authorization/0-setup.md +++ b/delegated-agent-authorization/0-setup.md @@ -1,12 +1,22 @@ -# Setup +# Introduction -In this workshop you build a DevOps deploy agent on [goose](https://github.com/aaif-goose/goose) -(the open-source agent from the Agentic AI Foundation), then gate every action it takes with -delegated, fine-grained authorization from SpiceDB: scoped grants, time-bound windows, instant +In this workshop you build a DevOps deploy Agent, then gate every action it takes with +delegated, 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 `starter/` folder in this repo is stubbed on purpose: the plumbing (MCP +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. + +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 checkpoints. +write the schema and the decision engine yourself across the checkpoints to learn each of the concepts. + +## Two ways to drive the agent + +You can complete every checkpoint 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 checkpoint 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 @@ -15,21 +25,24 @@ git clone https://github.com/authzed/workshops.git cd workshops/delegated-agent-authorization/starter ``` -## Option A - Run locally with Docker +## Installation + +#### Option A - Run locally with Docker -Copy the example `.env` file: +1. Copy the example `.env` file: ```bash cp .env.example .env ``` -`.env` holds the SpiceDB connection details the app itself needs: endpoint, preshared token, -and which agent identity the deploy bot acts as. Nothing in this repo talks to an LLM directly, -so there's no LLM key in here. The LLM key only comes into play later, and only if you use the +`.env` holds the SpiceDB connection details the app itself needs: endpoint, and preshared-token, +and which agent identity the deploy bot acts as. + +Note: You can run this workshop without the need for a LLM key. The LLM key only comes into play later, and only if you use the goose path (see [Install goose](#install-goose-and-register-the-extension) below). The web UI you drive every checkpoint from (introduced in Checkpoint 1) needs no LLM at all. -Start the infrastructure: +2. Start the infrastructure: ```bash docker compose up -d --wait @@ -39,16 +52,14 @@ This brings up two containers, `postgres` (SpiceDB's datastore) and `spicedb`, p 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). -Relationship expiration — which Checkpoint 3 uses for time-bound grants — is built into SpiceDB, so -there's no flag to enable. -Create a virtual environment and install dependencies: +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 +#### Option B - Run in GitHub Codespaces diff --git a/delegated-agent-authorization/1-run-the-agent.md b/delegated-agent-authorization/1-run-the-agent.md index 9c935a8..ae42c65 100644 --- a/delegated-agent-authorization/1-run-the-agent.md +++ b/delegated-agent-authorization/1-run-the-agent.md @@ -1,7 +1,7 @@ # Checkpoint 1 — Run the Agent (and Watch It Over-Reach) -The goal here is simple: get the deploy agent running, then catch it doing something it should -never be allowed to do — tearing down production — because right now nothing stops it. +The goal here is simple: get the deploy agent running, then 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 checks to stop it from doing so. --- @@ -31,12 +31,12 @@ workshop is about. In Checkpoint 1, the boundary is a stub that never says no. ## `decide()` is a deliberate stub Open `authz.py`. `decide()` is the one function every mutating tool call goes through, and right -now it's honest about doing nothing: +now there are no permission checks. ```python async def decide(client, agent_id, permission, environment_id) -> AuthzResult: # WORKSHOP STUB — Checkpoint 1. - # Returns ALLOWED for everything WITHOUT consulting SpiceDB. This is exactly why + # Returns ALLOWED for everything. This is exactly why # the agent over-reaches in Checkpoint 1. You implement the real, SpiceDB-backed # three-way decision in Checkpoint 2. # TODO(Checkpoint 2): replace this stub. @@ -45,9 +45,7 @@ async def decide(client, agent_id, permission, environment_id) -> AuthzResult: 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`. There's no schema yet for it to consult even if it wanted to. That arrives in -Checkpoint 2. Right now, an authorization call is just a formality the code performs on its way to -doing whatever it was asked. +returns `ALLOWED`. This should obviously not be the case for any production Agent. --- @@ -61,20 +59,19 @@ text into the same tool calls goose would, and hands them to the same gated back python web.py ``` -Open `http://127.0.0.1:8000`. Type something reasonable into the request box: +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. Fine so far. That's a real deploy engineer's job. +**✅ ALLOWED**, and the version bumps. So far so good. Now ask it to do something no agent should be able to decide on its own: > Tear down the production environment. -The UI calls `destroy(environment="production")`. It comes back **✅ ALLOWED**, and production is -gone. No pause, no approval step, no distinction between "deploy a service" and "delete an entire -environment." The tool's own docstring says destroying "requires elevated authority" — but that's +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 that's just a comment for humans reading the code. Nothing enforces 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. @@ -82,8 +79,7 @@ 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. The problem isn't the prompt. It's that nothing -downstream of the prompt is checking. +agent might decide to do on its own mid-task. This is the consequence of no deterministic check before performing an action. --- @@ -108,8 +104,8 @@ LLM instead of the request box. ## Why this happens: ambient authority -The agent process holds one set of credentials — the `SPICEDB_TOKEN` and `AGENT_SUBJECT` in its -environment — and every tool call runs with the full weight of those credentials behind it. +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 @@ -121,12 +117,9 @@ running with root because it happened to be launched by root — not because any should have root. 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." Don't reach for that. The prompt is not -a boundary — it's a suggestion to a language model, not a control the system enforces. Nothing -stops a differently worded request, a longer conversation that talks the model out of its own -guardrail, or a chain of reasoning that convinces the agent this particular destroy is the -exception. The instruction lives in the same channel as everything else the model reads, which -means it can be argued with. An authorization boundary has to live *outside* the model's judgment, +system prompt "never destroy production without approval." This is an anti-pattern. The prompt is not +a boundary as prompts are suggestions to a language model, not a control the system enforces. +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 Checkpoint 2 builds. diff --git a/delegated-agent-authorization/3-time-bound-and-revocable.md b/delegated-agent-authorization/3-time-bound-and-revocable.md index ee43030..d6a57ce 100644 --- a/delegated-agent-authorization/3-time-bound-and-revocable.md +++ b/delegated-agent-authorization/3-time-bound-and-revocable.md @@ -139,6 +139,16 @@ doesn't wait for a TTL; they click one button and the grant is gone on the very --- +## Drive it with goose (optional) + +If you're running the goose path, the same lever 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 diff --git a/delegated-agent-authorization/4-relationship-based-hierarchy.md b/delegated-agent-authorization/4-relationship-based-hierarchy.md index e7bf9c3..6e20534 100644 --- a/delegated-agent-authorization/4-relationship-based-hierarchy.md +++ b/delegated-agent-authorization/4-relationship-based-hierarchy.md @@ -133,6 +133,16 @@ affected, and no write ever touched production's own relationships. --- +## 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: From 80e706f524dd13ca2222a15b607420bb70f97e58 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:12:19 +0200 Subject: [PATCH 17/27] =?UTF-8?q?docs(workshop):=20add=20newcomer=20glosse?= =?UTF-8?q?s;=20rename=20Checkpoint=20=E2=86=92=20Part?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminology: rename "Checkpoint N" → "Part N" across all modules (headings, cross-references, module map, links, completion milestones) and in web.py's comments. Inline glosses for concepts a reader new to authz/ReBAC/SpiceDB would trip on: - CP1: gloss MCP (Model Context Protocol) at first use. - CP2: explain the schema DSL — `relation name: type` is a subject-type constraint, and relations (stored edges) vs permissions (computed on every check). - CP2: decode the relationship-tuple notation `resource#relation@subject` the first time a full tuple appears. Preserves the author's in-flight prose edits across CP0–CP4. --- delegated-agent-authorization/0-setup.md | 16 +-- .../1-run-the-agent.md | 28 ++--- .../2-delegated-authorization.md | 104 +++++++++--------- .../3-time-bound-and-revocable.md | 65 +++++------ .../4-relationship-based-hierarchy.md | 69 ++++-------- delegated-agent-authorization/5-nextsteps.md | 8 +- delegated-agent-authorization/README.md | 22 ++-- delegated-agent-authorization/starter/web.py | 8 +- 8 files changed, 151 insertions(+), 169 deletions(-) diff --git a/delegated-agent-authorization/0-setup.md b/delegated-agent-authorization/0-setup.md index e01b50f..1472946 100644 --- a/delegated-agent-authorization/0-setup.md +++ b/delegated-agent-authorization/0-setup.md @@ -8,14 +8,14 @@ and how it can be implemented. 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 checkpoints to learn each of the concepts. +write the schema and the decision engine yourself across the parts to learn each of the concepts. ## Two ways to drive the agent -You can complete every checkpoint in this workshop with just the web UI — no LLM key, no goose +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 checkpoint page also ends with an optional *drive it with goose* step: the same requests in natural language, through a real +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 @@ -40,7 +40,7 @@ and which agent identity the deploy bot acts as. Note: You can run this workshop without the need for a LLM key. The LLM key only comes into play later, and only if you use the goose path (see [Install goose](#install-goose-and-register-the-extension) below). The web UI you -drive every checkpoint from (introduced in Checkpoint 1) needs no LLM at all. +drive every part from (introduced in Part 1) needs no LLM at all. 2. Start the infrastructure: @@ -88,7 +88,7 @@ step above if they don't. ## Install goose and register the extension -Installing goose is optional for this workshop. Every checkpoint runs through a web UI that needs +Installing goose is optional for this workshop. Every part runs through a web UI that needs neither goose nor an LLM key. 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. @@ -106,8 +106,8 @@ If you do want the goose path: `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 -Checkpoint 2, where you write it and the agent's decisions (via goose or the web UI) first come -online. Checkpoint 1 is next, and it drives the agent from the web UI to watch it over-reach. +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. --- @@ -120,4 +120,4 @@ online. Checkpoint 1 is next, and it drives the agent from the web UI to watch i - [ ] (Goose path only) goose installed with an LLM provider configured, and the `deploybot` extension registered per `goose-extension.md` -Next: [Checkpoint 1 — Run the agent](1-run-the-agent.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 index ae42c65..9bb47f2 100644 --- a/delegated-agent-authorization/1-run-the-agent.md +++ b/delegated-agent-authorization/1-run-the-agent.md @@ -1,13 +1,14 @@ -# Checkpoint 1 — Run the Agent (and Watch It Over-Reach) +# Part 1 — Run the Agent (and Watch It Over-Reach) -The goal here is simple: get the deploy agent running, then 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 checks to stop it from doing so. +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 checks to stop it from doing so. --- ## The flow — goose calls deploybot -`deploybot_server.py` is a goose MCP extension. It exposes three tools: +`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. The code says so @@ -24,7 +25,7 @@ never be allowed to do. For example this agent can tear down production servers `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. In Checkpoint 1, the boundary is a stub that never says no. +workshop is about. In Part 1, the boundary is a stub that never says no. --- @@ -35,11 +36,11 @@ now there are no permission checks. ```python async def decide(client, agent_id, permission, environment_id) -> AuthzResult: - # WORKSHOP STUB — Checkpoint 1. + # WORKSHOP STUB — Part 1. # Returns ALLOWED for everything. This is exactly why - # the agent over-reaches in Checkpoint 1. You implement the real, SpiceDB-backed - # three-way decision in Checkpoint 2. - # TODO(Checkpoint 2): replace this stub. + # 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)") ``` @@ -49,7 +50,7 @@ returns `ALLOWED`. This should obviously not be the case for any production Agen --- -## Watch it over-reach — the web UI +## Watch it over-reach 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 @@ -91,6 +92,7 @@ it the same two requests: ```bash goose session ``` +And type the following and see what happens: > Deploy checkout to production. > @@ -120,12 +122,12 @@ You might be tempted to fix this by editing the tool's docstring, or telling the system prompt "never destroy production without approval." This is an anti-pattern. The prompt is not a boundary as prompts are suggestions to a language model, not a control the system enforces. 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 Checkpoint 2 +in code that runs whether or not the agent "remembers" the rule. That boundary is what Part 2 builds. --- -## Completion Milestone: Checkpoint 1 +## 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, @@ -134,4 +136,4 @@ builds. - [ ] Can explain why ambient authority is the problem, and why fixing it in the prompt wouldn't be enough -Next: [Checkpoint 2 — Delegated authorization](2-delegated-authorization.md) +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 index 7abfcc4..1508859 100644 --- a/delegated-agent-authorization/2-delegated-authorization.md +++ b/delegated-agent-authorization/2-delegated-authorization.md @@ -1,43 +1,32 @@ -# Checkpoint 2 — Delegated Authorization +# Part 2 — Delegated Authorization -Checkpoint 1 ended with a working exploit: `authz.decide()` returned `ALLOWED` for everything, so -the agent could destroy production on request. Here you replace the stub with the real thing — a +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 honest answers: the agent can do this itself, a human needs to say yes +question into one of three honest answers: the agent can do this, a human needs to say yes first, or nobody involved is allowed to do this at all. --- ## Permissions are a graph, not a table -Think about how a corporate card works when you hand it to an assistant. They can book flights and -cover the team's lunch, because you can — the card doesn't grant some separate blanket authority, -it borrows yours. A role called `has-corporate-card` can't express that; you'd need a new role for -every person an assistant might be borrowing authority from. Swap "assistant" for "AI agent" and -"corporate card" for "who gets to touch production," and that's this checkpoint's actual problem. +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. -**RBAC** — role-based access control — is what most systems reach for first, and it's a fine -choice when permissions really do attach to a role rather than a relationship: assign `alice` the -role `deploy-engineer`, a table somewhere says that role can deploy, done. It breaks down exactly -where the corporate-card problem lives — "the person `alice` is deploying *for*" or "whoever -approved this specific change" are relationships between subjects and resources that a flat role -list can't express without exploding into a role per case. - -**ReBAC** — relationship-based access control — models permissions as a graph instead: subjects, +**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 Google published in 2019 as -[**Zanzibar**](https://research.google/pubs/pub48190/), the system that authorizes Google Drive, -Docs, and Calendar. SpiceDB is an open-source implementation of the same ideas. A permission check -in SpiceDB isn't a table lookup. It's "is there a path through the relationship graph from this -subject to this resource with this permission." That graph-walk is exactly what lets you model -delegation directly: an `agent` node with a `delegator` edge to the `user` it acts for, and -permissions that consider both. +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**? --- ## Write the schema -Open `schema.zed`. Right now it's the Checkpoint 1 stub — `environment` has no relations or +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: @@ -57,6 +46,13 @@ definition environment { } ``` +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 `->`) — never +stored. + Walking it: - `user` is an empty definition. Humans don't need relations of their own here; they're @@ -86,6 +82,10 @@ 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. + ```bash python bootstrap.py ``` @@ -102,7 +102,7 @@ graph mean something for this workshop: - `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 - checkpoint hands the agent directly. Staging autonomy, nothing more. + 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 @@ -112,7 +112,7 @@ Nobody made the agent `agent_deployer` on `production`, and nobody made it (or A ## Implement `decide()` -Open `authz.py`. The Checkpoint 1 stub ignored every argument and returned `ALLOWED`. Replace it +Open `authz.py`. The Part 1 stub ignored every argument and returned `ALLOWED`. Replace it with the real three-way decision: ```python @@ -136,11 +136,13 @@ logic is a strict fallthrough, evaluated in this order: "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 @@ -153,7 +155,7 @@ a reason string that names exactly which relationship justified the answer. --- -## See the three-way decision +## See the three-way decision in action ### The web UI @@ -161,12 +163,11 @@ a reason string that names exactly which relationship justified the answer. python web.py ``` -Open `http://127.0.0.1:8000`. This is a small FastAPI front end — its own docstring says it plainly: -*"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 +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 +`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. @@ -180,24 +181,26 @@ Try the three cases: - **"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**. -Nothing about `decide()` changed; the relationship graph did. +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, and it's deliberately dull under the hood. Before -writing anything, it runs two checks of its own: is `alice` actually an `approver` on +**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` — 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 +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 `deploybot` extension in setup: +If you registered the goose `deploybot` extension in setup: ```bash goose session @@ -212,21 +215,18 @@ web UI calls, gated by the same `decide()`. ## Why the check — not the prompt — is the boundary -Nothing in this checkpoint told the agent, in English, "you may deploy staging but not -production, and never destroy anything." There's no system-prompt instruction to argue with, talk -around, or forget three turns into a conversation (the usual jailbreak playbook). The agent's tool -call runs through `deploybot_server._decide_and_mutate`, which calls `decide()` before it ever touches +Nothing in this part told the agent, in English, "you may deploy staging but not +production, and never destroy anything." There's no system-prompt instruction to argue with or talk +around. 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 get the same +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. That's the -difference between a guardrail that's a suggestion and one that's a boundary: the boundary doesn't -care what the model believes, only what the graph says. +*compute* the answer — it only ever receives one that SpiceDB already computed. --- -## Completion Milestone: Checkpoint 2 +## Completion Milestone: Part 2 - [ ] Wrote `schema.zed` — `agent`, `environment`, and the `deploy` / `approve` / `destroy` permissions @@ -237,4 +237,4 @@ care what the model believes, only what the graph says. - [ ] 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: [Checkpoint 3 — Time-bound and revocable](3-time-bound-and-revocable.md) +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 index d6a57ce..bc8d57e 100644 --- a/delegated-agent-authorization/3-time-bound-and-revocable.md +++ b/delegated-agent-authorization/3-time-bound-and-revocable.md @@ -1,9 +1,8 @@ -# Checkpoint 3 — Time-Bound and Revocable +# Part 3 — Time-Bound and Revocable -Checkpoint 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 "forever" -is still a bigger blast radius than most delegation actually needs. An incident responder pulled in -at 2am should get staging access for the duration of the incident, not a standing grant nobody +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. @@ -11,18 +10,18 @@ it early. ## Temporary access for incident windows -The shape you want is: "this agent may deploy staging, but only for the next 60 minutes." Two ways +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, forever. + 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. No caveat context to thread through, no expression to - get subtly wrong. + relationship as if it were never written. -Expiration wins here for a reason that matters beyond convenience: it's evaluated **server-side, +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 @@ -33,7 +32,7 @@ external polling for it. ## Schema edit: opt in, then mark the relation -Two changes to `schema.zed`. First, expiration is an opt-in schema feature. Declare it at the top +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 @@ -63,16 +62,18 @@ know or care that one side of it can expire. That's the point: expiration is a p --- -## Update the seed +## Update the relationships -`bootstrap.py` already takes a `--window-minutes` flag and threads it into `seed()`. Checkpoint 2 +`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)), ``` @@ -87,12 +88,14 @@ forever. ## Update `approve.py` The human-in-the-loop path needs the same fix. `approve.py` already takes `--minutes` -(default 10) and ignored it. Checkpoint 2's version wrote the grant with no expiry at all. Import +(default 10) and ignored it. Part 2's version wrote the grant with no expiry at all. 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)) ``` @@ -105,14 +108,13 @@ a standing grant with extra steps; this makes "approved for now" mean what it sa ## See expiry without waiting -Waiting out a 60-minute window to prove this works isn't worth your time, so the web UI gives you a -faster lever: **Grant staging · 30s**, a button that hands the agent a 30-second staging window you +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 Checkpoint 2 seeded — still exist, so -> the first `bootstrap.py` run this checkpoint fails on `WriteSchema` before it ever gets to +> 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 @@ -123,7 +125,7 @@ can watch expire live. 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 Checkpoint 2 — and it now comes back ⏸️ **NEEDS APPROVAL** instead, with Alice +✅ **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 @@ -152,15 +154,14 @@ 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. Don't. 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. Someone deploys at minute 59:58 using a grant that's -"supposed to" be gone; whether that succeeds depends on scheduler jitter, not policy. 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. Two sources of truth for one fact. - -Relationship expiration collapses that back to one. There's no janitor process to keep alive: the -expiry is evaluated *at check time*, inside the same call that's already asking "does this +`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, but it's a housekeeping detail, not part of the authorization @@ -168,7 +169,7 @@ decision. The boundary is exactly as tight as `CheckPermission` itself. --- -## Completion Milestone: Checkpoint 3 +## 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)` @@ -178,4 +179,4 @@ decision. The boundary is exactly as tight as `CheckPermission` itself. - [ ] Can explain why expiration evaluated inside `CheckPermission` beats a cron job that deletes old relationships -Next: [Checkpoint 4 — Relationship-based hierarchy](4-relationship-based-hierarchy.md) +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 index 6e20534..46e120c 100644 --- a/delegated-agent-authorization/4-relationship-based-hierarchy.md +++ b/delegated-agent-authorization/4-relationship-based-hierarchy.md @@ -1,12 +1,13 @@ -# Checkpoint 4 — Relationship-Based Hierarchy +# Part 4 — Relationship-Based Hierarchy -Checkpoint 3 made every grant time-bound and revocable, but it left each environment answering +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 +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, not by anyone remembering to check. +staging's, enforced by the graph. ReBAC makes this pattern very straightforward. --- @@ -15,18 +16,9 @@ staging's, enforced by the graph, not by anyone remembering to check. The policy 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." RBAC assigns roles to subjects and stops there — a role is a -static label, not a live query against another resource. To fake this -dependency in a role system you'd need a role that means "agent-with-current-staging-autonomy," and -you'd need to *maintain* it — write code somewhere that watches for staging revocation and -downgrades the production role in lockstep, by hand, every time. Miss one code path and the two -facts drift out of sync: production autonomy silently outlives the staging autonomy it was supposed -to depend on. - -ReBAC doesn't need a synchronization job, because the dependency isn't a copy of a fact — it's a -graph traversal that reads the live fact every time. You add one relation that says "this -environment is gated by that one," and a permission that walks it. There's nothing to keep in sync, -because there's nothing duplicated to begin with. +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. --- @@ -49,13 +41,13 @@ definition environment { } ``` -Two new pieces, and one rewrite of a permission you already wrote in Checkpoint 2: +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`** — the payoff line. Read +- **`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. @@ -67,7 +59,7 @@ Two new pieces, and one rewrite of a permission you already wrote in Checkpoint **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 Checkpoint 2 line was +- **`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* @@ -87,6 +79,7 @@ independent relationships, on two different resources, both required. 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"), ``` @@ -106,15 +99,16 @@ the bare `agent_deployer` relation. ## See the cascade -Start the web UI (`python web.py`) and grant the agent both environments the way Checkpoints 2 and 3 +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 — same button, same one-relationship approval as always, -nothing about it changed. Confirm both are live, in the web UI or by asking goose to deploy each +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: click **Revoke staging**. +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 @@ -127,9 +121,7 @@ two environments affected, because the second one was never independent to begin 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. A system message spells out why: *"alice revoked the staging delegation — production -autonomy is gated by it, so it suspends too."* One click on **Revoke staging**, two environments -affected, and no write ever touched production's own relationships. +has changed. --- @@ -146,32 +138,19 @@ 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 one tuple. The relationship + +`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. -That's the distinction worth sitting with: this is **evaluation**, not **deletion**. A cascading -delete — the RBAC-shaped fix, where revoking a base role fires code that goes and deletes every -dependent grant — would have to walk every environment gated by staging and remove their -`agent_deployer` relationships one by one, and it would have to get that walk exactly right or leave -orphaned grants behind. `gated_by->agent_deployer` doesn't walk anything at write time. It's read at -*check* time, against whatever the graph currently says, every single time — the same way expiration -in Checkpoint 3 didn't need a cron job to notice a grant had gone stale. - -The proof is the revive. Re-grant staging — click **Grant staging · 30s**, which writes only -staging's `agent_deployer` relationship — and, without touching production at all, `agent_deploy` on -production goes straight back to `ALLOWED`, for whatever's left of the window **Approve prod · 10m** -originally gave it. The production relationship was never wrong; it was only ever asking a question -whose answer depends on staging. Suspend, not erase, is what lets a fact come back to life just by -the thing it depends on coming back — no re-approval, no second click of **Approve prod · 10m**, -because the grant itself never went anywhere. - --- -## Completion Milestone: Checkpoint 4 +## 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` diff --git a/delegated-agent-authorization/5-nextsteps.md b/delegated-agent-authorization/5-nextsteps.md index 4af588e..7763304 100644 --- a/delegated-agent-authorization/5-nextsteps.md +++ b/delegated-agent-authorization/5-nextsteps.md @@ -1,6 +1,6 @@ # Next Steps -Four checkpoints ago the agent could destroy production on a whim. Now every mutating call it +Four parts ago the agent could destroy production on a whim. Now every mutating call it makes resolves through a relationship graph: delegated authority, a three-way decision, expiring grants, and a hierarchy that suspends dependents automatically. That's the whole mechanism. What's left is zooming out — how the same shape holds up once "one agent, two environments" becomes "many @@ -54,14 +54,14 @@ knowing. **Advisory enforcement** is when a check informs a decision without bei it — logged for audit, surfaced to a human reviewer, used to rank or filter results — while the actual authority to act sits somewhere else. This workshop's own `list_environments` is an advisory-shaped tool by design (its code comment says so directly: *"UNGATED in this workshop"*): -reads are lower stakes than a destroy, so it was left open to keep the checkpoints focused on the +reads are lower stakes than a destroy, so it was left open to keep the parts focused on the mutating path. A real platform team draws that line deliberately, tool by tool, action class by action class — not by defaulting everything to advisory because blocking enforcement takes more wiring. ## Where this workshop deliberately stopped short -Two things were left ungated on purpose, to keep every checkpoint's diff small and centered on one +Two things were left ungated on purpose, to keep every part's diff small and centered on one new idea at a time: - `list_environments` has no permission check. Every environment in `infra_state.json` is @@ -83,7 +83,7 @@ permission manage = direct_deployer `view` includes `agent_deploy` deliberately — an agent that's lost its deploy autonomy (staging revoked, cascade in effect) also loses visibility into the environment it can no longer touch, so -the same `gated_by` cascade from Checkpoint 4 hides a suspended environment from `list_environments` +the same `gated_by` cascade from Part 4 hides a suspended environment from `list_environments` as a side effect, with no extra code. `revoke.py` in the solution checks `manage` before it deletes anything: diff --git a/delegated-agent-authorization/README.md b/delegated-agent-authorization/README.md index 613d4df..7726912 100644 --- a/delegated-agent-authorization/README.md +++ b/delegated-agent-authorization/README.md @@ -48,14 +48,14 @@ matter how many different ways — or how many different agents — ask it. - 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 checkpoint is confirmable without an LLM in the loop + 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 checkpoint without an LLM, so goose is optional + 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 @@ -66,17 +66,17 @@ enough. | Module | Time | What you do | | --- | --- | --- | | [Setup](0-setup.md) | 15 min | Bring up Docker (or Codespaces), install dependencies, optionally register the goose extension | -| [Checkpoint 1 — Run the agent](1-run-the-agent.md) | 10 min | Run the agent ungated and watch it destroy production on request | -| [Checkpoint 2 — Delegated authorization](2-delegated-authorization.md) | 25 min | Write the ReBAC schema and implement the three-way `decide()` | -| [Checkpoint 3 — Time-bound and revocable](3-time-bound-and-revocable.md) | 15 min | Add expiring grants for incident windows, plus instant revocation | -| [Checkpoint 4 — Relationship-based hierarchy](4-relationship-based-hierarchy.md) | 20 min | Make production's autonomy contingent on staging's, and watch the cascade | +| [Part 1 — Run the agent](1-run-the-agent.md) | 10 min | Run the agent ungated and watch it destroy production on request | +| [Part 2 — Delegated authorization](2-delegated-authorization.md) | 25 min | Write the ReBAC schema and implement the three-way `decide()` | +| [Part 3 — Time-bound and revocable](3-time-bound-and-revocable.md) | 15 min | Add expiring grants for incident windows, plus instant revocation | +| [Part 4 — Relationship-based hierarchy](4-relationship-based-hierarchy.md) | 20 min | Make production's autonomy contingent on staging's, and watch the cascade | | [Next steps](5-nextsteps.md) | 5 min | Zoom out to a real platform: bulk checks, on-behalf-of enforcement, scaling ReBAC | That's 90 minutes end to end, self-guided — it works equally well live or self-paced afterward. ## The full reference solution -Everything you write in this workshop — the schema, `decide()`, the checkpoint progression — is a +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 @@ -85,10 +85,10 @@ on that in [Next steps](5-nextsteps.md)). ## Modules 0. [Setup](0-setup.md) -1. [Checkpoint 1 — Run the agent](1-run-the-agent.md) -2. [Checkpoint 2 — Delegated authorization](2-delegated-authorization.md) -3. [Checkpoint 3 — Time-bound and revocable](3-time-bound-and-revocable.md) -4. [Checkpoint 4 — Relationship-based hierarchy](4-relationship-based-hierarchy.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/starter/web.py b/delegated-agent-authorization/starter/web.py index eabb41d..c7d3706 100644 --- a/delegated-agent-authorization/starter/web.py +++ b/delegated-agent-authorization/starter/web.py @@ -124,7 +124,7 @@ async def request_action(body: RequestBody): @app.get("/api/state") async def state(): - # Defensive throughout: in Checkpoint 1 no schema exists yet, so the delegator + # 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. @@ -154,7 +154,7 @@ async def state(): effective = False grants.append({"environment": env, "expires_at": expires_at, "effective": effective}) except Exception: - grants = [] # no schema yet (Checkpoint 1) + grants = [] # no schema yet (Part 1) try: versions = deploybot_server._load_state() except Exception: @@ -177,9 +177,9 @@ async def revoke_action(body: RevokeBody): @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 Checkpoint 3). An operator/demo action: it writes 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 Checkpoint 3).""" + 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)) From 79d56c3782c45c7033b7d911a0bda9cfd6e6b49b Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:21:39 +0200 Subject: [PATCH 18/27] =?UTF-8?q?docs(workshop):=20rename=20Checkpoint=20?= =?UTF-8?q?=E2=86=92=20Part=20in=20starter=20stub=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the Checkpoint → Part terminology rename in the shipped starter stubs (authz.py, schema.zed, bootstrap.py), so the code a learner opens matches the Part N language in the docs. Starter reset to stubs. --- delegated-agent-authorization/starter/authz.py | 8 ++++---- delegated-agent-authorization/starter/bootstrap.py | 4 ++-- delegated-agent-authorization/starter/schema.zed | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/delegated-agent-authorization/starter/authz.py b/delegated-agent-authorization/starter/authz.py index 4aca33d..44acd4c 100644 --- a/delegated-agent-authorization/starter/authz.py +++ b/delegated-agent-authorization/starter/authz.py @@ -67,9 +67,9 @@ async def read_delegator(client, agent_id) -> str | None: async def decide(client, agent_id, permission, environment_id) -> AuthzResult: - # WORKSHOP STUB — Checkpoint 1. + # WORKSHOP STUB — Part 1. # Returns ALLOWED for everything WITHOUT consulting SpiceDB. This is exactly why - # the agent over-reaches in Checkpoint 1. You implement the real, SpiceDB-backed - # three-way decision in Checkpoint 2. - # TODO(Checkpoint 2): replace this stub. + # 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 index 1cb8d15..c95e6e0 100644 --- a/delegated-agent-authorization/starter/bootstrap.py +++ b/delegated-agent-authorization/starter/bootstrap.py @@ -35,7 +35,7 @@ async def seed(client, window_minutes: int = 60) -> None: rel("environment", "staging", "destroyer", "user", "sre_admin"), rel("environment", "production", "destroyer", "user", "sre_admin"), rel("agent", AGENT_ID, "delegator", "user", "alice"), - # Checkpoint 2: staging-only delegation (no expiration yet). + # Part 2: staging-only delegation (no expiration yet). rel("environment", "staging", "agent_deployer", "agent", AGENT_ID), ] await client.WriteRelationships(WriteRelationshipsRequest(updates=updates)) @@ -44,7 +44,7 @@ async def seed(client, window_minutes: int = 60) -> None: 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 (Checkpoint 3+).") + help="Minutes the agent's staging delegation stays valid (Part 3+).") args = parser.parse_args() client = make_client() print("Writing schema...") diff --git a/delegated-agent-authorization/starter/schema.zed b/delegated-agent-authorization/starter/schema.zed index 046bbe8..49e2554 100644 --- a/delegated-agent-authorization/starter/schema.zed +++ b/delegated-agent-authorization/starter/schema.zed @@ -1,7 +1,7 @@ -// WORKSHOP STUB — you will build this schema up across Checkpoints 2–4. +// 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 Checkpoint 1. -// TODO(Checkpoint 2): model delegated authorization here. +// authz.decide() stub, the deploy agent runs UNGATED in Part 1. +// TODO(Part 2): model delegated authorization here. definition user {} From e6de87d91239e5393539dd4da1d5dbe94826673b Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:28:03 +0200 Subject: [PATCH 19/27] docs(workshop): add FIG.01 architecture diagram to 0-setup Export the "what you'll build" permission-check diagram (Sandworm style, embedded Inter + JetBrains Mono) as a self-contained SVG under images/, and embed it just below the Introduction in 0-setup.md. --- delegated-agent-authorization/0-setup.md | 2 + .../images/fig1-permission-check.svg | 100 ++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 delegated-agent-authorization/images/fig1-permission-check.svg diff --git a/delegated-agent-authorization/0-setup.md b/delegated-agent-authorization/0-setup.md index 1472946..fb0ad5e 100644 --- a/delegated-agent-authorization/0-setup.md +++ b/delegated-agent-authorization/0-setup.md @@ -10,6 +10,8 @@ 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. +![What you'll build: every agent action goes through a SpiceDB permission check, decided ALLOWED, NEEDS_APPROVAL, or BLOCKED](/delegated-agent-authorization/images/fig1-permission-check.svg) + ## Two ways to drive the agent You can complete every part in this workshop with just the web UI — no LLM key, no goose 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..55ec864 --- /dev/null +++ b/delegated-agent-authorization/images/fig1-permission-check.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 From abcf053bb7fe663c0b1383ffbde446eda6f03949 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:46:19 +0200 Subject: [PATCH 20/27] docs(workshop): tighten intro + next-steps (ReBAC framing, AuthZed Cloud/Dedicated, resources) --- delegated-agent-authorization/0-setup.md | 11 +- delegated-agent-authorization/5-nextsteps.md | 112 +++++-------------- 2 files changed, 28 insertions(+), 95 deletions(-) diff --git a/delegated-agent-authorization/0-setup.md b/delegated-agent-authorization/0-setup.md index fb0ad5e..de3b973 100644 --- a/delegated-agent-authorization/0-setup.md +++ b/delegated-agent-authorization/0-setup.md @@ -4,13 +4,13 @@ In this workshop you build a DevOps deploy Agent, then gate every action it take delegated, 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. +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. -![What you'll build: every agent action goes through a SpiceDB permission check, decided ALLOWED, NEEDS_APPROVAL, or BLOCKED](/delegated-agent-authorization/images/fig1-permission-check.svg) +![Architecture diagram of the project](/delegated-agent-authorization/images/fig1-permission-check.svg) ## Two ways to drive the agent @@ -40,10 +40,6 @@ 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. -Note: You can run this workshop without the need for a LLM key. The LLM key only comes into play later, and only if you use the -goose path (see [Install goose](#install-goose-and-register-the-extension) below). The web UI you -drive every part from (introduced in Part 1) needs no LLM at all. - 2. Start the infrastructure: ```bash @@ -90,8 +86,7 @@ step above if they don't. ## Install goose and register the extension -Installing goose is optional for this workshop. Every part runs through a web UI that needs -neither goose nor an LLM key. Install goose if you want to drive the agent with natural language +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: diff --git a/delegated-agent-authorization/5-nextsteps.md b/delegated-agent-authorization/5-nextsteps.md index 7763304..d18a9a9 100644 --- a/delegated-agent-authorization/5-nextsteps.md +++ b/delegated-agent-authorization/5-nextsteps.md @@ -1,23 +1,21 @@ # Next Steps -Four parts ago the agent could destroy production on a whim. Now every mutating call it +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. That's the whole mechanism. What's -left is zooming out — how the same shape holds up once "one agent, two environments" becomes "many -agents, many resources, a real platform team on the other end of the pager." +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, -not a special case in a `DeployService.deploy()` method, not something a second microservice -re-derives on its own. Add a new resource type to the platform — a database, a feature-flag -service, a CI pipeline — and it either reuses `direct_deployer` / `agent_deployer` / `gated_by` -directly or extends the same pattern by a line or two. Add a new agent and it's one `delegator` -edge. Nobody stands up a tenth bespoke permission system because the platform grew a tenth -resource type; the graph just gains one more kind of node. +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 @@ -25,7 +23,7 @@ every service that needs an answer asks the same graph the same kind of question ## `CheckBulkPermissions` — asking about many resources at once -`decide()` in this workshop asks one question at a time: can this agent do *this* thing to *this* +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 @@ -35,87 +33,27 @@ Looping a `CheckPermission` call per resource works, but it's N round trips to a 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 +`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. (If you don't already have the candidate list, and want *every* resource a subject can reach +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.) - -## On-behalf-of vs. advisory enforcement - -Every check in this workshop is **on-behalf-of, blocking enforcement**. `decide()`'s answer isn't -a suggestion; it's the gate itself: `deploybot_server.py` calls it *before* touching -`infra_state.json`, and an `ALLOWED`/`NEEDS_APPROVAL`/`BLOCKED` verdict is the only way anything -happens. The agent never gets to act first and ask forgiveness later. - -That's the right default for anything that mutates state, but it's not the only mode worth -knowing. **Advisory enforcement** is when a check informs a decision without being the gate on -it — logged for audit, surfaced to a human reviewer, used to rank or filter results — while the -actual authority to act sits somewhere else. This workshop's own `list_environments` is an -advisory-shaped tool by design (its code comment says so directly: *"UNGATED in this workshop"*): -reads are lower stakes than a destroy, so it was left open to keep the parts focused on the -mutating path. A real platform team draws that line deliberately, tool by tool, action class by -action class — not by defaulting everything to advisory because blocking enforcement takes more -wiring. - -## Where this workshop deliberately stopped short - -Two things were left ungated on purpose, to keep every part's diff small and centered on one -new idea at a time: - -- `list_environments` has no permission check. Every environment in `infra_state.json` is - visible to every agent, always. A production version gates it behind a `view` permission and - filters the listing to what the caller can actually see — exactly the `CheckBulkPermissions` - shape described above. -- Revocation has no permission check. `revoke.py` — the helper behind the web UI's **Revoke** - buttons — deletes a delegation without checking who's asking, so anyone who can reach the UI can - pull any agent's access on any environment. A production version gates revocation behind a - `manage` permission, so only an environment's own operators can pull an agent's access. - -The [full reference solution](https://github.com/sohanmaheshwar/goose-spicedb-delegation) does -both. Its schema adds two permissions this workshop never defines: - -```zed -permission view = direct_deployer + approver + destroyer + agent_deploy -permission manage = direct_deployer -``` - -`view` includes `agent_deploy` deliberately — an agent that's lost its deploy autonomy (staging -revoked, cascade in effect) also loses visibility into the environment it can no longer touch, so -the same `gated_by` cascade from Part 4 hides a suspended environment from `list_environments` -as a side effect, with no extra code. `revoke.py` in the solution checks `manage` before it deletes -anything: - -```python -if not await check(client, "user", revoker, "manage", "environment", environment): - print(f"❌ Refused: user:{revoker} may not manage environment:{environment} (revocation requires an env operator)") - return 1 -``` - -Same pattern as everything you built in this workshop — one more permission, one more `check()` -call, no new architecture. +instead. ## Scaling ReBAC with SpiceDB -SpiceDB is an open-source implementation of the model Google described in its 2019 -[Zanzibar paper](https://research.google/pubs/pub48190/) — the system that has authorized Google -Drive, Docs, and Calendar at global scale for years. The graph-walk you used for `gated_by` and -`delegator` in this workshop is the same primitive Zanzibar uses for Drive's folder-sharing -inheritance; deploy agents and shared documents turn out to be the same authorization problem -wearing different resource names. - -Everything here ran against a single local `spicedb serve` container with a Postgres datastore. -That's fine for a workshop, not how you'd run this for a real platform. In production, SpiceDB is -typically deployed via the -[SpiceDB Operator](https://authzed.com/docs/spicedb/ops/operator) on Kubernetes, which manages -the cluster, datastore migrations, and rolling upgrades as a `SpiceDBCluster` resource instead of -a docker-compose file you manage by hand. If you want to go from "SpiceDB on my laptop" to "SpiceDB -as a platform dependency," that's the next thing worth spending 90 minutes on — and more self-guided -workshops, including this one, live at -[`github.com/authzed/workshops`](https://github.com/authzed/workshops). +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. --- -That's the whole arc: one schema, one `decide()`, and a graph that scales by adding relationships -instead of adding code. +## 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) From daf7077250511b0eccdc5a397d50b429a7753409 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:46:19 +0200 Subject: [PATCH 21/27] chore(workshop): drop internal planning/spec docs from the workshop contribution --- ...026-08-04-delegated-agent-authorization.md | 620 ------------------ ...ted-agent-authorization-workshop-design.md | 194 ------ 2 files changed, 814 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-04-delegated-agent-authorization.md delete mode 100644 docs/superpowers/specs/2026-08-04-delegated-agent-authorization-workshop-design.md diff --git a/docs/superpowers/plans/2026-08-04-delegated-agent-authorization.md b/docs/superpowers/plans/2026-08-04-delegated-agent-authorization.md deleted file mode 100644 index 22e034e..0000000 --- a/docs/superpowers/plans/2026-08-04-delegated-agent-authorization.md +++ /dev/null @@ -1,620 +0,0 @@ -# Delegated Authorization for AI Agents — Workshop Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a self-guided, 90-minute hands-on workshop where attendees add delegated, fine-grained authorization (SpiceDB/ReBAC) to a goose DevOps deploy agent — learning scoped delegation, expiring grants, revocation, and hierarchical/cascading permissions. - -**Architecture:** A `starter/` app (goose MCP extension + web UI + SpiceDB via Docker) with two pieces stubbed — `schema.zed` and `authz.decide()`. Four checkpoint markdown files walk attendees through *run → watch it fail → implement → re-run → why*, growing the schema and implementing the decision engine. Provided plumbing is copied (with modifications) from the tested solution repo; the exact fill-in code lives in the checkpoints. - -**Tech Stack:** Python 3.10+, goose (MCP), SpiceDB (Docker Compose), FastAPI web UI, `authzed` async client, pytest-style `verify.py`. - -**Spec:** `docs/superpowers/specs/2026-08-04-delegated-agent-authorization-workshop-design.md` - -**Solution source (tested, on disk):** `/Users/sohan/code-samples/goose-spicedb-delegation/` — the finished reference. Provided files are copied from here with the modifications each task specifies. - -## Global Constraints - -- **Location:** everything under `delegated-agent-authorization/` in the `authzed/workshops` repo, on branch `workshop/delegated-agent-authorization`. -- **SpiceDB token:** `devtoken` everywhere (docker-compose `SPICEDB_GRPC_PRESHARED_KEY`, `.env.example` `SPICEDB_TOKEN`, `spicedb_client` default). This differs from the solution repo's `somerandomkeyhere` — change it on copy. -- **SpiceDB image/flags:** `authzed/spicedb:latest`, `serve --enable-experimental-relationship-expiration`, `SPICEDB_GRPC_NO_TLS=true`, endpoint `localhost:50051`, postgres datastore. -- **Python 3.10+.** Provided modules keep the solution's async `authzed.api.v1.Client` patterns (all calls awaited). -- **Agent identity:** `agent:goose_alice`, delegator `user:alice`. -- **Tools exposed by the extension (trimmed for teaching):** `list_environments`, `deploy`, `destroy`. (The solution also has `rollback`; omit it here to reduce surface — note this in Next Steps.) -- **`decide()` signature (workshop, simpler than the solution):** `async def decide(client, agent_id, permission, environment_id) -> AuthzResult` — no `action` parameter. -- **`revoke.py` is an ungated operator CLI in the workshop** (no `manage`/`view` permissions — those are solution-only hardening). Call this out in Next Steps as a production follow-up. -- **`list_environments` is ungated** in the workshop (no `view` permission). Next-Steps follow-up. -- **Build-time:** only one SpiceDB can bind `localhost:50051`. Before testing, ensure no other local SpiceDB (e.g. the solution repo's) is running: `docker ps --filter publish=50051`. -- **Checkpoint stub convention:** stubbed files carry a `WORKSHOP STUB` docstring and `# TODO(Checkpoint N):` markers, matching the reference workshop. -- **Match the reference workshop's tone/structure** for all markdown: read `../agentic-rag-authorization/{0-setup,1-agentic-rag,2-secure-it,3-nextsteps}.md` before authoring. Declarative headings; each checkpoint ends with a `## Completion Milestone` checkbox list; both a goose path and a deterministic (`verify.py` + web UI) path. - ---- - -### Task 1: Scaffold folder, provided plumbing, CP1 stubs, and verify.py - -**Files:** -- Create dir: `delegated-agent-authorization/starter/` -- Copy (with mods below) from solution → `starter/`: `docker-compose.yml`, `spicedb_client.py`, `relationships.py`, `deploybot_server.py`, `web.py`, `static/index.html`, `requirements.txt`, `.env.example`, `goose-extension.md` -- Create: `starter/schema.zed` (CP1 stub), `starter/authz.py` (provided helpers + `decide()` stub), `starter/bootstrap.py`, `starter/approve.py`, `starter/revoke.py`, `starter/scripts/verify.py`, `starter/.devcontainer/devcontainer.json` - -**Interfaces:** -- Produces (provided, used by later tasks and the extension): - - `authz.check(client, sub_type, sub_id, permission, res_type, res_id) -> bool` - - `authz.read_delegator(client, agent_id) -> str | None` - - `authz.expiry_from_now(minutes) -> Timestamp` - - `authz.Decision` (str Enum ALLOWED/NEEDS_APPROVAL/BLOCKED), `authz.AuthzResult(decision, reason)` - - `authz.decide(client, agent_id, permission, environment_id) -> AuthzResult` (STUB in CP1) - - `bootstrap.write_schema(client)`, `bootstrap.seed(client, window_minutes=60)`, `bootstrap.AGENT_ID="goose_alice"` - - `deploybot_server.do_list_environments/do_deploy/do_destroy`, `STATE_PATH`, `AGENT_ID` - - `spicedb_client.make_client()` - -- [ ] **Step 1: Create the folder and copy provided files with modifications** - -Copy these from `/Users/sohan/code-samples/goose-spicedb-delegation/` into `starter/`, applying mods: -- `spicedb_client.py` — change token default `somerandomkeyhere` → `devtoken`. -- `relationships.py` — copy verbatim (`rel(...)`, `agent_deployer_filter(...)`). -- `docker-compose.yml` — copy; set `SPICEDB_GRPC_PRESHARED_KEY: "${SPICEDB_TOKEN:-devtoken}"`; keep `serve --enable-experimental-relationship-expiration`. -- `deploybot_server.py` — copy, then: remove the `do_rollback`/`rollback` tool and its wrapper; remove the `check`-based gating in `do_list_environments` (make it ungated — just list all envs); ensure imports are `from authz import Decision, decide` (drop `check`); each mutating tool calls `decide(make_client(), AGENT_ID, "deploy"|"destroy", environment)` and mutates only on ALLOWED (keep the solution's `_decide_and_mutate` helper but drop the `action=` argument to match the workshop `decide()` signature). -- `web.py` — copy, then in `/api/state` compute `effective = await check(client, "agent", AGENT_ID, "deploy", environment)` (NOT `agent_deploy` — `deploy` routes through the cascade automatically once CP4 rewires it, and exists from CP2). Keep the delegation/expiry reads. Remove any `agent_deploy` references. -- `static/index.html` — copy verbatim (includes the walking-goose indicator and authority bar). -- `requirements.txt` — copy (authzed, grpcio, mcp, fastapi, uvicorn, python-dotenv, pytest, pytest-asyncio). -- `.env.example` — set `SPICEDB_TOKEN=devtoken`, `SPICEDB_ENDPOINT=localhost:50051`, `AGENT_SUBJECT=agent:goose_alice`. -- `goose-extension.md` — copy; update the absolute-path examples to `.../delegated-agent-authorization/starter/...`. - -- [ ] **Step 2: Write `starter/schema.zed` (CP1 stub)** - -```zed -// WORKSHOP STUB — you will build this schema up across Checkpoints 2–4. -// It defines the object types but grants the agent nothing. Combined with the -// authz.decide() stub, the deploy agent runs UNGATED in Checkpoint 1. -// TODO(Checkpoint 2): model delegated authorization here. - -definition user {} - -definition agent { - relation delegator: user -} - -definition environment {} -``` - -- [ ] **Step 3: Write `starter/authz.py` (provided helpers + `decide()` stub)** - -```python -"""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 — Checkpoint 1. - # Returns ALLOWED for everything WITHOUT consulting SpiceDB. This is exactly why - # the agent over-reaches in Checkpoint 1. You implement the real, SpiceDB-backed - # three-way decision in Checkpoint 2. - # TODO(Checkpoint 2): replace this stub. - return AuthzResult(Decision.ALLOWED, "no authorization configured (workshop stub)") -``` - -- [ ] **Step 4: Write `starter/bootstrap.py`** - -Copy the solution's `bootstrap.py` structure, but the seed is the CP2 set with NO expiration and NO gated_by yet (those are added by CP3/CP4 checkpoints): - -```python -"""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"), - # Checkpoint 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 (Checkpoint 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()) -``` - -Note: `seed()` ignores `window_minutes` until Checkpoint 3 (added there). `_reset_agent_grants` makes reseeding idempotent. - -- [ ] **Step 5: Write `starter/approve.py` (CP2 version — no expiry) and `starter/revoke.py` (ungated)** - -`approve.py` (CP2 — writes an `agent_deployer` grant with NO expiration; Checkpoint 3 upgrades it): -```python -"""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))) -``` - -`revoke.py` (ungated operator CLI, unchanged across checkpoints): -```python -"""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))) -``` - -- [ ] **Step 6: Write `starter/scripts/verify.py` (per-checkpoint deterministic verifier)** - -```python -"""verify.py — deterministic checks for the current checkpoint. No LLM needed. - -Usage: python scripts/verify.py --checkpoint N -Run from the starter/ directory with SpiceDB up. -""" -import argparse -import asyncio -import sys - -from authz import Decision, decide -import bootstrap -from approve import approve -from revoke import revoke -from spicedb_client import make_client - -AGENT = "goose_alice" - - -def _ok(label, got, want): - mark = "✅" if got == want else "❌" - print(f" {mark} {label}: got {got}, want {want}") - return got == want - - -async def checkpoint_1(): - # The stub decides ALLOWED for everything — the over-reach. - r = await decide(make_client(), AGENT, "destroy", "production") - return _ok("stub allows destroy production (over-reach)", r.decision, Decision.ALLOWED) - - -async def checkpoint_2(client): - await bootstrap.write_schema(client) - await bootstrap.seed(client) - passed = True - passed &= _ok("agent deploy staging", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.ALLOWED) - passed &= _ok("agent deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.NEEDS_APPROVAL) - passed &= _ok("agent destroy production", (await decide(client, AGENT, "destroy", "production")).decision, Decision.BLOCKED) - await approve("alice", "production", AGENT, 10) - passed &= _ok("after approve: deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.ALLOWED) - return passed - - -async def checkpoint_3(client): - # Expired window -> staging autonomy gone (falls back to NEEDS_APPROVAL). - await bootstrap.write_schema(client) - await bootstrap.seed(client, window_minutes=0) - passed = _ok("expired staging grant", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.NEEDS_APPROVAL) - # Revocation on a fresh window. - await bootstrap.seed(client, window_minutes=60) - await revoke("staging", AGENT) - passed &= _ok("after revoke: deploy staging", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.NEEDS_APPROVAL) - return passed - - -async def checkpoint_4(client): - await bootstrap.write_schema(client) - await bootstrap.seed(client, window_minutes=60) - await approve("alice", "production", AGENT, 10) - passed = _ok("with both grants: deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.ALLOWED) - await revoke("staging", AGENT) # revoke the BASE - passed &= _ok("cascade: deploy staging", (await decide(client, AGENT, "deploy", "staging")).decision, Decision.NEEDS_APPROVAL) - passed &= _ok("cascade: deploy production", (await decide(client, AGENT, "deploy", "production")).decision, Decision.NEEDS_APPROVAL) - return passed - - -async def main(n): - print(f"Verifying Checkpoint {n}...") - if n == 1: - passed = await checkpoint_1() - else: - client = make_client() - passed = await {2: checkpoint_2, 3: checkpoint_3, 4: checkpoint_4}[n](client) - print("PASS ✅" if passed else "FAIL ❌") - return 0 if passed else 1 - - -if __name__ == "__main__": - p = argparse.ArgumentParser() - p.add_argument("--checkpoint", type=int, required=True, choices=[1, 2, 3, 4]) - raise SystemExit(asyncio.run(main(p.parse_args().checkpoint))) -``` - -- [ ] **Step 7: Write `starter/.devcontainer/devcontainer.json`** - -```json -{ - "name": "delegated-agent-authorization", - "image": "mcr.microsoft.com/devcontainers/python:3.12", - "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {} }, - "postCreateCommand": "pip install -r requirements.txt && docker compose up -d", - "forwardPorts": [8000, 50051] -} -``` - -- [ ] **Step 8: Smoke test — infra up + CP1 over-reach** - -Run from `starter/` (ensure nothing else holds :50051): -```bash -python -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt -docker compose up -d --wait -python -c "import deploybot_server, web, authz, bootstrap, approve, revoke" # imports resolve -python scripts/verify.py --checkpoint 1 -``` -Expected: imports succeed; verify prints `✅ stub allows destroy production (over-reach)` and `PASS ✅`. - -- [ ] **Step 9: Commit** - -```bash -cd ~/authzed-branches/workshops -git add delegated-agent-authorization/starter -git commit -m "feat(workshop): starter scaffolding, provided plumbing, CP1 stubs, verify.py" -``` - ---- - -### Task 2: `0-setup.md` - -**Files:** Create `delegated-agent-authorization/0-setup.md` - -- [ ] **Step 1: Author the setup doc** — model it on `../agentic-rag-authorization/0-setup.md`. Cover, in this order: - 1. One-paragraph framing: build a goose deploy agent, then add delegated authorization with SpiceDB; the `starter/` is stubbed on purpose. - 2. Get the code: `git clone https://github.com/authzed/workshops.git` → `cd workshops/delegated-agent-authorization/starter`. - 3. Option A — local Docker: `cp .env.example .env`, add an LLM key for goose (note: only needed for the goose path; the `verify.py`/web-UI path needs no LLM), `docker compose up -d` (brings up postgres + SpiceDB with the expiration flag on `devtoken`), venv + `pip install -r requirements.txt`. - 4. Option B — Codespaces: the `.devcontainer/` runs compose + installs deps on create. - 5. Install goose and register the deploy-bot extension: reference `goose-extension.md` (absolute paths to `.venv/bin/python` and `deploybot_server.py`, env `SPICEDB_ENDPOINT`/`SPICEDB_TOKEN=devtoken`/`AGENT_SUBJECT=agent:goose_alice`). - 6. `## Completion Milestone: Setup` checklist: repo cloned; infra up (Docker or Codespaces); `.env` has an LLM key (goose path); extension registered in goose. - 7. Ends: `Next: [Checkpoint 1 — Run the agent](1-run-the-agent.md)`. - -- [ ] **Step 2: Verify** — from a clean read, the commands are copy-pasteable and consistent with Task 1's files (token `devtoken`, port 50051, paths). Confirm `docker compose up -d --wait` succeeds and goose config keys match `goose-extension.md`. - -- [ ] **Step 3: Commit** `docs(workshop): 0-setup`. - ---- - -### Task 3: `1-run-the-agent.md` (Checkpoint 1 — watch it over-reach) - -**Files:** Create `delegated-agent-authorization/1-run-the-agent.md` - -- [ ] **Step 1: Author the checkpoint** — model on `../agentic-rag-authorization/1-agentic-rag.md`. Cover: - 1. The flow: goose calls the deploy-bot MCP extension; every tool routes through `authz.decide()` before acting. - 2. Show the `decide()` stub (quote the `WORKSHOP STUB` block from `authz.py`) and explain: it returns ALLOWED without consulting SpiceDB. - 3. **Watch it over-reach (goose path):** in a `goose session`, ask *"Deploy checkout to production"*, then *"Tear down the production environment."* Both execute — the agent has no authorization boundary. - 4. **Deterministic path:** `python scripts/verify.py --checkpoint 1` → shows the stub allows `destroy production`. - 5. Why: an agent runs with its host's ambient authority; with no authorization boundary, it can do anything the credentials can. Putting the rule in the prompt is not enough — a prompt can be bypassed. - 6. `## Completion Milestone: Checkpoint 1` — ran the agent; reproduced the over-reach via goose and/or `verify.py`; can explain why ambient authority is the problem. - 7. `Next: [Checkpoint 2 — Delegated authorization](2-delegated-authorization.md)`. - -- [ ] **Step 2: Verify** — the quoted stub matches `authz.py` exactly; `verify.py --checkpoint 1` output matches what the doc claims. - -- [ ] **Step 3: Commit** `docs(workshop): CP1 run-the-agent`. - ---- - -### Task 4: `2-delegated-authorization.md` (Checkpoint 2 — the core) - -**Files:** Create `delegated-agent-authorization/2-delegated-authorization.md` - -**Interfaces:** the fill-in code here must reproduce the CP2 state that `verify.py --checkpoint 2` asserts (staging ALLOWED, prod NEEDS_APPROVAL, destroy BLOCKED, post-approve prod ALLOWED). - -- [ ] **Step 1: Author the checkpoint** — model on `../agentic-rag-authorization/2-secure-it.md`. Cover: - 1. **Concepts (inline):** ReBAC vs. RBAC; Google Zanzibar; why the decision must be a deterministic check, not the prompt. - 2. **Write the schema** — attendee replaces `schema.zed` with (this exact block): - ```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 - } - ``` - Explain each relation/permission and the delegation idea (`agent.delegator`). - 3. **Seed it:** `python bootstrap.py` (writes schema + relationships: alice deploys both envs + approves prod; sre_admin is the only destroyer; the agent is delegated staging-only). - 4. **Implement `decide()`** — attendee replaces the stub with (this exact block): - ```python - async def decide(client, agent_id, permission, environment_id) -> AuthzResult: - 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}") - 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}'; delegator user:{delegator} holds it — human approval required") - return AuthzResult(Decision.BLOCKED, - f"neither agent:{agent_id} nor its delegator may '{permission}' environment:{environment_id}") - ``` - Explain the three-way logic: agent's own grant → ALLOWED; else the human it acts for could → NEEDS APPROVAL; else BLOCKED. - 5. **See it (both paths):** start the web UI (`python web.py`, open `http://127.0.0.1:8000`) — deploy staging ✅, deploy prod ⏸, destroy 🚫; run `python approve.py --approver alice --env production` then retry prod → ✅. And in goose, the same prompts. Deterministic: `python scripts/verify.py --checkpoint 2` → `PASS ✅`. - 6. Why the check (not the prompt) is the boundary — deterministic, unbypassable, the agent only ever gets an answer SpiceDB computed. - 7. `## Completion Milestone: Checkpoint 2` — wrote the schema; seeded; implemented `decide()`; `verify.py --checkpoint 2` passes; saw the three-way decision in the UI and/or goose; can explain ReBAC. - 8. `Next: [Checkpoint 3 — Time-bound and revocable](3-time-bound-and-revocable.md)`. - -- [ ] **Step 2: Verify the fill-ins reach the CP2 state** — from `starter/`, apply the schema + `decide()` blocks from the doc, then: - ```bash - python scripts/verify.py --checkpoint 2 - ``` - Expected `PASS ✅`. (Restore the stubs afterward so `starter/` ships stubbed: `git checkout starter/schema.zed starter/authz.py`.) - -- [ ] **Step 3: Commit** `docs(workshop): CP2 delegated-authorization`. - ---- - -### Task 5: `3-time-bound-and-revocable.md` (Checkpoint 3) - -**Files:** Create `delegated-agent-authorization/3-time-bound-and-revocable.md` - -**Interfaces:** fill-ins must reach the CP3 state `verify.py --checkpoint 3` asserts (window 0 → NEEDS_APPROVAL; revoke → NEEDS_APPROVAL). Requires the schema `agent_deployer: agent with expiration`, `seed()`/`approve.py` writing `optional_expires_at`. - -- [ ] **Step 1: Author the checkpoint.** Cover: - 1. Concept: temporary access — incident windows. SpiceDB's built-in relationship expiration (preferred over a caveat; evaluated server-side; garbage-collected). - 2. **Schema edit:** add `use expiration` at the top and change the relation to `relation agent_deployer: agent with expiration`. (Show the exact diff.) - 3. **Update the seed** — in `bootstrap.py`, change the staging grant line to carry an expiry: - ```python - from authz import expiry_from_now - # ... - rel("environment", "staging", "agent_deployer", "agent", AGENT_ID, - expires_at=expiry_from_now(window_minutes)), - ``` - 4. **Update `approve.py`** — write the grant with an expiry: - ```python - from authz import check, read_delegator, expiry_from_now - # ... - update = rel("environment", environment, "agent_deployer", "agent", agent_id, - expires_at=expiry_from_now(minutes)) - ``` - 5. **See expiry without waiting:** `python bootstrap.py --window-minutes 0` seeds an already-expired staging grant → the agent's autonomous staging deploy drops to NEEDS APPROVAL. Then **revocation:** `python revoke.py --env staging` → same effect, instantly. Both visible in the web UI (grant disappears / countdown) and via `python scripts/verify.py --checkpoint 3`. - 6. Why contingent evaluation beats a cron job that deletes grants. - 7. `## Completion Milestone: Checkpoint 3` — added expiration to schema + seed + approve; demoed the window and revoke; `verify.py --checkpoint 3` passes. - 8. `Next: [Checkpoint 4 — Relationship-based hierarchy](4-relationship-based-hierarchy.md)`. - -- [ ] **Step 2: Verify the fill-ins** — apply CP2 fill-ins + CP3 edits, then `python scripts/verify.py --checkpoint 3` → `PASS ✅`. Restore stubs after (`git checkout starter/`). - -- [ ] **Step 3: Commit** `docs(workshop): CP3 time-bound-and-revocable`. - ---- - -### Task 6: `4-relationship-based-hierarchy.md` (Checkpoint 4 — the payoff) - -**Files:** Create `delegated-agent-authorization/4-relationship-based-hierarchy.md` - -**Interfaces:** fill-ins must reach the CP4 state `verify.py --checkpoint 4` asserts (both grants → prod ALLOWED; revoke staging → staging AND production NEEDS_APPROVAL — the cascade). Requires `gated_by`, `agent_deploy = agent_deployer & gated_by->agent_deployer`, `deploy = direct_deployer + agent_deploy`, and gated_by seed. - -- [ ] **Step 1: Author the checkpoint.** Cover: - 1. Concept: hierarchical/contingent authority — production autonomy should depend on staging autonomy. RBAC can't express this without a role-per-combination explosion. - 2. **Schema edit** (show exact additions): - ```zed - // inside definition environment: - relation gated_by: environment - permission agent_deploy = agent_deployer & gated_by->agent_deployer - permission deploy = direct_deployer + agent_deploy - ``` - Explain the intersection + arrow: the agent may deploy here only if it holds this env's grant AND the gating env's grant. Staging gates itself (base); production is gated by staging. - 3. **Seed gated_by** — add to `bootstrap.seed()`: - ```python - rel("environment", "staging", "gated_by", "environment", "staging"), - rel("environment", "production", "gated_by", "environment", "staging"), - ``` - 4. **See the cascade:** re-seed; `python approve.py --approver alice --env production` (agent now deploys both). Then `python revoke.py --env staging` — production autonomy suspends automatically, no second delete. Web UI shows production as a dashed *suspended* grant. `python scripts/verify.py --checkpoint 4` → `PASS ✅`. - 5. Why: it's contingent *evaluation*, not a delete — so it's suspend-not-erase (re-granting staging revives production while its window lasts). This is the ReBAC superpower. - 6. `## Completion Milestone: Checkpoint 4` — added `gated_by` + `agent_deploy`; seeded the hierarchy; demoed the staging→production cascade; `verify.py --checkpoint 4` passes; can explain why RBAC can't do this. - 7. `Next: [Next steps](5-nextsteps.md)`. - -- [ ] **Step 2: Verify the fill-ins** — apply CP2+CP3+CP4 edits, then `python scripts/verify.py --checkpoint 4` → `PASS ✅`. Confirm the resulting schema matches the tested solution's `schema.zed` behavior. Restore stubs after (`git checkout starter/`). - -- [ ] **Step 3: Commit** `docs(workshop): CP4 relationship-based-hierarchy`. - ---- - -### Task 7: `README.md`, `5-nextsteps.md`, and full end-to-end validation - -**Files:** Create `delegated-agent-authorization/README.md`, `delegated-agent-authorization/5-nextsteps.md` - -- [ ] **Step 1: Author `README.md`** — model on `../agentic-rag-authorization/README.md`. Include: the title + abstract (from the conference listing); "Why this matters" (agents run with credentials that touch everything); "What you'll build" (bulleted); prerequisites (Docker/Codespaces, Python 3.10+, goose + an LLM key for the goose path); the **90-minute module map** with the per-checkpoint timing from the spec (Setup 15 · CP1 10 · CP2 25 · CP3 15 · CP4 20 · Next Steps 5); a link to the full reference solution `https://github.com/sohanmaheshwar/goose-spicedb-delegation`; and the ordered module links. End with "Let's get started with [Setup](0-setup.md)." - -- [ ] **Step 2: Author `5-nextsteps.md`.** Cover: modeling authority once as relationships for a whole platform; `CheckBulk` for "which of these N resources may this agent touch"; on-behalf-of vs. advisory enforcement; production hardening the workshop deliberately skipped (gate `revoke`/admin actions behind a `manage` permission; gate `list_environments` behind a `view` permission — point to the solution repo which does both); scaling ReBAC with SpiceDB (Zanzibar lineage, Kubernetes — link the other AuthZed workshop). Keep it a short zoom-out. - -- [ ] **Step 3: Full end-to-end validation (the integration test).** From a fresh `starter/` (stubs in place), walk the entire workshop in order, applying each checkpoint's fill-in code from the markdown and running its verifier: - ```bash - cd delegated-agent-authorization/starter - docker compose up -d --wait - python scripts/verify.py --checkpoint 1 # stub over-reach: PASS - # apply CP2 schema + decide() from 2-delegated-authorization.md - python scripts/verify.py --checkpoint 2 # PASS - # apply CP3 edits from 3-time-bound-and-revocable.md - python scripts/verify.py --checkpoint 3 # PASS - # apply CP4 edits from 4-relationship-based-hierarchy.md - python scripts/verify.py --checkpoint 4 # PASS - git checkout schema.zed authz.py bootstrap.py approve.py # restore ship-state stubs - ``` - All four must PASS. This proves the checkpoints' embedded code is correct and complete. Fix any checkpoint whose fill-ins don't reach its verifier state. - -- [ ] **Step 4: Confirm `starter/` ships stubbed** — `git status` clean; `schema.zed` and `authz.decide()` are the CP1 stubs; `bootstrap.py`/`approve.py` are the CP2 versions (no expiry). - -- [ ] **Step 5: Commit** `docs(workshop): README, next steps, end-to-end validated`. - ---- - -## Self-Review - -**Spec coverage:** -- Folder/structure → Task 1 + doc tasks. ✓ -- Guided-implement (schema + decide) → CP2 (Task 4), grown in CP3/CP4 (Tasks 5/6); everything else provided in Task 1. ✓ -- goose headline + deterministic verifier → both paths in every checkpoint; `verify.py` (Task 1) + web UI (copied Task 1). ✓ -- CP1 over-reach → Task 3 + `decide()` stub + `verify.py --checkpoint 1`. ✓ -- Delegation / 3-way → CP2 (Task 4). ✓ -- Time-bound + revoke → CP3 (Task 5). ✓ -- ReBAC hierarchy/cascade → CP4 (Task 6). ✓ -- Setup (Docker + Codespaces) → Task 2 + devcontainer (Task 1). ✓ -- README + module map + timing + next steps → Task 7. ✓ -- Prereqs, non-goals (no manage/view/rollback; revoke ungated) → Global Constraints + Next Steps follow-ups. ✓ -- Success criteria (clean clone → all checkpoints pass) → Task 7 Step 3 end-to-end validation. ✓ - -**Placeholder scan:** All code blocks are complete and exact. `# TODO(Checkpoint N):` markers are intentional workshop-stub content, not plan placeholders. Doc-authoring steps specify exact embedded code + the reference file to match for tone (not "write prose"). - -**Type consistency:** `decide(client, agent_id, permission, environment_id)` (no `action`) is consistent across authz.py, deploybot_server.py (Task 1 mod drops `action=`), verify.py, and the CP2 fill-in. `check`/`read_delegator`/`expiry_from_now`/`rel`/`agent_deployer_filter` signatures match the solution's and are used consistently. `deploy` (not `agent_deploy`) is the permission checked by web.py `/api/state` and verify.py — works CP2→CP4 because CP4 rewires `deploy` through `agent_deploy`. diff --git a/docs/superpowers/specs/2026-08-04-delegated-agent-authorization-workshop-design.md b/docs/superpowers/specs/2026-08-04-delegated-agent-authorization-workshop-design.md deleted file mode 100644 index 18c1c8f..0000000 --- a/docs/superpowers/specs/2026-08-04-delegated-agent-authorization-workshop-design.md +++ /dev/null @@ -1,194 +0,0 @@ -# Delegated Authorization for AI Agents — Workshop Design - -**Date:** 2026-08-04 -**Status:** Approved design, ready for implementation plan -**Folder:** `delegated-agent-authorization/` (in `authzed/workshops`) -**Branch:** `workshop/delegated-agent-authorization` - -## What this is - -A self-guided, hands-on workshop that teaches technical attendees how to add -**delegated, fine-grained authorization** to AI agents. Attendees build a DevOps -deploy agent on **goose** (the open-source agent from the Agentic AI Foundation) and -gate its every action with **SpiceDB** using **Relationship-Based Access Control -(ReBAC)**. By the end they understand scoped delegation, time-bound (expiring) grants, -instant revocation, and hierarchical permissions where revoking a base grant cascades -to dependents — something role-based systems can't express cleanly. - -Delivered first at a conference (Fri Sep 11, 2026, JST), then published to -`authzed/workshops`. - -## Audience, duration, learning goals - -- **Audience:** technical — AI engineers and programmers. Comfortable with a terminal, - Python, Docker. No prior authorization background assumed. -- **Duration:** 90 minutes, self-guided (works both live and after the fact). -- **Learning goals:** by the end, an attendee can explain and has hands-on built: - 1. **Delegated authorization** — an agent acts on a human's behalf with a scoped - subset of their authority; decisions resolve to ALLOWED / NEEDS APPROVAL / BLOCKED. - 2. **Time-bound expiration** — grants that expire on their own (incident windows), - plus instant revocation. - 3. **ReBAC / hierarchy** — relationship- and hierarchy-aware permissions, incl. a - cascade (revoking staging autonomy suspends production autonomy automatically). - -## Format (follows the reference workshop) - -Matches `agentic-rag-authorization`: a `starter/` app with intentionally-stubbed -authorization pieces, checkpoint markdown files that go *run it → watch it fail → -implement the fix → re-run → why this is the right design*, each ending in a -**Completion Milestone** checklist, plus a setup doc (local Docker **and** a Codespaces -devcontainer) and a web UI to see decisions live. - -**Pedagogy — guided implement:** attendees write the two pedagogically valuable things -themselves from `# TODO(Checkpoint N):` stubs — **the SpiceDB schema** (grown across -CP2→CP4) and **the `decide()` decision engine** (CP2). Everything else is provided -plumbing: the goose MCP extension, docker-compose, seed harness, approve/revoke scripts, -web UI, devcontainer. The exact code-to-write is embedded in each checkpoint (the -reference's own pattern). The complete reference solution is the published -`github.com/sohanmaheshwar/goose-spicedb-delegation` repo, linked from the workshop. - -**Two ways to see every checkpoint:** goose is the headline — attendees register the -MCP extension and talk to it in natural language ("Deploy checkout to production"). The -**web UI** + `python scripts/verify.py` are the deterministic verifier, so every -checkpoint is confirmable in a room without depending on an LLM key. The deterministic -path never needs an LLM. - -## Artifact structure - -``` -delegated-agent-authorization/ - README.md # overview, "what you'll build", prereqs, 90-min module map, solution link - 0-setup.md # Docker + Codespaces devcontainer, register goose extension, seed, verify - 1-run-the-agent.md # CP1: run the UNGATED agent, watch it over-reach - 2-delegated-authorization.md # CP2: write delegation schema + implement decide() (3-way); ReBAC concepts - 3-time-bound-and-revocable.md # CP3: expiring grants (incident window) + instant revoke - 4-relationship-based-hierarchy.md # CP4: gated_by cascade — revoke staging suspends prod - 5-nextsteps.md # scale: ReBAC in prod, CheckBulk, on-behalf-of, real platform teams - starter/ - docker-compose.yml # postgres + spicedb (+ expiration flag) - schema.zed # STUB — grown by learners across CP2→CP4 - authz.py # decide() + helpers STUB — implemented in CP2 - bootstrap.py # seed harness (grant-writing evolves with the schema) - deploybot_server.py # goose MCP extension — provided plumbing; calls decide() - spicedb_client.py # provided - relationships.py # provided TOUCH/filter helpers - approve.py, revoke.py # provided lifecycle scripts - web.py, static/index.html # provided web UI (chat + authority bar + decision states) - scripts/verify.py # per-checkpoint deterministic verifier - requirements.txt, .env.example - goose-extension.md # how to register the extension in goose - .devcontainer/devcontainer.json # Codespaces - images/ # authority-chain + decision-flow diagrams -``` - -## The checkpoint arc (the schema grows as they learn) - -Each checkpoint adds exactly one concept by editing the schema and re-running. The -naive → fix → why loop drives every one. - -### CP1 — Run it, watch it over-reach (~10 min) -`decide()` is a stub returning ALLOWED for everything (clear `# TODO(Checkpoint 2)` -docstring warning, mirroring the reference). Attendees register the extension, talk to -goose, and watch it deploy to production and tear down environments with no guardrail — -all green in the web UI. Takeaway: an agent runs with its host's ambient authority; with -no authorization boundary it can do anything the credentials can. - -### CP2 — Delegated authorization (~25 min, includes ReBAC concepts) -Attendees **write `schema.zed`**: -```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 -} -``` -and **implement `decide()`**: check the agent for the permission → ALLOWED; else read the -agent's `delegator` and check the delegator → NEEDS APPROVAL (a human could authorize); -else BLOCKED. Seed grants the agent staging-only deploy. Results: staging ✅, production -⏸ (with the `approve.py` human-in-the-loop flow), destroy 🚫. ReBAC and the Google -Zanzibar model are taught inline here (relationships, not flat roles; why the decision -lives in a deterministic check, not the prompt). - -### CP3 — Time-bound & revocable (~15 min) -Attendees add expiration to the schema (`use expiration`; -`agent_deployer: agent with expiration`) and grants carry `optional_expires_at`. -Demonstrate the incident window and — because it's contingent evaluation — show expiry -without waiting (`bootstrap.py --window-minutes 0`), then instant `revoke.py`. Note this -uses SpiceDB's built-in relationship expiration (preferred over a caveat for expiry). - -### CP4 — Relationship-based hierarchy (~20 min) -Attendees add the cascade to the schema: -```zed -relation gated_by: environment // production -> staging; staging -> itself -permission agent_deploy = agent_deployer & gated_by->agent_deployer -permission deploy = direct_deployer + agent_deploy -``` -Production autonomy becomes contingent on staging autonomy. Revoke staging → production -`agent_deploy` evaluates false automatically, no second delete. This is the payoff: the -thing RBAC can't express cleanly. The web UI shows the suspended production grant as a -dashed chip. - -### 5 — Next steps (~5 min) -How this maps to a real platform team: modeling authority once as relationships, -`CheckBulk` for "which of these N resources may this agent touch", on-behalf-of vs. -advisory enforcement, and scaling ReBAC with SpiceDB (Zanzibar lineage). Links to the -full solution repo and SpiceDB docs. - -## Verification - -- `starter/scripts/verify.py` asserts the expected decision matrix for the **current** - checkpoint (so a learner confirms their schema/`decide()` is correct before moving on), - mirroring the reference's `verify_permissions.py`. It runs against live SpiceDB, no LLM. -- The web UI shows each decision (ALLOWED / NEEDS APPROVAL / BLOCKED) and the live - authority state (delegation chain + expiry countdown). -- goose provides the natural-language "for real" path. - -## Prerequisites (stated in README/setup) - -- Docker (or a GitHub Codespace via the provided devcontainer) -- Python 3.10+ -- goose installed, plus an API key for any LLM goose supports (only needed for the - goose path; the deterministic verifier + web UI need no LLM) - -## Decisions & defaults - -- **Language:** Python (matches the demo and the authzed/langchain workshop stack). -- **SpiceDB:** local via Docker Compose, `authzed/spicedb:latest`, insecure preshared key - (`somerandomkeyhere` or `devtoken`), expiration enabled via - `--enable-experimental-relationship-expiration`. -- **Solution source:** adapt the tested `goose-spicedb-delegation` repo — the `starter/` - is that code with `schema.zed` and `authz.decide()` stubbed; checkpoints contain the - exact code to fill in. -- **Provided vs. implemented:** implemented by learner = schema + `decide()`; provided = - everything else. - -## Non-goals - -- Not a goose internals or prompt-engineering workshop. -- Not production deployment of SpiceDB (Kubernetes/Operator) — that's a different - workshop; touched only in Next Steps. -- No cloud accounts or paid services beyond an optional LLM key. -- Not multi-agent fleets (mentioned in Next Steps only). - -## To verify at build time - -- Exact goose custom-extension config (config.yaml stanza / `goose configure`) — reuse - the demo's verified `goose-extension.md`. -- Codespaces devcontainer that runs `docker compose up -d` + installs deps on create. -- `scripts/verify.py` output format and the per-checkpoint assertions matching each - schema state. - -## Success criteria - -- A learner starting from a clean clone completes setup, then all four checkpoints, with - `scripts/verify.py` passing at each, on Docker or Codespaces. -- CP1 visibly over-reaches; CP2 produces the three-way decision; CP3 shows expiry + - revoke; CP4 shows the staging→production cascade. -- The goose path and the web-UI/CLI path both demonstrate each checkpoint's behavior. -- Total content paces to ~90 minutes. From de94ff214803b42e5e617683e13f8813ab4005dd Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:47:21 +0200 Subject: [PATCH 22/27] docs(workshop): tighten Part 1 prose --- delegated-agent-authorization/1-run-the-agent.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/delegated-agent-authorization/1-run-the-agent.md b/delegated-agent-authorization/1-run-the-agent.md index 9bb47f2..50fad3f 100644 --- a/delegated-agent-authorization/1-run-the-agent.md +++ b/delegated-agent-authorization/1-run-the-agent.md @@ -5,19 +5,13 @@ never be allowed to do. For example: this agent can tear down production servers --- -## The flow — goose calls deploybot +## The backend `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. The code says so - directly: - - ```python - # UNGATED in this workshop: list_environments is not authorization-checked. - ``` - + 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 @@ -25,7 +19,7 @@ standard goose uses to call out to external tools. It exposes three tools: `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. In Part 1, the boundary is a stub that never says no. +workshop is about. --- From 61346055196c8f2c13fcd9066535686f4c8ef614 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:45:03 +0200 Subject: [PATCH 23/27] docs(workshop): step-by-step goose configure walkthrough for the deploybot extension Replace the ambiguous 'follow goose-extension.md' step with an explicit goose configure prompt-by-prompt flow: the absolute command path (venv python + deploybot_server.py) and the three env vars. --- delegated-agent-authorization/0-setup.md | 40 +++++++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/delegated-agent-authorization/0-setup.md b/delegated-agent-authorization/0-setup.md index de3b973..53b583f 100644 --- a/delegated-agent-authorization/0-setup.md +++ b/delegated-agent-authorization/0-setup.md @@ -95,11 +95,41 @@ If you do want the goose path: 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 into this repo's deploy tools. Follow - `goose-extension.md` — it walks through editing `~/.config/goose/config.yaml` (or running - `goose configure` interactively) with **absolute paths** to this repo's `.venv/bin/python` and - `deploybot_server.py`, plus three env vars the extension needs to reach SpiceDB: - `SPICEDB_ENDPOINT=localhost:50051`, `SPICEDB_TOKEN=devtoken`, `AGENT_SUBJECT=agent:goose_alice`. +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 From be46af16aa459621f6dd18aef900591f14b235cf Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:51:16 +0200 Subject: [PATCH 24/27] docs(workshop): re-apply goose configure walkthrough (lost in a local overwrite) --- delegated-agent-authorization/0-setup.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/delegated-agent-authorization/0-setup.md b/delegated-agent-authorization/0-setup.md index 53b583f..48f5d99 100644 --- a/delegated-agent-authorization/0-setup.md +++ b/delegated-agent-authorization/0-setup.md @@ -1,7 +1,6 @@ # Introduction -In this workshop you build a DevOps deploy Agent, then gate every action it takes with -delegated, fine-grained authorization: scoped grants, time-bound windows, instant +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. @@ -14,7 +13,7 @@ write the schema and the decision engine yourself across the parts to learn each ## Two ways to drive the agent -You can complete every part in this workshop with just the web UI — no LLM key, no goose +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 From 036f62f05c8ae6f277e09ac3f05ddd9302e3e3b4 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:17:04 +0200 Subject: [PATCH 25/27] docs(workshop): add FIG.02 relationship graph to Part 2 Export the Part 2 relationship graph (Sandworm style, playground-style layout, embedded JetBrains Mono) as a self-contained SVG under images/, and embed it at the end of the 'Permissions are a graph, not a table' section. --- .../2-delegated-authorization.md | 48 +++++++++++------ .../images/fig2-relationship-graph.svg | 51 +++++++++++++++++++ 2 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 delegated-agent-authorization/images/fig2-relationship-graph.svg diff --git a/delegated-agent-authorization/2-delegated-authorization.md b/delegated-agent-authorization/2-delegated-authorization.md index 1508859..47dec73 100644 --- a/delegated-agent-authorization/2-delegated-authorization.md +++ b/delegated-agent-authorization/2-delegated-authorization.md @@ -3,8 +3,11 @@ 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 honest answers: the agent can do this, a human needs to say yes -first, or nobody involved is allowed to do this at all. +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. --- @@ -20,11 +23,14 @@ SpiceDB stores access relationships as a graph, where nodes represent entities ( > 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. +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 @@ -50,10 +56,9 @@ Before walking through it, two bits of SpiceDB syntax. A `relation name: type` l 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 `->`) — never -stored. +permission is computed from relations on every check (with `+`, and later `&` and `->`). -Walking it: +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. @@ -84,7 +89,7 @@ actually has them. 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. +Note that each relationship write is just a simple API call. Run: ```bash python bootstrap.py @@ -117,15 +122,27 @@ 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}") + 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}'; delegator user:{delegator} holds it — human approval required") - return AuthzResult(Decision.BLOCKED, - f"neither agent:{agent_id} nor its delegator may '{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 @@ -215,9 +232,8 @@ web UI calls, gated by the same `decide()`. ## Why the check — not the prompt — is the boundary -Nothing in this part told the agent, in English, "you may deploy staging but not -production, and never destroy anything." There's no system-prompt instruction to argue with or talk -around. The agent's tool call runs through `deploybot_server._decide_and_mutate`, which calls `decide()` before it ever touches +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 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 From ec43ee0c300ed486dfef3fb1a3f33c736c7f96b2 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:26:59 +0200 Subject: [PATCH 26/27] docs(workshop): prose polish across setup, Part 1, Part 3, Part 4, and README --- delegated-agent-authorization/0-setup.md | 2 +- .../1-run-the-agent.md | 104 +++++++++--------- .../3-time-bound-and-revocable.md | 15 +-- .../4-relationship-based-hierarchy.md | 2 +- delegated-agent-authorization/README.md | 30 ++--- 5 files changed, 65 insertions(+), 88 deletions(-) diff --git a/delegated-agent-authorization/0-setup.md b/delegated-agent-authorization/0-setup.md index 48f5d99..74476bc 100644 --- a/delegated-agent-authorization/0-setup.md +++ b/delegated-agent-authorization/0-setup.md @@ -83,7 +83,7 @@ Codespaces gets you most of the way there. It isn't zero-config: confirm `.venv` `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 +## 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. diff --git a/delegated-agent-authorization/1-run-the-agent.md b/delegated-agent-authorization/1-run-the-agent.md index 50fad3f..9e7eedb 100644 --- a/delegated-agent-authorization/1-run-the-agent.md +++ b/delegated-agent-authorization/1-run-the-agent.md @@ -1,50 +1,11 @@ # 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 checks to stop it from doing so. +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. --- -## The backend - -`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`. `decide()` is 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. - ---- - -## Watch it over-reach +## 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 @@ -61,20 +22,19 @@ Open `http://127.0.0.1:8000`. Type something reasonable into the request box (or 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 no agent should be able to decide on its own: +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 that's -just a comment for humans reading the code. Nothing enforces 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 +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 no deterministic check before performing an action. +agent might decide to do on its own mid-task. This is the consequence of a lack of permission checks before performing an action. --- @@ -93,11 +53,50 @@ And type the following and see what happens: > 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 bug, driven by a real +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 @@ -108,16 +107,11 @@ concerned, there's no difference between "deploy a service" and "destroy product 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. It's the same failure mode as a script -running with root because it happened to be launched by root — not because anyone decided it -should have root. +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. The prompt is not -a boundary as prompts are suggestions to a language model, not a control the system enforces. -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. +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. --- diff --git a/delegated-agent-authorization/3-time-bound-and-revocable.md b/delegated-agent-authorization/3-time-bound-and-revocable.md index bc8d57e..47f5b64 100644 --- a/delegated-agent-authorization/3-time-bound-and-revocable.md +++ b/delegated-agent-authorization/3-time-bound-and-revocable.md @@ -79,17 +79,14 @@ rel("environment", "staging", "agent_deployer", "agent", AGENT_ID, ``` `rel()` (in `relationships.py`) already accepts an `expires_at` keyword and threads it into -`optional_expires_at`. That plumbing was there from the start, waiting for the schema to allow it. -Now every `bootstrap.py` run grants staging autonomy for exactly `window_minutes` from *now*, not +`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. `approve.py` already takes `--minutes` -(default 10) and ignored it. Part 2's version wrote the grant with no expiry at all. Import -`expiry_from_now` alongside the checks it already runs, and carry it into the write: +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 @@ -101,8 +98,7 @@ update = rel("environment", environment, "agent_deployer", "agent", agent_id, ``` Now clicking **Approve prod · 10m** in the web UI writes a grant that expires 10 minutes later on -its own. No follow-up step, nothing to remember to undo. An approval that never expires was really -a standing grant with extra steps; this makes "approved for now" mean what it says. +its own. There's no follow-up step or a step to undo. --- @@ -143,7 +139,7 @@ doesn't wait for a TTL; they click one button and the grant is gone on the very ## Drive it with goose (optional) -If you're running the goose path, the same lever works in natural language. Click **Grant staging · +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 @@ -164,8 +160,7 @@ 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, but it's a housekeeping detail, not part of the authorization -decision. The boundary is exactly as tight as `CheckPermission` itself. +background to reclaim storage. --- diff --git a/delegated-agent-authorization/4-relationship-based-hierarchy.md b/delegated-agent-authorization/4-relationship-based-hierarchy.md index 46e120c..eef48aa 100644 --- a/delegated-agent-authorization/4-relationship-based-hierarchy.md +++ b/delegated-agent-authorization/4-relationship-based-hierarchy.md @@ -13,7 +13,7 @@ staging's, enforced by the graph. ReBAC makes this pattern very straightforward. ## Contingent authority — why RBAC can't express this -The policy you want is: "the agent may deploy production on its own only while it can also deploy +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." diff --git a/delegated-agent-authorization/README.md b/delegated-agent-authorization/README.md index 7726912..1870f48 100644 --- a/delegated-agent-authorization/README.md +++ b/delegated-agent-authorization/README.md @@ -1,8 +1,7 @@ # 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. -This could lead to problems. +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. @@ -13,7 +12,9 @@ scoped delegation, expiring grants, instant revocation, and hierarchical permiss 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. +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) --- @@ -22,14 +23,14 @@ It's self-guided and hands-on, and everything runs locally with open-source tool 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 for free with the environment an agent runs in, rather than being granted for a +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 system prompt — "never destroy production without approval" — -doesn't hold up. A prompt is a suggestion to a language model, not a control the system enforces; +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 forgotten three turns into a longer conversation. An authorization +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 @@ -42,7 +43,7 @@ matter how many different ways — or how many different agents — ask it. - 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` — never a silent yes + `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 @@ -61,19 +62,6 @@ matter how many different ways — or how many different agents — ask it. No prior authorization background is assumed. Comfort with a terminal, Python, and Docker is enough. -## The 90-minute module map - -| Module | Time | What you do | -| --- | --- | --- | -| [Setup](0-setup.md) | 15 min | Bring up Docker (or Codespaces), install dependencies, optionally register the goose extension | -| [Part 1 — Run the agent](1-run-the-agent.md) | 10 min | Run the agent ungated and watch it destroy production on request | -| [Part 2 — Delegated authorization](2-delegated-authorization.md) | 25 min | Write the ReBAC schema and implement the three-way `decide()` | -| [Part 3 — Time-bound and revocable](3-time-bound-and-revocable.md) | 15 min | Add expiring grants for incident windows, plus instant revocation | -| [Part 4 — Relationship-based hierarchy](4-relationship-based-hierarchy.md) | 20 min | Make production's autonomy contingent on staging's, and watch the cascade | -| [Next steps](5-nextsteps.md) | 5 min | Zoom out to a real platform: bulk checks, on-behalf-of enforcement, scaling ReBAC | - -That's 90 minutes end to end, self-guided — it works equally well live or self-paced afterward. - ## The full reference solution Everything you write in this workshop — the schema, `decide()`, the part progression — is a From 6adf207be4f60ad03d37040f0dfc78c277fceec5 Mon Sep 17 00:00:00 2001 From: Sohan Maheshwar <1119120+sohanmaheshwar@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:26:10 +0200 Subject: [PATCH 27/27] docs(workshop): remove SpiceDB logo from FIG.01 authorization-layer beam --- .../images/fig1-permission-check.svg | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/delegated-agent-authorization/images/fig1-permission-check.svg b/delegated-agent-authorization/images/fig1-permission-check.svg index 55ec864..d9e0d14 100644 --- a/delegated-agent-authorization/images/fig1-permission-check.svg +++ b/delegated-agent-authorization/images/fig1-permission-check.svg @@ -70,15 +70,13 @@ SPICEDB - - AUTHORIZATION LAYER - - - + + +