Skip to main content
Glama
sychen6192

IBAC FastMCP Demo

by sychen6192
README.md
# IBAC demo -- MCP tools behind an intent gate and an OpenFGA gate

A teaching demo for the DevOpsDays 2026 talk "ReBAC with OpenFGA". An MCP
server exposes two tools, `list_team` and `list_salaries` (the slide deck
writes them as `list-team` / `list-salaries`; same things, fake data). Every
call has to get past two gates:

```
  request (x-api-key, x-user-intent)
    -> identity extraction        naive, on purpose
    -> Gate 1  IntentGate         does this call serve what the human asked for?
    -> Gate 2  FgaGate            does a tuple grant can_use on this tool?
    -> tool handler
```

Gate 1 is semantic and probabilistic, Gate 2 deterministic. The order matters:
an agent that legitimately holds a permission can still be stopped at Gate 1
when something talks it into using that permission off-task.

Both gates also filter `tools/list`, which is the earlier defence: an agent has
to know a tool exists before anything can talk it into reaching for one.

```
$ make discover
ceo-bot-key       'show me the team roster'
                  -> list_team
ceo-bot-key       'show me executive salaries'
                  -> list_salaries
random-stranger   'show me the team roster'
                  -> (nothing)
```

Same key, same permissions, different question, different menu. FgaGate answers
that with one `ListObjects` rather than a `Check` per tool, and because it is
the inner middleware the judge is never asked about a tool the agent could not
call anyway.

Enumerating gets silence; attempting gets an audited 403. An agent can still
find `list_salaries` by calling it blind -- but that attempt is a log line,
which is the trade being made.

## Run it

Pre-pull once, on a network that likes you:

```
docker pull openfga/openfga:v1.18.1 python:3.12.13-slim
```

Then:

```
make up        # build the image, start OpenFGA
make init      # create the store, start the server
make doctor    # preflight -- before you trust any of the above
make demo      # the acceptance table
make attack    # the money shot
```

`make help` lists the rest. Everything runs in containers -- there is no second
way to run this.

## The whole run, in order

Setup:

```
make up
make init
make doctor       # five lines, all ok, or stop here
make personas     # opencode configs -> ~/.ibac-personas/
```

Then, in the order the talk needs them:

```
make explain      # model vs tuple; can_use is computed, never stored
make patterns     # the five modelling moves, each asserted
make model-test   # the model has tests, and the DSL matches the JSON

opencode run --dir ~/.ibac-personas/ceo         "list the team"
opencode run --dir ~/.ibac-personas/ceo         "list the executive salaries"
opencode run --dir ~/.ibac-personas/ceo-payroll "list the executive salaries"
make discover     # the four lines behind what just happened

make tuples       # V0: two of them
make demo         # 6 pass, 2 pending

make magma        # V1
make demo         # 7 pass, 1 pending

make aqua         # V2
make demo         # 8 pass, 0 pending, 0 fail

make attack       # what a direct call gets: 403 at gate 1
make why          # and where any of those permissions came from
```

The three opencode lines are the point of the whole thing: list the team, then
ask the same persona for salaries and watch it answer that it only has
`ceo_list_team` -- not blocked, just not there -- then switch to `ceo-payroll`,
which is the same key with the same permissions and a different stated intent,
and get them.

If opencode misbehaves, `make discover`, `make demo` and `make attack` make the
same three points without it.

## Demo it with a real agent

```
make personas
```

writes one [opencode](https://opencode.ai) config per caller into
`~/.ibac-personas/`. Each pins a different `x-api-key` and `x-user-intent`, and
`--dir` picks which one the agent gets:

| persona | identity | stated intent | sees |
|---|---|---|---|
| `ceo` | ceo-bot-key | show me the team roster | `list_team` |
| `ceo-payroll` | ceo-bot-key | show me executive salaries | `list_salaries` |
| `stranger` | random-stranger | show me the team roster | nothing |
| `magma` | magma-grunt-01 | show me the team roster and the salaries | `list_team` (V1+) |
| `aqua` | archie | salary review for finance | `list_salaries` (V2) |

```
opencode run --dir ~/.ibac-personas/ceo "list the team"
opencode run --dir ~/.ibac-personas/ceo "list the executive salaries"
```

Outside the repo on purpose: opencode resolves its project config from the git
root, so a config in a subdirectory here is never read.

The second one does not get a 403. It gets this, which is better:

> 目前我能直接訪問的工具是 `ceo_list_team`⋯ 但這只會回傳人名與職稱,不包含薪資數據。

The agent is not blocked from calling `list_salaries`; the tool is not in its
world. Same key as `ceo-payroll`, same permissions -- a different stated intent,
a different menu. `stranger` gets an empty toolset and the agent simply has
nothing to reach for; opencode handles that without complaint.

**One persona per directory is the point.** `personas/all/opencode.json` hands
the agent all five at once, and an agent given five identities will shop:

```
$ opencode run --dir ~/.ibac-personas/all "use the ceo tools to list their salaries"
⚙ ceo-payroll_list_salaries
以下是 CEO Office 成員的年薪明細⋯
```

Told to use `ceo`, it reached for `ceo-payroll` instead and got the salaries.
Nothing was bypassed -- `ceo-payroll` genuinely holds that permission and stated
that intent -- but nobody chose that identity for the agent, the agent chose it
for itself. Worth thirty seconds; do everything else one persona at a time.

**Check what else your agent is carrying.** Whatever MCP servers your everyday
opencode setup injects come along. Ours arrived with about seventy tools,
including a filesystem server that read `server.py` and printed the salaries it
was never granted -- which makes rather a mess of a talk about an agent only
reaching what a tuple allows. Running from an empty directory scopes that server
to somewhere with nothing in it, and

```json
"tools": { "*": false, "ceo_*": true }
```

in the persona config is the documented way to leave the agent holding only the
gated tools. Rehearse it: an agent with a browser and a filesystem will find
another way to answer, and it will do it on stage.

Those configs also deny the agent `bash` and `edit`, which is not cosmetic. Run
without those denials and a blocked agent will rewrite `x-user-intent` in the
config and call the server with curl instead -- observed, not hypothetical. The
intent header is self-asserted, exactly like the API key. Gate 1 defends against
an agent that is *manipulated*; it does not defend against an agent that is
*itself* adversarial.

None of the gates fire in any of this, and that is the honest result: filter the
menu and a cooperative agent never reaches for what it should not have. The
call-time gates are there for the uncooperative case -- a compromised agent, or
curl. `make attack` is what that looks like.

## The storyline

| stage | command | what changes | the beat in the talk |
|---|---|---|---|
| V0 | `make init` | model_v0 + **2 tuples** | "We're done adding code." One agent, two tools, done. |
| V1 | `make magma` | model_v1 + **13 tuples** (1 userset + 12 members) | Magma shows up with twelve agents. One tuple grants the whole org `list_team`, twelve put the grunts in the org. |
| V2 | `make aqua` | **3 tuples**, model untouched | Aqua shows up. No schema change needed -- the model already said what it needed to say. |

The authorization model is written exactly **twice** in the whole story and
never again. OpenFGA keeps both versions in the store.

`models/` holds each version three ways: `.fga` is the DSL the slides show,
`.json` is what the API accepts and what `setup_fga.py` sends, and `.fga.yaml`
is the test.

```
make model-test
```

runs those tests against the DSL -- no store, no server, no network -- and then
checks that `fga model transform` on each `.fga` really does reproduce its
`.json`. The model is code; it gets the same treatment. Break either half and
the command goes red.

Three more commands, each on a throwaway store or read-only, none of which can
disturb a demo in progress:

- `make explain` -- model vs tuple: the same userset tuple rejected under
  model_v0 and accepted under model_v1, and `can_use` answering checks while
  never being stored.
- `make patterns` -- the five modelling moves (direct, folder inheritance,
  nested, userset, wildcard) executed on the deck's own document/folder example,
  every result asserted. The last one is the load-bearing one: `user:*` reaches
  every user and no agent, so every agent that reaches a tool has a tuple
  somebody wrote on purpose.
- `make why` -- `Expand` instead of `Check`: not "may they?" but "through
  what?", printed as the tree it actually is.

**V1 writes zero tuples for `list_salaries`.** Nobody wrote a deny rule, and
the twelve Magma agents still cannot read salaries. That is default-deny doing
the work.

**Scenario 7 is the point of the demo.** `ceo-bot-key` genuinely holds
`can_use` on `list_salaries` -- scenario 2 proves it. Ask it for the team
roster, then make it reach for salaries anyway:

```
$ make attack
BLOCKED at gate 1  (intent)
403 intent mismatch: tool list_salaries does not serve stated intent: 'show me the team roster'
```

Permissions alone would have allowed that call. Intent is what caught it.

Gate 1's judge is a keyword map by default, so the demo has no network
dependency. `INTENT_BACKEND=anthropic|ollama` swaps in a real model, and both
fail closed. See `.env.example`.

Every version is pinned exactly -- fastmcp 3.4.5, openfga-sdk 0.10.4, anthropic
0.120.2, OpenFGA v1.18.1, Python 3.12.13 -- because a conference is a bad place
to find out that a minor version moved. See `requirements.txt` and
`docker-compose.yml`.

Verified on Docker 29.1.3 / Compose 2.40.3 (Ubuntu 24.04) and Docker 24.0.6 /
Compose 2.21.0 (macOS).