From 62229dd892546c49c9f76c2ca16f03f70fe80014 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 6 Jun 2026 10:53:08 +0530 Subject: [PATCH 1/8] docs(sdks): document FGA support in authorizer-go and authorizer-js Add fine-grained authorization (FGA) documentation to the SDK reference: - authorizer-js functions.md: new getPermissions section, required_permissions param rows + samples for getSession, validateJWTToken, validateSession - authorizer-go index.md: GetPermissions in available methods + FGA usage examples (RequiredPermissions and GetPermissions) --- docs/sdks/authorizer-go/index.md | 41 +++++++++++++-- docs/sdks/authorizer-js/functions.md | 78 +++++++++++++++++++++++++--- 2 files changed, 108 insertions(+), 11 deletions(-) diff --git a/docs/sdks/authorizer-go/index.md b/docs/sdks/authorizer-go/index.md index 64b4d76..7b962ad 100644 --- a/docs/sdks/authorizer-go/index.md +++ b/docs/sdks/authorizer-go/index.md @@ -78,6 +78,40 @@ if res.IsValid { } ``` +### Step 4: Fine-grained authorization (FGA) + +Authorizer supports `resource:scope` based fine-grained permissions. The SDK exposes them in two ways. + +**Assert required permissions while validating** -- pass `RequiredPermissions` to `ValidateJWTToken`, `ValidateSession` or `GetSession`. They are evaluated with AND semantics: every entry must be granted, otherwise the result is unauthorized. + +```go +res, err := authorizerClient.ValidateJWTToken(&authorizer.ValidateJWTTokenInput{ + TokenType: authorizer.TokenTypeAccessToken, + Token: "your-jwt-token", + RequiredPermissions: []*authorizer.PermissionInput{ + {Resource: "documents", Scope: "read"}, + {Resource: "documents", Scope: "write"}, + }, +}) +if err != nil || !res.IsValid { + // unauthorized +} +``` + +**Fetch the principal's granted permissions** -- `GetPermissions` returns the `resource:scope` permissions for the authenticated principal. Pass the auth header (or session cookie) so the principal can be identified. + +```go +permissions, err := authorizerClient.GetPermissions(map[string]string{ + "Authorization": "Bearer your-access-token", +}) +if err != nil { + panic(err) +} +for _, p := range permissions { + fmt.Println(p.Resource, p.Scope) +} +``` + ## Available Methods The SDK provides the following methods: @@ -90,8 +124,9 @@ The SDK provides the following methods: - `GetProfile` -- Get user profile - `UpdateProfile` -- Update user profile - `MagicLinkLogin` -- Login with magic link -- `ValidateJWTToken` -- Validate a JWT token -- `GetSession` -- Get current session +- `ValidateJWTToken` -- Validate a JWT token (optionally with `RequiredPermissions` for FGA) +- `GetSession` -- Get current session (optionally with `RequiredPermissions` for FGA) +- `GetPermissions` -- Get the fine-grained `resource:scope` permissions granted to the authenticated user - `RevokeToken` -- Revoke a token - `Logout` -- Logout user -- `ValidateSession` -- Validate a session +- `ValidateSession` -- Validate a session (optionally with `RequiredPermissions` for FGA) diff --git a/docs/sdks/authorizer-js/functions.md b/docs/sdks/authorizer-js/functions.md index 8ca6a3a..f470b40 100644 --- a/docs/sdks/authorizer-js/functions.md +++ b/docs/sdks/authorizer-js/functions.md @@ -17,6 +17,7 @@ title: Functions - [signup](#--signup) - [verifyEmail](#--verifyemail) - [getProfile](#--getprofile) +- [getPermissions](#--getpermissions) - [updateProfile](#--updateprofile) - [forgotPassword](#--forgotpassword) - [resetPassword](#--resetpassword) @@ -291,6 +292,39 @@ const { data, errors } = await authRef.getProfile({ }) ``` +## - `getPermissions` + +Function to fetch the fine-grained authorization (FGA) permissions granted to the authenticated user. This function makes an authorized request, hence if it is used from the browser the HTTP cookie is sent if user has logged in else you need to pass headers object. + +It accepts the optional JSON object as parameter, you can pass the HTTP Headers there. + +| Key | Description | Required | +| --------------- | -------------------------------------------------------------------------------------- | -------- | +| `Authorization` | Authorization header passed to the server. It needs `Bearer access_token` as its value | true | + +It returns an array of permission objects in the response `data`. Each object has the following keys + +**Response** + +| Key | Description | +| ---------- | ------------------------------------------------------------------- | +| `resource` | The resource the permission applies to, e.g. `documents` | +| `scope` | The action allowed on the resource, e.g. `read`, `write`, `delete` | + +**Sample Usage** + +```js +// from browser if HTTP cookie is present +const { data, errors } = await authRef.getPermissions() + +// from NodeJS / if HTTP cookie is not used +const { data, errors } = await authRef.getPermissions({ + Authorization: `Bearer ${token}`, +}) + +// data => [{ resource: 'documents', scope: 'read' }, ...] +``` + ## - `updateProfile` Function to update profile of user. This function makes an authorized request, hence if it is used from the browser the HTTP cookie is sent if user has logged in else you need to pass headers object. @@ -473,7 +507,7 @@ const { data, errors } await authRef.getMetadata() Function to get session information. This function makes an authorized request, hence if it is used from the browser the HTTP cookie is sent if user has logged in else you need to pass headers object. -It accepts the optional JSON object as parameter, you can pass the HTTP Headers there. Optionally you can also validate the roles against the given token by passing the `roles` as second argument to function. +It accepts the optional JSON object as parameter, you can pass the HTTP Headers there. Optionally you can also pass a `SessionQueryRequest` object as the second argument to validate `roles` and `required_permissions` (FGA) against the session — if any required permission is denied, the request returns unauthorized. | Key | Description | Required | | --------------- | ------------------------------------------------------------------------------------ | -------- | @@ -512,6 +546,16 @@ const { data, errors } = await authRef.getSession( }, 'admin', ) + +// with fine-grained authorization (FGA) checks +const { data, errors } = await authRef.getSession( + { + Authorization: `Bearer some_token`, + }, + { + required_permissions: [{ resource: 'documents', scope: 'read' }], + }, +) ``` ## - `revokeToken` @@ -578,9 +622,10 @@ It expects the JSON object as parameter with following parameters | Key | Description | Required | | ------------ | -------------------------------------------------------------------------------------------------------- | -------- | -| `token_type` | Type of token that needs to be validated. It can be one of `access_token`, `refresh_token` or `id_token` | `true` | -| `token` | Jwt token string | `true` | -| `roles` | Array of roles to validate jwt token for | `false` | +| `token_type` | Type of token that needs to be validated. It can be one of `access_token`, `refresh_token` or `id_token` | `true` | +| `token` | Jwt token string | `true` | +| `roles` | Array of roles to validate jwt token for | `false` | +| `required_permissions` | Array of `{ resource, scope }` permissions (FGA) that must **all** be granted to the principal (AND semantics). If any is denied, `is_valid` is `false` | `false` | It returns the following keys in response `data` object @@ -594,8 +639,18 @@ It returns the following keys in response `data` object ```js const { data, errors } = await authRef.validateJWTToken({ - token_type: `access_token` - token: `some jwt token string` + token_type: `access_token`, + token: `some jwt token string`, +}) + +// with fine-grained authorization (FGA) checks +const { data, errors } = await authRef.validateJWTToken({ + token_type: `access_token`, + token: `some jwt token string`, + required_permissions: [ + { resource: 'documents', scope: 'read' }, + { resource: 'documents', scope: 'write' }, + ], }) ``` @@ -607,8 +662,9 @@ It expects the JSON object as parameter with following parameters | Key | Description | Required | | -------- | --------------------------------------------------------------------------------------------------- | -------- | -| `cookie` | browser session cookie value. If not present it will need coookie present in header as https cookie | `false` | -| `roles` | Array of roles to validate jwt token for | `false` | +| `cookie` | browser session cookie value. If not present it will need coookie present in header as https cookie | `false` | +| `roles` | Array of roles to validate jwt token for | `false` | +| `required_permissions` | Array of `{ resource, scope }` permissions (FGA) that must **all** be granted to the principal (AND semantics). If any is denied, `is_valid` is `false` | `false` | It returns the following keys in response `data` object @@ -624,6 +680,12 @@ It returns the following keys in response `data` object const { data, errors } = await authRef.validateSession({ cookie: ``, }) + +// with fine-grained authorization (FGA) checks +const { data, errors } = await authRef.validateSession({ + cookie: ``, + required_permissions: [{ resource: 'documents', scope: 'read' }], +}) ``` ## - `verifyOtp` From 52317ade16dd5b320f06e26a37d5253e1d6fc842 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 6 Jun 2026 10:54:30 +0530 Subject: [PATCH 2/8] docs(authorization): note FGA admin mutations are available in the dashboard UI --- docs/core/authorization.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/core/authorization.md b/docs/core/authorization.md index fc259d2..3e54927 100644 --- a/docs/core/authorization.md +++ b/docs/core/authorization.md @@ -102,6 +102,10 @@ Omit `required_permissions` to preserve pre-FGA behavior — the call returns/va All admin mutations require the super-admin secret (cookie or `X-Authorizer-Admin-Secret`). They are prefixed with `_authz_` to namespace the authorization API distinctly from other admin operations. +:::tip +Every mutation in this section can also be performed from the admin UI at **`/dashboard`** (Authorization section) — no GraphQL required. The dashboard calls these same `_authz_` mutations under the hood, so the two are interchangeable. +::: + ### Step 1 — Define resources and scopes ```graphql From 8f5fa1926bd02c8761b57469b7da2ef2f4414d84 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 10 Jun 2026 13:12:37 +0530 Subject: [PATCH 3/8] =?UTF-8?q?docs(fga):=20v2.3.0=20sweep=20=E2=80=94=20G?= =?UTF-8?q?raphQL=20API,=20metrics,=20security,=20server=20config,=20migra?= =?UTF-8?q?tion,=20SDKs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/core/graphql-api.md | 244 ++++++++++++++++++++------- docs/core/metrics-monitoring.md | 60 ++++--- docs/core/security.md | 4 +- docs/core/server-config.md | 8 +- docs/migration/v1-to-v2.md | 17 +- docs/sdks/authorizer-go/index.md | 61 +++++-- docs/sdks/authorizer-js/functions.md | 201 +++++++++++++++------- 7 files changed, 409 insertions(+), 186 deletions(-) diff --git a/docs/core/graphql-api.md b/docs/core/graphql-api.md index 7864640..6e87008 100644 --- a/docs/core/graphql-api.md +++ b/docs/core/graphql-api.md @@ -113,7 +113,6 @@ This query can take a optional input `params` of type `SessionQueryInput` which | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | | `roles` | Array of string with valid roles | false | | `scope` | List of openID scopes. If not present default scopes ['openid', 'email', 'profile'] is used | false | -| `required_permissions` | Array of `{resource, scope}` pairs evaluated with AND semantics against the caller's principal. Any deny or unmatched pair returns `unauthorized`. See [Authorization (FGA)](./authorization). | false | It returns `AuthResponse` type with the following keys. @@ -135,10 +134,7 @@ It returns `AuthResponse` type with the following keys. ```graphql query { session(params: { - roles: ["admin"], - required_permissions: [ - { resource: "dashboard", scope: "view" } - ] + roles: ["admin"] }) { message access_token @@ -205,7 +201,6 @@ Query to validate the given jwt token. This query needs input `params` of type ` | `token_type` | Type of token that needs to be validated. One of `access_token`, `refresh_token`, `id_token`. | `true` | | `token` | JWT string | `true` | | `roles` | Array of roles to validate the JWT token for | `false` | -| `required_permissions` | Array of `{resource, scope}` pairs evaluated with AND semantics against the JWT's principal. Any deny or unmatched pair returns `unauthorized`. See [Authorization (FGA)](./authorization). | `false` | It returns `ValidateJWTTokenResponse` type with the following keys. @@ -222,10 +217,7 @@ It returns `ValidateJWTTokenResponse` type with the following keys. query { validate_jwt_token(params: { token_type: "access_token", - token: "some jwt token", - required_permissions: [ - { resource: "docs", scope: "read" } - ] + token: "some jwt token" }) { is_valid claims @@ -242,7 +234,6 @@ Query to validate the browser session. This query needs input `params` of type ` | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | | `cookie` | Browser cookie. Either the browser HTTP cookie is present or this parameter must be supplied. | `false` | | `roles` | Array of roles to validate session for | `false` | -| `required_permissions` | Array of `{resource, scope}` pairs evaluated with AND semantics against the cookie's principal. Any deny or unmatched pair returns `unauthorized`. See [Authorization (FGA)](./authorization). | `false` | It returns `ValidateSessionResponse` type with the following keys. @@ -257,39 +248,90 @@ It returns `ValidateSessionResponse` type with the following keys. ```graphql query { validate_session(params: { - cookie: "", - required_permissions: [ - { resource: "docs", scope: "write" } - ] + cookie: "" }) { is_valid } } ``` -### `permissions` +### Authorization (client-facing) -Query the flat list of `(resource, scope)` pairs the calling principal has been granted. Requires a valid session or bearer token. +These queries answer authorization questions against the embedded FGA (ReBAC) engine. They require a valid session or bearer token. The subject is pinned server-side from the caller's token/cookie; the optional `user` override is honored only for super-admins. See [Authorization (FGA)](./authorization) for the full model. -**Response** +#### `fga_check` -| Key | Description | -| ---------- | ---------------------------------------- | -| `resource` | Resource name granted to the principal. | -| `scope` | Scope name granted on that resource. | +Check whether the subject has a `relation` on an `object`. Returns `{ allowed }`. -**Sample Query** +Input `FgaCheckInput`: + +| Key | Description | Required | +| ------------------- | --------------------------------------------------------------------------- | -------- | +| `relation` | Relation to check (e.g. `viewer`, `editor`). | `true` | +| `object` | Object identifier (e.g. `document:roadmap`). | `true` | +| `contextual_tuples` | Optional `[FgaTupleInput!]` of tuples evaluated only for this request. | `false` | +| `user` | Subject override (super-admin only). Defaults to the caller. | `false` | ```graphql query { - permissions { - resource - scope + fga_check(params: { + relation: "viewer", + object: "document:roadmap" + }) { + allowed } } ``` -See [Authorization (FGA)](./authorization) for the full model. +#### `fga_batch_check` + +Run multiple checks in a single request. Returns `{ results { allowed } }` in input order. + +Input `FgaBatchCheckInput`: + +| Key | Description | Required | +| -------- | ----------------------------------------------------------------------- | -------- | +| `checks` | `[FgaCheckPairInput!]!`, each `{ relation, object, contextual_tuples?, user? }`. | `true` | + +```graphql +query { + fga_batch_check(params: { + checks: [ + { relation: "viewer", object: "document:roadmap" }, + { relation: "editor", object: "document:budget" } + ] + }) { + results { + allowed + } + } +} +``` + +#### `fga_list_objects` + +List the objects of a given type on which the subject has a relation. Returns `{ objects }`. + +Input `FgaListObjectsInput`: + +| Key | Description | Required | +| ------------- | ----------------------------------------------------- | -------- | +| `relation` | Relation to list for (e.g. `viewer`). | `true` | +| `object_type` | Object type to enumerate (e.g. `document`). | `true` | +| `user` | Subject override (super-admin only). Defaults to the caller. | `false` | + +```graphql +query { + fga_list_objects(params: { + relation: "viewer", + object_type: "document" + }) { + objects + } +} +``` + +`FgaTupleInput` (used by `contextual_tuples` above and by the admin operations) is `{ user: String!, relation: String!, object: String! }`. ### `_user` @@ -1673,60 +1715,140 @@ mutation { ### Authorization (admin) -Manage the FGA policy graph. All require super-admin authentication (cookie or `X-Authorizer-Admin-Secret`). See [Authorization (FGA)](./authorization) for the conceptual model. +Manage the embedded FGA (ReBAC) engine: the authorization model and the relationship tuples. All require super-admin authentication (cookie or `X-Authorizer-Admin-Secret`). All admin authorization operations are namespaced with the `_fga_` prefix. See [Authorization (FGA)](./authorization) for the conceptual model. + +#### `_fga_write_model` -All admin authorization operations are namespaced with the `_authz_` prefix. +Write (replace) the authorization model from an FGA DSL string. Returns `FgaModel` `{ id, dsl }`. + +```graphql +mutation { + _fga_write_model(params: { + dsl: """ + model + schema 1.1 + type user + type document + relations + define viewer: [user] + define editor: [user] + """ + }) { + id + dsl + } +} +``` -#### `_authz_add_resource` / `_authz_update_resource` / `_authz_delete_resource` / `_authz_resources` +#### `_fga_get_model` -Manage **resources** (the nouns the application protects). +Read the current authorization model. Returns `FgaModel` `{ id, dsl }`. ```graphql -mutation { _authz_add_resource(params: { name: "docs" }) { id name } } -mutation { _authz_update_resource(params: { id: "", name: "documents" }) { id name } } -mutation { _authz_delete_resource(id: "") { message } } -query { _authz_resources(params: { pagination: { limit: 25, page: 1 } }) { - pagination { total limit page } - resources { id name } -} } +query { + _fga_get_model { + id + dsl + } +} ``` -#### `_authz_add_scope` / `_authz_update_scope` / `_authz_delete_scope` / `_authz_scopes` +#### `_fga_write_tuples` -Manage **scopes** (verbs / actions). Same input/output shape as resources. +Write relationship tuples. Returns `Response` `{ message }`. -#### `_authz_add_policy` / `_authz_update_policy` / `_authz_delete_policy` / `_authz_policies` +Input `params.tuples` is `[FgaTupleInput!]!`, each `{ user: String!, relation: String!, object: String! }`. -Manage **policies** (principal selectors). +```graphql +mutation { + _fga_write_tuples(params: { + tuples: [ + { user: "user:alice", relation: "viewer", object: "document:roadmap" } + ] + }) { + message + } +} +``` + +#### `_fga_delete_tuples` + +Delete relationship tuples. Same input shape as `_fga_write_tuples`. Returns `Response` `{ message }`. ```graphql mutation { - _authz_add_policy(params: { - name: "user-role-can-read", - type: "role", - targets: [{ target_type: "role", target_value: "user" }], - logic: "positive", - decision_strategy: "affirmative" - }) { id name } + _fga_delete_tuples(params: { + tuples: [ + { user: "user:alice", relation: "viewer", object: "document:roadmap" } + ] + }) { + message + } } ``` -`type` accepts `role`, `user`, or `attribute`. `target_value` for `role` policies must be a configured role (see `--roles`). `target_value` for `user` policies is the user's **ID** (not email). +#### `_fga_read_tuples` + +Read stored tuples with pagination. Returns `FgaTuples` `{ tuples { user relation object }, continuation_token }`. -#### `_authz_add_permission` / `_authz_update_permission` / `_authz_delete_permission` / `_authz_permissions` +Input `params`: -Bind a resource + scopes + policies into a single permission row. +| Key | Description | Required | +| -------------------- | ------------------------------------------------ | -------- | +| `page_size` | Maximum number of tuples to return. | `false` | +| `continuation_token` | Token from a previous response to fetch the next page. | `false` | ```graphql -mutation { - _authz_add_permission(params: { - name: "docs-read", - resource_id: "", - scope_ids: [""], - policy_ids: [""], - decision_strategy: "affirmative" - }) { id name } +query { + _fga_read_tuples(params: { page_size: 50 }) { + tuples { + user + relation + object + } + continuation_token + } +} +``` + +#### `_fga_list_users` + +List the users that have a given relation on an object (admin only). + +```graphql +query { + _fga_list_users(params: { + relation: "viewer", + object: "document:roadmap" + }) { + users + } +} +``` + +#### `_fga_expand` + +Expand the relationship/userset tree for a relation on an object (admin only). Useful for debugging how access is derived. + +```graphql +query { + _fga_expand(params: { + relation: "viewer", + object: "document:roadmap" + }) { + tree + } } ``` -`decision_strategy` is one of `affirmative` (default), `consensus`, or `unanimous`. See [Authorization §6](./authorization#6-decision-strategies). +#### `_fga_reset` + +Delete the authorization model, all of its versions, and all tuples. Returns `Response` `{ message }`. The operation is refused while any tuple still exists, so delete tuples first. + +```graphql +mutation { + _fga_reset { + message + } +} +``` diff --git a/docs/core/metrics-monitoring.md b/docs/core/metrics-monitoring.md index 336fd57..2496055 100644 --- a/docs/core/metrics-monitoring.md +++ b/docs/core/metrics-monitoring.md @@ -132,41 +132,48 @@ raised. Alert at the rate that distinguishes the two for your traffic profile. See [GraphQL hardening](./security#graphql-hardening) for the limits themselves. -### Authorization Metrics +### Authorization (FGA) Metrics + +These metrics cover the embedded fine-grained authorization (OpenFGA) engine. They +appear only once FGA is enabled and the corresponding operation has run at least +once. See [Authorization (FGA)](./authorization). | Metric | Type | Labels | Description | |--------|------|--------|-------------| -| `authorizer_required_permissions_checks_total` | Counter | `endpoint`, `outcome` | Per-endpoint outcome of `required_permissions` on session APIs. | -| `authorizer_authz_checks_total` | Counter | `result` | Every `CheckPermission` call. `result=allowed\|denied\|unmatched\|error`. | -| `authorizer_authz_unmatched_total` | Counter | — | `CheckPermission` calls that found no permission row for `(resource, scope)`. | -| `authorizer_authz_check_duration_seconds` | Histogram | — | End-to-end `CheckPermission` latency. | +| `authorizer_fga_checks_total` | Counter | `operation`, `result` | Access decisions from `fga_check` / `fga_batch_check`. The headline metric for adoption and denial/error alerting. | +| `authorizer_fga_check_duration_seconds` | Histogram | `operation` | Latency of the client-facing FGA engine reads. | +| `authorizer_fga_operations_total` | Counter | `operation`, `result` | Non-decision FGA operations (model/tuple management, enumeration, reset) by outcome. | -**`required_permissions_checks_total` labels:** +**`authorizer_fga_checks_total` labels:** | Label | Values | -| ----- | ------ | -| `endpoint` | `session`, `validate_session`, `validate_jwt_token` | -| `outcome` | `granted` (all listed permissions allowed) · `denied` (one or more denied) · `not_requested` (caller omitted the field) · `error` (CheckPermission errored — DB/validation) | +|---|---| +| `operation` | `check` (single `fga_check`) · `batch_check` (each pair of an `fga_batch_check` is counted individually) | +| `result` | `allowed` · `denied` · `error` (the engine call failed — fail-closed, so the caller was denied) | -**Use cases:** +**`authorizer_fga_check_duration_seconds`** `operation`: `check` · `batch_check` · `list_objects`. The histogram's `_count` also gives you a call rate per operation for free. -- `outcome="denied"` rising on a given endpoint = either a policy gap or an attacker probe. Cross-check with `authorizer_authz_unmatched_total` (gap) versus `authorizer_authz_checks_total{result="denied"}` (policy deny). -- `outcome="error"` should sit at zero. Any non-zero rate is an infra problem — alert on it. -- `outcome="not_requested"` is the FGA *adoption gap* — share of calls not yet opting into permission gating. +**`authorizer_fga_operations_total`** `operation`: `get_model` · `write_model` · `read_tuples` · `write_tuples` · `delete_tuples` · `list_users` · `expand` · `list_objects` · `reset`. `result`: `success` · `error`. -```promql -# Adoption: share of calls per endpoint that still don't pass required_permissions -sum by (endpoint) (rate(authorizer_required_permissions_checks_total{outcome="not_requested"}[5m])) - / -sum by (endpoint) (rate(authorizer_required_permissions_checks_total[5m])) -``` +Useful queries: ```promql -# Alert candidate: required_permissions errors over 5 minutes -sum(rate(authorizer_required_permissions_checks_total{outcome="error"}[5m])) > 0 +# FGA denial rate (last 5 minutes) +sum(rate(authorizer_fga_checks_total{result="denied"}[5m])) + +# FGA check error rate — should be ~0; a spike means the engine/store is failing closed +sum(rate(authorizer_fga_checks_total{result="error"}[5m])) + +# Admin authorization changes (model/tuple writes, resets) +sum by (operation) (increase(authorizer_fga_operations_total{operation=~"write_model|write_tuples|delete_tuples|reset"}[1h])) + +# p99 check latency +histogram_quantile(0.99, sum by (le, operation) (rate(authorizer_fga_check_duration_seconds_bucket[5m]))) ``` -See [Authorization (FGA)](./authorization) for the underlying model. +A non-zero `result="error"` rate on `authorizer_fga_checks_total` is an operational +signal — the engine or its datastore is failing, and checks are denying as a result. +Page on it. ### Infrastructure Metrics @@ -285,15 +292,6 @@ groups: severity: warning annotations: summary: "Elevated GraphQL error rate" - - - alert: AuthzRequiredPermissionsErrors - expr: sum(rate(authorizer_required_permissions_checks_total{outcome="error"}[5m])) > 0 - for: 5m - labels: - severity: page - annotations: - summary: "required_permissions checks are failing with errors" - description: "Authorizer's FGA evaluator is returning errors on required_permissions checks. Storage or validation failure is likely. Inspect server logs." ``` ## Manual Testing diff --git a/docs/core/security.md b/docs/core/security.md index 3bd6659..f614c86 100644 --- a/docs/core/security.md +++ b/docs/core/security.md @@ -426,14 +426,14 @@ This kills the user-enumeration attack surface entirely. ## Fine-grained authorization -Authorizer ships a built-in FGA engine that is **always enforcing** — a `required_permissions` check against an unmatched or denied `(resource, scope)` pair returns `unauthorized`. There is no permissive "log but allow" mode. See [Authorization (FGA)](./authorization) for the data model, admin mutations, and per-endpoint usage. +Authorizer ships an embedded **OpenFGA** (ReBAC) engine, and access checks **fail closed** — an `fga_check` for a relation that the relationship tuples don't grant is denied, and any engine or store error denies rather than allows. There is no permissive "log but allow" mode. See [Authorization (FGA)](./authorization) for the authorization model, admin mutations, and per-endpoint usage. --- ## See also - [Server Configuration](./server-config) — full CLI flag reference -- [Authorization (FGA)](./authorization) — resources, scopes, policies, permissions +- [Authorization (FGA)](./authorization) — OpenFGA authorization model, tuples, and access checks - [Rate Limiting](./rate-limiting) — rate limiter configuration - [Metrics & Monitoring](./metrics-monitoring) — Prometheus metrics including the new GraphQL limit counter - [v1 to v2 Migration](../migration/v1-to-v2) — for users upgrading from v1 diff --git a/docs/core/server-config.md b/docs/core/server-config.md index e4a321c..7e4818b 100644 --- a/docs/core/server-config.md +++ b/docs/core/server-config.md @@ -280,16 +280,18 @@ metric, labelled by limit kind. See ```bash ./build/server \ - --authorization-cache-ttl=300 \ + --fga-store=postgres \ + --fga-store-url="postgres://user:pass@host/db" \ --include-permissions-in-token=false \ --authorization-log-all-checks=false ``` -- **`--authorization-cache-ttl`** (default `300`): per-`CheckPermission` cache time-to-live in seconds. Set `0` to disable the cache. The cache is delegated to your configured `memory_store` — Redis when `--redis-url` is set, the database when only `--database-type` is configured, an in-process fallback otherwise. Cache is invalidated automatically when an admin mutation changes any resource, scope, policy, or permission. +- **`--fga-store`**: backing store for the embedded OpenFGA engine — one of `sqlite`, `postgres`, `mysql`, or `memory`. Only needed when the main database is NoSQL (see paragraph below); for SQL main databases the engine reuses that database automatically. +- **`--fga-store-url`**: connection string for the FGA store when `--fga-store` is set to a database driver. - **`--include-permissions-in-token`** (default `false`): when true, the access token's claims include the caller's flat `(resource, scope)` grant list. Useful for stateless downstream services that don't want to round-trip back to Authorizer per check. - **`--authorization-log-all-checks`** (default `false`): audit-log every `CheckPermission` call, not just denials. Diagnostic; expensive at scale. -Authorization is always enforcing — a `required_permissions` check against an unmatched or denied `(resource, scope)` pair returns `unauthorized`. There is no permissive mode. See [Authorization (FGA)](./authorization) for the full model. +Authorizer ships an embedded **OpenFGA** (ReBAC) engine. It is enabled by default when the main database is SQL-compatible (SQLite/Postgres/MySQL) and reuses that database. For NoSQL main databases (MongoDB, DynamoDB, …) it is off unless you set `--fga-store` (one of `sqlite`/`postgres`/`mysql`/`memory`) and `--fga-store-url`. Checks fail closed. See [Authorization (FGA)](./authorization). --- diff --git a/docs/migration/v1-to-v2.md b/docs/migration/v1-to-v2.md index 38b20b4..58b120c 100644 --- a/docs/migration/v1-to-v2.md +++ b/docs/migration/v1-to-v2.md @@ -509,21 +509,18 @@ import { SignUpRequest, LoginRequest } from '@authorizerdev/authorizer-js' ## Authorization (FGA) -v2 introduces a fine-grained authorization layer alongside the existing role check. It is **opt-in per request** — pre-v2 callers that do not pass `required_permissions` see no behavior change. +v2 adds an embedded **OpenFGA** engine for relationship-based access control (ReBAC). You author an authorization **model** (OpenFGA DSL: types + relations), grant access with **relationship tuples** (for example `user:alice` is `viewer` of `document:1`), and have your apps check access with `fga_check`. ### What's new -- `session`, `validate_session`, and `validate_jwt_token` accept a new optional `required_permissions: [PermissionInput!]` field. Any deny or unmatched `(resource, scope)` returns `unauthorized`. -- Admin GraphQL operations namespaced under `_authz_`: `_authz_add_resource`, `_authz_add_scope`, `_authz_add_policy`, `_authz_add_permission` (plus update / delete mutations and `_authz_resources` / `_authz_scopes` / `_authz_policies` / `_authz_permissions` list queries). Dashboard UI under Authorization → Resources / Scopes / Policies / Permissions. -- New per-call `permissions` query returns the flat `(resource, scope)` list granted to the calling principal. -- New CLI flag `--authorization-cache-ttl` (default `300` seconds). Cache is delegated to your configured `memory_store` (Redis or DB-backed); set `0` to disable. -- New Prometheus counter `authorizer_required_permissions_checks_total{endpoint, outcome}` for adoption + denial tracking. Outcomes: `granted` / `denied` / `not_requested` / `error`. +- **Embedded OpenFGA engine.** Enabled by default when the main database is SQL (SQLite, Postgres, MySQL), reusing that same database. For NoSQL main databases (MongoDB, DynamoDB, …) it is off unless you set `--fga-store` (`sqlite` / `postgres` / `mysql` / `memory`) and `--fga-store-url`. +- **Client query operations:** `fga_check`, `fga_batch_check`, and `fga_list_objects` (the subject is pinned server-side to the calling principal). +- **Admin GraphQL operations** (super-admin, `_fga_` prefix): `_fga_write_model`, `_fga_get_model`, `_fga_write_tuples`, `_fga_delete_tuples`, `_fga_read_tuples`, `_fga_list_users`, `_fga_expand`, and `_fga_reset`. Dashboard UI under **Authorization** → Step 1 Define model / Step 2 Grant access / Step 3 Test access. ### Adoption checklist -- [ ] **Define the policy graph first** via the dashboard (Authorization → Resources/Scopes/Policies/Permissions) or admin GraphQL mutations. Any `required_permissions` pointing at an undefined `(resource, scope)` returns `unauthorized` immediately — authorization is always enforcing, there is no permissive fallthrough. -- [ ] **Adopt incrementally** by adding `required_permissions` to one session API call site at a time. -- [ ] **Alert on `outcome="error"`** for `authorizer_required_permissions_checks_total` — should sit at zero. -- [ ] **Track adoption** via `outcome="not_requested"` per endpoint. +- [ ] **Define the authorization model first** via the dashboard (Authorization → Step 1 Define model) or the `_fga_write_model` admin mutation. +- [ ] **Grant access with tuples** using `_fga_write_tuples` (dashboard Step 2 Grant access) to relate subjects to objects. +- [ ] **Adopt `fga_check` incrementally** by adding access checks to one call site at a time (use `fga_batch_check` / `fga_list_objects` where it fits). Full reference: [Authorization (FGA)](../core/authorization). diff --git a/docs/sdks/authorizer-go/index.md b/docs/sdks/authorizer-go/index.md index 7b962ad..10bba93 100644 --- a/docs/sdks/authorizer-go/index.md +++ b/docs/sdks/authorizer-go/index.md @@ -80,35 +80,58 @@ if res.IsValid { ### Step 4: Fine-grained authorization (FGA) -Authorizer supports `resource:scope` based fine-grained permissions. The SDK exposes them in two ways. +Authorizer ships with an embedded [OpenFGA](https://openfga.dev) relationship-based authorization (ReBAC) engine. The SDK exposes three client-facing methods to query it. Each takes a request struct and a `headers map[string]string` (pass `Authorization: Bearer `). -**Assert required permissions while validating** -- pass `RequiredPermissions` to `ValidateJWTToken`, `ValidateSession` or `GetSession`. They are evaluated with AND semantics: every entry must be granted, otherwise the result is unauthorized. +**FgaCheck** -- check whether a user has a relation to an object. Returns `Allowed`. ```go -res, err := authorizerClient.ValidateJWTToken(&authorizer.ValidateJWTTokenInput{ - TokenType: authorizer.TokenTypeAccessToken, - Token: "your-jwt-token", - RequiredPermissions: []*authorizer.PermissionInput{ - {Resource: "documents", Scope: "read"}, - {Resource: "documents", Scope: "write"}, +res, err := authorizerClient.FgaCheck(&authorizer.FgaCheckRequest{ + Relation: "viewer", + Object: "document:roadmap", +}, map[string]string{ + "Authorization": "Bearer your-access-token", +}) +if err != nil { + panic(err) +} +if res.Allowed { + // user is a viewer of document:roadmap +} +``` + +**FgaBatchCheck** -- run multiple checks in a single request. Returns `Results` in the same order as the checks. + +```go +res, err := authorizerClient.FgaBatchCheck(&authorizer.FgaBatchCheckRequest{ + Checks: []*authorizer.FgaCheckPair{ + {Relation: "viewer", Object: "document:roadmap"}, + {Relation: "editor", Object: "document:roadmap"}, }, +}, map[string]string{ + "Authorization": "Bearer your-access-token", }) -if err != nil || !res.IsValid { - // unauthorized +if err != nil { + panic(err) +} +for _, r := range res.Results { + fmt.Println(r.Allowed) } ``` -**Fetch the principal's granted permissions** -- `GetPermissions` returns the `resource:scope` permissions for the authenticated principal. Pass the auth header (or session cookie) so the principal can be identified. +**FgaListObjects** -- list all objects of a given type the user has a relation to. Returns `Objects`. ```go -permissions, err := authorizerClient.GetPermissions(map[string]string{ +res, err := authorizerClient.FgaListObjects(&authorizer.FgaListObjectsRequest{ + Relation: "viewer", + ObjectType: "document", +}, map[string]string{ "Authorization": "Bearer your-access-token", }) if err != nil { panic(err) } -for _, p := range permissions { - fmt.Println(p.Resource, p.Scope) +for _, obj := range res.Objects { + fmt.Println(obj) } ``` @@ -124,9 +147,11 @@ The SDK provides the following methods: - `GetProfile` -- Get user profile - `UpdateProfile` -- Update user profile - `MagicLinkLogin` -- Login with magic link -- `ValidateJWTToken` -- Validate a JWT token (optionally with `RequiredPermissions` for FGA) -- `GetSession` -- Get current session (optionally with `RequiredPermissions` for FGA) -- `GetPermissions` -- Get the fine-grained `resource:scope` permissions granted to the authenticated user +- `ValidateJWTToken` -- Validate a JWT token +- `GetSession` -- Get current session - `RevokeToken` -- Revoke a token - `Logout` -- Logout user -- `ValidateSession` -- Validate a session (optionally with `RequiredPermissions` for FGA) +- `ValidateSession` -- Validate a session +- `FgaCheck` -- Check whether a user has a relation to an object (FGA) +- `FgaBatchCheck` -- Run multiple FGA checks in a single request +- `FgaListObjects` -- List objects of a type the user has a relation to (FGA) diff --git a/docs/sdks/authorizer-js/functions.md b/docs/sdks/authorizer-js/functions.md index f470b40..7d4d53d 100644 --- a/docs/sdks/authorizer-js/functions.md +++ b/docs/sdks/authorizer-js/functions.md @@ -17,7 +17,6 @@ title: Functions - [signup](#--signup) - [verifyEmail](#--verifyemail) - [getProfile](#--getprofile) -- [getPermissions](#--getpermissions) - [updateProfile](#--updateprofile) - [forgotPassword](#--forgotpassword) - [resetPassword](#--resetpassword) @@ -29,6 +28,9 @@ title: Functions - [logout](#--logout) - [validateJWTToken](#--validatejwttoken) - [validateSession](#--validatesession) +- [fgaCheck](#--fgacheck) +- [fgaBatchCheck](#--fgabatchcheck) +- [fgaListObjects](#--fgalistobjects) - [verifyOtp](#--verifyotp) - [resendOtp](#--resendotp) - [deactivateAccount](#--deactivateaccount) @@ -292,39 +294,6 @@ const { data, errors } = await authRef.getProfile({ }) ``` -## - `getPermissions` - -Function to fetch the fine-grained authorization (FGA) permissions granted to the authenticated user. This function makes an authorized request, hence if it is used from the browser the HTTP cookie is sent if user has logged in else you need to pass headers object. - -It accepts the optional JSON object as parameter, you can pass the HTTP Headers there. - -| Key | Description | Required | -| --------------- | -------------------------------------------------------------------------------------- | -------- | -| `Authorization` | Authorization header passed to the server. It needs `Bearer access_token` as its value | true | - -It returns an array of permission objects in the response `data`. Each object has the following keys - -**Response** - -| Key | Description | -| ---------- | ------------------------------------------------------------------- | -| `resource` | The resource the permission applies to, e.g. `documents` | -| `scope` | The action allowed on the resource, e.g. `read`, `write`, `delete` | - -**Sample Usage** - -```js -// from browser if HTTP cookie is present -const { data, errors } = await authRef.getPermissions() - -// from NodeJS / if HTTP cookie is not used -const { data, errors } = await authRef.getPermissions({ - Authorization: `Bearer ${token}`, -}) - -// data => [{ resource: 'documents', scope: 'read' }, ...] -``` - ## - `updateProfile` Function to update profile of user. This function makes an authorized request, hence if it is used from the browser the HTTP cookie is sent if user has logged in else you need to pass headers object. @@ -507,7 +476,7 @@ const { data, errors } await authRef.getMetadata() Function to get session information. This function makes an authorized request, hence if it is used from the browser the HTTP cookie is sent if user has logged in else you need to pass headers object. -It accepts the optional JSON object as parameter, you can pass the HTTP Headers there. Optionally you can also pass a `SessionQueryRequest` object as the second argument to validate `roles` and `required_permissions` (FGA) against the session — if any required permission is denied, the request returns unauthorized. +It accepts the optional JSON object as parameter, you can pass the HTTP Headers there. Optionally you can also pass a `SessionQueryRequest` object as the second argument to validate `roles` against the session. | Key | Description | Required | | --------------- | ------------------------------------------------------------------------------------ | -------- | @@ -546,16 +515,6 @@ const { data, errors } = await authRef.getSession( }, 'admin', ) - -// with fine-grained authorization (FGA) checks -const { data, errors } = await authRef.getSession( - { - Authorization: `Bearer some_token`, - }, - { - required_permissions: [{ resource: 'documents', scope: 'read' }], - }, -) ``` ## - `revokeToken` @@ -625,7 +584,6 @@ It expects the JSON object as parameter with following parameters | `token_type` | Type of token that needs to be validated. It can be one of `access_token`, `refresh_token` or `id_token` | `true` | | `token` | Jwt token string | `true` | | `roles` | Array of roles to validate jwt token for | `false` | -| `required_permissions` | Array of `{ resource, scope }` permissions (FGA) that must **all** be granted to the principal (AND semantics). If any is denied, `is_valid` is `false` | `false` | It returns the following keys in response `data` object @@ -642,16 +600,6 @@ const { data, errors } = await authRef.validateJWTToken({ token_type: `access_token`, token: `some jwt token string`, }) - -// with fine-grained authorization (FGA) checks -const { data, errors } = await authRef.validateJWTToken({ - token_type: `access_token`, - token: `some jwt token string`, - required_permissions: [ - { resource: 'documents', scope: 'read' }, - { resource: 'documents', scope: 'write' }, - ], -}) ``` ## - `validateSession` @@ -664,7 +612,6 @@ It expects the JSON object as parameter with following parameters | -------- | --------------------------------------------------------------------------------------------------- | -------- | | `cookie` | browser session cookie value. If not present it will need coookie present in header as https cookie | `false` | | `roles` | Array of roles to validate jwt token for | `false` | -| `required_permissions` | Array of `{ resource, scope }` permissions (FGA) that must **all** be granted to the principal (AND semantics). If any is denied, `is_valid` is `false` | `false` | It returns the following keys in response `data` object @@ -680,12 +627,144 @@ It returns the following keys in response `data` object const { data, errors } = await authRef.validateSession({ cookie: ``, }) +``` -// with fine-grained authorization (FGA) checks -const { data, errors } = await authRef.validateSession({ - cookie: ``, - required_permissions: [{ resource: 'documents', scope: 'read' }], +## - `fgaCheck` + +Function to perform a fine-grained authorization (FGA) check using the embedded [OpenFGA](https://openfga.dev) relationship-based authorization engine. It checks whether a user has a given relation to an object. + +This function makes an authorized request, hence from the browser the HTTP cookie is sent automatically if the user has logged in. From NodeJS pass the `Authorization` header as the optional second argument. + +It accepts a JSON object as the first parameter with the following keys + +| Key | Description | Required | +| ------------------- | -------------------------------------------------------------------------------- | -------- | +| `relation` | The relation to check, e.g. `viewer`, `editor` | true | +| `object` | The object to check the relation against, e.g. `document:roadmap` | true | +| `contextual_tuples` | Optional contextual relationship tuples evaluated only for this check | false | +| `user` | Optional user identifier. Defaults to the authenticated principal if omitted | false | + +It returns the following keys in response `data` object + +**Response** + +| Key | Description | +| --------- | --------------------------------------------------- | +| `allowed` | Boolean indicating if the relation is granted or not | + +**Sample Usage** + +```js +// from browser with HTTP Cookie +const { data, errors } = await authRef.fgaCheck({ + relation: 'viewer', + object: 'document:roadmap', }) + +// from NodeJS / if HTTP cookie is not used +const { data, errors } = await authRef.fgaCheck( + { + relation: 'viewer', + object: 'document:roadmap', + }, + { + Authorization: `Bearer ${token}`, + }, +) + +// data => { allowed: true } +``` + +## - `fgaBatchCheck` + +Function to perform multiple fine-grained authorization (FGA) checks in a single request. Returns the results in the same order as the supplied checks. + +This function makes an authorized request, hence from the browser the HTTP cookie is sent automatically if the user has logged in. From NodeJS pass the `Authorization` header as the optional second argument. + +It accepts a JSON object as the first parameter with the following keys + +| Key | Description | Required | +| -------- | ------------------------------------------------------------------- | -------- | +| `checks` | Array of `{ relation, object }` pairs to evaluate | true | + +It returns the following keys in response `data` object + +**Response** + +| Key | Description | +| --------- | ------------------------------------------------------------------- | +| `results` | Array of `{ allowed: boolean }`, one per check, in the same order | + +**Sample Usage** + +```js +// from browser with HTTP Cookie +const { data, errors } = await authRef.fgaBatchCheck({ + checks: [ + { relation: 'viewer', object: 'document:roadmap' }, + { relation: 'editor', object: 'document:roadmap' }, + ], +}) + +// from NodeJS / if HTTP cookie is not used +const { data, errors } = await authRef.fgaBatchCheck( + { + checks: [ + { relation: 'viewer', object: 'document:roadmap' }, + { relation: 'editor', object: 'document:roadmap' }, + ], + }, + { + Authorization: `Bearer ${token}`, + }, +) + +// data => { results: [{ allowed: true }, { allowed: false }] } +``` + +## - `fgaListObjects` + +Function to list all objects of a given type that the user has a relation to, using the embedded fine-grained authorization (FGA) engine. + +This function makes an authorized request, hence from the browser the HTTP cookie is sent automatically if the user has logged in. From NodeJS pass the `Authorization` header as the optional second argument. + +It accepts a JSON object as the first parameter with the following keys + +| Key | Description | Required | +| ------------- | ---------------------------------------------------------------------------- | -------- | +| `relation` | The relation to check, e.g. `viewer`, `editor` | true | +| `object_type` | The object type to list, e.g. `document` | true | +| `user` | Optional user identifier. Defaults to the authenticated principal if omitted | false | + +It returns the following keys in response `data` object + +**Response** + +| Key | Description | +| --------- | ------------------------------------------------------------------- | +| `objects` | Array of object identifiers the user has the given relation to | + +**Sample Usage** + +```js +// from browser with HTTP Cookie +const { data, errors } = await authRef.fgaListObjects({ + relation: 'viewer', + object_type: 'document', +}) + +// from NodeJS / if HTTP cookie is not used +const { data, errors } = await authRef.fgaListObjects( + { + relation: 'viewer', + object_type: 'document', + }, + { + Authorization: `Bearer ${token}`, + }, +) + +// data => { objects: ['document:roadmap', 'document:notes'] } ``` ## - `verifyOtp` From 3fd464bc12ea4d4d311b49968dd8a388d58ed00d Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 10 Jun 2026 13:12:37 +0530 Subject: [PATCH 4/8] docs(fga): real-world authorization recipes + integration guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New core/authorization-recipes page: how FGA fits an application (the two touchpoints — write tuples on domain events, check on reads), Express and Go middleware, list filtering with fga_list_objects, and five complete recipes: document sharing, multi-tenant org→project→resource hierarchy (grant once, inherit everywhere, fine-grained exceptions), job-role approval workflow, time-bound contractor access, and block lists. Every DSL block is validated against the embedded OpenFGA engine. - authorization.md: identify subjects by user: (not names), app roles vs FGA relations are decoupled, cross-link the recipes page. --- docs/core/authorization-recipes.md | 433 +++++++++++++++++++++++++++++ docs/core/authorization.md | 404 +++++++++++++-------------- sidebars.ts | 1 + 3 files changed, 631 insertions(+), 207 deletions(-) create mode 100644 docs/core/authorization-recipes.md diff --git a/docs/core/authorization-recipes.md b/docs/core/authorization-recipes.md new file mode 100644 index 0000000..f8150aa --- /dev/null +++ b/docs/core/authorization-recipes.md @@ -0,0 +1,433 @@ +--- +sidebar_position: 4 +title: Authorization Recipes (FGA) +--- + +# Authorization recipes — real-world FGA + +The [Authorization (FGA)](./authorization) page documents the engine and its API. +This page shows how to actually **use it from your application**: the integration +pattern, then five complete real-world recipes — each with the model, the tuples, +and the code your app runs. + +All models on this page are validated against the embedded engine in CI, and every +one is also available as a one-click example in the dashboard model editor +(**Authorization → Step 1 → Advanced → Browse examples**). + +--- + +## 1. How FGA fits into your system + +Your application keeps doing what it does; Authorizer answers one extra question per +request: **"may this user do this to this object?"** + +```text + ┌──────────────┐ login ┌─────────────┐ + │ Your app │ ───────► │ Authorizer │ + │ (frontend) │ ◄─────── │ │ + └──────┬───────┘ token │ ┌────────┐ │ + │ API call + token │ │ OpenFGA│ │ + ┌──────▼───────┐ │ │ engine │ │ + │ Your backend │ ───────► │ └────────┘ │ + │ │ fga_check│ │ + └──────────────┘ allowed?└─────────────┘ +``` + +There are exactly **two touchpoints**: + +| Touchpoint | When | API | Credential | +| --- | --- | --- | --- | +| **Write tuples** | On your domain events — a document is created, a user joins a project, someone clicks "Share", access is revoked | `_fga_write_tuples` / `_fga_delete_tuples` | Admin secret, **server-side only** | +| **Check access** | On every read/write your backend serves | `fga_check`, `fga_batch_check`, `fga_list_objects` | The **caller's own token** — the subject is pinned server-side and cannot be spoofed | + +### Identify users by id, never by name + +A tuple's subject is `user:` where `` is the **Authorizer user id** (the +`sub` claim of the token your backend already receives). Emails and display names +are not unique and change; the id is stable for the lifetime of the account. + +```text +user:1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed ✅ +user:alice@example.com ❌ +``` + +The recipes below use short ids like `user:1b9d…` for readability. + +### Your app's roles vs FGA roles — they can differ + +Authorizer's instance roles (`--roles`, the JWT `roles` claim) are **global and +coarse** — "is this person an admin at all". FGA relations are **object-scoped** — +"editor *of* `document:1`". They are decoupled on purpose: keep using the `roles` +claim for coarse route gating, and FGA for per-object decisions. An FGA role name +does not have to exist in `--roles`. + +### Writing tuples from your domain events + +Grant and revoke access by writing tuples when things happen in **your** system — +this is the part teams most often miss. Typical lifecycle: + +| Event in your app | Tuple operation | +| --- | --- | +| User creates a document | write `user:` `owner` `document:` | +| Document is filed in a folder | write `folder:` `parent_folder` `document:` | +| User clicks "Share with Bob (can edit)" | write `user:` `editor` `document:` | +| User joins an organization | write `user:` `member` `organization:` | +| "Make public" toggle | write `user:*` `viewer` `document:` | +| Access revoked / user offboarded | `_fga_delete_tuples` the matching tuple(s) | + +Server-side helper (any language — it's one GraphQL call): + +```js +// Server-side only: uses the admin secret. +async function writeTuples(tuples) { + await fetch('https://auth.yourapp.com/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Authorizer-Admin-Secret': process.env.AUTHORIZER_ADMIN_SECRET, + }, + body: JSON.stringify({ + query: `mutation ($params: FgaWriteTuplesInput!) { + _fga_write_tuples(params: $params) { message } + }`, + variables: { params: { tuples } }, + }), + }); +} + +// On "document created": +await writeTuples([ + { user: `user:${creatorId}`, relation: 'owner', object: `document:${docId}` }, +]); +``` + +### Checking access in your backend + +Use the SDKs ([authorizer-js](https://github.com/authorizerdev/authorizer-js), +[authorizer-go](https://github.com/authorizerdev/authorizer-go)) or one GraphQL +call. Express middleware: + +```js +import { Authorizer } from '@authorizerdev/authorizer-js'; + +const auth = new Authorizer({ + authorizerURL: 'https://auth.yourapp.com', + redirectURL: 'https://yourapp.com', + clientID: process.env.AUTHORIZER_CLIENT_ID, +}); + +// requirePermission('can_edit', req => `document:${req.params.id}`) +const requirePermission = (relation, objectFor) => async (req, res, next) => { + const { data } = await auth.fgaCheck( + { relation, object: objectFor(req) }, + { Authorization: req.headers.authorization }, // forward the caller's token + ); + if (!data?.allowed) return res.status(403).json({ error: 'forbidden' }); + next(); +}; + +app.get('/documents/:id', + requirePermission('can_view', (req) => `document:${req.params.id}`), + getDocumentHandler); + +app.put('/documents/:id', + requirePermission('can_edit', (req) => `document:${req.params.id}`), + updateDocumentHandler); +``` + +The same middleware in Go: + +```go +func RequirePermission(relation string, objectFor func(*http.Request) string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + res, err := authorizerClient.FgaCheck(&authorizer.FgaCheckRequest{ + Relation: relation, + Object: objectFor(r), + }, map[string]string{"Authorization": r.Header.Get("Authorization")}) + if err != nil || !res.Allowed { + http.Error(w, "forbidden", http.StatusForbidden) // fail closed + return + } + next.ServeHTTP(w, r) + }) + } +} +``` + +### Filtering lists — don't check one by one + +For "show me my documents", ask once and filter your DB query by the result: + +```js +const { data } = await auth.fgaListObjects( + { relation: 'can_view', object_type: 'document' }, + { Authorization: req.headers.authorization }, +); +// data.objects => ['document:1', 'document:7', ...] +const ids = data.objects.map((o) => o.split(':')[1]); +const docs = await db.documents.findMany({ where: { id: { in: ids } } }); +``` + +For rendering one page with many permission flags (can the user edit? delete? +share?), use `fga_batch_check` — one round trip, results in order. + +--- + +## 2. Recipe: document collaboration (Google-Docs-style sharing) + +**Scenario.** Users create documents, share them with specific people as editor or +viewer, and optionally make them public. + +**Model** (concentric: an `owner` is an `editor` is a `viewer` — one tuple grants +the whole stack): + +```dsl +model + schema 1.1 + +type user + +type document + relations + define owner: [user] + define editor: [user] or owner + define viewer: [user, user:*] or editor + define can_view: viewer + define can_edit: editor + define can_delete: owner +``` + +**Your app writes tuples on these events:** + +```text +# Priya creates doc 42: +user:1b9d… owner document:42 + +# Priya shares with Marco as editor, with Sam as viewer: +user:2c8e… editor document:42 +user:3d9f… viewer document:42 + +# Priya hits "anyone with the link can view": +user:* viewer document:42 +``` + +**Your backend checks:** + +```text +fga_check(can_edit, document:42) with Marco's token → allowed +fga_check(can_delete, document:42) with Marco's token → denied (owner only) +``` + +"Unshare" is just `_fga_delete_tuples` on the same tuple. No schema migration, no +`document_permissions` join table in your DB. + +--- + +## 3. Recipe: B2B multi-tenant SaaS (org → project → resource) + +**Scenario.** Customers are organizations; each has projects containing resources. +An org-wide grant must cover everything beneath it — **without one tuple per +resource** — while still allowing per-resource exceptions. + +**Model:** + +```dsl +model + schema 1.1 + +type user + +type organization + relations + define admin: [user] + define editor: [user] or admin + define viewer: [user] or editor + define can_view: viewer + define can_edit: editor + +type project + relations + define org: [organization] + define editor: [user] or editor from org + define viewer: [user] or editor or viewer from org + define can_view: viewer + define can_edit: editor + +type resource + relations + define project: [project] + define editor: [user] or editor from project + define viewer: [user] or editor or viewer from project + define can_view: viewer + define can_edit: editor +``` + +**Wire the structure once** (when an org/project/resource is created in your app): + +```text +organization:acme org project:webapp +project:webapp project resource:doc1 +project:webapp project resource:doc2 +``` + +**Grant once, high in the tree:** + +```text +user:1b9d… viewer organization:acme ← ONE tuple +``` + +Now every check below inherits, with zero per-resource tuples: + +```text +fga_check(can_view, resource:doc1) → allowed (viewer from project ← from org) +fga_check(can_view, resource:doc2) → allowed +fga_check(can_edit, resource:doc1) → denied (viewers don't edit) +``` + +**Fine-grained exception on top** — an external contractor edits one resource only: + +```text +user:2c8e… editor resource:doc1 + +fga_check(can_edit, resource:doc1) → allowed +fga_check(can_view, resource:doc2) → denied (nothing leaks to siblings) +``` + +When a new resource is created, your app writes **one structural tuple** +(`project:webapp project resource:doc3`) and every existing org-level grant +applies to it instantly. Tenant isolation falls out of the graph: members of +`organization:acme` simply have no path to `organization:globex` objects. + +--- + +## 4. Recipe: approval workflow with job roles + +**Scenario.** An HR or finance system where `labour`, `manager`, and `executive` +staff have escalating permissions on records — managers edit, executives approve +and delete. + +**Model** (`role#assignee` lets you grant a whole role with one tuple): + +```dsl +model + schema 1.1 + +type user + +type role + relations + define assignee: [user] + +type record + relations + define labour: [user, role#assignee] + define manager: [user, role#assignee] + define executive: [user, role#assignee] + define can_delete: executive + define can_approve: executive + define can_edit: manager or can_delete + define can_view: labour or can_edit +``` + +**Tuples:** + +```text +# Bind each role group to the records it covers (once per record type/instance): +role:manager#assignee manager record:expense-report-7 +role:executive#assignee executive record:expense-report-7 + +# Onboarding Dana as a manager is now ONE tuple, covering every bound record: +user:4e0a… assignee role:manager +``` + +**Checks:** + +```text +fga_check(can_edit, record:expense-report-7) with Dana's token → allowed +fga_check(can_approve, record:expense-report-7) with Dana's token → denied +``` + +Offboarding = delete Dana's one `assignee` tuple; every record access disappears +with it. + +--- + +## 5. Recipe: time-bound contractor access + +**Scenario.** A contractor gets access that must expire automatically — no cron +job to revoke it. + +**Model** (an ABAC condition on the grant): + +```dsl +model + schema 1.1 + +type user + +type document + relations + define viewer: [user with non_expired_grant] + define can_view: viewer + +condition non_expired_grant(current_time: timestamp, grant_time: timestamp, grant_duration: duration) { + current_time < grant_time + grant_duration +} +``` + +Write the tuple with its condition context (grant time + duration), then pass +`current_time` as request-time context on each check. Once +`grant_time + grant_duration` passes, the same check flips to **denied** — no +cleanup required. See [OpenFGA conditions](https://openfga.dev/docs/modeling/conditions) +for the tuple-condition syntax. + +--- + +## 6. Recipe: suspending a user (block list) + +**Scenario.** Broad access stays in place, but specific users must be locked out +of specific objects — overriding anything else that grants them access. + +**Model:** + +```dsl +model + schema 1.1 + +type user + +type document + relations + define viewer: [user] + define blocked: [user] + define can_view: viewer but not blocked +``` + +```text +user:* viewer document:handbook # everyone can read the handbook +user:5f1b… blocked document:handbook # …except this account + +fga_check(can_view, document:handbook) with 5f1b…'s token → denied +``` + +`but not` always wins over every grant path — direct, inherited, or public. + +--- + +## Cheat sheet: app event → FGA operation + +| Your app does | You call | +| --- | --- | +| Serve any protected read/write | `fga_check` (caller's token) | +| Render a list page | `fga_list_objects`, filter your DB query by the ids | +| Render one item with many action buttons | `fga_batch_check` | +| Create a resource | `_fga_write_tuples`: owner + structural parent tuple | +| Share / grant / promote | `_fga_write_tuples`: one tuple | +| Revoke / unshare / offboard | `_fga_delete_tuples`: the matching tuple(s) | +| Reorganize (move project to new org) | delete + write the structural tuple | +| Debug "why can X see Y?" | `_fga_expand` (admin), or the dashboard **Step 3 · Test access** with any subject | + +Start in the dashboard (define model → grant → test), then wire the two +touchpoints above into your backend. The [Authorization (FGA)](./authorization) +page has the full API reference. diff --git a/docs/core/authorization.md b/docs/core/authorization.md index 3e54927..b8ccf50 100644 --- a/docs/core/authorization.md +++ b/docs/core/authorization.md @@ -5,282 +5,272 @@ title: Authorization (FGA) # Authorization (Fine-Grained) -Authorizer ships a built-in fine-grained authorization (FGA) engine alongside its authentication features. FGA is **opt-in per request** and **always enforcing** — a request that asks for a permission the policy graph does not grant is rejected with `unauthorized`. +Authorizer ships a built-in **fine-grained authorization (FGA)** engine powered by an +embedded [OpenFGA](https://openfga.dev) instance — the open-source implementation of +Google's [Zanzibar](https://research.google/pubs/pub48190/) relationship-based access +control (ReBAC) model. Instead of static `role → permission` tables, you describe your +domain as **types** and **relations**, grant access with **relationship tuples**, and +ask the engine `Check(user, relation, object)` at request time. -This page covers: - -1. The data model — **resources, scopes, policies, permissions**. -2. How a caller asserts a permission via `required_permissions` on `session`, `validate_session`, and `validate_jwt_token`. -3. How an admin defines the policy graph via the `_authz_add_resource` / `_authz_add_scope` / `_authz_add_policy` / `_authz_add_permission` GraphQL mutations. -4. How a client reads its own granted permissions via the `permissions` query. -5. Decision strategies, principal targets, and operational observability. - ---- - -## 1. Model +FGA is **opt-in** and runs **in-process** — there is no extra service to deploy and no +network hop on a check. -| Concept | Purpose | Example | -| --- | --- | --- | -| **Resource** | A noun the application protects. | `docs`, `billing`, `org` | -| **Scope** | A verb / action on a resource. | `read`, `write`, `admin` | -| **Policy** | A rule that says **who** matches — a principal selector. Targets a role, a user ID, or an attribute. | "all users with role=`user`" | -| **Permission** | The binding `(resource, [scopes], [policies], decision_strategy)`. Allows scopes on the resource when at least one policy matches (per decision strategy). | "policy `user-role-can-read` grants `docs:read`" | -| **Principal** | The caller being checked. `{id, type, roles, max_scopes?}`. `type` is `user`, `client`, or `agent`. `max_scopes` (optional) is a ceiling — even if a policy grants more, scopes outside `max_scopes` are denied. | `{id: "u-1", type: "user", roles: ["user"]}` | +This page covers: -**Evaluator contract:** `CheckPermission(principal, resource, scope) → {allowed, matched_policy}`. +1. [Enabling FGA](#1-enabling-fga) +2. [The authorization model](#2-the-authorization-model) — types, relations, the DSL, and the dashboard. +3. [Granting access](#3-granting-access--relationship-tuples) — relationship tuples. +4. [Checking access](#4-checking-access--client-api) — `fga_check`, `fga_batch_check`, `fga_list_objects`. +5. [Admin GraphQL API](#5-admin-graphql-api) — the `_fga_*` operations the dashboard uses. +6. [SDKs](#6-sdks) and [operational notes](#7-operational-notes). -- If no permission row exists for `(resource, scope)`, the result is **deny**. No policy is consulted. -- If permissions exist, each is evaluated via its `decision_strategy` (see §6). An explicit deny short-circuits the request unless overridden by strategy. -- Errors (DB, invalid input) **always fail closed** — the caller sees `unauthorized`. +Looking for complete worked scenarios — document sharing, multi-tenant SaaS +hierarchies, job-role workflows — and the backend middleware to enforce them? +See **[Authorization recipes](./authorization-recipes)**. --- -## 2. Asserting permissions on session APIs - -Three GraphQL operations accept an optional `required_permissions: [PermissionInput!]`: +## 1. Enabling FGA -| Operation | Use case | -| --- | --- | -| `session` | SSO bootstrap. Returns `access_token` only if the cookie's user has every listed permission. Rotates the session cookie on success. | -| `validate_session` | Server-rendered apps with cookies. Validates the cookie **and** the permission set. Does not rotate. | -| `validate_jwt_token` | API gateway / service middleware. Validates a JWT **and** the permission set. Does not rotate. | +The engine stores its model and tuples in a SQL datastore. -**Input shape:** +- **SQL main database** (SQLite, Postgres, MySQL, …): FGA is **enabled by default** and + reuses your main database. Nothing to configure. +- **NoSQL main database** (MongoDB, DynamoDB, …): OpenFGA can't use these, so FGA is + **disabled** unless you point it at a SQL store with `--fga-store`. -```graphql -input PermissionInput { - resource: String! - scope: String! -} -``` - -Semantics: every entry in `required_permissions` must be allowed (AND). Any deny — or any unknown `(resource, scope)` pair — returns `unauthorized`. - -### Examples - -```graphql -# session -query { - session(params: { - required_permissions: [ - { resource: "docs", scope: "read" } - ] - }) { - access_token - user { id email roles } - } -} +| Flag | Purpose | +| --- | --- | +| `--fga-store` | Override the OpenFGA datastore: `sqlite`, `postgres`, `mysql`, or `memory` (dev only — non-persistent). Defaults to the main database when it is SQL-compatible. | +| `--fga-store-url` | Connection URI for an overridden `--fga-store` (a `file:` URI for SQLite, a DSN for Postgres/MySQL). Ignored when FGA reuses the main database. | -# validate_jwt_token — multiple required permissions are ANDed -query { - validate_jwt_token(params: { - token_type: "access_token", - token: "", - required_permissions: [ - { resource: "docs", scope: "read" }, - { resource: "billing", scope: "view" } - ] - }) { is_valid claims } -} +```bash +# Reuses Postgres main DB — FGA on automatically: +authorizer --database-type postgres --database-url "postgresql://..." -# validate_session -query { - validate_session(params: { - cookie: "", - required_permissions: [ - { resource: "docs", scope: "write" } - ] - }) { is_valid user { id roles } } -} +# MongoDB main DB — give FGA its own Postgres store to turn it on: +authorizer --database-type mongodb --database-url "mongodb://..." \ + --fga-store postgres --fga-store-url "postgresql://user:pass@host:5432/fga" ``` -Omit `required_permissions` to preserve pre-FGA behavior — the call returns/validates as before. +When FGA is disabled, every FGA GraphQL operation returns +`fine-grained authorization is not enabled` and the rest of the server runs normally. --- -## 3. Building the policy graph (admin mutations) - -All admin mutations require the super-admin secret (cookie or `X-Authorizer-Admin-Secret`). They are prefixed with `_authz_` to namespace the authorization API distinctly from other admin operations. +## 2. The authorization model -:::tip -Every mutation in this section can also be performed from the admin UI at **`/dashboard`** (Authorization section) — no GraphQL required. The dashboard calls these same `_authz_` mutations under the hood, so the two are interchangeable. -::: - -### Step 1 — Define resources and scopes - -```graphql -mutation { _authz_add_resource(params: { name: "docs" }) { id name } } -mutation { _authz_add_scope(params: { name: "read" }) { id name } } -mutation { _authz_add_scope(params: { name: "write" }) { id name } } -``` +The model is your permission **rulebook**. It declares the object **types** you protect, +the **relations** on each type (`owner`, `editor`, `viewer`, `can_view`…), and how those +relations are computed from one another. You write it once; access is then granted with +data (tuples), not by editing the model. -List, update, and delete each have symmetric operations: `_authz_resources` (list query), `_authz_update_resource`, `_authz_delete_resource`, and the same set for `scope` (`_authz_scopes`, `_authz_update_scope`, `_authz_delete_scope`). +It is expressed in OpenFGA's [DSL](https://openfga.dev/docs/configuration-language): -### Step 2 — Define a policy (who matches) +```dsl +model + schema 1.1 -A policy is a principal selector. The `type` field controls which target is honored: +type user -| `type` | `target_type` accepts | Notes | -| ----------- | -------------------- | ----- | -| `role` | `role` | `target_value` must be a configured role (see `--roles`). | -| `user` | `user` | `target_value` is the user's **ID** (not email). | -| `attribute` | `attribute` | Custom attribute match — `target_value` is the JSON key the principal must satisfy. | - -```graphql -mutation { - _authz_add_policy(params: { - name: "user-role-can-read", - type: "role", - targets: [{ target_type: "role", target_value: "user" }] - }) { id } -} +type document + relations + define owner: [user] + define editor: [user] or owner + define viewer: [user] or editor + define can_view: viewer + define can_edit: editor + define can_delete: owner ``` -### Step 3 — Bind it all together with a permission +Reading it: an `owner` is also an `editor` (`or owner`), an `editor` is also a `viewer`, +and the permission relations (`can_view`, `can_edit`, `can_delete`) resolve through them. +A single `owner` tuple therefore grants view, edit, and delete. -```graphql -mutation { - _authz_add_permission(params: { - name: "docs-read", - resource_id: "", - scope_ids: [""], - policy_ids: [""], - decision_strategy: "affirmative" - }) { id } -} -``` +The DSL also supports **hierarchies** (`viewer from parent`), **group usersets** +(`[user, team#member]`), **public access** (`[user:*]`), **exclusions** +(`viewer but not blocked`), and **conditions** (ABAC, e.g. time-bound grants). -`scope_ids` can include multiple scopes — one permission row can cover `read` + `write`. `policy_ids` likewise can include multiple policies; their combination follows `decision_strategy` (see §6). +### From the dashboard ---- +Open **`/dashboard` → Authorization → Step 1 · Define the model**. Two ways in: -## 4. Reading granted permissions — `permissions` +- **Roles & permissions** (the default) — a simple matrix: list your roles + (`admin`, `editor`, `viewer`) and the actions they can take (`view`, `edit`, `delete`), + then tick which role can do what. The dashboard turns the matrix into a valid OpenFGA + RBAC model for you — no DSL to learn. The configured instance roles (`--roles`) are + used to seed it. +- **Advanced (DSL)** — the raw editor with a catalog of ready-made examples (document + sharing, folder hierarchy, organizations & teams, RBAC, groups, block lists, + multi-tenant SaaS, GitHub-style repos, time-bound conditions) and a plain-English + summary of whatever you type. -A signed-in caller can ask "what am I allowed to do?" without enumerating every `(resource, scope)` pair: +### Versioning -```graphql -query { - permissions { - resource - scope - } -} -``` - -Returns the flat list of `(resource, scope)` pairs granted to the caller's principal. Useful for: - -- Building UIs that hide/show actions based on the current user. -- JWT embedding — bake the list into a custom claim if you want a stateless authz check downstream. - ---- - -## 5. Principal types - -`CheckPermission` evaluates against a `Principal`. Authorizer derives the principal automatically from the calling identity: - -| Auth method | `principal.type` | `principal.id` | -| ----------- | ---------------- | -------------- | -| User session / JWT | `user` | user's UUID | -| Machine-to-machine client credentials | `client` | client ID | -| Agent token (planned) | `agent` | agent ID | - -`max_scopes` is an optional **delegation ceiling** carried on the principal — e.g. a downstream token issued via OAuth's `scope=` param can be ceilinged so it never exceeds the granted set even if policies later widen. +There is always exactly **one active model**. Saving creates a new **immutable version** +and makes it active; earlier versions are retained so in-flight requests stay valid. +OpenFGA models are **append-only** — an individual version cannot be deleted. To change +the rules, save a new version. To wipe everything and start over, **reset** the store +(see [§7](#7-operational-notes)). --- -## 6. Decision strategies - -A permission can attach multiple policies. Their verdicts combine via `decision_strategy`: +## 3. Granting access — relationship tuples -| Strategy | Semantics | When to use | -| -------- | --------- | ----------- | -| `affirmative` (default) | Any policy granting access wins; deny only if all deny. | Most-permissive — additive role grants. | -| `consensus` | More grants than denies → allow. Equal split → deny. | Voting-style approval. | -| `unanimous` | All policies must grant; any deny denies. | Strict — e.g. "billing-admin AND on-call". | +A **relationship tuple** is a single fact: _`user` is `relation` of `object`_. Tuples are +the data that actually grants access; add and remove them any time without touching the +model. -An **explicit deny** from any policy in `unanimous` or `consensus` short-circuits to deny. - ---- +```text +user:alice viewer document:1 → Alice can view document 1 +user:bob owner document:1 → Bob owns document 1 (⇒ editor, viewer) +team:eng#member viewer document:1 → every member of team:eng can view it +user:* viewer document:5 → document 5 is public +``` -## 7. Observability +:::info Identify users by id, not name +In real tuples the subject is `user:` — the **Authorizer user id** (the token's +`sub` claim). Names and emails aren't unique and can change; the id is stable. +`user:alice` is used on this page for readability only. +::: -Two Prometheus counters surface authorization behavior. Detailed shapes live in [Metrics & Monitoring](./metrics-monitoring#authorization-metrics). +Note that FGA relation names are **independent of the instance's `--roles`** (the JWT +`roles` claim): app roles stay global and coarse, FGA relations are object-scoped and +usually more granular. The dashboard's model builder seeds from your configured roles +as a convenience, but the two sets are free to differ. -| Counter | What it measures | -| ------- | ---------------- | -| `authorizer_required_permissions_checks_total{endpoint, outcome}` | Per-endpoint outcome of `required_permissions`: `granted`, `denied`, `not_requested`, `error`. **Use this for FGA adoption + denial alerting.** | -| `authorizer_authz_checks_total{result}` | Per-`CheckPermission` evaluator outcome: `allowed`, `denied`, `unmatched`, `error`. Lower-level than the above. | -| `authorizer_authz_unmatched_total` | Subset of evaluator calls that found no permission row for `(resource, scope)`. Watch this when adding new `required_permissions` call sites to find gaps in your policy graph. | +From the dashboard: **Step 2 · Grant access** — add tuples inline, with one-click +templates for the common shapes (direct grant, assign a role, grant a whole role, public +access, grant-on-a-folder-that-cascades). -`outcome="error"` on `authorizer_required_permissions_checks_total` is an operational signal — a DB/storage failure is preventing the check from completing. Page on it. +:::tip +To avoid one tuple per object id, grant on a container (`folder`, `organization`) and let +resources inherit via a `… from parent` relation, or use `user:*` for public access. +::: --- -## 8. Caching +## 4. Checking access — client API -`CheckPermission` results are cached for `--authorization-cache-ttl` seconds (default `300`, set `0` to disable). The cache is delegated to your configured `memory_store` — Redis when `--redis-url` is set, the database when only `--database-type` is configured, an in-process fallback otherwise. +These three queries are the **client-facing** surface — they answer questions for the +**authenticated caller**. The subject is pinned server-side from the request (bearer +token or session cookie); it cannot be spoofed from the client. A super-admin may pass an +optional `user` to check on behalf of another subject; a non-trusted caller supplying +`user` is rejected. -Cache is invalidated automatically when an admin mutation changes any resource, scope, policy, or permission. There is no per-request cache bypass. +### `fga_check` — one question ---- - -## 9. Common patterns +```graphql +query { + fga_check(params: { relation: "can_view", object: "document:1" }) { + allowed + } +} +``` -### Gating an API gateway route +### `fga_batch_check` — many at once -Use `validate_jwt_token` from your gateway middleware: +Results are returned positionally, aligned with the `checks` you sent. ```graphql query { - validate_jwt_token(params: { - token_type: "access_token", - token: "", - required_permissions: [{ resource: "billing", scope: "view" }] - }) { is_valid } + fga_batch_check(params: { + checks: [ + { relation: "can_view", object: "document:1" }, + { relation: "can_edit", object: "document:1" } + ] + }) { + results { allowed } + } } ``` -Cache the result for the JWT's remaining lifetime. The server already caches the underlying evaluator result for `--authorization-cache-ttl`; an extra layer at the gateway saves the network hop. - -### Server-rendered app with cookies +### `fga_list_objects` — what can I access? -Use `validate_session` on each protected page render: +Returns the fully-qualified ids of every object of a type the caller relates to — ideal +for filtering a list down to what the user is allowed to see. ```graphql query { - validate_session(params: { - cookie: "", - required_permissions: [{ resource: "admin", scope: "view" }] - }) { is_valid user { id roles } } + fga_list_objects(params: { relation: "can_view", object_type: "document" }) { + objects # ["document:1", "document:7", ...] + } } ``` -### Bootstrapping SSO with a permission gate +All three also accept optional `contextual_tuples` (evaluated for that one call only, +never persisted) — handy for "what-if" checks or passing request-time facts. + +From the dashboard: **Step 3 · Test access** runs `fga_check` for the logged-in admin so +you can verify the model and tuples interactively. + +--- + +## 5. Admin GraphQL API -`session` mints a fresh access token but only when the policy graph allows the listed permissions: +Authoring the model and tuples is an admin task. These operations require the super-admin +secret (cookie or `X-Authorizer-Admin-Secret`) and are prefixed `_fga_` to namespace the +admin authorization API. The dashboard calls exactly these under the hood, so the UI and +the API are interchangeable. + +| Operation | Type | Purpose | +| --- | --- | --- | +| `_fga_write_model(params: { dsl })` | mutation | Install a new authorization-model version from its DSL. | +| `_fga_get_model` | query | Fetch the active model (id + DSL). | +| `_fga_write_tuples(params: { tuples })` | mutation | Add relationship tuples. | +| `_fga_delete_tuples(params: { tuples })` | mutation | Remove relationship tuples. | +| `_fga_read_tuples(params: { page_size, continuation_token })` | query | Page through stored tuples. | +| `_fga_list_users(params)` | query | List the users that have a relation on an object (reveals the access graph — admin only). | +| `_fga_expand(params)` | query | Expand the relationship/userset tree for a `(relation, object)` (admin only). | +| `_fga_reset` | mutation | Delete the model, **all** versions, and **all** tuples, then start a fresh empty store. Refused while any tuple still exists. | ```graphql -query { - session(params: { - required_permissions: [{ resource: "dashboard", scope: "view" }] - }) { - access_token - user { id } +# Install a model: +mutation { + _fga_write_model(params: { dsl: "model\n schema 1.1\n\ntype user\n\ntype document\n relations\n define viewer: [user]\n define can_view: viewer" }) { + id } } + +# Grant access: +mutation { + _fga_write_tuples(params: { + tuples: [{ user: "user:alice", relation: "viewer", object: "document:1" }] + }) { message } +} ``` --- -## 10. Adopting FGA in an existing deployment +## 6. SDKs + +The official SDKs expose the read-side client API (model/tuple authoring stays in the +dashboard / admin API): -FGA is opt-in per call. Existing callers that don't pass `required_permissions` see no behavior change. +- **Go** — `FgaCheck`, `FgaBatchCheck`, `FgaListObjects`. See the + [authorizer-go README](https://github.com/authorizerdev/authorizer-go#fine-grained-authorization-fga). +- **JavaScript / TypeScript** — `fgaCheck`, `fgaBatchCheck`, `fgaListObjects`. See the + [authorizer-js README](https://github.com/authorizerdev/authorizer-js#fine-grained-authorization-fga). -To roll it out: +For each, pass the caller's auth header in server contexts; in the browser the session +cookie is used automatically. + +--- -1. **Define the policy graph first.** Add resources, scopes, policies, and permissions via the dashboard (or the admin GraphQL mutations above) before any caller starts asserting them. Any `required_permissions` pointing at an undefined `(resource, scope)` returns `unauthorized` immediately — there is no permissive "log but allow" fallback. -2. **Adopt incrementally.** Add `required_permissions` to one endpoint at a time. Watch `authorizer_required_permissions_checks_total{endpoint, outcome}` per endpoint: - - `outcome="not_requested"` falling = adoption rising. - - `outcome="denied"` rising = policy gap or attacker probe. - - `outcome="error"` non-zero = page; storage / validation failure. -3. **Build the dashboards.** See [Metrics & Monitoring §Authorization Metrics](./metrics-monitoring#authorization-metrics) for PromQL examples. +## 7. Operational notes + +- **Fail closed.** If a check can't be completed (engine disabled, store error), the + caller is denied — never silently allowed. +- **Resetting.** `_fga_reset` (dashboard: Step 1 → _Danger zone_) is the only way to + remove a model and its past versions, because OpenFGA models are append-only. It is + **refused while any relationship tuples still exist**, so live grants are never dropped + silently — delete the tuples first. The action is audited (`admin.fga_reset`). +- **Auditing.** Model writes, tuple writes/deletes, and resets are recorded as admin + audit events, visible under **Audit Logs** in the dashboard. +- **Metrics.** The engine exports Prometheus metrics — `authorizer_fga_checks_total` + (allow/deny/error), `authorizer_fga_check_duration_seconds`, and + `authorizer_fga_operations_total` — for adoption tracking and denial/error alerting. + See [Metrics & Monitoring → Authorization (FGA) Metrics](./metrics-monitoring#authorization-fga-metrics). +- **Learn the model language.** See the OpenFGA docs: + [modeling guide](https://openfga.dev/docs/modeling/getting-started) and + [configuration language](https://openfga.dev/docs/configuration-language). diff --git a/sidebars.ts b/sidebars.ts index 6faf9bb..e57d6f0 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -19,6 +19,7 @@ const sidebars: SidebarsConfig = { 'core/server-config', 'core/security', 'core/authorization', + 'core/authorization-recipes', 'core/databases', 'core/endpoints', 'core/graphql-api', From 1d8457f44613ba979722c3f1e92836340ac2dd0b Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 10 Jun 2026 13:17:46 +0530 Subject: [PATCH 5/8] docs(sdks): link FGA sections to the authorization recipes page --- docs/sdks/authorizer-go/index.md | 2 ++ docs/sdks/authorizer-js/functions.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/sdks/authorizer-go/index.md b/docs/sdks/authorizer-go/index.md index 10bba93..dff8886 100644 --- a/docs/sdks/authorizer-go/index.md +++ b/docs/sdks/authorizer-go/index.md @@ -82,6 +82,8 @@ if res.IsValid { Authorizer ships with an embedded [OpenFGA](https://openfga.dev) relationship-based authorization (ReBAC) engine. The SDK exposes three client-facing methods to query it. Each takes a request struct and a `headers map[string]string` (pass `Authorization: Bearer `). +For complete worked scenarios — Go HTTP middleware, list filtering, and tuple lifecycle — see [Authorization recipes](/core/authorization-recipes). + **FgaCheck** -- check whether a user has a relation to an object. Returns `Allowed`. ```go diff --git a/docs/sdks/authorizer-js/functions.md b/docs/sdks/authorizer-js/functions.md index 7d4d53d..c57a6b8 100644 --- a/docs/sdks/authorizer-js/functions.md +++ b/docs/sdks/authorizer-js/functions.md @@ -633,6 +633,8 @@ const { data, errors } = await authRef.validateSession({ Function to perform a fine-grained authorization (FGA) check using the embedded [OpenFGA](https://openfga.dev) relationship-based authorization engine. It checks whether a user has a given relation to an object. +For complete worked scenarios — Express middleware, list filtering, and tuple lifecycle — see [Authorization recipes](/core/authorization-recipes). + This function makes an authorized request, hence from the browser the HTTP cookie is sent automatically if the user has logged in. From NodeJS pass the `Authorization` header as the optional second argument. It accepts a JSON object as the first parameter with the following keys From 5f2a119f1b7a4c995e821b6cf8a6681353e29e7d Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 10 Jun 2026 13:55:14 +0530 Subject: [PATCH 6/8] docs(fga): merge recipes into one Authorization page, ids everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Merge authorization-recipes into authorization.md (§8 Using FGA from your application, §9 Real-world recipes, §10 Cheat sheet) — one page instead of two near-identically named sidebar entries. - Identify everything by id: user: for subjects and numeric ids for objects (organization:101, project:201, resource:301…) across authorization, graphql-api, SDK pages and the migration guide. role:* objects stay keyed by role name by design. - Model builder copy: starts from admin/editor/viewer; configured roles are one-click additions. --- docs/core/authorization-recipes.md | 433 --------------------------- docs/core/authorization.md | 431 +++++++++++++++++++++++++- docs/core/graphql-api.md | 14 +- docs/migration/v1-to-v2.md | 2 +- docs/sdks/authorizer-go/index.md | 10 +- docs/sdks/authorizer-js/functions.md | 18 +- sidebars.ts | 1 - 7 files changed, 440 insertions(+), 469 deletions(-) delete mode 100644 docs/core/authorization-recipes.md diff --git a/docs/core/authorization-recipes.md b/docs/core/authorization-recipes.md deleted file mode 100644 index f8150aa..0000000 --- a/docs/core/authorization-recipes.md +++ /dev/null @@ -1,433 +0,0 @@ ---- -sidebar_position: 4 -title: Authorization Recipes (FGA) ---- - -# Authorization recipes — real-world FGA - -The [Authorization (FGA)](./authorization) page documents the engine and its API. -This page shows how to actually **use it from your application**: the integration -pattern, then five complete real-world recipes — each with the model, the tuples, -and the code your app runs. - -All models on this page are validated against the embedded engine in CI, and every -one is also available as a one-click example in the dashboard model editor -(**Authorization → Step 1 → Advanced → Browse examples**). - ---- - -## 1. How FGA fits into your system - -Your application keeps doing what it does; Authorizer answers one extra question per -request: **"may this user do this to this object?"** - -```text - ┌──────────────┐ login ┌─────────────┐ - │ Your app │ ───────► │ Authorizer │ - │ (frontend) │ ◄─────── │ │ - └──────┬───────┘ token │ ┌────────┐ │ - │ API call + token │ │ OpenFGA│ │ - ┌──────▼───────┐ │ │ engine │ │ - │ Your backend │ ───────► │ └────────┘ │ - │ │ fga_check│ │ - └──────────────┘ allowed?└─────────────┘ -``` - -There are exactly **two touchpoints**: - -| Touchpoint | When | API | Credential | -| --- | --- | --- | --- | -| **Write tuples** | On your domain events — a document is created, a user joins a project, someone clicks "Share", access is revoked | `_fga_write_tuples` / `_fga_delete_tuples` | Admin secret, **server-side only** | -| **Check access** | On every read/write your backend serves | `fga_check`, `fga_batch_check`, `fga_list_objects` | The **caller's own token** — the subject is pinned server-side and cannot be spoofed | - -### Identify users by id, never by name - -A tuple's subject is `user:` where `` is the **Authorizer user id** (the -`sub` claim of the token your backend already receives). Emails and display names -are not unique and change; the id is stable for the lifetime of the account. - -```text -user:1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed ✅ -user:alice@example.com ❌ -``` - -The recipes below use short ids like `user:1b9d…` for readability. - -### Your app's roles vs FGA roles — they can differ - -Authorizer's instance roles (`--roles`, the JWT `roles` claim) are **global and -coarse** — "is this person an admin at all". FGA relations are **object-scoped** — -"editor *of* `document:1`". They are decoupled on purpose: keep using the `roles` -claim for coarse route gating, and FGA for per-object decisions. An FGA role name -does not have to exist in `--roles`. - -### Writing tuples from your domain events - -Grant and revoke access by writing tuples when things happen in **your** system — -this is the part teams most often miss. Typical lifecycle: - -| Event in your app | Tuple operation | -| --- | --- | -| User creates a document | write `user:` `owner` `document:` | -| Document is filed in a folder | write `folder:` `parent_folder` `document:` | -| User clicks "Share with Bob (can edit)" | write `user:` `editor` `document:` | -| User joins an organization | write `user:` `member` `organization:` | -| "Make public" toggle | write `user:*` `viewer` `document:` | -| Access revoked / user offboarded | `_fga_delete_tuples` the matching tuple(s) | - -Server-side helper (any language — it's one GraphQL call): - -```js -// Server-side only: uses the admin secret. -async function writeTuples(tuples) { - await fetch('https://auth.yourapp.com/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Authorizer-Admin-Secret': process.env.AUTHORIZER_ADMIN_SECRET, - }, - body: JSON.stringify({ - query: `mutation ($params: FgaWriteTuplesInput!) { - _fga_write_tuples(params: $params) { message } - }`, - variables: { params: { tuples } }, - }), - }); -} - -// On "document created": -await writeTuples([ - { user: `user:${creatorId}`, relation: 'owner', object: `document:${docId}` }, -]); -``` - -### Checking access in your backend - -Use the SDKs ([authorizer-js](https://github.com/authorizerdev/authorizer-js), -[authorizer-go](https://github.com/authorizerdev/authorizer-go)) or one GraphQL -call. Express middleware: - -```js -import { Authorizer } from '@authorizerdev/authorizer-js'; - -const auth = new Authorizer({ - authorizerURL: 'https://auth.yourapp.com', - redirectURL: 'https://yourapp.com', - clientID: process.env.AUTHORIZER_CLIENT_ID, -}); - -// requirePermission('can_edit', req => `document:${req.params.id}`) -const requirePermission = (relation, objectFor) => async (req, res, next) => { - const { data } = await auth.fgaCheck( - { relation, object: objectFor(req) }, - { Authorization: req.headers.authorization }, // forward the caller's token - ); - if (!data?.allowed) return res.status(403).json({ error: 'forbidden' }); - next(); -}; - -app.get('/documents/:id', - requirePermission('can_view', (req) => `document:${req.params.id}`), - getDocumentHandler); - -app.put('/documents/:id', - requirePermission('can_edit', (req) => `document:${req.params.id}`), - updateDocumentHandler); -``` - -The same middleware in Go: - -```go -func RequirePermission(relation string, objectFor func(*http.Request) string) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - res, err := authorizerClient.FgaCheck(&authorizer.FgaCheckRequest{ - Relation: relation, - Object: objectFor(r), - }, map[string]string{"Authorization": r.Header.Get("Authorization")}) - if err != nil || !res.Allowed { - http.Error(w, "forbidden", http.StatusForbidden) // fail closed - return - } - next.ServeHTTP(w, r) - }) - } -} -``` - -### Filtering lists — don't check one by one - -For "show me my documents", ask once and filter your DB query by the result: - -```js -const { data } = await auth.fgaListObjects( - { relation: 'can_view', object_type: 'document' }, - { Authorization: req.headers.authorization }, -); -// data.objects => ['document:1', 'document:7', ...] -const ids = data.objects.map((o) => o.split(':')[1]); -const docs = await db.documents.findMany({ where: { id: { in: ids } } }); -``` - -For rendering one page with many permission flags (can the user edit? delete? -share?), use `fga_batch_check` — one round trip, results in order. - ---- - -## 2. Recipe: document collaboration (Google-Docs-style sharing) - -**Scenario.** Users create documents, share them with specific people as editor or -viewer, and optionally make them public. - -**Model** (concentric: an `owner` is an `editor` is a `viewer` — one tuple grants -the whole stack): - -```dsl -model - schema 1.1 - -type user - -type document - relations - define owner: [user] - define editor: [user] or owner - define viewer: [user, user:*] or editor - define can_view: viewer - define can_edit: editor - define can_delete: owner -``` - -**Your app writes tuples on these events:** - -```text -# Priya creates doc 42: -user:1b9d… owner document:42 - -# Priya shares with Marco as editor, with Sam as viewer: -user:2c8e… editor document:42 -user:3d9f… viewer document:42 - -# Priya hits "anyone with the link can view": -user:* viewer document:42 -``` - -**Your backend checks:** - -```text -fga_check(can_edit, document:42) with Marco's token → allowed -fga_check(can_delete, document:42) with Marco's token → denied (owner only) -``` - -"Unshare" is just `_fga_delete_tuples` on the same tuple. No schema migration, no -`document_permissions` join table in your DB. - ---- - -## 3. Recipe: B2B multi-tenant SaaS (org → project → resource) - -**Scenario.** Customers are organizations; each has projects containing resources. -An org-wide grant must cover everything beneath it — **without one tuple per -resource** — while still allowing per-resource exceptions. - -**Model:** - -```dsl -model - schema 1.1 - -type user - -type organization - relations - define admin: [user] - define editor: [user] or admin - define viewer: [user] or editor - define can_view: viewer - define can_edit: editor - -type project - relations - define org: [organization] - define editor: [user] or editor from org - define viewer: [user] or editor or viewer from org - define can_view: viewer - define can_edit: editor - -type resource - relations - define project: [project] - define editor: [user] or editor from project - define viewer: [user] or editor or viewer from project - define can_view: viewer - define can_edit: editor -``` - -**Wire the structure once** (when an org/project/resource is created in your app): - -```text -organization:acme org project:webapp -project:webapp project resource:doc1 -project:webapp project resource:doc2 -``` - -**Grant once, high in the tree:** - -```text -user:1b9d… viewer organization:acme ← ONE tuple -``` - -Now every check below inherits, with zero per-resource tuples: - -```text -fga_check(can_view, resource:doc1) → allowed (viewer from project ← from org) -fga_check(can_view, resource:doc2) → allowed -fga_check(can_edit, resource:doc1) → denied (viewers don't edit) -``` - -**Fine-grained exception on top** — an external contractor edits one resource only: - -```text -user:2c8e… editor resource:doc1 - -fga_check(can_edit, resource:doc1) → allowed -fga_check(can_view, resource:doc2) → denied (nothing leaks to siblings) -``` - -When a new resource is created, your app writes **one structural tuple** -(`project:webapp project resource:doc3`) and every existing org-level grant -applies to it instantly. Tenant isolation falls out of the graph: members of -`organization:acme` simply have no path to `organization:globex` objects. - ---- - -## 4. Recipe: approval workflow with job roles - -**Scenario.** An HR or finance system where `labour`, `manager`, and `executive` -staff have escalating permissions on records — managers edit, executives approve -and delete. - -**Model** (`role#assignee` lets you grant a whole role with one tuple): - -```dsl -model - schema 1.1 - -type user - -type role - relations - define assignee: [user] - -type record - relations - define labour: [user, role#assignee] - define manager: [user, role#assignee] - define executive: [user, role#assignee] - define can_delete: executive - define can_approve: executive - define can_edit: manager or can_delete - define can_view: labour or can_edit -``` - -**Tuples:** - -```text -# Bind each role group to the records it covers (once per record type/instance): -role:manager#assignee manager record:expense-report-7 -role:executive#assignee executive record:expense-report-7 - -# Onboarding Dana as a manager is now ONE tuple, covering every bound record: -user:4e0a… assignee role:manager -``` - -**Checks:** - -```text -fga_check(can_edit, record:expense-report-7) with Dana's token → allowed -fga_check(can_approve, record:expense-report-7) with Dana's token → denied -``` - -Offboarding = delete Dana's one `assignee` tuple; every record access disappears -with it. - ---- - -## 5. Recipe: time-bound contractor access - -**Scenario.** A contractor gets access that must expire automatically — no cron -job to revoke it. - -**Model** (an ABAC condition on the grant): - -```dsl -model - schema 1.1 - -type user - -type document - relations - define viewer: [user with non_expired_grant] - define can_view: viewer - -condition non_expired_grant(current_time: timestamp, grant_time: timestamp, grant_duration: duration) { - current_time < grant_time + grant_duration -} -``` - -Write the tuple with its condition context (grant time + duration), then pass -`current_time` as request-time context on each check. Once -`grant_time + grant_duration` passes, the same check flips to **denied** — no -cleanup required. See [OpenFGA conditions](https://openfga.dev/docs/modeling/conditions) -for the tuple-condition syntax. - ---- - -## 6. Recipe: suspending a user (block list) - -**Scenario.** Broad access stays in place, but specific users must be locked out -of specific objects — overriding anything else that grants them access. - -**Model:** - -```dsl -model - schema 1.1 - -type user - -type document - relations - define viewer: [user] - define blocked: [user] - define can_view: viewer but not blocked -``` - -```text -user:* viewer document:handbook # everyone can read the handbook -user:5f1b… blocked document:handbook # …except this account - -fga_check(can_view, document:handbook) with 5f1b…'s token → denied -``` - -`but not` always wins over every grant path — direct, inherited, or public. - ---- - -## Cheat sheet: app event → FGA operation - -| Your app does | You call | -| --- | --- | -| Serve any protected read/write | `fga_check` (caller's token) | -| Render a list page | `fga_list_objects`, filter your DB query by the ids | -| Render one item with many action buttons | `fga_batch_check` | -| Create a resource | `_fga_write_tuples`: owner + structural parent tuple | -| Share / grant / promote | `_fga_write_tuples`: one tuple | -| Revoke / unshare / offboard | `_fga_delete_tuples`: the matching tuple(s) | -| Reorganize (move project to new org) | delete + write the structural tuple | -| Debug "why can X see Y?" | `_fga_expand` (admin), or the dashboard **Step 3 · Test access** with any subject | - -Start in the dashboard (define model → grant → test), then wire the two -touchpoints above into your backend. The [Authorization (FGA)](./authorization) -page has the full API reference. diff --git a/docs/core/authorization.md b/docs/core/authorization.md index b8ccf50..5eb836b 100644 --- a/docs/core/authorization.md +++ b/docs/core/authorization.md @@ -23,10 +23,8 @@ This page covers: 4. [Checking access](#4-checking-access--client-api) — `fga_check`, `fga_batch_check`, `fga_list_objects`. 5. [Admin GraphQL API](#5-admin-graphql-api) — the `_fga_*` operations the dashboard uses. 6. [SDKs](#6-sdks) and [operational notes](#7-operational-notes). - -Looking for complete worked scenarios — document sharing, multi-tenant SaaS -hierarchies, job-role workflows — and the backend middleware to enforce them? -See **[Authorization recipes](./authorization-recipes)**. +7. [Using FGA from your application](#8-using-fga-from-your-application) — middleware, the tuple lifecycle, list filtering. +8. [Real-world recipes](#9-real-world-recipes) — document sharing, multi-tenant SaaS, job roles, time-bound access, block lists. --- @@ -98,8 +96,9 @@ Open **`/dashboard` → Authorization → Step 1 · Define the model**. Two ways - **Roles & permissions** (the default) — a simple matrix: list your roles (`admin`, `editor`, `viewer`) and the actions they can take (`view`, `edit`, `delete`), then tick which role can do what. The dashboard turns the matrix into a valid OpenFGA - RBAC model for you — no DSL to learn. The configured instance roles (`--roles`) are - used to seed it. + RBAC model for you — no DSL to learn. It starts from a standard + `admin / editor / viewer` set; your configured instance roles (`--roles`) are offered + as one-click additions. - **Advanced (DSL)** — the raw editor with a catalog of ready-made examples (document sharing, folder hierarchy, organizations & teams, RBAC, groups, block lists, multi-tenant SaaS, GitHub-style repos, time-bound conditions) and a plain-English @@ -122,16 +121,16 @@ the data that actually grants access; add and remove them any time without touch model. ```text -user:alice viewer document:1 → Alice can view document 1 -user:bob owner document:1 → Bob owns document 1 (⇒ editor, viewer) -team:eng#member viewer document:1 → every member of team:eng can view it +user:1b9d… viewer document:1 → this user can view document 1 +user:2c8e… owner document:1 → this user owns document 1 (⇒ editor, viewer) +team:9#member viewer document:1 → every member of team:9 can view it user:* viewer document:5 → document 5 is public ``` :::info Identify users by id, not name -In real tuples the subject is `user:` — the **Authorizer user id** (the token's -`sub` claim). Names and emails aren't unique and can change; the id is stable. -`user:alice` is used on this page for readability only. +The subject is `user:` — the **Authorizer user id** (the token's `sub` claim, +shown on the dashboard's Users page). Names and emails aren't unique and can change; +the id is stable. Short ids like `user:1b9d…` on this page abbreviate full UUIDs. ::: Note that FGA relation names are **independent of the instance's `--roles`** (the JWT @@ -235,7 +234,7 @@ mutation { # Grant access: mutation { _fga_write_tuples(params: { - tuples: [{ user: "user:alice", relation: "viewer", object: "document:1" }] + tuples: [{ user: "user:1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed", relation: "viewer", object: "document:1" }] }) { message } } ``` @@ -274,3 +273,409 @@ cookie is used automatically. - **Learn the model language.** See the OpenFGA docs: [modeling guide](https://openfga.dev/docs/modeling/getting-started) and [configuration language](https://openfga.dev/docs/configuration-language). + +--- + +## 8. Using FGA from your application + +Your application keeps doing what it does; Authorizer answers one extra question per +request: **"may this user do this to this object?"** + +```text + ┌──────────────┐ login ┌─────────────┐ + │ Your app │ ───────► │ Authorizer │ + │ (frontend) │ ◄─────── │ │ + └──────┬───────┘ token │ ┌────────┐ │ + │ API call + token │ │ OpenFGA│ │ + ┌──────▼───────┐ │ │ engine │ │ + │ Your backend │ ───────► │ └────────┘ │ + │ │ fga_check│ │ + └──────────────┘ allowed?└─────────────┘ +``` + +There are exactly **two touchpoints**: + +| Touchpoint | When | API | Credential | +| --- | --- | --- | --- | +| **Write tuples** | On your domain events — a document is created, a user joins a project, someone clicks "Share", access is revoked | `_fga_write_tuples` / `_fga_delete_tuples` | Admin secret, **server-side only** | +| **Check access** | On every read/write your backend serves | `fga_check`, `fga_batch_check`, `fga_list_objects` | The **caller's own token** — the subject is pinned server-side and cannot be spoofed | + +### Writing tuples from your domain events + +Grant and revoke access by writing tuples when things happen in **your** system — +this is the part teams most often miss. Typical lifecycle: + +| Event in your app | Tuple operation | +| --- | --- | +| User creates a document | write `user:` `owner` `document:` | +| Document is filed in a folder | write `folder:` `parent_folder` `document:` | +| User clicks "Share with Bob (can edit)" | write `user:` `editor` `document:` | +| User joins an organization | write `user:` `member` `organization:` | +| "Make public" toggle | write `user:*` `viewer` `document:` | +| Access revoked / user offboarded | `_fga_delete_tuples` the matching tuple(s) | + +Server-side helper (any language — it's one GraphQL call): + +```js +// Server-side only: uses the admin secret. +async function writeTuples(tuples) { + await fetch('https://auth.yourapp.com/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Authorizer-Admin-Secret': process.env.AUTHORIZER_ADMIN_SECRET, + }, + body: JSON.stringify({ + query: `mutation ($params: FgaWriteTuplesInput!) { + _fga_write_tuples(params: $params) { message } + }`, + variables: { params: { tuples } }, + }), + }); +} + +// On "document created": +await writeTuples([ + { user: `user:${creatorId}`, relation: 'owner', object: `document:${docId}` }, +]); +``` + +### Checking access in your backend + +Use the SDKs ([authorizer-js](https://github.com/authorizerdev/authorizer-js), +[authorizer-go](https://github.com/authorizerdev/authorizer-go)) or one GraphQL +call. Express middleware: + +```js +import { Authorizer } from '@authorizerdev/authorizer-js'; + +const auth = new Authorizer({ + authorizerURL: 'https://auth.yourapp.com', + redirectURL: 'https://yourapp.com', + clientID: process.env.AUTHORIZER_CLIENT_ID, +}); + +// requirePermission('can_edit', req => `document:${req.params.id}`) +const requirePermission = (relation, objectFor) => async (req, res, next) => { + const { data } = await auth.fgaCheck( + { relation, object: objectFor(req) }, + { Authorization: req.headers.authorization }, // forward the caller's token + ); + if (!data?.allowed) return res.status(403).json({ error: 'forbidden' }); + next(); +}; + +app.get('/documents/:id', + requirePermission('can_view', (req) => `document:${req.params.id}`), + getDocumentHandler); + +app.put('/documents/:id', + requirePermission('can_edit', (req) => `document:${req.params.id}`), + updateDocumentHandler); +``` + +The same middleware in Go: + +```go +func RequirePermission(relation string, objectFor func(*http.Request) string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + res, err := authorizerClient.FgaCheck(&authorizer.FgaCheckRequest{ + Relation: relation, + Object: objectFor(r), + }, map[string]string{"Authorization": r.Header.Get("Authorization")}) + if err != nil || !res.Allowed { + http.Error(w, "forbidden", http.StatusForbidden) // fail closed + return + } + next.ServeHTTP(w, r) + }) + } +} +``` + +### Filtering lists — don't check one by one + +For "show me my documents", ask once and filter your DB query by the result: + +```js +const { data } = await auth.fgaListObjects( + { relation: 'can_view', object_type: 'document' }, + { Authorization: req.headers.authorization }, +); +// data.objects => ['document:1', 'document:7', ...] +const ids = data.objects.map((o) => o.split(':')[1]); +const docs = await db.documents.findMany({ where: { id: { in: ids } } }); +``` + +For rendering one page with many permission flags (can the user edit? delete? +share?), use `fga_batch_check` — one round trip, results in order. + +--- + +--- + +## 9. Real-world recipes + +Complete worked scenarios — each with the model, the tuples, and the checks your +app runs. Every model below is also a one-click example in the dashboard model +editor (**Step 1 → Advanced → Browse examples**) and is validated against the +embedded engine in CI. + +### Document collaboration (Google-Docs-style sharing) + +**Scenario.** Users create documents, share them with specific people as editor or +viewer, and optionally make them public. + +**Model** (concentric: an `owner` is an `editor` is a `viewer` — one tuple grants +the whole stack): + +```dsl +model + schema 1.1 + +type user + +type document + relations + define owner: [user] + define editor: [user] or owner + define viewer: [user, user:*] or editor + define can_view: viewer + define can_edit: editor + define can_delete: owner +``` + +**Your app writes tuples on these events:** + +```text +# Priya creates doc 42: +user:1b9d… owner document:42 + +# Priya shares with Marco as editor, with Sam as viewer: +user:2c8e… editor document:42 +user:3d9f… viewer document:42 + +# Priya hits "anyone with the link can view": +user:* viewer document:42 +``` + +**Your backend checks:** + +```text +fga_check(can_edit, document:42) with Marco's token → allowed +fga_check(can_delete, document:42) with Marco's token → denied (owner only) +``` + +"Unshare" is just `_fga_delete_tuples` on the same tuple. No schema migration, no +`document_permissions` join table in your DB. + +--- + +### B2B multi-tenant SaaS (org → project → resource) + +**Scenario.** Customers are organizations; each has projects containing resources. +An org-wide grant must cover everything beneath it — **without one tuple per +resource** — while still allowing per-resource exceptions. + +**Model:** + +```dsl +model + schema 1.1 + +type user + +type organization + relations + define admin: [user] + define editor: [user] or admin + define viewer: [user] or editor + define can_view: viewer + define can_edit: editor + +type project + relations + define org: [organization] + define editor: [user] or editor from org + define viewer: [user] or editor or viewer from org + define can_view: viewer + define can_edit: editor + +type resource + relations + define project: [project] + define editor: [user] or editor from project + define viewer: [user] or editor or viewer from project + define can_view: viewer + define can_edit: editor +``` + +**Wire the structure once** (when an org/project/resource is created in your app): + +```text +organization:101 org project:201 +project:201 project resource:301 +project:201 project resource:302 +``` + +**Grant once, high in the tree:** + +```text +user:1b9d… viewer organization:101 ← ONE tuple +``` + +Now every check below inherits, with zero per-resource tuples: + +```text +fga_check(can_view, resource:301) → allowed (viewer from project ← from org) +fga_check(can_view, resource:302) → allowed +fga_check(can_edit, resource:301) → denied (viewers don't edit) +``` + +**Fine-grained exception on top** — an external contractor edits one resource only: + +```text +user:2c8e… editor resource:301 + +fga_check(can_edit, resource:301) → allowed +fga_check(can_view, resource:302) → denied (nothing leaks to siblings) +``` + +When a new resource is created, your app writes **one structural tuple** +(`project:201 project resource:303`) and every existing org-level grant +applies to it instantly. Tenant isolation falls out of the graph: members of +`organization:101` simply have no path to `organization:102` objects. + +--- + +### Approval workflow with job roles + +**Scenario.** An HR or finance system where `labour`, `manager`, and `executive` +staff have escalating permissions on records — managers edit, executives approve +and delete. + +**Model** (`role#assignee` lets you grant a whole role with one tuple): + +```dsl +model + schema 1.1 + +type user + +type role + relations + define assignee: [user] + +type record + relations + define labour: [user, role#assignee] + define manager: [user, role#assignee] + define executive: [user, role#assignee] + define can_delete: executive + define can_approve: executive + define can_edit: manager or can_delete + define can_view: labour or can_edit +``` + +**Tuples:** + +```text +# Bind each role group to the records it covers (once per record type/instance): +role:manager#assignee manager record:88 +role:executive#assignee executive record:88 + +# Onboarding Dana as a manager is now ONE tuple, covering every bound record: +user:4e0a… assignee role:manager +``` + +**Checks:** + +```text +fga_check(can_edit, record:88) with Dana's token → allowed +fga_check(can_approve, record:88) with Dana's token → denied +``` + +Offboarding = delete Dana's one `assignee` tuple; every record access disappears +with it. + +--- + +### Time-bound contractor access + +**Scenario.** A contractor gets access that must expire automatically — no cron +job to revoke it. + +**Model** (an ABAC condition on the grant): + +```dsl +model + schema 1.1 + +type user + +type document + relations + define viewer: [user with non_expired_grant] + define can_view: viewer + +condition non_expired_grant(current_time: timestamp, grant_time: timestamp, grant_duration: duration) { + current_time < grant_time + grant_duration +} +``` + +Write the tuple with its condition context (grant time + duration), then pass +`current_time` as request-time context on each check. Once +`grant_time + grant_duration` passes, the same check flips to **denied** — no +cleanup required. See [OpenFGA conditions](https://openfga.dev/docs/modeling/conditions) +for the tuple-condition syntax. + +--- + +### Suspending a user (block list) + +**Scenario.** Broad access stays in place, but specific users must be locked out +of specific objects — overriding anything else that grants them access. + +**Model:** + +```dsl +model + schema 1.1 + +type user + +type document + relations + define viewer: [user] + define blocked: [user] + define can_view: viewer but not blocked +``` + +```text +user:* viewer document:7 # everyone can read the handbook +user:5f1b… blocked document:7 # …except this account + +fga_check(can_view, document:7) with 5f1b…'s token → denied +``` + +`but not` always wins over every grant path — direct, inherited, or public. + +--- + +--- + +## 10. Cheat sheet + +App event → FGA operation: + +| Your app does | You call | +| --- | --- | +| Serve any protected read/write | `fga_check` (caller's token) | +| Render a list page | `fga_list_objects`, filter your DB query by the ids | +| Render one item with many action buttons | `fga_batch_check` | +| Create a resource | `_fga_write_tuples`: owner + structural parent tuple | +| Share / grant / promote | `_fga_write_tuples`: one tuple | +| Revoke / unshare / offboard | `_fga_delete_tuples`: the matching tuple(s) | +| Reorganize (move project to new org) | delete + write the structural tuple | +| Debug "why can X see Y?" | `_fga_expand` (admin), or the dashboard **Step 3 · Test access** with any subject | diff --git a/docs/core/graphql-api.md b/docs/core/graphql-api.md index 6e87008..381b684 100644 --- a/docs/core/graphql-api.md +++ b/docs/core/graphql-api.md @@ -268,7 +268,7 @@ Input `FgaCheckInput`: | Key | Description | Required | | ------------------- | --------------------------------------------------------------------------- | -------- | | `relation` | Relation to check (e.g. `viewer`, `editor`). | `true` | -| `object` | Object identifier (e.g. `document:roadmap`). | `true` | +| `object` | Object identifier (e.g. `document:1`). | `true` | | `contextual_tuples` | Optional `[FgaTupleInput!]` of tuples evaluated only for this request. | `false` | | `user` | Subject override (super-admin only). Defaults to the caller. | `false` | @@ -276,7 +276,7 @@ Input `FgaCheckInput`: query { fga_check(params: { relation: "viewer", - object: "document:roadmap" + object: "document:1" }) { allowed } @@ -297,7 +297,7 @@ Input `FgaBatchCheckInput`: query { fga_batch_check(params: { checks: [ - { relation: "viewer", object: "document:roadmap" }, + { relation: "viewer", object: "document:1" }, { relation: "editor", object: "document:budget" } ] }) { @@ -1763,7 +1763,7 @@ Input `params.tuples` is `[FgaTupleInput!]!`, each `{ user: String!, relation: S mutation { _fga_write_tuples(params: { tuples: [ - { user: "user:alice", relation: "viewer", object: "document:roadmap" } + { user: "user:1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed", relation: "viewer", object: "document:1" } ] }) { message @@ -1779,7 +1779,7 @@ Delete relationship tuples. Same input shape as `_fga_write_tuples`. Returns `Re mutation { _fga_delete_tuples(params: { tuples: [ - { user: "user:alice", relation: "viewer", object: "document:roadmap" } + { user: "user:1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed", relation: "viewer", object: "document:1" } ] }) { message @@ -1819,7 +1819,7 @@ List the users that have a given relation on an object (admin only). query { _fga_list_users(params: { relation: "viewer", - object: "document:roadmap" + object: "document:1" }) { users } @@ -1834,7 +1834,7 @@ Expand the relationship/userset tree for a relation on an object (admin only). U query { _fga_expand(params: { relation: "viewer", - object: "document:roadmap" + object: "document:1" }) { tree } diff --git a/docs/migration/v1-to-v2.md b/docs/migration/v1-to-v2.md index 58b120c..e294a02 100644 --- a/docs/migration/v1-to-v2.md +++ b/docs/migration/v1-to-v2.md @@ -509,7 +509,7 @@ import { SignUpRequest, LoginRequest } from '@authorizerdev/authorizer-js' ## Authorization (FGA) -v2 adds an embedded **OpenFGA** engine for relationship-based access control (ReBAC). You author an authorization **model** (OpenFGA DSL: types + relations), grant access with **relationship tuples** (for example `user:alice` is `viewer` of `document:1`), and have your apps check access with `fga_check`. +v2 adds an embedded **OpenFGA** engine for relationship-based access control (ReBAC). You author an authorization **model** (OpenFGA DSL: types + relations), grant access with **relationship tuples** (for example `user:` is `viewer` of `document:1`), and have your apps check access with `fga_check`. ### What's new diff --git a/docs/sdks/authorizer-go/index.md b/docs/sdks/authorizer-go/index.md index dff8886..ec47d53 100644 --- a/docs/sdks/authorizer-go/index.md +++ b/docs/sdks/authorizer-go/index.md @@ -82,14 +82,14 @@ if res.IsValid { Authorizer ships with an embedded [OpenFGA](https://openfga.dev) relationship-based authorization (ReBAC) engine. The SDK exposes three client-facing methods to query it. Each takes a request struct and a `headers map[string]string` (pass `Authorization: Bearer `). -For complete worked scenarios — Go HTTP middleware, list filtering, and tuple lifecycle — see [Authorization recipes](/core/authorization-recipes). +For complete worked scenarios — Go HTTP middleware, list filtering, and tuple lifecycle — see [Authorization recipes](/core/authorization#9-real-world-recipes). **FgaCheck** -- check whether a user has a relation to an object. Returns `Allowed`. ```go res, err := authorizerClient.FgaCheck(&authorizer.FgaCheckRequest{ Relation: "viewer", - Object: "document:roadmap", + Object: "document:1", }, map[string]string{ "Authorization": "Bearer your-access-token", }) @@ -97,7 +97,7 @@ if err != nil { panic(err) } if res.Allowed { - // user is a viewer of document:roadmap + // user is a viewer of document:1 } ``` @@ -106,8 +106,8 @@ if res.Allowed { ```go res, err := authorizerClient.FgaBatchCheck(&authorizer.FgaBatchCheckRequest{ Checks: []*authorizer.FgaCheckPair{ - {Relation: "viewer", Object: "document:roadmap"}, - {Relation: "editor", Object: "document:roadmap"}, + {Relation: "viewer", Object: "document:1"}, + {Relation: "editor", Object: "document:1"}, }, }, map[string]string{ "Authorization": "Bearer your-access-token", diff --git a/docs/sdks/authorizer-js/functions.md b/docs/sdks/authorizer-js/functions.md index c57a6b8..ee62c3c 100644 --- a/docs/sdks/authorizer-js/functions.md +++ b/docs/sdks/authorizer-js/functions.md @@ -633,7 +633,7 @@ const { data, errors } = await authRef.validateSession({ Function to perform a fine-grained authorization (FGA) check using the embedded [OpenFGA](https://openfga.dev) relationship-based authorization engine. It checks whether a user has a given relation to an object. -For complete worked scenarios — Express middleware, list filtering, and tuple lifecycle — see [Authorization recipes](/core/authorization-recipes). +For complete worked scenarios — Express middleware, list filtering, and tuple lifecycle — see [Authorization recipes](/core/authorization#9-real-world-recipes). This function makes an authorized request, hence from the browser the HTTP cookie is sent automatically if the user has logged in. From NodeJS pass the `Authorization` header as the optional second argument. @@ -642,7 +642,7 @@ It accepts a JSON object as the first parameter with the following keys | Key | Description | Required | | ------------------- | -------------------------------------------------------------------------------- | -------- | | `relation` | The relation to check, e.g. `viewer`, `editor` | true | -| `object` | The object to check the relation against, e.g. `document:roadmap` | true | +| `object` | The object to check the relation against, e.g. `document:1` | true | | `contextual_tuples` | Optional contextual relationship tuples evaluated only for this check | false | | `user` | Optional user identifier. Defaults to the authenticated principal if omitted | false | @@ -660,14 +660,14 @@ It returns the following keys in response `data` object // from browser with HTTP Cookie const { data, errors } = await authRef.fgaCheck({ relation: 'viewer', - object: 'document:roadmap', + object: 'document:1', }) // from NodeJS / if HTTP cookie is not used const { data, errors } = await authRef.fgaCheck( { relation: 'viewer', - object: 'document:roadmap', + object: 'document:1', }, { Authorization: `Bearer ${token}`, @@ -703,8 +703,8 @@ It returns the following keys in response `data` object // from browser with HTTP Cookie const { data, errors } = await authRef.fgaBatchCheck({ checks: [ - { relation: 'viewer', object: 'document:roadmap' }, - { relation: 'editor', object: 'document:roadmap' }, + { relation: 'viewer', object: 'document:1' }, + { relation: 'editor', object: 'document:1' }, ], }) @@ -712,8 +712,8 @@ const { data, errors } = await authRef.fgaBatchCheck({ const { data, errors } = await authRef.fgaBatchCheck( { checks: [ - { relation: 'viewer', object: 'document:roadmap' }, - { relation: 'editor', object: 'document:roadmap' }, + { relation: 'viewer', object: 'document:1' }, + { relation: 'editor', object: 'document:1' }, ], }, { @@ -766,7 +766,7 @@ const { data, errors } = await authRef.fgaListObjects( }, ) -// data => { objects: ['document:roadmap', 'document:notes'] } +// data => { objects: ['document:1', 'document:notes'] } ``` ## - `verifyOtp` diff --git a/sidebars.ts b/sidebars.ts index e57d6f0..6faf9bb 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -19,7 +19,6 @@ const sidebars: SidebarsConfig = { 'core/server-config', 'core/security', 'core/authorization', - 'core/authorization-recipes', 'core/databases', 'core/endpoints', 'core/graphql-api', From b36956c9552f4ba57d053f9e839f88fabc10e561 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 10 Jun 2026 18:39:36 +0530 Subject: [PATCH 7/8] docs(fga): check_permissions + list_permissions public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public FGA surface is now exactly two operations (fga_check, fga_batch_check and fga_list_objects are gone — never released): - check_permissions: one or many checks per call; results positional and echoing each pair. list_permissions: objects the subject holds a permission on. - Subject defaults to the caller's token; an explicit user is honored only for super-admins or when it equals the caller's own subject. - Access Tester docs replaced by Users → View Permissions (the dashboard's per-user list_permissions modal). - Updated: authorization (§4, middleware, recipes, cheat sheet), graphql-api, metrics labels, security, migration, and both SDK pages (CheckPermissions/ ListPermissions, checkPermissions/listPermissions). --- docs/core/authorization.md | 108 ++++++++++---------- docs/core/graphql-api.md | 69 ++++--------- docs/core/metrics-monitoring.md | 8 +- docs/core/security.md | 2 +- docs/migration/v1-to-v2.md | 6 +- docs/sdks/authorizer-go/index.md | 57 ++++------- docs/sdks/authorizer-js/functions.md | 145 ++++----------------------- 7 files changed, 120 insertions(+), 275 deletions(-) diff --git a/docs/core/authorization.md b/docs/core/authorization.md index 5eb836b..64eaacf 100644 --- a/docs/core/authorization.md +++ b/docs/core/authorization.md @@ -20,7 +20,7 @@ This page covers: 1. [Enabling FGA](#1-enabling-fga) 2. [The authorization model](#2-the-authorization-model) — types, relations, the DSL, and the dashboard. 3. [Granting access](#3-granting-access--relationship-tuples) — relationship tuples. -4. [Checking access](#4-checking-access--client-api) — `fga_check`, `fga_batch_check`, `fga_list_objects`. +4. [Checking access](#4-checking-access--client-api) — `check_permissions`, `list_permissions`. 5. [Admin GraphQL API](#5-admin-graphql-api) — the `_fga_*` operations the dashboard uses. 6. [SDKs](#6-sdks) and [operational notes](#7-operational-notes). 7. [Using FGA from your application](#8-using-fga-from-your-application) — middleware, the tuple lifecycle, list filtering. @@ -151,57 +151,49 @@ resources inherit via a `… from parent` relation, or use `user:*` for public a ## 4. Checking access — client API -These three queries are the **client-facing** surface — they answer questions for the -**authenticated caller**. The subject is pinned server-side from the request (bearer -token or session cookie); it cannot be spoofed from the client. A super-admin may pass an -optional `user` to check on behalf of another subject; a non-trusted caller supplying -`user` is rejected. +The client-facing surface is exactly **two queries**. The subject defaults to the +**authenticated caller** — resolved server-side from the bearer token or session +cookie. An optional `user` ("type:id", or a bare id treated as `user:`) is +honored only when the caller is a **super-admin** or when it **equals the +caller's own token subject**; anything else is rejected, never silently ignored. -### `fga_check` — one question +### `check_permissions` — one or many questions -```graphql -query { - fga_check(params: { relation: "can_view", object: "document:1" }) { - allowed - } -} -``` - -### `fga_batch_check` — many at once - -Results are returned positionally, aligned with the `checks` you sent. +A single check is simply a list of one. Results come back **in order** and echo +the checked pair, so batch responses are self-describing. ```graphql query { - fga_batch_check(params: { + check_permissions(params: { checks: [ { relation: "can_view", object: "document:1" }, { relation: "can_edit", object: "document:1" } ] }) { - results { allowed } + results { relation object allowed } } } ``` -### `fga_list_objects` — what can I access? +Each check also accepts optional `contextual_tuples` (evaluated for that one +call only, never persisted) — handy for "what-if" checks or request-time facts. -Returns the fully-qualified ids of every object of a type the caller relates to — ideal -for filtering a list down to what the user is allowed to see. +### `list_permissions` — what can I access? + +Returns the fully-qualified ids of every object of a type the subject holds the +permission on — ideal for filtering a list down to what the user may see. ```graphql query { - fga_list_objects(params: { relation: "can_view", object_type: "document" }) { + list_permissions(params: { relation: "can_view", object_type: "document" }) { objects # ["document:1", "document:7", ...] } } ``` -All three also accept optional `contextual_tuples` (evaluated for that one call only, -never persisted) — handy for "what-if" checks or passing request-time facts. - -From the dashboard: **Step 3 · Test access** runs `fga_check` for the logged-in admin so -you can verify the model and tuples interactively. +From the dashboard: open **Users → ⋯ → View Permissions** to run +`list_permissions` for any user (the admin session may specify a subject) and +see exactly which objects they hold a permission on. --- @@ -246,9 +238,9 @@ mutation { The official SDKs expose the read-side client API (model/tuple authoring stays in the dashboard / admin API): -- **Go** — `FgaCheck`, `FgaBatchCheck`, `FgaListObjects`. See the +- **Go** — `CheckPermissions`, `ListPermissions`. See the [authorizer-go README](https://github.com/authorizerdev/authorizer-go#fine-grained-authorization-fga). -- **JavaScript / TypeScript** — `fgaCheck`, `fgaBatchCheck`, `fgaListObjects`. See the +- **JavaScript / TypeScript** — `checkPermissions`, `listPermissions`. See the [authorizer-js README](https://github.com/authorizerdev/authorizer-js#fine-grained-authorization-fga). For each, pass the caller's auth header in server contexts; in the browser the session @@ -289,8 +281,8 @@ request: **"may this user do this to this object?"** │ API call + token │ │ OpenFGA│ │ ┌──────▼───────┐ │ │ engine │ │ │ Your backend │ ───────► │ └────────┘ │ - │ │ fga_check│ │ - └──────────────┘ allowed?└─────────────┘ + │ │ check_ │ │ + └──────────────┘permissions└─────────────┘ ``` There are exactly **two touchpoints**: @@ -298,7 +290,7 @@ There are exactly **two touchpoints**: | Touchpoint | When | API | Credential | | --- | --- | --- | --- | | **Write tuples** | On your domain events — a document is created, a user joins a project, someone clicks "Share", access is revoked | `_fga_write_tuples` / `_fga_delete_tuples` | Admin secret, **server-side only** | -| **Check access** | On every read/write your backend serves | `fga_check`, `fga_batch_check`, `fga_list_objects` | The **caller's own token** — the subject is pinned server-side and cannot be spoofed | +| **Check access** | On every read/write your backend serves | `check_permissions`, `list_permissions` | The **caller's own token** by default — an explicit `user` is honored only for super-admins or self | ### Writing tuples from your domain events @@ -357,11 +349,12 @@ const auth = new Authorizer({ // requirePermission('can_edit', req => `document:${req.params.id}`) const requirePermission = (relation, objectFor) => async (req, res, next) => { - const { data } = await auth.fgaCheck( - { relation, object: objectFor(req) }, + const { data } = await auth.checkPermissions( + { checks: [{ relation, object: objectFor(req) }] }, { Authorization: req.headers.authorization }, // forward the caller's token ); - if (!data?.allowed) return res.status(403).json({ error: 'forbidden' }); + if (!data?.results?.[0]?.allowed) + return res.status(403).json({ error: 'forbidden' }); next(); }; @@ -380,11 +373,12 @@ The same middleware in Go: func RequirePermission(relation string, objectFor func(*http.Request) string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - res, err := authorizerClient.FgaCheck(&authorizer.FgaCheckRequest{ - Relation: relation, - Object: objectFor(r), + res, err := authorizerClient.CheckPermissions(&authorizer.CheckPermissionsRequest{ + Checks: []*authorizer.PermissionCheckInput{ + {Relation: relation, Object: objectFor(r)}, + }, }, map[string]string{"Authorization": r.Header.Get("Authorization")}) - if err != nil || !res.Allowed { + if err != nil || len(res.Results) == 0 || !res.Results[0].Allowed { http.Error(w, "forbidden", http.StatusForbidden) // fail closed return } @@ -399,7 +393,7 @@ func RequirePermission(relation string, objectFor func(*http.Request) string) fu For "show me my documents", ask once and filter your DB query by the result: ```js -const { data } = await auth.fgaListObjects( +const { data } = await auth.listPermissions( { relation: 'can_view', object_type: 'document' }, { Authorization: req.headers.authorization }, ); @@ -409,7 +403,7 @@ const docs = await db.documents.findMany({ where: { id: { in: ids } } }); ``` For rendering one page with many permission flags (can the user edit? delete? -share?), use `fga_batch_check` — one round trip, results in order. +share?), pass several checks to `check_permissions` — one round trip, results in order. --- @@ -463,8 +457,8 @@ user:* viewer document:42 **Your backend checks:** ```text -fga_check(can_edit, document:42) with Marco's token → allowed -fga_check(can_delete, document:42) with Marco's token → denied (owner only) +check_permissions(can_edit, document:42) with Marco's token → allowed +check_permissions(can_delete, document:42) with Marco's token → denied (owner only) ``` "Unshare" is just `_fga_delete_tuples` on the same tuple. No schema migration, no @@ -528,9 +522,9 @@ user:1b9d… viewer organization:101 ← ONE tuple Now every check below inherits, with zero per-resource tuples: ```text -fga_check(can_view, resource:301) → allowed (viewer from project ← from org) -fga_check(can_view, resource:302) → allowed -fga_check(can_edit, resource:301) → denied (viewers don't edit) +check_permissions(can_view, resource:301) → allowed (viewer from project ← from org) +check_permissions(can_view, resource:302) → allowed +check_permissions(can_edit, resource:301) → denied (viewers don't edit) ``` **Fine-grained exception on top** — an external contractor edits one resource only: @@ -538,8 +532,8 @@ fga_check(can_edit, resource:301) → denied (viewers don't edit) ```text user:2c8e… editor resource:301 -fga_check(can_edit, resource:301) → allowed -fga_check(can_view, resource:302) → denied (nothing leaks to siblings) +check_permissions(can_edit, resource:301) → allowed +check_permissions(can_view, resource:302) → denied (nothing leaks to siblings) ``` When a new resource is created, your app writes **one structural tuple** @@ -592,8 +586,8 @@ user:4e0a… assignee role:manager **Checks:** ```text -fga_check(can_edit, record:88) with Dana's token → allowed -fga_check(can_approve, record:88) with Dana's token → denied +check_permissions(can_edit, record:88) with Dana's token → allowed +check_permissions(can_approve, record:88) with Dana's token → denied ``` Offboarding = delete Dana's one `assignee` tuple; every record access disappears @@ -656,7 +650,7 @@ type document user:* viewer document:7 # everyone can read the handbook user:5f1b… blocked document:7 # …except this account -fga_check(can_view, document:7) with 5f1b…'s token → denied +check_permissions(can_view, document:7) with 5f1b…'s token → denied ``` `but not` always wins over every grant path — direct, inherited, or public. @@ -671,11 +665,11 @@ App event → FGA operation: | Your app does | You call | | --- | --- | -| Serve any protected read/write | `fga_check` (caller's token) | -| Render a list page | `fga_list_objects`, filter your DB query by the ids | -| Render one item with many action buttons | `fga_batch_check` | +| Serve any protected read/write | `check_permissions` (caller's token) | +| Render a list page | `list_permissions`, filter your DB query by the ids | +| Render one item with many action buttons | `check_permissions` with several checks | | Create a resource | `_fga_write_tuples`: owner + structural parent tuple | | Share / grant / promote | `_fga_write_tuples`: one tuple | | Revoke / unshare / offboard | `_fga_delete_tuples`: the matching tuple(s) | | Reorganize (move project to new org) | delete + write the structural tuple | -| Debug "why can X see Y?" | `_fga_expand` (admin), or the dashboard **Step 3 · Test access** with any subject | +| Debug "why can X see Y?" | `_fga_expand` (admin), or **Users → View Permissions** for any subject | diff --git a/docs/core/graphql-api.md b/docs/core/graphql-api.md index 381b684..f47de7b 100644 --- a/docs/core/graphql-api.md +++ b/docs/core/graphql-api.md @@ -257,73 +257,48 @@ query { ### Authorization (client-facing) -These queries answer authorization questions against the embedded FGA (ReBAC) engine. They require a valid session or bearer token. The subject is pinned server-side from the caller's token/cookie; the optional `user` override is honored only for super-admins. See [Authorization (FGA)](./authorization) for the full model. +These queries answer authorization questions against the embedded FGA (ReBAC) engine. They require a valid session or bearer token. The subject is pinned server-side from the caller's token/cookie; the optional `user` is honored only for super-admins or when it equals the caller's own subject. See [Authorization (FGA)](./authorization) for the full model. -#### `fga_check` +#### `check_permissions` -Check whether the subject has a `relation` on an `object`. Returns `{ allowed }`. +Evaluate one or more permission checks in a single call. Returns `{ results { relation object allowed } }`, positionally aligned with `checks` and echoing each pair. -Input `FgaCheckInput`: +Input `CheckPermissionsInput`: -| Key | Description | Required | -| ------------------- | --------------------------------------------------------------------------- | -------- | -| `relation` | Relation to check (e.g. `viewer`, `editor`). | `true` | -| `object` | Object identifier (e.g. `document:1`). | `true` | -| `contextual_tuples` | Optional `[FgaTupleInput!]` of tuples evaluated only for this request. | `false` | -| `user` | Subject override (super-admin only). Defaults to the caller. | `false` | +| Key | Description | Required | +| -------- | -------------------------------------------------------------------------------------------------------- | -------- | +| `checks` | `[PermissionCheckInput!]!` — each `{ relation!, object!, contextual_tuples? }`. | `true` | +| `user` | Subject ("type:id", bare id → `user:`). Honored only for super-admins or self; defaults to the caller. | `false` | ```graphql query { - fga_check(params: { - relation: "viewer", - object: "document:1" - }) { - allowed - } -} -``` - -#### `fga_batch_check` - -Run multiple checks in a single request. Returns `{ results { allowed } }` in input order. - -Input `FgaBatchCheckInput`: - -| Key | Description | Required | -| -------- | ----------------------------------------------------------------------- | -------- | -| `checks` | `[FgaCheckPairInput!]!`, each `{ relation, object, contextual_tuples?, user? }`. | `true` | - -```graphql -query { - fga_batch_check(params: { + check_permissions(params: { checks: [ - { relation: "viewer", object: "document:1" }, - { relation: "editor", object: "document:budget" } + { relation: "can_view", object: "document:1" }, + { relation: "can_edit", object: "document:1" } ] }) { - results { - allowed - } + results { relation object allowed } } } ``` -#### `fga_list_objects` +#### `list_permissions` -List the objects of a given type on which the subject has a relation. Returns `{ objects }`. +List the objects of a given type on which the subject holds a relation. Returns `{ objects }`. -Input `FgaListObjectsInput`: +Input `ListPermissionsInput`: -| Key | Description | Required | -| ------------- | ----------------------------------------------------- | -------- | -| `relation` | Relation to list for (e.g. `viewer`). | `true` | -| `object_type` | Object type to enumerate (e.g. `document`). | `true` | -| `user` | Subject override (super-admin only). Defaults to the caller. | `false` | +| Key | Description | Required | +| ------------- | -------------------------------------------------------------------------------------------------------- | -------- | +| `relation` | Relation to list for (e.g. `can_view`). | `true` | +| `object_type` | Object type to enumerate (e.g. `document`). | `true` | +| `user` | Subject ("type:id", bare id → `user:`). Honored only for super-admins or self; defaults to the caller. | `false` | ```graphql query { - fga_list_objects(params: { - relation: "viewer", + list_permissions(params: { + relation: "can_view", object_type: "document" }) { objects diff --git a/docs/core/metrics-monitoring.md b/docs/core/metrics-monitoring.md index 2496055..d2415a4 100644 --- a/docs/core/metrics-monitoring.md +++ b/docs/core/metrics-monitoring.md @@ -140,7 +140,7 @@ once. See [Authorization (FGA)](./authorization). | Metric | Type | Labels | Description | |--------|------|--------|-------------| -| `authorizer_fga_checks_total` | Counter | `operation`, `result` | Access decisions from `fga_check` / `fga_batch_check`. The headline metric for adoption and denial/error alerting. | +| `authorizer_fga_checks_total` | Counter | `operation`, `result` | Access decisions from `check_permissions`. The headline metric for adoption and denial/error alerting. | | `authorizer_fga_check_duration_seconds` | Histogram | `operation` | Latency of the client-facing FGA engine reads. | | `authorizer_fga_operations_total` | Counter | `operation`, `result` | Non-decision FGA operations (model/tuple management, enumeration, reset) by outcome. | @@ -148,12 +148,12 @@ once. See [Authorization (FGA)](./authorization). | Label | Values | |---|---| -| `operation` | `check` (single `fga_check`) · `batch_check` (each pair of an `fga_batch_check` is counted individually) | +| `operation` | `check_permissions` (each supplied pair is counted individually) | | `result` | `allowed` · `denied` · `error` (the engine call failed — fail-closed, so the caller was denied) | -**`authorizer_fga_check_duration_seconds`** `operation`: `check` · `batch_check` · `list_objects`. The histogram's `_count` also gives you a call rate per operation for free. +**`authorizer_fga_check_duration_seconds`** `operation`: `check_permissions` · `list_permissions`. The histogram's `_count` also gives you a call rate per operation for free. -**`authorizer_fga_operations_total`** `operation`: `get_model` · `write_model` · `read_tuples` · `write_tuples` · `delete_tuples` · `list_users` · `expand` · `list_objects` · `reset`. `result`: `success` · `error`. +**`authorizer_fga_operations_total`** `operation`: `get_model` · `write_model` · `read_tuples` · `write_tuples` · `delete_tuples` · `list_users` · `expand` · `list_permissions` · `reset`. `result`: `success` · `error`. Useful queries: diff --git a/docs/core/security.md b/docs/core/security.md index f614c86..3f38915 100644 --- a/docs/core/security.md +++ b/docs/core/security.md @@ -426,7 +426,7 @@ This kills the user-enumeration attack surface entirely. ## Fine-grained authorization -Authorizer ships an embedded **OpenFGA** (ReBAC) engine, and access checks **fail closed** — an `fga_check` for a relation that the relationship tuples don't grant is denied, and any engine or store error denies rather than allows. There is no permissive "log but allow" mode. See [Authorization (FGA)](./authorization) for the authorization model, admin mutations, and per-endpoint usage. +Authorizer ships an embedded **OpenFGA** (ReBAC) engine, and access checks **fail closed** — a `check_permissions` for a relation that the relationship tuples don't grant is denied, and any engine or store error denies rather than allows. There is no permissive "log but allow" mode. See [Authorization (FGA)](./authorization) for the authorization model, admin mutations, and per-endpoint usage. --- diff --git a/docs/migration/v1-to-v2.md b/docs/migration/v1-to-v2.md index e294a02..554760a 100644 --- a/docs/migration/v1-to-v2.md +++ b/docs/migration/v1-to-v2.md @@ -509,18 +509,18 @@ import { SignUpRequest, LoginRequest } from '@authorizerdev/authorizer-js' ## Authorization (FGA) -v2 adds an embedded **OpenFGA** engine for relationship-based access control (ReBAC). You author an authorization **model** (OpenFGA DSL: types + relations), grant access with **relationship tuples** (for example `user:` is `viewer` of `document:1`), and have your apps check access with `fga_check`. +v2 adds an embedded **OpenFGA** engine for relationship-based access control (ReBAC). You author an authorization **model** (OpenFGA DSL: types + relations), grant access with **relationship tuples** (for example `user:` is `viewer` of `document:1`), and have your apps check access with `check_permissions`. ### What's new - **Embedded OpenFGA engine.** Enabled by default when the main database is SQL (SQLite, Postgres, MySQL), reusing that same database. For NoSQL main databases (MongoDB, DynamoDB, …) it is off unless you set `--fga-store` (`sqlite` / `postgres` / `mysql` / `memory`) and `--fga-store-url`. -- **Client query operations:** `fga_check`, `fga_batch_check`, and `fga_list_objects` (the subject is pinned server-side to the calling principal). +- **Client query operations:** `check_permissions` and `list_permissions` (the subject defaults to the calling principal; an explicit `user` is honored only for super-admins or self). - **Admin GraphQL operations** (super-admin, `_fga_` prefix): `_fga_write_model`, `_fga_get_model`, `_fga_write_tuples`, `_fga_delete_tuples`, `_fga_read_tuples`, `_fga_list_users`, `_fga_expand`, and `_fga_reset`. Dashboard UI under **Authorization** → Step 1 Define model / Step 2 Grant access / Step 3 Test access. ### Adoption checklist - [ ] **Define the authorization model first** via the dashboard (Authorization → Step 1 Define model) or the `_fga_write_model` admin mutation. - [ ] **Grant access with tuples** using `_fga_write_tuples` (dashboard Step 2 Grant access) to relate subjects to objects. -- [ ] **Adopt `fga_check` incrementally** by adding access checks to one call site at a time (use `fga_batch_check` / `fga_list_objects` where it fits). +- [ ] **Adopt `check_permissions` incrementally** by adding access checks to one call site at a time (use `list_permissions` where it fits). Full reference: [Authorization (FGA)](../core/authorization). diff --git a/docs/sdks/authorizer-go/index.md b/docs/sdks/authorizer-go/index.md index ec47d53..b32e784 100644 --- a/docs/sdks/authorizer-go/index.md +++ b/docs/sdks/authorizer-go/index.md @@ -80,61 +80,41 @@ if res.IsValid { ### Step 4: Fine-grained authorization (FGA) -Authorizer ships with an embedded [OpenFGA](https://openfga.dev) relationship-based authorization (ReBAC) engine. The SDK exposes three client-facing methods to query it. Each takes a request struct and a `headers map[string]string` (pass `Authorization: Bearer `). +Authorizer ships with an embedded [OpenFGA](https://openfga.dev) relationship-based authorization (ReBAC) engine. The SDK exposes two client-facing methods to query it. Each takes a request struct and a `headers map[string]string` (pass `Authorization: Bearer `). For complete worked scenarios — Go HTTP middleware, list filtering, and tuple lifecycle — see [Authorization recipes](/core/authorization#9-real-world-recipes). -**FgaCheck** -- check whether a user has a relation to an object. Returns `Allowed`. +**CheckPermissions** -- evaluate one or more permission checks in a single call. `Results` come back in the same order as `Checks` and echo each pair. ```go -res, err := authorizerClient.FgaCheck(&authorizer.FgaCheckRequest{ - Relation: "viewer", - Object: "document:1", -}, map[string]string{ - "Authorization": "Bearer your-access-token", -}) -if err != nil { - panic(err) -} -if res.Allowed { - // user is a viewer of document:1 -} -``` - -**FgaBatchCheck** -- run multiple checks in a single request. Returns `Results` in the same order as the checks. - -```go -res, err := authorizerClient.FgaBatchCheck(&authorizer.FgaBatchCheckRequest{ - Checks: []*authorizer.FgaCheckPair{ - {Relation: "viewer", Object: "document:1"}, - {Relation: "editor", Object: "document:1"}, +res, err := authorizerClient.CheckPermissions(&authorizer.CheckPermissionsRequest{ + Checks: []*authorizer.PermissionCheckInput{ + {Relation: "can_view", Object: "document:1"}, + {Relation: "can_edit", Object: "document:1"}, }, }, map[string]string{ - "Authorization": "Bearer your-access-token", + "Authorization": "Bearer " + token, }) if err != nil { panic(err) } -for _, r := range res.Results { - fmt.Println(r.Allowed) +if res.Results[0].Allowed { + // caller may view document:1 } ``` -**FgaListObjects** -- list all objects of a given type the user has a relation to. Returns `Objects`. +The subject defaults to the caller's token. An optional `User` ("type:id", bare id treated as `user:`) is honored only for super-admins or when it equals the caller's own subject. Each check also accepts optional `ContextualTuples`, evaluated for that call only. + +**ListPermissions** -- list all objects of a given type the subject holds a relation on. Returns `Objects`. ```go -res, err := authorizerClient.FgaListObjects(&authorizer.FgaListObjectsRequest{ - Relation: "viewer", +res, err := authorizerClient.ListPermissions(&authorizer.ListPermissionsRequest{ + Relation: "can_view", ObjectType: "document", }, map[string]string{ - "Authorization": "Bearer your-access-token", + "Authorization": "Bearer " + token, }) -if err != nil { - panic(err) -} -for _, obj := range res.Objects { - fmt.Println(obj) -} +// res.Objects => ["document:1", "document:7", ...] ``` ## Available Methods @@ -154,6 +134,5 @@ The SDK provides the following methods: - `RevokeToken` -- Revoke a token - `Logout` -- Logout user - `ValidateSession` -- Validate a session -- `FgaCheck` -- Check whether a user has a relation to an object (FGA) -- `FgaBatchCheck` -- Run multiple FGA checks in a single request -- `FgaListObjects` -- List objects of a type the user has a relation to (FGA) +- `CheckPermissions` -- Evaluate one or more permission checks (FGA) +- `ListPermissions` -- List objects the subject holds a permission on (FGA) diff --git a/docs/sdks/authorizer-js/functions.md b/docs/sdks/authorizer-js/functions.md index ee62c3c..4088f5e 100644 --- a/docs/sdks/authorizer-js/functions.md +++ b/docs/sdks/authorizer-js/functions.md @@ -28,9 +28,8 @@ title: Functions - [logout](#--logout) - [validateJWTToken](#--validatejwttoken) - [validateSession](#--validatesession) -- [fgaCheck](#--fgacheck) -- [fgaBatchCheck](#--fgabatchcheck) -- [fgaListObjects](#--fgalistobjects) +- [checkPermissions](#--checkpermissions) +- [listPermissions](#--listpermissions) - [verifyOtp](#--verifyotp) - [resendOtp](#--resendotp) - [deactivateAccount](#--deactivateaccount) @@ -629,144 +628,42 @@ const { data, errors } = await authRef.validateSession({ }) ``` -## - `fgaCheck` +## - `checkPermissions` -Function to perform a fine-grained authorization (FGA) check using the embedded [OpenFGA](https://openfga.dev) relationship-based authorization engine. It checks whether a user has a given relation to an object. - -For complete worked scenarios — Express middleware, list filtering, and tuple lifecycle — see [Authorization recipes](/core/authorization#9-real-world-recipes). - -This function makes an authorized request, hence from the browser the HTTP cookie is sent automatically if the user has logged in. From NodeJS pass the `Authorization` header as the optional second argument. - -It accepts a JSON object as the first parameter with the following keys - -| Key | Description | Required | -| ------------------- | -------------------------------------------------------------------------------- | -------- | -| `relation` | The relation to check, e.g. `viewer`, `editor` | true | -| `object` | The object to check the relation against, e.g. `document:1` | true | -| `contextual_tuples` | Optional contextual relationship tuples evaluated only for this check | false | -| `user` | Optional user identifier. Defaults to the authenticated principal if omitted | false | - -It returns the following keys in response `data` object - -**Response** - -| Key | Description | -| --------- | --------------------------------------------------- | -| `allowed` | Boolean indicating if the relation is granted or not | - -**Sample Usage** - -```js -// from browser with HTTP Cookie -const { data, errors } = await authRef.fgaCheck({ - relation: 'viewer', - object: 'document:1', -}) - -// from NodeJS / if HTTP cookie is not used -const { data, errors } = await authRef.fgaCheck( - { - relation: 'viewer', - object: 'document:1', - }, - { - Authorization: `Bearer ${token}`, - }, -) - -// data => { allowed: true } -``` - -## - `fgaBatchCheck` - -Function to perform multiple fine-grained authorization (FGA) checks in a single request. Returns the results in the same order as the supplied checks. +Function to evaluate one or more fine-grained authorization (FGA) permission checks against the embedded [OpenFGA](https://openfga.dev) engine, in a single call. `results` come back in the same order as `checks` and echo each pair. This function makes an authorized request, hence from the browser the HTTP cookie is sent automatically if the user has logged in. From NodeJS pass the `Authorization` header as the optional second argument. -It accepts a JSON object as the first parameter with the following keys - -| Key | Description | Required | -| -------- | ------------------------------------------------------------------- | -------- | -| `checks` | Array of `{ relation, object }` pairs to evaluate | true | - -It returns the following keys in response `data` object - -**Response** - -| Key | Description | -| --------- | ------------------------------------------------------------------- | -| `results` | Array of `{ allowed: boolean }`, one per check, in the same order | +The subject defaults to the caller's token. An optional `user` ("type:id", or a bare id treated as `user:`) is honored only for super-admins or when it equals the caller's own token subject. Each check also accepts optional `contextual_tuples`, evaluated for that call only and never persisted. -**Sample Usage** +For complete worked scenarios — Express middleware, list filtering, and tuple lifecycle — see [Authorization recipes](/core/authorization#9-real-world-recipes). ```js -// from browser with HTTP Cookie -const { data, errors } = await authRef.fgaBatchCheck({ - checks: [ - { relation: 'viewer', object: 'document:1' }, - { relation: 'editor', object: 'document:1' }, - ], -}) - -// from NodeJS / if HTTP cookie is not used -const { data, errors } = await authRef.fgaBatchCheck( +const { data, errors } = await authRef.checkPermissions( { checks: [ - { relation: 'viewer', object: 'document:1' }, - { relation: 'editor', object: 'document:1' }, + { relation: 'can_view', object: 'document:1' }, + { relation: 'can_edit', object: 'document:1' }, ], }, - { - Authorization: `Bearer ${token}`, - }, -) + { Authorization: `Bearer ${token}` }, // omit in the browser to use the cookie +); -// data => { results: [{ allowed: true }, { allowed: false }] } +if (data?.results?.[0]?.allowed) { + // caller may view document:1 +} ``` -## - `fgaListObjects` - -Function to list all objects of a given type that the user has a relation to, using the embedded fine-grained authorization (FGA) engine. - -This function makes an authorized request, hence from the browser the HTTP cookie is sent automatically if the user has logged in. From NodeJS pass the `Authorization` header as the optional second argument. - -It accepts a JSON object as the first parameter with the following keys - -| Key | Description | Required | -| ------------- | ---------------------------------------------------------------------------- | -------- | -| `relation` | The relation to check, e.g. `viewer`, `editor` | true | -| `object_type` | The object type to list, e.g. `document` | true | -| `user` | Optional user identifier. Defaults to the authenticated principal if omitted | false | +## - `listPermissions` -It returns the following keys in response `data` object - -**Response** - -| Key | Description | -| --------- | ------------------------------------------------------------------- | -| `objects` | Array of object identifiers the user has the given relation to | - -**Sample Usage** +Function to list all objects of a given type the subject holds a relation on — ideal for filtering a list page down to what the user may see. Subject resolution follows the same rules as `checkPermissions`. ```js -// from browser with HTTP Cookie -const { data, errors } = await authRef.fgaListObjects({ - relation: 'viewer', - object_type: 'document', -}) - -// from NodeJS / if HTTP cookie is not used -const { data, errors } = await authRef.fgaListObjects( - { - relation: 'viewer', - object_type: 'document', - }, - { - Authorization: `Bearer ${token}`, - }, -) - -// data => { objects: ['document:1', 'document:notes'] } +const { data, errors } = await authRef.listPermissions( + { relation: 'can_view', object_type: 'document' }, + { Authorization: `Bearer ${token}` }, +); +// data?.objects => ['document:1', 'document:7', ...] ``` ## - `verifyOtp` From fbc68fc2cadd1e940145de8a5734b56b8ea43775 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Thu, 11 Jun 2026 11:08:26 +0530 Subject: [PATCH 8/8] docs(specs): move program design docs from the server repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FGA/OpenFGA migration plan, agentic delegation design, enterprise authz model, implementation agents, migration-tool design, and the ReBAC guide now live here — the server repo no longer carries design docs. --- specs/2026-06-08-migration-tool-design.md | 282 ++++++++++++++++++++++ specs/AGENTIC_DELEGATION_DESIGN.md | 152 ++++++++++++ specs/ENTERPRISE_AUTHZ_MODEL.md | 173 +++++++++++++ specs/FGA_IMPLEMENTATION_AGENTS.md | 80 ++++++ specs/FGA_OPENFGA_MIGRATION_PLAN.md | 208 ++++++++++++++++ specs/fga-rebac-guide.md | 164 +++++++++++++ 6 files changed, 1059 insertions(+) create mode 100644 specs/2026-06-08-migration-tool-design.md create mode 100644 specs/AGENTIC_DELEGATION_DESIGN.md create mode 100644 specs/ENTERPRISE_AUTHZ_MODEL.md create mode 100644 specs/FGA_IMPLEMENTATION_AGENTS.md create mode 100644 specs/FGA_OPENFGA_MIGRATION_PLAN.md create mode 100644 specs/fga-rebac-guide.md diff --git a/specs/2026-06-08-migration-tool-design.md b/specs/2026-06-08-migration-tool-design.md new file mode 100644 index 0000000..a05821d --- /dev/null +++ b/specs/2026-06-08-migration-tool-design.md @@ -0,0 +1,282 @@ +# Authorizer Migration Tool — Design Spec + +**Status:** Draft for review +**Date:** 2026-06-08 +**Author:** Principal Engineering +**Scope:** A zero-downtime migration tool to move users, credentials, and configuration into Authorizer from Auth0, Clerk, WorkOS, Keycloak, Okta, and SuperTokens — plus the Authorizer core APIs required to support it. + +--- + +## 1. Problem & goals + +Authorizer (as a SaaS / self-hosted offering) wants to win customers off incumbent auth providers. The single biggest adoption blocker is migration risk: moving an existing user base without **(a)** forcing password resets, **(b)** a maintenance window, or **(c)** losing roles/MFA/social links. + +### Goals +1. Migrate **users + credentials + social links + roles + MFA + org/tenant + client config** from six sources. +2. **Zero downtime, zero forced reset** for the common case (coexistence migration with live cutover and rollback-before-cutover). +3. Data never transits Authorizer SaaS unnecessarily — extraction runs in the customer's network. +4. Idempotent, resumable, inspectable, dry-runnable. + +### Non-goals (v1) +- Auto-applying org/tenant and OAuth-client/connection config into Authorizer (v1 = **report-only**, see §9). +- True "no re-login" session continuity via foreign-JWKS bridging (v1 = **one silent re-auth at cutover**, see §7). +- Reversible-after-cutover write-journaling (v1 = **forward-only after a validated bake**, see §7). +- WebAuthn/passkey migration (cryptographically bound to original origin — re-enroll required). + +--- + +## 2. Research findings: what each source actually exposes + +Verified against current provider docs / source (June 2026). This table drives every connector's behavior. + +| Source | User profiles | Password hashes (self-serve?) | Hash algo | MFA/TOTP seeds | Social links | Roles/RBAC | Orgs/Tenants | +|---|---|---|---|---|---|---|---| +| **Keycloak** | ✅ realm export / Admin API | ✅ **Yes** — realm-export JSON | PBKDF2-sha256/512 (+iter+salt, 64-byte dk) | ✅ **Yes** — seed in export | ✅ `federatedIdentities` | ✅ realm/client roles, groups | realm = tenant | +| **SuperTokens** | ✅ Core API (`GET /users`) | ✅ **Yes** — direct DB read (self-hosted) | **bcrypt default**, or argon2id | ✅ **Yes** — DB `totp_user_devices.secret_key` | ✅ `thirdParty` per loginMethod | ✅ roles recipe | ✅ multitenancy API | +| **Clerk** | ✅ Backend API / Dashboard CSV | ✅ **Yes** — Dashboard CSV incl. hashes | bcrypt | ❌ no | ✅ `external_accounts` | ✅ org roles/perms | ✅ organizations | +| **Auth0** | ✅ `POST /jobs/users-exports` | ⚠️ **support ticket only** (PGP, paid tier) | bcrypt (`$2a/$2b$`) | ⚠️ same PGP ticket | ✅ `identities[]` | ✅ Authorization Core | ✅ Organizations | +| **Okta** | ✅ `GET /api/v1/users` | ❌ **Never** | n/a | ❌ no | ✅ IdP links | ✅ groups, roles | single org | +| **WorkOS** | ✅ User Mgmt API | ❌ **Never** (reset/lazy) | n/a | ❌ no | ✅ AuthKit identities | ✅ roles | ✅ orgs + SSO conns | + +**Conclusion:** Hashes are obtainable from three sources directly (Keycloak/SuperTokens/Clerk), one with heavy effort (Auth0), never from two (Okta/WorkOS). Therefore the tool **must** support both a bulk-hash path and a live lazy/JIT path. Confirmed cross-reference: WorkOS's own "migrate from X" docs document the same realities and fall back to password-reset; we improve on that with live lazy verification. + +### Key source endpoints (for connectors) +- **Auth0:** `POST /api/v2/jobs/users-exports` (NDJSON, 24h TTL, 60s download link); `GET /roles`, `/roles/{id}/permissions`, `/users/{id}/roles`; `/organizations/*`; `/clients` (+`read:client_keys` for secrets); `/connections` (+`read:connections_options` for social/SAML keys); `/resource-servers`; `/users/{id}/enrollments` (MFA metadata only). Mgmt token via M2M client-credentials, 24h expiry. **Password hashes + MFA secrets:** PGP support-ticket export only (`export-password-hashes-and-mfa-secrets`), not Free tier. +- **Clerk:** `GET /v1/users` (Backend API, bcrypt `password_digest`/`password_hasher`); Dashboard CSV export includes hashes (since 2024-10-23); `/v1/organizations` + memberships; trickle-migration documented. Insecure hashers transparently upgraded to bcrypt on first login. +- **WorkOS:** `GET /user_management/users` (cursor pagination); `/organizations`, `/organization_memberships`; `/connections` (SSO SAML/OIDC); `/directories` (SCIM). No hash export → lazy/reset. Import side accepts bcrypt/scrypt/firebase-scrypt/ssha/pbkdf2/argon2 in PHC. +- **Keycloak:** realm export via `kc.sh export --users different_files` (credentials included) — `credentials[].secretData{value,salt}` + `credentialData{hashIterations,algorithm}` (PBKDF2, **64-byte derived key**, read per-credential iteration count); OTP creds carry the TOTP seed; `federatedIdentities`; `realmRoles`/`clientRoles`; groups; `/clients` (+secrets), `/identity-provider/instances` (+secrets). Admin token via `admin-cli` client-credentials. +- **Okta:** `GET /api/v1/users` (limit 200, link-header cursor); `/idps` + `/idps/{id}/users`; `/users/{id}/factors` (no secrets); `/groups`; `/apps` (+`/apps/{id}/users`). SSWS or OAuth2 scopes (`okta.users.read`, …). **No hash export** → Okta Password Import Inline Hook is the lazy pattern (we mirror it in reverse). +- **SuperTokens:** Core API `GET /users` (`paginationToken` loop), `/recipe/roles`, `/recipe/multitenancy/tenant/list`, `/recipe/user/metadata`; **hashes + TOTP secrets via direct DB read** (`emailpassword_users.password_hash`, `totp_user_devices.secret_key`); bulk-import API shape (`loginMethods[].passwordHash`+`hashingAlgorithm` ∈ {argon2,bcrypt,firebase_scrypt}, `totpDevices[].secretKey`, `userRoles`, `userMetadata`, `externalUserId`) is the reference for our own import API. + +--- + +## 3. Architecture + +Three components. + +### (a) `authorizer-migrate` CLI (Go single binary) +Matches Authorizer's stack. Runs in the **customer's network** so source data never transits Authorizer SaaS. Pipeline: + +``` + extract transform/normalize load +┌──────────┐ source ┌────────────────────┐ ┌──────────────────┐ +│ connector│──API/DB────▶ │ .amf.json │ ───▶ │ Authorizer │ +│ (source │ │ (canonical AMF, │ │ _import_users │ +│ creds) │ │ inspectable) │ │ (admin creds) │ +└──────────┘ └────────────────────┘ └──────────────────┘ +``` + +Commands: +- `authorizer-migrate extract --from --config --out users.amf.json [--since ]` +- `authorizer-migrate validate users.amf.json` (schema + referential integrity, offline) +- `authorizer-migrate load --in users.amf.json --authorizer --admin-secret … [--dry-run] [--resume]` +- `authorizer-migrate report --in users.amf.json` (org/RBAC/client-config report, §9) + +Properties: **idempotent upsert** (`source`+`externalUserId`), **resumable** (checkpoint file), **dry-run**, **delta** (`--since`). + +### (b) AMF — Authorizer Migration Format +One canonical JSON schema all connectors normalize into. Decouples extract (source creds) from load (target creds), gives a human review/edit/diff checkpoint, makes the pipeline idempotent + resumable. Sketch: + +```jsonc +{ + "amfVersion": "1.0", + "source": { "provider": "keycloak", "exportedAt": 1749312000, "realmOrTenant": "acme" }, + "users": [ + { + "externalUserId": "f8a...", // stable source id → idempotency key + "email": "jane@acme.com", + "emailVerified": true, + "phoneNumber": null, + "givenName": "Jane", "familyName": "Doe", + "picture": "https://...", + "roles": ["admin", "billing"], + "credential": { + "type": "password_hash", // password_hash | none (lazy) | reset + "algorithm": "pbkdf2-sha256", // bcrypt | pbkdf2-sha256 | pbkdf2-sha512 | argon2id | scrypt | firebase-scrypt + "hash": "base64...", // normalized to PHC string where possible + "params": { "iterations": 27500, "salt": "base64...", "keyLen": 64 } + }, + "identities": [ + { "provider": "google", "providerUserId": "104...", "email": "jane@gmail.com" } + ], + "mfa": [ + { "type": "totp", "secret": "BASE32SEED", "algorithm": "SHA1", "digits": 6, "period": 30 } + ], + "appData": { "sourceOrg": "acme", "legacyId": "..." }, + "createdAt": 1700000000, "updatedAt": 1749000000 + } + ], + "lazyMigration": { // present when source can't export hashes + "enabled": true, + "originProvider": "okta", + "verifyVia": "password-grant" // how Authorizer re-verifies on first login + } +} +``` + +`config-report` (orgs, roles tree, OAuth clients, connections) is emitted to a **separate** `*.report.json` consumed by `report`, not by `load` (v1 report-only). + +### (c) Authorizer core additions +The "missing APIs." See §4–§6. + +--- + +## 4. New Authorizer APIs (the missing surface) + +Authorizer today has **no bulk import** and **no create-user-with-prehashed-password** path (`signup` hashes plaintext; `invite_members` invites). We add: + +### 4.1 `_import_users` — admin GraphQL mutation (async job) +- Admin-authenticated (admin secret), behind the existing `_`-prefixed admin namespace. +- Accepts a batch (≤ N per call, e.g. 1000) of canonical AMF user records. +- Creates users with **pre-hashed passwords + algorithm metadata**, identities, roles, TOTP devices, `app_data`, and `externalUserId`+`source` for idempotency. +- **Idempotent upsert:** re-running with the same `source`+`externalUserId` updates instead of duplicating (enables delta sync). +- Returns a `jobID`. + +```graphql +mutation { _import_users(params: ImportUsersInput!): ImportJob! } + +input ImportUsersInput { + source: String! # "auth0" | "keycloak" | ... + users: [ImportUserInput!]! + upsert: Boolean = true +} +input ImportUserInput { + external_user_id: String! + email: String + email_verified: Boolean + phone_number: String + given_name: String + family_name: String + picture: String + roles: [String!] + password_hash: String # optional; omit for lazy/reset users + password_hash_algorithm: String # bcrypt | pbkdf2-sha256 | pbkdf2-sha512 | argon2id | scrypt | firebase-scrypt + password_hash_params: String # JSON: iterations, salt, keyLen, memory, parallelism, etc. + identities: [ImportIdentityInput!] + mfa: [ImportMFAInput!] + app_data: String # JSON + created_at: Int64 + updated_at: Int64 +} +``` + +### 4.2 `_import_status(jobID)` + job resource +Large tenants import async. Poll job progress and inspect per-row failures (mirrors SuperTokens' `bulk-import/users?status=FAILED`). + +```graphql +query { _import_status(job_id: ID!): ImportJob! } +type ImportJob { id: ID! status: ImportStatus! total: Int! succeeded: Int! failed: Int! errors: [ImportRowError!]! } +enum ImportStatus { QUEUED PROCESSING COMPLETED COMPLETED_WITH_ERRORS FAILED } +type ImportRowError { external_user_id: String! message: String! } +``` + +--- + +## 5. User schema change + multi-algorithm verifier + +**Today:** `User.Password *string` (bcrypt, `bcrypt.DefaultCost`), no algorithm column → a Keycloak PBKDF2 or SuperTokens argon2id hash cannot be verified after import. + +**Change (decision: add algo-metadata + multi-algo verify with transparent upgrade):** + +1. Add columns to `User` (all providers): `password_hash_algorithm *string`, `password_hash_params *string` (JSON). Nullable; null/empty ⇒ legacy bcrypt (back-compat — existing rows untouched). +2. Add a `crypto` verifier registry: `bcrypt` (native, existing), `pbkdf2-sha256`, `pbkdf2-sha512`, `argon2id`, `scrypt`, `firebase-scrypt`. Each verifies a candidate password against `(hash, params)`. +3. **Login path change:** on password verify, dispatch by `password_hash_algorithm`. On the **first successful** login against a non-bcrypt hash, **transparently re-hash to bcrypt** (`bcrypt.DefaultCost`), persist, and clear the algorithm/params columns. This is Clerk's upgrade model — within a tail period the whole base converges to native bcrypt and the foreign-algo code is exercised only transiently. + +**Verification:** import a hand-written AMF with one bcrypt, one pbkdf2-sha256, and one argon2id user; log in as each with the original password and get a session with **no reset**; confirm the non-bcrypt rows flip to bcrypt + null metadata after first login. + +**Security notes:** foreign-algo verifiers are constant-time-compared; params are validated (bounded iteration/memory to prevent a malicious AMF from triggering resource exhaustion); the existing dummy-bcrypt timing-equalization on login (`internal/graphql/login.go`) is extended to cover the dispatch so non-existent-user timing doesn't leak. + +--- + +## 6. Lazy / JIT migration-source (the zero-reset coexistence engine) + +For Okta & WorkOS (no hash export) **and** for any user whose password changed after the last delta, Authorizer verifies the password **live against the origin provider** on first login. + +**Decision: lives in Authorizer core** as a configurable *migration source* (not an external webhook), mirroring Auth0/Okta's own inline-hook pattern but in reverse. + +- New config object (DB-config, like other Authorizer settings): `migration_source { provider, base_url, credentials, verify_method }` where `verify_method` ∈ `{ password-grant, ropc, verify-endpoint }`. +- A user imported via the lazy path carries `credential.type = none` and a dedicated nullable `User.migration_source *string` column (checked on the hot login path — avoids parsing `app_data`). Non-null ⇒ this user still needs origin verification. +- **Failed-login hook:** when local verification fails (or there is no local hash) for a user with non-null `migration_source`, Authorizer calls the origin's password-grant/verify API with the submitted credentials. On success → mint an Authorizer session, **store the password as bcrypt**, null out `migration_source`. On failure → normal invalid-credentials. +- Promoted from "Okta/WorkOS only" to a **general coexistence primitive** usable by all six connectors. + +**Verification:** configure an Okta migration source; import an Okta user with `credential.type=none`; log into Authorizer with the user's real Okta password → success, session issued, row now has a bcrypt hash and null `migration_source`; second login verifies locally (no origin call). + +--- + +## 7. Zero-downtime strategy + +Principle: **never a moment where neither provider can authenticate; never a forced reset or maintenance window.** Four layers: + +1. **Bulk pre-seed** — initial export → AMF → `_import_users`. Authorizer runs as a **shadow**; app still points at the old provider. Most users immediately authenticatable where hashes are exportable. +2. **Repeatable delta sync** — `extract --since ` + idempotent `load` upsert, scheduled (e.g. hourly), keeps the shadow current as users sign up / edit profiles in the old system. +3. **Live lazy verify-against-origin (§6)** — the correctness guarantee: whatever the user's *current* origin password is, it validates live on first login. **Delta sync therefore never needs to carry passwords for correctness** — only profile freshness and lazy-load reduction. +4. **Staged cutover + rollback:** + +``` +Coexist: app → OLD (authoritative). CLI bulk + delta → Authorizer (shadow, READ-ONLY from source). +Cutover: flip app auth → Authorizer (config/DNS). Authorizer authoritative for writes. + Lazy hook verifies stragglers live against OLD (read-only). No more writes to OLD. +Decommission: after tail period + final reconciliation, disable OLD + lazy hook. +``` + +Because coexistence is **read-only from the source**, rollback *before* cutover is a no-op and the source is never mutated. + +### Decided tradeoffs +- **Session continuity = one silent re-auth at cutover.** Old-provider JWTs aren't trusted post-cutover; users re-authenticate once (seamless via bulk hash or lazy verify — no reset, no error). No foreign-JWKS bridging in v1. +- **Rollback = forward-only after a validated bake.** Cutover begins with a canary (e.g. 1–2% traffic or a pilot tenant) that is validated; once full cutover is confirmed healthy, forward-only. No write-journaling / reverse-sync in v1. + +### Deliverable: cutover runbook +A checklist doc (pre-flight credential/scope checks, pre-seed, delta cadence, canary, validation queries, go/no-go, rollback-before-cutover procedure, decommission criteria) shipped alongside the CLI. + +--- + +## 8. Per-provider connector behavior + +| Connector | Profiles | Password disposition | MFA | Notes | +|---|---|---|---|---| +| **Keycloak** | realm export | **bulk hash** (PBKDF2 → multi-algo verify) | **bulk TOTP seed** | best case; per-credential iterations; realm→instance | +| **SuperTokens** | Core API + DB | **bulk hash** (bcrypt direct / argon2id via multi-algo) | **bulk TOTP seed** (DB) | DB read for hash+seed; account-linking → identities | +| **Clerk** | Backend API / CSV | **bulk hash** (bcrypt) | re-enroll | CSV includes hashes | +| **Auth0** | export job | **bulk hash if PGP export obtained**, else **lazy** | bulk if PGP, else re-enroll | NDJSON→JSON; identities[] | +| **Okta** | Users API | **lazy** (verify-against-origin) | re-enroll | mirrors Okta Password Import Hook in reverse | +| **WorkOS** | User Mgmt API | **lazy** | re-enroll | SSO/Directory config → report | + +Each connector: paginates fully (no silent caps — log dropped/over-limit), normalizes to AMF, supports `--since`. + +--- + +## 9. Scope mapping for orgs / RBAC / client-config (v1 = report-only) + +- **Orgs/tenants:** Authorizer is single-tenant per instance. Map **1 Keycloak realm / 1 Auth0 tenant / 1 WorkOS org-set → 1 Authorizer instance.** Multi-org sources emit a **mapping report**; org membership is preserved as a role and/or `app_data.sourceOrg` namespace (no silent loss). +- **RBAC:** source roles/permissions → Authorizer roles + the new **FGA permission model**; emitted to the report with a proposed mapping the customer confirms. +- **App/client & connection config** (OAuth clients, social keys, SAML/OIDC enterprise connections): exported into the report (secrets flagged, never auto-written). Customer applies via Authorizer's DB-config. **Rationale:** these are low-volume, high-blast-radius config objects; auto-writing them into a single-tenant instance is risky and provider-shape-specific. Auto-apply is a post-v1 candidate. + +--- + +## 10. Phasing & milestones (each ships + verifies independently) + +| Phase | Deliverable | Verify | +|---|---|---| +| **0** | User schema columns + multi-algo verifier + transparent upgrade (§5) | bcrypt+pbkdf2+argon2id users log in from hand-written AMF, no reset; non-bcrypt flips to bcrypt post-login | +| **1** | `_import_users` + `_import_status` async job (§4) | import 10k-row AMF; idempotent re-run upserts; failures surfaced per-row | +| **2** | AMF spec + CLI skeleton (`extract`/`validate`/`load`, dry-run, resume, `--since`) | round-trip a sample AMF; resume after kill; dry-run mutates nothing | +| **3** | Connectors (hash-friendly order): Keycloak → SuperTokens → Clerk → Auth0 | each extracts to valid AMF; bulk-hash users log in post-load with no reset | +| **4** | Migration-source + failed-login hook (§6); Okta + WorkOS connectors | Okta user logs into Authorizer with old password → silently upgraded | +| **5** | Zero-downtime cutover runbook + delta-sync scheduling guide (§7) | dry-run a full coexist→delta→cutover on a pilot tenant | +| **6** | `report` command: orgs/RBAC/client-config (§9) | report lists every org/role/client/connection with proposed mapping | + +--- + +## 11. Risks & open questions + +- **Auth0 hash export is gated** (PGP + paid tier + CISO sign-off). For customers who can't/won't, Auth0 falls to the lazy path — document this prominently; it's the main "it depends" in the matrix. +- **argon2id params** must be carried exactly (memory/iterations/parallelism); a mismatch silently fails verification. Validate on `load`. +- **Keycloak iteration counts are per-credential** and version-dependent — never hardcode; read each credential. Confirm 64-byte derived-key length against the customer's Keycloak version. +- **Delta-sync window for profile writes** (not passwords) made in the old system between last delta and cutover are lost unless cutover stops old-system writes — handled by the "old is authoritative until cutover, then writes move to Authorizer" rule; flag in runbook. +- **Rate limits** (Auth0 Mgmt API, Okta org-wide concurrency) — connectors must backoff; large exports use the async export job where available, not page-scan. +- **PII handling** — AMF files contain hashes/seeds/PII; CLI writes them `0600`, supports encryption-at-rest for the AMF file, and the runbook mandates secure deletion post-migration. + +--- + +## 12. Out of scope (v1) +WebAuthn/passkey migration; foreign-JWKS session bridging; reversible-after-cutover write-journaling; auto-applying org/client config; non-listed source providers (Cognito/Firebase/Supabase — future connectors, AMF already accommodates them). diff --git a/specs/AGENTIC_DELEGATION_DESIGN.md b/specs/AGENTIC_DELEGATION_DESIGN.md new file mode 100644 index 0000000..42f2fb3 --- /dev/null +++ b/specs/AGENTIC_DELEGATION_DESIGN.md @@ -0,0 +1,152 @@ +# Design: Agentic Delegation Chain (Token Exchange · Attenuation · Audit) + +**Scope:** Capabilities #13 (RFC 8693 token exchange), #14 (attenuation / least-privilege), #21 (audit delegation chain) from the agentic-auth capability matrix. This is the **highest-leverage layer after ReBAC** — it answers *"who is asking, with whose borrowed authority, and constrained to what?"* + +**Current state (verified in code):** greenfield. No token-exchange grant, no `act` claim, audit has a single actor. `authorization.Principal.MaxScopes` exists as a "delegation ceiling" to build on. + +--- + +## 1. The problem + +An agent acts **as itself** **on behalf of a user**, possibly delegated through one or more apps. Three things must be true on every downstream call: + +1. **Both identities travel together** — the resource server and audit must see *agent X acting for user Y*. +2. **The agent gets least privilege** — a token downscoped to the task, never the user's full power. +3. **The full chain is recorded** — `app → agent → user` is queryable in audit. + +RFC 8693 (OAuth 2.0 Token Exchange) is the standard mechanism. The `act` claim carries the chain. + +--- + +## 2. The `act` claim — the heart of the design + +A task-scoped token minted for the agent: + +```jsonc +{ + "sub": "user:alice", // whose authority is exercised + "aud": "https://calendar.example", // bound target (RFC 8707) + "scope": "calendar:read", // attenuated, not alice's full scope + "exp": "", // short-lived + "act": { // who is acting + "sub": "agent:booking-bot", + "act": { // nested: who delegated to the agent + "sub": "app:concierge" + } + } +} +``` + +- `sub` stays the **user** (authority source); `act.sub` is the **immediate actor**; nested `act` encodes multi-hop delegation. +- **`act` becomes a reserved claim** (alongside `roles`, `scope`, …) in `internal/token/auth_token.go` so `CustomAccessTokenScript` cannot forge it. + +--- + +## 3. Token exchange endpoint (#13) + +Extend `POST /oauth/token` with `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`. + +| Param | Meaning | +|---|---| +| `subject_token` (+ `_type`) | the user's token (authority being exercised) | +| `actor_token` (+ `_type`) | the agent's token (the actor) — present ⇒ **delegation** | +| `requested_token_type` | usually `urn:ietf:params:oauth:token-type:access_token` | +| `resource` / `audience` | RFC 8707 target binding (**required** for MCP) | +| `scope` | requested (down)scope | + +**Two semantics:** +- **Delegation** (`actor_token` present): mint composite token with `sub=user`, `act` chain. Default agent path. +- **Impersonation** (no `actor_token`): actor fully becomes the subject. **Admin-gated, always audited.** Used sparingly (support tooling), not the agent path. + +**Flow:** +``` +1. validate subject_token + actor_token (sig, exp, aud, not revoked) +2. resolve actor's delegation ceiling (agent service-account MaxScopes) +3. attenuated_scope = intersection(subject_effective, requested_scope, actor_ceiling) ← §4 +4. mint access_token: sub=subject, act=chain, aud=resource, scope=attenuated, short exp +5. audit: actor=agent, on_behalf_of=user, chain ← §5 +``` + +--- + +## 4. Attenuation — least privilege per task (#14) + +The exchanged token's authority is the **intersection** of three ceilings: + +``` +effective = subject_permissions ∩ requested_scope ∩ agent_delegation_ceiling +``` + +- **`subject_permissions`** — what the user actually has (scopes + FGA-derived). +- **`requested_scope`** — what the agent asked for (RAR can carry structured detail). +- **`agent_delegation_ceiling`** — `Principal.MaxScopes` on the agent's service account (the existing primitive). An agent can never exceed its registered ceiling even if the user is an admin. + +Further constraints stamped on the token: +- **Audience binding** (`aud` = `resource`, RFC 8707) — token works only at the named MCP server / API. +- **Short TTL** — 5 min default for agent tokens (matches roadmap 4.2). +- **(Later)** sender-constraint via DPoP (Phase 5.2). + +**FGA interplay:** object-level checks still run at the resource server as `Check(user:alice, relation, object, context={actor: agent, purpose})`. OpenFGA **Conditions** can additionally require the actor be a delegated agent / within a context window. Token attenuation is the *coarse* least-privilege gate; FGA is the *fine* one. Both apply. + +--- + +## 5. Audit delegation chain (#21) + +**Schema change (storage tax across all DBs).** Extend `schemas.AuditLog` and `audit.Event`: + +```go +// audit.Event — add: +OnBehalfOfID string // the subject (user) the actor acted for +OnBehalfOfType string // "user" | "agent" +DelegationChain string // serialized act chain, e.g. "app:concierge>agent:booking-bot>user:alice" +``` + +- Every action performed with an exchanged token logs **actor + on-behalf-of + chain**. +- Makes "what did `booking-bot` do for Alice last week?" and "every action delegated through `app:concierge`" queryable. +- Schema must be added to all 6 DB implementations + AutoMigrate / collection setup (same multi-DB pattern as any new field). + +--- + +## 6. End-to-end runtime flow + +``` +User ──OIDC──► Authorizer ──► user token (sub=alice) +App/agent ──token-exchange(subject=user, actor=agent, scope=calendar:read, resource=calendar)──► Authorizer +Authorizer ──► attenuated token (sub=alice, act=[app>agent], aud=calendar, scope=calendar:read, exp=5m) +Agent ──► MCP tool / Calendar API (presents attenuated token) +Resource server ──► validate aud+scope ──► FGA Check(alice, read, calendar:..., ctx{actor:agent}) +Authorizer audit ──► {actor: agent, on_behalf_of: alice, chain: app>agent>alice, action, resource} +User ──► dashboard: view / revoke active agent delegations (roadmap 4.3) +``` + +--- + +## 7. Decisions (LOCKED — principal-engineer calls) + +| # | Decision | Locked choice | +|---|---|---| +| DC1 | Delegation vs impersonation | **Both**; impersonation admin-gated + always audited | +| DC2 | Where the agent ceiling lives | Agent **service-account `MaxScopes`** + optional FGA tuples | +| DC3 | `act` claim format | **RFC 8693 nested `act`** (multi-hop chains) | +| DC4 | Token binding | **RFC 8707 `resource`/`aud` required** for exchanged/MCP tokens; DPoP later (Phase 5.2) | +| DC5 | Revocation | **Short TTL (5m) baseline + revocation-list-via-introspection for sensitive scopes** (see §7.1) | + +### 7.1 Revocation — the real design (not a footnote) +A two-tier model, reusing the **existing `/oauth/introspect` endpoint**: +- **Default (non-sensitive scopes):** rely on the **5-minute TTL**. Revoking a delegation stops *refresh*; in-flight access tokens expire within the window. Acceptable for the common case. +- **Sensitive scopes (operator-flagged, e.g. `payments:*`):** the exchanged token is marked `sensitive`, and **resource servers MUST call `/oauth/introspect`** before honoring it. Introspection checks a **revocation list** updated the instant a user revokes the delegation (dashboard / `revoke` mutation) → immediate effect, no TTL wait. +- **Distributed propagation:** the revocation list lives in `memory_store` (Redis/DB) so all nodes and the introspect endpoint see revocations consistently. Document the (sub-second) propagation window. + +This keeps the hot path cheap (short TTL, no introspection) while giving immediate revocation where it actually matters. + +**Prereq dependency (review fix #10):** this design sits on **agent identity + M2M/client-credentials** (roadmap Phase 2 / 4.2) and the **OpenFGA decision core** (FGA_OPENFGA_MIGRATION_PLAN.md). Do not start Wave 2 before those land. + +## 8. Phasing (verify-gated) + +1. **`act` claim + reserved-claim guard** → verify: minted token carries `act`; custom script cannot overwrite it. +2. **Token-exchange grant (delegation only)** → verify: subject+actor tokens produce composite token with correct `act` + intersected scope + bound `aud`. +3. **Attenuation via agent `MaxScopes`** → verify: agent cannot exceed its ceiling even with an admin subject token. +4. **Audit chain fields (all DBs)** → verify: exchanged-token action logs actor + on_behalf_of + chain; `make test-all-db` green. +5. **Impersonation (admin-gated) + dashboard revocation** → verify: impersonation requires admin + audits; user can revoke an active delegation. + +**Dependency:** sits on top of the OpenFGA decision core (FGA_OPENFGA_MIGRATION_PLAN.md) and the M2M/service-account + agent-identity work (roadmap 2.2 / 4.2). diff --git a/specs/ENTERPRISE_AUTHZ_MODEL.md b/specs/ENTERPRISE_AUTHZ_MODEL.md new file mode 100644 index 0000000..36faa64 --- /dev/null +++ b/specs/ENTERPRISE_AUTHZ_MODEL.md @@ -0,0 +1,173 @@ +# Enterprise Authorization Model (OpenFGA ReBAC) + +How an enterprise expresses **fine-grained permissions on resources by role**, plus **one-off user-specific grants**, plus **user-specific exceptions/denials** — and how all of them mix in a single check. + +--- + +## The mental model: every check is `UNION of grants − exclusions` + +Four ways a user gets (or loses) access, all evaluated together: + +| Layer | Mechanism | Example | +|---|---|---| +| **1. Role-based** (broad) | Assign a *role* to a relation → everyone with the role inherits | "all `editor`s can edit project docs" | +| **2. Structural** (hierarchy) | Membership + inheritance org→team→project→doc | "engineering team members can view phoenix docs" | +| **3. User-specific grant** (additive) | Direct tuple to one user | "Frank gets view on this one budget" | +| **4. User-specific exception** (subtractive) | `blocked` relation + `but not` | "Erin is blocked from this sensitive doc despite team membership" | + +A `Check` resolves to: **(role grants ∪ structural grants ∪ direct grants) − blocked**. That intersection/union/exclusion *is* "all the mix-matches." + +--- + +## The authorization model (DSL — authored once) + +```dsl +model + schema 1.1 + +type user + +# Roles are first-class objects so they can be assigned to any relation (RBAC-in-ReBAC) +type role + relations + define assignee: [user] + +type organization + relations + define admin: [user, role#assignee] + define member: [user, role#assignee] or admin + +type team + relations + define org: [organization] + define member: [user, team#member] or admin from org # org admins are implicitly team members + +type project + relations + define team: [team] + define lead: [user, role#assignee] + define editor: [user, role#assignee] or lead or member from team + define viewer: [user, role#assignee] or editor + +type document + relations + define project: [project] + define owner: [user] + define blocked: [user] # user-specific exception + define editor: [user, role#assignee] or owner or editor from project + define viewer: [user, role#assignee] or editor or viewer from project + define can_edit: editor but not blocked # effective permission + define can_view: viewer but not blocked +``` + +Key constructs that deliver each layer: +- `[role#assignee]` → **role-based** grants (assign a whole role to a relation). +- `[user]` → **user-specific** one-off grants. +- `X from Y` → **hierarchical inheritance** (e.g., `editor from project`). +- `but not blocked` → **user-specific exception/deny** override. + +--- + +## Worked scenario: Acme Corp + +**Structure:** `org:acme` → `team:engineering`, `team:finance` → `project:phoenix` (eng), `project:ledger` (finance) → `doc:design-spec` (phoenix), `doc:q4-budget` (ledger). +**Roles:** `role:org-admin`, `role:editor`, `role:auditor`. + +### Relationship tuples (the data — written at runtime) + +```text +# --- roles & org --- +role:org-admin#assignee @ user:alice +organization:acme#admin @ role:org-admin#assignee # role grants org admin +team:engineering#org @ organization:acme +team:finance#org @ organization:acme + +# --- structural membership --- +team:engineering#member @ user:bob +team:engineering#member @ user:erin +team:finance#member @ user:carol + +# --- project wiring + role-based grant --- +project:phoenix#team @ team:engineering +project:ledger#team @ team:finance +project:phoenix#editor @ role:editor#assignee # role:editor → edit phoenix +role:editor#assignee @ user:bob +project:ledger#viewer @ role:auditor#assignee # role:auditor → view ledger +role:auditor#assignee @ user:dave + +# --- documents --- +document:design-spec#project @ project:phoenix +document:q4-budget#project @ project:ledger + +# --- user-specific overrides --- +document:design-spec#blocked @ user:erin # exception: deny (layer 4) +document:q4-budget#viewer @ user:frank # odd one-off grant (layer 3) +``` + +### Resulting access (Check results) + +| User | Why | `can_view design-spec` | `can_edit design-spec` | `can_view q4-budget` | +|---|---|---|---|---| +| **alice** | org-admin role → admin → (member→editor→) inherits everywhere | ✅ | ✅ | ✅ | +| **bob** | `role:editor` → project editor → doc editor | ✅ | ✅ | ❌ | +| **erin** | eng member → would inherit viewer **but blocked** | ❌ *(exception)* | ❌ *(exception)* | ❌ | +| **frank** | not a member; **direct one-off** viewer on budget only | ❌ | ❌ | ✅ *(one-off)* | +| **dave** | `role:auditor` → view ledger only (read) | ❌ | ❌ | ✅ *(role, read-only)* | +| **carol** | finance member; no path to eng docs | ❌ | ❌ | ✅ | + +### The mix-match, on one resource + +`doc:design-spec` effective **can_edit** set = + +``` + { alice } # role-based (org-admin) + structural inheritance +∪ { bob } # role-based (role:editor on project) +∪ { } # role/structural +∪ { } # user-specific grant +− { erin } # user-specific exception (but not blocked) +``` + +One check, four layers, resolved by the engine. Adding a contractor for a day = one tuple; revoking = delete it. No role explosion, no policy rewrite. + +--- + +## ABAC / conditional twist (optional) + +Need "editors may edit **only during business hours**" or "**only from corp network**"? Attach an OpenFGA **Condition**: + +```dsl +condition in_business_hours(now: timestamp) { + now.Hours >= 9 && now.Hours < 18 +} +type document + relations + define editor: [user, role#assignee with in_business_hours] or ... +``` + +Context (`now`, IP, purpose) is passed at check time — no separate ABAC engine. + +--- + +## How the enterprise drives this (Dashboard + API) + +| Task | Dashboard | API | +|---|---|---| +| Define the model | **Authorization Model** page (DSL editor) | `_fga_write_model` | +| Assign a role to a user | "Assign role" (writes `role:X#assignee@user:Y`) | `_fga_write_tuples` | +| Grant role broad access on a resource type | Model relation `[role#assignee]` + tuple | `_fga_write_tuples` | +| One-off share to a user | "Share resource" | `_fga_write_tuples` | +| Block/except a user | "Block user" (writes `#blocked`) | `_fga_write_tuples` | +| Ask "can X do Y on Z?" | **Access Tester** | `fga_check` | +| "What can X see?" (UI / RAG) | — | `fga_list_objects` | +| "Who can see Z?" | resource access panel | `expand` | + +--- + +## Why this is the enterprise sweet spot + +- **Roles still feel like roles** (assign a role, broad access follows) — admins keep the mental model they know. +- **One-off exceptions don't break the model** — additive grants and `but not` exclusions are just tuples. +- **Hierarchy is automatic** — grant at org/team/project, resources inherit. +- **Reverse queries** (`list_objects`) power both AI retrieval and "show me everything I can access" UIs. +- **Auditable & revocable** — every grant is a tuple with a clear provenance; revoke = delete. +``` diff --git a/specs/FGA_IMPLEMENTATION_AGENTS.md b/specs/FGA_IMPLEMENTATION_AGENTS.md new file mode 100644 index 0000000..68dfdfa --- /dev/null +++ b/specs/FGA_IMPLEMENTATION_AGENTS.md @@ -0,0 +1,80 @@ +# FGA → OpenFGA + Agentic Auth: Agent Fleet & Execution Plan + +How a small fleet of specialized agents carries the OpenFGA migration and agentic-auth program forward **to standard, with verification at every gate**. This is the orchestration layer over the design docs. + +**Source-of-truth docs (agents MUST read before acting):** +- `FGA_OPENFGA_MIGRATION_PLAN.md` — phased plan + LOCKED decisions (D1–D4, store, why-logs) +- `AGENTIC_DELEGATION_DESIGN.md` — Wave 2 delegation (DC1–DC5) +- `ENTERPRISE_AUTHZ_MODEL.md` — the OpenFGA model + Acme worked example +- `ROADMAP_V2.md` — Agentic Authorization Track (4 waves) + +--- + +## The fleet + +| Agent | Model | Owns | Reads | Produces | +|---|---|---|---|---| +| **authz-researcher** | opus | Deep, adversarially-verified research on OpenFGA, Zanzibar, Auth0 parity, and standards (RFC 8693/8707/9728, MCP, CIBA, AuthZEN) | the web + design docs | cited research briefs that gate design choices | +| **fga-engineer** | opus | Wave 1 — engine SPI, embedded/external OpenFGA, model/tuple/check APIs, GraphQL `_fga_*`/`fga_*`, session/validate changes, dashboard wiring, SDK FGA surface | migration plan, openfga-modeling skill | working code + tests, one verified phase at a time | +| **delegation-engineer** | opus | Wave 2 — RFC 8693 token exchange, `act` claim, attenuation, audit delegation chain | delegation design, agentic-auth-standards skill | working code + tests, security-first | + +**Existing agents reused (do not duplicate):** +- `principal-engineer` — owns any change touching >1 subsystem; the default driver if a program agent isn't a better fit. +- `security-engineer` — **mandatory second pass** on every FGA/delegation PR (auth-sensitive). +- `doc-writer` — migration guide, API docs, Auth0-import guide. + +**Domain skills (auto-load on matching work):** +- `openfga-modeling` — DSL/tuple patterns, enterprise model, the **verified embed API**, check/list_objects, conditions. +- `agentic-auth-standards` — the standards + the LOCKED decisions, so no agent re-litigates them. + +--- + +## Execution waves (each gated by `Verify`) + +### Wave 0 — Research & validate *(authz-researcher)* +- Confirm current OpenFGA embed API + SQLite/Postgres datastore bootstrap (the one remaining spike step). +- Track standards deltas (MCP spec, ID-JAG draft status, AuthZEN). +- → **Verify:** every claim feeding a design decision is cited and adversarially checked. Spike code compiles & runs. + +### Wave 1 — Decision core *(fga-engineer → security-engineer)* +Follows `FGA_OPENFGA_MIGRATION_PLAN.md` Phases 1→7. The old engine was removed outright (never rolled out). Authorizer embeds OpenFGA in-process — it IS the engine; FGA is enabled by configuring a store (`--fga-store`). No engine-selector or external-service flags. +- → **Verify per phase:** the phase's own Verify gate + `go build ./...` + `make test-all-db` (proves no DB-impl dangling refs) + security-engineer review. + +### Wave 2 — Delegation core *(delegation-engineer → security-engineer)* +Follows `AGENTIC_DELEGATION_DESIGN.md`. **Prereq:** agent identity + M2M (roadmap Phase 2). Do not start before it lands. +- → **Verify:** `act` chain correct, attenuation can't exceed ceiling, revocation works for sensitive scopes, audit chain queryable, security review. + +### Waves 3–4 — Async/custody + Enterprise hardening +CIBA+RAR, Token Vault, MCP/ID-JAG, JIT/guardrails. New research brief per capability before build. + +--- + +## Handoff protocol (research → implement → review) + +1. **Research first.** No engineer agent starts a capability without a current `authz-researcher` brief (or a cited section in the design docs). "Don't assume" is enforced here. +2. **Implement to the plan.** Engineer agents follow the phased plan + locked decisions verbatim. Deviation requires an explicit, written rationale appended to the relevant design doc — not a silent change. +3. **Verify before claiming done.** Build + tests + the phase's Verify gate. No "should work." +4. **Security review is mandatory**, not optional, for every FGA/token/delegation PR (`security-engineer`). +5. **Docs follow** (`doc-writer`) once an API is frozen. + +--- + +## Definition of Done (program standard — non-negotiable) + +A change is done only when ALL hold: +- [ ] Matches the LOCKED decisions (or amends the design doc with rationale). +- [ ] `go build ./...` green; `make generate-graphql` run if schema changed. +- [ ] Tests written and passing; `make test-all-db` green for storage-touching changes. +- [ ] Auth-sensitive code reviewed by `security-engineer`. +- [ ] Principal pinned to token `sub` on every `fga_*` runtime check; admin-gating on `_fga_*` and model edits; fail-closed on engine error. +- [ ] No secrets in logs; no stale-allow cache path (cache only in embedded mode). +- [ ] Verified against the phase's `Verify` gate with real output, not assertion. + +--- + +## Guardrails the agents must respect (lessons already paid for) +- **FGA store is SQL-only** (SQLite single-node / external Postgres for HA). Do not attempt Mongo/Dynamo adapters. +- **Don't double-cache** decisions in external mode. +- **`required_relations` is the new fine path; `roles` filter stays for coarse** — never force capabilities into ReBAC except via the singleton-object pattern. +- **Two-release removal** of the old engine, not big-bang. +- **`go.mod` probe passed** (openfga v1.17.1, sqlite→v1.51.0 compiles clean) — but re-run `make test-sqlite` on the integration branch. diff --git a/specs/FGA_OPENFGA_MIGRATION_PLAN.md b/specs/FGA_OPENFGA_MIGRATION_PLAN.md new file mode 100644 index 0000000..ced8ff6 --- /dev/null +++ b/specs/FGA_OPENFGA_MIGRATION_PLAN.md @@ -0,0 +1,208 @@ +# Migration Plan: Replace Bespoke FGA with OpenFGA + +**Status:** Decisions locked · Phase 0 PASSED · Phases 1–4 DONE on branch `feat/fga-engine-spi` (uncommitted) · **Precondition:** FGA is pre-stable (no GA compat guarantee) · **Type:** Breaking, full replacement + +> **Implementation status:** Old FGA (Resource/Scope/Policy/Permission, #607/#610/#611) **fully removed** (never rolled out). New OpenFGA engine seam (Phase 1), GraphQL `_fga_*`/`fga_*` API + engine routed into request paths (Phase 3), and `required_relations` on session/validate (Phase 4) all **DONE** — `go build ./...` green, FGA + integration tests pass, proof-grep clean, principal-pinning + nil-engine handling tested. **Remaining:** dashboard FGA pages (Phase 5, done), SDK cleanup in the separate `authorizer-go`/`authorizer-js` repos (Phase 6), Auth0 import tool + docs (Phase 7). +> +> **Config simplification (supersedes D2 + the deployment-mode external-service framing below):** Authorizer **embeds OpenFGA in-process — it IS the engine.** The `--authorization-engine`, `--fga-mode`, and `--fga-external-url` flags were **removed**, along with three dead old-engine flags (`--authorization-cache-ttl`, `--include-permissions-in-token`, `--authorization-log-all-checks`). +> +> **FGA reuses the main database by default.** When `--database-type` is `sqlite`/`postgres`/`mysql`/`mariadb`, FGA derives its store from the existing `--database-url` automatically — **no extra flags** (OpenFGA tables live in the main DB, like the old engine; migrations run on boot, goose-locked → HA-safe). `--fga-store` (+ `--fga-store-url`) is an **override**, required only when the main DB is not OpenFGA-compatible (mongodb, dynamodb, cassandra, couchbase, arangodb, sqlserver) or to use a dedicated store. Resolved by `config.FGAStoreConfig()` (unit-tested). The driver-conflict known-issue below is **resolved** (Option B: GORM standardized on `modernc.org/sqlite`), which is what makes same-DB reuse possible. + +> ✅ **RESOLVED — SQLite driver double-registration (Option B).** Linking OpenFGA's SQL datastores (`modernc.org/sqlite`) alongside Authorizer's old GORM SQLite driver (`glebarez/go-sqlite`) panicked at startup (`sql: Register called twice for driver sqlite`, since both register the name `sqlite`). Fixed by standardizing GORM on `modernc.org/sqlite` via a local dialect (`internal/storage/db/sql/sqlitedialect/`, a near-verbatim copy of glebarez's MIT dialect with the driver import swapped) and dropping `glebarez/*`. The `fga_sql` build tag was removed — OpenFGA's SQL datastores are now in the **default build**. Verified: default binary starts with no panic; embedded SQLite FGA runs in-process alongside GORM SQLite; full SQLite suite green. Pure-Go, no CGO. *(Maintenance note: the vendored dialect is now Authorizer-maintained — apply modernc/dialect updates manually.)* + +> **Phase 0 spike result (FULLY validated, not assumed):** `openfga v1.17.1` embeds in-process; DSL→model→tuples→`Check`→`ListObjects` work incl. `but not` exclusion. **Persistent SQLite datastore verified** — data written by one process is read back by a separate fresh process (persistence across restart proven); bootstrap = `migrate.RunMigrations(...)` then `sqlite.New(uri, sqlcommon.NewConfig())` (pure-Go, no CGO). `go.mod` integration probe **passed** (whole authorizer tree builds with openfga added, `modernc.org/sqlite`→v1.51.0). Binary +~36–38M. See `openfga-modeling` skill for the verified bootstrap recipe + gotchas. **No open Phase 0 risks.** + +--- + +## 0. Decisions (LOCKED — principal-engineer calls) + +| # | Decision | Locked choice | Note | +|---|---|---|---| +| **D1** | Multi-DB / FGA store | **Decouple from main DB.** Embedded **SQLite = single-node/dev only**; **external Postgres/MySQL required for HA/multi-replica.** No custom datastore adapters for Mongo/Dynamo/etc. | ⚠️ SQLite cannot back multi-writer. **Honest cost: any HA install runs a second datastore** — the real price of the multi-DB moat for FGA. | +| **D2** | Deployment | **Both, configurable.** Embedded default (single-binary for dev/single-node) + **external OpenFGA first-class** (HA/distributed/cloud-native). | — | +| **D3** | Permissions API | **Replace `required_permissions` → `required_relations`.** Keep existing `roles`/`scope` filters for coarse gating; document **singleton-object pattern** (`feature:x#reader`) for capability checks. | `roles` filter already covers coarse RBAC; no dead fields kept. | +| **D4** | Roles ↔ graph | **Dual: keep `roles` claim AND mirror role grants as tuples** (`role:x#assignee@user:y`), maintained by Authorizer on role assignment. | Required for `[role#assignee]` grants + `list_objects`. | +| **Store** | Multi-tenancy | **Single global OpenFGA store; isolate tenants in the graph** (namespaced object IDs + org-membership relations). Per-store only for data-residency isolation. | OpenFGA stores aren't built for thousands of tenants. | +| **Why** | Explainability | **OpenFGA `Expand` for "why"; log decision traces for denied sensitive checks.** No custom explainer. | Compliance requirement. | + +**Model-change governance:** the authorization model (DSL) is extremely powerful (one edit re-grants broadly) → admin-gated, audited, and staged (write new model version, validate, then activate). + +**What is NOT removed:** core auth — login, OAuth2/OIDC, token issuance, sessions, **roles** (`roles`/`allowed_roles` claims), **OAuth scopes**, and the **custom-token-script hook**. Only the FGA Resource/Scope/Policy/Permission subsystem is removed. + +> ⚠️ **Two different "scope" concepts — do not conflate.** (a) **OAuth `scope`** (the `scope` claim, `SessionQueryRequest.scope`, consent) is core OIDC — **untouched**. (b) The FGA **`Scope` entity** (resource scope) is what we delete. The removal inventory in §1 refers only to (b). + +### Auth0 authz layers — what coexists with OpenFGA +OpenFGA covers only the ReBAC layer. The full Auth0-parity stack is layered; these layers are **retained and integrated**, not replaced: + +| Auth0 layer | Covered by | Plan action | +|---|---|---| +| OAuth/OIDC scopes (API gate) | OAuth scopes (existing) | Keep; checked **before** FGA (cheap coarse gate) | +| RBAC roles → token | roles claims (existing) | Keep; bridge to graph per D4 | +| ReBAC (object access) | **OpenFGA (new)** | This plan | +| Actions/Rules (token pipeline) | `CustomAccessTokenScript` (existing) | Keep; expose `engine.Check`/`ListObjects` to it | +| ABAC / conditional | **OpenFGA Conditions + contextual tuples** | Fold in — no separate ABAC engine | +| Organizations / B2B | tuples (`org:X#member@user:Y`) + `org_id` claim | Model membership as tuples | +| CIBA / Token Vault / RFC 8693 delegation | — | **Out of scope** (next agent-auth layer; FGA authorizes, doesn't implement) | + +**Enforcement order at runtime:** OAuth scope check → FGA `Check`. Both must pass. + +--- + +## 1. Removal inventory (what "completely remove" deletes) + +Verifiable by `grep` returning zero hits post-removal (excluding OpenFGA-new code). + +**Backend — storage** +- `internal/storage/schemas/`: `resource.go`, `scope.go`, `policy.go`, `permission.go` (Resource, Scope, Policy, PolicyTarget, Permission, PermissionScope, PermissionPolicy, and the `*WithPolicies`/`*View` denorm types) +- Provider interface methods in `internal/storage/provider.go`: all `*Resource`, `*Scope`, `*Policy`, `*PolicyTarget`, `*Permission`, `*PermissionScope`, `*PermissionPolicy`, `GetPermissionsForResourceScope` +- All 6 DB implementations of the above: `db/sql/`, `db/mongodb/`, `db/arangodb/`, `db/cassandradb/`, `db/couchbase/`, `db/dynamodb/` (`resource.go`, `scope.go`, `policy.go`, `permission.go` in each) +- AutoMigrate / collection-creation entries for those schemas in each `db/*/provider.go` +- `schemas/model.go` `CollectionList` entries for those collections + +**Backend — engine** +- `internal/authorization/` evaluator logic for resource/scope/policy (`evaluator.go`, parts of `cache.go`) — **repurposed**, not all deleted (the `Provider` interface + cache plumbing is reused by the new engine; see §3) + +**Backend — GraphQL** +- `internal/graph/schema.graphqls`: types `AuthzResource(s)`, `AuthzScope(s)`, `AuthzPolicy/Target/Policies`, `AuthzPermission(s)`, `Permission`, `PermissionInput`; inputs `Add/Update Resource/Scope/Policy/Permission`, `PolicyTargetInput`; mutations `_authz_add/update/delete_{resource,scope,policy,permission}`; queries `_authz_{resources,scopes,policies,permissions}`, `permissions` +- `internal/graphql/`: `authz_*.go` (16 files), `permission_check.go`, `permissions.go` + +**Dashboard** +- `web/dashboard/src/pages/authorization/`: `Resources.tsx`, `Scopes.tsx`, `Policies.tsx`, `Permissions.tsx` +- Authz entries in `graphql/mutation/index.ts`, `graphql/queries/index.ts`, `types.ts` + +**SDKs** +- `authorizer-go`: `PermissionInput{Resource,Scope}`, `RequiredPermissions` fields on `get_session.go`, `validate_jwt_token.go`, `validate_session.go` +- `authorizer-js`: `PermissionInput`, `Permission`, `required_permissions` on session/validate types + +--- + +## 2. Target architecture + +``` +Relying app / AI agent / MCP client + │ check(user, relation, object) · list_objects · batch_check + ▼ +Authorizer GraphQL ──► AuthorizationEngine (OpenFGA) + _fga_* admin ├─ embedded openfga lib (default) ┐ + fga_check / list └─ external openfga (gRPC, config) ┘ + │ │ + validate_jwt_token / validate_session / session + (required_permissions → relation checks) + ▼ + FGA tuple store (SQLite | Postgres | MySQL) +``` + +- **Model**: OpenFGA authorization model (DSL) — types, relations, conditions. +- **Data**: relationship tuples `(object, relation, user)`. +- **Decision**: `Check`; **retrieval**: `ListObjects` (RAG pre-filter), `BatchCheck`. + +### 2.1 Deployment modes (single-node / HA / serverless) +The FGA store is the deciding factor. Same engine, different backing. + +| Mode | Engine | FGA store | Migrations | Notes | +|---|---|---|---|---| +| **Single-node / dev** | embedded | **SQLite file** | on boot (idempotent) OK | one process only; WAL sidecar files on local disk | +| **HA / multi-replica** | embedded *or* external | **external Postgres/MySQL** | **separate init job** | SQLite cannot back multiple writers | +| **Serverless** (Lambda/Cloud Run-scale/Vercel/Fly) | **external OpenFGA service preferred** (or embedded engine → external SQL) | **external managed Postgres/MySQL behind a connection pooler** | **separate init job; NEVER on cold start** | see rules below | + +**Serverless rules (review-derived — embedded-SQLite is NOT serverless-compatible):** +- ❌ **No embedded SQLite** — ephemeral, non-shared disk; N concurrent instances can't share one file. Serverless ⇒ external SQL store, same constraint as HA but stricter. +- ❌ **No migrate-on-cold-start** — run `migrate.RunMigrations` as a deploy/init job; concurrent cold starts must not race migrations and must not pay init latency. +- ⚠️ **Connection pooling required** — per-instance pgx pools × many instances = connection explosion. Front Postgres with pgbouncer / RDS Proxy / provider pooling. +- ⚠️ **Flush before response on freeze platforms (Lambda)** — OpenFGA workers and Authorizer's fire-and-forget audit goroutine (incl. the delegation audit chain) may be frozen post-response; ensure audit writes complete before returning, or enqueue. +- ✅ **External `memory_store`** (Redis/DB) already serverless-ready — used for sessions, the FGA decision cache (embedded mode only), and the delegation revocation list. +- ✅ **Don't cache FGA decisions in external mode** (locked rule) — so no stale-allow across ephemeral instances. +- **Platform fit:** Cloud Run/Fly (process alive while warm) tolerate the embedded engine + external store; Lambda/Vercel (freeze-based) prefer the **external OpenFGA service** so the function binary stays lean and no in-process engine state depends on the frozen runtime. + +--- + +## 3. Phased plan (goal-driven; each phase has a verify gate) + +### Phase 1 — Engine seam + OpenFGA embed +1. Add `internal/authorization/engine` interface: `Check(ctx, user, relation, object, ctxTuples) (bool, error)`, `ListObjects(ctx, user, relation, type) ([]string, error)`, `BatchCheck`, `WriteTuples`, `DeleteTuples`, `ReadTuples`, `WriteModel`, `ReadModel`. +2. Vendor `github.com/openfga/openfga` as an in-process library; implement `engine.openfga`. +3. Wire FGA store config: `--fga-store=sqlite|postgres|mysql`, `--fga-store-url`, `--fga-mode=embedded|external`, `--fga-external-url`. +4. Init in `cmd/root.go` after Storage/MemoryStore. + +→ **Verify:** unit test writes a model + tuples, `Check` returns expected allow/deny against the embedded store; server boots with `--fga-mode=embedded` on a Mongo main DB. + +### Phase 2 — Remove bespoke storage + engine *(deferred one release — review fix #4)* +**Do not big-bang.** Ship Phase 1's SPI with **both** engines for one release: OpenFGA default, old `policy` engine still selectable (`--authorization-engine=fga|policy`). Validate FGA in production, *then* remove the old engine in the following release. The "completely remove" directive still holds — just one release later, behind the seam that exists precisely to de-risk this. +1. (Release N) Both engines live behind SPI; default `fga`. +2. (Release N+1) Delete schemas, provider methods, and all 6 DB impls per §1; remove AutoMigrate / collection entries; delete dead `evaluator.go` resource/scope/policy paths (keep cache plumbing reused by the new engine). + +→ **Verify:** N: both engines pass integration tests. N+1: `grep -r "Resource\|Scope\|Policy\|Permission" internal/storage` returns only unrelated hits; `go build ./...` green; `make test-all-db` green. + +### Phase 3 — GraphQL API replacement (breaking) +1. Remove all `_authz_*` + `permissions` + `Permission*` schema/resolvers. +2. Add admin model/tuple management (admin-gated, `_` prefix): + - `_fga_write_model(params): FgaModel!` / `_fga_get_model: FgaModel!` + - `_fga_write_tuples(params: [FgaTupleInput!]!): Response!` + - `_fga_delete_tuples(params: [FgaTupleInput!]!): Response!` + - `_fga_read_tuples(params: FgaReadInput!): FgaTuples!` +3. Add runtime check API (authenticated, principal = `user:`): + - `fga_check(params: FgaCheckInput!): FgaCheckResponse!` + - `fga_list_objects(params: FgaListObjectsInput!): FgaObjects!` + - `fga_batch_check(params: [FgaCheckInput!]!): [FgaCheckResponse!]!` + - `FgaCheckInput { object: String!, relation: String!, context: Map }` +4. `make generate-graphql`. + +→ **Verify:** integration test: admin writes model+tuples via GraphQL, authenticated user gets correct `fga_check`/`fga_list_objects` results; old `_authz_*` ops return "unknown field". + +### Phase 4 — session / validate_session / validate_jwt (the API the user flagged) *(D3 — review fix #2)* +**Coarse vs fine are different questions — don't force capabilities into ReBAC.** +- **Coarse gating stays** on the existing `roles` (and `scope`) filters — pure "user must have role/scope X" needs no graph. +- **Fine gating is new:** replace `required_permissions` with `required_relations: [{object, relation}]` (AND, OpenFGA `Check` with `user:`). +- **Capability-style checks** (e.g., "can read reports at all") use the **singleton-object pattern** — model `feature:reports#reader` and check `{object: "feature:reports", relation: "reader"}`. Documented, not awkward. +1. Schema: remove `required_permissions: [PermissionInput!]`; add `required_relations: [FgaRelationCheck!]` on `SessionQueryRequest`, `ValidateJWTTokenRequest`, `ValidateSessionRequest`. Keep `roles`/`scope` filters untouched. +2. Replace `enforceRequiredPermissions` → `enforceRequiredRelations`: loop `engine.Check(user, rel.relation, rel.object)`, AND semantics, fail-closed, keep metrics/labels shape. +3. Update `session.go`, `validate_jwt_token.go`, `validate_session.go` call sites. + +→ **Verify:** `validate_session` with a satisfied relation → authorized; unsatisfied → `unauthorized`; empty list still authorizes; existing `roles` filter still gates coarsely. + +### Phase 5 — Dashboard +Replace `pages/authorization/{Resources,Scopes,Policies,Permissions}.tsx` with: +1. **Authorization Model** page — DSL editor (textarea + validate-on-save via `_fga_write_model`), shows current model + version. +2. **Relationship Tuples** page — table + add/delete (`object`, `relation`, `user`), backed by `_fga_read/write/delete_tuples`. +3. **Access Tester** page — form (`user`, `relation`, `object`) → calls `fga_check`, shows allow/deny + (optionally) `expand`. +4. Update `graphql/mutation|queries/index.ts`, `types.ts`, route stays `authorization/*`. + +→ **Verify:** `make build-dashboard` green; manual: define model, add tuple, tester returns allow; remove tuple, tester returns deny. + +### Phase 6 — SDKs (client-facing surface only, per agreed scope — no admin CRUD) +**authorizer-go** and **authorizer-js**: +1. Remove `PermissionInput`/`Permission`/`required_permissions`. +2. Add `required_relations` to `GetSession`/`ValidateJWTToken`/`ValidateSession` params. +3. Add client methods: `FgaCheck`, `FgaListObjects`, `FgaBatchCheck` (+ a thin `FgaRetriever`-style helper for RAG pre-filtering in each). + +→ **Verify:** SDK unit tests against a running server: `FgaCheck` allow/deny correct; `FgaListObjects` returns expected IDs; `ValidateSession` honors `required_relations`. + +### Phase 7 — Auth0 import tool + Docs *(review fix #7 — the migration value, actually built)* +1. **Auth0 FGA → Authorizer import** CLI/endpoint: ingest an Auth0/OpenFGA **model (DSL)** and **tuple export** → write via `_fga_write_model` / `_fga_write_tuples`. This is the headline migration deliverable; without it "ports 1:1" is just a claim. +2. `MIGRATION.md`: breaking-change notes + Auth0-FGA→Authorizer mapping; singleton-object pattern guide for coarse checks. +3. Document `--fga-*` flags, **SQL-store requirement (single-node SQLite vs HA external Postgres/MySQL — D1)**, embedded vs external. +4. Update `ROADMAP_V2.md`: FGA = OpenFGA ReBAC. + +→ **Verify:** a real Auth0 FGA export imports and `fga_check` reproduces the same decisions; a follower can stand up FGA from the guide alone. + +--- + +## 4. Cross-cutting + +- **Audit/metrics:** mirror existing `RecordAuthzCheck` / required-permissions metric shape for the new check + relation paths (low-cardinality labels). +- **Cache (review fix #5 — don't double-cache):** **only cache in embedded mode**, where Authorizer intercepts every tuple/model write and can invalidate; key on `(user, relation, object, model_version)`. In **external mode, do NOT layer Authorizer's cache** — tuples can be written directly to OpenFGA, so rely on OpenFGA's own consistency/caching. A second cache there = stale-allow bug. +- **`list_objects` cost (review fix #9):** expensive + an enumeration surface → mandatory pagination, result cap, latency budget, and rate-limit/DoS guards on `fga_list_objects`. +- **Security:** `_fga_*` admin-gated (`IsSuperAdmin`); `fga_*` authenticated, principal pinned to token `sub` (never client-supplied user); fail-closed on engine error. **Model edits** admin-gated + audited + staged (write→validate→activate). +- **Test matrix:** `make test-all-db` must pass to prove removal left no DB-impl dangling refs, even though the FGA store itself is SQL-only. + +## 5. Ordering / rollout *(review fix #4 — seam, not big-bang)* +- **Release N:** Phase 1 (SPI + embed) + Phases 3/4 (new API) ship with **both engines** behind `--authorization-engine`, default `fga`. Old engine still selectable. +- **Release N+1:** Phase 2 removal once FGA is validated in production. +- **Then:** 5 (dashboard) + 6 (SDKs) once the API is frozen; 7 (import tool + docs). +- **Prereqs (review fix #10):** Wave-2 delegation depends on agent identity + M2M/client-credentials (roadmap Phase 2) and OAuth 2.1 AS / DCR (Phase 4.1) — not started here. + +## 6. Open risks +1. **D1 multi-DB** — the central tradeoff (above). +2. **Embedding maturity** — confirm `openfga` embeds cleanly as a lib (Phase 0 spike) vs. forcing external mode. +3. **Consistency window** — document OpenFGA consistency options for distributed deployments. +4. **SDK scope** — admin tuple CRUD intentionally excluded from SDKs (matches agreed client-facing-only scope). diff --git a/specs/fga-rebac-guide.md b/specs/fga-rebac-guide.md new file mode 100644 index 0000000..45a31d5 --- /dev/null +++ b/specs/fga-rebac-guide.md @@ -0,0 +1,164 @@ +# Authorizer ReBAC guide (OpenFGA) + +Authorizer's fine-grained authorization (FGA) embeds OpenFGA in-process and +models access as **relationships** between objects, not as flat per-user grants. +This guide covers the patterns that make ReBAC worth using — hierarchy and +inheritance — plus two things that are easy to get wrong: which kind of "role" +you are dealing with, and how to identify subjects. + +## 1. Two kinds of "role" — they can and should differ + +| | Authorizer (app) roles | FGA roles | +|---|---|---| +| What | Configured via `--roles`; carried in the JWT `roles` claim. Read in the dashboard via the admin-only `_admin_meta` query. | Relations in the model (`editor`) and `role:` objects (`role:editor`). | +| Scope | Global, coarse, identity-level — "is this principal an admin at all". | Fine-grained, object-scoped — "editor **of** `resource:301`". | +| Lives in | The token. | The authorization graph (model + tuples). | + +They are **decoupled by design**. FGA roles are usually more granular than app +roles (a `viewer` *of one org*, an `editor` *of one document*), so an FGA role +name does **not** have to be one of your configured app roles. Forcing parity +would throw away ReBAC's main advantage. If you want a specific app role to be +globally assignable in the graph, *mirror it* as a tuple +(`role:admin#assignee@user:`) — don't equate the two sets. + +## 2. Always identify subjects by user **ID**, never by name + +A tuple's subject is `user:`. Use the user's **immutable id** (Authorizer's +user UUID), e.g.: + +``` +user:1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed ✅ stable, unique +user:alice ❌ names aren't unique and change +``` + +Display names and emails are not unique and can change; the user id is stable +for the lifetime of the account. The dashboard and docs use `user:` +placeholders (or short ids like `user:1b9d…`); in real tuples, use the full id. +The same applies to objects: identify orgs, projects and resources by their ids +(`organization:101`), never by name. The one exception is `role:` objects, +which are keyed by the role name by design (`role:editor#assignee`). + +(The engine does not hard-enforce an id format, because a subject can also be a +wildcard `user:*` or a userset like `group:9#member` / `role:editor#assignee`. +The id convention is a guideline, not a validation.) + +## 3. Hierarchy: grant once, inherit everywhere + +The canonical model is `organization → project → resource`, where each level +inherits viewer/editor from its parent via `X from parent`: + +```dsl +model + schema 1.1 +type user +type organization + relations + define admin: [user] + define editor: [user] or admin + define viewer: [user] or editor + define can_view: viewer + define can_edit: editor +type project + relations + define org: [organization] + define editor: [user] or editor from org + define viewer: [user] or editor or viewer from org + define can_view: viewer + define can_edit: editor +type resource + relations + define project: [project] + define editor: [user] or editor from project + define viewer: [user] or editor or viewer from project + define can_view: viewer + define can_edit: editor +``` + +The roles are **concentric** — `viewer: [user] or editor` means an editor is +automatically a viewer, so you never grant both. (This follows OpenFGA's +concentric-relationships guidance: owner ⊃ editor ⊃ viewer.) + +This is the **"Org → project → resource"** example in the dashboard model +builder (Step 1 → Advanced → Browse examples). + +### Wire up the structure once + +``` +organization:101 org project:201 # project belongs to org +project:201 project resource:301 # resource belongs to project +project:201 project resource:302 +``` + +### Grant once, high in the tree + +``` +user:1b9d… viewer organization:101 # one tuple +``` + +Now every check below inherits — **no per-resource tuples needed**: + +``` +Check(user:1b9d…, can_view, organization:101) → allowed +Check(user:1b9d…, can_view, project:201) → allowed (viewer from org) +Check(user:1b9d…, can_view, resource:301) → allowed (viewer from project ← from org) +Check(user:1b9d…, can_view, resource:302) → allowed +``` + +A viewer does **not** inherit edit: + +``` +Check(user:1b9d…, can_edit, resource:301) → denied +``` + +`ListObjects(user:1b9d…, can_view, "resource")` returns +`["resource:301", "resource:302"]` — the whole subtree, from one grant. + +## 4. Fine-grained grants coexist with the hierarchy + +Inheritance does not stop you from granting a single object directly. A direct +grant stays **scoped to that object**: + +``` +user:2c8e… editor resource:301 # one resource only +``` + +``` +Check(user:2c8e…, can_edit, resource:301) → allowed (direct grant) +Check(user:2c8e…, can_view, resource:301) → allowed (concentric: editor ⊃ viewer) +Check(user:2c8e…, can_edit, resource:302) → denied (does NOT leak to siblings) +Check(user:2c8e…, can_view, resource:302) → denied +``` + +So you compose **broad inherited access** (grant on the org/project) with +**narrow exceptions** (grant on a single resource) in the same model. + +## 5. What the save paths validate + +- **`_fga_write_model`** parses the DSL and runs OpenFGA's model-consistency + check (relations reference defined types, no illegal cycles). It does **not** + validate role names against app roles. +- **`_fga_write_tuples`** validates each tuple **against the active model** — the + object type must exist and the relation must be defined on it, with an allowed + user type. A tuple referencing an undefined relation/type is rejected. It does + **not** validate `role:` ids or `user:` against any external list. + +Both surfaces are super-admin gated and audited. The dashboard model builder +starts from a standard `admin / editor / viewer` set and offers the instance's +configured roles (read via `_admin_meta`) as optional one-click additions — FGA +roles are free to diverge from app roles (see §1). + +## Where this lives in the code + +- Engine: `internal/authorization/engine/openfga/` (`openfga.go` bootstrap + + `Config`, `operations.go` model/tuple/check). SPI in + `internal/authorization/engine/engine.go`. +- Hierarchy + fine-grained behaviour is covered by + `internal/authorization/engine/openfga/hierarchy_test.go`. +- Every shipped model (dashboard example catalog, editor placeholder, and the + DSL in this guide) is validated against the real embedded engine by + `internal/authorization/engine/openfga/examples_validation_test.go` — the + in-repo equivalent of `fga model validate`. +- Dashboard examples: `web/dashboard/src/pages/authorization/modelDsl.ts` + (`MODEL_EXAMPLES`), tested in `modelDsl.test.ts`. +- `_admin_meta` query: `internal/graphql/admin_meta.go` (super-admin gated), + tested in `internal/integration_tests/admin_meta_test.go`.