# AgentFGA > Fine-grained authorization for AI agents and agentic products. AgentFGA decides what each agent, and each user it acts for, can see and do, down to a single document, tool or record. It is an OpenFGA-compatible server: the official OpenFGA SDKs, the `fga` CLI and the OpenFGA modeling language work against it unchanged. It runs on Cloudflare Workers (one Durable Object per store) or as a Node server on Postgres, MySQL, SQLite or memory. These instructions are for a coding agent. Your job is to deploy AgentFGA, describe the app's permissions as an authorization model, and make the app's AI agent ask AgentFGA before it acts. Work through the steps in order. Ask the user before you: - choose between Cloudflare Workers and the Node server (step 1); - deploy anything, create cloud resources or change production configuration; - decide who may do what, when the code doesn't make it clear. Never commit API keys or tokens. Keep them in secrets or untracked env files. ## 1. Get AgentFGA and choose where it runs AgentFGA needs Node.js 22.13 or later. Work in the AgentFGA repository. If it isn't in the workspace, ask the user where to get it. Pick one way to run it, and confirm the choice with the user: - **Cloudflare Workers** (recommended for new projects). Needs a Cloudflare account with Workers and Durable Objects. Each store is its own Durable Object, so capacity grows with the number of stores. A check takes about 6 ms on the server. - **Node server**, when the user already runs Postgres or MySQL, already runs OpenFGA, or must self-host. It takes the same flags, `OPENFGA_*` environment variables and defaults as `openfga run`, and uses OpenFGA's own database schema. - **Docker**: the Node server as an image laid out like `openfga/openfga`. ## 2. Run it locally ```sh npm install echo "AGENTFGA_AUTHN_PRESHARED_KEYS=dev-key-123" > .dev.vars npm run dev # http://127.0.0.1:8787 ``` `.dev.vars` holds the API key clients send as `Authorization: Bearer `. Keep `.dev.vars` out of git. For the Node server instead: ```sh npm run server -- --datastore-engine sqlite --datastore-uri ./openfga.db \ --authn-method preshared --authn-preshared-keys dev-key-123 # http://127.0.0.1:8080 ``` Check it is up: `curl http://127.0.0.1:8787/healthz` returns `{"status":"SERVING"}` (port 8080 for the Node server). ## 3. Deploy Generate a strong key first: ```sh node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))" ``` ### Cloudflare Workers ```sh npx wrangler login # interactive: ask the user to run it echo "$AGENTFGA_KEY" | npx wrangler secret put AGENTFGA_AUTHN_PRESHARED_KEYS npm run deploy # prints the Worker's URL ``` `AGENTFGA_AUTHN_PRESHARED_KEYS` takes several keys separated by commas. To rotate keys, add the new key, move every client to it, then remove the old one. Other settings go in `vars` in `wrangler.jsonc` (see Configuration below). ### Node server ```sh npm run build:server # dist/agentfga.mjs: one file, drivers and migrations included node dist/agentfga.mjs migrate --datastore-engine postgres --datastore-uri "$DATABASE_URL" node dist/agentfga.mjs run --datastore-engine postgres --datastore-uri "$DATABASE_URL" \ --authn-method preshared --authn-preshared-keys "$AGENTFGA_KEY" ``` Engines: `memory` (default, lost on restart), `sqlite` (a file path), `postgres` (a URI), `mysql` (a URI or a Go DSN such as `user:pass@tcp(db:3306)/openfga`). Ports: HTTP 8080, gRPC 8081, Prometheus metrics 2112. `/healthz` answers `SERVING` once the database is migrated. `--workers 0` runs one process per CPU (not with `memory`). The Node server's default authentication is `none`, so always set `--authn-method`. ### Docker ```sh docker build -t agentfga . docker run agentfga migrate --datastore-engine postgres --datastore-uri "$DATABASE_URL" docker run -p 8080:8080 -p 8081:8081 agentfga run --datastore-engine postgres \ --datastore-uri "$DATABASE_URL" --authn-method preshared --authn-preshared-keys "$AGENTFGA_KEY" ``` ## 4. Model the app's permissions Read the app's code to find: - **who acts**: users, and each AI agent (give agents their own type; don't let them borrow a user's identity); - **what they act on**: tools the agent can call, and the records or documents it can read or change; - **how access is granted today**: ownership, sharing, teams, roles, organizations. Write the model in OpenFGA's modeling language. Start from this one and adapt it: ``` model schema 1.1 type user type agent type team relations define member: [user, agent] type tool relations define can_use: [agent, team#member] type document relations define owner: [user] define temp: [agent with not_expired] define viewer: [user, team#member] or owner or temp condition not_expired(current_time: timestamp, grant_time: timestamp, grant_duration: duration) { current_time < grant_time + grant_duration } ``` Modeling reference: - Direct types: `[user]` one user; `[team#member]` everyone with that relation; `[user:*]` every user (public); `[agent with not_expired]` only with that condition. - Operators: `or` (either), `and` (both), `but not` (exclusion), `viewer from parent` (a relation on a related object). - Conditions are CEL. Parameter types: bool, string, int, uint, double, duration (`"1h"`), timestamp (RFC 3339), ipaddress (with `.in_cidr(cidr)`), `list`, `map`. Parameters come from the tuple, the request's `context`, or both. Always send the parameters a condition reads; a missing one can fail the request with `validation_error`. - Up to 100 types per model. Models are immutable: each write creates a new model id. Two common patterns have complete examples in the repository's `examples/` folder, each with the model, tests, app code and a demo. Read the one that fits before writing your own: - **Custom roles**, when the app's customers define their own roles. `examples/custom-roles` for roles that apply across an organization: each permission is a relation accepting `[role#member]`, and a role is tuples (`role:billing-manager#member can_manage_billing organization:acme`, `team:finance#member assignee role:billing-manager`), so creating or changing a role needs no model change. `examples/custom-roles-per-project` for roles assigned per resource, through a `role_assignment` type. Before writing a role change, check that the person making it holds every permission it hands out. - **Agent service accounts** (`examples/agent-service-accounts`), when customers create and manage their own agents. Grants go to `service_account:#enabled`, where `enabled` holds the account itself, so deleting that one tuple switches all of the account's grants off. Managers change grants, runners may start the agent, and when it acts for a person, check both the account and the person. Create a store and write the model with the `fga` CLI: ```sh export FGA_API_URL= export FGA_API_TOKEN= fga store create --name # note the store id export FGA_STORE_ID= fga model write --file model.fga # note the authorization_model_id ``` Or with the SDK: `fga.writeAuthorizationModel(transformer.transformDSLToJSONObject(dsl))`, using `@openfga/syntax-transformer`. If the app serves several customers, create one store per customer and keep the store id with the customer's record. Stores never share data. On Workers, a store lives near where it was created, so create each customer's store from a server near where their agents run. ## 5. Connect the app Install an OpenFGA SDK: `npm install @openfga/sdk`, `pip install openfga-sdk`, or `go get github.com/openfga/go-sdk`. Add these settings to the app's environment and secrets: `FGA_API_URL`, `FGA_API_TOKEN`, `FGA_STORE_ID`, and `FGA_MODEL_ID` (pin the model in production, so a new model takes effect only when code that uses it is deployed). ```ts import { CredentialsMethod, OpenFgaClient } from "@openfga/sdk"; export const fga = new OpenFgaClient({ apiUrl: process.env.FGA_API_URL!, storeId: process.env.FGA_STORE_ID!, authorizationModelId: process.env.FGA_MODEL_ID, credentials: { method: CredentialsMethod.ApiToken, config: { token: process.env.FGA_API_TOKEN! }, }, }); ``` ```python import os from openfga_sdk.client import ClientConfiguration, OpenFgaClient from openfga_sdk.credentials import CredentialConfiguration, Credentials configuration = ClientConfiguration( api_url=os.environ["FGA_API_URL"], store_id=os.environ["FGA_STORE_ID"], authorization_model_id=os.environ.get("FGA_MODEL_ID"), credentials=Credentials( method="api_token", configuration=CredentialConfiguration(api_token=os.environ["FGA_API_TOKEN"]), ), ) ``` ## 6. Enforce permissions in the agent ### Check before every tool call Put one check in front of the code that runs tools, so no tool runs without a yes. Fail closed: if the check errors or times out, don't run the tool. ```ts async function runTool(agentId: string, tool: Tool, input: unknown) { const { allowed } = await fga.check({ user: `agent:${agentId}`, relation: "can_use", object: `tool:${tool.name}`, }); if (!allowed) throw new Error(`agent:${agentId} may not use ${tool.name}`); return tool.run(input); } ``` ### Filter what reaches the model Before retrieved content goes into the prompt, keep only what the user may read. - Large corpus: retrieve candidates, then check them all with BatchCheck (up to 50 per request; the SDKs split longer lists). - Small set per user: ListObjects returns every object the user may view (at most 1,000 within 3 s by default); search only those. ```ts const { result } = await fga.batchCheck({ checks: hits.map((hit, i) => ({ user: `user:${userId}`, relation: "viewer", object: `document:${hit.documentId}`, correlationId: String(i), })), }); const allowed = new Set(result.filter((r) => r.allowed).map((r) => r.correlationId)); const context = hits.filter((_, i) => allowed.has(String(i))); ``` ### Act for a user When an agent works for someone, require both: the agent may use the tool, and the user may touch the object. Ask both in one BatchCheck and proceed only if every result is allowed. ### Access that expires Grant through a relation that requires a condition, store the grant's start and length with the tuple, and send the current time with each check: ```ts await fga.write({ writes: [{ user: "agent:support-bot", relation: "temp", object: "document:q3-plan", condition: { name: "not_expired", context: { grant_time: new Date().toISOString(), grant_duration: "1h" } }, }], }); const { allowed } = await fga.check({ user: "agent:support-bot", relation: "viewer", object: "document:q3-plan", context: { current_time: new Date().toISOString() }, }); ``` ### Grants for a single request Contextual tuples count for one request and are never stored. Use them for facts known at request time, such as the agent run a request belongs to or the groups in a user's sign-in token (up to 100 per request): ```ts await fga.check({ user: "agent:run-7f3", relation: "can_use", object: "tool:issue-refund", contextualTuples: [{ user: "agent:run-7f3", relation: "member", object: "team:support" }], }); ``` ### Keep relationships in sync Wherever the app creates, shares, transfers or deletes something, or changes team membership or roles, write or delete the matching tuples in the same code path. One Write applies up to 100 changes atomically. Set `on_duplicate: "ignore"` / `on_missing: "ignore"` (HTTP API) when a retry may repeat a change. Backfill existing data with a one-off script. Every read sees the latest writes, so a new tuple applies to the very next check. ### Handle errors Errors are JSON `{"code": "...", "message": "..."}`. Retry `503 unavailable` (store overloaded) and `409` (conflicting write) with backoff. Don't retry `400` (`validation_error`, unknown type or relation, bad model) or `401` (bad credentials); fix the request. ## 7. Verify Before finishing: 1. `fga query check` a case that should be allowed and one that should be denied, for each relation the app uses. 2. Run the agent and confirm a tool call it isn't granted is blocked, and that retrieval drops documents the user can't read. 3. Save the important cases as assertions for the model (`PUT /stores/{store_id}/assertions/{model_id}`) so they travel with it. 4. Confirm no key or token was committed. Tell the user what you deployed, where, which settings and secrets you added, and how to rotate the key. ## API reference Base URL: the Worker URL (`http://127.0.0.1:8787` locally) or `http://host:8080` for the Node server. Every request except `/healthz` needs `Authorization: Bearer ` (or an OIDC token, if configured). Request and response bodies match OpenFGA's HTTP API. - `POST /stores` create a store; `GET /stores` list; `GET /stores/{store_id}` get; `DELETE /stores/{store_id}` delete - `POST /stores/{store_id}/authorization-models` write a model; `GET .../authorization-models` list (newest first); `GET .../authorization-models/{id}` read - `POST /stores/{store_id}/write` add and delete tuples: `{"writes": {"tuple_keys": [...]}, "deletes": {"tuple_keys": [...]}}` - `POST /stores/{store_id}/read` read stored tuples; `GET /stores/{store_id}/changes` every write and delete in order - `POST /stores/{store_id}/check` `{"tuple_key": {"user", "relation", "object"}, "context", "contextual_tuples": {"tuple_keys": [...]}}` returns `{"allowed": bool}` - `POST /stores/{store_id}/batch-check` up to 50 checks, each with a `correlation_id`; returns `{"result": {"": {"allowed": bool}}}` - `POST /stores/{store_id}/list-objects` `{"type", "relation", "user"}` returns `{"objects": [...]}` - `POST /stores/{store_id}/streamed-list-objects` the same, as newline-delimited JSON - `POST /stores/{store_id}/list-users` `{"object": {"type", "id"}, "relation", "user_filters": [{"type"}]}` returns `{"users": [...]}` - `POST /stores/{store_id}/expand` the tree behind a relation, for debugging - `PUT` / `GET /stores/{store_id}/assertions/{authorization_model_id}` test cases for a model Default limits: 100 tuples per write, 50 checks per BatchCheck, 100 contextual tuples per request, 100 types per model, resolution depth 25, 1,000 results and 3 s for ListObjects and ListUsers (partial results at the deadline), 3 s per request. ## Configuration Workers (`vars` in `wrangler.jsonc`, or secrets): - `AGENTFGA_AUTHN_METHOD`: `preshared` (default), `oidc`, or `none` (local only) - `AGENTFGA_AUTHN_PRESHARED_KEYS`: secret, comma-separated - `AGENTFGA_AUTHN_OIDC_ISSUER`, `AGENTFGA_AUTHN_OIDC_AUDIENCE`: required for `oidc`; optional `_ISSUER_ALIASES`, `_SUBJECTS`, `_CLIENT_ID_CLAIMS` - `AGENTFGA_REQUEST_TIMEOUT_MS` (3000), `AGENTFGA_MAX_TUPLES_PER_WRITE` (100), `AGENTFGA_MAX_TYPES_PER_AUTHORIZATION_MODEL` (100), `AGENTFGA_MAX_CHECKS_PER_BATCH_CHECK` (50), `AGENTFGA_RESOLVE_NODE_LIMIT` (25), `AGENTFGA_LIST_OBJECTS_MAX_RESULTS` / `_DEADLINE_MS` (1000 / 3000), `AGENTFGA_LIST_USERS_MAX_RESULTS` / `_DEADLINE_MS` (1000 / 3000) - `AGENTFGA_DIAGNOSTIC_HEADERS`: `true` adds headers naming the data centers that handled a request Node server: OpenFGA's flags, or `OPENFGA_` environment variables (`--datastore-uri` is `OPENFGA_DATASTORE_URI`). Main flags: `--datastore-engine`, `--datastore-uri`, `--datastore-secondary-uri` (read replica), `--http-addr`, `--grpc-addr`, `--metrics-addr`, `--authn-method`, `--authn-preshared-keys`, `--authn-oidc-issuer`, `--authn-oidc-audience`, `--request-timeout`, `--workers`, `--log-format`, `--log-level`. `agentfga run --help` lists them all. ## Differences from OpenFGA - Not supported: experimental `$expression` conditions, AuthZEN endpoints, OpenFGA's access control, tracing. The gRPC API and metrics are on the Node server only. - Unknown store ids return `404 store_id_not_found` on every route. - On Workers every read is strongly consistent; the `consistency` field changes nothing. - StreamedListObjects computes the full result, then sends it. - Conditions run on cel-js: compile errors are worded differently and OpenFGA's condition cost limit isn't enforced.