diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 557d9f1ca3..2ec5d9540d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -510,7 +510,7 @@ buildtest:opa-policies: services: - docker:dind script: - - docker run -v $PWD/deploy/helm/internal-charts/opa/policies:/policies openpolicyagent/opa:0.33.0 test -v ./policies + - docker run -v $PWD/deploy/helm/internal-charts/opa/policies:/policies openpolicyagent/opa:0.33.1 test -v ./policies buildtest:helm-charts: stage: buildtest diff --git a/CHANGES.md b/CHANGES.md index 7397ee040b..afe983ef7a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,8 +2,12 @@ ## Next -- #3231 Upgraded to Open Policy Agent v0.33.0 +- #3231 Upgraded to Open Policy Agent v0.33.x - #3251 Fixed akka HTTP client POST request racing conditions +- #3253 Add New /auth/opa/decision Endpoint +- move pre-defined role ids into a single contants file in magda-typescript-common +- Rewrote the OPA AST parser for better evaluation & reference handling +- Added common policy entry point: entrypoint/allow.rego ## 1.1.0 diff --git a/deploy/helm/internal-charts/opa/README.md b/deploy/helm/internal-charts/opa/README.md index feda94645c..d3d19eee43 100644 --- a/deploy/helm/internal-charts/opa/README.md +++ b/deploy/helm/internal-charts/opa/README.md @@ -21,7 +21,7 @@ Kubernetes: `>= 1.14.0-0` | image.pullPolicy | string | `"IfNotPresent"` | | | image.pullSecrets | bool | `false` | | | image.repository | string | `"openpolicyagent"` | | -| image.tag | string | `"0.33.0"` | | +| image.tag | string | `"0.33.1"` | | | loaderImage.name | string | `"magda-configmap-dir-loader"` | | | loaderImage.pullPolicy | string | `"IfNotPresent"` | | | loaderImage.pullSecrets | bool | `false` | | diff --git a/deploy/helm/internal-charts/opa/docker-compose.yml b/deploy/helm/internal-charts/opa/docker-compose.yml index 417cc2c2b0..e6bad87c10 100644 --- a/deploy/helm/internal-charts/opa/docker-compose.yml +++ b/deploy/helm/internal-charts/opa/docker-compose.yml @@ -1,7 +1,7 @@ version: "3" services: test-opa: - image: openpolicyagent/opa:0.33.0 + image: openpolicyagent/opa:0.33.1 ports: - 8181:8181 volumes: diff --git a/deploy/helm/internal-charts/opa/policies/entrypoint/allow.rego b/deploy/helm/internal-charts/opa/policies/entrypoint/allow.rego new file mode 100644 index 0000000000..3b6d3c9611 --- /dev/null +++ b/deploy/helm/internal-charts/opa/policies/entrypoint/allow.rego @@ -0,0 +1,28 @@ +package entrypoint + +import data.object.dataset.allow as dataset_allow +import data.object.content.allowRead as content_allowRead + +# When no rule match, the decision will be `denied` +default allow = false + +allow { + # users with admin roles will have access to everything + input.user.roles[_].id == "00000000-0000-0003-0000-000000000000" +} + +allow { + ## delegate dataset related decision to dataset_allow + startswith(input.operationUri, "object/dataset/") + dataset_allow +} + +allow { + ## delegate content related decision to content_allowRead + startswith(input.operationUri, "object/content/") + + ## Operation type must be read + endswith(input.operationUri, "/read") + + content_allowRead +} \ No newline at end of file diff --git a/deploy/helm/internal-charts/opa/policies/entrypoint/allow_test.rego b/deploy/helm/internal-charts/opa/policies/entrypoint/allow_test.rego new file mode 100644 index 0000000000..885a69be78 --- /dev/null +++ b/deploy/helm/internal-charts/opa/policies/entrypoint/allow_test.rego @@ -0,0 +1,80 @@ +package entrypoint + +test_allow_non_admin_should_have_no_permission_to_not_defined_resource { + not allow with input as { + "operationUri": "object/test-any-object/test-any-operation", + "user": { + "displayName": "Jacky Jiang", + "email": "jacky.jiang@data61.csiro.au", + "id": "80a9dce4-91af-44e2-a2f4-9ddccb3f4c5e", + "permissions": [ + { + "id": "72d52505-cf96-47b2-9b74-d0fdc1f5aee7", + "name": "View Draft Dataset (Own)", + "operations": [{ + "id": "bf946197-392a-4dbb-a7e1-789424e231a4", + "name": "Read Draft Dataset", + "uri": "object/dataset/draft/read", + }], + "orgUnitOwnershipConstraint": false, + "preAuthorisedConstraint": false, + "resourceId": "ea5d2d58-165a-48cb-9b22-42edd6a3024a", + "resourceUri": "object/dataset/draft", + "userOwnershipConstraint": true, + }, + { + "id": "e5ce2fc4-9f38-4f52-8190-b770ed2074e6", + "name": "View Published Dataset", + "operations": [{ + "id": "f1e2af3e-5d98-4081-a4ff-7016f43002fa", + "name": "Read Publish Dataset", + "uri": "object/dataset/published/read", + }], + "orgUnitOwnershipConstraint": false, + "preAuthorisedConstraint": false, + "resourceId": "ef3b767f-d06b-46f4-9302-031ae5004275", + "resourceUri": "object/dataset/published", + "userOwnershipConstraint": false, + }, + ], + "photoURL": "//www.gravatar.com/avatar/bed026a33c154abec6852b4e313bf1ce", + "roles": [ + { + "id": "00000000-0000-0002-0000-000000000000", + "name": "Authenticated Users", + "permissionIds": ["e5ce2fc4-9f38-4f52-8190-b770ed2074e6"], + }, + { + "id": "14ff3f57-e8ea-4771-93af-c6ea91a798d5", + "name": "Approvers", + "permissionIds": [ + "e5ce2fc4-9f38-4f52-8190-b770ed2074e6", + "72d52505-cf96-47b2-9b74-d0fdc1f5aee7", + ], + }, + ], + "source": "ckan", + }, + } +} + +test_allow_admin_should_have_permission_to_not_defined_resource { + allow with input as { + "operationUri": "object/test-any-object/test-any-operation", + "user": { + "displayName": "Jacky Jiang", + "email": "jacky.jiang@data61.csiro.au", + "id": "80a9dce4-91af-44e2-a2f4-9ddccb3f4c5e", + "permissions": [], + "photoURL": "//www.gravatar.com/avatar/bed026a33c154abec6852b4e313bf1ce", + "roles": [ + { + "id": "00000000-0000-0003-0000-000000000000", + "name": "Admin Users", + "permissionIds": [] + } + ], + "source": "ckan", + }, + } +} diff --git a/deploy/helm/internal-charts/opa/values.yaml b/deploy/helm/internal-charts/opa/values.yaml index c9202af5b0..2c45a76e3a 100644 --- a/deploy/helm/internal-charts/opa/values.yaml +++ b/deploy/helm/internal-charts/opa/values.yaml @@ -1,7 +1,7 @@ image: name: opa repository: openpolicyagent - tag: 0.33.0 + tag: 0.33.1 pullPolicy: IfNotPresent pullSecrets: false diff --git a/magda-authorization-api/src/Database.ts b/magda-authorization-api/src/Database.ts index c469dc70a8..64e0aaed26 100644 --- a/magda-authorization-api/src/Database.ts +++ b/magda-authorization-api/src/Database.ts @@ -10,6 +10,11 @@ import arrayToMaybe from "magda-typescript-common/src/util/arrayToMaybe"; import pg from "pg"; import _ from "lodash"; import GenericError from "magda-typescript-common/src/authorization-api/GenericError"; +import { + ANONYMOUS_USERS_ROLE_ID, + AUTHENTICATED_USERS_ROLE_ID, + ADMIN_USERS_ROLE_ID +} from "magda-typescript-common/src/authorization-api/constants"; import { getUserId } from "magda-typescript-common/src/session/GetUserId"; import NestedSetModelQueryer from "./NestedSetModelQueryer"; import isUuid from "magda-typescript-common/src/util/isUuid"; @@ -19,11 +24,7 @@ export interface DatabaseOptions { dbPort: number; } -const ANONYMOUS_USERS_ROLE = "00000000-0000-0001-0000-000000000000"; -const AUTHENTICATED_USERS_ROLE = "00000000-0000-0002-0000-000000000000"; -const ADMIN_USERS_ROLE = "00000000-0000-0003-0000-000000000000"; - -const defaultAnonymousUserInfo: User = { +export const defaultAnonymousUserInfo: User = { id: "", displayName: "Anonymous User", email: "", @@ -33,7 +34,7 @@ const defaultAnonymousUserInfo: User = { isAdmin: false, roles: [ { - id: ANONYMOUS_USERS_ROLE, + id: ANONYMOUS_USERS_ROLE_ID, name: "Anonymous Users", description: "Default role for unauthenticated users", permissionIds: [] as string[] @@ -306,14 +307,14 @@ export default class Database { //--- add default authenticated role to the newly create user await this.pool.query( "INSERT INTO user_roles (role_id, user_id) VALUES($1, $2)", - [AUTHENTICATED_USERS_ROLE, userId] + [AUTHENTICATED_USERS_ROLE_ID, userId] ); //--- add default Admin role to the newly create user (if isAdmin is true) if (user.isAdmin) { await this.pool.query( "INSERT INTO user_roles (role_id, user_id) VALUES($1, $2)", - [ADMIN_USERS_ROLE, userId] + [ADMIN_USERS_ROLE_ID, userId] ); } diff --git a/magda-authorization-api/src/createOpaRouter.ts b/magda-authorization-api/src/createOpaRouter.ts index cfcf2f8e4d..10bb1184ba 100644 --- a/magda-authorization-api/src/createOpaRouter.ts +++ b/magda-authorization-api/src/createOpaRouter.ts @@ -5,7 +5,9 @@ import Database from "./Database"; import { User } from "magda-typescript-common/src/authorization-api/model"; import { getUserSession } from "magda-typescript-common/src/session/GetUserSession"; import OpaCompileResponseParser from "magda-typescript-common/src/OpaCompileResponseParser"; -import request from "request-promise-native"; +import setResponseNoCache from "magda-typescript-common/src/express/setResponseNoCache"; +import GenericError from "magda-typescript-common/src/authorization-api/GenericError"; +import request, { RequestPromiseOptions } from "request-promise-native"; import bodyParser from "body-parser"; import objectPath from "object-path"; @@ -116,10 +118,7 @@ export default function createOpaRouter(options: OpaRouterOptions): Router { return otherQueryParams; } - async function appendUserInfoToInput( - req: express.Request, - res: express.Response - ) { + async function appendUserInfoToInput(req: express.Request) { const userInfo: User = await database.getCurrentUserInfo( req, jwtSecret @@ -166,8 +165,14 @@ export default function createOpaRouter(options: OpaRouterOptions): Router { reqOpts.json = reqData; - //console.log(JSON.stringify(reqOpts, null, 2)); + return reqOpts; + } + async function proxyToOpa( + req: express.Request, + res: express.Response, + reqOpts: RequestPromiseOptions + ) { try { // -- request's pipe api doesn't work well with chunked response const fullResponse = await request( @@ -200,12 +205,9 @@ export default function createOpaRouter(options: OpaRouterOptions): Router { async function proxyRequest(req: express.Request, res: express.Response) { try { - res.set({ - "Cache-Control": "no-cache, no-store, must-revalidate", - Pragma: "no-cache", - Expires: "0" - }); - await appendUserInfoToInput(req, res); + setResponseNoCache(res); + const reqOpts = await appendUserInfoToInput(req); + await proxyToOpa(req, res, reqOpts); } catch (e) { res.status(e.statusCode || 500).send( `Failed to proxy OPA request: ${e}` @@ -213,6 +215,286 @@ export default function createOpaRouter(options: OpaRouterOptions): Router { } } + /** + * @apiGroup Auth + * @api {post} /v0/auth/opa/decision[/path...] Get Auth Decision From OPA + * @apiDescription Ask OPA ([Open Policy Agent](https://www.openpolicyagent.org/)) make authorisation decision on proposed resource operation URI. + * The resource operation URI is supplied as part of request URL path. + * e.g. a request sent to URL `https:///api/v0/auth/opa/decision/object/dataset/draft/read` indicates an authorisation decision for is sought: + * - operation uri: `object/dataset/draft/read` + * - resource uri: `object/dataset/draft` + * + * The `resource uri` & `operation uri` info together with: + * - other optional extra context data supplied + * - current user profile. e.g. roles & permissions + * + * will be used to construct the context data object `input` that will be used to assist OPA's auth decision making. + * + * Regardless the `operation uri` supplied, this endpoint will always ask OPA to make decision using entrypoint policy `entrypoint/allow.rego` at policy directory root. + * The `entrypoint/allow.rego` should be responsible for delegating the designated policy to make the actual auth decision for a particular type of resource. + * + * e.g. The default policy `entrypoint/allow.rego` will delegate polciy `object/dataset/allow.rego` to make decision for operation uri: `object/dataset/draft/read`. + * + * Please note: you can [replace built-in policy files](https://github.com/magda-io/magda/blob/master/docs/docs/how-to-add-custom-opa-policies.md) (including `entrypoint/allow.rego`) when deploy Magda with helm config. + * + * > This API endpoint is also available as a HTTP GET endpoint. You can access the same functionality via the GET endpoint except not being able to supply parameters via HTTP request body. + * + * @apiParam (Request URL Path) {String} path The URI of the resource operation that you propose to perform. + * From this URI (e.g. `object/dataset/draft/read`), we can also work out resource URI(e.g. `object/dataset/draft`). + * Depends on policy logic, URI pattern (e.g. `object/dataset/*/read`) might be supported. + * > If you request the decision for a non-exist resource type, the default policy will evaluate to `false` (denied). + * + * @apiQuery (Query String Parameters) {String} [operationUri] Use to supply / overwrite the operation uri. + * Any parameters supplied via `Query String Parameters` have higher priority. Thus, can overwrite the same parameter supplied via `Request Body JSON`. + * However, `operationUri` supplied via `Query String Parameters` can't overwrite the `operationUri` supplied via `Request URL Path`. + * + * @apiParam (Query String Parameters) {String} [resourceUri] Use to supply / overwrite the resource uri. + * + * @apiParam (Query String Parameters) {String[]} [unknowns] Use to supply A list of references that should be considered as "unknown" during the policy evaluation. + * More details please see `unknowns` parameter in `Request Body JSON` section below. + * > Please note: you can supply an array by a query string like `unknowns=ref1&unknowns=ref2` + * + * @apiParam (Query String Parameters) {string="true"} [rawAst] Output RAW AST response from OPA instead parsed & processed result. + * As long as the parameter present in query string, the RAW AST option will be turned on. e.g. both `?rawAst` & `?rawAst=true` will work. + * + * @apiParam (Query String Parameters) {string="full"} [explain] Include OPA decision explaination in the RAW AST response from OPA. + * Only work when `rawAst` is on. + * + * @apiParam (Query String Parameters) {string="true"} [pretty] Include human readable OPA decision explaination in the RAW AST response from OPA. + * Only work when `rawAst` is on & `explain`="full". + * + * @apiParam (Query String Parameters) {string="true"} [humanReadable] Output parsed & processed result in human readable format. + * This option will not work when `rawAst` is on. + * As long as the parameter present in query string, the `humanReadable` option will be turned on. e.g. both `?humanReadable` & `?humanReadable=true` will work. + * + * @apiParam (Query String Parameters) {string="false"} [concise] Output parsed & processed result in a concise format. This is default output format. + * This option will not work when `rawAst` is on. + * You can set `concise`=`false` to output a format more similar to original OPA AST (with more details). + * + * @apiParam (Request Body JSON) {String[]} [unknowns] A list of references that should be considered as "unknown" during the policy evaluation. + * If a conclusive/unconditional auth decision can't be made without knowing "unknown" data, the residual rules of the "partial evaluation" result will be responded in [rego](https://www.openpolicyagent.org/docs/latest/policy-language/) AST JSON format. + * e.g. When `unknowns=["input.object.dataset"]`, any rules related to dataset's attributes will be kept and output as residual rules, unless existing context info is sufficient to make a conclusive/unconditional auth decision (e.g. admin can access all datasets the values of regardless dataset attributes). + * > Please note: When `unknowns` is NOT supplied, this endpoint will auto-generate a JSON path that is made up of string "input" and first 2 segments of `operationUri` as the unknown reference. + * > e.g. When `operationUri` = `object/dataset/draft/read` and `unknowns` parameter is not supplied, by default, this endpoint will set `unknowns` parameter's value to array ["input.object.dataset"]. + * > However, when extra context data is supplied as part request data at field `input.object.dataset`, the `unknowns` will not be set. + * > If you prevent the endpoint from auto-generating `unknowns`, you can supply `unknowns` parameter as an empty string. + * + * @apiParam (Request Body JSON) {Object} [input] OPA "`input` data". Use to provide extra context data to support the auth decison making. + * e.g. When you need to make decision on one particular dataset (rather than a group of dataset), you can supply the `input` data object as the following: + * ```json + * { + * "object": { + * "dataset": { + * // all dataset attributes + * ... + * } + * } + * } + * ``` + * + * > Please note: It's not possible to overwrite system generated context data fields via `input` data object. + * > e.g: + * > - `input.user` + * > - `input.timestamp` + * + * @apiSuccess (Success JSON Response Body) {bool} hasResidualRules indicates whether or not the policy engine can make a conclusive/unconditional auth decision. + * When a conclusive/unconditional auth decision is made (i.e. `hasResidualRules`=`false`), the auth decision is returned as policy evaluation value in `result` field. + * Usually, `true` means the operation should be `allowed`. + * + * @apiSuccess (Success JSON Response Body) {any} [result] Only presents when `hasResidualRules`=`false`. + * The result field contains the policy evaluation result value. `true` means th eoperation is allowed and `false` means otherwise. + * By default, it should be in `bool` type. However, you can opt to overwite the policy to return other type of data. + * + * @apiSuccess (Success JSON Response Body) {object[]} [residualRules] Only presents when `hasResidualRules`=`true`. + * A list of residual rules as the result of the partial evaluation of policy due to `unknowns`. + * The residual rules can be used to generate storage engine DSL (e.g. SQL or Elasticsearch DSL) for policy enforcement. + * + * @apiSuccess (Success JSON Response Body) {bool} [hasWarns] indicates whether or not the warning messages have been produced during OPA AST parsing. + * Not available when `rawAst` query parameter is set. + * + * @apiSuccess (Success JSON Response Body) {string[]} [warns] Any warning messages that are produced during OPA AST parsing. + * Only available when `hasWarns`=`true`. + * + * @apiSuccessExample {json} Successful Response Example: a conclusive/unconditional auth decision is made + * { + * "hasResidualRules" : false, + * "result": true // -- the evaluation value of the policy. By default, `true` means operation should be `allowed`. + * } + * + * @apiSuccessExample {json} Successful Response Example: Partial Evaluation Result + * + * { + * "hasResidualRules": true, + * "residualRules": [{"default":true,"head":{"name":"allow","value":{"type":"boolean","value":false}},"body":[{"terms":{"type":"boolean","value":true},"index":0}]},{"head":{"name":"allow","value":{"type":"boolean","value":true}},"body":[{"terms":[{"type":"ref","value":[{"type":"var","value":"eq"}]},{"type":"ref","value":[{"type":"var","value":"input"},{"type":"string","value":"object"},{"type":"string","value":"dataset"},{"type":"string","value":"publishingState"}]},{"type":"string","value":"published"}],"index":0}]}] + * } + * + * + * @apiErrorExample {string} Status Code: 500/400 + * Failed to get auth decision: xxxxxxxxx + */ + async function getAuthDecision( + req: express.Request, + res: express.Response + ) { + try { + setResponseNoCache(res); + + let operationUri = req.params[0]; + if ( + !operationUri && + req?.query?.operationUri && + typeof req.query.operationUri == "string" + ) { + operationUri = req.query.operationUri; + } else if ( + !operationUri && + req?.body?.operationUri && + typeof req.body.operationUri == "string" + ) { + operationUri = req.body.operationUri; + } + + operationUri = operationUri.trim(); + + if (operationUri === "/") { + throw new GenericError("`/` is not valid `operationUri`", 400); + } + + if (!operationUri) { + throw new GenericError( + "Please specify `operationUri` for the request", + 400 + ); + } + + if (operationUri[0] === "/") { + operationUri = operationUri.substr(1); + } + + const opUriParts = operationUri.split("/"); + + let resourceUri = + opUriParts.length > 1 + ? opUriParts.slice(0, opUriParts.length - 1).join("/") + : opUriParts[0]; + + if ( + req?.query?.resourceUri && + typeof req.query.resourceUri == "string" + ) { + resourceUri = req.query.resourceUri; + } else if ( + req?.body?.resourceUri && + typeof req.body.resourceUri == "string" + ) { + resourceUri = req.body.resourceUri; + } + + const reqOpts = await appendUserInfoToInput(req); + reqOpts.json.input.operationUri = operationUri; + reqOpts.json.input.resourceUri = resourceUri; + + /** + * By default, we will auto-generate `unknowns` reference list. + * The auto-generated `unknowns` reference list will contains a JSON path that is made up of string "input" and first 2 segments of `operationUri`. + * e.g. if `operationUri` is `object/dataset/draft/read`, the `unknowns`=["input.object.dataset"] + * + * + * We will not auto-generate `unknowns`, when: + * - `req.query.unknowns` (query string parameter) or `req.body.unknowns` (JSON request body) has set to empty string + * - OR non-empty `unknowns` is supplied either via query string or request body. + * - OR context data has been supplied via request body for the auto-generated unknown reference. + * - e.g. When `operationUri` is `object/dataset/draft/read` and the user supplies `dataset` object at `input.object`, + * there is no point to set ["input.object.dataset"] as `unknowns`, because it's supplied by the user. + */ + const autoGenerateUnknowns = + req?.query?.unknowns === "" || + req?.body?.unknowns === "" || + reqOpts?.json?.unknowns || + // test whether the context data match the auto-generated unknown json path has been supplied + objectPath.has( + reqOpts.json.input, + opUriParts.length > 2 ? opUriParts.slice(0, 2) : opUriParts + ) + ? false + : true; + + if (autoGenerateUnknowns) { + const unknownRef = [ + "input", + ...(opUriParts.length > 2 + ? opUriParts.slice(0, 2) + : opUriParts) + ].join("."); + reqOpts.json.unknowns = [unknownRef]; + } + + reqOpts.json.query = "data.entrypoint.allow"; + + // -- request's pipe api doesn't work well with opa's chunked response + const fullResponse = await request(`${opaUrl}v1/compile`, reqOpts); + + if ( + fullResponse.statusCode >= 200 && + fullResponse.statusCode < 300 + ) { + if (typeof req?.query?.rawAst !== "undefined") { + res.status(200).send(fullResponse.body); + } else { + const outputInConciseFormat = + req?.query?.concise === "false" ? false : true; + const parser = new OpaCompileResponseParser(); + parser.parse(fullResponse.body); + if (typeof req?.query?.humanReadable !== "undefined") { + res.status(200).send( + parser.evaluateAsHumanReadableString() + ); + } else { + const result = parser.evaluate(); + const resData = { + hasResidualRules: !result.isCompleteEvaluated + } as any; + + if (result.isCompleteEvaluated) { + resData.value = + // output unconditional "no rule matched" (value as `undefined`) as `false` + typeof result.value === "undefined" + ? false + : result.value; + } else { + resData.residualRules = result.residualRules.map( + (rule) => + outputInConciseFormat + ? rule.toConciseData() + : rule.toData() + ); + } + + resData.hasWarns = parser.hasWarns; + if (parser.hasWarns) { + resData.warns = parser.warns; + } + + res.status(200).send(resData); + } + } + } else { + res.status(fullResponse.statusCode).send(fullResponse.body); + } + } catch (e) { + console.log(e); + res.status(e.statusCode || 500).send( + // request promise core add extra status code to error.message + // https://github.com/request/promise-core/blob/091bac074e6c94850b999f0f824494d8b06faa1c/lib/errors.js#L26 + // Thus, we will try to use e.error if available + e?.error ? e.error : e?.message ? e.message : String(e) + ); + } + } + + router.get("/decision*", getAuthDecision); + router.post("/decision*", getAuthDecision); + opaRoutes.map((route) => { if (route.method == "post") { router.post(route.path, proxyRequest); diff --git a/magda-authorization-api/src/test/createOpaRouter.spec.ts b/magda-authorization-api/src/test/createOpaRouter.spec.ts new file mode 100644 index 0000000000..e40abf01a2 --- /dev/null +++ b/magda-authorization-api/src/test/createOpaRouter.spec.ts @@ -0,0 +1,352 @@ +import {} from "mocha"; +import request from "supertest"; +import urijs from "urijs"; +import nock from "nock"; +import express from "express"; +import addJwtSecretFromEnvVar from "magda-typescript-common/src/session/addJwtSecretFromEnvVar"; +//import buildJwt from "magda-typescript-common/src/session/buildJwt"; +import fakeArgv from "magda-typescript-common/src/test/fakeArgv"; +import createOpaRouter from "../createOpaRouter"; +import { expect } from "chai"; +import mockDatabase from "./mockDatabase"; +import mockUserDataStore from "magda-typescript-common/src/test/mockUserDataStore"; +import Database from "../Database"; +//import { Request } from "supertest"; +import mockApiKeyStore from "./mockApiKeyStore"; +import { ANONYMOUS_USERS_ROLE_ID } from "magda-typescript-common/src/authorization-api/constants"; +import testDataSimple from "magda-typescript-common/src/test/sampleOpaResponses/simple.json"; + +describe("Auth api router", function (this: Mocha.ISuiteCallbackContext) { + this.timeout(10000); + + const opaBaseUrl = "http://localhost:8181/"; + + let app: express.Express; + let argv: any; + + function createOpaNockScope( + onRequest: + | ((queryParams: { [key: string]: any }, jsonData: any) => void) + | null = null, + response: { [key: string]: any } | null = null + ) { + const scope = nock(opaBaseUrl) + .post("/v1/compile") + .query(true) + .once() + .reply(function (uri, requestBody) { + if (onRequest) { + const query = urijs(uri).search(true); + onRequest(query, requestBody); + } + const resData = response + ? response + : { + result: {} + }; + return JSON.stringify(resData); + }); + return scope; + } + + before(function () { + nock.disableNetConnect(); + nock.enableNetConnect("127.0.0.1"); + argv = retrieveArgv(); + app = buildExpressApp(); + }); + + afterEach(function () { + mockUserDataStore.reset(); + mockApiKeyStore.reset(); + nock.cleanAll(); + }); + + after(() => { + nock.cleanAll(); + nock.enableNetConnect(); + }); + + function retrieveArgv() { + const argv = addJwtSecretFromEnvVar( + fakeArgv({ + listenPort: 6014, + dbHost: "localhost", + dbPort: 5432, + jwtSecret: "squirrel" + }) + ); + return argv; + } + + function buildExpressApp() { + const apiRouter = createOpaRouter({ + jwtSecret: argv.jwtSecret, + database: new mockDatabase() as Database, + opaUrl: opaBaseUrl + }); + + const app = express(); + app.use(apiRouter); + + return app; + } + + /*function setMockRequestSession(req: Request, userId: string) { + return req.set("X-Magda-Session", buildJwt(argv.jwtSecret, userId)); + }*/ + + describe("Test `/decision`", () => { + it("should return 400 status code if not specify operation uri", async () => { + const scope = createOpaNockScope(); + + mockUserDataStore.reset(); + + const req = request(app).get(`/decision`); + await req.then((res) => { + expect(res.status).to.be.equal(400); + expect(res.text).to.be.equal( + "Please specify `operationUri` for the request" + ); + }); + expect(scope.isDone()).to.be.equal(false); + }); + + it("should return 200 status code when specify operation uri", async () => { + let data: any; + + const scope = createOpaNockScope((queryParams, requestData) => { + data = requestData; + }); + + mockUserDataStore.reset(); + + const req = request(app).get( + `/decision/object/any-object/any-operation` + ); + await req.then((res) => { + expect(res.body.hasResidualRules).to.be.equal(false); + expect(res.body.value).to.be.equal(false); + }); + expect(scope.isDone()).to.be.equal(true); + expect(data.unknowns).to.have.members(["input.object.any-object"]); + expect(data.query).to.be.equal("data.entrypoint.allow"); + expect(data.input.user.roles).to.have.members([ + ANONYMOUS_USERS_ROLE_ID + ]); + expect(data.input.operationUri).to.equal( + "object/any-object/any-operation" + ); + expect(data.input.resourceUri).to.equal("object/any-object"); + expect(data.input.timestamp).to.be.within( + Date.now() - 20000, + Date.now() + 20000 + ); + }); + + it("Can supply extra input (extra data) via POST request", async () => { + let data: any; + + const scope = createOpaNockScope((queryParams, requestData) => { + data = requestData; + }); + + mockUserDataStore.reset(); + + const testNum = Math.random(); + const req = request(app) + .post(`/decision/object/any-object/any-operation`) + .send({ + input: { + object: { + dataset: { + testNum + } + } + } + }); + + await req.then((res) => { + expect(res.body.hasResidualRules).to.be.equal(false); + expect(res.body.value).to.be.equal(false); + }); + expect(scope.isDone()).to.be.equal(true); + + expect(data.unknowns).to.have.members(["input.object.any-object"]); + expect(data.query).to.be.equal("data.entrypoint.allow"); + expect(data.input.user.roles).to.have.members([ + ANONYMOUS_USERS_ROLE_ID + ]); + expect(data.input.operationUri).to.equal( + "object/any-object/any-operation" + ); + expect(data.input.object.dataset.testNum).to.equal(testNum); + expect(data.input.resourceUri).to.equal("object/any-object"); + expect(data.input.timestamp).to.be.within( + Date.now() - 20000, + Date.now() + 20000 + ); + }); + + it("Can overwrite `unknowns` & `resourceUri` via query parameters", async () => { + let data: any; + + const scope = createOpaNockScope((queryParams, requestData) => { + data = requestData; + }); + + mockUserDataStore.reset(); + + // overwrite `unknowns` & `resourceUri` via query parameters + await request(app).get( + `/decision/object/any-object/any-operation?unknowns=input.x&unknowns=input.y&resourceUri=x/y` + ); + + expect(scope.isDone()).to.be.equal(true); + + expect(data.unknowns).to.have.members(["input.x", "input.y"]); + expect(data.input.resourceUri).to.equal("x/y"); + expect(data.query).to.be.equal("data.entrypoint.allow"); + expect(data.input.user.roles).to.have.members([ + ANONYMOUS_USERS_ROLE_ID + ]); + expect(data.input.operationUri).to.equal( + "object/any-object/any-operation" + ); + expect(data.input.timestamp).to.be.within( + Date.now() - 20000, + Date.now() + 20000 + ); + }); + + it("Can overwrite `unknowns` & `resourceUri` via post request body", async () => { + let data: any; + + const scope = createOpaNockScope((queryParams, requestData) => { + data = requestData; + }); + + mockUserDataStore.reset(); + + // overwrite `unknowns` & `resourceUri` via query parameters + await request(app) + .post(`/decision/object/any-object/any-operation`) + .send({ + unknowns: ["input.x", "input.y"], + resourceUri: "x/y" + }); + + expect(scope.isDone()).to.be.equal(true); + + expect(data.unknowns).to.have.members(["input.x", "input.y"]); + expect(data.input.resourceUri).to.equal("x/y"); + expect(data.query).to.be.equal("data.entrypoint.allow"); + expect(data.input.user.roles).to.have.members([ + ANONYMOUS_USERS_ROLE_ID + ]); + expect(data.input.operationUri).to.equal( + "object/any-object/any-operation" + ); + expect(data.input.timestamp).to.be.within( + Date.now() - 20000, + Date.now() + 20000 + ); + }); + + it("passing `rawAst` query parameter will output raw opa ast", async () => { + const scope = createOpaNockScope(null, testDataSimple); + + mockUserDataStore.reset(); + + const req = request(app).get( + `/decision/object/content/*/read?rawAst` + ); + + await req.then((res) => { + // when `rawAst` set, response raw AST + expect(res.body).to.have.all.keys("result"); + expect(res.body.result).to.have.all.keys("queries", "support"); + expect(res.body.result.queries).to.be.instanceof(Array); + expect(res.body.result.support).to.be.instanceof(Array); + }); + expect(scope.isDone()).to.be.equal(true); + }); + + it("when `rawAst` not set, response parsed concise result", async () => { + const scope = createOpaNockScope(null, testDataSimple); + + const req = request(app).get(`/decision/object/content/*/read`); + + await req.then((res) => { + // when `rawAst` not set, response parsed result + expect(res.body.hasResidualRules).to.be.equal(true); + expect(res.body.residualRules).to.be.instanceof(Array); + expect(res.body.residualRules.length).to.be.equal(1); + expect( + res.body.residualRules[0].expressions.length + ).to.be.equal(1); + const exp = res.body.residualRules[0].expressions[0]; + expect(exp).to.not.have.property("terms"); + expect(exp.operator).to.be.equal("="); + expect(exp.operands[0]).to.be.equal("input.object.content.id"); + expect(exp.operands[1]).to.be.equal( + "header/navigation/datasets" + ); + }); + expect(scope.isDone()).to.be.equal(true); + }); + + it("when `rawAst` not set and `concise`=false is set, response parsed result", async () => { + const scope = createOpaNockScope(null, testDataSimple); + + const req = request(app).get( + `/decision/object/content/*/read?concise=false` + ); + + await req.then((res) => { + expect(res.body.hasResidualRules).to.be.equal(true); + expect(res.body.residualRules).to.be.instanceof(Array); + expect(res.body.residualRules.length).to.be.equal(1); + expect( + res.body.residualRules[0].expressions.length + ).to.be.equal(1); + const exp = res.body.residualRules[0].expressions[0]; + expect(exp).to.have.property("terms"); + expect(exp.terms.length).to.be.equal(3); + }); + expect(scope.isDone()).to.be.equal(true); + }); + + it("Will pass on `explain` parameter to OPA", async () => { + let query: any; + const scope = createOpaNockScope((queryParams) => { + query = queryParams; + }, testDataSimple); + + const req = request(app).get( + `/decision/object/content/*/read?explain=full` + ); + + await req; + + expect(query.explain).to.be.equal("full"); + expect(scope.isDone()).to.be.equal(true); + }); + + it("Will pass on `pretty` parameter to OPA", async () => { + let query: any; + const scope = createOpaNockScope((queryParams) => { + query = queryParams; + }, testDataSimple); + + const req = request(app).get( + `/decision/object/content/*/read?pretty=true` + ); + + await req; + + expect(query.pretty).to.be.equal("true"); + expect(scope.isDone()).to.be.equal(true); + }); + }); +}); diff --git a/magda-authorization-api/src/test/mockDatabase.ts b/magda-authorization-api/src/test/mockDatabase.ts index a98b2bcc4b..428aed917d 100644 --- a/magda-authorization-api/src/test/mockDatabase.ts +++ b/magda-authorization-api/src/test/mockDatabase.ts @@ -84,6 +84,7 @@ export default class MockDatabase { db.getUserRoles.callsFake(this.getUserRoles); db.getUser.callsFake(this.getUser); db.getCurrentUserInfo.callThrough(); + db.getDefaultAnonymousUserInfo.callThrough(); return await db.getCurrentUserInfo(req, jwtSecret); } diff --git a/magda-gateway/src/createBaseProxy.ts b/magda-gateway/src/createBaseProxy.ts index ff878ad3dd..0808a1a243 100644 --- a/magda-gateway/src/createBaseProxy.ts +++ b/magda-gateway/src/createBaseProxy.ts @@ -1,6 +1,7 @@ import httpProxy from "http-proxy"; import express from "express"; import { IncomingHttpHeaders } from "http"; +import getNoCacheHeaders from "magda-typescript-common/src/express/getNoCacheHeaders"; import groupBy = require("lodash/groupBy"); @@ -152,23 +153,15 @@ export default function createBaseProxy( ) { // when incoming request specifically ask for a no-cache response // we set the following header to make sure not only CDN will not cache it but also web browser will not cache it - setHeaderValue( - proxyRes.headers, - "Cache-Control", - "max-age=0, no-cache, must-revalidate, proxy-revalidate" - ); - setHeaderValue( - proxyRes.headers, - "Expires", - "Thu, 01 Jan 1970 00:00:00 GMT" - ); - setHeaderValue( - proxyRes.headers, - "Last-Modified", - // toGMTString is deprecated but `Last-Modified` requires that format: - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified - // A test case will be added to make sure we know when the function is not available at runtime - (new Date() as any)["toGMTString"]() + const noCacheHeaders = getNoCacheHeaders(); + (Object.keys(noCacheHeaders) as Array< + keyof typeof noCacheHeaders + >).forEach((headerName) => + setHeaderValue( + proxyRes.headers, + headerName, + noCacheHeaders[headerName] + ) ); } else if ( // Add a default cache time of 60 seconds on GETs so the CDN can cache in times of high load. diff --git a/magda-gateway/src/test/createOpenfaasGatewayProxy.spec.ts b/magda-gateway/src/test/createOpenfaasGatewayProxy.spec.ts index 0ff45c8827..d4f3466505 100644 --- a/magda-gateway/src/test/createOpenfaasGatewayProxy.spec.ts +++ b/magda-gateway/src/test/createOpenfaasGatewayProxy.spec.ts @@ -4,6 +4,10 @@ import { expect } from "chai"; import nock, { Scope } from "nock"; import supertest from "supertest"; import randomstring from "randomstring"; +import { + AUTHENTICATED_USERS_ROLE_ID, + ADMIN_USERS_ROLE_ID +} from "magda-typescript-common/src/authorization-api/constants"; import createOpenfaasGatewayProxy from "../createOpenfaasGatewayProxy"; import setupTenantMode from "../setupTenantMode"; @@ -17,12 +21,12 @@ const adminUserData = { orgUnitId: null as string | null, roles: [ { - id: "00000000-0000-0002-0000-000000000000", + id: AUTHENTICATED_USERS_ROLE_ID, name: "Authenticated Users", permissionIds: [] as string[] }, { - id: "00000000-0000-0003-0000-000000000000", + id: ADMIN_USERS_ROLE_ID, name: "Admin Users", permissionIds: [] } @@ -41,7 +45,7 @@ const nonAdminUserData = { orgUnitId: null as string | null, roles: [ { - id: "00000000-0000-0002-0000-000000000000", + id: AUTHENTICATED_USERS_ROLE_ID, name: "Authenticated Users", permissionIds: [] as string[] } @@ -58,7 +62,7 @@ describe("Test createOpenfaasGatewayProxy", () => { const authApiBaseUrl = "http://authApi"; let authApiScope: Scope; - const adminUserId = "00000000-0000-4000-8000-000000000000"; + const adminUserId = ADMIN_USERS_ROLE_ID; const nonAdminUserId = "00000000-0000-4000-8000-100000000000"; after(() => { diff --git a/magda-scala-common/src/test/scala/au/csiro/data61/magda/opa/OpaParserSpec.scala b/magda-scala-common/src/test/scala/au/csiro/data61/magda/opa/OpaParserSpec.scala index 77608f72fc..48d5428e2c 100644 --- a/magda-scala-common/src/test/scala/au/csiro/data61/magda/opa/OpaParserSpec.scala +++ b/magda-scala-common/src/test/scala/au/csiro/data61/magda/opa/OpaParserSpec.scala @@ -11,7 +11,8 @@ import spray.json._ class OpaParserSpec extends FunSpec with Matchers { val logger = LoggerFactory.getLogger(getClass) - val opaSampleResponseFolder = "magda-typescript-common/src/test/" + val opaSampleResponseFolder = + "magda-typescript-common/src/test/sampleOpaResponses/" describe("Test sample OPA Unconditional True Response") { it( @@ -19,7 +20,7 @@ class OpaParserSpec extends FunSpec with Matchers { ) { val jsonResSource: BufferedSource = fromFile( - opaSampleResponseFolder + "sampleOpaResponseUnconditionalTrue.json" + opaSampleResponseFolder + "unconditionalTrue.json" ) val jsonRes: String = try { diff --git a/magda-typescript-common/src/OpaCompileResponseParser.ts b/magda-typescript-common/src/OpaCompileResponseParser.ts index 44d387d825..8f81d7b745 100644 --- a/magda-typescript-common/src/OpaCompileResponseParser.ts +++ b/magda-typescript-common/src/OpaCompileResponseParser.ts @@ -124,6 +124,7 @@ export class RegoRule { if (!(this.parser instanceof OpaCompileResponseParser)) { throw new Error("Require parser parameter to create a RegoRule"); } + this.evaluate(); } clone(options: Partial = {}): RegoRule { @@ -149,34 +150,55 @@ export class RegoRule { * @memberof RegoRule */ evaluate() { - this.expressions = this.expressions.map((exp) => exp.evaluate()); - const falseExpression = this.expressions.find( - (exp) => exp.isMatch() === false - ); - if (!_.isUndefined(falseExpression)) { - // --- rule expressions are always evaluated in the context of AND - // --- any false expression will make the rule not match + if (this.isCompleteEvaluated) { + return this; + } + + if (!this?.expressions?.length) { + // a rule with empty body / no expression is matched this.isCompleteEvaluated = true; - this.isMatched = false; - } else { - // --- filter out all expressions are evaluated - // --- note any non-false value will considered as a match (true) i.e. 0 is equivalent to true - // --- empty expression array indicates unconditional match (true) - const idx = this.expressions.findIndex( - (exp) => !exp.isCompleteEvaluated - ); - if (idx === -1) { + this.isMatched = true; + return this; + } + + let unresolvable = false; + for (let i = 0; i < this.expressions.length; i++) { + const exp = this.expressions[i]; + exp.evaluate(); + if (!exp.isResolvable()) { + unresolvable = true; + continue; + } + if (!exp.isMatched()) { + // --- rule expressions are always evaluated in the context of AND + // --- any false expression will make the rule not match this.isCompleteEvaluated = true; - this.isMatched = true; - } else { - // --- further dry the rule if the rule has unsolved exps - // --- if a exp is matched (i.e. true) it can be strip out as true AND xxxx = xxxx - this.expressions = this.expressions.filter( - (exp) => exp.isMatch() !== true - ); + this.isMatched = false; + return this; } } - return this; + + if (unresolvable) { + // there is at least one exp is unresolvable now + return this; + } else { + this.isCompleteEvaluated = true; + this.isMatched = true; + return this; + } + } + + /** + * Whether or not the rule is resolvable (i.e. we can tell whether it's matched or not) now. + * + * @return {*} {boolean} + * @memberof RegoRule + */ + isResolvable(): boolean { + if (!this.isCompleteEvaluated) { + this.evaluate(); + } + return this.isCompleteEvaluated; } /** @@ -202,6 +224,34 @@ export class RegoRule { } } + toData() { + return { + default: this.isDefault, + value: this.value, + fullName: this.fullName, + name: this.name, + expressions: this.expressions.map((exp, idx) => exp.toData(idx)) + }; + } + + toJson() { + return JSON.stringify(this.toData()); + } + + toConciseData() { + return { + default: this.isDefault, + value: this.value, + fullName: this.fullName, + name: this.name, + expressions: this.expressions.map((exp) => exp.toConciseData()) + }; + } + + toConciseJSON() { + return JSON.stringify(this.toConciseData()); + } + /** * Create RegoRule from Opa response data * @@ -236,7 +286,6 @@ export class RegoRule { parser }; const regoRule = new RegoRule(ruleOptions); - regoRule.evaluate(); return regoRule; } @@ -273,9 +322,7 @@ export interface RegoRefPart { value: string; } -export const RegoOperators: { - [k: string]: string; -} = { +export const RegoOperators = { eq: "=", // --- eq & equal are different in rego but no difference for value evluation. equal: "=", neq: "!=", @@ -283,7 +330,11 @@ export const RegoOperators: { gt: ">", lte: "<=", gte: ">=" -}; +} as const; + +export type RegoOperatorAstString = keyof typeof RegoOperators; + +export type RegoOperatorString = typeof RegoOperators[RegoOperatorAstString]; export type RegoTermValue = RegoRef | RegoValue; @@ -433,7 +484,7 @@ export class RegoTerm { * @returns {string} * @memberof RegoTerm */ - asOperator(): string { + asOperator() { if (this.value instanceof RegoRef) { return this.value.asOperator(); } else { @@ -469,13 +520,48 @@ export class RegoTerm { return undefined; } else { const fullName = this.fullRefString(); - const result = this.parser.completeRuleResults[fullName]; - if (_.isUndefined(result)) return undefined; - return result.value; + if (!this.parser.isRefResolvable(fullName)) { + return undefined; + } + return this.parser.getRefValue(fullName); + } + } + } + + /** + * Whether or not the RegoTerm is resolvable + * + * @return {*} {boolean} + * @memberof RegoTerm + */ + isValueResolvable(): boolean { + if (!this.isRef()) { + return true; + } else { + if (this.isOperator()) { + return false; + } else { + const fullName = this.fullRefString(); + return this.parser.isRefResolvable(fullName); } } } + toData() { + if (this.isRef()) { + return (this.value as RegoRef).toData(); + } else { + return { + type: this.type, + value: this.value + }; + } + } + + toJson(): string { + return JSON.stringify(this.toData()); + } + static parseFromData( data: any, parser: OpaCompileResponseParser @@ -631,17 +717,43 @@ export class RegoExp { } } - isMatch() { - const value = this.getValue(); - if (_.isUndefined(value)) { + /** + * Whether or not a expression should be considered as "matched". + * If all expressions of a rule are "matched", the rule will be considered as "matched". + * Thus, the rule has a value. + * + * Please note: if an expression's value is `0`, empty string "", null etc, the expression is considered as "matched". + * We only consider an expression as "Not Matched" when the expression has value `false` or is undefined. + * + * @return {boolean} + * @memberof RegoExp + */ + isMatched() { + if (!this.isResolvable()) { return undefined; + } + const isMatched = + this.value === false || _.isUndefined(this.value) ? false : true; + if (this.isNegated) { + return !isMatched; } else { - if (value === false || _.isUndefined(value)) return false; - // --- 0 is a match - return true; + return isMatched; } } + /** + * Whether or not the expression is resolvable now. + * + * @return {boolean} + * @memberof RegoExp + */ + isResolvable(): boolean { + if (!this.isCompleteEvaluated) { + this.evaluate(); + } + return this.isCompleteEvaluated; + } + /** * Convert operator term to string and put rest operands into an array. * And then return a [Operator, Operands] structure @@ -649,7 +761,7 @@ export class RegoExp { * @returns {[string, RegoTerm[]]} * @memberof RegoExp */ - toOperatorOperandsArray(): [string, RegoTerm[]] { + toOperatorOperandsArray(): [RegoOperatorString, RegoTerm[]] { if (this.terms.length !== 3) { throw new Error( `Can't get Operator & Operands from non 3 terms expression: ${this.termsAsString()}` @@ -661,12 +773,7 @@ export class RegoExp { if (t.isOperator()) { operator = t.asOperator(); } else { - const value = t.getValue(); - if (!_.isUndefined(value)) { - operands.push( - new RegoTerm(typeof value, value, this.parser) - ); - } else operands.push(t); + operands.push(t); } }); if (!operator) { @@ -689,19 +796,23 @@ export class RegoExp { * @memberof RegoExp */ evaluate() { + if (this.isCompleteEvaluated) { + return this; + } + // --- so far there is no 2 terms expression e.g. ! x + // --- builtin function should never be included in residual rule + // --- as we won't apply them on unknowns if (this.terms.length === 0) { // --- exp should be considered as matched (true) - // --- unless isNegated is true - // --- will try to normalise isNegated here this.isCompleteEvaluated = true; - this.value = this.isNegated ? false : true; - this.isNegated = false; - } - if (this.terms.length === 1) { + this.value = true; + return this; + } else if (this.terms.length === 1) { const term = this.terms[0]; + if (!term.isValueResolvable()) { + return this; + } const value = term.getValue(); - if (_.isUndefined(value)) return this; - this.value = value; this.isCompleteEvaluated = true; return this; @@ -709,40 +820,45 @@ export class RegoExp { // --- 3 terms expression e.g. true == true or x >= 3 // --- we only evalute some redundant expression e.g. true == true or false != true const [operator, operands] = this.toOperatorOperandsArray(); - if (operands.findIndex((op) => op.isRef()) !== -1) { - // --- this expression involve unknown no need to evalute - return this; - } else { - const operandsValues = operands.map((op) => op.getValue()); - let value = null; - switch (operator) { - case "=": - value = operandsValues[0] === operandsValues[1]; - break; - case ">": - value = operandsValues[0] > operandsValues[1]; - break; - case "<": - value = operandsValues[0] < operandsValues[1]; - break; - case ">=": - value = operandsValues[0] >= operandsValues[1]; - break; - case "<=": - value = operandsValues[0] <= operandsValues[1]; - break; - case "!=": - value = operandsValues[0] != operandsValues[1]; - break; - default: - throw new Error( - `Invalid 3 terms rego expression, Unknown operator "${operator}": ${this.termsAsString()}` - ); - } - this.isCompleteEvaluated = true; - this.value = value; + if ( + !operands[0].isValueResolvable() || + !operands[1].isValueResolvable() + ) { + // if one of the term value is resolvable now, we can't evaluate further. return this; } + + const operandsValues = operands.map((op) => op.getValue()); + if (operandsValues.findIndex((v) => typeof v === "undefined")) { + } + let value = null; + switch (operator) { + case "=": + value = operandsValues[0] === operandsValues[1]; + break; + case ">": + value = operandsValues[0] > operandsValues[1]; + break; + case "<": + value = operandsValues[0] < operandsValues[1]; + break; + case ">=": + value = operandsValues[0] >= operandsValues[1]; + break; + case "<=": + value = operandsValues[0] <= operandsValues[1]; + break; + case "!=": + value = operandsValues[0] != operandsValues[1]; + break; + default: + throw new Error( + `Invalid 3 terms rego expression, Unknown operator "${operator}": ${this.termsAsString()}` + ); + } + this.isCompleteEvaluated = true; + this.value = value; + return this; } else { throw new Error( `Invalid ${ @@ -750,10 +866,46 @@ export class RegoExp { } terms rego expression: ${this.termsAsString()}` ); } - // --- so far there is no 2 terms expression e.g. ! x - // --- builtin function should never be included in residual rule - // --- as we won't apply them on unknowns - return this; + } + + toData(index: number = 0) { + const terms = this.terms.map((term) => term.toData()); + if (this.isNegated) { + return { + negated: true, + index, + terms + }; + } else { + return { + index, + terms + }; + } + } + + toJSON(index: number = 0): string { + return JSON.stringify(this.toData(index)); + } + + toConciseData() { + const [operator, operands] = this.toOperatorOperandsArray(); + const data = { + negated: this.isNegated, + operator, + operands: operands.map((item) => { + if (item.isRef()) { + return item.fullRefString(); + } else { + return item.getValue(); + } + }) + }; + return data; + } + + toConciseJSON(): string { + return JSON.stringify(this.toConciseData()); } static parseFromData( @@ -801,6 +953,17 @@ export class RegoRef { return new RegoRef(this.parts.map((p) => ({ ...p }))); } + toData() { + return { + type: "ref", + value: this.parts + }; + } + + toJson(): string { + return JSON.stringify(this.toData()); + } + static parseFromData(data: any): RegoRef { if (data.type === "ref") { return new RegoRef(data.value as RegoRefPart[]); @@ -846,10 +1009,8 @@ export class RegoRef { } if (isFirstPart) isFirstPart = false; return partStr; - //--- a.[_].[_] should be a[_][_] }) - .join(".") - .replace(/\.\[/g, "["); + .join("."); return this.removeAllPrefixs(str, removalPrefixs); } @@ -896,8 +1057,9 @@ export class RegoRef { return refString.lastIndexOf("[_]") === refString.length - 3; } - asOperator(): string { - if (this.isOperator()) return RegoOperators[this.fullRefString()]; + asOperator(): RegoOperatorString | null { + if (this.isOperator()) + return RegoOperators[this.fullRefString() as RegoOperatorAstString]; else return null; } } @@ -915,6 +1077,189 @@ export function value2String(value: RegoValue) { else return JSON.stringify(value); } +export class RegoRuleSet { + public fullName: string = ""; + public name: string = ""; + public rules: RegoRule[] = []; + public defaultRule: RegoRule | null = null; + public value?: any; + public isCompleteEvaluated: boolean = false; + public parser: OpaCompileResponseParser; + + constructor( + parser: OpaCompileResponseParser, + rules: RegoRule[], + fullName: string = "", + name: string = "" + ) { + this.parser = parser; + if (rules?.length) { + const defaultRuleIdx = rules.findIndex((r) => r.isDefault); + if (defaultRuleIdx !== -1) { + this.defaultRule = rules[defaultRuleIdx]; + } + this.rules = rules.filter((r) => !r.isDefault); + } + if (fullName) { + this.fullName = fullName; + } else if (rules?.[0]?.fullName) { + this.fullName = rules[0].fullName; + } + + if (name) { + this.name = name; + } else if (rules?.[0]?.name) { + this.name = rules[0].name; + } + + this.evaluate(); + } + + evaluate(): RegoRuleSet { + if (this.isCompleteEvaluated) { + return this; + } + + if (!this.rules?.length) { + if (!this.defaultRule) { + this.isCompleteEvaluated = true; + this.value = undefined; + return this; + } else { + if (this.defaultRule.isResolvable()) { + this.isCompleteEvaluated = true; + this.value = this.defaultRule.value; + return this; + } else { + return this; + } + } + } + this.rules.forEach((r) => r.evaluate()); + const matchedRule = this.rules.find( + (r) => r.isResolvable() && r.isMatched + ); + if (matchedRule) { + this.isCompleteEvaluated = true; + this.value = matchedRule.value; + return this; + } + + if (this.rules.findIndex((r) => !r.isResolvable()) !== -1) { + // still has rule unresolvable + return this; + } + + // rest (if any) are all unmatched rules + if (!this.defaultRule) { + this.isCompleteEvaluated = true; + this.value = undefined; + return this; + } else { + if (this.defaultRule.isResolvable()) { + this.isCompleteEvaluated = true; + this.value = this.defaultRule.value; + return this; + } else { + return this; + } + } + } + + isResolvable(): boolean { + if (!this.isCompleteEvaluated) { + this.evaluate(); + } + return this.isCompleteEvaluated; + } + + getResidualRules(): RegoRule[] { + if (this.isResolvable()) { + return []; + } + + let rules = this.defaultRule + ? [this.defaultRule, ...this.rules] + : [...this.rules]; + + rules = this.rules.filter((r) => !r.isResolvable()); + if (!rules.length) { + return []; + } + + rules = _.flatMap(rules, (rule) => { + // all resolvable expressions can all be ignored as: + // - if the expression is resolved to "matched", it won't impact the result of the rule + // - if the expression is resolved to "unmatched", the rule should be resolved to "unmatched" earlier. + const unresolvedExpressions = rule.expressions.filter( + (exp) => !exp.isResolvable() + ); + if (unresolvedExpressions.length !== 1) { + return [rule]; + } + // For rules with single expression, reduce the layer by replacing it with target reference rules + const exp = unresolvedExpressions[0]; + if (exp.terms.length === 1) { + const fullName = exp.terms[0].fullRefString(); + const ruleSet = this.parser.ruleSets[fullName]; + if (!ruleSet) { + const compressedRule = rule.clone(); + compressedRule.expressions = [exp]; + return [compressedRule]; + } + return ruleSet.getResidualRules(); + } else if (exp.terms.length === 3) { + const [operator, [op1, op2]] = exp.toOperatorOperandsArray(); + if (operator != "=" && operator != "!=") { + // For now, we will only further process the ref when operator is = or != + return [rule]; + } + if (!op1.isValueResolvable() && !op2.isValueResolvable()) { + // when both op1 & op1 are not resolvable ref, we will not attempt to process further + return [rule]; + } + const value = op1.isValueResolvable() + ? op1.getValue() + : op2.getValue(); + const refTerm = op1.isValueResolvable() ? op2 : op1; + + const fullName = refTerm.fullRefString(); + const ruleSet = this.parser.ruleSets[fullName]; + if (!ruleSet) { + const compressedRule = rule.clone(); + compressedRule.expressions = [exp]; + return [compressedRule]; + } + let refRules = ruleSet.getResidualRules(); + + // when negated expression, reverse the operator + const convertedOperator: RegoOperatorString = exp.isNegated + ? operator == "=" + ? "!=" + : "=" + : operator; + + if (convertedOperator == "=") { + refRules = refRules.filter((r) => r.value == value); + } else { + refRules = refRules.filter((r) => r.value != value); + } + if (!refRules.length) { + // this means this rule can never matched + return []; + } + return refRules; + } else { + throw new Error( + `Failed to produce residualRules for rule: ${rule.toJson()}` + ); + } + }); + + return rules; + } +} + /** * OPA result Parser * @@ -957,6 +1302,16 @@ export default class OpaCompileResponseParser { */ public rules: RegoRule[] = []; + /** + * Parsed, compressed & evaluated rule sets + * + * @type {RegoRuleSet[]} + * @memberof OpaCompileResponseParser + */ + public ruleSets: { + [fullName: string]: RegoRuleSet; + } = {}; + public queries: RegoExp[] = []; /** @@ -1004,23 +1359,30 @@ export default class OpaCompileResponseParser { } else { this.data = json; } - if (!this.data.result) { + /** + * OPA might output {"result": {}} as unconditional `false` or never matched + */ + if (!this.data.result || !Object.keys(this.data.result).length) { // --- mean no rule matched this.setQueryRuleResult(false); return []; } this.data = this.data.result; if ( - (!this.data.queries || - !_.isArray(this.data.queries) || - !this.data.queries.length) && - (!_.isArray(this.data.support) || !this.data.support.length) + !this.data.queries || + !_.isArray(this.data.queries) || + !this.data.queries.length ) { - // --- mean no rule matched this.setQueryRuleResult(false); return []; } + if (this.data.queries.findIndex((q: any) => !q?.length) !== -1) { + // when query is always true, the "queries" value in the result will contain an empty array + this.setQueryRuleResult(true); + return []; + } + const queries: any[] = this.data.queries; if (queries) { @@ -1046,7 +1408,6 @@ export default class OpaCompileResponseParser { value: true, parser: this }); - rule.evaluate(); this.originalRules.push(rule); this.rules.push(rule); }); @@ -1070,93 +1431,70 @@ export default class OpaCompileResponseParser { this ); this.originalRules.push(regoRule); - // --- only save matched rules - if (!regoRule.isCompleteEvaluated) { - this.rules.push(regoRule); - } else { - if (regoRule.isMatched) { - this.rules.push(regoRule); - } - } + this.rules.push(regoRule); }); }); } - this.calculateCompleteRuleResult(); - this.reduceDependencies(); + _.uniq(this.rules.map((r) => r.fullName)).forEach( + (fullName) => + (this.ruleSets[fullName] = new RegoRuleSet( + this, + this.rules.filter((r) => r.fullName === fullName), + fullName + )) + ); + + this.resolveAllRuleSets(); return this.rules; } - /** - * Tried to merge rules outcome so that the ref value can be established easier - * After this step, any rules doesn't involve unknown should be merged to one value - * This will help to generate more concise query later. - * `CompleteRule` rule involves no `unknowns` - * - * Only for internal usage - * - * @private - * @memberof OpaCompileResponseParser - */ - private calculateCompleteRuleResult() { - const fullNames = this.rules.map((r) => r.fullName); - fullNames.forEach((fullName) => { - const rules = this.rules.filter((r) => r.fullName === fullName); - const nonCompletedRules = rules.filter( - (r) => !r.isCompleteEvaluated - ); - const completedRules = rules.filter((r) => r.isCompleteEvaluated); - const defaultRules = completedRules.filter((r) => r.isDefault); - const nonDefaultRules = completedRules.filter((r) => !r.isDefault); - if (nonDefaultRules.length) { - // --- if a non default complete eveluated rules exist - // --- it will be the final outcome - this.completeRuleResults[ - fullName - ] = this.createCompleteRuleResult(nonDefaultRules[0]); - return; - } - if (!nonCompletedRules.length) { - // --- if no unevaluated rule left, default rule value should be used - if (defaultRules.length) { - this.completeRuleResults[ - fullName - ] = this.createCompleteRuleResult(defaultRules[0]); - return; - } else { - // --- no matched complete non default rule left; Not possible - throw new Error( - `Unexpected empty rule result for ${fullName}` - ); - } - } else { - // --- do nothing - // --- Some defaultRules might be able to strip out once - // --- nonCompleteRules are determined later - return; - } - }); + isRefResolvable(fullName: string): boolean { + if (this.completeRuleResults[fullName]) { + return true; + } + const ruleSet = this.ruleSets[fullName]; + if (!ruleSet) { + return false; + } + return ruleSet.isResolvable(); } - /** - * Only for internal usage - * - * @returns - * @private - * @memberof OpaCompileResponseParser - */ - private reduceDependencies() { - const rules = this.rules.filter((r) => !r.isCompleteEvaluated); - if (!rules.length) return; - for (let i = 0; i < rules.length; i++) { - const rule = rules[i]; - rule.expressions = rule.expressions.map((e) => e.evaluate()); - rule.evaluate(); + getRefValue(fullName: string): any { + const completeResult = this.completeRuleResults[fullName]; + if (completeResult) { + return completeResult.value; + } + const ruleSet = this.ruleSets[fullName]; + if (!ruleSet || !ruleSet.isResolvable()) { + return undefined; + } + return ruleSet.value; + } + + private resolveAllRuleSets() { + while (true) { + const unresolvedSetsNum = Object.values(this.ruleSets).filter( + (rs) => !rs.isResolvable() + ).length; + + if (!unresolvedSetsNum) { + break; + } + + Object.values(this.ruleSets).forEach((rs) => rs.evaluate()); + + const newUnresolvedSetsNum = Object.values(this.ruleSets).filter( + (rs) => !rs.isResolvable() + ).length; + + if ( + !newUnresolvedSetsNum || + newUnresolvedSetsNum >= unresolvedSetsNum + ) { + break; + } } - // --- unmatched non-default rule can be stripped out - this.rules = this.rules.filter( - (r) => !(r.isCompleteEvaluated && !r.isMatched && !r.isDefault) - ); } /** @@ -1167,146 +1505,27 @@ export default class OpaCompileResponseParser { * @memberof OpaCompileResponseParser */ evaluateRule(fullName: string): CompleteRuleResult | null { - if (this.completeRuleResults?.[fullName]?.isCompleteEvaluated) { - // --- already evaluated during paring or dependencies removal - return this.completeRuleResults?.[fullName]; + if (this.completeRuleResults[fullName]) { + return this.completeRuleResults[fullName]; } - let rules = this.rules.filter((r) => r.fullName === fullName); - const originalRuleName = rules[0].name; - if (!rules.length) { - // --- no any rule matched; often (depends on your policy) it means a overall non-matched (false) + const ruleSet = this.ruleSets[fullName]; + if (!ruleSet) { return null; } - - const defaultRule = rules.find((r) => r.isDefault); - const defaultValue = _.isUndefined(defaultRule) - ? undefined - : defaultRule.value; - - if (rules.find((r) => r.isCompleteEvaluated)) - // --- filter out default rules & unmatched - // --- isMatch is only set when r.isCompleteEvaluated = true - rules = rules.filter( - (r) => !(r.isDefault || (r.isCompleteEvaluated && !r.isMatched)) - ); - - if (!rules.length) { + if (ruleSet.isResolvable()) { return { fullName, - name: defaultRule ? defaultRule.name : "", - value: defaultValue, - isCompleteEvaluated: true, - residualRules: [] + name: ruleSet.name, + value: ruleSet.value, + isCompleteEvaluated: true }; } else { - const matchedRule = rules.find((r) => r.isMatched); - if (matchedRule) { - return { - fullName, - name: originalRuleName, - value: matchedRule.value, - isCompleteEvaluated: true, - residualRules: [] - }; - } - - const ruleWithEmptyExps = rules.find((r) => !r.expressions.length); - if (ruleWithEmptyExps) { - // empty exp / body means unconditional match - return { - fullName, - name: originalRuleName, - value: ruleWithEmptyExps.value, - isCompleteEvaluated: true, - residualRules: [] - }; - } - if (rules.length === 1 && rules[0].expressions.length === 1) { - rules[0].expressions[0].terms.length === 1; - } - // if a rules contains one expression only, we will try to resolve any possible rule ref - rules = _.flatMap(rules, (rule) => { - if (rules.length === 1 && rules[0].expressions.length === 1) { - const exp = rules[0].expressions[0]; - if (exp.terms.length === 1 && exp.terms[0].isRef()) { - const ruleRef = exp.terms[0].fullRefString(); - const result = this.evaluateRule(ruleRef); - if (result) { - if (result.isCompleteEvaluated) { - return [ - RegoRule.createFromValue(result.value, this) - ]; - } else { - return result.residualRules; - } - } else { - return [rule]; - } - } else if (exp.terms.length === 3) { - const [ - opStr, - [op1, op2] - ] = exp.toOperatorOperandsArray(); - if ( - opStr === "=" && - ((op1.isRef() && typeof op2.value === "boolean") || - (op2.isRef() && typeof op1.value === "boolean")) - ) { - const ruleRef = op1.isRef() - ? op1.fullRefString() - : op2.fullRefString(); - const bVal = - typeof op1.value === "boolean" - ? op1.value - : op2.value; - - const result = this.evaluateRule(ruleRef); - if (result) { - if (result.isCompleteEvaluated) { - if (bVal === false) { - return [ - RegoRule.createFromValue( - !result.value, - this - ) - ]; - } else { - return [ - RegoRule.createFromValue( - result.value, - this - ) - ]; - } - } else { - if (bVal === false) { - return result.residualRules.map((r) => - r.clone({ value: !r.value }) - ); - } else { - return result.residualRules; - } - } - } else { - return [rule]; - } - } else { - return [rule]; - } - } else { - return [rule]; - } - } else { - return [rule]; - } - }); - return { fullName, - name: rules[0].name, + name: ruleSet.name, value: undefined, isCompleteEvaluated: false, - residualRules: rules + residualRules: ruleSet.getResidualRules() }; } } @@ -1332,6 +1551,9 @@ export default class OpaCompileResponseParser { const result = this.evaluateRule(fullName); if (result === null) return "null"; if (result.isCompleteEvaluated) { + if (typeof result.value === "undefined") { + return "undefined"; + } return value2String(result.value); } let parts = result.residualRules.map((r) => r.toHumanReadableString()); @@ -1351,24 +1573,6 @@ export default class OpaCompileResponseParser { return this.evaluateRuleAsHumanReadableString(this.pseudoQueryRuleName); } - /** - * Only for internal usage - * - * @param {RegoRule} rule - * @returns {CompleteRuleResult} - * @private - * @memberof OpaCompileResponseParser - */ - private createCompleteRuleResult(rule: RegoRule): CompleteRuleResult { - return { - fullName: rule.fullName, - name: rule.name, - value: rule.value, - isCompleteEvaluated: true, - residualRules: [] - }; - } - reportWarns(msg: string) { this.warns.push(msg); this.hasWarns = true; diff --git a/magda-typescript-common/src/authorization-api/constants.ts b/magda-typescript-common/src/authorization-api/constants.ts new file mode 100644 index 0000000000..15ba803663 --- /dev/null +++ b/magda-typescript-common/src/authorization-api/constants.ts @@ -0,0 +1,4 @@ +export const ANONYMOUS_USERS_ROLE_ID = "00000000-0000-0001-0000-000000000000"; +export const AUTHENTICATED_USERS_ROLE_ID = + "00000000-0000-0002-0000-000000000000"; +export const ADMIN_USERS_ROLE_ID = "00000000-0000-0003-0000-000000000000"; diff --git a/magda-typescript-common/src/express/getNoCacheHeaders.ts b/magda-typescript-common/src/express/getNoCacheHeaders.ts new file mode 100644 index 0000000000..86a2a3594c --- /dev/null +++ b/magda-typescript-common/src/express/getNoCacheHeaders.ts @@ -0,0 +1,7 @@ +const getNoCacheHeaders = () => ({ + "Cache-Control": "max-age=0, no-cache, must-revalidate, proxy-revalidate", + Expires: "Thu, 01 Jan 1970 00:00:00 GMT", + "Last-Modified": new Date().toUTCString() +}); + +export default getNoCacheHeaders; diff --git a/magda-typescript-common/src/express/setResponseNoCache.ts b/magda-typescript-common/src/express/setResponseNoCache.ts new file mode 100644 index 0000000000..fbb14a9f6f --- /dev/null +++ b/magda-typescript-common/src/express/setResponseNoCache.ts @@ -0,0 +1,6 @@ +import { Response } from "express"; +import getNoCacheHeaders from "./getNoCacheHeaders"; + +const setResponseNoCache = (res: Response) => res.set(getNoCacheHeaders()); + +export default setResponseNoCache; diff --git a/magda-typescript-common/src/test/getAuthDecision.spec.ts b/magda-typescript-common/src/test/getAuthDecision.spec.ts index 23c862ce2e..758201fff3 100644 --- a/magda-typescript-common/src/test/getAuthDecision.spec.ts +++ b/magda-typescript-common/src/test/getAuthDecision.spec.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; import getAuthDecision from "../opa/getAuthDecision"; -import testDataUnconditionalTrue from "./sampleOpaResponseUnconditionalTrue.json"; +import testDataUnconditionalTrue from "./sampleOpaResponses/unconditionalTrue.json"; import "mocha"; /* diff --git a/magda-typescript-common/src/test/sampleOpaResponse.json b/magda-typescript-common/src/test/sampleOpaResponses/content.json similarity index 100% rename from magda-typescript-common/src/test/sampleOpaResponse.json rename to magda-typescript-common/src/test/sampleOpaResponses/content.json diff --git a/magda-typescript-common/src/test/sampleOpaResponses/datasetPermissionWithOrgUnitConstraint.json b/magda-typescript-common/src/test/sampleOpaResponses/datasetPermissionWithOrgUnitConstraint.json new file mode 100644 index 0000000000..070a04a3c5 --- /dev/null +++ b/magda-typescript-common/src/test/sampleOpaResponses/datasetPermissionWithOrgUnitConstraint.json @@ -0,0 +1,341 @@ +{ + "result": { + "queries": [ + [ + { + "terms": { + "type": "ref", + "value": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "partial" + }, + { + "type": "string", + "value": "entrypoint" + }, + { + "type": "string", + "value": "allow" + } + ] + }, + "index": 0 + } + ] + ], + "support": [ + { + "package": { + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "partial" + }, + { + "type": "string", + "value": "object" + }, + { + "type": "string", + "value": "dataset" + } + ] + }, + "rules": [ + { + "default": true, + "head": { + "name": "allow", + "value": { + "type": "boolean", + "value": false + } + }, + "body": [ + { + "terms": { + "type": "boolean", + "value": true + }, + "index": 0 + } + ] + }, + { + "head": { + "name": "allow", + "value": { + "type": "boolean", + "value": true + } + }, + "body": [ + { + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "eq" + } + ] + }, + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "input" + }, + { + "type": "string", + "value": "object" + }, + { + "type": "string", + "value": "dataset" + }, + { + "type": "string", + "value": "publishingState" + } + ] + }, + { + "type": "string", + "value": "published" + } + ], + "index": 0 + }, + { + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "eq" + } + ] + }, + { + "type": "string", + "value": "5447fcb1-74ec-451c-b6ef-007aa736a346" + }, + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "input" + }, + { + "type": "string", + "value": "object" + }, + { + "type": "string", + "value": "dataset" + }, + { + "type": "string", + "value": "accessControl" + }, + { + "type": "string", + "value": "orgUnitOwnerId" + } + ] + } + ], + "index": 1 + } + ] + }, + { + "head": { + "name": "allow", + "value": { + "type": "boolean", + "value": true + } + }, + "body": [ + { + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "eq" + } + ] + }, + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "input" + }, + { + "type": "string", + "value": "object" + }, + { + "type": "string", + "value": "dataset" + }, + { + "type": "string", + "value": "publishingState" + } + ] + }, + { + "type": "string", + "value": "published" + } + ], + "index": 0 + }, + { + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "eq" + } + ] + }, + { + "type": "string", + "value": "b749759e-6e6a-44c0-87ab-4590744187cf" + }, + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "input" + }, + { + "type": "string", + "value": "object" + }, + { + "type": "string", + "value": "dataset" + }, + { + "type": "string", + "value": "accessControl" + }, + { + "type": "string", + "value": "orgUnitOwnerId" + } + ] + } + ], + "index": 1 + } + ] + } + ] + }, + { + "package": { + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "partial" + }, + { + "type": "string", + "value": "entrypoint" + } + ] + }, + "rules": [ + { + "default": true, + "head": { + "name": "allow", + "value": { + "type": "boolean", + "value": false + } + }, + "body": [ + { + "terms": { + "type": "boolean", + "value": true + }, + "index": 0 + } + ] + }, + { + "head": { + "name": "allow", + "value": { + "type": "boolean", + "value": true + } + }, + "body": [ + { + "terms": { + "type": "ref", + "value": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "partial" + }, + { + "type": "string", + "value": "object" + }, + { + "type": "string", + "value": "dataset" + }, + { + "type": "string", + "value": "allow" + } + ] + }, + "index": 0 + } + ] + } + ] + } + ] + } +} diff --git a/magda-typescript-common/src/test/sampleOpaResponseSimple.json b/magda-typescript-common/src/test/sampleOpaResponses/simple.json similarity index 100% rename from magda-typescript-common/src/test/sampleOpaResponseSimple.json rename to magda-typescript-common/src/test/sampleOpaResponses/simple.json diff --git a/magda-typescript-common/src/test/sampleOpaResponses/unconditionalFalseSimple.json b/magda-typescript-common/src/test/sampleOpaResponses/unconditionalFalseSimple.json new file mode 100644 index 0000000000..f2b4f9c6f9 --- /dev/null +++ b/magda-typescript-common/src/test/sampleOpaResponses/unconditionalFalseSimple.json @@ -0,0 +1,3 @@ +{ + "result": {} +} diff --git a/magda-typescript-common/src/test/sampleOpaResponses/unconditionalNotMacthed.json b/magda-typescript-common/src/test/sampleOpaResponses/unconditionalNotMacthed.json new file mode 100644 index 0000000000..d7309b0401 --- /dev/null +++ b/magda-typescript-common/src/test/sampleOpaResponses/unconditionalNotMacthed.json @@ -0,0 +1,73 @@ +{ + "result": { + "queries": [ + [ + { + "terms": { + "type": "ref", + "value": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "partial" + }, + { + "type": "string", + "value": "entrypoint" + }, + { + "type": "string", + "value": "allow" + } + ] + }, + "index": 0 + } + ] + ], + "support": [ + { + "package": { + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "partial" + }, + { + "type": "string", + "value": "entrypoint" + } + ] + }, + "rules": [ + { + "default": true, + "head": { + "name": "allow", + "value": { + "type": "boolean", + "value": false + } + }, + "body": [ + { + "terms": { + "type": "boolean", + "value": true + }, + "index": 0 + } + ] + } + ] + } + ] + } +} diff --git a/magda-typescript-common/src/test/sampleOpaResponses/unconditionalNotMacthedWithExtraRefs.json b/magda-typescript-common/src/test/sampleOpaResponses/unconditionalNotMacthedWithExtraRefs.json new file mode 100644 index 0000000000..3e087a0ae0 --- /dev/null +++ b/magda-typescript-common/src/test/sampleOpaResponses/unconditionalNotMacthedWithExtraRefs.json @@ -0,0 +1,155 @@ +{ + "result": { + "queries": [ + [ + { + "terms": { + "type": "ref", + "value": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "partial" + }, + { + "type": "string", + "value": "entrypoint" + }, + { + "type": "string", + "value": "allow" + } + ] + }, + "index": 0 + } + ] + ], + "support": [ + { + "package": { + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "partial" + }, + { + "type": "string", + "value": "object" + }, + { + "type": "string", + "value": "dataset" + } + ] + }, + "rules": [ + { + "default": true, + "head": { + "name": "allow", + "value": { + "type": "boolean", + "value": false + } + }, + "body": [ + { + "terms": { + "type": "boolean", + "value": true + }, + "index": 0 + } + ] + } + ] + }, + { + "package": { + "path": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "partial" + }, + { + "type": "string", + "value": "entrypoint" + } + ] + }, + "rules": [ + { + "default": true, + "head": { + "name": "allow", + "value": { + "type": "boolean", + "value": false + } + }, + "body": [ + { + "terms": { + "type": "boolean", + "value": true + }, + "index": 0 + } + ] + }, + { + "head": { + "name": "allow", + "value": { + "type": "boolean", + "value": true + } + }, + "body": [ + { + "terms": { + "type": "ref", + "value": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "partial" + }, + { + "type": "string", + "value": "object" + }, + { + "type": "string", + "value": "dataset" + }, + { + "type": "string", + "value": "allow" + } + ] + }, + "index": 0 + } + ] + } + ] + } + ] + } +} diff --git a/magda-typescript-common/src/test/sampleOpaResponseUnconditionalTrue.json b/magda-typescript-common/src/test/sampleOpaResponses/unconditionalTrue.json similarity index 100% rename from magda-typescript-common/src/test/sampleOpaResponseUnconditionalTrue.json rename to magda-typescript-common/src/test/sampleOpaResponses/unconditionalTrue.json diff --git a/magda-typescript-common/src/test/sampleOpaResponses/unconditionalTrueSimple.json b/magda-typescript-common/src/test/sampleOpaResponses/unconditionalTrueSimple.json new file mode 100644 index 0000000000..db6915e076 --- /dev/null +++ b/magda-typescript-common/src/test/sampleOpaResponses/unconditionalTrueSimple.json @@ -0,0 +1,48 @@ +{ + "result": { + "queries": [ + [], + [ + { + "index": 0, + "terms": [ + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "gte" + } + ] + }, + { + "type": "number", + "value": 4 + }, + { + "type": "ref", + "value": [ + { + "type": "var", + "value": "data" + }, + { + "type": "string", + "value": "reports" + }, + { + "type": "var", + "value": "$02" + }, + { + "type": "string", + "value": "clearance_level" + } + ] + } + ] + } + ] + ] + } +} diff --git a/magda-typescript-common/src/test/sampleOpaResponseUnconditionalTrueWithDefaultRule.json b/magda-typescript-common/src/test/sampleOpaResponses/unconditionalTrueWithDefaultRule.json similarity index 100% rename from magda-typescript-common/src/test/sampleOpaResponseUnconditionalTrueWithDefaultRule.json rename to magda-typescript-common/src/test/sampleOpaResponses/unconditionalTrueWithDefaultRule.json diff --git a/magda-typescript-common/src/test/sampleOpaResponseWithDefaultRule.json b/magda-typescript-common/src/test/sampleOpaResponses/withDefaultRule.json similarity index 100% rename from magda-typescript-common/src/test/sampleOpaResponseWithDefaultRule.json rename to magda-typescript-common/src/test/sampleOpaResponses/withDefaultRule.json diff --git a/magda-typescript-common/src/test/testOpaCompileResponseParser.spec.ts b/magda-typescript-common/src/test/testOpaCompileResponseParser.spec.ts index 1de66b727b..461f69cbb9 100644 --- a/magda-typescript-common/src/test/testOpaCompileResponseParser.spec.ts +++ b/magda-typescript-common/src/test/testOpaCompileResponseParser.spec.ts @@ -1,10 +1,15 @@ import { expect } from "chai"; import OpaCompileResponseParser from "../OpaCompileResponseParser"; -import testData from "./sampleOpaResponse.json"; -import testDataSimple from "./sampleOpaResponseSimple.json"; -import testDataUnconditionalTrue from "./sampleOpaResponseUnconditionalTrue.json"; -import testDataUnconditionalTrueWithDefaultRule from "./sampleOpaResponseUnconditionalTrueWithDefaultRule.json"; -import testDataEsriPolicyWithDefaultRule from "./sampleOpaResponseWithDefaultRule.json"; +import testData from "./sampleOpaResponses/content.json"; +import testDataSimple from "./sampleOpaResponses/simple.json"; +import testDataUnconditionalTrue from "./sampleOpaResponses/unconditionalTrue.json"; +import testDataUnconditionalTrueWithDefaultRule from "./sampleOpaResponses/unconditionalTrueWithDefaultRule.json"; +import testDataEsriPolicyWithDefaultRule from "./sampleOpaResponses/withDefaultRule.json"; +import testDataUnconditionalNotMacthed from "./sampleOpaResponses/unconditionalNotMacthed.json"; +import testDataUnconditionalNotMacthedWithExtraRefs from "./sampleOpaResponses/unconditionalNotMacthedWithExtraRefs.json"; +import testDataUnconditionalFalseSimple from "./sampleOpaResponses/unconditionalFalseSimple.json"; +import testDataUnconditionalTrueSimple from "./sampleOpaResponses/unconditionalTrueSimple.json"; +import testDataDatasetPermissionWithOrgUnitConstraint from "./sampleOpaResponses/datasetPermissionWithOrgUnitConstraint.json"; import "mocha"; /** @@ -171,3 +176,170 @@ describe("Test OpaCompileResultParser with esri policy that contains default rul expect(result).to.be.equal("true"); }); }); + +describe("Test OpaCompileResultParser with unconditional not matched (no rule in query is matched) response", function () { + it("Parse sample response with no errors", function () { + const parser = new OpaCompileResponseParser(); + const data = parser.parse( + JSON.stringify(testDataUnconditionalNotMacthed) + ); + expect(parser.hasWarns).to.be.equal(false); + expect(data).to.be.an("array"); + }); + + it("Should evalute query from parse result correctly", function () { + const parser = new OpaCompileResponseParser(); + parser.parse(JSON.stringify(testDataUnconditionalNotMacthed)); + const result = parser.evaluate(); + expect(parser.hasWarns).to.be.equal(false); + expect(result.isCompleteEvaluated).to.be.equal(true); + expect(result.value).to.be.undefined; + }); + + it("Should generate correct human readable string", function () { + const parser = new OpaCompileResponseParser(); + parser.parse(JSON.stringify(testDataUnconditionalNotMacthed)); + const result = parser.evaluateAsHumanReadableString(); + expect(parser.hasWarns).to.be.equal(false); + expect(result).to.be.equal("undefined"); + }); +}); + +describe("Test OpaCompileResultParser with unconditional not matched (no rule in query is matched) with extra refs response", function () { + it("Parse sample response with no errors", function () { + const parser = new OpaCompileResponseParser(); + const data = parser.parse( + JSON.stringify(testDataUnconditionalNotMacthedWithExtraRefs) + ); + expect(parser.hasWarns).to.be.equal(false); + expect(data).to.be.an("array"); + }); + + it("Should evalute query from parse result correctly", function () { + const parser = new OpaCompileResponseParser(); + parser.parse( + JSON.stringify(testDataUnconditionalNotMacthedWithExtraRefs) + ); + const result = parser.evaluate(); + expect(parser.hasWarns).to.be.equal(false); + expect(result.isCompleteEvaluated).to.be.equal(true); + expect(result.value).to.be.undefined; + }); + + it("Should generate correct human readable string", function () { + const parser = new OpaCompileResponseParser(); + parser.parse( + JSON.stringify(testDataUnconditionalNotMacthedWithExtraRefs) + ); + const result = parser.evaluateAsHumanReadableString(); + expect(parser.hasWarns).to.be.equal(false); + expect(result).to.be.equal("undefined"); + }); +}); + +describe("Test OpaCompileResultParser with unconditional true (simple response)", function () { + it("Parse sample response with no errors", function () { + const parser = new OpaCompileResponseParser(); + const data = parser.parse( + JSON.stringify(testDataUnconditionalTrueSimple) + ); + expect(parser.hasWarns).to.be.equal(false); + expect(data).to.be.an("array"); + }); + + it("Should evalute query from parse result correctly", function () { + const parser = new OpaCompileResponseParser(); + parser.parse(JSON.stringify(testDataUnconditionalTrueSimple)); + const result = parser.evaluate(); + expect(parser.hasWarns).to.be.equal(false); + expect(result.isCompleteEvaluated).to.be.equal(true); + expect(result.value).to.be.equal(true); + }); + + it("Should generate correct human readable string", function () { + const parser = new OpaCompileResponseParser(); + parser.parse(JSON.stringify(testDataUnconditionalTrueSimple)); + const result = parser.evaluateAsHumanReadableString(); + expect(parser.hasWarns).to.be.equal(false); + expect(result).to.be.equal("true"); + }); +}); + +describe("Test OpaCompileResultParser with unconditional false (simple response)", function () { + it("Parse sample response with no errors", function () { + const parser = new OpaCompileResponseParser(); + const data = parser.parse( + JSON.stringify(testDataUnconditionalFalseSimple) + ); + expect(parser.hasWarns).to.be.equal(false); + expect(data).to.be.an("array"); + }); + + it("Should evalute query from parse result correctly", function () { + const parser = new OpaCompileResponseParser(); + parser.parse(JSON.stringify(testDataUnconditionalFalseSimple)); + const result = parser.evaluate(); + expect(parser.hasWarns).to.be.equal(false); + expect(result.isCompleteEvaluated).to.be.equal(true); + expect(result.value).to.be.equal(false); + }); + + it("Should generate correct human readable string", function () { + const parser = new OpaCompileResponseParser(); + parser.parse(JSON.stringify(testDataUnconditionalFalseSimple)); + const result = parser.evaluateAsHumanReadableString(); + expect(parser.hasWarns).to.be.equal(false); + expect(result).to.be.equal("false"); + }); +}); + +describe("Test OpaCompileResultParser with datasetPermissionWithOrgUnitConstraint", function () { + it("Parse sample response with no errors", function () { + const parser = new OpaCompileResponseParser(); + const data = parser.parse( + JSON.stringify(testDataDatasetPermissionWithOrgUnitConstraint) + ); + expect(parser.hasWarns).to.be.equal(false); + expect(data).to.be.an("array"); + }); + + it("Should evalute query from parse result correctly", function () { + const parser = new OpaCompileResponseParser(); + parser.parse( + JSON.stringify(testDataDatasetPermissionWithOrgUnitConstraint) + ); + const result = parser.evaluate(); + expect(parser.hasWarns).to.be.equal(false); + expect(result.isCompleteEvaluated).to.be.equal(false); + expect(result.residualRules).to.be.an("array"); + expect(result.residualRules.length).to.be.equal(2); + expect(result.residualRules[0].isCompleteEvaluated).to.be.equal(false); + expect(result.residualRules[0].expressions.length).to.be.equal(2); + expect(result.residualRules[0].expressions[0].terms.length).to.be.equal( + 3 + ); + expect( + result.residualRules[0].expressions[0].toHumanReadableString() + ).to.be.equal('input.object.dataset.publishingState = "published"'); + expect(result.residualRules[0].expressions[1].terms.length).to.be.equal( + 3 + ); + expect( + result.residualRules[0].expressions[1].toHumanReadableString() + ).to.be.equal( + '"5447fcb1-74ec-451c-b6ef-007aa736a346" = input.object.dataset.accessControl.orgUnitOwnerId' + ); + }); + + it("Should generate correct human readable string", function () { + const parser = new OpaCompileResponseParser(); + parser.parse( + JSON.stringify(testDataDatasetPermissionWithOrgUnitConstraint) + ); + const result = parser.evaluateAsHumanReadableString(); + expect(parser.hasWarns).to.be.equal(false); + expect(result).to.be.equal( + `( input.object.dataset.publishingState = "published" AND \n"5447fcb1-74ec-451c-b6ef-007aa736a346" = input.object.dataset.accessControl.orgUnitOwnerId )\nOR\n( input.object.dataset.publishingState = "published" AND \n"b749759e-6e6a-44c0-87ab-4590744187cf" = input.object.dataset.accessControl.orgUnitOwnerId )` + ); + }); +}); diff --git a/magda-web-client/src/config.ts b/magda-web-client/src/config.ts index b0f7072218..411531cbc8 100644 --- a/magda-web-client/src/config.ts +++ b/magda-web-client/src/config.ts @@ -5,8 +5,9 @@ import Temporal from "./Components/Dataset/Search/Facets/Temporal"; import { ValidationFieldList } from "./Components/Dataset/Add/ValidationManager"; import urijs from "urijs"; import removePathPrefix from "helpers/removePathPrefix"; +import { ADMIN_USERS_ROLE_ID } from "@magda/typescript-common/dist/authorization-api/constants"; -export const ADMIN_ROLE_ID = "00000000-0000-0003-0000-000000000000"; +export const ADMIN_ROLE_ID = ADMIN_USERS_ROLE_ID; declare global { interface Window { diff --git a/magda-web-client/src/reducers/userManagementReducer.ts b/magda-web-client/src/reducers/userManagementReducer.ts index f56d03825a..9b4e7847ee 100644 --- a/magda-web-client/src/reducers/userManagementReducer.ts +++ b/magda-web-client/src/reducers/userManagementReducer.ts @@ -1,4 +1,5 @@ import { Action } from "../types"; +import { ANONYMOUS_USERS_ROLE_ID } from "@magda/typescript-common/dist/authorization-api/constants"; export type Role = { id: string; @@ -28,7 +29,7 @@ const defaultUserInfo: User = { isAdmin: false, roles: [ { - id: "00000000-0000-0001-0000-000000000000", + id: ANONYMOUS_USERS_ROLE_ID, name: "Anonymous Users", description: "Default role for unauthenticated users", permissionIds: []