From a004edd8a802949f779ad148f3f30093fbc05140 Mon Sep 17 00:00:00 2001 From: Ewan Harris Date: Thu, 9 May 2024 12:45:29 +0100 Subject: [PATCH] docs: add list users snippet, concept and testing (#732) Co-authored-by: Will Vedder Co-authored-by: Raghd Hamzeh --- docs/content/concepts.mdx | 33 +- .../getting-started/perform-list-users.mdx | 189 ++++++++++ docs/content/modeling/testing-models.mdx | 63 ++++ docs/sidebars.js | 5 + package-lock.json | 18 +- package.json | 2 +- .../SnippetViewer/ListUsersRequestViewer.tsx | 346 ++++++++++++++++++ src/components/Docs/SnippetViewer/index.ts | 1 + 8 files changed, 646 insertions(+), 11 deletions(-) create mode 100644 docs/content/getting-started/perform-list-users.mdx create mode 100644 src/components/Docs/SnippetViewer/ListUsersRequestViewer.tsx diff --git a/docs/content/concepts.mdx b/docs/content/concepts.mdx index 995e25eef..48490f321 100644 --- a/docs/content/concepts.mdx +++ b/docs/content/concepts.mdx @@ -17,6 +17,7 @@ import { RelatedSection, RelationshipTuplesViewer, UpdateProductNameInLinks, + ListUsersRequestViewer } from '@components/Docs'; # Concepts @@ -591,7 +592,37 @@ For example, the following returns all the objects with document type for which expectedResults={['document:otherdoc', 'document:planning']} /> -For more information, see the [Relationship Queries page](./interacting/relationship-queries.mdx) and the official [Check API Reference](/api/service#Relationship%20Queries/ListObjects). +For more information, see the [Relationship Queries page](./interacting/relationship-queries.mdx) and the [List Objects API Reference](/api/service#Relationship%20Queries/ListObjects). + + +
+ + +## What Is A List Users Request? + +A **list users request** is a call to the list users endpoint that returns all users of a given type that have a specified relationship with an object. + + + +List users requests are completed using the relevant `ListUsers` method in SDKs, the `fga query list-users` command in the CLI, or by manually calling the [ListUsers endpoint](/api/service#Relationship%20Queries/ListUsers) using curl or in your code. + +The list users endpoint responds with a list of users and excluded users for a given type that have the specificed relationship with an object. + +For example, the following returns all the users of type `user` that have the `viewer` relationship for `document:planning`: + + + +For more information, see the the [ListUsers API Reference](/api/service#Relationship%20Queries/ListUsers).
diff --git a/docs/content/getting-started/perform-list-users.mdx b/docs/content/getting-started/perform-list-users.mdx new file mode 100644 index 000000000..a68fb5bb2 --- /dev/null +++ b/docs/content/getting-started/perform-list-users.mdx @@ -0,0 +1,189 @@ +--- +title: Perform a List Users call +sidebar_position: 4 +slug: /getting-started/perform-list-users +description: List all users that have a certain relation with a particular object +--- + +import { + SupportedLanguage, + languageLabelMap, + ListUsersRequestViewer, + DocumentationNotice, + ProductConcept, + ProductName, + ProductNameFormat, + RelatedSection, + SdkSetupHeader, + SdkSetupPrerequisite, +} from '@components/Docs'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Perform a List Users call + +:::caution Warning +ListUsers is currently in an experimental release. Read [the announcement](https://openfga.dev/blog/list-users-announcement) for more information. +::: + + + +This section will illustrate how to perform a request to determine all the of a given that have a specified with a given . + +## Before You Start + + + + + +1. +2. You have [installed the SDK](./install-sdk.mdx). +3. You have [configured the _authorization model_](./configure-model.mdx) and [updated the _relationship tuples_](./update-tuples.mdx). +4. You have loaded `FGA_STORE_ID` and `FGA_API_URL` as environment variables. + + + + + +1. +2. You have [installed the SDK](./install-sdk.mdx). +3. You have [configured the _authorization model_](./configure-model.mdx) and [updated the _relationship tuples_](./update-tuples.mdx). +4. You have loaded `FGA_STORE_ID` and `FGA_API_URL` as environment variables. + + + + + +1. +2. You have [installed the SDK](./install-sdk.mdx). +3. You have [configured the _authorization model_](./configure-model.mdx) and [updated the _relationship tuples_](./update-tuples.mdx). +4. You have loaded `FGA_STORE_ID` and `FGA_API_URL` as environment variables. + + + +{/* + +1. +2. You have [installed the SDK](./install-sdk.mdx). +3. You have [configured the _authorization model_](./configure-model.mdx) and [updated the _relationship tuples_](./update-tuples.mdx). +4. You have loaded `FGA_STORE_ID` and `FGA_API_URL` as environment variables. + + */} + + + +1. +2. You have [installed the SDK](./install-sdk.mdx). +3. You have [configured the _authorization model_](./configure-model.mdx) and [updated the _relationship tuples_](./update-tuples.mdx). +4. You have loaded `FGA_STORE_ID` and `FGA_API_URL` as environment variables. + + + + + +1. +2. You have [configured the _authorization model_](./configure-model.mdx). +3. You have loaded `FGA_STORE_ID` and `FGA_API_URL` as environment variables. + + + + + +1. +2. You have [configured the _authorization model_](./configure-model.mdx) and [updated the _relationship tuples_](./update-tuples.mdx). +3. You have loaded `FGA_STORE_ID` and `FGA_API_URL` as environment variables. + + + + +## Step By Step + +Assume that you want to list all users of type `user` that have a `reader` relationship with `document:planning`: + +### 01. Configure the API Client + +Before calling the ListUsers API, you will need to configure the API client. + + + + + + + + + + + + + + + + + +{/* + + + + */} + + + + + + + + + + + + +To obtain the [access token](https://auth0.com/docs/get-started/authentication-and-authorization-flow/call-your-api-using-the-client-credentials-flow): + + + + + + +### 02. Calling List Users API + +To return all users of type `user` that have have the `reader` relationship with `document:planning`: + + + +The result `user:anne` and `user:beth` are the `user` objects that have the `reader` relationship with `document:planning`. + +:::caution Warning +The performance characteristics of the ListUsers endpoint vary drastically depending on the model complexity, number of tuples, and the relations it needs to evaluate. Relations with 'and' or 'but not' are particularly expensive to evaluate. +::: + +## Related Sections + + diff --git a/docs/content/modeling/testing-models.mdx b/docs/content/modeling/testing-models.mdx index f6553bfad..51e2fff39 100644 --- a/docs/content/modeling/testing-models.mdx +++ b/docs/content/modeling/testing-models.mdx @@ -82,6 +82,7 @@ Tests have the following structure: |`tuples` | A set of tuples that are only considered for the test | |`check` | A set of tests for Check calls, each with a user/object and a set of assertions | |`list_objects` | A set of tests for ListObjects calls, each one with a user/type and a set of assertions for any number of relations| +|`list_users` | A set of tests for ListUsers calls, each one with an object and user filter and a set of assertions for the users and excluded_users for any number of relations | ## Write Check tests @@ -144,6 +145,68 @@ The following verifies the expected results using the `list_objects` option in < ``` The example above checks that `user:anne` has access to the `organization:acme` as a member and is not an admin of any organization. It also checks that `user:peter`, given the current time is February 1st 2024, 0:10 AM, is not related to any organization as a member, but is related to `organization:acme` as an admin. +## Write List Users tests + +:::caution Warning +ListUsers is currently in an experimental release. Read [the announcement](https://openfga.dev/blog/list-users-announcement) for more information. +::: + +List users tests verify the results of the [list-users API](../getting-started/perform-list-users.mdx) to validate the users who or do not have access to an object + +Each list users verification has the following structure: + +| Object | Description | +| -------- | -------- | +|`object` | The object to list users for | +|`user_filter` | Specifies the type or userset to filter with, this must only contain one entry | +|`user_filter.type` | The specific type of results to return with response | +|`user_filter.relation` | The specific relation of results to return with response. Specify to return usersets (optional) | +|`context` | A set of tests for contextual parameters used to evaluate [conditions](./conditions.mdx)| +|`assertions` | A list of assertions to make | +|`` | The name of the relation you want to verify | +|`.users` | The users who should have the stated relation to the object | +|`.excluded_users` | The users who should have explicitly not have access to the object due to evaluations of type bound public access and negation (e.g. "all users except anne") | + +In order to simplify test writing, the following syntax is supported for the various object types included in `users` and `excluded_users` from the API response: + +* `:` to represent a userset that is a user +* `:#` to represent a userset that is a relation on a type +* `:*` to represent a userset that is a type bound public access for a type + +The following is an example of using the `list_users` option in tests: + +```yaml + list_users: + - object: organization:acme + user_filter: + - type: user + assertions: + member: + users: + - user:anne + excluded_users: [] + admin: + users: [] + excluded_users: [] + + - object: organization:acme + user_filter: + - type: employee + context: + current_time : "2024-02-01T00:10:00Z" + assertions: + member: + users: [] + excluded_users: [] + admin: + users: + - employee:peter + excluded_users: [] + +``` +The example above checks that `user:anne` has access to the `organization:acme` as a member and is not an admin of any organization. It also checks that `employee:peter`, given the current time is February 1st 2024, 0:10 AM, is not related to any organization as a member, but is related to `organization:acme` as an admin. + + ## Running tests Tests are run using the `model test` CLI command. For instructions on installing the OpenFGA CLI, visit the [OpenFGA CLI Github repository](https://github.com/openfga/cli). diff --git a/docs/sidebars.js b/docs/sidebars.js index 80674903c..ae4d9e2c3 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -98,6 +98,11 @@ const sidebars = { label: 'Perform a List Objects Request', id: 'content/getting-started/perform-list-objects', }, + { + type: 'doc', + label: 'Perform a List Users Request', + id: 'content/getting-started/perform-list-users', + }, { type: 'doc', label: 'Use the FGA CLI', diff --git a/package-lock.json b/package-lock.json index e4980fb90..64ef4b132 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@easyops-cn/docusaurus-search-local": "0.40.1", "@lottiefiles/react-lottie-player": "3.5.3", "@openfga/frontend-utils": "^0.2.0-beta.9", - "@openfga/sdk": "^0.3.5", + "@openfga/sdk": "^0.4.0", "@openfga/syntax-transformer": "^0.2.0-beta.17", "assert-never": "1.2.1", "clsx": "2.1.1", @@ -3404,11 +3404,11 @@ } }, "node_modules/@openfga/sdk": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@openfga/sdk/-/sdk-0.3.5.tgz", - "integrity": "sha512-ChS/D9khwiy2nqffxTjUMwd9wkNvY34nHgk6BWjLfEaceahBZVEMCkvcPW1N/oCoA5CbhI1+AJ1/LgHZrZc93g==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@openfga/sdk/-/sdk-0.4.0.tgz", + "integrity": "sha512-1UnAcBdwCqLVAg8nuX72KMbJxBg3qsg3m2qf7FLZFwVAK6YHvzHYZ+gATCjZe4mMhr3qmZsb3dZRqE8hTbz9ng==", "dependencies": { - "axios": "^1.6.7", + "axios": "^1.6.8", "tiny-async-pool": "^2.1.0" }, "engines": { @@ -21083,11 +21083,11 @@ } }, "@openfga/sdk": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@openfga/sdk/-/sdk-0.3.5.tgz", - "integrity": "sha512-ChS/D9khwiy2nqffxTjUMwd9wkNvY34nHgk6BWjLfEaceahBZVEMCkvcPW1N/oCoA5CbhI1+AJ1/LgHZrZc93g==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@openfga/sdk/-/sdk-0.4.0.tgz", + "integrity": "sha512-1UnAcBdwCqLVAg8nuX72KMbJxBg3qsg3m2qf7FLZFwVAK6YHvzHYZ+gATCjZe4mMhr3qmZsb3dZRqE8hTbz9ng==", "requires": { - "axios": "^1.6.7", + "axios": "^1.6.8", "tiny-async-pool": "^2.1.0" } }, diff --git a/package.json b/package.json index 8565c09d9..b8de15e3f 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@easyops-cn/docusaurus-search-local": "0.40.1", "@lottiefiles/react-lottie-player": "3.5.3", "@openfga/frontend-utils": "^0.2.0-beta.9", - "@openfga/sdk": "^0.3.5", + "@openfga/sdk": "^0.4.0", "@openfga/syntax-transformer": "^0.2.0-beta.17", "assert-never": "1.2.1", "clsx": "2.1.1", diff --git a/src/components/Docs/SnippetViewer/ListUsersRequestViewer.tsx b/src/components/Docs/SnippetViewer/ListUsersRequestViewer.tsx new file mode 100644 index 000000000..bc6d6551b --- /dev/null +++ b/src/components/Docs/SnippetViewer/ListUsersRequestViewer.tsx @@ -0,0 +1,346 @@ +import { getFilteredAllowedLangs, SupportedLanguage, DefaultAuthorizationModelId } from './SupportedLanguage'; +import { defaultOperationsViewer } from './DefaultTabbedViewer'; +import assertNever from 'assert-never/index'; +import { ListUsersResponse, TupleKey } from '@openfga/sdk'; + +interface ListUsersRequestViewerOpts { + authorizationModelId?: string; + objectType: string; + objectId: string; + relation: string; + userFilterType: string; + userFilterRelation?: string; + contextualTuples?: TupleKey[]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context?: Record; + expectedResults: ListUsersResponse; + skipSetup?: boolean; + allowedLanguages?: SupportedLanguage[]; +} + +function listUsersRequestViewer(lang: SupportedLanguage, opts: ListUsersRequestViewerOpts): string { + const { + relation, + objectType, + objectId, + userFilterType, + userFilterRelation, + contextualTuples, + context, + expectedResults, + } = opts; + const modelId = opts.authorizationModelId ? opts.authorizationModelId : DefaultAuthorizationModelId; + + const response = `{"users": [${expectedResults.users.map((r) => JSON.stringify(r)).join(', ')}],"excluded_users":[${expectedResults.excluded_users.map((r) => JSON.stringify(r)).join(', ')}]}`; + + switch (lang) { + case SupportedLanguage.PLAYGROUND: + return `# Note: List Users is not currently supported on the playground`; + case SupportedLanguage.CLI: + return `fga query list-users --store-id=\${FGA_STORE_ID} --model-id=${modelId} --object ${objectType}:${objectId} --relation ${relation} --user-filter ${userFilterType}${userFilterRelation ? `#${userFilterRelation}` : ''} ${ + contextualTuples + ? `${contextualTuples + .map((tuple) => ` --contextual-tuple "${tuple.user} ${tuple.relation} ${tuple.object}"`) + .join(' ')}` + : '' + }${context ? ` --context='${JSON.stringify(context)}'` : ''} + +# Response: ${response}`; + case SupportedLanguage.CURL: + /* eslint-disable max-len */ + return `curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/list-users \\ + -H "Authorization: Bearer $FGA_API_TOKEN" \\ # Not needed if service does not require authorization + -H "content-type: application/json" \\ + -d '{ + "authorization_model_id": "${modelId}", + "object": { + "type: "${objectType}", + "id": "${objectId}", + }, + "relation": "${relation}", + "user_filters": [ + { + "type": "${userFilterType}"${ + userFilterRelation + ? `, +, + "relation": "${userFilterRelation}"` + : '' + } + } + ]${ + contextualTuples + ? `, + "contextual_tuples": { + "tuple_keys": [${contextualTuples + .map( + (tuple) => ` + {"object": "${tuple.object}", "relation": "${tuple.relation}", "user": "${tuple.user}"}`, + ) + .join(',')} + ] + }` + : '' + }${ + context + ? `, + "context":${JSON.stringify(context)}` + : '' + } + }' + + +# Response: ${response}`; + /* eslint-enable max-len */ + case SupportedLanguage.JS_SDK: + return `const response = await fgaClient.listUsers({ + object: { + type: "${objectType}", + id: "${objectId}" + }, + user_filters: [{ + type: "${userFilterType}",${ + userFilterRelation + ? `, + relation: "${userFilterRelation}"` + : '' + } + }], + relation: "${relation}",${ + contextualTuples?.length + ? ` + contextualTuples: { + tuple_keys: [${contextualTuples + .map( + (tupleKey) => `{ + user: "${tupleKey.user}", + relation: "${tupleKey.relation}", + object: "${tupleKey.object}" + }`, + ) + .join(', ')}] + },` + : '' + }${ + context + ? ` + context:${JSON.stringify(context)},` + : '' + } +}, { + authorization_model_id: "${modelId}", +}); +// response.users = [${expectedResults.users.map((u) => JSON.stringify(u)).join(',')}] +// response.excluded_users = p${expectedResults.excluded_users.map((u) => JSON.stringify(u)).join(',')}]`; + case SupportedLanguage.GO_SDK: + /* eslint-disable no-tabs */ + return `options := ClientListUsersOptions{ + AuthorizationModelId: PtrString("${modelId}"), +} + +userFilters := []openfga.UserTypeFilter{{ Type:"${userFilterType}"${userFilterRelation ? `,Relation:${userFilterRelation} ` : ' '}}} + +body := ClientListUsersRequest{ + Object: openfga.Object{ + Type: "${objectType}", + Id: "${objectId}", + }, + Relation: "${relation}", + UserFilters: userFilters,${ + !contextualTuples + ? '' + : ` + ContextualTuples: []ClientContextualTupleKey{ +${ + !contextualTuples + ? '' + : contextualTuples + .map( + (tuple) => + ` { + User: "${tuple.user}", + Relation: "${tuple.relation}", + Object: "${tuple.object}", + },`, + ) + .join('\n') +} + },` + }${ + context + ? ` + Context: &map[string]interface{}${JSON.stringify(context)},` + : '' + } +} + +data, err := fgaClient.ListUsers(context.Background()). + Body(body). + Options(options). + Execute() + +// data.Users = [${expectedResults.users.map((u) => JSON.stringify(u)).join(', ')}] +// data.ExcludedUsers = [${expectedResults.excluded_users.map((u) => JSON.stringify(u)).join(', ')}]`; + case SupportedLanguage.DOTNET_SDK: + return ` +var options = new ClientWriteOptions { + AuthorizationModelId = "${modelId}", +}; +var body = new ClientListUsersRequest { + Object = new FgaObject { + Type = "${objectType}", + Id = "${objectId}" + }, + Relation = "${relation}", + UserFilters = new List { + new() { + Type = "${userFilterType}"${ + userFilterRelation + ? ` + Relation = "${userFilterRelation}" + ` + : '' + } + } + }${ + contextualTuples + ? `, + ,ContextualTuples = new List({ + ${contextualTuples + .map((tuple) => `new(user: "${tuple.user}", relation: "${tuple.relation}", _object: "${tuple.object}")`) + .join(',\n ')} +})` + : '' + } + ${ + context + ? `Context = new { ${Object.entries(context) + .map(([k, v]) => `${k}="${v}"`) + .join(',')} }` + : '' + } +}; + +var response = await fgaClient.ListUsers(body, options); + +// response.Users = [${expectedResults.users.map((u) => JSON.stringify(u)).join(',')}] +// response.ExcludedUsers = [${expectedResults.excluded_users.map((u) => JSON.stringify(u)).join(',')}]`; + case SupportedLanguage.PYTHON_SDK: + return ''; + // return ` + // options = { + // "authorization_model_id": "${modelId}" + // } + + // userFilters=[UserTypeFilter(type="${userFilterType}"${userFilterRelation ? `, relation="${userFilterRelation}"` : ''})] + + // body = ClientListUsersRequest( + // object=FgaObject(type="${objectId}, id="${objectId}"), + // relation="${relation}", + // userFilters=userFilters,${ + // contextualTuples + // ? ` + // contextual_tuples=[ + // ${contextualTuples + // .map( + // (tuple) => `ClientTupleKey(user="${tuple.user}", relation="${tuple.relation}", object="${tuple.object}")`, + // ) + // .join(',\n ')} + // ],` + // : `` + // }${ + // context + // ? ` + // context=dict(${Object.entries(context) + // .map( + // ([k, v]) => ` + // ${k}="${v}"`, + // ) + // .join(',')} + // )` + // : '' + // } + // ) + + // response = await fga_client.list_users(body, options) + + // # response.users = [${expectedResults.users.map((u) => JSON.stringify(u)).join(',')}] + // # response.excludedUsers = [${expectedResults.excluded_users.map((u) => JSON.stringify(u)).join(',')}]`; + case SupportedLanguage.RPC: + return `listUsers( + "${objectId}", // list the objects that the user \`${objectId}\` + "${relation}", // has an \`${relation}\` relation + "${objectType}", // and that are of type \`${objectType}\` + authorization_model_id = "${modelId}", // for this particular authorization model id ${ + contextualTuples + ? ` + contextual_tuples = [ // Assuming the following is true + ${contextualTuples + .map((tuple) => `{user = "${tuple.user}", relation = "${tuple.relation}", object = "${tuple.object}"}`) + .join(',\n ')} + ]` + : '' + } +); + +Reply: ${response}`; + + case SupportedLanguage.JAVA_SDK: { + const contextualTuplesList = contextualTuples + ? ` + .contextualTupleKeys( + List.of(${contextualTuples.map( + (tuple) => ` + new ClientTupleKey() + .user("${tuple.user}") + .relation("${tuple.relation}") + ._object("${tuple.object}") + ))`, + )}` + : ''; + const contextCall = context + ? ` + .context(Map.of(${Object.entries(context) + .map(([k, v]) => `"${k}", "${v}"`) + .join(',')}))` + : ''; + return `var options = new ClientListUsersOptions() + .authorizationModelId("${modelId}"); + +var userFilters = new ArrayList() { + { + add(new UserTypeFilter().type("${userFilterType}")${userFilterRelation ? `.relation("${userFilterRelation}")` : ''}); + } +}; + + +var body = new ClientListUsersRequest() + ._object(new FgaObject().type("${objectType}").id("${objectId}")) + .relation("${relation}") + .userFilters(userFilters)${contextualTuplesList}${contextCall}; + +var response = fgaClient.listUsers(body, options).get(); + +// response.getUsers() = [${expectedResults.users.map((u) => JSON.stringify(u)).join(',')}] +// response.getExcludedUsers() = [${expectedResults.excluded_users.map((u) => JSON.stringify(u)).join(',')}]`; + } + default: + assertNever(lang); + } +} + +export function ListUsersRequestViewer(opts: ListUsersRequestViewerOpts): JSX.Element { + const defaultLangs = [ + SupportedLanguage.JS_SDK, + SupportedLanguage.GO_SDK, + SupportedLanguage.DOTNET_SDK, + // SupportedLanguage.PYTHON_SDK, + SupportedLanguage.JAVA_SDK, + SupportedLanguage.CLI, + SupportedLanguage.CURL, + SupportedLanguage.RPC, + ]; + const allowedLanguages = getFilteredAllowedLangs(opts.allowedLanguages, defaultLangs); + return defaultOperationsViewer(allowedLanguages, opts, listUsersRequestViewer); +} diff --git a/src/components/Docs/SnippetViewer/index.ts b/src/components/Docs/SnippetViewer/index.ts index 49c9e3d27..79626669c 100644 --- a/src/components/Docs/SnippetViewer/index.ts +++ b/src/components/Docs/SnippetViewer/index.ts @@ -2,6 +2,7 @@ export * from './CheckRequestViewer'; export * from './DefaultTabbedViewer'; export * from './ExpandRequestViewer'; export * from './ListObjectsRequestViewer'; +export * from './ListUsersRequestViewer'; export * from './ReadChangesRequestViewer'; export * from './ReadRequestViewer'; export { SdkSetupHeader } from './SdkSetup';