diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index a23f02dc..34821164 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 53619f19..00195b60 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..164ecc5e 100644 --- a/README.md +++ b/README.md @@ -18,26 +18,26 @@ 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) + - [List Stores](#list-stores) + - [Create Store](#create-store) + - [Get Store](#get-store) + - [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 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) + - [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) - - [Write Relationship Tuples](#write-relationship-tuples) + - [Create Relationship Tuples](#create-relationship-tuples) - [Delete Relationship Tuples](#delete-relationship-tuples) - [Relationship Queries](#relationship-queries) - [Check](#check) @@ -45,12 +45,13 @@ A cross-platform CLI to interact with an OpenFGA server - [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) + - [Manage JSON-to-tuple mappings](#manage-json-to-tuple-mappings) + - [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) + - [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) @@ -207,30 +208,72 @@ 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` | + +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. + ###### Command -fga store **create** + +``` +fga store create [flags] +``` ###### 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). + +* `--format`: Authorization model input format. Can be "fga", "json", or "modular". +* `--model`: Authorization Model File Name +* `--name`: Store Name ###### Example -`fga store create --name "FGA Demo Store"` + +```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) +``` ###### Response + ```json { "id": "01H0H015178Y2V4CX10C2KGHF4", @@ -240,9 +283,8 @@ fga store **create** } ``` -`fga store create --model Model.fga` +Response for `fga store create --model Model.fga`: -###### Response ```json { "store": { @@ -257,207 +299,167 @@ fga store **create** } ``` -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: +##### Get Store -```bash -export FGA_STORE_ID=$(fga store create --model model.fga | jq -r .store.id) -``` -##### Import Store +Get a particular store. ###### Command -fga store **import** + +``` +fga store get [flags] +``` ###### 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. + +* `--store-id`: Store ID ###### Example -`fga store import --file model.fga.yaml` + +```bash +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" +} ``` -##### Export Store +##### Delete Store + +Mark a store as deleted. ###### Command -fga store **export** + +``` +fga store delete [flags] +``` ###### 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) + +* `--force`: Force delete without confirmation +* `--store-id`: Store ID ###### Example -`fga store export --store-id=01H0H015178Y2V4CX10C2KGHF4` -###### Response -```yaml -name: Test -model: |+ - model - schema 1.1 +```bash +fga store delete --store-id=01H0H015178Y2V4CX10C2KGHF4 +``` - type user +###### Response - type group - relations - define member: [user] - define moderator: [user] +```json +{} +``` -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. +##### Import Store Data -##### List Stores +Import a store: updating the name, model and appending the global tuples ###### Command -fga store **list** + +``` +fga store import [flags] +``` ###### Parameters -* `--max-pages`: Max number of pages to retrieve (default: 20) -* `--name`: Filter stores by exact name (substrings and regexes are not supported) + +* `--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 store list` -`fga store list --name="FGA Demo Store"` +```bash +fga store import --file=model.fga.yaml +``` ###### Response + ```json -{ - "stores": [{ - "id": "..", - "name": "..", - "created_at": "", - "updated_at": "", - "deleted_at": "" - }, { .. }] -} +{} ``` -##### Get Store +##### 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 store **get** + +``` +fga store export [flags] +``` ###### Parameters -* `--store-id`: Specifies the store id to get + +* `--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 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 store export --store-id=01H0H015178Y2V4CX10C2KGHF4 ``` -##### Delete Store - -###### Command -fga store **delete** +###### Response -###### Parameters -* `--store-id`: Specifies the store id to delete +```yaml +name: Test +model: |+ + model + schema 1.1 -###### Example -`fga store delete --store-id=01H0H015178Y2V4CX10C2KGHF4` + type user -###### Response -```json -{} +tuples: + - user: user:1 + relation: member + object: group:admins +tests: [] ``` #### Authorization Models -* `model` +Write, read, list and validate authorization models. -| 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` | +##### Parameters +* `--store-id`: Store ID ##### 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. +List all authorization models for a store, in descending order by creation date. ###### 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": [ { @@ -474,21 +476,31 @@ fga model **list** ##### Write Authorization Model +Writes a new authorization model. + ###### Command -fga model **write** + +``` +fga model write [model] [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) + +* `--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 ###### 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 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 +``` ###### Response -```json5 + +```json { "authorization_model_id":"01GXSA8YR785C4FYS3C0RTG7B1" } @@ -496,20 +508,31 @@ fga model **write** ##### Read a Single Authorization Model +Read an authorization model. Omit --model-id to get the latest model. + ###### Command -fga model **get** + +``` +fga model get [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`. + +* `--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 model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1 --field size --field model --field id --field created_at` + +```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 +``` ###### Response -```python + +```fga # Model ID: 01GXSA8YR785C4FYS3C0RTG7B1 # Created At: 2023-04-11 23:26:34.759 +0000 UTC # Size: 20.05 KB @@ -523,35 +546,67 @@ type document define can_view: [user] ``` -In `json` format, the fields appear intermixed with the model. +##### 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 model validate [model] [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 +``` + +###### Response + +```json +{"id":"01GPGWB8R33HWXS3KK6YG4ETGH","created_at":"2023-01-11T16:59:22Z","is_valid":true,"size_kb":0.05} +``` -`fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1 --field size --field model --field id --field created_at --format=json` +Invalid model: ```json -{ - "id":"01GXSA8YR785C4FYS3C0RTG7B1", - "created_at":"2023-04-11T23:26:34.759Z", - "size_kb":20.05, - "schema_version":"1.1", - "type_definitions": [...] -} +{"id":"01GPGTVEH5NYTQ19RYFQKE0Q4Z","created_at":"2023-01-11T16:33:15Z","is_valid":false,"error":"invalid schema version","size_kb":0.05} ``` -##### Read the Latest Authorization Model +##### Transform an Authorization Model -If `model-id` is not specified when using the `get` command, the latest authorization model will be returned. +Convert an authorization model between formats (.fga, .json, .mod). ###### Command -fga model **get** + +``` +fga model transform [model] [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` 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` + +```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 + +```fga model schema 1.1 @@ -562,157 +617,36 @@ type document define can_view: [user] ``` -##### Validate an Authorization Model +##### Test an Authorization Model -###### Command -fga model **validate** - -###### 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` +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. -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. +###### Command -###### 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} +fga model test [flags] ``` -##### 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** - ###### Parameters -* `--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. - -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). - -The tests file should be in yaml and have the following format: - -```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 -``` +* `--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 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 model test --tests model.fga.yaml +fga model test --tests "tests/*.fga.yaml" +``` ###### Response -```shell +```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 --- @@ -722,231 +656,156 @@ Checks 3/4 passing ListObjects 3/3 passing ``` -##### Transform an Authorization Model - -The **transform** command lets you convert between different authorization model formats (`.fga`, `.json`, `.mod`). +#### Relationship Tuples -###### Command -fga model **transform** +Read, write, delete, import and listen to changes in relationship tuples in a store. -###### 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) +##### Parameters +* `--store-id`: Store ID -###### Example -`fga model transform --file model.json` +##### Read Relationship Tuple Changes (Watch) -###### Response -```python -model - schema 1.1 +Get a list of relationship tuple changes (Writes and Deletes) across time. -type user +###### Command -type document - relations - define can_view: [user] +``` +fga tuple changes [flags] ``` -#### 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=` | +###### Parameters -##### Write Relationship Tuples +* `--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. -###### Command -fga tuple **write** --store-id= +###### Example -###### 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` +```bash +fga tuple changes --store-id=01H0H015178Y2V4CX10C2KGHF4 --type=document --continuation-token=M3w= +``` ###### Response -```json5 + +```json { - "successful": [ + "changes": [ { - "object":"document:roadmap", - "relation":"writer", - "user":"user:annie" + "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=" } ``` -###### 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""}" -``` +##### Read Relationship Tuples +Read relationship tuples that exist in the system (does not evaluate). -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 +``` +fga tuple read [flags] ``` -If using a `jsonl` file, the format should be: +###### Parameters -```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"} -``` +* `--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 -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 tuple read --store-id=01H0H015178Y2V4CX10C2KGHF4 --user user:anne --relation can_view --object document:roadmap ``` ###### 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": [ + "tuples": [ { - "tuple_key": { - "object":"document:roadmap", - "relation":"writer", - "user":"carl" + "key": { + "object": "document:roadmap", + "relation": "can_view", + "user": "user:anne" }, - "reason":"Write validation error ..." + "timestamp": "2023-07-06T15:12:55.080666875Z" } - ], - "failed_count": 1, - "successful_count": 1, - "total_count": 2 + ] } ``` -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: +##### Create Relationship Tuples -`fga tuple write --file tuples.json' --hide-imported-tuples > results.json` +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. -Then, process the file with `jq` to convert it to format that you can send the CLI again: +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""}") -`jq -c '[.failed[] | {user: .tuple_key.user, relation: .tuple_key.relation, object: .tuple_key.object}]' result.json > failed_tuples.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 -`fga tuple write --file failed_tuples.json' --hide-imported-tuples ` - -##### Delete Relationship Tuples +This command is flexible in accepting data inputs, making it easier to add multiple relationship tuples in various convenient formats. ###### Command -fga tuple **delete** --store-id= + +``` +fga tuple write [ ] [flags] +``` ###### 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` -###### Response -```json5 -{} -``` +* `--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. +* `--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 (with file) -`fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.json` +###### 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 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 + 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 -```json5 + +```json { "successful": [ { @@ -955,447 +814,416 @@ fga tuple **delete** --store-id= "user":"user:annie" } ], - "failed": [ - { - "tuple_key": { - "object":"document:roadmap", - "relation":"writer", - "user":"carl" - }, - "reason":"Write validation error ..." - } - ] + "failed": [], + "failed_count": 0, + "successful_count": 1, + "total_count": 1 } ``` -If you want to delete all the tuples in a store, you can use the following code: - -``` -fga tuple read --output-format=simple-json --max-pages=0 > tuples.json -fga tuple delete --file tuples.json -``` +##### Delete Relationship Tuples -##### Read Relationship Tuples +Delete relationship tuples from the store. ###### 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 -###### Example -`fga tuple read --store-id=01H0H015178Y2V4CX10C2KGHF4 --user user:anne --relation can_view --object document:roadmap` - -###### Response -```json5 -{ - "tuples": [ - { - "key": { - "object": "document:roadmap", - "relation": "can_view", - "user": "user:anne" - }, - "timestamp": "2023-07-06T15:12:55.080666875Z" - } - ] -} ``` -###### Response (--output-format=simple-json) -```json5 -[ - { - "object": "document:roadmap", - "relation": "can_view", - "user": "user:anne" - } -] +fga tuple delete [ ] [flags] ``` +###### Parameters -If you want to transform this output in a way that can be then imported using the `fga tuple write` you can run +* `--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) -``` -fga tuple read --output-format=simple-json --max-pages 0 > tuples.json -fga tuple write --file tuples.json +###### 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 ``` -##### Read Relationship Tuple Changes (Watch) +###### Response -###### Command -fga tuple **changes** --type --store-id= +```json +{} +``` -###### 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 +Response when using `--file`: -###### Example -`fga tuple changes --store-id=01H0H015178Y2V4CX10C2KGHF4 --type=document --continuation-token=M3w=` - -###### Response -```json5 +```json { - "changes": [ + "successful": [ { - "operation": "TUPLE_OPERATION_WRITE", - "timestamp": "2023-07-06T15:12:40.294950382Z", - "tuple_key": { - "object": "document:roadmap", - "relation": "can_view", - "user": "user:anne" - } + "object":"document:roadmap", + "relation":"writer", + "user":"user:annie" } ], - "continuation_token":"NHw=" + "failed": [] } ``` #### Relationship Queries -- `query` +Run queries (Check, Expand, ListObjects, ListRelations, ListUsers) that are evaluated according to a particular model. -| 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` | +##### 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 query **check** [--condition] [--contextual-tuple "\ \ \"]* --store-id= [--model-id=] -###### 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) +``` +fga query check +``` ###### 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 query check --store-id="01H4P8Z95KTXXEP6Z03T75Q984" user:anne can_view document:roadmap --context '{"ip_address":"127.0.0.1"}' --consistency "HIGHER_CONSISTENCY" +``` ###### Response -```json5 + +```json { - "allowed": true, + "allowed": true, + "resolution": "" } ``` -##### List Objects +##### Expand + +Expands the relationships in userset tree format. ###### Command -fga query **list-objects** [--contextual-tuple " "]* --store-id= [--model-id=] -###### 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) +``` +fga query expand +``` ###### 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"` + +```bash +fga query expand --store-id="01H4P8Z95KTXXEP6Z03T75Q984" can_view document:roadmap --consistency "HIGHER_CONSISTENCY" +``` ###### Response -```json5 + +```json { - "objects": [ - "document:roadmap", - "document:budget" - ], + "tree": { + "root": { + "name": "document:roadmap#can_view", + "union": { + "nodes": [ + { + "name": "document:roadmap#can_view", + "leaf": { + "users": { + "users": [ + "user:anne" + ] + } + } + } + ] + } + } + } } ``` -##### List Relations +##### List Objects + +List the objects of a certain type that a user has a particular relation to. ###### Command -fga query **list-relations** [--relation ]* [--contextual-tuple " "]* --store-id= [--model-id=] -###### 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) +``` +fga query list-objects +``` ###### 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"` + +```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 + +```json { - "relations": [ - "can_view" - ], + "objects": [ + "document:roadmap" + ] } ``` -##### Expand +##### List Relations + +List relations that a user has with an object. ###### Command -fga query **expand** --store-id= [--model-id=] + +``` +fga query list-relations [flags] +``` ###### Parameters -* `--store-id`: Specifies the store id -* `--model-id`: Specifies the model id to target (optional) -* `--consistency`: Consistency preference (optional) + +* `--relation`: Relation ###### Example -`fga query expand --store-id=01H0H015178Y2V4CX10C2KGHF4 can_view document:roadmap` + +```bash +fga query list-relations --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne document:roadmap --relation can_view --consistency "HIGHER_CONSISTENCY" +``` ###### Response -```json5 + +```json { - "tree": { - "root": { - "name": "repo:openfga/openfga#reader", - "union": { - "nodes": [{ - "leaf": { - "users": { - "users": ["user:anne"] - } - }, - "name": "repo:openfga/openfga#reader" - }] - } - } - } + "relations": [ + "can_view" + ] } ``` ##### List Users +List all users that have a certain relation with a particular object + ###### Command -fga query **list-users** --object --relation --user-filter [--contextual-tuple " "]* --store-id= [--model-id=] + +``` +fga query list-users [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) + +* `--object`: Object to list users for +* `--relation`: Relation to evaluate on +* `--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 -- `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"` + +```bash +fga query list-users --store-id=01H0H015178Y2V4CX10C2KGHF4 --object document:roadmap --relation can_view --user-filter user --consistency "HIGHER_CONSISTENCY" +``` ###### Response -```json5 + +```json { + "users": [ { - "users": [ - { - "object": { - "type": "user", - "id": "anne" - } - } - ] + "object": { + "type": "user", + "id": "anne" + } } + ] } ``` -#### Mapping +#### Manage JSON-to-tuple mappings -Tooling for authoring, validating, testing, evaluating, and applying JSON→tuple mapping files. Most commands run offline; `sync` requires store credentials. +Validate, test, and run JSON-to-tuple mapping files. -- `mapping` +##### Validate a mapping file -| 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` | - -##### Validate Mapping +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] -The mapping file is optional: omit it in an interactive terminal to choose one from a `.yaml`/`.yml` file picker. +``` +fga mapping validate [mapping-file] [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. + +* `--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 mapping validate mapping.yaml` - -`fga mapping validate --format json --model-file model.fga mapping.yaml` - -###### Response -``` -mapping is valid (2 rules) -``` -With `--verbose`: -``` - ✓ add-member - ✓ add-admin -mapping is valid (2 rules) +```bash +fga mapping validate mapping.yaml + fga mapping validate --format json mapping.yaml + fga mapping validate --model-file model.fga mapping.yaml ``` -JSON response: -```json -{"valid":true,"rule_count":2,"rules":["add-member","add-admin"]} -``` +##### Run the embedded tests in a mapping file -##### Test Mapping +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] -The mapping file is optional: omit it in an interactive terminal to choose one from a `.yaml`/`.yml` file picker. +``` +fga mapping test [mapping-file] [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 ``) +* `--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 -`fga mapping test mapping.yaml` - -`fga mapping test --format junit --output-file results.xml mapping.yaml` -###### Response -``` -PASS admin gets member and admin (3ms) -PASS regular user only gets member (2ms) -2 passed, 0 failed (5ms) +```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 ``` -##### Init Mapping +##### 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.yaml] + +``` +fga mapping init [mapping-file] [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 -###### Example -`fga mapping init` +* `--force`: Overwrite an existing file +* `--minimal`: Emit a skeleton without the example test block -`fga mapping init --minimal my-mapping.yaml` +###### Example -###### Response -``` -Created mapping.yaml +```bash +fga mapping init + fga mapping init my-mapping.yaml + fga mapping init --minimal mapping.yaml + fga mapping init --force mapping.yaml ``` -##### Evaluate Mapping +##### Evaluate a mapping against JSON input and emit tuple operations -###### Command -fga mapping **evaluate** [mapping-file] +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). -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`. +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). -The mapping file is optional: omit it in an interactive terminal to choose one from a `.yaml`/`.yml` file picker. +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. -> `fga mapping run` is a deprecated alias for `fga mapping evaluate` and will be removed in a future release. +###### Command + +``` +fga mapping evaluate [mapping-file] [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`. +* `--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 -`echo '{"id":"anne","org":"acme"}' | fga mapping evaluate mapping.yaml` -`fga mapping evaluate --writes-only mapping.yaml --input event.json > out.jsonl && fga tuple write --store-id $FGA_STORE_ID --file out.jsonl` +```bash +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 -```json + +```jsonl {"op":"write","user":"user:anne","relation":"member","object":"org:acme"} ``` -###### 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. +##### Apply a mapping against JSON input, writing results to a store -``` -$ 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 +Reads JSONL from stdin (or --input) and evaluates it against the mapping file, +then applies the resulting tuple writes and deletes to the store. -> {"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 -``` +Rules using tuple_filters read current store state, diff against desired state, +and derive concrete writes and deletes before applying them. -##### Sync Mapping +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). -###### Command -fga mapping **sync** [mapping-file] +--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. -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. +###### Command -The mapping file is optional: omit it in an interactive terminal to choose one from a `.yaml`/`.yml` file picker. +``` +fga mapping sync [mapping-file] [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 +* `--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 -`echo '{"id":"anne","org":"acme"}' | fga mapping sync mapping.yaml --store-id $FGA_STORE_ID` - -`fga mapping sync mapping.yaml --input events.jsonl --store-id $FGA_STORE_ID --dry-run > planned.jsonl` -`fga mapping sync mapping.yaml --store-id $FGA_STORE_ID --continue-on-error < events.jsonl` +```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 -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 +```jsonl {"op":"write","user":"user:anne","relation":"member","object":"org:acme"} -{"op":"delete","user":"user:bob","relation":"member","object":"org:acme"} ``` + + ## 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..3cd8677b --- /dev/null +++ b/cmd/gendocs/main.go @@ -0,0 +1,44 @@ +/* +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/spf13/cobra" + + "github.com/openfga/cli/cmd" + "github.com/openfga/cli/internal/doc" +) + +func main() { + cobra.EnableCommandSorting = false + + 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/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" { 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) 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..7a67e3a0 100644 --- a/cmd/model/model.go +++ b/cmd/model/model.go @@ -24,13 +24,13 @@ 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.", } func init() { - ModelCmd.AddCommand(writeCmd) ModelCmd.AddCommand(listCmd) + ModelCmd.AddCommand(writeCmd) ModelCmd.AddCommand(getCmd) ModelCmd.AddCommand(validateCmd) ModelCmd.AddCommand(transformCmd) 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..39fc341c 100644 --- a/cmd/model/transform.go +++ b/cmd/model/transform.go @@ -42,12 +42,24 @@ func determineOutputFormat(input, output authorizationmodel.ModelFormat) authori // transformCmd represents the transform command. var transformCmd = &cobra.Command{ - Use: "transform", - Short: "Transforms an authorization model", + 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 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 { @@ -102,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 4cb9bbc9..4f82e6e9 100644 --- a/cmd/model/validate.go +++ b/cmd/model/validate.go @@ -94,11 +94,19 @@ 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.", + 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": "```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 { var inputModel string if err := authorizationmodel.ReadFromInputFileOrArg( diff --git a/cmd/model/write.go b/cmd/model/write.go index 23e7e682..6efd3e1b 100644 --- a/cmd/model/write.go +++ b/cmd/model/write.go @@ -52,12 +52,17 @@ 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 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..e73d1240 100644 --- a/cmd/query/check.go +++ b/cmd/query/check.go @@ -62,11 +62,17 @@ 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.", - 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..2c63a335 100644 --- a/cmd/query/expand.go +++ b/cmd/query/expand.go @@ -56,11 +56,34 @@ 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 - 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..6f21841b 100644 --- a/cmd/query/list-objects.go +++ b/cmd/query/list-objects.go @@ -62,11 +62,18 @@ 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 - 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..2049423f 100644 --- a/cmd/query/list-relations.go +++ b/cmd/query/list-relations.go @@ -126,11 +126,18 @@ 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 - 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..69c31686 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 + 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": [ + { + "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..f4eff004 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() { @@ -82,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/create.go b/cmd/store/create.go index 2b205616..a4663e06 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" - -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) - `, +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": "```json\n" + + `{ + "id": "01H0H015178Y2V4CX10C2KGHF4", + "name": "FGA Demo Store", + "created_at": "2023-05-19T16:10:07.637585677Z", + "updated_at": "2023-05-19T16:10:07.637585677Z" +}` + "\n```\n\nResponse for `fga store create --model Model.fga`:\n\n```json\n" + + `{ + "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" + } +}` + "\n```", + "docs:response:raw": "true", + }, RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd) storeName, _ := cmd.Flags().GetString("name") @@ -152,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/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..8c2731b5 100644 --- a/cmd/store/store.go +++ b/cmd/store/store.go @@ -24,13 +24,13 @@ 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", } func init() { - StoreCmd.AddCommand(createCmd) StoreCmd.AddCommand(listCmd) + StoreCmd.AddCommand(createCmd) StoreCmd.AddCommand(getCmd) StoreCmd.AddCommand(deleteCmd) StoreCmd.AddCommand(importCmd) 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..43bc21a8 100644 --- a/cmd/tuple/delete.go +++ b/cmd/tuple/delete.go @@ -32,12 +32,26 @@ 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": "```json\n{}\n```\n\nResponse when using `--file`:\n\n```json\n" + + `{ + "successful": [ + { + "object":"document:roadmap", + "relation":"writer", + "user":"user:annie" + } + ], + "failed": [] +}` + "\n```", + "docs:response:raw": "true", + }, 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..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 " + @@ -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..6eebaf51 --- /dev/null +++ b/internal/doc/generate.go @@ -0,0 +1,252 @@ +/* +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) + + 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, escapeHTML(f.Usage)) + }) + + fmt.Fprintf(b, "\n") + } + + for _, sub := range cmd.Commands() { + if !sub.IsAvailableCommand() || sub.IsAdditionalHelpTopicCommand() { + continue + } + + writeSection(b, sub) + } + + return + } + + 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) { + 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, escapeHTML(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 != "" { + 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, "```%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 +} + +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 { + 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". +// 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 + + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(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 new file mode 100644 index 00000000..b8d10c97 --- /dev/null +++ b/internal/doc/generate_test.go @@ -0,0 +1,491 @@ +/* +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 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 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() + + cases := []struct { + input string + want string + }{ + {"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 { + 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) + } +}