diff --git a/.openapi-generator/FILES b/.openapi-generator/FILES index e086dd2..d250384 100644 --- a/.openapi-generator/FILES +++ b/.openapi-generator/FILES @@ -7,8 +7,6 @@ .github/ISSUE_TEMPLATE/config.yaml .github/ISSUE_TEMPLATE/feature_request.yaml .github/dependabot.yaml -.github/workflows/main.yaml -.github/workflows/semgrep.yaml .gitignore .gitignore .madgerc diff --git a/CHANGELOG.md b/CHANGELOG.md index 648e386..718e71e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ - fix: error correctly if apiUrl is not provided (#161) - feat: add support for `start_time` parameter in `ReadChanges` endpoint - BREAKING: As of this release, the min node version required by the SDK is now v16.15.0 +- feat!: add support for server-side `BatchCheck` method. + +BREAKING CHNAGES: + +- The minimum noce version required by this SDK is now v16.15.0 +- Usage of the existing `batchCheck` method should now use the `clientBatchCheck` method. The existing `BatchCheckResponse` has been renamed to `ClientBatchCheckResponse` and it now bundles the results in a field called `result` instead of `responses`. ## v0.7.0 diff --git a/README.md b/README.md index b938aa8..ab37ba2 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ yarn add @openfga/sdk We strongly recommend you initialize the `OpenFgaClient` only once and then re-use it throughout your app, otherwise you will incur the cost of having to re-initialize multiple times or at every request, the cost of reduced connection pooling and re-use, and would be particularly costly in the client credentials flow, as that flow will be performed on every request. -> The `OpenFgaClient` will by default retry API requests up to 15 times on 429 and 5xx errors. +> The `OpenFgaClient` will by default retry API requests up to 3 times on 429 and 5xx errors. #### No Credentials @@ -446,7 +446,7 @@ const result = await fgaClient.check({ ##### Batch Check Run a set of [checks](#check). Batch Check will return `allowed: false` if it encounters an error, and will return the error in the body. -If 429s or 5xxs are encountered, the underlying check will retry up to 15 times before giving up. +If 429s or 5xxs are encountered, the underlying check will retry up to 3 times before giving up. ```javascript const options = { @@ -667,7 +667,7 @@ const response = await fgaClient.writeAssertions([{ ### Retries -If a network request fails with a 429 or 5xx error from the server, the SDK will automatically retry the request up to 15 times with a minimum wait time of 100 milliseconds between each attempt. +If a network request fails with a 429 or 5xx error from the server, the SDK will automatically retry the request up to 3 times with a minimum wait time of 100 milliseconds between each attempt. To customize this behavior, create an object with `maxRetry` and `minWaitInMs` properties. `maxRetry` determines the maximum number of retries (up to 15), while `minWaitInMs` sets the minimum wait time between retries in milliseconds. diff --git a/api.ts b/api.ts index cdfa933..103f7fd 100644 --- a/api.ts +++ b/api.ts @@ -35,6 +35,11 @@ import { AssertionTupleKey, AuthErrorCode, AuthorizationModel, + BatchCheckItem, + BatchCheckRequest, + BatchCheckResponse, + BatchCheckSingleResult, + CheckError, CheckRequest, CheckRequestTupleKey, CheckResponse, @@ -120,6 +125,45 @@ import { TelemetryAttribute, TelemetryAttributes } from "./telemetry/attributes" */ export const OpenFgaApiAxiosParamCreator = function (configuration: Configuration, credentials: Credentials) { return { + /** + * The `BatchCheck` API functions nearly identically to `Check`, but instead of checking a single user-object relationship BatchCheck accepts a list of relationships to check and returns a map containing `BatchCheckItem` response for each check it received. An associated `correlation_id` is required for each check in the batch. This ID is used to correlate a check to the appropriate response. It is a string consisting of only alphanumeric characters or hyphens with a maximum length of 36 characters. This `correlation_id` is used to map the result of each check to the item which was checked, so it must be unique for each item in the batch. We recommend using a UUID or ULID as the `correlation_id`, but you can use whatever unique identifier you need as long as it matches this regex pattern: `^[\\w\\d-]{1,36}$` For more details on how `Check` functions, see the docs for `/check`. ### Examples #### A BatchCheckRequest ```json { \"checks\": [ { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:anne\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PM3QM7VBPGB8KMPK8SBD5\" }, { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:bob\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PMM6A90NV5ET0F28CYSZQ\" } ] } ``` Below is a possible response to the above request. Note that the result map\'s keys are the `correlation_id` values from the checked items in the request: ```json { \"result\": { \"01JA8PMM6A90NV5ET0F28CYSZQ\": { \"allowed\": false, \"error\": {\"message\": \"\"} }, \"01JA8PM3QM7VBPGB8KMPK8SBD5\": { \"allowed\": true, \"error\": {\"message\": \"\"} } } ``` + * @summary Send a list of `check` operations in a single request + * @param {string} storeId + * @param {BatchCheckRequest} body + * @param {*} [options] Override http request option. + * @throws { FgaError } + */ + batchCheck: (storeId: string, body: BatchCheckRequest, options: any = {}): RequestArgs => { + // verify required parameter 'storeId' is not null or undefined + assertParamExists("batchCheck", "storeId", storeId); + // verify required parameter 'body' is not null or undefined + assertParamExists("batchCheck", "body", body); + const localVarPath = "/stores/{store_id}/batch-check" + .replace(`{${"store_id"}}`, encodeURIComponent(String(storeId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: "POST", ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter["Content-Type"] = "application/json"; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + localVarRequestOptions.headers = {...localVarHeaderParameter, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions); + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * The Check API returns whether a given user has a relationship with a given object in a given store. The `user` field of the request can be a specific target, such as `user:anne`, or a userset (set of users) such as `group:marketing#member` or a type-bound public access `user:*`. To arrive at a result, the API uses: an authorization model, explicit tuples written through the Write API, contextual tuples present in the request, and implicit tuples that exist by virtue of applying set theory (such as `document:2021-budget#viewer@document:2021-budget#viewer`; the set of users who are viewers of `document:2021-budget` are the set of users who are the viewers of `document:2021-budget`). A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. You may also provide an `authorization_model_id` in the body. This will be used to assert that the input `tuple_key` is valid for the model specified. If not specified, the assertion will be made against the latest authorization model ID. It is strongly recommended to specify authorization model id for better performance. You may also provide a `context` object that will be used to evaluate the conditioned tuples in the system. It is strongly recommended to provide a value for all the input parameters of all the conditions, to ensure that all tuples be evaluated correctly. By default, the Check API caches results for a short time to optimize performance. You may specify a value of `HIGHER_CONSISTENCY` for the optional `consistency` parameter in the body to inform the server that higher conisistency is preferred at the expense of increased latency. Consideration should be given to the increased latency if requesting higher consistency. The response will return whether the relationship exists in the field `allowed`. Some exceptions apply, but in general, if a Check API responds with `{allowed: true}`, then you can expect the equivalent ListObjects query to return the object, and viceversa. For example, if `Check(user:anne, reader, document:2021-budget)` responds with `{allowed: true}`, then `ListObjects(user:anne, reader, document)` may include `document:2021-budget` in the response. ## Examples ### Querying with contextual tuples In order to check if user `user:anne` of type `user` has a `reader` relationship with object `document:2021-budget` given the following contextual tuple ```json { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ``` the Check API can be used with the following request body: ```json { \"tuple_key\": { \"user\": \"user:anne\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Querying usersets Some Checks will always return `true`, even without any tuples. For example, for the following authorization model ```python model schema 1.1 type user type document relations define reader: [user] ``` the following query ```json { \"tuple_key\": { \"user\": \"document:2021-budget#reader\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } } ``` will always return `{ \"allowed\": true }`. This is because usersets are self-defining: the userset `document:2021-budget#reader` will always have the `reader` relation with `document:2021-budget`. ### Querying usersets with difference in the model A Check for a userset can yield results that must be treated carefully if the model involves difference. For example, for the following authorization model ```python model schema 1.1 type user type group relations define member: [user] type document relations define blocked: [user] define reader: [group#member] but not blocked ``` the following query ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"group:finance\" }, { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, { \"user\": \"user:anne\", \"relation\": \"blocked\", \"object\": \"document:2021-budget\" } ] }, } ``` will return `{ \"allowed\": true }`, even though a specific user of the userset `group:finance#member` does not have the `reader` relationship with the given object. ### Requesting higher consistency By default, the Check API caches results for a short time to optimize performance. You may request higher consistency to inform the server that higher consistency should be preferred at the expense of increased latency. Care should be taken when requesting higher consistency due to the increased latency. ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"consistency\": \"HIGHER_CONSISTENCY\" } ``` * @summary Check whether a user is authorized to access an object @@ -757,6 +801,22 @@ export const OpenFgaApiAxiosParamCreator = function (configuration: Configuratio export const OpenFgaApiFp = function(configuration: Configuration, credentials: Credentials) { const localVarAxiosParamCreator = OpenFgaApiAxiosParamCreator(configuration, credentials); return { + /** + * The `BatchCheck` API functions nearly identically to `Check`, but instead of checking a single user-object relationship BatchCheck accepts a list of relationships to check and returns a map containing `BatchCheckItem` response for each check it received. An associated `correlation_id` is required for each check in the batch. This ID is used to correlate a check to the appropriate response. It is a string consisting of only alphanumeric characters or hyphens with a maximum length of 36 characters. This `correlation_id` is used to map the result of each check to the item which was checked, so it must be unique for each item in the batch. We recommend using a UUID or ULID as the `correlation_id`, but you can use whatever unique identifier you need as long as it matches this regex pattern: `^[\\w\\d-]{1,36}$` For more details on how `Check` functions, see the docs for `/check`. ### Examples #### A BatchCheckRequest ```json { \"checks\": [ { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:anne\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PM3QM7VBPGB8KMPK8SBD5\" }, { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:bob\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PMM6A90NV5ET0F28CYSZQ\" } ] } ``` Below is a possible response to the above request. Note that the result map\'s keys are the `correlation_id` values from the checked items in the request: ```json { \"result\": { \"01JA8PMM6A90NV5ET0F28CYSZQ\": { \"allowed\": false, \"error\": {\"message\": \"\"} }, \"01JA8PM3QM7VBPGB8KMPK8SBD5\": { \"allowed\": true, \"error\": {\"message\": \"\"} } } ``` + * @summary Send a list of `check` operations in a single request + * @param {string} storeId + * @param {BatchCheckRequest} body + * @param {*} [options] Override http request option. + * @throws { FgaError } + */ + async batchCheck(storeId: string, body: BatchCheckRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { + const localVarAxiosArgs = localVarAxiosParamCreator.batchCheck(storeId, body, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { + [TelemetryAttribute.FgaClientRequestMethod]: "BatchCheck", + [TelemetryAttribute.FgaClientRequestStoreId]: storeId ?? "", + ...TelemetryAttributes.fromRequestBody(body) + }); + }, /** * The Check API returns whether a given user has a relationship with a given object in a given store. The `user` field of the request can be a specific target, such as `user:anne`, or a userset (set of users) such as `group:marketing#member` or a type-bound public access `user:*`. To arrive at a result, the API uses: an authorization model, explicit tuples written through the Write API, contextual tuples present in the request, and implicit tuples that exist by virtue of applying set theory (such as `document:2021-budget#viewer@document:2021-budget#viewer`; the set of users who are viewers of `document:2021-budget` are the set of users who are the viewers of `document:2021-budget`). A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. You may also provide an `authorization_model_id` in the body. This will be used to assert that the input `tuple_key` is valid for the model specified. If not specified, the assertion will be made against the latest authorization model ID. It is strongly recommended to specify authorization model id for better performance. You may also provide a `context` object that will be used to evaluate the conditioned tuples in the system. It is strongly recommended to provide a value for all the input parameters of all the conditions, to ensure that all tuples be evaluated correctly. By default, the Check API caches results for a short time to optimize performance. You may specify a value of `HIGHER_CONSISTENCY` for the optional `consistency` parameter in the body to inform the server that higher conisistency is preferred at the expense of increased latency. Consideration should be given to the increased latency if requesting higher consistency. The response will return whether the relationship exists in the field `allowed`. Some exceptions apply, but in general, if a Check API responds with `{allowed: true}`, then you can expect the equivalent ListObjects query to return the object, and viceversa. For example, if `Check(user:anne, reader, document:2021-budget)` responds with `{allowed: true}`, then `ListObjects(user:anne, reader, document)` may include `document:2021-budget` in the response. ## Examples ### Querying with contextual tuples In order to check if user `user:anne` of type `user` has a `reader` relationship with object `document:2021-budget` given the following contextual tuple ```json { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ``` the Check API can be used with the following request body: ```json { \"tuple_key\": { \"user\": \"user:anne\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Querying usersets Some Checks will always return `true`, even without any tuples. For example, for the following authorization model ```python model schema 1.1 type user type document relations define reader: [user] ``` the following query ```json { \"tuple_key\": { \"user\": \"document:2021-budget#reader\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } } ``` will always return `{ \"allowed\": true }`. This is because usersets are self-defining: the userset `document:2021-budget#reader` will always have the `reader` relation with `document:2021-budget`. ### Querying usersets with difference in the model A Check for a userset can yield results that must be treated carefully if the model involves difference. For example, for the following authorization model ```python model schema 1.1 type user type group relations define member: [user] type document relations define blocked: [user] define reader: [group#member] but not blocked ``` the following query ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"group:finance\" }, { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, { \"user\": \"user:anne\", \"relation\": \"blocked\", \"object\": \"document:2021-budget\" } ] }, } ``` will return `{ \"allowed\": true }`, even though a specific user of the userset `group:finance#member` does not have the `reader` relationship with the given object. ### Requesting higher consistency By default, the Check API caches results for a short time to optimize performance. You may request higher consistency to inform the server that higher consistency should be preferred at the expense of increased latency. Care should be taken when requesting higher consistency due to the increased latency. ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"consistency\": \"HIGHER_CONSISTENCY\" } ``` * @summary Check whether a user is authorized to access an object @@ -1016,6 +1076,17 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: export const OpenFgaApiFactory = function (configuration: Configuration, credentials: Credentials, axios?: AxiosInstance) { const localVarFp = OpenFgaApiFp(configuration, credentials); return { + /** + * The `BatchCheck` API functions nearly identically to `Check`, but instead of checking a single user-object relationship BatchCheck accepts a list of relationships to check and returns a map containing `BatchCheckItem` response for each check it received. An associated `correlation_id` is required for each check in the batch. This ID is used to correlate a check to the appropriate response. It is a string consisting of only alphanumeric characters or hyphens with a maximum length of 36 characters. This `correlation_id` is used to map the result of each check to the item which was checked, so it must be unique for each item in the batch. We recommend using a UUID or ULID as the `correlation_id`, but you can use whatever unique identifier you need as long as it matches this regex pattern: `^[\\w\\d-]{1,36}$` For more details on how `Check` functions, see the docs for `/check`. ### Examples #### A BatchCheckRequest ```json { \"checks\": [ { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:anne\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PM3QM7VBPGB8KMPK8SBD5\" }, { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:bob\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PMM6A90NV5ET0F28CYSZQ\" } ] } ``` Below is a possible response to the above request. Note that the result map\'s keys are the `correlation_id` values from the checked items in the request: ```json { \"result\": { \"01JA8PMM6A90NV5ET0F28CYSZQ\": { \"allowed\": false, \"error\": {\"message\": \"\"} }, \"01JA8PM3QM7VBPGB8KMPK8SBD5\": { \"allowed\": true, \"error\": {\"message\": \"\"} } } ``` + * @summary Send a list of `check` operations in a single request + * @param {string} storeId + * @param {BatchCheckRequest} body + * @param {*} [options] Override http request option. + * @throws { FgaError } + */ + batchCheck(storeId: string, body: BatchCheckRequest, options?: any): PromiseResult { + return localVarFp.batchCheck(storeId, body, options).then((request) => request(axios)); + }, /** * The Check API returns whether a given user has a relationship with a given object in a given store. The `user` field of the request can be a specific target, such as `user:anne`, or a userset (set of users) such as `group:marketing#member` or a type-bound public access `user:*`. To arrive at a result, the API uses: an authorization model, explicit tuples written through the Write API, contextual tuples present in the request, and implicit tuples that exist by virtue of applying set theory (such as `document:2021-budget#viewer@document:2021-budget#viewer`; the set of users who are viewers of `document:2021-budget` are the set of users who are the viewers of `document:2021-budget`). A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. You may also provide an `authorization_model_id` in the body. This will be used to assert that the input `tuple_key` is valid for the model specified. If not specified, the assertion will be made against the latest authorization model ID. It is strongly recommended to specify authorization model id for better performance. You may also provide a `context` object that will be used to evaluate the conditioned tuples in the system. It is strongly recommended to provide a value for all the input parameters of all the conditions, to ensure that all tuples be evaluated correctly. By default, the Check API caches results for a short time to optimize performance. You may specify a value of `HIGHER_CONSISTENCY` for the optional `consistency` parameter in the body to inform the server that higher conisistency is preferred at the expense of increased latency. Consideration should be given to the increased latency if requesting higher consistency. The response will return whether the relationship exists in the field `allowed`. Some exceptions apply, but in general, if a Check API responds with `{allowed: true}`, then you can expect the equivalent ListObjects query to return the object, and viceversa. For example, if `Check(user:anne, reader, document:2021-budget)` responds with `{allowed: true}`, then `ListObjects(user:anne, reader, document)` may include `document:2021-budget` in the response. ## Examples ### Querying with contextual tuples In order to check if user `user:anne` of type `user` has a `reader` relationship with object `document:2021-budget` given the following contextual tuple ```json { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ``` the Check API can be used with the following request body: ```json { \"tuple_key\": { \"user\": \"user:anne\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Querying usersets Some Checks will always return `true`, even without any tuples. For example, for the following authorization model ```python model schema 1.1 type user type document relations define reader: [user] ``` the following query ```json { \"tuple_key\": { \"user\": \"document:2021-budget#reader\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } } ``` will always return `{ \"allowed\": true }`. This is because usersets are self-defining: the userset `document:2021-budget#reader` will always have the `reader` relation with `document:2021-budget`. ### Querying usersets with difference in the model A Check for a userset can yield results that must be treated carefully if the model involves difference. For example, for the following authorization model ```python model schema 1.1 type user type group relations define member: [user] type document relations define blocked: [user] define reader: [group#member] but not blocked ``` the following query ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"group:finance\" }, { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, { \"user\": \"user:anne\", \"relation\": \"blocked\", \"object\": \"document:2021-budget\" } ] }, } ``` will return `{ \"allowed\": true }`, even though a specific user of the userset `group:finance#member` does not have the `reader` relationship with the given object. ### Requesting higher consistency By default, the Check API caches results for a short time to optimize performance. You may request higher consistency to inform the server that higher consistency should be preferred at the expense of increased latency. Care should be taken when requesting higher consistency due to the increased latency. ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"consistency\": \"HIGHER_CONSISTENCY\" } ``` * @summary Check whether a user is authorized to access an object @@ -1204,6 +1275,19 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent * @extends {BaseAPI} */ export class OpenFgaApi extends BaseAPI { + /** + * The `BatchCheck` API functions nearly identically to `Check`, but instead of checking a single user-object relationship BatchCheck accepts a list of relationships to check and returns a map containing `BatchCheckItem` response for each check it received. An associated `correlation_id` is required for each check in the batch. This ID is used to correlate a check to the appropriate response. It is a string consisting of only alphanumeric characters or hyphens with a maximum length of 36 characters. This `correlation_id` is used to map the result of each check to the item which was checked, so it must be unique for each item in the batch. We recommend using a UUID or ULID as the `correlation_id`, but you can use whatever unique identifier you need as long as it matches this regex pattern: `^[\\w\\d-]{1,36}$` For more details on how `Check` functions, see the docs for `/check`. ### Examples #### A BatchCheckRequest ```json { \"checks\": [ { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:anne\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PM3QM7VBPGB8KMPK8SBD5\" }, { \"tuple_key\": { \"object\": \"document:2021-budget\" \"relation\": \"reader\", \"user\": \"user:bob\", }, \"contextual_tuples\": {...} \"context\": {} \"correlation_id\": \"01JA8PMM6A90NV5ET0F28CYSZQ\" } ] } ``` Below is a possible response to the above request. Note that the result map\'s keys are the `correlation_id` values from the checked items in the request: ```json { \"result\": { \"01JA8PMM6A90NV5ET0F28CYSZQ\": { \"allowed\": false, \"error\": {\"message\": \"\"} }, \"01JA8PM3QM7VBPGB8KMPK8SBD5\": { \"allowed\": true, \"error\": {\"message\": \"\"} } } ``` + * @summary Send a list of `check` operations in a single request + * @param {string} storeId + * @param {BatchCheckRequest} body + * @param {*} [options] Override http request option. + * @throws { FgaError } + * @memberof OpenFgaApi + */ + public batchCheck(storeId: string, body: BatchCheckRequest, options?: any): Promise> { + return OpenFgaApiFp(this.configuration, this.credentials).batchCheck(storeId, body, options).then((request) => request(this.axios)); + } + /** * The Check API returns whether a given user has a relationship with a given object in a given store. The `user` field of the request can be a specific target, such as `user:anne`, or a userset (set of users) such as `group:marketing#member` or a type-bound public access `user:*`. To arrive at a result, the API uses: an authorization model, explicit tuples written through the Write API, contextual tuples present in the request, and implicit tuples that exist by virtue of applying set theory (such as `document:2021-budget#viewer@document:2021-budget#viewer`; the set of users who are viewers of `document:2021-budget` are the set of users who are the viewers of `document:2021-budget`). A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. You may also provide an `authorization_model_id` in the body. This will be used to assert that the input `tuple_key` is valid for the model specified. If not specified, the assertion will be made against the latest authorization model ID. It is strongly recommended to specify authorization model id for better performance. You may also provide a `context` object that will be used to evaluate the conditioned tuples in the system. It is strongly recommended to provide a value for all the input parameters of all the conditions, to ensure that all tuples be evaluated correctly. By default, the Check API caches results for a short time to optimize performance. You may specify a value of `HIGHER_CONSISTENCY` for the optional `consistency` parameter in the body to inform the server that higher conisistency is preferred at the expense of increased latency. Consideration should be given to the increased latency if requesting higher consistency. The response will return whether the relationship exists in the field `allowed`. Some exceptions apply, but in general, if a Check API responds with `{allowed: true}`, then you can expect the equivalent ListObjects query to return the object, and viceversa. For example, if `Check(user:anne, reader, document:2021-budget)` responds with `{allowed: true}`, then `ListObjects(user:anne, reader, document)` may include `document:2021-budget` in the response. ## Examples ### Querying with contextual tuples In order to check if user `user:anne` of type `user` has a `reader` relationship with object `document:2021-budget` given the following contextual tuple ```json { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ``` the Check API can be used with the following request body: ```json { \"tuple_key\": { \"user\": \"user:anne\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"time_slot:office_hours\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Querying usersets Some Checks will always return `true`, even without any tuples. For example, for the following authorization model ```python model schema 1.1 type user type document relations define reader: [user] ``` the following query ```json { \"tuple_key\": { \"user\": \"document:2021-budget#reader\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } } ``` will always return `{ \"allowed\": true }`. This is because usersets are self-defining: the userset `document:2021-budget#reader` will always have the `reader` relation with `document:2021-budget`. ### Querying usersets with difference in the model A Check for a userset can yield results that must be treated carefully if the model involves difference. For example, for the following authorization model ```python model schema 1.1 type user type group relations define member: [user] type document relations define blocked: [user] define reader: [group#member] but not blocked ``` the following query ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"member\", \"object\": \"group:finance\" }, { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, { \"user\": \"user:anne\", \"relation\": \"blocked\", \"object\": \"document:2021-budget\" } ] }, } ``` will return `{ \"allowed\": true }`, even though a specific user of the userset `group:finance#member` does not have the `reader` relationship with the given object. ### Requesting higher consistency By default, the Check API caches results for a short time to optimize performance. You may request higher consistency to inform the server that higher consistency should be preferred at the expense of increased latency. Care should be taken when requesting higher consistency due to the increased latency. ```json { \"tuple_key\": { \"user\": \"group:finance#member\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" }, \"consistency\": \"HIGHER_CONSISTENCY\" } ``` * @summary Check whether a user is authorized to access an object diff --git a/apiModel.ts b/apiModel.ts index a1d91b1..984ea49 100644 --- a/apiModel.ts +++ b/apiModel.ts @@ -152,6 +152,123 @@ export interface AuthorizationModel { */ conditions?: { [key: string]: Condition; }; } +/** + * + * @export + * @interface BatchCheckItem + */ +export interface BatchCheckItem { + /** + * + * @type {CheckRequestTupleKey} + * @memberof BatchCheckItem + */ + tuple_key: CheckRequestTupleKey; + /** + * + * @type {ContextualTupleKeys} + * @memberof BatchCheckItem + */ + contextual_tuples?: ContextualTupleKeys; + /** + * + * @type {object} + * @memberof BatchCheckItem + */ + context?: object; + /** + * correlation_id must be a string containing only letters, numbers, or hyphens, with length ≤ 36 characters. + * @type {string} + * @memberof BatchCheckItem + */ + correlation_id: string; +} +/** + * + * @export + * @interface BatchCheckRequest + */ +export interface BatchCheckRequest { + /** + * + * @type {Array} + * @memberof BatchCheckRequest + */ + checks: Array; + /** + * + * @type {string} + * @memberof BatchCheckRequest + */ + authorization_model_id?: string; + /** + * + * @type {ConsistencyPreference} + * @memberof BatchCheckRequest + */ + consistency?: ConsistencyPreference; +} + + +/** + * + * @export + * @interface BatchCheckResponse + */ +export interface BatchCheckResponse { + /** + * map keys are the correlation_id values from the BatchCheckItems in the request + * @type {{ [key: string]: BatchCheckSingleResult; }} + * @memberof BatchCheckResponse + */ + result?: { [key: string]: BatchCheckSingleResult; }; +} +/** + * + * @export + * @interface BatchCheckSingleResult + */ +export interface BatchCheckSingleResult { + /** + * + * @type {boolean} + * @memberof BatchCheckSingleResult + */ + allowed?: boolean; + /** + * + * @type {CheckError} + * @memberof BatchCheckSingleResult + */ + error?: CheckError; +} +/** + * + * @export + * @interface CheckError + */ +export interface CheckError { + /** + * + * @type {ErrorCode} + * @memberof CheckError + */ + input_error?: ErrorCode; + /** + * + * @type {InternalErrorCode} + * @memberof CheckError + */ + internal_error?: InternalErrorCode; + /** + * + * @type {string} + * @memberof CheckError + */ + message?: string; +} + + /** * * @export diff --git a/client.ts b/client.ts index 81f375a..6faf8e3 100644 --- a/client.ts +++ b/client.ts @@ -17,10 +17,15 @@ import asyncPool = require("tiny-async-pool"); import { OpenFgaApi } from "./api"; import { Assertion, + BatchCheckItem, + BatchCheckRequest, + BatchCheckResponse, + CheckError, CheckRequest, CheckRequestTupleKey, CheckResponse, ConsistencyPreference, + ContextualTupleKeys, CreateStoreRequest, CreateStoreResponse, ExpandRequestTupleKey, @@ -96,6 +101,7 @@ export class ClientConfiguration extends Configuration { } const DEFAULT_MAX_METHOD_PARALLEL_REQS = 10; +const DEFAULT_MAX_BATCH_SIZE = 50; const CLIENT_METHOD_HEADER = "X-OpenFGA-Client-Method"; const CLIENT_BULK_REQUEST_ID_HEADER = "X-OpenFGA-Client-Bulk-Request-Id"; @@ -126,9 +132,9 @@ export type ClientCheckRequest = CheckRequestTupleKey & Pick & { contextualTuples?: Array }; -export type ClientBatchCheckRequest = ClientCheckRequest[]; +export type ClientBatchCheckClientRequest = ClientCheckRequest[]; -export type ClientBatchCheckSingleResponse = { +export type ClientBatchCheckSingleClientResponse = { _request: ClientCheckRequest; } & ({ allowed: boolean; @@ -138,8 +144,46 @@ export type ClientBatchCheckSingleResponse = { error: Error; }); +export interface ClientBatchCheckClientResponse { + result: ClientBatchCheckSingleClientResponse[]; +} + +export interface ClientBatchCheckClientRequestOpts { + maxParallelRequests?: number; +} + +// For server batch check +export type ClientBatchCheckItem = { + user: string; + relation: string; + object: string; + correlationId?: string; + contextualTuples?: ContextualTupleKeys; + context?: object; +}; + +// for server batch check +export type ClientBatchCheckRequest = { + checks: ClientBatchCheckItem[]; +}; + +// for server batch check +export interface ClientBatchCheckRequestOpts { + maxParallelRequests?: number; + maxBatchSize?: number; +} + + +// for server batch check +export type ClientBatchCheckSingleResponse = { + allowed: boolean; + request: ClientBatchCheckItem; + correlationId: string; + error?: CheckError; +} + export interface ClientBatchCheckResponse { - responses: ClientBatchCheckSingleResponse[]; + result: ClientBatchCheckSingleResponse[]; } export interface ClientWriteRequestOpts { @@ -150,10 +194,6 @@ export interface ClientWriteRequestOpts { } } -export interface BatchCheckRequestOpts { - maxParallelRequests?: number; -} - export interface ClientWriteRequest { writes?: TupleKey[]; deletes?: TupleKeyWithoutCondition[]; @@ -587,26 +627,27 @@ export class OpenFgaClient extends BaseAPI { } /** - * BatchCheck - Run a set of checks (evaluates) - * @param {ClientBatchCheckRequest} body - * @param {ClientRequestOptsWithAuthZModelId & BatchCheckRequestOpts} [options] + * BatchCheck - Run a set of checks (evaluates) by calling the single check endpoint multiple times in parallel. + * @param {ClientBatchCheckClientRequest} body + * @param {ClientRequestOptsWithAuthZModelId & ClientBatchCheckClientRequestOpts} [options] * @param {number} [options.maxParallelRequests] - Max number of requests to issue in parallel. Defaults to `10` * @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration + * @param {string} [options.consistency] - Optional consistency level for the request. Default is `MINIMIZE_LATENCY` * @param {object} [options.headers] - Custom headers to send alongside the request * @param {object} [options.retryParams] - Override the retry parameters for this request * @param {number} [options.retryParams.maxRetry] - Override the max number of retries on each API request * @param {number} [options.retryParams.minWaitInMs] - Override the minimum wait before a retry is initiated */ - async batchCheck(body: ClientBatchCheckRequest, options: ClientRequestOptsWithConsistency & BatchCheckRequestOpts = {}): Promise { + async clientBatchCheck(body: ClientBatchCheckClientRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckClientRequestOpts = {}): Promise { const { headers = {}, maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS } = options; - setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "BatchCheck"); + setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "ClientBatchCheck"); setHeaderIfNotSet(headers, CLIENT_BULK_REQUEST_ID_HEADER, generateRandomIdWithNonUniqueFallback()); - const responses: ClientBatchCheckSingleResponse[] = []; + const result: ClientBatchCheckSingleClientResponse[] = []; for await (const singleCheckResponse of asyncPool(maxParallelRequests, body, (tuple) => this.check(tuple, { ...options, headers }) .then(response => { - (response as ClientBatchCheckSingleResponse)._request = tuple; - return response as ClientBatchCheckSingleResponse; + (response as ClientBatchCheckSingleClientResponse)._request = tuple; + return response as ClientBatchCheckSingleClientResponse; }) .catch(err => { if (err instanceof FgaApiAuthenticationError) { @@ -620,12 +661,109 @@ export class OpenFgaClient extends BaseAPI { }; }) )) { - responses.push(singleCheckResponse); + result.push(singleCheckResponse); } + return { result }; + } + + - return { responses }; + private singleBatchCheck(body: BatchCheckRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {}): Promise { + return this.api.batchCheck(this.getStoreId(options)!, body, options); } + /** + * BatchCheck - Run a set of checks (evaluates) by calling the batch-check endpoint. + * Given the provided list of checks, it will call batch check, splitting the checks into batches based + * on the `options.maxBatchSize` parameter (default 50 checks) if needed. + * @param {ClientBatchCheckClientRequest} body + * @param {ClientRequestOptsWithAuthZModelId & ClientBatchCheckClientRequestOpts} [options] + * @param {number} [options.maxParallelRequests] - Max number of requests to issue in parallel, if executing multiple requests. Defaults to `10` + * @param {number} [options.maxBatchSize] - Max number of checks to include in a single batch check request. Defaults to `50`. + * @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration. + * @param {string} [options.consistency] - + * @param {object} [options.headers] - Custom headers to send alongside the request + * @param {object} [options.retryParams] - Override the retry parameters for this request + * @param {number} [options.retryParams.maxRetry] - Override the max number of retries on each API request + * @param {number} [options.retryParams.minWaitInMs] - Override the minimum wait before a retry is initiated + */ + async batchCheck( + body: ClientBatchCheckRequest, + options: ClientRequestOptsWithConsistency & ClientBatchCheckRequestOpts = {} + ): Promise { + const { + headers = {}, + maxBatchSize = DEFAULT_MAX_BATCH_SIZE, + maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS, + } = options; + + setHeaderIfNotSet(headers, CLIENT_BULK_REQUEST_ID_HEADER, generateRandomIdWithNonUniqueFallback()); + + const correlationIdToCheck = new Map(); + const transformed: BatchCheckItem[] = []; + + // Validate and transform checks + for (const check of body.checks) { + // Generate a correlation ID if not provided + if (!check.correlationId) { + check.correlationId = generateRandomIdWithNonUniqueFallback(); + } + + // Ensure that correlation IDs are unique + if (correlationIdToCheck.has(check.correlationId)) { + throw new FgaValidationError("correlationId", "When calling batchCheck, correlation IDs must be unique"); + } + correlationIdToCheck.set(check.correlationId, check); + + // Transform the check into the BatchCheckItem format + transformed.push({ + tuple_key: { + user: check.user, + relation: check.relation, + object: check.object, + }, + context: check.context, + contextual_tuples: check.contextualTuples, + correlation_id: check.correlationId, + }); + } + + // Split the transformed checks into batches based on maxBatchSize + const batchedChecks = chunkArray(transformed, maxBatchSize); + + // Execute batch checks in parallel with a limit of maxParallelRequests + const results: ClientBatchCheckSingleResponse[] = []; + const batchResponses = asyncPool(maxParallelRequests, batchedChecks, async (batch: BatchCheckItem[]) => { + const batchRequest: BatchCheckRequest = { + checks: batch, + authorization_model_id: options.authorizationModelId, + consistency: options.consistency, + }; + + const response = await this.singleBatchCheck(batchRequest, { ...options, headers }); + return response.result; + }); + + // Collect the responses and associate them with their correlation IDs + for await (const response of batchResponses) { + if (response) { + for (const [correlationId, result] of Object.entries(response)) { + const check = correlationIdToCheck.get(correlationId); + if (check && result) { + results.push({ + allowed: result.allowed || false, + request: check, + correlationId, + error: result.error, + }); + } + } + } + } + + return { result: results }; + } + /** * Expand - Expands the relationships in userset tree format (evaluates) * @param {ClientExpandRequest} body @@ -680,7 +818,7 @@ export class OpenFgaClient extends BaseAPI { * @param {object} listRelationsRequest.context The contextual tuples to send * @param options */ - async listRelations(listRelationsRequest: ClientListRelationsRequest, options: ClientRequestOptsWithConsistency & BatchCheckRequestOpts = {}): Promise { + async listRelations(listRelationsRequest: ClientListRelationsRequest, options: ClientRequestOptsWithConsistency & ClientBatchCheckClientRequestOpts = {}): Promise { const { user, object, relations, contextualTuples, context } = listRelationsRequest; const { headers = {}, maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS } = options; setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "ListRelations"); @@ -690,7 +828,7 @@ export class OpenFgaClient extends BaseAPI { throw new FgaValidationError("relations", "When calling listRelations, at least one relation must be passed in the relations field"); } - const batchCheckResults = await this.batchCheck(relations.map(relation => ({ + const batchCheckResults = await this.clientBatchCheck(relations.map(relation => ({ user, relation, object, @@ -698,12 +836,12 @@ export class OpenFgaClient extends BaseAPI { context, })), { ...options, headers, maxParallelRequests }); - const firstErrorResponse = batchCheckResults.responses.find(response => (response as any).error); + const firstErrorResponse = batchCheckResults.result.find(response => (response as any).error); if (firstErrorResponse) { throw (firstErrorResponse as any).error; } - return { relations: batchCheckResults.responses.filter(result => result.allowed).map(result => result._request.relation) }; + return { relations: batchCheckResults.result.filter(result => result.allowed).map(result => result._request.relation) }; } /** diff --git a/configuration.ts b/configuration.ts index ed0ba48..45598da 100644 --- a/configuration.ts +++ b/configuration.ts @@ -17,7 +17,7 @@ import { assertParamExists, isWellFormedUlidString, isWellFormedUriString } from import { TelemetryConfig, TelemetryConfiguration } from "./telemetry/configuration"; // default maximum number of retry -const DEFAULT_MAX_RETRY = 15; +const DEFAULT_MAX_RETRY = 3; // default minimum wait period in retry - but will backoff exponentially const DEFAULT_MIN_WAIT_MS = 100; diff --git a/example/example1/example1.mjs b/example/example1/example1.mjs index f8b82be..dae177f 100644 --- a/example/example1/example1.mjs +++ b/example/example1/example1.mjs @@ -1,4 +1,5 @@ import { CredentialsMethod, FgaApiValidationError, OpenFgaClient, TypeName } from "@openfga/sdk"; +import { randomUUID } from "crypto"; async function main () { let credentials; @@ -137,6 +138,11 @@ async function main () { Type: "document" } } + }, + { + user: "user:bob", + relation: "writer", + object: "document:7772ab2a-d83f-756d-9397-c5ed9f3cb69a" } ] }, { authorizationModelId }); @@ -180,6 +186,35 @@ async function main () { }); console.log(`Allowed: ${allowed}`); + // execute a batch check + const anneCorrelationId = randomUUID(); + const { result } = await fgaClient.batchCheck({ + checks: [ + { + // should have access + user: "user:anne", + relation: "viewer", + object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + context: { + ViewCount: 100 + }, + correlationId: anneCorrelationId, + }, + { + // should NOT have access + user: "user:anne", + relation: "viewer", + object: "document:7772ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + ] + }); + + const anneAllowed = result.filter(r => r.correlationId === anneCorrelationId); + console.log(`Anne is allowed access to ${anneAllowed.length} documents`); + anneAllowed.forEach(item => { + console.log(`Anne is allowed access to ${item.request.object}`); + }); + console.log("Writing Assertions"); await fgaClient.writeAssertions([ { diff --git a/example/opentelemetry/opentelemetry.mjs b/example/opentelemetry/opentelemetry.mjs index 87635de..c29fbe4 100644 --- a/example/opentelemetry/opentelemetry.mjs +++ b/example/opentelemetry/opentelemetry.mjs @@ -40,6 +40,7 @@ const telemetryConfig = { }, [TelemetryMetric.HistogramQueryDuration]: { attributes: new Set([ + TelemetryAttribute.FgaClientRequestBatchCheckSize, TelemetryAttribute.HttpResponseStatusCode, TelemetryAttribute.UserAgentOriginal, TelemetryAttribute.FgaClientRequestMethod, @@ -99,6 +100,34 @@ async function main () { } } + console.log("Calling BatcCheck") + await fgaClient.batchCheck({ + checks: [ + { + object: "doc:roadmap", + relation: "can_read", + user: "user:anne", + }, + { + object: "doc:roadmap", + relation: "can_read", + user: "user:dan", + }, + { + object: "doc:finances", + relation: "can_read", + user: "user:dan" + }, + { + object: "doc:finances", + relation: "can_reads", + user: "user:anne", + } + ] + }, { + authorizationModelId: "01JC6KPJ0CKSZ69C5Z26CYWX2N" + }); + console.log("writing tuple"); await fgaClient.write({ writes: [ diff --git a/telemetry/attributes.ts b/telemetry/attributes.ts index 41ac4cd..7da58c7 100644 --- a/telemetry/attributes.ts +++ b/telemetry/attributes.ts @@ -21,6 +21,7 @@ export enum TelemetryAttribute { FgaClientResponseModelId = "fga-client.response.model_id", FgaClientUser = "fga-client.user", HttpClientRequestDuration = "http.client.request.duration", + FgaClientRequestBatchCheckSize = "fga-client.request.batch_check_size", HttpHost = "http.host", HttpRequestMethod = "http.request.method", HttpRequestResendCount = "http.request.resend_count", @@ -121,6 +122,9 @@ export class TelemetryAttributes { attributes[TelemetryAttribute.FgaClientUser] = body.tuple_key.user; } + if (body?.checks?.length) { + attributes[TelemetryAttribute.FgaClientRequestBatchCheckSize] = body.checks.length; + } return attributes; } } diff --git a/telemetry/configuration.ts b/telemetry/configuration.ts index 0df3948..5de796b 100644 --- a/telemetry/configuration.ts +++ b/telemetry/configuration.ts @@ -71,7 +71,8 @@ export class TelemetryConfiguration implements TelemetryConfig { // TelemetryAttribute.HttpServerRequestDuration, // This not included by default as it has a very high cardinality which could increase costs for users - // TelemetryAttribute.FgaClientUser + // TelemetryAttribute.FgaClientUser, + // TelemetryAttribute.FgaClientRequestBatchCheckSize ]); /** @@ -97,6 +98,7 @@ export class TelemetryConfiguration implements TelemetryConfig { TelemetryAttribute.HttpClientRequestDuration, TelemetryAttribute.HttpServerRequestDuration, TelemetryAttribute.FgaClientUser, + TelemetryAttribute.FgaClientRequestBatchCheckSize, ]); /** diff --git a/tests/client.test.ts b/tests/client.test.ts index 26440e3..5e96114 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -22,6 +22,8 @@ import { OpenFgaClient, ListUsersResponse, ConsistencyPreference, + ErrorCode, + BatchCheckRequest, } from "../index"; import { baseConfig, defaultConfiguration, getNocks } from "./helpers"; @@ -511,7 +513,7 @@ describe("OpenFGA Client", () => { }); }); - describe("BatchCheck", () => { + describe("ClientBatchCheck", () => { it("should properly call the Check API", async () => { const tuples = [{ user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", @@ -526,30 +528,29 @@ describe("OpenFGA Client", () => { relation: "reader", object: "workspace:3", }]; - const scope0 = nocks.check(defaultConfiguration.storeId!, tuples[0], defaultConfiguration.getBasePath(), { allowed: true }, 200, ConsistencyPreference.HigherConsistency).matchHeader("X-OpenFGA-Client-Method", "BatchCheck"); - const scope1 = nocks.check(defaultConfiguration.storeId!, tuples[1], defaultConfiguration.getBasePath(), { allowed: false }, 200, ConsistencyPreference.HigherConsistency).matchHeader("X-OpenFGA-Client-Method", "BatchCheck"); + const scope0 = nocks.check(defaultConfiguration.storeId!, tuples[0], defaultConfiguration.getBasePath(), { allowed: true }, 200, ConsistencyPreference.HigherConsistency).matchHeader("X-OpenFGA-Client-Method", "ClientBatchCheck"); + const scope1 = nocks.check(defaultConfiguration.storeId!, tuples[1], defaultConfiguration.getBasePath(), { allowed: false }, 200, ConsistencyPreference.HigherConsistency).matchHeader("X-OpenFGA-Client-Method", "ClientBatchCheck"); const scope2 = nocks.check(defaultConfiguration.storeId!, tuples[2], defaultConfiguration.getBasePath(), { "code": "validation_error", "message": "relation 'workspace#reader' not found" - }, 400, ConsistencyPreference.HigherConsistency).matchHeader("X-OpenFGA-Client-Method", "BatchCheck"); + }, 400, ConsistencyPreference.HigherConsistency).matchHeader("X-OpenFGA-Client-Method", "ClientBatchCheck"); const scope3 = nock(defaultConfiguration.getBasePath()) .get(`/stores/${defaultConfiguration.storeId!}/authorization-models`) .query({ page_size: 1 }) .reply(200, { authorization_models: [], }); - expect(scope0.isDone()).toBe(false); expect(scope1.isDone()).toBe(false); expect(scope2.isDone()).toBe(false); - const response = await fgaClient.batchCheck([tuples[0], tuples[1], tuples[2]], { consistency: ConsistencyPreference.HigherConsistency }); + const response = await fgaClient.clientBatchCheck([tuples[0], tuples[1], tuples[2]], { consistency: ConsistencyPreference.HigherConsistency }); expect(scope0.isDone()).toBe(true); expect(scope1.isDone()).toBe(true); expect(scope2.isDone()).toBe(true); expect(scope3.isDone()).toBe(false); - expect(response.responses.length).toBe(3); - expect(response.responses.sort((a, b) => String(a._request.object).localeCompare(b._request.object))) + expect(response.result.length).toBe(3); + expect(response.result.sort((a, b) => String(a._request.object).localeCompare(b._request.object))) .toMatchObject(expect.arrayContaining([ { _request: tuples[0], allowed: true, }, { _request: tuples[1], allowed: false }, @@ -558,6 +559,202 @@ describe("OpenFGA Client", () => { }); }); + describe("BatchCheck", () => { + it(" should throw error when correlationIds are duplicated", async () => { + expect( + fgaClient.batchCheck({ + checks: [ + { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + object: "workspace:1", + relation: "viewer", + correlationId: "cor-id", + }, + { + user: "user:91284243-9356-4421-8fbf-a4f8d36aa31b", + object: "workspace:2", + relation: "viewer", + correlationId: "cor-id", + }, + ] + } + ) + ).rejects.toThrow(new FgaValidationError("correlationId", "When calling batchCheck, correlation IDs must be unique")); + }); + it("should return empty results when empty checks are specified", async () => { + const response = await fgaClient.batchCheck({ + checks: [], + }); + expect(response.result.length).toBe(0); + }); + it("should handle single batch successfully", async () => { + const mockedResponse = { + result: { + "cor-1": { + allowed: true, + error: undefined, + }, + "cor-2": { + allowed: false, + error: undefined, + }, + }, + }; + + const scope = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse, undefined, ConsistencyPreference.HigherConsistency, "01GAHCE4YVKPQEKZQHT2R89MQV").matchHeader("X-OpenFGA-Client-Bulk-Request-Id", /.*/); + + expect(scope.isDone()).toBe(false); + const response = await fgaClient.batchCheck({ + checks: [{ + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "can_read", + object: "document", + contextualTuples: { + tuple_keys: [{ + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "editor", + object: "folder:product" + }, { + user: "folder:product", + relation: "parent", + object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a" + } + ] + }, + correlationId: "cor-1", + }, + { + user: "folder:product", + relation: "parent", + object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + correlationId: "cor-2", + }], + }, { + authorizationModelId: "01GAHCE4YVKPQEKZQHT2R89MQV", + consistency: ConsistencyPreference.HigherConsistency, + }); + + expect(scope.isDone()).toBe(true); + expect(response.result).toHaveLength(2); + expect(response.result[0].allowed).toBe(true); + expect(response.result[1].allowed).toBe(false); + }); + it("should split batches successfully", async () => { + const mockedResponse0 = { + result: { + "cor-1": { + allowed: true, + error: undefined, + }, + "cor-2": { + allowed: false, + error: undefined, + }, + }, + }; + const mockedResponse1 = { + result: { + "cor-3": { + allowed: false, + error: { + input_error: ErrorCode.RelationNotFound, + message: "relation not found", + } + } + }, + }; + + const scope0 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse0, undefined, ConsistencyPreference.HigherConsistency, "01GAHCE4YVKPQEKZQHT2R89MQV").matchHeader("X-OpenFGA-Client-Bulk-Request-Id", /.*/); + const scope1 = nocks.singleBatchCheck(baseConfig.storeId!, mockedResponse1, undefined, ConsistencyPreference.HigherConsistency, "01GAHCE4YVKPQEKZQHT2R89MQV").matchHeader("X-OpenFGA-Client-Bulk-Request-Id", /.*/); + + expect(scope0.isDone()).toBe(false); + expect(scope1.isDone()).toBe(false); + + const response = await fgaClient.batchCheck({ + checks: [{ + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "can_read", + object: "document", + contextualTuples: { + tuple_keys: [{ + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "editor", + object: "folder:product" + }, { + user: "folder:product", + relation: "parent", + object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a" + } + ] + }, + correlationId: "cor-1", + }, + { + user: "folder:product", + relation: "parent", + object: "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + correlationId: "cor-2", + }, + { + user: "folder:product", + relation: "can_view", + object: "document:9992ab2a-d83f-756d-9397-c5ed9f3cj8a4", + correlationId: "cor-3", + }], + }, { + authorizationModelId: "01GAHCE4YVKPQEKZQHT2R89MQV", + consistency: ConsistencyPreference.HigherConsistency, + maxBatchSize: 2, + }); + + expect(scope0.isDone()).toBe(true); + expect(scope1.isDone()).toBe(true); + expect(response.result).toHaveLength(3); + + const resp0 = response.result.find(r => r.correlationId === "cor-1"); + const resp1 = response.result.find(r => r.correlationId === "cor-2"); + const resp2 = response.result.find(r => r.correlationId === "cor-3"); + + expect(resp0?.allowed).toBe(true); + expect(resp0?.request.user).toBe("user:81684243-9356-4421-8fbf-a4f8d36aa31b"); + expect(resp0?.request.relation).toBe("can_read"); + expect(resp0?.request.object).toBe("document"); + + expect(resp1?.allowed).toBe(false); + expect(resp1?.request.user).toBe("folder:product"); + expect(resp1?.request.relation).toBe("parent"); + expect(resp1?.request.object).toBe("document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a"); + + expect(resp2?.allowed).toBe(false); + expect(resp2?.request.user).toBe("folder:product"); + expect(resp2?.request.relation).toBe("can_view"); + expect(resp2?.request.object).toBe("document:9992ab2a-d83f-756d-9397-c5ed9f3cj8a4"); + + expect(resp2?.error?.input_error).toBe(ErrorCode.RelationNotFound); + expect(resp2?.error?.message).toBe("relation not found"); + }); + it("should throw an error if auth fails", async () => { + + const scope = nock(defaultConfiguration.getBasePath()) + .post(`/stores/${baseConfig.storeId!}/batch-check`) + .reply(401, {}); + + try { + await fgaClient.batchCheck({ + checks: [{ + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "can_read", + object: "document", + }], + }); + } catch (err) { + expect(err).toBeInstanceOf(FgaApiAuthenticationError); + } finally { + expect(scope.isDone()).toBe(true); + } + }); + }); + describe("Expand", () => { it("should properly call the Expand API", async () => { const tuple = { diff --git a/tests/helpers/nocks.ts b/tests/helpers/nocks.ts index 9efe1e7..e647555 100644 --- a/tests/helpers/nocks.ts +++ b/tests/helpers/nocks.ts @@ -15,6 +15,8 @@ import type * as Nock from "nock"; import { AuthorizationModel, + BatchCheckRequest, + BatchCheckResponse, CheckRequest, CheckResponse, ConsistencyPreference, @@ -213,6 +215,20 @@ export const getNocks = ((nock: typeof Nock) => ({ ) .reply(statusCode, response as CheckResponse); }, + singleBatchCheck: ( + storeId: string, + responseBody: BatchCheckResponse, + basePath = defaultConfiguration.getBasePath(), + consistency: ConsistencyPreference|undefined | undefined, + authorizationModelId = "auth-model-id", + ) => { + return nock(basePath) + .post(`/stores/${storeId}/batch-check`, (body: BatchCheckRequest) => + body.consistency === consistency && + body.authorization_model_id === authorizationModelId + ) + .reply(200, responseBody); + }, expand: ( storeId: string, tuple: TupleKey, diff --git a/tests/telemetry/attributes.test.ts b/tests/telemetry/attributes.test.ts index 33bfee5..8116207 100644 --- a/tests/telemetry/attributes.test.ts +++ b/tests/telemetry/attributes.test.ts @@ -106,4 +106,31 @@ describe("TelemetryAttributes", () => { expect(attributes[TelemetryAttribute.FgaClientRequestModelId]).toEqual("model-id"); expect(attributes[TelemetryAttribute.FgaClientUser]).toBeUndefined(); }); + + + test("should create attributes from a batchCheck request body correctly", () => { + const body = { + authorization_model_id: "model-id", + checks: [ + { + tuple_key: { + user: "user:anne", + object: "doc:123", + relation: "can_view" + } + }, + { + tuple_key: { + user: "user:anne", + object: "doc:789", + relation: "can_view" + } + } + ] + }; + const attributes = TelemetryAttributes.fromRequestBody(body); + + expect(attributes[TelemetryAttribute.FgaClientRequestModelId]).toEqual("model-id"); + expect(attributes[TelemetryAttribute.FgaClientRequestBatchCheckSize]).toEqual(2); + }); });