From a9c5b54b2e122887c83d2142355c06b605452321 Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Fri, 18 Sep 2026 15:40:50 +0100 Subject: [PATCH 1/8] feat(docs): generate Commands section of README from cobra metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a code generator (cmd/gendocs) that walks the cobra command tree and splices a fresh Commands section and table-of-contents block into README.md. Command descriptions, parameters, examples, and response samples are sourced directly from each command's Long, Example, and Annotations fields, making the cobra definition the single source of truth. A make generate-docs target runs the generator. A CI step ("Check README commands section is up to date") fails the build if the committed README doesn't match the generated output, so drift is caught on every PR. To populate the generated output, Long, Example, and docs:response / docs:response:lang annotation fields were added to all store, model, tuple, and query commands. docs:response:lang controls the fence language for non-JSON responses (yaml for store export, fga for model get and transform, text for model test). Commands are ordered alphabetically (cobra's Commands() order). Top-level runnables (version) are excluded from both the body and the TOC — they have no group heading. --- .github/workflows/main.yaml | 3 + Makefile | 6 +- README.md | 1429 +++++++++++++-------------------- cmd/gendocs/main.go | 40 + cmd/model/get.go | 23 +- cmd/model/list.go | 16 +- cmd/model/model.go | 2 +- cmd/model/test.go | 19 +- cmd/model/transform.go | 14 +- cmd/model/validate.go | 10 +- cmd/model/write.go | 5 + cmd/query/check.go | 8 +- cmd/query/expand.go | 25 +- cmd/query/list-objects.go | 9 +- cmd/query/list-relations.go | 9 +- cmd/query/list-users.go | 14 +- cmd/query/query.go | 2 +- cmd/root.go | 6 + cmd/store/create.go | 26 +- cmd/store/delete.go | 3 + cmd/store/export.go | 21 +- cmd/store/get.go | 8 + cmd/store/import.go | 3 + cmd/store/list.go | 11 + cmd/store/store.go | 2 +- cmd/tuple/changes.go | 25 +- cmd/tuple/delete.go | 15 + cmd/tuple/read.go | 14 + cmd/tuple/tuple.go | 2 +- cmd/tuple/write.go | 15 + internal/doc/generate.go | 202 +++++ internal/doc/generate_test.go | 388 +++++++++ 32 files changed, 1452 insertions(+), 923 deletions(-) create mode 100644 cmd/gendocs/main.go create mode 100644 internal/doc/generate.go create mode 100644 internal/doc/generate_test.go diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index e3b3b791..81ec9d94 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -69,6 +69,9 @@ jobs: - name: Run tests run: make test + - name: Check README commands section is up to date + run: make generate-docs && git diff --exit-code README.md + shellcheck: name: Shellcheck runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 12e959f1..99194b85 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,7 @@ $(MOCK_DIR)/client.go: $(GO_BIN)/mockgen $(MOCK_SRC_DIR) #----------------------------------------------------------------------------------------------------------------------- # Phony Rules(https://www.gnu.org/software/make/manual/html_node/Phony-Targets.html) #----------------------------------------------------------------------------------------------------------------------- -.PHONY: build run clean test lint audit format +.PHONY: build run clean test lint audit format generate-docs help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @@ -118,3 +118,7 @@ format: $(GO_BIN)/gofumpt ## Format Go source files generate-mocks: $(MOCK_DIR)/*.go ## Generate Go mocks @echo "==> Mocks generated" + +generate-docs: ## Regenerate the Commands section of README.md from cobra command metadata + @echo "==> Generating docs" + @go run ./cmd/gendocs diff --git a/README.md b/README.md index 888c3388..815e848d 100644 --- a/README.md +++ b/README.md @@ -18,39 +18,39 @@ A cross-platform CLI to interact with an OpenFGA server - [Usage](#usage) - [Configuration](#configuration) - [Custom Headers](#custom-headers) + - [Commands](#commands) - - [Stores](#stores) - - [List All Stores](#list-stores) - - [Create a Store](#create-store) - - [Import a Store](#import-store) - - [Export a Store](#export-store) - - [Get a Store](#get-store) - - [Delete a Store](#delete-store) + - [Manage JSON-to-tuple mappings](#manage-jsontotuple-mappings) + - [Scaffold a starter mapping file](#scaffold-a-starter-mapping-file) + - [Evaluate a mapping against JSON input and emit tuple operations](#evaluate-a-mapping-against-json-input-and-emit-tuple-operations) + - [Run the embedded tests in a mapping file](#run-the-embedded-tests-in-a-mapping-file) + - [Validate a mapping file](#validate-a-mapping-file) - [Authorization Models](#authorization-models) - - [Read Authorization Models](#read-authorization-models) - - [Write Authorization Model](#write-authorization-model) - [Read a Single Authorization Model](#read-a-single-authorization-model) - - [Read the Latest Authorization Model](#read-the-latest-authorization-model) - - [Validate an Authorization Model](#validate-an-authorization-model) - - [Run Tests on an Authorization Model](#run-tests-on-an-authorization-model) + - [Read Authorization Models](#read-authorization-models) + - [Test an Authorization Model](#test-an-authorization-model) - [Transform an Authorization Model](#transform-an-authorization-model) - - [Relationship Tuples](#relationship-tuples) - - [Read Relationship Tuple Changes (Watch)](#read-relationship-tuple-changes-watch) - - [Read Relationship Tuples](#read-relationship-tuples) - - [Write Relationship Tuples](#write-relationship-tuples) - - [Delete Relationship Tuples](#delete-relationship-tuples) + - [Validate Authorization Model](#validate-authorization-model) + - [Write Authorization Model](#write-authorization-model) - [Relationship Queries](#relationship-queries) - [Check](#check) - [Expand](#expand) - [List Objects](#list-objects) - [List Relations](#list-relations) - [List Users](#list-users) - - [Mapping](#mapping) - - [Validate a Mapping File](#validate-mapping) - - [Run Embedded Tests](#test-mapping) - - [Scaffold a Mapping File](#init-mapping) - - [Evaluate Against JSON Input](#run-mapping) - - [Sync to Store](#sync-mapping) + - [Stores](#stores) + - [Create Store](#create-store) + - [Delete Store](#delete-store) + - [Export Store Data](#export-store-data) + - [Get Store](#get-store) + - [Import Store Data](#import-store-data) + - [List Stores](#list-stores) + - [Relationship Tuples](#relationship-tuples) + - [Read Relationship Tuple Changes (Watch)](#read-relationship-tuple-changes-watch) + - [Delete Relationship Tuples](#delete-relationship-tuples) + - [Read Relationship Tuples](#read-relationship-tuples) + - [Create Relationship Tuples](#create-relationship-tuples) + - [Contributing](#contributing) - [License](#license) @@ -207,257 +207,169 @@ custom-headers: ### Commands -#### Stores -| Description | command | parameters | example | -|---------------------------------|----------|-----------------|----------------------------------------------------------| -| [Create a Store](#create-store) | `create` | `--name` | `fga store create --name="FGA Demo Store"` | -| [Import a Store](#import-store) | `import` | `--file` | `fga store import --file store.fga.yaml` | -| [Export a Store](#export-store) | `export` | `--store-id` | `fga store export --store-id=01H0H015178Y2V4CX10C2KGHF4` | -| [List Stores](#list-stores) | `list` | `--name` | `fga store list --name="FGA Demo Store"` | -| [Get a Store](#get-store) | `get` | `--store-id` | `fga store get --store-id=01H0H015178Y2V4CX10C2KGHF4` | -| [Delete a Store](#delete-store) | `delete` | `--store-id` | `fga store delete --store-id=01H0H015178Y2V4CX10C2KGHF4` | + +#### Manage JSON-to-tuple mappings -##### Create Store +##### Scaffold a starter mapping file ###### Command -fga store **create** -###### Parameters -* `--name`: Name of the store to be created. If the `model` parameter is specified, the model file name will be used as the default store name. -* `--model`: File with the authorization model. Can be in JSON, OpenFGA format, or fga.mod file (optional). -* `--format` : Authorization model input format. Can be "fga", "json", or "modular" (optional, defaults to the model file extension if present). - -###### Example -`fga store create --name "FGA Demo Store"` - -###### Response -```json -{ - "id": "01H0H015178Y2V4CX10C2KGHF4", - "name": "FGA Demo Store", - "created_at": "2023-05-19T16:10:07.637585677Z", - "updated_at": "2023-05-19T16:10:07.637585677Z" -} -``` - -`fga store create --model Model.fga` - -###### Response -```json -{ - "store": { - "id":"01H6H9CNQRP2TVCFR7899XGNY8", - "name":"Model", - "created_at":"2023-07-29T16:58:28.984402Z", - "updated_at":"2023-07-29T16:58:28.984402Z" - }, - "model": { - "authorization_model_id":"01H6H9CNQV36Y9WS1RJGRN8D06" - } -} ``` - -To automatically set the created store id as an environment variable that will then be used by the CLI, you can use the following command: - -```bash -export FGA_STORE_ID=$(fga store create --model model.fga | jq -r .store.id) +fga mapping init [mapping-file] [flags] ``` -##### Import Store - -###### Command -fga store **import** ###### Parameters -* `--file`: File containing the store. See [Store File Format](docs/STORE_FILE.md) for detailed documentation. -* `--store-id`: Specifies the store id to import into -* `--max-tuples-per-write`: Max tuples to send in a single write (optional, default=1) -* `--max-parallel-requests`: Max requests to send in parallel (optional, default=4) -* `--allow-external-files`: Allow `model_file`, `tuple_file` and `tuple_files` references in the store file to resolve outside the store file's directory (optional, default=false). Only enable this for store files you trust. + +* `--force`: Overwrite an existing file +* `--minimal`: Emit a skeleton without the example test block ###### Example -`fga store import --file model.fga.yaml` -###### Response -```json -{} +```bash +fga mapping init + fga mapping init my-mapping.yaml + fga mapping init --minimal mapping.yaml + fga mapping init --force mapping.yaml ``` -##### Export Store +##### Evaluate a mapping against JSON input and emit tuple operations ###### Command -fga store **export** - -###### Parameters -* `--store-id`: Specifies the store to export -* `--output-file`: The file to output the store to (optional, writes to the terminal if omitted) -* `--model-id`: Specifies the model to export (optional, exports the latest model if omitted) -* `--max-tuples`: Specifies the max number of tuples to include in the output (optional, defaults to 100) -###### Example -`fga store export --store-id=01H0H015178Y2V4CX10C2KGHF4` +``` +fga mapping run [mapping-file] [flags] +``` -###### Response -```yaml -name: Test -model: |+ - model - schema 1.1 +###### Parameters - type user +* `--aggregate`: Buffer all records and collapse them (dedup tuples and filters, detect write/delete conflicts) before emitting +* `--continue-on-error`: Skip input records that fail to parse or evaluate (warn to stderr) and exit non-zero if any were skipped +* `--format`: Output format: "jsonl" or "json" +* `--input`: Path to JSONL input file, one JSON object per line (default: stdin) +* `--interactive`: Explore the mapping in a terminal loop: paste JSON documents and see the tuples they produce (requires a TTY) +* `--writes-only`: Emit only write operations in ClientTupleKey format (consumable by fga tuple write) - type group - relations - define member: [user] - define moderator: [user] +###### Example -tuples: - - user: user:1 - relation: member - object: group:admins - - user: user:1 - relation: member - object: group:employees - - user: user:2 - relation: member - object: group:employees - - user: user:1 - relation: moderator - object: group:employees -tests: - - name: Tests - check: - - user: user:1 - object: group:admins - assertions: - member: true - - user: user:2 - object: group:admins - assertions: - member: false - - user: user:1 - object: group:employees - assertions: - member: true - moderator: true - - user: user:2 - object: group:employees - assertions: - member: true - moderator: false - # checks can also be defined for multiple users sharing the same expectation - - object: group:employees - users: - - user:1 - - user:2 - assertions: - member: true - - # checks can also target multiple objects with the same expectation - - objects: - - group:admins - - group:employees - user: user:1 - assertions: - member: true -``` - -If using `output-file`, the response will be written to the specified file on disk. If the desired file already exists, you will be prompted to overwrite the file. +```bash +echo '{"id":"anne","org":"acme"}' | fga mapping run mapping.yaml + fga mapping run mapping.yaml --input event.json --format json + fga mapping run --writes-only mapping.yaml > out.jsonl && fga tuple write --store-id $STORE_ID --file out.jsonl + fga mapping run mapping.yaml -i +``` -##### List Stores +##### Run the embedded tests in a mapping file ###### Command -fga store **list** + +``` +fga mapping test [mapping-file] [flags] +``` ###### Parameters -* `--max-pages`: Max number of pages to retrieve (default: 20) -* `--name`: Filter stores by exact name (substrings and regexes are not supported) -###### Example -`fga store list` +* `--fail-fast`: Stop after the first failing test +* `--format`: Output format: "text", "json", or "junit" +* `--no-color`: Disable color in text output +* `--output-file`: Write output to a file instead of stdout +* `--run`: Run only tests whose name contains this substring (case-sensitive) +* `--verbose`: Show rule trace and tuple details for each test (text format only) -`fga store list --name="FGA Demo Store"` +###### Example -###### Response -```json -{ - "stores": [{ - "id": "..", - "name": "..", - "created_at": "", - "updated_at": "", - "deleted_at": "" - }, { .. }] -} +```bash +fga mapping test mapping.yaml + fga mapping test --format json mapping.yaml + fga mapping test --format junit --output-file results.xml mapping.yaml + fga mapping test --run anne mapping.yaml + fga mapping test --fail-fast mapping.yaml ``` -##### Get Store +##### Validate a mapping file ###### Command -fga store **get** + +``` +fga mapping validate [mapping-file] [flags] +``` ###### Parameters -* `--store-id`: Specifies the store id to get + +* `--format`: Output format: "text" or "json" +* `--model-file`: Path to FGA authorization model file (DSL, JSON, or modular) +* `--verbose`: Show per-rule validation status (text format only) ###### Example -`fga store get --store-id=01H0H015178Y2V4CX10C2KGHF4` -###### Response -```json -{ - "id": "01H0H015178Y2V4CX10C2KGHF4", - "name": "FGA Demo Store", - "created_at": "2023-05-19T16:10:07.637585677Z", - "updated_at": "2023-05-19T16:10:07.637585677Z" -} +```bash +fga mapping validate mapping.yaml + fga mapping validate --format json mapping.yaml + fga mapping validate --model-file model.fga mapping.yaml ``` -##### Delete Store +#### Authorization Models + +##### Read a Single Authorization Model ###### Command -fga store **delete** + +``` +fga model get [flags] +``` ###### Parameters -* `--store-id`: Specifies the store id to delete + +* `--field`: Fields to display, choices are: id, created_at, size and model +* `--format`: Authorization model output format. Can be "fga" or "json" +* `--model-id`: Authorization Model ID +* `--store-id`: Store ID ###### Example -`fga store delete --store-id=01H0H015178Y2V4CX10C2KGHF4` -###### Response -```json -{} +```bash +fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1 --field size --field model --field id --field created_at +fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 ``` -#### Authorization Models +###### Response -* `model` +```fga +# Model ID: 01GXSA8YR785C4FYS3C0RTG7B1 +# Created At: 2023-04-11 23:26:34.759 +0000 UTC +# Size: 20.05 KB +model + schema 1.1 -| Description | command | parameters | example | -|-----------------------------------------------------------------------------|-------------|----------------------------|---------------------------------------------------------------------------------------------| -| [Read Authorization Models](#read-authorization-models) | `list` | `--store-id` | `fga model list --store-id=01H0H015178Y2V4CX10C2KGHF4` | -| [Write Authorization Model ](#write-authorization-model) | `write` | `--store-id`, `--file` | `fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file model.fga` | -| [Read a Single Authorization Model](#read-a-single-authorization-model) | `get` | `--store-id`, `--model-id` | `fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1` | -| [Validate an Authorization Model](#validate-an-authorization-model) | `validate` | `--file`, `--format` | `fga model validate --file model.fga` | -| [Run Tests on an Authorization Model](#run-tests-on-an-authorization-model) | `test` | `--tests`, `--verbose`, `--max-types-per-authorization-model` | `fga model test --tests "**/*.fga.yaml"` | -| [Transform an Authorization Model](#transform-an-authorization-model) | `transform` | `--file`, `--input-format` | `fga model transform --file model.json` | +type user +type document + relations + define can_view: [user] +``` ##### Read Authorization Models -List all authorization models for a store, in descending order by creation date. The first model in the list is the latest one. - ###### Command -fga model **list** + +``` +fga model list [flags] +``` ###### Parameters -* `--store-id`: Specifies the store id -* `--max-pages`: Max number of pages to retrieve (default: 20) -* `--field`: Fields to display. Choices are: id, created_at and model. Default are id, created_at. + +* `--field`: Fields to display, choices are: id, created_at and model +* `--max-pages`: Max number of pages to get. +* `--store-id`: Store ID ###### Example -`fga model list --store-id=01H0H015178Y2V4CX10C2KGHF4` + +```bash +fga model list --store-id=01H0H015178Y2V4CX10C2KGHF4 +``` ###### Response -```json5 + +```json { "authorization_models": [ { @@ -472,47 +384,69 @@ fga model **list** } ``` -##### Write Authorization Model +##### Test an Authorization Model ###### Command -fga model **write** + +``` +fga model test [flags] +``` ###### Parameters -* `--store-id`: Specifies the store id -* `--file`: File containing the authorization model. -* `--format`: Authorization model input format. Can be "fga", "json", or "modular". Defaults to the file extension if provided (optional) + +* `--allow-external-files`: Allow model_file, tuple_file and tuple_files references in the test file to resolve to paths outside the test file's directory. Only enable this for test files you trust. +* `--max-types-per-authorization-model`: Max allowed number of type definitions per authorization model +* `--model-id`: Model ID +* `--store-id`: Store ID +* `--suppress-summary`: Suppress the plain text summary output +* `--tests`: Path or glob of YAML test files +* `--verbose`: Print verbose JSON output ###### Example -* `fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file=model.fga` -* `fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file=fga.mod` -* `fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 '{"type_definitions": [ { "type": "user" }, { "type": "document", "relations": { "can_view": { "this": {} } }, "metadata": { "relations": { "can_view": { "directly_related_user_types": [ { "type": "user" } ] }}}} ], "schema_version": "1.1"}' --format json` + +```bash +fga model test --tests model.fga.yaml +fga model test --tests "tests/*.fga.yaml" +``` ###### Response -```json5 -{ - "authorization_model_id":"01GXSA8YR785C4FYS3C0RTG7B1" -} + +```text +(FAILING) test-1: Checks (2/3 passing) | ListObjects (2/2 passing) +ⅹ Check(user=user:anne,relation=can_share,object=folder:1): expected=false, got=true +--- +# Test Summary # +Tests 1/2 passing +Checks 3/4 passing +ListObjects 3/3 passing ``` -##### Read a Single Authorization Model +##### Transform an Authorization Model ###### Command -fga model **get** + +``` +fga model transform [flags] +``` ###### Parameters -* `--store-id`: Specifies the store id -* `--model-id`: Specifies the model id -* `--format`: Authorization model output format. Can be "fga" or "json" (default fga). -* `--field`: Fields to display, choices are: `id`, `created_at`, `size` and `model`. Default is `model`. + +* `--file`: File Name. The file should have the model in the JSON or DSL format or be an `fga.mod` format +* `--input-format`: Authorization model input format. Can be "fga", "json", or "modular" +* `--output-format`: Authorization model output format. Can be "fga" or "json"." ###### Example -`fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1 --field size --field model --field id --field created_at` + +```bash +fga model transform --file=model.json +fga model transform --file=model.fga +fga model transform '{ "schema_version": "1.1", "type_definitions":[{"type":"user"}] }' --input-format json +fga model transform --file=fga.mod +``` ###### Response -```python -# Model ID: 01GXSA8YR785C4FYS3C0RTG7B1 -# Created At: 2023-04-11 23:26:34.759 +0000 UTC -# Size: 20.05 KB + +```fga model schema 1.1 @@ -523,879 +457,596 @@ type document define can_view: [user] ``` -In `json` format, the fields appear intermixed with the model. +##### Validate Authorization Model -`fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1 --field size --field model --field id --field created_at --format=json` +###### Command -```json -{ - "id":"01GXSA8YR785C4FYS3C0RTG7B1", - "created_at":"2023-04-11T23:26:34.759Z", - "size_kb":20.05, - "schema_version":"1.1", - "type_definitions": [...] -} ``` - -##### Read the Latest Authorization Model - -If `model-id` is not specified when using the `get` command, the latest authorization model will be returned. - -###### Command -fga model **get** +fga model validate [flags] +``` ###### Parameters -* `--store-id`: Specifies the store id + +* `--file`: File Name. The file should have the model in the JSON or DSL format or be an fga.mod file +* `--format`: Authorization model input format. Can be "fga", "json", or "modular" ###### Example -`fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4` + +```bash +fga model validate --file model.json +``` ###### Response -```python -model - schema 1.1 -type user +```json +{"id":"01GPGWB8R33HWXS3KK6YG4ETGH","created_at":"2023-01-11T16:59:22Z","is_valid":true,"size_kb":0.05} -type document - relations - define can_view: [user] +Invalid model: +{"id":"01GPGTVEH5NYTQ19RYFQKE0Q4Z","created_at":"2023-01-11T16:33:15Z","is_valid":false,"error":"invalid schema version","size_kb":0.05} ``` -##### Validate an Authorization Model +##### Write Authorization Model ###### Command -fga model **validate** + +``` +fga model write [flags] +``` ###### Parameters -* `--file`: File containing the authorization model. -* `--format`: Authorization model input format. Can be "fga", "json", or "modular". Defaults to the file extension if provided (optional) -###### Example -`fga model validate --file model.json` +* `--file`: File Name. The file should have the model in the JSON or DSL format +* `--format`: Authorization model input format. Can be "fga", "json", or "modular" +* `--store-id`: Store ID -When the input parses successfully, the JSON response includes `size_kb`, the protobuf-serialized size of the model in KB. If the input cannot be parsed, the command exits with an error and does not emit a JSON response. +###### Example -###### JSON Response -* Valid model with an ID -```json5 -{"id":"01GPGWB8R33HWXS3KK6YG4ETGH","created_at":"2023-01-11T16:59:22Z","is_valid":true,"size_kb":0.05} -``` -* Valid model without an ID -```json5 -{"is_valid":true,"size_kb":0.05} -``` -* Invalid model with an ID -```json5 -{"id":"01GPGTVEH5NYTQ19RYFQKE0Q4Z","created_at":"2023-01-11T16:33:15Z","is_valid":false,"error":"invalid schema version","size_kb":0.05} -``` -* Invalid model without an ID -```json5 -{"is_valid":false,"error":"the relation type 'employee' on 'member' in object type 'group' is not valid","size_kb":0.05} +```bash +fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file=model.json +fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file=fga.mod +fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 '{"type_definitions":[{"type":"user"},{"type":"document","relations":{"can_view":{"this":{}}},"metadata":{"relations":{"can_view":{"directly_related_user_types":[{"type":"user"}]}}}}],"schema_version":"1.1"}' --format=json ``` -##### Run Tests on an Authorization Model - -Given a model, and a set of tests (tuples, check and list objects requests, and expected results) report back on any tests that do not return the same results as expected. - -###### Command -fga model **test** +###### Response -###### Parameters +```json +{ + "authorization_model_id":"01GXSA8YR785C4FYS3C0RTG7B1" +} +``` -* `--tests`: Name of the tests file, or a glob pattern to multiple files (for example `"tests/*.fga.yaml"`, or `"**/*.fga.yaml"`). Each file must be in yaml format. See [Store File Format](docs/STORE_FILE.md) for detailed documentation. -* `--verbose`: Outputs the results in JSON -* `--max-types-per-authorization-model`: Max allowed number of type definitions per authorization model (default: 100). Increase this when testing models with more than 100 type definitions. -* `--allow-external-files`: Allow `model_file`, `tuple_file` and `tuple_files` references in the test file to resolve outside the test file's directory (optional, default=false). Only enable this for test files you trust. +#### Relationship Queries -If a model is provided, the test will run in a built-in OpenFGA instance (you do not need a separate server). Otherwise, the test will be run against the configured store of your OpenFGA instance. When running against a remote instance, the tuples will be sent as contextual tuples, and will have to abide by the OpenFGA server limits (20 contextual tuples per request). +##### Check -The tests file should be in yaml and have the following format: +###### Command -```yaml ---- -name: Store Name # store name, optional -# model_file: ./model.fga # a global model that would apply to all tests, optional -# model can be used instead of model_file, optional -model: | - model - schema 1.1 - type user - type folder - relations - define owner: [user] - define parent: [folder] - define can_view: owner or can_view from parent - define can_write: owner or can_write from parent - define can_share: owner - -# You can use `tuples`, `tuple_file`, and `tuple_files` together or individually to provide global tuples for all tests. -# Example using a single tuple file: -# tuple_file: ./tuples.yaml -# Example using multiple tuple files: -# tuple_files: -# - ./model_tuples_2.yaml -# - ./model_tuples_3.yaml -tuples: # global tuples that would apply to all tests, optional - - user: folder:1 - relation: parent - object: folder:2 -tests: # required - - name: test-1 - description: testing that the model works # optional - # tuple_file: ./tuples.json # tuples that would apply per test - tuples: - - user: user:anne - relation: owner - object: folder:1 - check: # a set of checks to run - - user: user:anne - object: folder:1 - assertions: - # a set of expected results for each relation - can_view: true - can_write: true - can_share: false - # checks can group multiple users that share the same expected results - - object: folder:2 - users: - - user:beth - - user:carl - assertions: - can_view: false - # checks can group multiple objects that share the same expected results - - objects: - - folder:1 - - folder:2 - user: user:beth - assertions: - can_write: false - # either "user" or "users" may be provided, but not both - # either "object" or "objects" may be provided, but not both - list_objects: # a set of list objects to run - - user: user:anne - type: folder - assertions: - # a set of expected results for each relation - can_view: - - folder:1 - - folder:2 - can_write: - - folder:1 - - folder:2 - - name: test-2 - description: another test - tuples: - - user: user:anne - relation: owner - object: folder:1 - check: - - user: user:anne - object: folder:1 - assertions: - # a set of expected results for each relation - can_view: true - list_objects: - - user: user:anne - type: folder - assertions: - # a set of expected results for each relation - can_view: - - folder:1 - - folder:2 +``` +fga query check ``` ###### Example -`fga model test --tests "tests/*.fga.yaml"` -For more examples of `.fga.yaml` files, check our [Store File Format documentation](docs/STORE_FILE.md) and the [sample-stores repository](https://github.com/openfga/sample-stores/). +```bash +fga query check --store-id="01H4P8Z95KTXXEP6Z03T75Q984" user:anne can_view document:roadmap --context '{"ip_address":"127.0.0.1"}' --consistency "HIGHER_CONSISTENCY" +``` ###### Response -```shell -(FAILING) test-1: Checks (2/3 passing) | ListObjects (2/2 passing) -ⅹ Check(user=user:anne,relation=can_share,object=folder:1): expected=false, got=true ---- -# Test Summary # -Tests 1/2 passing -Checks 3/4 passing -ListObjects 3/3 passing +```json +{ + "allowed": true, + "resolution": "" +} ``` -##### Transform an Authorization Model - -The **transform** command lets you convert between different authorization model formats (`.fga`, `.json`, `.mod`). +##### Expand ###### Command -fga model **transform** - -###### Parameters -* `--file`: File containing the authorization model -* `--input-format`: Authorization model input format. Can be "fga", "json", or "modular". Defaults to the file extension if provided (optional) -* `--output-format`: Authorization model output format. Can be "fga" or "json". If not specified, it will default to `fga` if the input format is `json` and to `json` otherwise (optional) +``` +fga query expand +``` ###### Example -`fga model transform --file model.json` -###### Response -```python -model - schema 1.1 - -type user - -type document - relations - define can_view: [user] +```bash +fga query expand --store-id="01H4P8Z95KTXXEP6Z03T75Q984" can_view document:roadmap --consistency "HIGHER_CONSISTENCY" ``` -#### Relationship Tuples - -* `tuple` - -| Description | command | parameters | example | -|-----------------------------------------------------------------------------------|-----------|-----------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------| -| [Write Relationship Tuples](#write-relationship-tuples) | `write` | `--store-id`, `--model-id` `--file` `--on-duplicate` | `fga tuple write user:anne can_view document:roadmap --store-id=01H0H015178Y2V4CX10C2KGHF4` | -| [Delete Relationship Tuples](#delete-relationship-tuples) | `delete` | `--store-id`, `--model-id` `--file` `--on-missing` | `fga tuple delete user:anne can_view document:roadmap --store-id=01H0H015178Y2V4CX10C2KGHF4` | -| [Read Relationship Tuples](#read-relationship-tuples) | `read` | `--store-id` | `fga tuple read --store-id=01H0H015178Y2V4CX10C2KGHF4` | -| [Read Relationship Tuple Changes (Watch)](#read-relationship-tuple-changes-watch) | `changes` | `--store-id`, `--type`, `--start-time`, `--continuation-token`, | `fga tuple changes --store-id=01H0H015178Y2V4CX10C2KGHF4 --type=document --start-time=2022-01-01T00:00:00Z --continuation-token=M3w=` | - -##### Write Relationship Tuples - -###### Command -fga tuple **write** --store-id= - -###### Parameters -* ``: User -* ``: Relation -* ``: Object -* `--condition-name`: Condition name (optional) -* `--condition-context`: Condition context (optional) -* `--condition-expression`: Dynamic condition CEL expression (optional, mutually exclusive with `--condition-name`/`--condition-context`) -* `--condition-parameters`: Dynamic condition parameter types, as a JSON object with at least one parameter (requires `--condition-expression`) -* `--store-id`: Specifies the store id -* `--model-id`: Specifies the model id to target (optional) -* `--file`: Specifies the file name, `json`, `jsonl`, `yaml` and `csv` files are supported -* `--max-tuples-per-write`: Max tuples to send in a single write (optional, default=1, or 40 if `--max-rps` is set and this flag is omitted) -* `--max-parallel-requests`: Max requests to send in parallel (optional, default=4, or `max-rps/5` if `--max-rps` is set and this flag is omitted) -* `--hide-imported-tuples`: When importing from a file, do not output successfully imported tuples in the command output (optional, default=false) -* `--max-rps`: Max requests per second. When set, the CLI will ramp up requests from 1 RPS to the set value. If `--rampup-period-in-sec` is omitted it defaults to `max-rps*2`. -* `--rampup-period-in-sec`: Time in seconds to wait between each batch of tuples when ramping up. Only used if `--max-rps` is set. -* `--on-duplicate`: Behavior when a tuple to be written already exists. Options are: - * `ignore`: Skip the tuple and do not return an error. Default when importing via a file. - * `error`: Return an error for the tuple. Default when writing a single tuple via arguments. -* All integer parameters must be greater than zero when provided. - -###### Example (with arguments) -- `fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap` -- `fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap --condition-name inOffice --condition-context '{"office_ip":"10.0.1.10"}'` -- `fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 agent:alice-claude can_call tool:slack_send_message --condition-expression "channel_name == '#product-announcements'" --condition-parameters '{"channel_name":"string"}'` -- `fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1 --file tuples.json` -- `fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --max-rps 10` -- `fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --on-duplicate ignore` - ###### Response -```json5 + +```json { - "successful": [ - { - "object":"document:roadmap", - "relation":"writer", - "user":"user:annie" + "tree": { + "root": { + "name": "document:roadmap#can_view", + "union": { + "nodes": [ + { + "name": "document:roadmap#can_view", + "leaf": { + "users": { + "users": [ + "user:anne" + ] + } + } + } + ] + } } - ], + } } ``` -###### Example (with file) -`fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.json` - -If using a `csv` file, the format should be: - -```csv -user_type,user_id,user_relation,relation,object_type,object_id,condition_name,condition_context -folder,product,,parent,folder,product-2021,inOfficeIP,"{""ip_addr"":""10.0.0.1""}" -``` - +##### List Objects -If using a `yaml` file, the format should be: +###### Command -```yaml -- user: folder:5 - relation: parent - object: folder:product-2021 -- user: folder:product-2021 - relation: parent - object: folder:product-2021Q1 ``` - -If using a `jsonl` file, the format should be: - -```jsonl -{"user": "user:anne", "relation": "owner", "object": "folder:product"} -{"user": "folder:product", "relation": "parent", "object": "folder:product-2021", "condition": {"name": "inOfficeIP", "context": {"ip_addr": "10.0.0.1"}}} -{"user": "user:beth", "relation": "viewer", "object": "folder:product-2021"} +fga query list-objects ``` -If using a `json` file, the format should be: +###### Example -```json -[ - { - "user": "user:anne", - "relation": "owner", - "object": "folder:product" - }, - { - "user": "folder:product", - "relation": "parent", - "object": "folder:product-2021" - }, - { - "user": "user:beth", - "relation": "viewer", - "object": "folder:product-2021" - } -] +```bash +fga query list-objects --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document --contextual-tuple "user:anne can_view folder:product" --contextual-tuple "folder:product parent document:roadmap" --consistency "HIGHER_CONSISTENCY" ``` ###### Response -```json5 -{ - "successful": [ - { - "object":"document:roadmap", - "relation":"writer", - "user":"user:annie" - } - ], - "failed": [ - { - "tuple_key": { - "object":"document:roadmap", - "relation":"writer", - "user":"carl" - }, - "reason":"Write validation error ..." - } - ], - "failed_count": 1, - "successful_count": 1, - "total_count": 2 -} -``` -###### Response with `--hide-imported-tuples` -```json5 +```json { - "failed": [ - { - "tuple_key": { - "object":"document:roadmap", - "relation":"writer", - "user":"carl" - }, - "reason":"Write validation error ..." - } - ], - "failed_count": 1, - "successful_count": 1, - "total_count": 2 + "objects": [ + "document:roadmap" + ] } ``` -In some cases you could want to retry failed tuples (e.g. network connectivity error). To achieve that, you can direct the output to a file: - -`fga tuple write --file tuples.json' --hide-imported-tuples > results.json` - -Then, process the file with `jq` to convert it to format that you can send the CLI again: +##### List Relations -`jq -c '[.failed[] | {user: .tuple_key.user, relation: .tuple_key.relation, object: .tuple_key.object}]' result.json > failed_tuples.json` +###### Command -`fga tuple write --file failed_tuples.json' --hide-imported-tuples ` +``` +fga query list-relations [flags] +``` -##### Delete Relationship Tuples +###### Parameters -###### Command -fga tuple **delete** --store-id= +* `--relation`: Relation -###### Parameters -* ``: User -* ``: Relation -* ``: Object -* `--store-id`: Specifies the store id -* `--model-id`: Specifies the model id to target (optional) -* `--file`: Specifies the file name, `yaml`, `json`, and `jsonl` files are supported -* `--max-tuples-per-write`: Max tuples to send in a single write (optional, default=1) -* `--max-parallel-requests`: Max requests to send in parallel (optional, default=4) -* `--on-missing`: Behavior when a tuple to be deleted does not exist. Options are: - * `ignore`: Skip the tuple and do not return an error. Default when importing via a file. - * `error`: Return an error for the tuple. Default when deleting a single tuple via arguments. - -###### Example (with arguments) -- `fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap` -- `fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap --on-missing ignore` +###### Example -###### Response -```json5 -{} +```bash +fga query list-relations --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne document:roadmap --relation can_view --consistency "HIGHER_CONSISTENCY" ``` -###### Example (with file) -`fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.json` - ###### Response -```json5 + +```json { - "successful": [ - { - "object":"document:roadmap", - "relation":"writer", - "user":"user:annie" - } - ], - "failed": [ - { - "tuple_key": { - "object":"document:roadmap", - "relation":"writer", - "user":"carl" - }, - "reason":"Write validation error ..." - } + "relations": [ + "can_view" ] } ``` -If you want to delete all the tuples in a store, you can use the following code: +##### List Users + +###### Command ``` -fga tuple read --output-format=simple-json --max-pages=0 > tuples.json -fga tuple delete --file tuples.json +fga query list-users [flags] ``` -##### Read Relationship Tuples - -###### Command -fga tuple **read** [--user=] [--relation=] [--object=] --store-id= - ###### Parameters -* `--store-id`: Specifies the store id -* `--user`: User -* `--relation`: Relation -* `--object`: Object -* `--max-pages`: Max number of pages to get. Set to 0 to get all pages. (default 20) -* `--page-size`: Number of tuples to return per page. Defaults to 100 when max-pages=0, or 50 otherwise. Max is 100. -* `--output-format`: Can be `csv`, `yaml`, `json` or `simple-json`. Use `simple-json` for a simpler json format that can be piped to the write and delete commands + +* `--object`: Object to list users for +* `--relation`: Relation to evaluate on +* `--user-filter`: Filter the responses can be in the formats (to filter objects and typed public bound access) or # (to filter usersets) ###### Example -`fga tuple read --store-id=01H0H015178Y2V4CX10C2KGHF4 --user user:anne --relation can_view --object document:roadmap` + +```bash +fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --consistency "HIGHER_CONSISTENCY" +``` ###### Response -```json5 + +```json { - "tuples": [ + "users": [ { - "key": { - "object": "document:roadmap", - "relation": "can_view", - "user": "user:anne" - }, - "timestamp": "2023-07-06T15:12:55.080666875Z" + "object": { + "type": "user", + "id": "anne" + } } ] } ``` -###### Response (--output-format=simple-json) -```json5 -[ - { - "object": "document:roadmap", - "relation": "can_view", - "user": "user:anne" - } -] -``` +#### Stores + +##### Create Store -If you want to transform this output in a way that can be then imported using the `fga tuple write` you can run +###### Command ``` -fga tuple read --output-format=simple-json --max-pages 0 > tuples.json -fga tuple write --file tuples.json +fga store create [flags] ``` -##### Read Relationship Tuple Changes (Watch) - -###### Command -fga tuple **changes** --type --store-id= - ###### Parameters -* `--store-id`: Specifies the store id -* `--type`: Restrict to a specific type (optional) -* `--start-time`: Return changes since a specified time (optional) -* `--max-pages`: Max number of pages to retrieve (default: 20) -* `--continuation-token`: Continuation token to start changes from + +* `--format`: Authorization model input format. Can be "fga", "json" or "modular +* `--model`: Authorization Model File Name +* `--name`: Store Name ###### Example -`fga tuple changes --store-id=01H0H015178Y2V4CX10C2KGHF4 --type=document --continuation-token=M3w=` -###### Response -```json5 -{ - "changes": [ - { - "operation": "TUPLE_OPERATION_WRITE", - "timestamp": "2023-07-06T15:12:40.294950382Z", - "tuple_key": { - "object": "document:roadmap", - "relation": "can_view", - "user": "user:anne" - } - } - ], - "continuation_token":"NHw=" -} +```bash +fga store create --name "FGA Demo Store" +fga store create --model Model.fga +export FGA_STORE_ID=$(fga store create --model model.fga | jq -r .store.id) ``` -#### Relationship Queries +###### Response -- `query` +```json +{ + "id": "01H0H015178Y2V4CX10C2KGHF4", + "name": "FGA Demo Store", + "created_at": "2023-05-19T16:10:07.637585677Z", + "updated_at": "2023-05-19T16:10:07.637585677Z" +} -| Description | command | parameters | example | -|-----------------------------------|------------------|----------------------------|---------------------------------------------------------------------------------------------| -| [Check](#check) | `check` | `--store-id`, `--model-id` | `fga query check --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap` | -| [List Objects](#list-objects) | `list-objects` | `--store-id`, `--model-id` | `fga query list-objects --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document` | -| [List Relations](#list-relations) | `list-relations` | `--store-id`, `--model-id` | `fga query list-relations --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne document` | -| [Expand](#expand) | `expand` | `--store-id`, `--model-id` | `fga query expand --store-id=01H0H015178Y2V4CX10C2KGHF4 can_view document:roadmap` | +Response for fga store create --model Model.fga: +{ + "store": { + "id":"01H6H9CNQRP2TVCFR7899XGNY8", + "name":"Model", + "created_at":"2023-07-29T16:58:28.984402Z", + "updated_at":"2023-07-29T16:58:28.984402Z" + }, + "model": { + "authorization_model_id":"01H6H9CNQV36Y9WS1RJGRN8D06" + } +} +``` -##### Check +##### Delete Store ###### Command -fga query **check** [--condition] [--contextual-tuple "\ \ \"]* --store-id= [--model-id=] + +``` +fga store delete [flags] +``` ###### Parameters -* `--store-id`: Specifies the store id -* `--model-id`: Specifies the model id to target (optional) -* `--contextual-tuple`: Contextual tuples (optional) -* `--context`: Condition context (optional) -* `--consistency`: Consistency preference (optional) + +* `--force`: Force delete without confirmation +* `--store-id`: Store ID ###### Example -- `fga query check --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap --contextual-tuple "user:anne can_view folder:product" --contextual-tuple "folder:product parent document:roadmap"` -- `fga query check --store-id="01H4P8Z95KTXXEP6Z03T75Q984" user:anne can_view document:roadmap --context '{"ip_address":"127.0.0.1"}' --consistency="HIGHER_CONSISTENCY"` +```bash +fga store delete --store-id=01H0H015178Y2V4CX10C2KGHF4 +``` ###### Response -```json5 -{ - "allowed": true, -} + +```json +{} ``` -##### List Objects +##### Export Store Data ###### Command -fga query **list-objects** [--contextual-tuple " "]* --store-id= [--model-id=] + +``` +fga store export [flags] +``` ###### Parameters -* `--store-id`: Specifies the store id -* `--model-id`: Specifies the model id to target (optional) -* `--contextual-tuple`: Contextual tuples (optional) (can be multiple) -* `--context`: Condition context (optional) -* `--consistency`: Consistency preference (optional) + +* `--max-tuples`: max number of tuples to return in the output +* `--model-id`: Authorization Model ID +* `--output-file`: name of the file to export the store to +* `--store-id`: store ID ###### Example -- `fga query list-objects --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document --contextual-tuple "user:anne can_view folder:product" --contextual-tuple "folder:product parent document:roadmap"` -- `fga query list-objects --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document --context '{"ip_address":"127.0.0.1"} --consistency="HIGHER_CONSISTENCY"` -###### Response -```json5 -{ - "objects": [ - "document:roadmap", - "document:budget" - ], -} +```bash +fga store export --store-id=01H0H015178Y2V4CX10C2KGHF4 ``` -##### List Relations - -###### Command -fga query **list-relations** [--relation ]* [--contextual-tuple " "]* --store-id= [--model-id=] +###### Response -###### Parameters -* `--store-id`: Specifies the store id -* `--model-id`: Specifies the model id to target (optional) -* `--contextual-tuple`: Contextual tuples (optional) (can be multiple) -* `--context`: Condition context (optional) -* `--consistency`: Consistency preference (optional) +```yaml +name: Test +model: |+ + model + schema 1.1 -###### Example -- `fga query list-relations --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne document:roadmap --relation can_view` -- `fga query list-relations --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne document:roadmap --relation can_view --contextual-tuple "user:anne can_view folder:product"` -- `fga query list-relations --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne document:roadmap --relation can_view --context '{"ip_address":"127.0.0.1"} --consistency="HIGHER_CONSISTENCY"` + type user -###### Response -```json5 -{ - "relations": [ - "can_view" - ], -} +tuples: + - user: user:1 + relation: member + object: group:admins +tests: [] ``` -##### Expand +##### Get Store ###### Command -fga query **expand** --store-id= [--model-id=] + +``` +fga store get [flags] +``` ###### Parameters -* `--store-id`: Specifies the store id -* `--model-id`: Specifies the model id to target (optional) -* `--consistency`: Consistency preference (optional) + +* `--store-id`: Store ID ###### Example -`fga query expand --store-id=01H0H015178Y2V4CX10C2KGHF4 can_view document:roadmap` + +```bash +fga store get --store-id=01H0H015178Y2V4CX10C2KGHF4 +``` ###### Response -```json5 + +```json { - "tree": { - "root": { - "name": "repo:openfga/openfga#reader", - "union": { - "nodes": [{ - "leaf": { - "users": { - "users": ["user:anne"] - } - }, - "name": "repo:openfga/openfga#reader" - }] - } - } - } + "id": "01H0H015178Y2V4CX10C2KGHF4", + "name": "FGA Demo Store", + "created_at": "2023-05-19T16:10:07.637585677Z", + "updated_at": "2023-05-19T16:10:07.637585677Z" } ``` -##### List Users +##### Import Store Data ###### Command -fga query **list-users** --object --relation --user-filter [--contextual-tuple " "]* --store-id= [--model-id=] + +``` +fga store import [flags] +``` ###### Parameters -* `--store-id`: Specifies the store id -* `--object`: Specifies the object to list users for -* `--relation`: Specifies the relation to search on -* `--user-filter`: Specifies the type or userset to filter with -* `--model-id`: Specifies the model id to target (optional) -* `--contextual-tuple`: Contextual tuples (optional) (can be multiple) -* `--context`: Condition context (optional) -* `--consistency`: Consistency preference (optional) + +* `--allow-external-files`: Allow model_file, tuple_file and tuple_files references in the store file to resolve to paths outside the store file's directory. Only enable this for store files you trust. +* `--file`: File Name. The file should have the store +* `--max-parallel-requests`: Max number of requests to issue to the server in parallel. +* `--max-tuples-per-write`: Max tuples per write chunk. +* `--store-id`: Store ID ###### Example -- `fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --user-filter user` -- `fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --user-filter user --contextual-tuple "user:anne can_view folder:product"` -- `fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --user-filter group#member --context '{"ip_address":"127.0.0.1"} --consistency="HIGHER_CONSISTENCY"` -###### Response -```json5 -{ - { - "users": [ - { - "object": { - "type": "user", - "id": "anne" - } - } - ] - } -} +```bash +fga store import --file=model.fga.yaml ``` -#### Mapping - -Tooling for authoring, validating, testing, evaluating, and applying JSON→tuple mapping files. Most commands run offline; `sync` requires store credentials. - -- `mapping` +###### Response -| Description | command | parameters | example | -|---------------------------------------------------|------------|---------------------------------------------------|-------------------------------------------------------------| -| [Scaffold a mapping file](#init-mapping) | `init` | `[mapping.yaml]`, `--minimal`, `--force` | `fga mapping init` | -| [Validate a mapping file](#validate-mapping) | `validate` | `--format`, `--model-file`, `--verbose` | `fga mapping validate mapping.yaml` | -| [Run embedded tests](#test-mapping) | `test` | `--format`, `--run`, `--fail-fast`, `--output-file`, `--verbose`, `--no-color` | `fga mapping test mapping.yaml` | -| [Sync to store](#sync-mapping) | `sync` | `--store-id`, `--model-id`, `--input`, `--dry-run`, `--continue-on-error`, `--quiet` | `fga mapping sync mapping.yaml --store-id $FGA_STORE_ID < events.jsonl` | +```json +{} +``` -##### Validate Mapping +##### List Stores ###### Command -fga mapping **validate** [mapping-file] -The mapping file is optional: omit it in an interactive terminal to choose one from a `.yaml`/`.yml` file picker. +``` +fga store list [flags] +``` ###### Parameters -* `--format`: Output format — `text` (default) or `json` -* `--model-file`: Path to an FGA authorization model file (DSL, JSON, or modular). When provided, every tuple template is checked against the model. -* `--verbose`: Show per-rule validation status (text format only) -###### Example -`fga mapping validate mapping.yaml` +* `--max-pages`: Max number of pages to get. +* `--name`: Filter stores by exact name. Substrings and regexes are not supported. -`fga mapping validate --format json --model-file model.fga mapping.yaml` +###### Example -###### Response -``` -mapping is valid (2 rules) +```bash +fga store list ``` -With `--verbose`: -``` - ✓ add-member - ✓ add-admin -mapping is valid (2 rules) -``` +###### Response -JSON response: ```json -{"valid":true,"rule_count":2,"rules":["add-member","add-admin"]} +{ + "stores": [{ + "id": "..", + "name": "..", + "created_at": "", + "updated_at": "", + "deleted_at": "" + }] +} ``` -##### Test Mapping +#### Relationship Tuples + +##### Read Relationship Tuple Changes (Watch) ###### Command -fga mapping **test** [mapping-file] -The mapping file is optional: omit it in an interactive terminal to choose one from a `.yaml`/`.yml` file picker. +``` +fga tuple changes [flags] +``` ###### Parameters -* `--format`: Output format — `text` (default), `json`, or `junit` -* `--run`: Run only tests whose name contains this substring (case-sensitive) -* `--fail-fast`: Stop after the first failing test -* `--output-file` / `-o`: Write output to a file instead of stdout -* `--verbose`: Show rule trace and tuple details for each test (text: full trace and tuples; junit: trace in ``) -* `--no-color`: Disable color in text output + +* `--continuation-token`: Continuation token to start changes from. +* `--max-pages`: Max number of pages to get. +* `--start-time`: Time to return changes since. +* `--type`: Type to restrict the changes by. ###### Example -`fga mapping test mapping.yaml` -`fga mapping test --format junit --output-file results.xml mapping.yaml` +```bash +fga tuple changes --store-id=01H0H015178Y2V4CX10C2KGHF4 --type=document --continuation-token=M3w= +``` ###### Response -``` -PASS admin gets member and admin (3ms) -PASS regular user only gets member (2ms) -2 passed, 0 failed (5ms) + +```json +{ + "changes": [ + { + "operation": "TUPLE_OPERATION_WRITE", + "timestamp": "2023-07-06T15:12:40.294950382Z", + "tuple_key": { + "object": "document:roadmap", + "relation": "can_view", + "user": "user:anne" + } + } + ], + "continuation_token":"NHw=" +} ``` -##### Init Mapping +##### Delete Relationship Tuples ###### Command -fga mapping **init** [mapping.yaml] + +``` +fga tuple delete [flags] +``` ###### Parameters -* `[mapping.yaml]`: Output file path (optional, defaults to `mapping.yaml`) -* `--minimal`: Emit a skeleton file without the example test block -* `--force`: Overwrite an existing file without prompting + +* `--file`: Tuples file +* `--hide-imported-tuples`: Hide successfully imported tuples from output +* `--max-parallel-requests`: Max number of requests to issue to the server in parallel. +* `--max-tuples-per-write`: Max tuples per write chunk. +* `--model-id`: Model ID +* `--on-missing`: Whether to ignore or error on missing tuples. Valid values are 'ignore' and 'error'. (default: 'ignore' when deleting a file of tuples, 'error' otherwise) ###### Example -`fga mapping init` -`fga mapping init --minimal my-mapping.yaml` +```bash +fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap + fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --on-missing ignore +``` ###### Response -``` -Created mapping.yaml -``` -##### Evaluate Mapping +```json +{} -###### Command -fga mapping **evaluate** [mapping-file] +Response when using --file: +{ + "successful": [ + { + "object":"document:roadmap", + "relation":"writer", + "user":"user:annie" + } + ], + "failed": [] +} +``` -Reads JSONL (one JSON object per line) from stdin (or `--input`) and emits tuple operations as JSONL (default) or a JSON batch. Runs entirely offline. Rules using `tuple_filters` cannot be expanded without a store; they are reported as warnings on stderr, or under `tuple_filter_operations` with `--format json`. +##### Read Relationship Tuples -The mapping file is optional: omit it in an interactive terminal to choose one from a `.yaml`/`.yml` file picker. +###### Command -> `fga mapping run` is a deprecated alias for `fga mapping evaluate` and will be removed in a future release. +``` +fga tuple read [flags] +``` ###### Parameters -* `--input`: Path to a JSONL input file, one JSON object per line (default: stdin) -* `--format`: Output format — `jsonl` (default) or `json` -* `--writes-only`: Emit only write-action tuples in `ClientTupleKey` format, consumable directly by `fga tuple write --file` -* `--aggregate`: Buffer all records and collapse them (dedup tuples and filters, detect write/delete conflicts) before emitting -* `--continue-on-error`: Skip input records that fail to parse or evaluate (warn to stderr) and exit non-zero if any were skipped -* `--interactive` / `-i`: Explore the mapping in a terminal loop — type or paste a JSON document and see the tuple operations it produces. The document is evaluated as soon as it forms a complete JSON value, so a single-line object is evaluated on Enter and a multi-line one when its closing brace is typed. Supports line editing (arrow keys, history). Requires an interactive terminal (both stdin and stdout must be a TTY) and cannot be combined with `--input`, `--writes-only`, `--format`, `--aggregate`, or `--continue-on-error`. -###### Example -`echo '{"id":"anne","org":"acme"}' | fga mapping evaluate mapping.yaml` +* `--consistency`: Consistency preference for the request. Valid options are HIGHER_CONSISTENCY and MINIMIZE_LATENCY. +* `--max-pages`: Max number of pages to get. Set to 0 to get all pages. +* `--object`: Object +* `--output-format`: Specifies the format for data presentation. Valid options: json, simple-json, csv, and yaml. +* `--page-size`: Number of tuples to return per page. Defaults to 100 when max-pages=0, or 50 otherwise. Max is 100. +* `--relation`: Relation +* `--user`: User -`fga mapping evaluate --writes-only mapping.yaml --input event.json > out.jsonl && fga tuple write --store-id $FGA_STORE_ID --file out.jsonl` +###### Example -###### Response -```json -{"op":"write","user":"user:anne","relation":"member","object":"org:acme"} +```bash +fga tuple read --store-id=01H0H015178Y2V4CX10C2KGHF4 --user user:anne --relation can_view --object document:roadmap ``` -###### Interactive mode -In a terminal, `-i` starts an explorer loop. Type or paste a JSON document and the resulting tuple operations are printed as an aligned table. The document is evaluated as soon as it parses as complete JSON — a single-line object on Enter, a multi-line one when its closing brace is typed. While more input is expected the prompt shows `...` and a one-time hint notes that the document is not yet valid JSON; pressing Enter on a blank line evaluates whatever is buffered. Invalid JSON is reported with the line, column, and a caret under the offending character. A rule with `tuple_filters` cannot be resolved offline, so its filter conditions are shown as `filter:patch` or `filter:delete` rows (the action distinguishes how the store is reconciled), with the desired-state tuples it reconciles toward shown as indented `desired` rows (they drive a read-diff-write against a store rather than being written directly). Filter fields left unset match any value and render as `*`, and a conditioned tuple shows its condition name and rendered context in brackets. Line editing (arrow keys, history) is available. Available commands: `:reload` re-reads and recompiles the mapping from disk, `:trace on|off` toggles the per-rule trace, and `:quit` exits. - -``` -$ fga mapping evaluate mapping.yaml -i -mapping loaded: 2 rules. Type or paste a JSON document; it is evaluated once complete. commands: :reload :trace on|off :quit +###### Response -> {"id":"anne","org":"acme"} - write user:anne member org:acme -> {"id": bob} -Error: invalid JSON at line 1, column 8: invalid character 'b' looking for beginning of value - {"id": bob} - ^ -> {"id":"anne","org":"acme"} - filter:patch user:anne * org:acme - desired user:anne viewer org:acme -> :quit +```json +{ + "tuples": [ + { + "key": { + "object": "document:roadmap", + "relation": "can_view", + "user": "user:anne" + }, + "timestamp": "2023-07-06T15:12:55.080666875Z" + } + ] +} ``` -##### Sync Mapping +##### Create Relationship Tuples ###### Command -fga mapping **sync** [mapping-file] - -Reads JSONL from stdin (or `--input`) and evaluates each record against the mapping file, then applies the resulting tuple writes and deletes to the store. Rules using `tuple_filters` read current store state, diff against desired state, and derive the minimal write/delete set before applying. Deletes are issued before writes so that a condition change on the same user–relation–object (delete old + write new) lands correctly. -The mapping file is optional: omit it in an interactive terminal to choose one from a `.yaml`/`.yml` file picker. +``` +fga tuple write [flags] +``` ###### Parameters -* `--store-id` (required): Store ID -* `--model-id`: Authorization Model ID -* `--input`: Path to a JSONL input file, one JSON object per line (default: stdin) -* `--dry-run`: Read the store and print planned operations to stdout as JSONL instead of writing them. Suppressed at an interactive terminal where the output would be unreadable. -* `--continue-on-error`: Skip records that fail to evaluate or apply (shown on stderr) and exit non-zero if any were skipped -* `--quiet`: Suppress progress output; only errors are written to stderr -###### Example -`echo '{"id":"anne","org":"acme"}' | fga mapping sync mapping.yaml --store-id $FGA_STORE_ID` +* `--condition-context`: Condition Context (as a JSON string) +* `--condition-name`: Condition Name +* `--file`: Tuples file +* `--hide-imported-tuples`: Hide successfully imported tuples from output +* `--max-parallel-requests`: Max number of requests to issue to the server in parallel. +* `--max-rps`: The maximum requests per second. +* `--max-tuples-per-write`: Max tuples per write chunk. +* `--model-id`: Model ID +* `--on-duplicate`: Whether to ignore or error on duplicate tuples. Valid values are 'ignore' and 'error'. (default: 'ignore' when importing a file of tuples, 'error' otherwise) +* `--rampup-period-in-sec`: The period over which to ramp up the request rate. -`fga mapping sync mapping.yaml --input events.jsonl --store-id $FGA_STORE_ID --dry-run > planned.jsonl` +###### Example -`fga mapping sync mapping.yaml --store-id $FGA_STORE_ID --continue-on-error < events.jsonl` +```bash +fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap --condition-name inOffice --condition-context '{"office_ip":"10.0.1.10"}' + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.json + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.yaml + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --max-tuples-per-write 10 --max-parallel-requests 5 + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --max-rps 10 + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --on-duplicate ignore +``` ###### Response -Progress is shown on stderr as a single updating line (or once per second when not a terminal): -``` -15:04:05 ✓ +12 -3 10 records -``` -With `--quiet`, only errors appear on stderr. - -`--dry-run` (with stdout redirected) emits planned operations as JSONL: ```json -{"op":"write","user":"user:anne","relation":"member","object":"org:acme"} -{"op":"delete","user":"user:bob","relation":"member","object":"org:acme"} +{ + "successful": [ + { + "object":"document:roadmap", + "relation":"writer", + "user":"user:annie" + } + ], + "failed": [], + "failed_count": 0, + "successful_count": 1, + "total_count": 1 +} ``` + + ## Contributing See [CONTRIBUTING](https://github.com/openfga/.github/blob/main/CONTRIBUTING.md). diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go new file mode 100644 index 00000000..a08f6744 --- /dev/null +++ b/cmd/gendocs/main.go @@ -0,0 +1,40 @@ +/* +Copyright © 2023 OpenFGA + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Command gendocs regenerates the Commands section of README.md from cobra +// command metadata. Run from the repository root: go run ./cmd/gendocs +package main + +import ( + "log" + + "github.com/openfga/cli/cmd" + "github.com/openfga/cli/internal/doc" +) + +func main() { + root := cmd.RootCmd() + + section := doc.GenerateCommandsSection(root) + if err := doc.SpliceIntoReadme("README.md", section); err != nil { + log.Fatalf("gendocs: %v", err) + } + + toc := doc.GenerateCommandsTOC(root) + if err := doc.SpliceCommandsTOC("README.md", toc); err != nil { + log.Fatalf("gendocs: %v", err) + } +} diff --git a/cmd/model/get.go b/cmd/model/get.go index 5b1676be..416c859f 100644 --- a/cmd/model/get.go +++ b/cmd/model/get.go @@ -29,10 +29,25 @@ import ( // getCmd represents the get command. var getCmd = &cobra.Command{ - Use: "get", - Short: "Read a Single Authorization Model", - Long: "Read an authorization model, pass in an empty model ID to get latest.", - Example: `fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1`, + Use: "get", + Short: "Read a Single Authorization Model", + Long: "Read an authorization model. Omit --model-id to get the latest model.", + Example: `fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1 --field size --field model --field id --field created_at +fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4`, + Annotations: map[string]string{ + "docs:response": `# Model ID: 01GXSA8YR785C4FYS3C0RTG7B1 +# Created At: 2023-04-11 23:26:34.759 +0000 UTC +# Size: 20.05 KB +model + schema 1.1 + +type user + +type document + relations + define can_view: [user]`, + "docs:response:lang": "fga", + }, RunE: func(cmd *cobra.Command, _ []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/model/list.go b/cmd/model/list.go index a48cd6f5..b04e8708 100644 --- a/cmd/model/list.go +++ b/cmd/model/list.go @@ -71,8 +71,22 @@ func listModels( var listCmd = &cobra.Command{ Use: "list", Short: "Read Authorization Models", - Long: "List authorization models in a store.", + Long: "List all authorization models for a store, in descending order by creation date.", Example: "fga model list --store-id=01H0H015178Y2V4CX10C2KGHF4", + Annotations: map[string]string{ + "docs:response": `{ + "authorization_models": [ + { + "id":"01H6H9XH1G5Q6DK6PFMGDZNH9S", + "created_at":"2023-07-29T17:07:41Z" + }, + { + "id":"01H6H9PPR6C3P45R75X55ZFP46", + "created_at":"2023-07-29T17:03:57Z" + } + ] +}`, + }, RunE: func(cmd *cobra.Command, _ []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/model/model.go b/cmd/model/model.go index 8343a19b..9a7752b8 100644 --- a/cmd/model/model.go +++ b/cmd/model/model.go @@ -24,7 +24,7 @@ import ( // ModelCmd represents the store command. var ModelCmd = &cobra.Command{ Use: "model", - Short: "Interact with Authorization Models", + Short: "Authorization Models", Long: "Write, read, list and validate authorization models.", } diff --git a/cmd/model/test.go b/cmd/model/test.go index 82d5a37f..891d1651 100644 --- a/cmd/model/test.go +++ b/cmd/model/test.go @@ -41,10 +41,21 @@ var errNoTestFilesMatched = errors.New("no test files matched pattern") // modelTestCmd represents the test command. var modelTestCmd = &cobra.Command{ - Use: "test", - Short: "Test an Authorization Model", - Long: "Run a set of tests against a particular Authorization Model.", - Example: `fga model test --tests model.fga.yaml`, + Use: "test", + Short: "Test an Authorization Model", + Long: "Run a set of tests against a particular Authorization Model. If a model is provided the test will run in a built-in OpenFGA instance; otherwise it runs against the configured store.", + Example: `fga model test --tests model.fga.yaml +fga model test --tests "tests/*.fga.yaml"`, + Annotations: map[string]string{ + "docs:response": `(FAILING) test-1: Checks (2/3 passing) | ListObjects (2/2 passing) +ⅹ Check(user=user:anne,relation=can_share,object=folder:1): expected=false, got=true +--- +# Test Summary # +Tests 1/2 passing +Checks 3/4 passing +ListObjects 3/3 passing`, + "docs:response:lang": "text", + }, RunE: func(cmd *cobra.Command, _ []string) error { // Read and validate all flags testsFileName, err := cmd.Flags().GetString("tests") diff --git a/cmd/model/transform.go b/cmd/model/transform.go index 96056b5d..84af2ba1 100644 --- a/cmd/model/transform.go +++ b/cmd/model/transform.go @@ -43,11 +43,23 @@ func determineOutputFormat(input, output authorizationmodel.ModelFormat) authori // transformCmd represents the transform command. var transformCmd = &cobra.Command{ Use: "transform", - Short: "Transforms an authorization model", + Short: "Transform an Authorization Model", + Long: "Convert an authorization model between formats (.fga, .json, .mod).", Example: `fga model transform --file=model.json fga model transform --file=model.fga fga model transform '{ "schema_version": "1.1", "type_definitions":[{"type":"user"}] }' --input-format json fga model transform --file=fga.mod`, + Annotations: map[string]string{ + "docs:response": `model + schema 1.1 + +type user + +type document + relations + define can_view: [user]`, + "docs:response:lang": "fga", + }, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/model/validate.go b/cmd/model/validate.go index 4cb9bbc9..58d56b50 100644 --- a/cmd/model/validate.go +++ b/cmd/model/validate.go @@ -96,9 +96,15 @@ func validate(inputModel authorizationmodel.AuthzModel) validationResult { var validateCmd = &cobra.Command{ Use: "validate", Short: "Validate Authorization Model", - Long: "Validates that an authorization model is valid.", + Long: "Validates that an authorization model is valid. When the input parses successfully, the JSON response includes size_kb, the protobuf-serialized size of the model in KB.", Example: `fga model validate --file model.json`, - Args: cobra.MaximumNArgs(1), + Annotations: map[string]string{ + "docs:response": `{"id":"01GPGWB8R33HWXS3KK6YG4ETGH","created_at":"2023-01-11T16:59:22Z","is_valid":true,"size_kb":0.05} + +Invalid model: +{"id":"01GPGTVEH5NYTQ19RYFQKE0Q4Z","created_at":"2023-01-11T16:33:15Z","is_valid":false,"error":"invalid schema version","size_kb":0.05}`, + }, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { var inputModel string if err := authorizationmodel.ReadFromInputFileOrArg( diff --git a/cmd/model/write.go b/cmd/model/write.go index 23e7e682..b2350396 100644 --- a/cmd/model/write.go +++ b/cmd/model/write.go @@ -58,6 +58,11 @@ var writeCmd = &cobra.Command{ Example: `fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file=model.json fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file=fga.mod fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 '{"type_definitions":[{"type":"user"},{"type":"document","relations":{"can_view":{"this":{}}},"metadata":{"relations":{"can_view":{"directly_related_user_types":[{"type":"user"}]}}}}],"schema_version":"1.1"}' --format=json`, //nolint:lll + Annotations: map[string]string{ + "docs:response": `{ + "authorization_model_id":"01GXSA8YR785C4FYS3C0RTG7B1" +}`, + }, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/query/check.go b/cmd/query/check.go index da1414e5..76015244 100644 --- a/cmd/query/check.go +++ b/cmd/query/check.go @@ -66,7 +66,13 @@ var checkCmd = &cobra.Command{ Short: "Check", Example: `fga query check --store-id="01H4P8Z95KTXXEP6Z03T75Q984" user:anne can_view document:roadmap --context '{"ip_address":"127.0.0.1"}' --consistency "HIGHER_CONSISTENCY"`, //nolint:lll Long: "Check if a user has a particular relation with an object.", - Args: cobra.ExactArgs(3), //nolint:mnd + Annotations: map[string]string{ + "docs:response": `{ + "allowed": true, + "resolution": "" +}`, + }, + Args: cobra.ExactArgs(3), //nolint:mnd RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/query/expand.go b/cmd/query/expand.go index afb0faea..679dd18f 100644 --- a/cmd/query/expand.go +++ b/cmd/query/expand.go @@ -60,7 +60,30 @@ var expandCmd = &cobra.Command{ Short: "Expand", Long: "Expands the relationships in userset tree format.", Example: `fga query expand --store-id="01H4P8Z95KTXXEP6Z03T75Q984" can_view document:roadmap --consistency "HIGHER_CONSISTENCY"`, //nolint:lll - Args: cobra.ExactArgs(2), //nolint:mnd,lll + Annotations: map[string]string{ + "docs:response": `{ + "tree": { + "root": { + "name": "document:roadmap#can_view", + "union": { + "nodes": [ + { + "name": "document:roadmap#can_view", + "leaf": { + "users": { + "users": [ + "user:anne" + ] + } + } + } + ] + } + } + } +}`, + }, + Args: cobra.ExactArgs(2), //nolint:mnd,lll RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/query/list-objects.go b/cmd/query/list-objects.go index afa4bc51..0ce54101 100644 --- a/cmd/query/list-objects.go +++ b/cmd/query/list-objects.go @@ -66,7 +66,14 @@ var listObjectsCmd = &cobra.Command{ Short: "List Objects", Long: "List the objects of a certain type that a user has a particular relation to.", Example: `fga query list-objects --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document --contextual-tuple "user:anne can_view folder:product" --contextual-tuple "folder:product parent document:roadmap" --consistency "HIGHER_CONSISTENCY"`, //nolint:lll - Args: cobra.ExactArgs(3), //nolint:mnd,lll + Annotations: map[string]string{ + "docs:response": `{ + "objects": [ + "document:roadmap" + ] +}`, + }, + Args: cobra.ExactArgs(3), //nolint:mnd,lll RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/query/list-relations.go b/cmd/query/list-relations.go index 3a6a294a..9312ee52 100644 --- a/cmd/query/list-relations.go +++ b/cmd/query/list-relations.go @@ -130,7 +130,14 @@ var listRelationsCmd = &cobra.Command{ Short: "List Relations", Long: "List relations that a user has with an object.", Example: `fga query list-relations --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne document:roadmap --relation can_view --consistency "HIGHER_CONSISTENCY"`, //nolint:lll - Args: cobra.ExactArgs(2), //nolint:mnd,lll + Annotations: map[string]string{ + "docs:response": `{ + "relations": [ + "can_view" + ] +}`, + }, + Args: cobra.ExactArgs(2), //nolint:mnd,lll RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/query/list-users.go b/cmd/query/list-users.go index 0caf3775..b37dae17 100644 --- a/cmd/query/list-users.go +++ b/cmd/query/list-users.go @@ -93,9 +93,21 @@ func listUsers( var listUsersCmd = &cobra.Command{ Use: "list-users", - Short: "List users", + Short: "List Users", Long: "List all users that have a certain relation with a particular object", Example: `fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --consistency "HIGHER_CONSISTENCY"`, //nolint:lll + Annotations: map[string]string{ + "docs:response": `{ + "users": [ + { + "object": { + "type": "user", + "id": "anne" + } + } + ] +}`, + }, RunE: func(cmd *cobra.Command, _ []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/query/query.go b/cmd/query/query.go index 7df4834b..4887f28b 100644 --- a/cmd/query/query.go +++ b/cmd/query/query.go @@ -27,7 +27,7 @@ import ( // QueryCmd represents the query command. var QueryCmd = &cobra.Command{ Use: "query", - Short: "Run Queries", + Short: "Relationship Queries", Long: "Run queries (Check, Expand, ListObjects, ListRelations, ListUsers) that are evaluated according to a particular model.", //nolint:lll } diff --git a/cmd/root.go b/cmd/root.go index 3bbc7d91..9c6ea63a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -43,6 +43,12 @@ var rootCmd = &cobra.Command{ SilenceUsage: true, } +// RootCmd returns the root cobra command, used by tooling that needs to inspect +// the full command tree without executing it (e.g. doc generation, man pages). +func RootCmd() *cobra.Command { + return rootCmd +} + // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { diff --git a/cmd/store/create.go b/cmd/store/create.go index 2b205616..4b5ef63a 100644 --- a/cmd/store/create.go +++ b/cmd/store/create.go @@ -112,11 +112,29 @@ var createCmd = &cobra.Command{ Short: "Create Store", Long: "Create an OpenFGA store.", Example: `fga store create --name "FGA Demo Store" +fga store create --model Model.fga +export FGA_STORE_ID=$(fga store create --model model.fga | jq -r .store.id)`, + Annotations: map[string]string{ + "docs:response": `{ + "id": "01H0H015178Y2V4CX10C2KGHF4", + "name": "FGA Demo Store", + "created_at": "2023-05-19T16:10:07.637585677Z", + "updated_at": "2023-05-19T16:10:07.637585677Z" +} -To set the created store id as an environment variable that will be used by the CLI, you can use the following command: - -export FGA_STORE_ID=$(fga store create --model Model.fga | jq -r .store.id) - `, +Response for fga store create --model Model.fga: +{ + "store": { + "id":"01H6H9CNQRP2TVCFR7899XGNY8", + "name":"Model", + "created_at":"2023-07-29T16:58:28.984402Z", + "updated_at":"2023-07-29T16:58:28.984402Z" + }, + "model": { + "authorization_model_id":"01H6H9CNQV36Y9WS1RJGRN8D06" + } +}`, + }, RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd) storeName, _ := cmd.Flags().GetString("name") diff --git a/cmd/store/delete.go b/cmd/store/delete.go index 33135166..3240599f 100644 --- a/cmd/store/delete.go +++ b/cmd/store/delete.go @@ -34,6 +34,9 @@ var deleteCmd = &cobra.Command{ Short: "Delete Store", Long: "Mark a store as deleted.", Example: "fga store delete --store-id=01H0H015178Y2V4CX10C2KGHF4", + Annotations: map[string]string{ + "docs:response": `{}`, + }, RunE: func(cmd *cobra.Command, _ []string) error { clientConfig := cmdutils.GetClientConfig(cmd) // First, confirm whether this is intended diff --git a/cmd/store/export.go b/cmd/store/export.go index bb7ea594..c1cdb938 100644 --- a/cmd/store/export.go +++ b/cmd/store/export.go @@ -132,9 +132,24 @@ func buildStoreData(ctx context.Context, config fga.ClientConfig, fgaClient clie // exportCmd represents the export store command. var exportCmd = &cobra.Command{ Use: "export", - Short: "Export store data", - Long: `Export a store to YAML`, - Example: "fga store export", + Short: "Export Store Data", + Long: `Export a store to YAML. If --output-file is specified the response will be written to that file; otherwise it is written to stdout.`, + Example: "fga store export --store-id=01H0H015178Y2V4CX10C2KGHF4", + Annotations: map[string]string{ + "docs:response": `name: Test +model: |+ + model + schema 1.1 + + type user + +tuples: + - user: user:1 + relation: member + object: group:admins +tests: []`, + "docs:response:lang": "yaml", + }, RunE: func(cmd *cobra.Command, _ []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/store/get.go b/cmd/store/get.go index 37fdd219..47515ae3 100644 --- a/cmd/store/get.go +++ b/cmd/store/get.go @@ -46,6 +46,14 @@ var getCmd = &cobra.Command{ Short: "Get Store", Long: `Get a particular store.`, Example: "fga store get --store-id=01H0H015178Y2V4CX10C2KGHF4", + Annotations: map[string]string{ + "docs:response": `{ + "id": "01H0H015178Y2V4CX10C2KGHF4", + "name": "FGA Demo Store", + "created_at": "2023-05-19T16:10:07.637585677Z", + "updated_at": "2023-05-19T16:10:07.637585677Z" +}`, + }, RunE: func(cmd *cobra.Command, _ []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/store/import.go b/cmd/store/import.go index fa1673be..4f406c95 100644 --- a/cmd/store/import.go +++ b/cmd/store/import.go @@ -311,6 +311,9 @@ var importCmd = &cobra.Command{ Short: "Import Store Data", Long: `Import a store: updating the name, model and appending the global tuples`, Example: "fga store import --file=model.fga.yaml", + Annotations: map[string]string{ + "docs:response": `{}`, + }, RunE: func(cmd *cobra.Command, _ []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/store/list.go b/cmd/store/list.go index 40e87d75..fd7e8920 100644 --- a/cmd/store/list.go +++ b/cmd/store/list.go @@ -73,6 +73,17 @@ var listCmd = &cobra.Command{ Short: "List Stores", Long: `Get a list of stores.`, Example: "fga store list", + Annotations: map[string]string{ + "docs:response": `{ + "stores": [{ + "id": "..", + "name": "..", + "created_at": "", + "updated_at": "", + "deleted_at": "" + }] +}`, + }, RunE: func(cmd *cobra.Command, _ []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/store/store.go b/cmd/store/store.go index 88294419..a8f9c789 100644 --- a/cmd/store/store.go +++ b/cmd/store/store.go @@ -24,7 +24,7 @@ import ( // StoreCmd represents the store command. var StoreCmd = &cobra.Command{ Use: "store", - Short: "Interact with OpenFGA Stores", + Short: "Stores", Long: "Create, Get, Delete and List OpenFGA Stores", } diff --git a/cmd/tuple/changes.go b/cmd/tuple/changes.go index 2e7d7453..f3081cd6 100644 --- a/cmd/tuple/changes.go +++ b/cmd/tuple/changes.go @@ -88,11 +88,26 @@ func readChanges( // changesCmd represents the changes command. var changesCmd = &cobra.Command{ - Use: "changes", - Short: "Read Relationship Tuple Changes (Watch)", - Long: "Get a list of relationship tuple changes (Writes and Deletes) across time.", - Example: `fga tuple changes --store-id=01H0H015178Y2V4CX10C2KGHF4 --type document - --start-time 2022-01-01T00:00:00Z --continuation-token=MXw=`, + Use: "changes", + Short: "Read Relationship Tuple Changes (Watch)", + Long: "Get a list of relationship tuple changes (Writes and Deletes) across time.", + Example: `fga tuple changes --store-id=01H0H015178Y2V4CX10C2KGHF4 --type=document --continuation-token=M3w=`, + Annotations: map[string]string{ + "docs:response": `{ + "changes": [ + { + "operation": "TUPLE_OPERATION_WRITE", + "timestamp": "2023-07-06T15:12:40.294950382Z", + "tuple_key": { + "object": "document:roadmap", + "relation": "can_view", + "user": "user:anne" + } + } + ], + "continuation_token":"NHw=" +}`, + }, RunE: func(cmd *cobra.Command, _ []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/tuple/delete.go b/cmd/tuple/delete.go index 4bf5bd37..78dbb35e 100644 --- a/cmd/tuple/delete.go +++ b/cmd/tuple/delete.go @@ -38,6 +38,21 @@ var deleteCmd = &cobra.Command{ Long: "Delete relationship tuples from the store.", Example: ` fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --on-missing ignore`, + Annotations: map[string]string{ + "docs:response": `{} + +Response when using --file: +{ + "successful": [ + { + "object":"document:roadmap", + "relation":"writer", + "user":"user:annie" + } + ], + "failed": [] +}`, + }, RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/tuple/read.go b/cmd/tuple/read.go index 3360b1ca..f30cc695 100644 --- a/cmd/tuple/read.go +++ b/cmd/tuple/read.go @@ -169,6 +169,20 @@ var readCmd = &cobra.Command{ Short: "Read Relationship Tuples", Long: "Read relationship tuples that exist in the system (does not evaluate).", Example: "fga tuple read --store-id=01H0H015178Y2V4CX10C2KGHF4 --user user:anne --relation can_view --object document:roadmap", //nolint:lll + Annotations: map[string]string{ + "docs:response": `{ + "tuples": [ + { + "key": { + "object": "document:roadmap", + "relation": "can_view", + "user": "user:anne" + }, + "timestamp": "2023-07-06T15:12:55.080666875Z" + } + ] +}`, + }, RunE: func(cmd *cobra.Command, _ []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/tuple/tuple.go b/cmd/tuple/tuple.go index d5af2254..a53f143e 100644 --- a/cmd/tuple/tuple.go +++ b/cmd/tuple/tuple.go @@ -27,7 +27,7 @@ import ( // TupleCmd represents the tuple command. var TupleCmd = &cobra.Command{ Use: "tuple", - Short: "Interact with Relationship Tuples", + Short: "Relationship Tuples", Long: "Read, write, delete, import and listen to changes in relationship tuples in a store.", } diff --git a/cmd/tuple/write.go b/cmd/tuple/write.go index 851f3f36..42019062 100644 --- a/cmd/tuple/write.go +++ b/cmd/tuple/write.go @@ -77,6 +77,21 @@ var writeCmd = &cobra.Command{ fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --max-tuples-per-write 10 --max-parallel-requests 5 fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --max-rps 10 fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --on-duplicate ignore`, + Annotations: map[string]string{ + "docs:response": `{ + "successful": [ + { + "object":"document:roadmap", + "relation":"writer", + "user":"user:annie" + } + ], + "failed": [], + "failed_count": 0, + "successful_count": 1, + "total_count": 1 +}`, + }, RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/internal/doc/generate.go b/internal/doc/generate.go new file mode 100644 index 00000000..ad2078fc --- /dev/null +++ b/internal/doc/generate.go @@ -0,0 +1,202 @@ +/* +Copyright © 2023 OpenFGA + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package doc generates the Commands section of README.md from cobra command metadata. +package doc + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const ( + beginMarker = "" + endMarker = "" + beginTOCMarker = "" + endTOCMarker = "" +) + +// GenerateCommandsSection walks the cobra command tree rooted at rootCmd and +// returns a markdown string suitable for splicing between the README markers. +// Hidden and unavailable commands are excluded. +func GenerateCommandsSection(rootCmd *cobra.Command) string { + var b strings.Builder + + for _, cmd := range rootCmd.Commands() { + if !cmd.IsAvailableCommand() || cmd.IsAdditionalHelpTopicCommand() { + continue + } + if cmd.Runnable() { + continue // top-level runnables (e.g. version) have no group heading — skip, consistent with TOC + } + + writeSection(&b, cmd) + } + + return b.String() +} + +func writeSection(b *strings.Builder, cmd *cobra.Command) { + if !cmd.Runnable() { + fmt.Fprintf(b, "#### %s\n\n", cmd.Short) + + for _, sub := range cmd.Commands() { + if !sub.IsAvailableCommand() || sub.IsAdditionalHelpTopicCommand() { + continue + } + + writeSection(b, sub) + } + + return + } + + fmt.Fprintf(b, "##### %s\n\n", cmd.Short) + + fmt.Fprintf(b, "###### Command\n\n```\n%s\n```\n\n", cmd.UseLine()) + + if hasVisibleLocalFlags(cmd) { + fmt.Fprintf(b, "###### Parameters\n\n") + + cmd.NonInheritedFlags().VisitAll(func(f *pflag.Flag) { + if f.Hidden { + return + } + + fmt.Fprintf(b, "* `--%s`: %s\n", f.Name, f.Usage) + }) + + fmt.Fprintf(b, "\n") + } + + if cmd.Example != "" { + fmt.Fprintf(b, "###### Example\n\n```bash\n%s\n```\n\n", strings.TrimSpace(cmd.Example)) + } + + if response, ok := cmd.Annotations["docs:response"]; ok && response != "" { + lang := cmd.Annotations["docs:response:lang"] + if lang == "" { + lang = "json" + } + + fmt.Fprintf(b, "###### Response\n\n```%s\n%s\n```\n\n", lang, response) + } +} + +func hasVisibleLocalFlags(cmd *cobra.Command) bool { + hasVisible := false + + cmd.NonInheritedFlags().VisitAll(func(f *pflag.Flag) { + if !f.Hidden { + hasVisible = true + } + }) + + return hasVisible +} + +// SpliceIntoReadme replaces the content between the BEGIN_COMMANDS and +// END_COMMANDS HTML comment markers in the file at readmePath with section. +func SpliceIntoReadme(readmePath, section string) error { + return splice(readmePath, beginMarker, endMarker, section) +} + +// SpliceCommandsTOC replaces the content between the BEGIN_COMMANDS_TOC and +// END_COMMANDS_TOC HTML comment markers in the file at readmePath with toc. +func SpliceCommandsTOC(readmePath, toc string) error { + return splice(readmePath, beginTOCMarker, endTOCMarker, toc) +} + +func splice(readmePath, begin, end, section string) error { + content, err := os.ReadFile(readmePath) + if err != nil { + return fmt.Errorf("failed to read %s: %w", readmePath, err) + } + + text := string(content) + beginIdx := strings.Index(text, begin) + endIdx := strings.Index(text, end) + + if beginIdx == -1 || endIdx == -1 { + return fmt.Errorf("splice markers not found in %s: expected %q and %q", readmePath, begin, end) + } + + if beginIdx > endIdx { + return fmt.Errorf("splice markers out of order in %s: %q appears after %q", readmePath, begin, end) + } + + newContent := text[:beginIdx+len(begin)] + "\n" + section + text[endIdx:] + + return os.WriteFile(readmePath, []byte(newContent), 0o644) //nolint:wrapcheck +} + +// GenerateCommandsTOC returns the markdown list entry for Commands and all its +// subgroups, suitable for splicing between the BEGIN_COMMANDS_TOC markers. +// The marker wraps the entire - [Commands] subtree so no HTML comment +// interrupts the list and triggers code-block parsing. +func GenerateCommandsTOC(rootCmd *cobra.Command) string { + var b strings.Builder + + b.WriteString(" - [Commands](#commands)\n") + + for _, cmd := range rootCmd.Commands() { + if !cmd.IsAvailableCommand() || cmd.IsAdditionalHelpTopicCommand() { + continue + } + + writeTOCSection(&b, cmd) + } + + return b.String() +} + +func writeTOCSection(b *strings.Builder, cmd *cobra.Command) { + if cmd.Runnable() { + return // top-level runnables (e.g. version) are not command groups — skip + } + + fmt.Fprintf(b, " - [%s](#%s)\n", cmd.Short, anchor(cmd.Short)) + + for _, sub := range cmd.Commands() { + if !sub.IsAvailableCommand() || sub.IsAdditionalHelpTopicCommand() { + continue + } + + fmt.Fprintf(b, " - [%s](#%s)\n", sub.Short, anchor(sub.Short)) + } +} + +// anchor converts a heading string to a GitHub markdown anchor. +// "Read a Single Authorization Model" -> "read-a-single-authorization-model". +func anchor(s string) string { + s = strings.ToLower(s) + var b strings.Builder + + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + case r == ' ': + b.WriteRune('-') + } + } + + return b.String() +} diff --git a/internal/doc/generate_test.go b/internal/doc/generate_test.go new file mode 100644 index 00000000..994847b3 --- /dev/null +++ b/internal/doc/generate_test.go @@ -0,0 +1,388 @@ +/* +Copyright © 2023 OpenFGA + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package doc_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openfga/cli/internal/doc" +) + +// buildTestTree builds a minimal cobra tree mirroring the real fga structure. +func buildTestTree() *cobra.Command { + root := &cobra.Command{Use: "fga", Short: "OpenFGA CLI"} + + storeGroup := &cobra.Command{ + Use: "store", + Short: "Stores", + Long: "Manage OpenFGA stores.", + } + + createCmd := &cobra.Command{ + Use: "create", + Short: "Create Store", + Long: "Create an OpenFGA store.", + Example: `fga store create --name "FGA Demo Store"`, + Annotations: map[string]string{ + "docs:response": `{"id": "01H0H015178Y2V4CX10C2KGHF4", "name": "FGA Demo Store"}`, + }, + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + createCmd.Flags().String("name", "", "Store Name") + + listCmd := &cobra.Command{ + Use: "list", + Short: "List Stores", + Long: "List OpenFGA stores.", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + + hiddenCmd := &cobra.Command{ + Use: "hidden", + Short: "Hidden Command", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + + storeGroup.AddCommand(createCmd, listCmd, hiddenCmd) + root.AddCommand(storeGroup) + + return root +} + +func TestGenerateCommandsSection_GroupHeader(t *testing.T) { + t.Parallel() + + root := buildTestTree() + section := doc.GenerateCommandsSection(root) + + assert.Contains(t, section, "#### Stores") +} + +func TestGenerateCommandsSection_CommandHeader(t *testing.T) { + t.Parallel() + + root := buildTestTree() + section := doc.GenerateCommandsSection(root) + + assert.Contains(t, section, "##### Create Store") +} + +func TestGenerateCommandsSection_CommandSection(t *testing.T) { + t.Parallel() + + root := buildTestTree() + section := doc.GenerateCommandsSection(root) + + assert.Contains(t, section, "###### Command") + assert.Contains(t, section, "fga store create") +} + +func TestGenerateCommandsSection_ParametersSection(t *testing.T) { + t.Parallel() + + root := buildTestTree() + section := doc.GenerateCommandsSection(root) + + assert.Contains(t, section, "###### Parameters") + assert.Contains(t, section, "`--name`") + assert.Contains(t, section, "Store Name") +} + +func TestGenerateCommandsSection_ExampleSection(t *testing.T) { + t.Parallel() + + root := buildTestTree() + section := doc.GenerateCommandsSection(root) + + assert.Contains(t, section, "###### Example") + assert.Contains(t, section, `FGA Demo Store`) +} + +func TestGenerateCommandsSection_ResponseSection(t *testing.T) { + t.Parallel() + + root := buildTestTree() + section := doc.GenerateCommandsSection(root) + + assert.Contains(t, section, "###### Response") + assert.Contains(t, section, "01H0H015178Y2V4CX10C2KGHF4") +} + +func TestGenerateCommandsSection_OmitsParametersSectionWhenNoFlags(t *testing.T) { + t.Parallel() + + root := buildTestTree() + section := doc.GenerateCommandsSection(root) + + // list command has no flags — find its section and verify no Parameters header follows + listIdx := strings.Index(section, "##### List Stores") + require.NotEqual(t, -1, listIdx) + + // Find the next command header after list + nextCmdIdx := strings.Index(section[listIdx+1:], "#####") + var listSection string + if nextCmdIdx == -1 { + listSection = section[listIdx:] + } else { + listSection = section[listIdx : listIdx+1+nextCmdIdx] + } + + assert.NotContains(t, listSection, "###### Parameters") +} + +func TestGenerateCommandsSection_OmitsExampleSectionWhenEmpty(t *testing.T) { + t.Parallel() + + root := buildTestTree() + section := doc.GenerateCommandsSection(root) + + // list command has no Example field + listIdx := strings.Index(section, "##### List Stores") + require.NotEqual(t, -1, listIdx) + + nextCmdIdx := strings.Index(section[listIdx+1:], "#####") + var listSection string + if nextCmdIdx == -1 { + listSection = section[listIdx:] + } else { + listSection = section[listIdx : listIdx+1+nextCmdIdx] + } + + assert.NotContains(t, listSection, "###### Example") +} + +func TestGenerateCommandsSection_OmitsResponseSectionWhenNoAnnotation(t *testing.T) { + t.Parallel() + + root := buildTestTree() + section := doc.GenerateCommandsSection(root) + + listIdx := strings.Index(section, "##### List Stores") + require.NotEqual(t, -1, listIdx) + + nextCmdIdx := strings.Index(section[listIdx+1:], "#####") + var listSection string + if nextCmdIdx == -1 { + listSection = section[listIdx:] + } else { + listSection = section[listIdx : listIdx+1+nextCmdIdx] + } + + assert.NotContains(t, listSection, "###### Response") +} + +func TestGenerateCommandsSection_HiddenCommandsExcluded(t *testing.T) { + t.Parallel() + + root := buildTestTree() + section := doc.GenerateCommandsSection(root) + + assert.NotContains(t, section, "Hidden Command") +} + +func TestSpliceIntoReadme_ReplacesContentBetweenMarkers(t *testing.T) { + t.Parallel() + + original := "# Title\n\n\nold content here\n\n\n# Footer\n" + newSection := "new generated content" + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "README.md") + require.NoError(t, os.WriteFile(path, []byte(original), 0o600)) + + err := doc.SpliceIntoReadme(path, newSection) + require.NoError(t, err) + + result, err := os.ReadFile(path) + require.NoError(t, err) + + content := string(result) + assert.Contains(t, content, "# Title") + assert.Contains(t, content, "") + assert.Contains(t, content, newSection) + assert.Contains(t, content, "") + assert.Contains(t, content, "# Footer") + assert.NotContains(t, content, "old content here") +} + +func TestSpliceIntoReadme_ErrorsWhenMarkersNotFound(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "README.md") + require.NoError(t, os.WriteFile(path, []byte("# No markers here\n"), 0o600)) + + err := doc.SpliceIntoReadme(path, "content") + require.Error(t, err) +} + +func TestGenerateCommandsTOC_StartsWithCommandsHeader(t *testing.T) { + t.Parallel() + + root := buildTestTree() + toc := doc.GenerateCommandsTOC(root) + + assert.True(t, strings.HasPrefix(toc, " - [Commands](#commands)\n")) +} + +func TestGenerateCommandsTOC_GroupEntries(t *testing.T) { + t.Parallel() + + root := buildTestTree() + toc := doc.GenerateCommandsTOC(root) + + assert.Contains(t, toc, "- [Stores](#stores)") +} + +func TestGenerateCommandsTOC_CommandEntries(t *testing.T) { + t.Parallel() + + root := buildTestTree() + toc := doc.GenerateCommandsTOC(root) + + assert.Contains(t, toc, "- [Create Store](#create-store)") + assert.Contains(t, toc, "- [List Stores](#list-stores)") +} + +func TestGenerateCommandsTOC_CommandsIndentedUnderGroup(t *testing.T) { + t.Parallel() + + root := buildTestTree() + toc := doc.GenerateCommandsTOC(root) + + // Group at 4-space indent, commands at 6-space indent + assert.Contains(t, toc, " - [Stores](#stores)\n") + assert.Contains(t, toc, " - [Create Store](#create-store)\n") +} + +func TestGenerateCommandsTOC_HiddenCommandsExcluded(t *testing.T) { + t.Parallel() + + root := buildTestTree() + toc := doc.GenerateCommandsTOC(root) + + assert.NotContains(t, toc, "Hidden Command") +} + +func TestSpliceCommandsTOC_ReplacesContentBetweenMarkers(t *testing.T) { + t.Parallel() + + original := "# Title\n\n\nold toc\n\n\n# Footer\n" + newTOC := "new toc content" + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "README.md") + require.NoError(t, os.WriteFile(path, []byte(original), 0o600)) + + err := doc.SpliceCommandsTOC(path, newTOC) + require.NoError(t, err) + + result, err := os.ReadFile(path) + require.NoError(t, err) + + content := string(result) + assert.Contains(t, content, newTOC) + assert.NotContains(t, content, "old toc") +} + +func TestSpliceIntoReadme_ErrorsWhenMarkersOutOfOrder(t *testing.T) { + t.Parallel() + + // END before BEGIN — splice must error rather than produce garbled output + original := "\nsome content\n\n" + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "README.md") + require.NoError(t, os.WriteFile(path, []byte(original), 0o600)) + + err := doc.SpliceIntoReadme(path, "content") + require.Error(t, err) +} + +func TestGenerateCommandsSection_TopLevelRunnableExcluded(t *testing.T) { + t.Parallel() + + root := buildTestTree() + versionCmd := &cobra.Command{ + Use: "version", + Short: "Print version", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + root.AddCommand(versionCmd) + + section := doc.GenerateCommandsSection(root) + + assert.NotContains(t, section, "Print version") +} + +func TestGenerateCommandsSection_NonJSONResponseLang(t *testing.T) { + t.Parallel() + + root := &cobra.Command{Use: "fga"} + group := &cobra.Command{Use: "model", Short: "Models"} + cmd := &cobra.Command{ + Use: "transform", + Short: "Transform Model", + Annotations: map[string]string{ + "docs:response": "model\n schema 1.1", + "docs:response:lang": "fga", + }, + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + group.AddCommand(cmd) + root.AddCommand(group) + + section := doc.GenerateCommandsSection(root) + + assert.Contains(t, section, "```fga\nmodel\n schema 1.1\n```") +} + +func TestAnchor_PunctuationStripped(t *testing.T) { + t.Parallel() + + cases := []struct { + input string + want string + }{ + {"Read Relationship Tuple Changes (Watch)", "read-relationship-tuple-changes-watch"}, + {"Authorization Models", "authorization-models"}, + {"List Objects", "list-objects"}, + } + + for _, tc := range cases { + section := doc.GenerateCommandsTOC(func() *cobra.Command { + root := &cobra.Command{Use: "fga"} + group := &cobra.Command{Use: "grp", Short: tc.input} + group.AddCommand(&cobra.Command{ + Use: "sub", + Short: "Sub", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + }) + root.AddCommand(group) + return root + }()) + assert.Contains(t, section, "#"+tc.want, "anchor for %q", tc.input) + } +} From e850c80280a5da324ff6b45dc398b01027d13302 Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Fri, 9 Oct 2026 12:13:56 +0100 Subject: [PATCH 2/8] =?UTF-8?q?feat(docs):=20address=20reviewer=20findings?= =?UTF-8?q?=20=E2=80=94=20ordering,=20Long,=20group=20flags,=20positional?= =?UTF-8?q?=20args?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve registration order in generated docs by setting cobra.EnableCommandSorting=false in cmd/gendocs/main.go and reordering AddCommand calls to match the intended learning path: store -> model -> tuple -> query -> mapping at the top level, and a write-before-read subcommand order within each group. Render cmd.Long for both runnable and group commands so descriptions like "Check if a user has a particular relation with an object" appear in the generated section. Emit a Parameters section under group command headings for their PersistentFlags, so --store-id, --model-id, --contextual-tuple, --context, and --consistency are documented under Relationship Queries rather than silently omitted. Update Use fields on check, expand, list-objects, list-relations (query) and write, delete (tuple) to include positional argument names so UseLine() generates complete syntax like "fga query check " instead of "fga query check". # Conflicts: # cmd/mapping/mapping.go --- README.md | 980 +++++++++++++++++++--------------- cmd/gendocs/main.go | 3 + cmd/model/model.go | 2 +- cmd/query/check.go | 2 +- cmd/query/expand.go | 2 +- cmd/query/list-objects.go | 2 +- cmd/query/list-relations.go | 2 +- cmd/root.go | 2 +- cmd/store/store.go | 2 +- cmd/tuple/delete.go | 2 +- cmd/tuple/tuple.go | 4 +- cmd/tuple/write.go | 2 +- internal/doc/generate.go | 34 ++ internal/doc/generate_test.go | 54 ++ 14 files changed, 648 insertions(+), 445 deletions(-) diff --git a/README.md b/README.md index 815e848d..8ca098d4 100644 --- a/README.md +++ b/README.md @@ -20,36 +20,36 @@ A cross-platform CLI to interact with an OpenFGA server - [Custom Headers](#custom-headers) - [Commands](#commands) - - [Manage JSON-to-tuple mappings](#manage-jsontotuple-mappings) - - [Scaffold a starter mapping file](#scaffold-a-starter-mapping-file) - - [Evaluate a mapping against JSON input and emit tuple operations](#evaluate-a-mapping-against-json-input-and-emit-tuple-operations) - - [Run the embedded tests in a mapping file](#run-the-embedded-tests-in-a-mapping-file) - - [Validate a mapping file](#validate-a-mapping-file) + - [Stores](#stores) + - [Create Store](#create-store) + - [Get Store](#get-store) + - [List Stores](#list-stores) + - [Delete Store](#delete-store) + - [Import Store Data](#import-store-data) + - [Export Store Data](#export-store-data) - [Authorization Models](#authorization-models) + - [Write Authorization Model](#write-authorization-model) - [Read a Single Authorization Model](#read-a-single-authorization-model) - [Read Authorization Models](#read-authorization-models) - - [Test an Authorization Model](#test-an-authorization-model) - - [Transform an Authorization Model](#transform-an-authorization-model) - [Validate Authorization Model](#validate-authorization-model) - - [Write Authorization Model](#write-authorization-model) + - [Transform an Authorization Model](#transform-an-authorization-model) + - [Test an Authorization Model](#test-an-authorization-model) + - [Relationship Tuples](#relationship-tuples) + - [Create Relationship Tuples](#create-relationship-tuples) + - [Delete Relationship Tuples](#delete-relationship-tuples) + - [Read Relationship Tuples](#read-relationship-tuples) + - [Read Relationship Tuple Changes (Watch)](#read-relationship-tuple-changes-watch) - [Relationship Queries](#relationship-queries) - [Check](#check) - [Expand](#expand) - [List Objects](#list-objects) - [List Relations](#list-relations) - [List Users](#list-users) - - [Stores](#stores) - - [Create Store](#create-store) - - [Delete Store](#delete-store) - - [Export Store Data](#export-store-data) - - [Get Store](#get-store) - - [Import Store Data](#import-store-data) - - [List Stores](#list-stores) - - [Relationship Tuples](#relationship-tuples) - - [Read Relationship Tuple Changes (Watch)](#read-relationship-tuple-changes-watch) - - [Delete Relationship Tuples](#delete-relationship-tuples) - - [Read Relationship Tuples](#read-relationship-tuples) - - [Create Relationship Tuples](#create-relationship-tuples) + - [Manage JSON-to-tuple mappings](#manage-jsontotuple-mappings) + - [Scaffold a starter mapping file](#scaffold-a-starter-mapping-file) + - [Validate a mapping file](#validate-a-mapping-file) + - [Run the embedded tests in a mapping file](#run-the-embedded-tests-in-a-mapping-file) + - [Evaluate a mapping against JSON input and emit tuple operations](#evaluate-a-mapping-against-json-input-and-emit-tuple-operations) - [Contributing](#contributing) - [License](#license) @@ -208,285 +208,233 @@ custom-headers: ### Commands -#### Manage JSON-to-tuple mappings +#### Stores -##### Scaffold a starter mapping file +Create, Get, Delete and List OpenFGA Stores + +##### Create Store + +Create an OpenFGA store. ###### Command ``` -fga mapping init [mapping-file] [flags] +fga store create [flags] ``` ###### Parameters -* `--force`: Overwrite an existing file -* `--minimal`: Emit a skeleton without the example test block +* `--format`: Authorization model input format. Can be "fga", "json" or "modular +* `--model`: Authorization Model File Name +* `--name`: Store Name ###### Example ```bash -fga mapping init - fga mapping init my-mapping.yaml - fga mapping init --minimal mapping.yaml - fga mapping init --force mapping.yaml +fga store create --name "FGA Demo Store" +fga store create --model Model.fga +export FGA_STORE_ID=$(fga store create --model model.fga | jq -r .store.id) ``` -##### Evaluate a mapping against JSON input and emit tuple operations +###### Response -###### Command +```json +{ + "id": "01H0H015178Y2V4CX10C2KGHF4", + "name": "FGA Demo Store", + "created_at": "2023-05-19T16:10:07.637585677Z", + "updated_at": "2023-05-19T16:10:07.637585677Z" +} +Response for fga store create --model Model.fga: +{ + "store": { + "id":"01H6H9CNQRP2TVCFR7899XGNY8", + "name":"Model", + "created_at":"2023-07-29T16:58:28.984402Z", + "updated_at":"2023-07-29T16:58:28.984402Z" + }, + "model": { + "authorization_model_id":"01H6H9CNQV36Y9WS1RJGRN8D06" + } +} ``` -fga mapping run [mapping-file] [flags] -``` - -###### Parameters - -* `--aggregate`: Buffer all records and collapse them (dedup tuples and filters, detect write/delete conflicts) before emitting -* `--continue-on-error`: Skip input records that fail to parse or evaluate (warn to stderr) and exit non-zero if any were skipped -* `--format`: Output format: "jsonl" or "json" -* `--input`: Path to JSONL input file, one JSON object per line (default: stdin) -* `--interactive`: Explore the mapping in a terminal loop: paste JSON documents and see the tuples they produce (requires a TTY) -* `--writes-only`: Emit only write operations in ClientTupleKey format (consumable by fga tuple write) - -###### Example -```bash -echo '{"id":"anne","org":"acme"}' | fga mapping run mapping.yaml - fga mapping run mapping.yaml --input event.json --format json - fga mapping run --writes-only mapping.yaml > out.jsonl && fga tuple write --store-id $STORE_ID --file out.jsonl - fga mapping run mapping.yaml -i -``` +##### Get Store -##### Run the embedded tests in a mapping file +Get a particular store. ###### Command ``` -fga mapping test [mapping-file] [flags] +fga store get [flags] ``` ###### Parameters -* `--fail-fast`: Stop after the first failing test -* `--format`: Output format: "text", "json", or "junit" -* `--no-color`: Disable color in text output -* `--output-file`: Write output to a file instead of stdout -* `--run`: Run only tests whose name contains this substring (case-sensitive) -* `--verbose`: Show rule trace and tuple details for each test (text format only) +* `--store-id`: Store ID ###### Example ```bash -fga mapping test mapping.yaml - fga mapping test --format json mapping.yaml - fga mapping test --format junit --output-file results.xml mapping.yaml - fga mapping test --run anne mapping.yaml - fga mapping test --fail-fast mapping.yaml -``` - -##### Validate a mapping file - -###### Command - -``` -fga mapping validate [mapping-file] [flags] +fga store get --store-id=01H0H015178Y2V4CX10C2KGHF4 ``` -###### Parameters - -* `--format`: Output format: "text" or "json" -* `--model-file`: Path to FGA authorization model file (DSL, JSON, or modular) -* `--verbose`: Show per-rule validation status (text format only) - -###### Example +###### Response -```bash -fga mapping validate mapping.yaml - fga mapping validate --format json mapping.yaml - fga mapping validate --model-file model.fga mapping.yaml +```json +{ + "id": "01H0H015178Y2V4CX10C2KGHF4", + "name": "FGA Demo Store", + "created_at": "2023-05-19T16:10:07.637585677Z", + "updated_at": "2023-05-19T16:10:07.637585677Z" +} ``` -#### Authorization Models +##### List Stores -##### Read a Single Authorization Model +Get a list of stores. ###### Command ``` -fga model get [flags] +fga store list [flags] ``` ###### Parameters -* `--field`: Fields to display, choices are: id, created_at, size and model -* `--format`: Authorization model output format. Can be "fga" or "json" -* `--model-id`: Authorization Model ID -* `--store-id`: Store ID +* `--max-pages`: Max number of pages to get. +* `--name`: Filter stores by exact name. Substrings and regexes are not supported. ###### Example ```bash -fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1 --field size --field model --field id --field created_at -fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 +fga store list ``` ###### Response -```fga -# Model ID: 01GXSA8YR785C4FYS3C0RTG7B1 -# Created At: 2023-04-11 23:26:34.759 +0000 UTC -# Size: 20.05 KB -model - schema 1.1 - -type user - -type document - relations - define can_view: [user] +```json +{ + "stores": [{ + "id": "..", + "name": "..", + "created_at": "", + "updated_at": "", + "deleted_at": "" + }] +} ``` -##### Read Authorization Models +##### Delete Store + +Mark a store as deleted. ###### Command ``` -fga model list [flags] +fga store delete [flags] ``` ###### Parameters -* `--field`: Fields to display, choices are: id, created_at and model -* `--max-pages`: Max number of pages to get. +* `--force`: Force delete without confirmation * `--store-id`: Store ID ###### Example ```bash -fga model list --store-id=01H0H015178Y2V4CX10C2KGHF4 +fga store delete --store-id=01H0H015178Y2V4CX10C2KGHF4 ``` ###### Response ```json -{ - "authorization_models": [ - { - "id":"01H6H9XH1G5Q6DK6PFMGDZNH9S", - "created_at":"2023-07-29T17:07:41Z" - }, - { - "id":"01H6H9PPR6C3P45R75X55ZFP46", - "created_at":"2023-07-29T17:03:57Z" - } - ] -} +{} ``` -##### Test an Authorization Model +##### Import Store Data + +Import a store: updating the name, model and appending the global tuples ###### Command ``` -fga model test [flags] +fga store import [flags] ``` ###### Parameters -* `--allow-external-files`: Allow model_file, tuple_file and tuple_files references in the test file to resolve to paths outside the test file's directory. Only enable this for test files you trust. -* `--max-types-per-authorization-model`: Max allowed number of type definitions per authorization model -* `--model-id`: Model ID +* `--allow-external-files`: Allow model_file, tuple_file and tuple_files references in the store file to resolve to paths outside the store file's directory. Only enable this for store files you trust. +* `--file`: File Name. The file should have the store +* `--max-parallel-requests`: Max number of requests to issue to the server in parallel. +* `--max-tuples-per-write`: Max tuples per write chunk. * `--store-id`: Store ID -* `--suppress-summary`: Suppress the plain text summary output -* `--tests`: Path or glob of YAML test files -* `--verbose`: Print verbose JSON output ###### Example ```bash -fga model test --tests model.fga.yaml -fga model test --tests "tests/*.fga.yaml" +fga store import --file=model.fga.yaml ``` ###### Response -```text -(FAILING) test-1: Checks (2/3 passing) | ListObjects (2/2 passing) -ⅹ Check(user=user:anne,relation=can_share,object=folder:1): expected=false, got=true ---- -# Test Summary # -Tests 1/2 passing -Checks 3/4 passing -ListObjects 3/3 passing +```json +{} ``` -##### Transform an Authorization Model +##### Export Store Data + +Export a store to YAML. If --output-file is specified the response will be written to that file; otherwise it is written to stdout. ###### Command ``` -fga model transform [flags] +fga store export [flags] ``` ###### Parameters -* `--file`: File Name. The file should have the model in the JSON or DSL format or be an `fga.mod` format -* `--input-format`: Authorization model input format. Can be "fga", "json", or "modular" -* `--output-format`: Authorization model output format. Can be "fga" or "json"." +* `--max-tuples`: max number of tuples to return in the output +* `--model-id`: Authorization Model ID +* `--output-file`: name of the file to export the store to +* `--store-id`: store ID ###### Example ```bash -fga model transform --file=model.json -fga model transform --file=model.fga -fga model transform '{ "schema_version": "1.1", "type_definitions":[{"type":"user"}] }' --input-format json -fga model transform --file=fga.mod +fga store export --store-id=01H0H015178Y2V4CX10C2KGHF4 ``` ###### Response -```fga -model - schema 1.1 - -type user - -type document - relations - define can_view: [user] -``` - -##### Validate Authorization Model +```yaml +name: Test +model: |+ + model + schema 1.1 -###### Command + type user +tuples: + - user: user:1 + relation: member + object: group:admins +tests: [] ``` -fga model validate [flags] -``` - -###### Parameters - -* `--file`: File Name. The file should have the model in the JSON or DSL format or be an fga.mod file -* `--format`: Authorization model input format. Can be "fga", "json", or "modular" -###### Example - -```bash -fga model validate --file model.json -``` +#### Authorization Models -###### Response +Write, read, list and validate authorization models. -```json -{"id":"01GPGWB8R33HWXS3KK6YG4ETGH","created_at":"2023-01-11T16:59:22Z","is_valid":true,"size_kb":0.05} +##### Parameters -Invalid model: -{"id":"01GPGTVEH5NYTQ19RYFQKE0Q4Z","created_at":"2023-01-11T16:33:15Z","is_valid":false,"error":"invalid schema version","size_kb":0.05} -``` +* `--store-id`: Store ID ##### Write Authorization Model +Writes a new authorization model. + ###### Command ``` @@ -515,363 +463,360 @@ fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 '{"type_definitions":[{"ty } ``` -#### Relationship Queries +##### Read a Single Authorization Model -##### Check +Read an authorization model. Omit --model-id to get the latest model. ###### Command ``` -fga query check +fga model get [flags] ``` +###### Parameters + +* `--field`: Fields to display, choices are: id, created_at, size and model +* `--format`: Authorization model output format. Can be "fga" or "json" +* `--model-id`: Authorization Model ID +* `--store-id`: Store ID + ###### Example ```bash -fga query check --store-id="01H4P8Z95KTXXEP6Z03T75Q984" user:anne can_view document:roadmap --context '{"ip_address":"127.0.0.1"}' --consistency "HIGHER_CONSISTENCY" +fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1 --field size --field model --field id --field created_at +fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 ``` ###### Response -```json -{ - "allowed": true, - "resolution": "" -} -``` - -##### Expand - -###### Command - -``` -fga query expand -``` +```fga +# Model ID: 01GXSA8YR785C4FYS3C0RTG7B1 +# Created At: 2023-04-11 23:26:34.759 +0000 UTC +# Size: 20.05 KB +model + schema 1.1 -###### Example +type user -```bash -fga query expand --store-id="01H4P8Z95KTXXEP6Z03T75Q984" can_view document:roadmap --consistency "HIGHER_CONSISTENCY" +type document + relations + define can_view: [user] ``` -###### Response - -```json -{ - "tree": { - "root": { - "name": "document:roadmap#can_view", - "union": { - "nodes": [ - { - "name": "document:roadmap#can_view", - "leaf": { - "users": { - "users": [ - "user:anne" - ] - } - } - } - ] - } - } - } -} -``` +##### Read Authorization Models -##### List Objects +List all authorization models for a store, in descending order by creation date. ###### Command ``` -fga query list-objects +fga model list [flags] ``` +###### Parameters + +* `--field`: Fields to display, choices are: id, created_at and model +* `--max-pages`: Max number of pages to get. +* `--store-id`: Store ID + ###### Example ```bash -fga query list-objects --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document --contextual-tuple "user:anne can_view folder:product" --contextual-tuple "folder:product parent document:roadmap" --consistency "HIGHER_CONSISTENCY" +fga model list --store-id=01H0H015178Y2V4CX10C2KGHF4 ``` ###### Response ```json { - "objects": [ - "document:roadmap" + "authorization_models": [ + { + "id":"01H6H9XH1G5Q6DK6PFMGDZNH9S", + "created_at":"2023-07-29T17:07:41Z" + }, + { + "id":"01H6H9PPR6C3P45R75X55ZFP46", + "created_at":"2023-07-29T17:03:57Z" + } ] } ``` -##### List Relations +##### Validate Authorization Model + +Validates that an authorization model is valid. When the input parses successfully, the JSON response includes size_kb, the protobuf-serialized size of the model in KB. ###### Command ``` -fga query list-relations [flags] +fga model validate [flags] ``` ###### Parameters -* `--relation`: Relation +* `--file`: File Name. The file should have the model in the JSON or DSL format or be an fga.mod file +* `--format`: Authorization model input format. Can be "fga", "json", or "modular" ###### Example ```bash -fga query list-relations --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne document:roadmap --relation can_view --consistency "HIGHER_CONSISTENCY" +fga model validate --file model.json ``` ###### Response ```json -{ - "relations": [ - "can_view" - ] -} +{"id":"01GPGWB8R33HWXS3KK6YG4ETGH","created_at":"2023-01-11T16:59:22Z","is_valid":true,"size_kb":0.05} + +Invalid model: +{"id":"01GPGTVEH5NYTQ19RYFQKE0Q4Z","created_at":"2023-01-11T16:33:15Z","is_valid":false,"error":"invalid schema version","size_kb":0.05} ``` -##### List Users +##### Transform an Authorization Model + +Convert an authorization model between formats (.fga, .json, .mod). ###### Command ``` -fga query list-users [flags] +fga model transform [flags] ``` ###### Parameters -* `--object`: Object to list users for -* `--relation`: Relation to evaluate on -* `--user-filter`: Filter the responses can be in the formats (to filter objects and typed public bound access) or # (to filter usersets) +* `--file`: File Name. The file should have the model in the JSON or DSL format or be an `fga.mod` format +* `--input-format`: Authorization model input format. Can be "fga", "json", or "modular" +* `--output-format`: Authorization model output format. Can be "fga" or "json"." ###### Example ```bash -fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --consistency "HIGHER_CONSISTENCY" +fga model transform --file=model.json +fga model transform --file=model.fga +fga model transform '{ "schema_version": "1.1", "type_definitions":[{"type":"user"}] }' --input-format json +fga model transform --file=fga.mod ``` ###### Response -```json -{ - "users": [ - { - "object": { - "type": "user", - "id": "anne" - } - } - ] -} +```fga +model + schema 1.1 + +type user + +type document + relations + define can_view: [user] ``` -#### Stores +##### Test an Authorization Model -##### Create Store +Run a set of tests against a particular Authorization Model. If a model is provided the test will run in a built-in OpenFGA instance; otherwise it runs against the configured store. ###### Command ``` -fga store create [flags] +fga model test [flags] ``` ###### Parameters -* `--format`: Authorization model input format. Can be "fga", "json" or "modular -* `--model`: Authorization Model File Name -* `--name`: Store Name +* `--allow-external-files`: Allow model_file, tuple_file and tuple_files references in the test file to resolve to paths outside the test file's directory. Only enable this for test files you trust. +* `--max-types-per-authorization-model`: Max allowed number of type definitions per authorization model +* `--model-id`: Model ID +* `--store-id`: Store ID +* `--suppress-summary`: Suppress the plain text summary output +* `--tests`: Path or glob of YAML test files +* `--verbose`: Print verbose JSON output ###### Example ```bash -fga store create --name "FGA Demo Store" -fga store create --model Model.fga -export FGA_STORE_ID=$(fga store create --model model.fga | jq -r .store.id) +fga model test --tests model.fga.yaml +fga model test --tests "tests/*.fga.yaml" ``` ###### Response -```json -{ - "id": "01H0H015178Y2V4CX10C2KGHF4", - "name": "FGA Demo Store", - "created_at": "2023-05-19T16:10:07.637585677Z", - "updated_at": "2023-05-19T16:10:07.637585677Z" -} - -Response for fga store create --model Model.fga: -{ - "store": { - "id":"01H6H9CNQRP2TVCFR7899XGNY8", - "name":"Model", - "created_at":"2023-07-29T16:58:28.984402Z", - "updated_at":"2023-07-29T16:58:28.984402Z" - }, - "model": { - "authorization_model_id":"01H6H9CNQV36Y9WS1RJGRN8D06" - } -} +```text +(FAILING) test-1: Checks (2/3 passing) | ListObjects (2/2 passing) +ⅹ Check(user=user:anne,relation=can_share,object=folder:1): expected=false, got=true +--- +# Test Summary # +Tests 1/2 passing +Checks 3/4 passing +ListObjects 3/3 passing ``` -##### Delete Store - -###### Command +#### Relationship Tuples -``` -fga store delete [flags] -``` +Read, write, delete, import and listen to changes in relationship tuples in a store. -###### Parameters +##### Parameters -* `--force`: Force delete without confirmation * `--store-id`: Store ID -###### Example +##### Create Relationship Tuples -```bash -fga store delete --store-id=01H0H015178Y2V4CX10C2KGHF4 -``` +Add relationship tuples to the store. This command allows for the creation of relationship tuples either through direct command line arguments or by specifying a file. The file can be in JSON, YAML, or CSV format. -###### Response +When using a CSV file, the file must adhere to a specific header structure for the command to correctly interpret the data. The required CSV header structure is as follows: +- "user_type": Specifies the type of the user in the relationship tuple. (e.g. "team") +- "user_id": The unique identifier of the user. (e.g. "marketing") +- "user_relation": Defines the user relation forming a userset. (optional) (e.g. "member") +- "relation": Defines the tuple relation. (e.g. "viewer") +- "object_type": Specifies the type of the object in the relationship tuple. (e.g. "document") +- "object_id": The unique identifier of the object. (e.g. "roadmap") +- "condition_name": The name of the condition. (optional) (e.g. "inOfficeIP") +- "condition_context": The context of the condition as a json object. (optional) (e.g. "{""ip_addr"":""10.0.0.1""}") -```json -{} -``` +For example, a valid CSV file might start with a row like: +user_type,user_id,user_relation,relation,object_type,object_id,condition_name,condition_context -##### Export Store Data +This command is flexible in accepting data inputs, making it easier to add multiple relationship tuples in various convenient formats. ###### Command ``` -fga store export [flags] +fga tuple write [flags] ``` ###### Parameters -* `--max-tuples`: max number of tuples to return in the output -* `--model-id`: Authorization Model ID -* `--output-file`: name of the file to export the store to -* `--store-id`: store ID - -###### Example - -```bash -fga store export --store-id=01H0H015178Y2V4CX10C2KGHF4 -``` - -###### Response - -```yaml -name: Test -model: |+ - model - schema 1.1 - - type user - -tuples: - - user: user:1 - relation: member - object: group:admins -tests: [] -``` - -##### Get Store - -###### Command - -``` -fga store get [flags] -``` - -###### Parameters - -* `--store-id`: Store ID +* `--condition-context`: Condition Context (as a JSON string) +* `--condition-name`: Condition Name +* `--file`: Tuples file +* `--hide-imported-tuples`: Hide successfully imported tuples from output +* `--max-parallel-requests`: Max number of requests to issue to the server in parallel. +* `--max-rps`: The maximum requests per second. +* `--max-tuples-per-write`: Max tuples per write chunk. +* `--model-id`: Model ID +* `--on-duplicate`: Whether to ignore or error on duplicate tuples. Valid values are 'ignore' and 'error'. (default: 'ignore' when importing a file of tuples, 'error' otherwise) +* `--rampup-period-in-sec`: The period over which to ramp up the request rate. ###### Example ```bash -fga store get --store-id=01H0H015178Y2V4CX10C2KGHF4 +fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap --condition-name inOffice --condition-context '{"office_ip":"10.0.1.10"}' + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.json + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.yaml + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --max-tuples-per-write 10 --max-parallel-requests 5 + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --max-rps 10 + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --on-duplicate ignore ``` ###### Response ```json { - "id": "01H0H015178Y2V4CX10C2KGHF4", - "name": "FGA Demo Store", - "created_at": "2023-05-19T16:10:07.637585677Z", - "updated_at": "2023-05-19T16:10:07.637585677Z" + "successful": [ + { + "object":"document:roadmap", + "relation":"writer", + "user":"user:annie" + } + ], + "failed": [], + "failed_count": 0, + "successful_count": 1, + "total_count": 1 } ``` -##### Import Store Data +##### Delete Relationship Tuples + +Delete relationship tuples from the store. ###### Command ``` -fga store import [flags] +fga tuple delete [flags] ``` ###### Parameters -* `--allow-external-files`: Allow model_file, tuple_file and tuple_files references in the store file to resolve to paths outside the store file's directory. Only enable this for store files you trust. -* `--file`: File Name. The file should have the store +* `--file`: Tuples file +* `--hide-imported-tuples`: Hide successfully imported tuples from output * `--max-parallel-requests`: Max number of requests to issue to the server in parallel. * `--max-tuples-per-write`: Max tuples per write chunk. -* `--store-id`: Store ID +* `--model-id`: Model ID +* `--on-missing`: Whether to ignore or error on missing tuples. Valid values are 'ignore' and 'error'. (default: 'ignore' when deleting a file of tuples, 'error' otherwise) ###### Example ```bash -fga store import --file=model.fga.yaml +fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap + fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --on-missing ignore ``` ###### Response ```json {} + +Response when using --file: +{ + "successful": [ + { + "object":"document:roadmap", + "relation":"writer", + "user":"user:annie" + } + ], + "failed": [] +} ``` -##### List Stores +##### Read Relationship Tuples + +Read relationship tuples that exist in the system (does not evaluate). ###### Command ``` -fga store list [flags] +fga tuple read [flags] ``` ###### Parameters -* `--max-pages`: Max number of pages to get. -* `--name`: Filter stores by exact name. Substrings and regexes are not supported. +* `--consistency`: Consistency preference for the request. Valid options are HIGHER_CONSISTENCY and MINIMIZE_LATENCY. +* `--max-pages`: Max number of pages to get. Set to 0 to get all pages. +* `--object`: Object +* `--output-format`: Specifies the format for data presentation. Valid options: json, simple-json, csv, and yaml. +* `--page-size`: Number of tuples to return per page. Defaults to 100 when max-pages=0, or 50 otherwise. Max is 100. +* `--relation`: Relation +* `--user`: User ###### Example ```bash -fga store list +fga tuple read --store-id=01H0H015178Y2V4CX10C2KGHF4 --user user:anne --relation can_view --object document:roadmap ``` ###### Response ```json { - "stores": [{ - "id": "..", - "name": "..", - "created_at": "", - "updated_at": "", - "deleted_at": "" - }] + "tuples": [ + { + "key": { + "object": "document:roadmap", + "relation": "can_view", + "user": "user:anne" + }, + "timestamp": "2023-07-06T15:12:55.080666875Z" + } + ] } ``` -#### Relationship Tuples - ##### Read Relationship Tuple Changes (Watch) +Get a list of relationship tuple changes (Writes and Deletes) across time. + ###### Command ``` @@ -910,141 +855,308 @@ fga tuple changes --store-id=01H0H015178Y2V4CX10C2KGHF4 --type=document --contin } ``` -##### Delete Relationship Tuples +#### Relationship Queries + +Run queries (Check, Expand, ListObjects, ListRelations, ListUsers) that are evaluated according to a particular model. + +##### Parameters + +* `--consistency`: Consistency preference for the request. Valid options are HIGHER_CONSISTENCY and MINIMIZE_LATENCY. +* `--context`: Query context (as a JSON string) +* `--contextual-tuple`: Contextual Tuple, output: "user relation object" +* `--model-id`: Model ID +* `--store-id`: Store ID + +##### Check + +Check if a user has a particular relation with an object. ###### Command ``` -fga tuple delete [flags] +fga query check ``` -###### Parameters +###### Example -* `--file`: Tuples file -* `--hide-imported-tuples`: Hide successfully imported tuples from output -* `--max-parallel-requests`: Max number of requests to issue to the server in parallel. -* `--max-tuples-per-write`: Max tuples per write chunk. -* `--model-id`: Model ID -* `--on-missing`: Whether to ignore or error on missing tuples. Valid values are 'ignore' and 'error'. (default: 'ignore' when deleting a file of tuples, 'error' otherwise) +```bash +fga query check --store-id="01H4P8Z95KTXXEP6Z03T75Q984" user:anne can_view document:roadmap --context '{"ip_address":"127.0.0.1"}' --consistency "HIGHER_CONSISTENCY" +``` + +###### Response + +```json +{ + "allowed": true, + "resolution": "" +} +``` + +##### Expand + +Expands the relationships in userset tree format. + +###### Command + +``` +fga query expand +``` ###### Example ```bash -fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap - fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --on-missing ignore +fga query expand --store-id="01H4P8Z95KTXXEP6Z03T75Q984" can_view document:roadmap --consistency "HIGHER_CONSISTENCY" ``` ###### Response ```json -{} - -Response when using --file: { - "successful": [ - { - "object":"document:roadmap", - "relation":"writer", - "user":"user:annie" + "tree": { + "root": { + "name": "document:roadmap#can_view", + "union": { + "nodes": [ + { + "name": "document:roadmap#can_view", + "leaf": { + "users": { + "users": [ + "user:anne" + ] + } + } + } + ] + } } - ], - "failed": [] + } } ``` -##### Read Relationship Tuples +##### List Objects + +List the objects of a certain type that a user has a particular relation to. ###### Command ``` -fga tuple read [flags] +fga query list-objects +``` + +###### Example + +```bash +fga query list-objects --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document --contextual-tuple "user:anne can_view folder:product" --contextual-tuple "folder:product parent document:roadmap" --consistency "HIGHER_CONSISTENCY" +``` + +###### Response + +```json +{ + "objects": [ + "document:roadmap" + ] +} +``` + +##### List Relations + +List relations that a user has with an object. + +###### Command + +``` +fga query list-relations [flags] ``` ###### Parameters -* `--consistency`: Consistency preference for the request. Valid options are HIGHER_CONSISTENCY and MINIMIZE_LATENCY. -* `--max-pages`: Max number of pages to get. Set to 0 to get all pages. -* `--object`: Object -* `--output-format`: Specifies the format for data presentation. Valid options: json, simple-json, csv, and yaml. -* `--page-size`: Number of tuples to return per page. Defaults to 100 when max-pages=0, or 50 otherwise. Max is 100. * `--relation`: Relation -* `--user`: User ###### Example ```bash -fga tuple read --store-id=01H0H015178Y2V4CX10C2KGHF4 --user user:anne --relation can_view --object document:roadmap +fga query list-relations --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne document:roadmap --relation can_view --consistency "HIGHER_CONSISTENCY" ``` ###### Response ```json { - "tuples": [ - { - "key": { - "object": "document:roadmap", - "relation": "can_view", - "user": "user:anne" - }, - "timestamp": "2023-07-06T15:12:55.080666875Z" - } + "relations": [ + "can_view" ] } ``` -##### Create Relationship Tuples +##### List Users + +List all users that have a certain relation with a particular object ###### Command ``` -fga tuple write [flags] +fga query list-users [flags] ``` ###### Parameters -* `--condition-context`: Condition Context (as a JSON string) -* `--condition-name`: Condition Name -* `--file`: Tuples file -* `--hide-imported-tuples`: Hide successfully imported tuples from output -* `--max-parallel-requests`: Max number of requests to issue to the server in parallel. -* `--max-rps`: The maximum requests per second. -* `--max-tuples-per-write`: Max tuples per write chunk. -* `--model-id`: Model ID -* `--on-duplicate`: Whether to ignore or error on duplicate tuples. Valid values are 'ignore' and 'error'. (default: 'ignore' when importing a file of tuples, 'error' otherwise) -* `--rampup-period-in-sec`: The period over which to ramp up the request rate. +* `--object`: Object to list users for +* `--relation`: Relation to evaluate on +* `--user-filter`: Filter the responses can be in the formats (to filter objects and typed public bound access) or # (to filter usersets) ###### Example ```bash -fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap - fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap --condition-name inOffice --condition-context '{"office_ip":"10.0.1.10"}' - fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.json - fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.yaml - fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv - fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --max-tuples-per-write 10 --max-parallel-requests 5 - fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --max-rps 10 - fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --on-duplicate ignore +fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --consistency "HIGHER_CONSISTENCY" ``` ###### Response ```json { - "successful": [ + "users": [ { - "object":"document:roadmap", - "relation":"writer", - "user":"user:annie" + "object": { + "type": "user", + "id": "anne" + } } - ], - "failed": [], - "failed_count": 0, - "successful_count": 1, - "total_count": 1 + ] } ``` +#### Manage JSON-to-tuple mappings + +Validate, test, and run JSON-to-tuple mapping files. + +##### Scaffold a starter mapping file + +Creates a new mapping YAML file with a sample rule and embedded test. Defaults to mapping.yaml. + +###### Command + +``` +fga mapping init [mapping-file] [flags] +``` + +###### Parameters + +* `--force`: Overwrite an existing file +* `--minimal`: Emit a skeleton without the example test block + +###### Example + +```bash +fga mapping init + fga mapping init my-mapping.yaml + fga mapping init --minimal mapping.yaml + fga mapping init --force mapping.yaml +``` + +##### Validate a mapping file + +Validates that a mapping file is syntactically correct and all expressions compile. +With --model-file, also checks that every tuple template is consistent with the +authorization model: object types, relations, and user types must exist and be valid. + +###### Command + +``` +fga mapping validate [mapping-file] [flags] +``` + +###### Parameters + +* `--format`: Output format: "text" or "json" +* `--model-file`: Path to FGA authorization model file (DSL, JSON, or modular) +* `--verbose`: Show per-rule validation status (text format only) + +###### Example + +```bash +fga mapping validate mapping.yaml + fga mapping validate --format json mapping.yaml + fga mapping validate --model-file model.fga mapping.yaml +``` + +##### Run the embedded tests in a mapping file + +Compiles the mapping and runs its embedded test cases, reporting pass/fail per case. +Exits 1 when any test fails, 2 when the mapping file cannot be compiled. +Use --format to choose between human-readable text (default), JSON, or JUnit XML output. + +###### Command + +``` +fga mapping test [mapping-file] [flags] +``` + +###### Parameters + +* `--fail-fast`: Stop after the first failing test +* `--format`: Output format: "text", "json", or "junit" +* `--no-color`: Disable color in text output +* `--output-file`: Write output to a file instead of stdout +* `--run`: Run only tests whose name contains this substring (case-sensitive) +* `--verbose`: Show rule trace and tuple details for each test (text format only) + +###### Example + +```bash +fga mapping test mapping.yaml + fga mapping test --format json mapping.yaml + fga mapping test --format junit --output-file results.xml mapping.yaml + fga mapping test --run anne mapping.yaml + fga mapping test --fail-fast mapping.yaml +``` + +##### Evaluate a mapping against JSON input and emit tuple operations + +Reads JSONL from stdin (or --input) and evaluates it against the mapping file. +Input is JSON Lines: one JSON object per line. Outputs tuple operations as JSONL (default) +or a JSON batch (--format json). +Runs entirely offline — no store reads, no credentials, no network. +Rules using tuple_filters cannot be expanded offline and are reported as warnings on stderr +(or under tuple_filter_operations in the --format json batch). + +Default JSONL emits one operation per line with an "op" field (write or delete). +With --writes-only only write operations are emitted, in the bare ClientTupleKey shape +consumable directly by fga tuple write --file (JSONL lines, or a JSON array with --format json). + +Each input record is evaluated and streamed in order. --format json instead collects the whole +run into a single document. --aggregate buffers all records and collapses them (cross-record +dedup of tuples and unresolved filters, plus write/delete conflict detection) before emitting; +a conflict is a runtime error. Both --format json and --aggregate buffer the whole run in memory +before emitting; the default streaming JSONL does not. +--continue-on-error skips any record that fails to parse or evaluate (warning to stderr) and +continues; the command still exits non-zero if any record was skipped. + +###### Command + +``` +fga mapping run [mapping-file] [flags] +``` + +###### Parameters + +* `--aggregate`: Buffer all records and collapse them (dedup tuples and filters, detect write/delete conflicts) before emitting +* `--continue-on-error`: Skip input records that fail to parse or evaluate (warn to stderr) and exit non-zero if any were skipped +* `--format`: Output format: "jsonl" or "json" +* `--input`: Path to JSONL input file, one JSON object per line (default: stdin) +* `--interactive`: Explore the mapping in a terminal loop: paste JSON documents and see the tuples they produce (requires a TTY) +* `--writes-only`: Emit only write operations in ClientTupleKey format (consumable by fga tuple write) + +###### Example + +```bash +echo '{"id":"anne","org":"acme"}' | fga mapping run mapping.yaml + fga mapping run mapping.yaml --input event.json --format json + fga mapping run --writes-only mapping.yaml > out.jsonl && fga tuple write --store-id $STORE_ID --file out.jsonl + fga mapping run mapping.yaml -i +``` + ## Contributing diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index a08f6744..4965728b 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -23,9 +23,12 @@ import ( "github.com/openfga/cli/cmd" "github.com/openfga/cli/internal/doc" + "github.com/spf13/cobra" ) func main() { + cobra.EnableCommandSorting = false + root := cmd.RootCmd() section := doc.GenerateCommandsSection(root) diff --git a/cmd/model/model.go b/cmd/model/model.go index 9a7752b8..b263b888 100644 --- a/cmd/model/model.go +++ b/cmd/model/model.go @@ -30,8 +30,8 @@ var ModelCmd = &cobra.Command{ func init() { ModelCmd.AddCommand(writeCmd) - ModelCmd.AddCommand(listCmd) ModelCmd.AddCommand(getCmd) + ModelCmd.AddCommand(listCmd) ModelCmd.AddCommand(validateCmd) ModelCmd.AddCommand(transformCmd) ModelCmd.AddCommand(modelTestCmd) diff --git a/cmd/query/check.go b/cmd/query/check.go index 76015244..e73d1240 100644 --- a/cmd/query/check.go +++ b/cmd/query/check.go @@ -62,7 +62,7 @@ func check( // checkCmd represents the check command. var checkCmd = &cobra.Command{ - Use: "check", + Use: "check ", Short: "Check", Example: `fga query check --store-id="01H4P8Z95KTXXEP6Z03T75Q984" user:anne can_view document:roadmap --context '{"ip_address":"127.0.0.1"}' --consistency "HIGHER_CONSISTENCY"`, //nolint:lll Long: "Check if a user has a particular relation with an object.", diff --git a/cmd/query/expand.go b/cmd/query/expand.go index 679dd18f..2c63a335 100644 --- a/cmd/query/expand.go +++ b/cmd/query/expand.go @@ -56,7 +56,7 @@ func expand( // expandCmd represents the expand command. var expandCmd = &cobra.Command{ - Use: "expand", + Use: "expand ", Short: "Expand", Long: "Expands the relationships in userset tree format.", Example: `fga query expand --store-id="01H4P8Z95KTXXEP6Z03T75Q984" can_view document:roadmap --consistency "HIGHER_CONSISTENCY"`, //nolint:lll diff --git a/cmd/query/list-objects.go b/cmd/query/list-objects.go index 0ce54101..6f21841b 100644 --- a/cmd/query/list-objects.go +++ b/cmd/query/list-objects.go @@ -62,7 +62,7 @@ func listObjects( // listObjectsCmd represents the listObjects command. var listObjectsCmd = &cobra.Command{ - Use: "list-objects", + Use: "list-objects ", Short: "List Objects", Long: "List the objects of a certain type that a user has a particular relation to.", Example: `fga query list-objects --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document --contextual-tuple "user:anne can_view folder:product" --contextual-tuple "folder:product parent document:roadmap" --consistency "HIGHER_CONSISTENCY"`, //nolint:lll diff --git a/cmd/query/list-relations.go b/cmd/query/list-relations.go index 9312ee52..2049423f 100644 --- a/cmd/query/list-relations.go +++ b/cmd/query/list-relations.go @@ -126,7 +126,7 @@ func listRelations(ctx context.Context, // listRelationsCmd represents the listRelations command. var listRelationsCmd = &cobra.Command{ - Use: "list-relations", + Use: "list-relations ", Short: "List Relations", Long: "List relations that a user has with an object.", Example: `fga query list-relations --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne document:roadmap --relation can_view --consistency "HIGHER_CONSISTENCY"`, //nolint:lll diff --git a/cmd/root.go b/cmd/root.go index 9c6ea63a..f4eff004 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -88,11 +88,11 @@ func init() { rootCmd.Version = versionStr rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(manCmd) - rootCmd.AddCommand(mapping.MappingCmd) rootCmd.AddCommand(store.StoreCmd) rootCmd.AddCommand(model.ModelCmd) rootCmd.AddCommand(tuple.TupleCmd) rootCmd.AddCommand(query.QueryCmd) + rootCmd.AddCommand(mapping.MappingCmd) } // initConfig reads in config file and ENV variables if set. diff --git a/cmd/store/store.go b/cmd/store/store.go index a8f9c789..180124bc 100644 --- a/cmd/store/store.go +++ b/cmd/store/store.go @@ -30,8 +30,8 @@ var StoreCmd = &cobra.Command{ func init() { StoreCmd.AddCommand(createCmd) - StoreCmd.AddCommand(listCmd) StoreCmd.AddCommand(getCmd) + StoreCmd.AddCommand(listCmd) StoreCmd.AddCommand(deleteCmd) StoreCmd.AddCommand(importCmd) StoreCmd.AddCommand(exportCmd) diff --git a/cmd/tuple/delete.go b/cmd/tuple/delete.go index 78dbb35e..2c518602 100644 --- a/cmd/tuple/delete.go +++ b/cmd/tuple/delete.go @@ -32,7 +32,7 @@ import ( // deleteCmd represents the delete command. var deleteCmd = &cobra.Command{ - Use: "delete", + Use: "delete ", Short: "Delete Relationship Tuples", Args: ExactArgsOrFlag(3, "file"), //nolint:mnd Long: "Delete relationship tuples from the store.", diff --git a/cmd/tuple/tuple.go b/cmd/tuple/tuple.go index a53f143e..5aa2dd58 100644 --- a/cmd/tuple/tuple.go +++ b/cmd/tuple/tuple.go @@ -32,10 +32,10 @@ var TupleCmd = &cobra.Command{ } func init() { - TupleCmd.AddCommand(changesCmd) - TupleCmd.AddCommand(readCmd) TupleCmd.AddCommand(writeCmd) TupleCmd.AddCommand(deleteCmd) + TupleCmd.AddCommand(readCmd) + TupleCmd.AddCommand(changesCmd) TupleCmd.PersistentFlags().String("store-id", "", "Store ID") diff --git a/cmd/tuple/write.go b/cmd/tuple/write.go index 42019062..6cc474a5 100644 --- a/cmd/tuple/write.go +++ b/cmd/tuple/write.go @@ -45,7 +45,7 @@ type ImportStats struct { // writeCmd represents the write command. var writeCmd = &cobra.Command{ - Use: "write", + Use: "write ", Aliases: []string{"import"}, Short: "Create Relationship Tuples", Long: "Add relationship tuples to the store. This command allows for the creation of " + diff --git a/internal/doc/generate.go b/internal/doc/generate.go index ad2078fc..109f52d7 100644 --- a/internal/doc/generate.go +++ b/internal/doc/generate.go @@ -57,6 +57,24 @@ func writeSection(b *strings.Builder, cmd *cobra.Command) { if !cmd.Runnable() { fmt.Fprintf(b, "#### %s\n\n", cmd.Short) + if cmd.Long != "" { + fmt.Fprintf(b, "%s\n\n", cmd.Long) + } + + if hasVisiblePersistentFlags(cmd) { + fmt.Fprintf(b, "##### Parameters\n\n") + + cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { + if f.Hidden { + return + } + + fmt.Fprintf(b, "* `--%s`: %s\n", f.Name, f.Usage) + }) + + fmt.Fprintf(b, "\n") + } + for _, sub := range cmd.Commands() { if !sub.IsAvailableCommand() || sub.IsAdditionalHelpTopicCommand() { continue @@ -70,6 +88,10 @@ func writeSection(b *strings.Builder, cmd *cobra.Command) { fmt.Fprintf(b, "##### %s\n\n", cmd.Short) + if cmd.Long != "" { + fmt.Fprintf(b, "%s\n\n", cmd.Long) + } + fmt.Fprintf(b, "###### Command\n\n```\n%s\n```\n\n", cmd.UseLine()) if hasVisibleLocalFlags(cmd) { @@ -112,6 +134,18 @@ func hasVisibleLocalFlags(cmd *cobra.Command) bool { return hasVisible } +func hasVisiblePersistentFlags(cmd *cobra.Command) bool { + hasVisible := false + + cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { + if !f.Hidden { + hasVisible = true + } + }) + + return hasVisible +} + // SpliceIntoReadme replaces the content between the BEGIN_COMMANDS and // END_COMMANDS HTML comment markers in the file at readmePath with section. func SpliceIntoReadme(readmePath, section string) error { diff --git a/internal/doc/generate_test.go b/internal/doc/generate_test.go index 994847b3..e30d294a 100644 --- a/internal/doc/generate_test.go +++ b/internal/doc/generate_test.go @@ -359,6 +359,60 @@ func TestGenerateCommandsSection_NonJSONResponseLang(t *testing.T) { assert.Contains(t, section, "```fga\nmodel\n schema 1.1\n```") } +func TestGenerateCommandsSection_LongRenderedForRunnable(t *testing.T) { + t.Parallel() + + root := buildTestTree() + section := doc.GenerateCommandsSection(root) + + // createCmd has Long: "Create an OpenFGA store." — must appear before ###### Command + createIdx := strings.Index(section, "##### Create Store") + require.NotEqual(t, -1, createIdx) + commandBlockIdx := strings.Index(section[createIdx:], "###### Command") + longIdx := strings.Index(section[createIdx:], "Create an OpenFGA store.") + + assert.NotEqual(t, -1, longIdx, "Long should appear in runnable section") + assert.Less(t, longIdx, commandBlockIdx, "Long should appear before ###### Command") +} + +func TestGenerateCommandsSection_LongRenderedForGroup(t *testing.T) { + t.Parallel() + + root := buildTestTree() + section := doc.GenerateCommandsSection(root) + + // storeGroup has Long: "Manage OpenFGA stores." — must appear after #### Stores + assert.Contains(t, section, "Manage OpenFGA stores.") +} + +func TestGenerateCommandsSection_GroupPersistentFlagsRendered(t *testing.T) { + t.Parallel() + + root := &cobra.Command{Use: "fga"} + group := &cobra.Command{Use: "query", Short: "Queries", Long: "Run queries."} + group.PersistentFlags().String("store-id", "", "Store ID") + sub := &cobra.Command{ + Use: "check", + Short: "Check", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + group.AddCommand(sub) + root.AddCommand(group) + + section := doc.GenerateCommandsSection(root) + + groupIdx := strings.Index(section, "#### Queries") + require.NotEqual(t, -1, groupIdx) + + checkIdx := strings.Index(section, "##### Check") + require.NotEqual(t, -1, checkIdx) + + // Parameters section should appear between group heading and first subcommand + paramIdx := strings.Index(section[groupIdx:checkIdx], "##### Parameters") + assert.NotEqual(t, -1, paramIdx, "group persistent flags should be rendered under group heading") + assert.Contains(t, section, "`--store-id`") +} + func TestAnchor_PunctuationStripped(t *testing.T) { t.Parallel() From 66dca59e4da7c844fe37f1f0bbcd8d208f8d45e8 Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Thu, 8 Oct 2026 16:20:47 +0100 Subject: [PATCH 3/8] feat(docs): reorder commands to match SDK canonical order Reorder AddCommand calls in store, model, and tuple group files so the generated docs section follows the same sequence used in the OpenFGA SDKs and docs site (list before create, changes/read before write/delete, etc.). cobra.EnableCommandSorting is disabled in gendocs only, so this reordering affects docs output only and not user-facing help text. --- README.md | 326 ++++++++++++++++++++++----------------------- cmd/model/model.go | 2 +- cmd/store/store.go | 2 +- cmd/tuple/tuple.go | 4 +- 4 files changed, 167 insertions(+), 167 deletions(-) diff --git a/README.md b/README.md index 8ca098d4..9c7282ac 100644 --- a/README.md +++ b/README.md @@ -21,24 +21,24 @@ A cross-platform CLI to interact with an OpenFGA server - [Commands](#commands) - [Stores](#stores) + - [List Stores](#list-stores) - [Create Store](#create-store) - [Get Store](#get-store) - - [List Stores](#list-stores) - [Delete Store](#delete-store) - [Import Store Data](#import-store-data) - [Export Store Data](#export-store-data) - [Authorization Models](#authorization-models) + - [Read Authorization Models](#read-authorization-models) - [Write Authorization Model](#write-authorization-model) - [Read a Single Authorization Model](#read-a-single-authorization-model) - - [Read Authorization Models](#read-authorization-models) - [Validate Authorization Model](#validate-authorization-model) - [Transform an Authorization Model](#transform-an-authorization-model) - [Test an Authorization Model](#test-an-authorization-model) - [Relationship Tuples](#relationship-tuples) + - [Read Relationship Tuple Changes (Watch)](#read-relationship-tuple-changes-watch) + - [Read Relationship Tuples](#read-relationship-tuples) - [Create Relationship Tuples](#create-relationship-tuples) - [Delete Relationship Tuples](#delete-relationship-tuples) - - [Read Relationship Tuples](#read-relationship-tuples) - - [Read Relationship Tuple Changes (Watch)](#read-relationship-tuple-changes-watch) - [Relationship Queries](#relationship-queries) - [Check](#check) - [Expand](#expand) @@ -212,6 +212,41 @@ custom-headers: Create, Get, Delete and List OpenFGA Stores +##### List Stores + +Get a list of stores. + +###### Command + +``` +fga store list [flags] +``` + +###### Parameters + +* `--max-pages`: Max number of pages to get. +* `--name`: Filter stores by exact name. Substrings and regexes are not supported. + +###### Example + +```bash +fga store list +``` + +###### Response + +```json +{ + "stores": [{ + "id": "..", + "name": "..", + "created_at": "", + "updated_at": "", + "deleted_at": "" + }] +} +``` + ##### Create Store Create an OpenFGA store. @@ -291,41 +326,6 @@ fga store get --store-id=01H0H015178Y2V4CX10C2KGHF4 } ``` -##### List Stores - -Get a list of stores. - -###### Command - -``` -fga store list [flags] -``` - -###### Parameters - -* `--max-pages`: Max number of pages to get. -* `--name`: Filter stores by exact name. Substrings and regexes are not supported. - -###### Example - -```bash -fga store list -``` - -###### Response - -```json -{ - "stores": [{ - "id": "..", - "name": "..", - "created_at": "", - "updated_at": "", - "deleted_at": "" - }] -} -``` - ##### Delete Store Mark a store as deleted. @@ -431,6 +431,45 @@ Write, read, list and validate authorization models. * `--store-id`: Store ID +##### Read Authorization Models + +List all authorization models for a store, in descending order by creation date. + +###### Command + +``` +fga model list [flags] +``` + +###### Parameters + +* `--field`: Fields to display, choices are: id, created_at and model +* `--max-pages`: Max number of pages to get. +* `--store-id`: Store ID + +###### Example + +```bash +fga model list --store-id=01H0H015178Y2V4CX10C2KGHF4 +``` + +###### Response + +```json +{ + "authorization_models": [ + { + "id":"01H6H9XH1G5Q6DK6PFMGDZNH9S", + "created_at":"2023-07-29T17:07:41Z" + }, + { + "id":"01H6H9PPR6C3P45R75X55ZFP46", + "created_at":"2023-07-29T17:03:57Z" + } + ] +} +``` + ##### Write Authorization Model Writes a new authorization model. @@ -503,45 +542,6 @@ type document define can_view: [user] ``` -##### Read Authorization Models - -List all authorization models for a store, in descending order by creation date. - -###### Command - -``` -fga model list [flags] -``` - -###### Parameters - -* `--field`: Fields to display, choices are: id, created_at and model -* `--max-pages`: Max number of pages to get. -* `--store-id`: Store ID - -###### Example - -```bash -fga model list --store-id=01H0H015178Y2V4CX10C2KGHF4 -``` - -###### Response - -```json -{ - "authorization_models": [ - { - "id":"01H6H9XH1G5Q6DK6PFMGDZNH9S", - "created_at":"2023-07-29T17:07:41Z" - }, - { - "id":"01H6H9PPR6C3P45R75X55ZFP46", - "created_at":"2023-07-29T17:03:57Z" - } - ] -} -``` - ##### Validate Authorization Model Validates that an authorization model is valid. When the input parses successfully, the JSON response includes size_kb, the protobuf-serialized size of the model in KB. @@ -657,6 +657,91 @@ Read, write, delete, import and listen to changes in relationship tuples in a st * `--store-id`: Store ID +##### Read Relationship Tuple Changes (Watch) + +Get a list of relationship tuple changes (Writes and Deletes) across time. + +###### Command + +``` +fga tuple changes [flags] +``` + +###### Parameters + +* `--continuation-token`: Continuation token to start changes from. +* `--max-pages`: Max number of pages to get. +* `--start-time`: Time to return changes since. +* `--type`: Type to restrict the changes by. + +###### Example + +```bash +fga tuple changes --store-id=01H0H015178Y2V4CX10C2KGHF4 --type=document --continuation-token=M3w= +``` + +###### Response + +```json +{ + "changes": [ + { + "operation": "TUPLE_OPERATION_WRITE", + "timestamp": "2023-07-06T15:12:40.294950382Z", + "tuple_key": { + "object": "document:roadmap", + "relation": "can_view", + "user": "user:anne" + } + } + ], + "continuation_token":"NHw=" +} +``` + +##### Read Relationship Tuples + +Read relationship tuples that exist in the system (does not evaluate). + +###### Command + +``` +fga tuple read [flags] +``` + +###### Parameters + +* `--consistency`: Consistency preference for the request. Valid options are HIGHER_CONSISTENCY and MINIMIZE_LATENCY. +* `--max-pages`: Max number of pages to get. Set to 0 to get all pages. +* `--object`: Object +* `--output-format`: Specifies the format for data presentation. Valid options: json, simple-json, csv, and yaml. +* `--page-size`: Number of tuples to return per page. Defaults to 100 when max-pages=0, or 50 otherwise. Max is 100. +* `--relation`: Relation +* `--user`: User + +###### Example + +```bash +fga tuple read --store-id=01H0H015178Y2V4CX10C2KGHF4 --user user:anne --relation can_view --object document:roadmap +``` + +###### Response + +```json +{ + "tuples": [ + { + "key": { + "object": "document:roadmap", + "relation": "can_view", + "user": "user:anne" + }, + "timestamp": "2023-07-06T15:12:55.080666875Z" + } + ] +} +``` + ##### Create Relationship Tuples Add relationship tuples to the store. This command allows for the creation of relationship tuples either through direct command line arguments or by specifying a file. The file can be in JSON, YAML, or CSV format. @@ -770,91 +855,6 @@ Response when using --file: } ``` -##### Read Relationship Tuples - -Read relationship tuples that exist in the system (does not evaluate). - -###### Command - -``` -fga tuple read [flags] -``` - -###### Parameters - -* `--consistency`: Consistency preference for the request. Valid options are HIGHER_CONSISTENCY and MINIMIZE_LATENCY. -* `--max-pages`: Max number of pages to get. Set to 0 to get all pages. -* `--object`: Object -* `--output-format`: Specifies the format for data presentation. Valid options: json, simple-json, csv, and yaml. -* `--page-size`: Number of tuples to return per page. Defaults to 100 when max-pages=0, or 50 otherwise. Max is 100. -* `--relation`: Relation -* `--user`: User - -###### Example - -```bash -fga tuple read --store-id=01H0H015178Y2V4CX10C2KGHF4 --user user:anne --relation can_view --object document:roadmap -``` - -###### Response - -```json -{ - "tuples": [ - { - "key": { - "object": "document:roadmap", - "relation": "can_view", - "user": "user:anne" - }, - "timestamp": "2023-07-06T15:12:55.080666875Z" - } - ] -} -``` - -##### Read Relationship Tuple Changes (Watch) - -Get a list of relationship tuple changes (Writes and Deletes) across time. - -###### Command - -``` -fga tuple changes [flags] -``` - -###### Parameters - -* `--continuation-token`: Continuation token to start changes from. -* `--max-pages`: Max number of pages to get. -* `--start-time`: Time to return changes since. -* `--type`: Type to restrict the changes by. - -###### Example - -```bash -fga tuple changes --store-id=01H0H015178Y2V4CX10C2KGHF4 --type=document --continuation-token=M3w= -``` - -###### Response - -```json -{ - "changes": [ - { - "operation": "TUPLE_OPERATION_WRITE", - "timestamp": "2023-07-06T15:12:40.294950382Z", - "tuple_key": { - "object": "document:roadmap", - "relation": "can_view", - "user": "user:anne" - } - } - ], - "continuation_token":"NHw=" -} -``` - #### Relationship Queries Run queries (Check, Expand, ListObjects, ListRelations, ListUsers) that are evaluated according to a particular model. diff --git a/cmd/model/model.go b/cmd/model/model.go index b263b888..7a67e3a0 100644 --- a/cmd/model/model.go +++ b/cmd/model/model.go @@ -29,9 +29,9 @@ var ModelCmd = &cobra.Command{ } func init() { + ModelCmd.AddCommand(listCmd) ModelCmd.AddCommand(writeCmd) ModelCmd.AddCommand(getCmd) - ModelCmd.AddCommand(listCmd) ModelCmd.AddCommand(validateCmd) ModelCmd.AddCommand(transformCmd) ModelCmd.AddCommand(modelTestCmd) diff --git a/cmd/store/store.go b/cmd/store/store.go index 180124bc..8c2731b5 100644 --- a/cmd/store/store.go +++ b/cmd/store/store.go @@ -29,9 +29,9 @@ var StoreCmd = &cobra.Command{ } func init() { + StoreCmd.AddCommand(listCmd) StoreCmd.AddCommand(createCmd) StoreCmd.AddCommand(getCmd) - StoreCmd.AddCommand(listCmd) StoreCmd.AddCommand(deleteCmd) StoreCmd.AddCommand(importCmd) StoreCmd.AddCommand(exportCmd) diff --git a/cmd/tuple/tuple.go b/cmd/tuple/tuple.go index 5aa2dd58..a53f143e 100644 --- a/cmd/tuple/tuple.go +++ b/cmd/tuple/tuple.go @@ -32,10 +32,10 @@ var TupleCmd = &cobra.Command{ } func init() { + TupleCmd.AddCommand(changesCmd) + TupleCmd.AddCommand(readCmd) TupleCmd.AddCommand(writeCmd) TupleCmd.AddCommand(deleteCmd) - TupleCmd.AddCommand(readCmd) - TupleCmd.AddCommand(changesCmd) TupleCmd.PersistentFlags().String("store-id", "", "Store ID") From 3bd0be7880012293db383a7351904826cd1e23f6 Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Thu, 8 Oct 2026 16:21:54 +0100 Subject: [PATCH 4/8] docs: regenerate README against rebased main Picks up --condition-expression and --condition-parameters flags added by the dynamic conditions feature (#768). --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 9c7282ac..2f40602b 100644 --- a/README.md +++ b/README.md @@ -770,7 +770,9 @@ fga tuple write [flags] ###### Parameters * `--condition-context`: Condition Context (as a JSON string) +* `--condition-expression`: Dynamic condition CEL expression * `--condition-name`: Condition Name +* `--condition-parameters`: Dynamic condition parameter types (as a JSON object with at least one parameter) * `--file`: Tuples file * `--hide-imported-tuples`: Hide successfully imported tuples from output * `--max-parallel-requests`: Max number of requests to issue to the server in parallel. @@ -785,6 +787,7 @@ fga tuple write [flags] ```bash fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap --condition-name inOffice --condition-context '{"office_ip":"10.0.1.10"}' + fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 agent:alice-claude can_call tool:slack_send_message --condition-expression "channel_name == '#product-announcements'" --condition-parameters '{"channel_name":"string"}' fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.json fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.yaml fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv From 738e1170903237d9980674ff2b61c8028064d4b2 Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Thu, 8 Oct 2026 16:30:23 +0100 Subject: [PATCH 5/8] feat(docs): address Copilot review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - anchor(): preserve hyphens so "JSON-to-tuple" slugs render correctly - escapeHTML(): replace < > in flag usage with HTML entities so they survive GitHub Markdown rendering - docs:response:raw: new annotation flag — emit response content as raw markdown instead of wrapping in a single fence, enabling multi-payload responses for create, validate, and delete - write/validate/transform Use strings: add [model] optional arg - tuple write/delete Use strings: wrap positional args as optional since --file makes them unnecessary - list-users example: add --user-filter (required flag was absent) - store create --format: fix unterminated quote - model transform --output-format: remove extra trailing quote --- README.md | 33 ++++++++++++++--------- cmd/model/transform.go | 4 +-- cmd/model/validate.go | 12 +++++---- cmd/model/write.go | 2 +- cmd/query/list-users.go | 2 +- cmd/store/create.go | 14 +++++----- cmd/tuple/delete.go | 11 ++++---- cmd/tuple/write.go | 2 +- internal/doc/generate.go | 32 +++++++++++++++++------ internal/doc/generate_test.go | 49 +++++++++++++++++++++++++++++++++++ 10 files changed, 118 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 2f40602b..e91b7105 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ A cross-platform CLI to interact with an OpenFGA server - [List Objects](#list-objects) - [List Relations](#list-relations) - [List Users](#list-users) - - [Manage JSON-to-tuple mappings](#manage-jsontotuple-mappings) + - [Manage JSON-to-tuple mappings](#manage-json-to-tuple-mappings) - [Scaffold a starter mapping file](#scaffold-a-starter-mapping-file) - [Validate a mapping file](#validate-a-mapping-file) - [Run the embedded tests in a mapping file](#run-the-embedded-tests-in-a-mapping-file) @@ -259,7 +259,7 @@ fga store create [flags] ###### Parameters -* `--format`: Authorization model input format. Can be "fga", "json" or "modular +* `--format`: Authorization model input format. Can be "fga", "json", or "modular". * `--model`: Authorization Model File Name * `--name`: Store Name @@ -280,8 +280,11 @@ export FGA_STORE_ID=$(fga store create --model model.fga | jq -r .store.id) "created_at": "2023-05-19T16:10:07.637585677Z", "updated_at": "2023-05-19T16:10:07.637585677Z" } +``` + +Response for `fga store create --model Model.fga`: -Response for fga store create --model Model.fga: +```json { "store": { "id":"01H6H9CNQRP2TVCFR7899XGNY8", @@ -477,7 +480,7 @@ Writes a new authorization model. ###### Command ``` -fga model write [flags] +fga model write [model] [flags] ``` ###### Parameters @@ -549,7 +552,7 @@ Validates that an authorization model is valid. When the input parses successful ###### Command ``` -fga model validate [flags] +fga model validate [model] [flags] ``` ###### Parameters @@ -567,8 +570,11 @@ fga model validate --file model.json ```json {"id":"01GPGWB8R33HWXS3KK6YG4ETGH","created_at":"2023-01-11T16:59:22Z","is_valid":true,"size_kb":0.05} +``` Invalid model: + +```json {"id":"01GPGTVEH5NYTQ19RYFQKE0Q4Z","created_at":"2023-01-11T16:33:15Z","is_valid":false,"error":"invalid schema version","size_kb":0.05} ``` @@ -579,14 +585,14 @@ Convert an authorization model between formats (.fga, .json, .mod). ###### Command ``` -fga model transform [flags] +fga model transform [model] [flags] ``` ###### Parameters * `--file`: File Name. The file should have the model in the JSON or DSL format or be an `fga.mod` format * `--input-format`: Authorization model input format. Can be "fga", "json", or "modular" -* `--output-format`: Authorization model output format. Can be "fga" or "json"." +* `--output-format`: Authorization model output format. Can be "fga" or "json". ###### Example @@ -764,7 +770,7 @@ This command is flexible in accepting data inputs, making it easier to add multi ###### Command ``` -fga tuple write [flags] +fga tuple write [ ] [flags] ``` ###### Parameters @@ -821,7 +827,7 @@ Delete relationship tuples from the store. ###### Command ``` -fga tuple delete [flags] +fga tuple delete [ ] [flags] ``` ###### Parameters @@ -844,8 +850,11 @@ fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view docume ```json {} +``` -Response when using --file: +Response when using `--file`: + +```json { "successful": [ { @@ -1007,12 +1016,12 @@ fga query list-users [flags] * `--object`: Object to list users for * `--relation`: Relation to evaluate on -* `--user-filter`: Filter the responses can be in the formats (to filter objects and typed public bound access) or # (to filter usersets) +* `--user-filter`: Filter the responses can be in the formats <type> (to filter objects and typed public bound access) or <type>#<relation> (to filter usersets) ###### Example ```bash -fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --consistency "HIGHER_CONSISTENCY" +fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --user-filter user --consistency "HIGHER_CONSISTENCY" ``` ###### Response diff --git a/cmd/model/transform.go b/cmd/model/transform.go index 84af2ba1..39fc341c 100644 --- a/cmd/model/transform.go +++ b/cmd/model/transform.go @@ -42,7 +42,7 @@ func determineOutputFormat(input, output authorizationmodel.ModelFormat) authori // transformCmd represents the transform command. var transformCmd = &cobra.Command{ - Use: "transform", + Use: "transform [model]", Short: "Transform an Authorization Model", Long: "Convert an authorization model between formats (.fga, .json, .mod).", Example: `fga model transform --file=model.json @@ -114,5 +114,5 @@ var ( func init() { transformCmd.Flags().String("file", "", "File Name. The file should have the model in the JSON or DSL format or be an `fga.mod` format") //nolint:lll transformCmd.Flags().Var(&transformInputFormat, "input-format", `Authorization model input format. Can be "fga", "json", or "modular"`) //nolint:lll - transformCmd.Flags().Var(&transformOutputFormat, "output-format", `Authorization model output format. Can be "fga" or "json"."`) //nolint:lll + transformCmd.Flags().Var(&transformOutputFormat, "output-format", `Authorization model output format. Can be "fga" or "json".`) //nolint:lll } diff --git a/cmd/model/validate.go b/cmd/model/validate.go index 58d56b50..4f82e6e9 100644 --- a/cmd/model/validate.go +++ b/cmd/model/validate.go @@ -94,15 +94,17 @@ func validate(inputModel authorizationmodel.AuthzModel) validationResult { // validateCmd represents the validate command. var validateCmd = &cobra.Command{ - Use: "validate", + Use: "validate [model]", Short: "Validate Authorization Model", Long: "Validates that an authorization model is valid. When the input parses successfully, the JSON response includes size_kb, the protobuf-serialized size of the model in KB.", Example: `fga model validate --file model.json`, Annotations: map[string]string{ - "docs:response": `{"id":"01GPGWB8R33HWXS3KK6YG4ETGH","created_at":"2023-01-11T16:59:22Z","is_valid":true,"size_kb":0.05} - -Invalid model: -{"id":"01GPGTVEH5NYTQ19RYFQKE0Q4Z","created_at":"2023-01-11T16:33:15Z","is_valid":false,"error":"invalid schema version","size_kb":0.05}`, + "docs:response": "```json\n" + + `{"id":"01GPGWB8R33HWXS3KK6YG4ETGH","created_at":"2023-01-11T16:59:22Z","is_valid":true,"size_kb":0.05}` + + "\n```\n\nInvalid model:\n\n```json\n" + + `{"id":"01GPGTVEH5NYTQ19RYFQKE0Q4Z","created_at":"2023-01-11T16:33:15Z","is_valid":false,"error":"invalid schema version","size_kb":0.05}` + + "\n```", + "docs:response:raw": "true", }, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/model/write.go b/cmd/model/write.go index b2350396..6efd3e1b 100644 --- a/cmd/model/write.go +++ b/cmd/model/write.go @@ -52,7 +52,7 @@ func Write( // writeCmd represents the write command. var writeCmd = &cobra.Command{ - Use: "write", + Use: "write [model]", Short: "Write Authorization Model", Long: "Writes a new authorization model.", Example: `fga model write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file=model.json diff --git a/cmd/query/list-users.go b/cmd/query/list-users.go index b37dae17..69c31686 100644 --- a/cmd/query/list-users.go +++ b/cmd/query/list-users.go @@ -95,7 +95,7 @@ var listUsersCmd = &cobra.Command{ Use: "list-users", Short: "List Users", Long: "List all users that have a certain relation with a particular object", - Example: `fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --consistency "HIGHER_CONSISTENCY"`, //nolint:lll + Example: `fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --user-filter user --consistency "HIGHER_CONSISTENCY"`, //nolint:lll Annotations: map[string]string{ "docs:response": `{ "users": [ diff --git a/cmd/store/create.go b/cmd/store/create.go index 4b5ef63a..a4663e06 100644 --- a/cmd/store/create.go +++ b/cmd/store/create.go @@ -115,15 +115,14 @@ var createCmd = &cobra.Command{ fga store create --model Model.fga export FGA_STORE_ID=$(fga store create --model model.fga | jq -r .store.id)`, Annotations: map[string]string{ - "docs:response": `{ + "docs:response": "```json\n" + + `{ "id": "01H0H015178Y2V4CX10C2KGHF4", "name": "FGA Demo Store", "created_at": "2023-05-19T16:10:07.637585677Z", "updated_at": "2023-05-19T16:10:07.637585677Z" -} - -Response for fga store create --model Model.fga: -{ +}` + "\n```\n\nResponse for `fga store create --model Model.fga`:\n\n```json\n" + + `{ "store": { "id":"01H6H9CNQRP2TVCFR7899XGNY8", "name":"Model", @@ -133,7 +132,8 @@ Response for fga store create --model Model.fga: "model": { "authorization_model_id":"01H6H9CNQV36Y9WS1RJGRN8D06" } -}`, +}` + "\n```", + "docs:response:raw": "true", }, RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd) @@ -170,5 +170,5 @@ var createModelInputFormat = authorizationmodel.ModelFormatDefault func init() { createCmd.Flags().String("name", "", "Store Name") createCmd.Flags().String("model", "", "Authorization Model File Name") - createCmd.Flags().Var(&createModelInputFormat, "format", `Authorization model input format. Can be "fga", "json" or "modular`) //nolint:lll + createCmd.Flags().Var(&createModelInputFormat, "format", `Authorization model input format. Can be "fga", "json", or "modular".`) //nolint:lll } diff --git a/cmd/tuple/delete.go b/cmd/tuple/delete.go index 2c518602..43bc21a8 100644 --- a/cmd/tuple/delete.go +++ b/cmd/tuple/delete.go @@ -32,17 +32,15 @@ import ( // deleteCmd represents the delete command. var deleteCmd = &cobra.Command{ - Use: "delete ", + Use: "delete [ ]", Short: "Delete Relationship Tuples", Args: ExactArgsOrFlag(3, "file"), //nolint:mnd Long: "Delete relationship tuples from the store.", Example: ` fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.csv --on-missing ignore`, Annotations: map[string]string{ - "docs:response": `{} - -Response when using --file: -{ + "docs:response": "```json\n{}\n```\n\nResponse when using `--file`:\n\n```json\n" + + `{ "successful": [ { "object":"document:roadmap", @@ -51,7 +49,8 @@ Response when using --file: } ], "failed": [] -}`, +}` + "\n```", + "docs:response:raw": "true", }, RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd) diff --git a/cmd/tuple/write.go b/cmd/tuple/write.go index 6cc474a5..5e9eca50 100644 --- a/cmd/tuple/write.go +++ b/cmd/tuple/write.go @@ -45,7 +45,7 @@ type ImportStats struct { // writeCmd represents the write command. var writeCmd = &cobra.Command{ - Use: "write ", + Use: "write [ ]", Aliases: []string{"import"}, Short: "Create Relationship Tuples", Long: "Add relationship tuples to the store. This command allows for the creation of " + diff --git a/internal/doc/generate.go b/internal/doc/generate.go index 109f52d7..6eebaf51 100644 --- a/internal/doc/generate.go +++ b/internal/doc/generate.go @@ -69,7 +69,7 @@ func writeSection(b *strings.Builder, cmd *cobra.Command) { return } - fmt.Fprintf(b, "* `--%s`: %s\n", f.Name, f.Usage) + fmt.Fprintf(b, "* `--%s`: %s\n", f.Name, escapeHTML(f.Usage)) }) fmt.Fprintf(b, "\n") @@ -102,7 +102,7 @@ func writeSection(b *strings.Builder, cmd *cobra.Command) { return } - fmt.Fprintf(b, "* `--%s`: %s\n", f.Name, f.Usage) + fmt.Fprintf(b, "* `--%s`: %s\n", f.Name, escapeHTML(f.Usage)) }) fmt.Fprintf(b, "\n") @@ -113,12 +113,18 @@ func writeSection(b *strings.Builder, cmd *cobra.Command) { } if response, ok := cmd.Annotations["docs:response"]; ok && response != "" { - lang := cmd.Annotations["docs:response:lang"] - if lang == "" { - lang = "json" - } + fmt.Fprintf(b, "###### Response\n\n") + + if cmd.Annotations["docs:response:raw"] == "true" { + fmt.Fprintf(b, "%s\n\n", response) + } else { + lang := cmd.Annotations["docs:response:lang"] + if lang == "" { + lang = "json" + } - fmt.Fprintf(b, "###### Response\n\n```%s\n%s\n```\n\n", lang, response) + fmt.Fprintf(b, "```%s\n%s\n```\n\n", lang, response) + } } } @@ -219,6 +225,7 @@ func writeTOCSection(b *strings.Builder, cmd *cobra.Command) { // anchor converts a heading string to a GitHub markdown anchor. // "Read a Single Authorization Model" -> "read-a-single-authorization-model". +// Hyphens in the original string are preserved (GitHub keeps them in slugs). func anchor(s string) string { s = strings.ToLower(s) var b strings.Builder @@ -227,10 +234,19 @@ func anchor(s string) string { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) - case r == ' ': + case r == ' ', r == '-': b.WriteRune('-') } } return b.String() } + +// escapeHTML replaces angle brackets with HTML entities so they render +// correctly in GitHub Markdown list items (GFM strips unknown HTML tags). +func escapeHTML(s string) string { + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + + return s +} diff --git a/internal/doc/generate_test.go b/internal/doc/generate_test.go index e30d294a..b8d10c97 100644 --- a/internal/doc/generate_test.go +++ b/internal/doc/generate_test.go @@ -413,6 +413,54 @@ func TestGenerateCommandsSection_GroupPersistentFlagsRendered(t *testing.T) { assert.Contains(t, section, "`--store-id`") } +func TestGenerateCommandsSection_AngleBracketsEscaped(t *testing.T) { + t.Parallel() + + root := &cobra.Command{Use: "fga"} + group := &cobra.Command{Use: "query", Short: "Queries"} + cmd := &cobra.Command{ + Use: "list-users", + Short: "List Users", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmd.Flags().String("user-filter", "", "Filter in format or #") + group.AddCommand(cmd) + root.AddCommand(group) + + section := doc.GenerateCommandsSection(root) + + assert.Contains(t, section, "<type>") + assert.Contains(t, section, "<type>#<relation>") + assert.NotContains(t, section, "") +} + +func TestGenerateCommandsSection_RawResponse(t *testing.T) { + t.Parallel() + + rawContent := "```json\n{\"ok\":true}\n```\n\nError case:\n\n```json\n{\"ok\":false}\n```" + + root := &cobra.Command{Use: "fga"} + group := &cobra.Command{Use: "store", Short: "Stores"} + cmd := &cobra.Command{ + Use: "create", + Short: "Create Store", + Annotations: map[string]string{ + "docs:response": rawContent, + "docs:response:raw": "true", + }, + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + group.AddCommand(cmd) + root.AddCommand(group) + + section := doc.GenerateCommandsSection(root) + + assert.Contains(t, section, "###### Response") + assert.Contains(t, section, rawContent) + // raw mode must not double-wrap in a fence + assert.NotContains(t, section, "```json\n```json") +} + func TestAnchor_PunctuationStripped(t *testing.T) { t.Parallel() @@ -423,6 +471,7 @@ func TestAnchor_PunctuationStripped(t *testing.T) { {"Read Relationship Tuple Changes (Watch)", "read-relationship-tuple-changes-watch"}, {"Authorization Models", "authorization-models"}, {"List Objects", "list-objects"}, + {"Manage JSON-to-tuple mappings", "manage-json-to-tuple-mappings"}, } for _, tc := range cases { From 14547d937349c4ca922214297ab760537b205962 Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Thu, 8 Oct 2026 16:37:44 +0100 Subject: [PATCH 6/8] fix(lint): move cobra import to external group in gendocs/main.go --- cmd/gendocs/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 4965728b..3cd8677b 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -21,9 +21,10 @@ package main import ( "log" + "github.com/spf13/cobra" + "github.com/openfga/cli/cmd" "github.com/openfga/cli/internal/doc" - "github.com/spf13/cobra" ) func main() { From a100620ddb94715248501ac393bf5e87d2b2eacc Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Fri, 9 Oct 2026 12:14:56 +0100 Subject: [PATCH 7/8] feat(docs): add docs metadata for mapping evaluate command --- README.md | 66 +++++++++++++++++++++++++--------------------- cmd/mapping/run.go | 4 +++ 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index e91b7105..43af306f 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ A cross-platform CLI to interact with an OpenFGA server - [List Relations](#list-relations) - [List Users](#list-users) - [Manage JSON-to-tuple mappings](#manage-json-to-tuple-mappings) - - [Scaffold a starter mapping file](#scaffold-a-starter-mapping-file) - [Validate a mapping file](#validate-a-mapping-file) - [Run the embedded tests in a mapping file](#run-the-embedded-tests-in-a-mapping-file) + - [Scaffold a starter mapping file](#scaffold-a-starter-mapping-file) - [Evaluate a mapping against JSON input and emit tuple operations](#evaluate-a-mapping-against-json-input-and-emit-tuple-operations) - [Contributing](#contributing) @@ -1043,30 +1043,6 @@ fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roa Validate, test, and run JSON-to-tuple mapping files. -##### Scaffold a starter mapping file - -Creates a new mapping YAML file with a sample rule and embedded test. Defaults to mapping.yaml. - -###### Command - -``` -fga mapping init [mapping-file] [flags] -``` - -###### Parameters - -* `--force`: Overwrite an existing file -* `--minimal`: Emit a skeleton without the example test block - -###### Example - -```bash -fga mapping init - fga mapping init my-mapping.yaml - fga mapping init --minimal mapping.yaml - fga mapping init --force mapping.yaml -``` - ##### Validate a mapping file Validates that a mapping file is syntactically correct and all expressions compile. @@ -1124,6 +1100,30 @@ fga mapping test mapping.yaml fga mapping test --fail-fast mapping.yaml ``` +##### Scaffold a starter mapping file + +Creates a new mapping YAML file with a sample rule and embedded test. Defaults to mapping.yaml. + +###### Command + +``` +fga mapping init [mapping-file] [flags] +``` + +###### Parameters + +* `--force`: Overwrite an existing file +* `--minimal`: Emit a skeleton without the example test block + +###### Example + +```bash +fga mapping init + fga mapping init my-mapping.yaml + fga mapping init --minimal mapping.yaml + fga mapping init --force mapping.yaml +``` + ##### Evaluate a mapping against JSON input and emit tuple operations Reads JSONL from stdin (or --input) and evaluates it against the mapping file. @@ -1148,7 +1148,7 @@ continues; the command still exits non-zero if any record was skipped. ###### Command ``` -fga mapping run [mapping-file] [flags] +fga mapping evaluate [mapping-file] [flags] ``` ###### Parameters @@ -1163,10 +1163,16 @@ fga mapping run [mapping-file] [flags] ###### Example ```bash -echo '{"id":"anne","org":"acme"}' | fga mapping run mapping.yaml - fga mapping run mapping.yaml --input event.json --format json - fga mapping run --writes-only mapping.yaml > out.jsonl && fga tuple write --store-id $STORE_ID --file out.jsonl - fga mapping run mapping.yaml -i +echo '{"id":"anne","org":"acme"}' | fga mapping evaluate mapping.yaml + fga mapping evaluate mapping.yaml --input event.json --format json + fga mapping evaluate --writes-only mapping.yaml > out.jsonl && fga tuple write --store-id $STORE_ID --file out.jsonl + fga mapping evaluate mapping.yaml -i +``` + +###### Response + +```jsonl +{"op":"write","user":"user:anne","relation":"member","object":"org:acme"} ``` diff --git a/cmd/mapping/run.go b/cmd/mapping/run.go index 0bf8efd1..65d4c045 100644 --- a/cmd/mapping/run.go +++ b/cmd/mapping/run.go @@ -500,6 +500,10 @@ continues; the command still exits non-zero if any record was skipped.`, fga mapping evaluate mapping.yaml --input event.json --format json fga mapping evaluate --writes-only mapping.yaml > out.jsonl && fga tuple write --store-id $STORE_ID --file out.jsonl fga mapping evaluate mapping.yaml -i`, + Annotations: map[string]string{ + "docs:response": `{"op":"write","user":"user:anne","relation":"member","object":"org:acme"}`, + "docs:response:lang": "jsonl", + }, Args: cobra.RangeArgs(0, 1), RunE: func(cmd *cobra.Command, args []string) error { if cmd.CalledAs() == "run" { From 100137c0e52ab0e924bee0187bc4672137dbfa6b Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Fri, 9 Oct 2026 12:23:45 +0100 Subject: [PATCH 8/8] feat(docs): add docs metadata for mapping sync command Adds docs:response and docs:response:lang annotations to syncCmd, matching the evaluate command format (JSONL with op/user/relation/object shape). Regenerates README to include the sync subcommand section. --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++ cmd/mapping/sync.go | 4 ++++ 2 files changed, 51 insertions(+) diff --git a/README.md b/README.md index 43af306f..164ecc5e 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ A cross-platform CLI to interact with an OpenFGA server - [Run the embedded tests in a mapping file](#run-the-embedded-tests-in-a-mapping-file) - [Scaffold a starter mapping file](#scaffold-a-starter-mapping-file) - [Evaluate a mapping against JSON input and emit tuple operations](#evaluate-a-mapping-against-json-input-and-emit-tuple-operations) + - [Apply a mapping against JSON input, writing results to a store](#apply-a-mapping-against-json-input-writing-results-to-a-store) - [Contributing](#contributing) - [License](#license) @@ -1175,6 +1176,52 @@ echo '{"id":"anne","org":"acme"}' | fga mapping evaluate mapping.yaml {"op":"write","user":"user:anne","relation":"member","object":"org:acme"} ``` +##### Apply a mapping against JSON input, writing results to a store + +Reads JSONL from stdin (or --input) and evaluates it against the mapping file, +then applies the resulting tuple writes and deletes to the store. + +Rules using tuple_filters read current store state, diff against desired state, +and derive concrete writes and deletes before applying them. + +Progress is reported to stderr as a single updating line showing running write +and delete counts. On a non-interactive terminal each update is a new line. +Use --quiet to suppress all progress output (errors are always shown). + +--dry-run evaluates and expands filters (reading the store) but prints the +planned operations to stdout instead of writing them. +--continue-on-error skips records that fail to evaluate or apply (shown on +stderr) and exits non-zero if any were skipped. + +###### Command + +``` +fga mapping sync [mapping-file] [flags] +``` + +###### Parameters + +* `--continue-on-error`: Skip records that fail to evaluate or apply (shown on stderr) and exit non-zero if any were skipped +* `--dry-run`: Read the store and print planned operations to stdout without writing (suppressed at an interactive terminal) +* `--input`: Path to JSONL input file, one JSON object per line (default: stdin) +* `--model-id`: Authorization Model ID +* `--quiet`: Suppress progress output; only errors are written to stderr +* `--store-id`: Store ID + +###### Example + +```bash +echo '{"id":"anne","org":"acme"}' | fga mapping sync mapping.yaml --store-id $STORE_ID + fga mapping sync mapping.yaml --input events.jsonl --store-id $STORE_ID --dry-run + fga mapping sync mapping.yaml --store-id $STORE_ID --continue-on-error < events.jsonl +``` + +###### Response + +```jsonl +{"op":"write","user":"user:anne","relation":"member","object":"org:acme"} +``` + ## Contributing diff --git a/cmd/mapping/sync.go b/cmd/mapping/sync.go index 7e16323f..8ab3ea53 100644 --- a/cmd/mapping/sync.go +++ b/cmd/mapping/sync.go @@ -213,6 +213,10 @@ stderr) and exits non-zero if any were skipped.`, Example: ` echo '{"id":"anne","org":"acme"}' | fga mapping sync mapping.yaml --store-id $STORE_ID fga mapping sync mapping.yaml --input events.jsonl --store-id $STORE_ID --dry-run fga mapping sync mapping.yaml --store-id $STORE_ID --continue-on-error < events.jsonl`, + Annotations: map[string]string{ + "docs:response": `{"op":"write","user":"user:anne","relation":"member","object":"org:acme"}`, + "docs:response:lang": "jsonl", + }, Args: cobra.RangeArgs(0, 1), RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd)