diff --git a/README.md b/README.md index 7b2ddc8..d32bbcc 100644 --- a/README.md +++ b/README.md @@ -33,24 +33,27 @@ docker run -d \ The proxy is configured via environment variables. The following environment variables are supported: -| Name | Description | Default | Note | -| ----------------------------- | ------------------------------------------------------------------------------------ | ----------- | ------------------------------------------ | -| `APP_PORT` | Port to listen on. | `8080` | | -| `APP_BASE_PATH` | Base path fo the proxy. | `/` | | -| `APP_POLICY` | Policy to use, either `builtin` or `custom`. | `builtin` | | -| `APP_POLICY_TYPE` | Type of the builtin policy to use, either `allow_all` or `allow_org_wide`. | `allow_all` | | -| `APP_POLICY_CONFIG` | Config to pass to the policy evaluation. Format: `key=value;key=value` | | | -| `APP_POLICY_PATH` | Path to the custom policy (_.wasm_). | | | -| `APP_GH_AUTH_TYPE` | Type of the GitHub authentication to use, either `app` or `token`. | | **Required** | -| `APP_GH_AUTH_APP_ID` | ID of the GitHub App to use for authentication. (Only for `app` auth type.) | | Required if `APP_GH_AUTH_TYPE` is `app`. | -| `APP_GH_AUTH_APP_PRIVATE_KEY` | Private key of the GitHub App to use for authentication. (Only for `app` auth type.) | | Required if `APP_GH_AUTH_TYPE` is `app`. | -| `APP_GH_AUTH_TOKEN` | Token to use for authentication. (Only for `token` auth type.) | | Required if `APP_GH_AUTH_TYPE` is `token`. | +| Name | Description | Default | Note | +| ----------------------------- | ------------------------------------------------------------------------------------ | ----------- | ---------------------------------------------------- | +| `APP_PORT` | Port to listen on. | `8080` | | +| `APP_BASE_PATH` | Base path fo the proxy. | `/` | | +| `APP_POLICY` | Policy to use, either `builtin`, `opa-wasm` or `cel`. | `builtin` | | +| `APP_POLICY_TYPE` | Type of the builtin policy to use, either `allow_all` or `allow_org_wide`. | `allow_all` | | +| `APP_POLICY_CONFIG` | Config to pass to the policy evaluation. Format: `key=value;key=value` | | | +| `APP_POLICY_PATH` | Path to the opa-wasm policy (_.wasm_ file). | | Only available if `APP_POLICY` is set to `opa-wasm`. | +| `APP_POLICY_EXPRESSION` | Express when using CEL. | | Only available if `APP_POLICY` is set to `cel`. | +| `APP_GH_AUTH_TYPE` | Type of the GitHub authentication to use, either `app` or `token`. | | **Required** | +| `APP_GH_AUTH_APP_ID` | ID of the GitHub App to use for authentication. (Only for `app` auth type.) | | Required if `APP_GH_AUTH_TYPE` is `app`. | +| `APP_GH_AUTH_APP_PRIVATE_KEY` | Private key of the GitHub App to use for authentication. (Only for `app` auth type.) | | Required if `APP_GH_AUTH_TYPE` is `app`. | +| `APP_GH_AUTH_TOKEN` | Token to use for authentication. (Only for `token` auth type.) | | Required if `APP_GH_AUTH_TYPE` is `token`. | ## Policies Policies are used to determine whether a request is allowed or not. -The implementation is based on -the [Open Policy Agent (OPA)](https://www.openpolicyagent.org/). + +This is implemented using [cross-policy](https://github.com/abinnovision/cross-policy). As of right now, +the [opa-wasm target](https://github.com/abinnovision/cross-policy/tree/main/packages/target-opa-wasm) +and [cel target](https://github.com/abinnovision/cross-policy/tree/main/packages/target-cel) is supported. ### Built-in Policies @@ -64,11 +67,11 @@ This policy allows all requests from within the configured organization. It requires the `organization` configuration to be set on the `APP_POLICY_CONFIG` environment variable. -### Custom Policies +### Open Policy Agent (OPA) WASM Policies -Custom policies can be used by setting the `APP_POLICY` environment variable -to `custom` and providing the path to the policy file via `APP_POLICY_PATH`. The -policy file must be a valid WebAssembly file built with +OPA WASM can be used by setting the `APP_POLICY` environment variable +to `opa-wasm` and providing the path to the policy file via `APP_POLICY_PATH`. +The policy file must be a valid WebAssembly file built with [Open Policy Agent (OPA)](https://www.openpolicyagent.org/). To get started, @@ -78,10 +81,30 @@ Also, see the source [code of the built-in policies for examples](./policies). [This script](./policies/build.sh) can be used to build the policy file. It can -be place in the folder with many .rego files and it will build a .wasm file in +be placed in the folder with many .rego files, and it will build a .wasm file in the same folder for each .rego file. -#### Input schema for policies +### Common Expression Language (CEL) Policies + +CEL can be used by setting the `APP_POLICY` environment variable +to `cel` and providing the expression via `APP_POLICY_EXPRESSION`. + +CEL is a language for expressing policies in a way that is straightforward to understand and write. +It is used by Google in many of its services. + +See the [@cross-policy/target-cel](http://npmjs.com/package/@cross-policy/target-cel) package for further details and +possible limitations. + +#### Example configuration + +This example configuration allows only requests from the owner `abinnovision`. + +``` +APP_POLICY=cel +APP_POLICY_EXPRESSION='caller.owner == "abinnovision"' +``` + +### Input schema for policies The input schema for the policy is defined as follows: diff --git a/package.json b/package.json index 66308bc..aaae5d0 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "prettier": "@abinnovision/prettier-config", "dependencies": { "@cross-policy/core": "^0.1.0", + "@cross-policy/target-cel": "^0.1.0", "@cross-policy/target-opa-wasm": "^0.1.0", "@octokit/auth-app": "^7.1.1", "@octokit/auth-callback": "^5.0.1", diff --git a/policies/allow_all.rego b/policies/allow_all.rego deleted file mode 100644 index cf896af..0000000 --- a/policies/allow_all.rego +++ /dev/null @@ -1,4 +0,0 @@ -package main - -# Allow all actions. -allow := true diff --git a/policies/allow_all.wasm b/policies/allow_all.wasm deleted file mode 100644 index df467f0..0000000 Binary files a/policies/allow_all.wasm and /dev/null differ diff --git a/policies/allow_org_wide.rego b/policies/allow_org_wide.rego deleted file mode 100644 index 5957710..0000000 --- a/policies/allow_org_wide.rego +++ /dev/null @@ -1,15 +0,0 @@ -package main -import future.keywords - -# Deny per default. -default allow := false - -allow if { - input.config.organization != null - input.config.organization != "" - - # The target and caller must be in the configure organization. - input.caller.owner == input.config.organization - input.target.owner == input.config.organization -} - diff --git a/policies/allow_org_wide.wasm b/policies/allow_org_wide.wasm deleted file mode 100644 index 167429b..0000000 Binary files a/policies/allow_org_wide.wasm and /dev/null differ diff --git a/src/dispatch/policy.spec.ts b/src/dispatch/policy.spec.ts index 14c1e57..24121f6 100644 --- a/src/dispatch/policy.spec.ts +++ b/src/dispatch/policy.spec.ts @@ -123,4 +123,36 @@ describe("handler/policy", () => { expect(result).toBe(false); }); }); + + describe("target/cel", () => { + it("should throw exception on invalid expression", async () => { + vi.stubEnv("APP_GH_AUTH_TYPE", "token"); + vi.stubEnv("APP_GH_AUTH_TOKEN", "token"); + vi.stubEnv("APP_POLICY", "cel"); + vi.stubEnv("APP_POLICY_EXPRESSION", "this is not a valid expression"); + + const { evaluatePolicyForRequest } = await import("./policy.js"); + + await expect( + evaluatePolicyForRequest({ + target: { + owner: "abinnovison", + repository: "github-workflow-dispatch-proxy", + ref: "main", + workflow: "update-version", + + inputs: { + test: "test", + }, + }, + caller: { + owner: "abinnovison", + repository: "github-workflow-dispatch-proxy", + ref: "main", + workflow: "build", + }, + }), + ).rejects.toThrow(); + }); + }); }); diff --git a/src/dispatch/policy.ts b/src/dispatch/policy.ts index 3255d25..aecc19f 100644 --- a/src/dispatch/policy.ts +++ b/src/dispatch/policy.ts @@ -1,33 +1,40 @@ import { createCrossPolicy } from "@cross-policy/core"; +import { celPolicyTarget } from "@cross-policy/target-cel"; import { opaWasmPolicyTarget } from "@cross-policy/target-opa-wasm"; -import * as path from "path"; import z from "zod"; import { getConfig } from "../utils/config.js"; -// The built-in policies. -const builtInPolicyMapping = { - allow_all: "allow_all.wasm", - allow_org_wide: "allow_org_wide.wasm", +/** + * Built-in expressions written in CEL. + */ +const builtinExpressions = { + allow_all: `true`, + allow_org_wide: `config.organization == caller.owner && config.organization == target.owner`, }; /** - * Provides the policy to use based on the current config. + * Creates the cross-policy compatible target based on the current config. */ -function getPolicyPath(): string { +function createCrossPolicyTarget() { const config = getConfig(); - let policyPath: string; - if (config.POLICY === "custom") { - policyPath = config.POLICY_PATH; + if (config.POLICY === "opa-wasm") { + return opaWasmPolicyTarget({ + policyPath: config.POLICY_PATH, + }); + } else if (config.POLICY === "cel") { + return celPolicyTarget({ + expression: config.POLICY_EXPRESSION, + }); + } else if (config.POLICY === "builtin") { + return celPolicyTarget({ + expression: builtinExpressions[config.POLICY_TYPE], + }); } else { - policyPath = path.join( - process.env.POLICY_DIR as string, - builtInPolicyMapping[config.POLICY_TYPE], - ); + // This should never happen. + throw new Error(`Unsupported policy type`); } - - return policyPath; } const schema = z.object({ @@ -51,7 +58,7 @@ const schema = z.object({ }); const crossPolicy = createCrossPolicy({ - target: opaWasmPolicyTarget({ policyPath: getPolicyPath() }), + target: createCrossPolicyTarget(), schema, }); diff --git a/src/utils/config.ts b/src/utils/config.ts index 344a3ba..a2e3ec2 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -25,10 +25,15 @@ const policySchema = z.discriminatedUnion("POLICY", [ POLICY_CONFIG: z.string().optional(), }), z.object({ - POLICY: z.literal("custom"), + POLICY: z.literal("opa-wasm"), POLICY_PATH: z.string(), POLICY_CONFIG: z.string().optional(), }), + z.object({ + POLICY: z.literal("cel"), + POLICY_EXPRESSION: z.string(), + POLICY_CONFIG: z.string().optional(), + }), ]); const githubAuthSchema = z.discriminatedUnion("GH_AUTH_TYPE", [ diff --git a/yarn.lock b/yarn.lock index ffd2df3..482f46a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -133,6 +133,48 @@ __metadata: languageName: node linkType: hard +"@chevrotain/cst-dts-gen@npm:11.0.3": + version: 11.0.3 + resolution: "@chevrotain/cst-dts-gen@npm:11.0.3" + dependencies: + "@chevrotain/gast": "npm:11.0.3" + "@chevrotain/types": "npm:11.0.3" + lodash-es: "npm:4.17.21" + checksum: 10/601d23fa3312bd0e32816bd3f9ca2dcba775a52192a082fd6c5e4a2e8ee068523401191babbe2c346d6d2551900a67b549f2f74d7ebb7d5b2ee1b6fa3c8857a0 + languageName: node + linkType: hard + +"@chevrotain/gast@npm:11.0.3": + version: 11.0.3 + resolution: "@chevrotain/gast@npm:11.0.3" + dependencies: + "@chevrotain/types": "npm:11.0.3" + lodash-es: "npm:4.17.21" + checksum: 10/7169453a8fbfa994e91995523dea09eab87ab23062ad93f6e51f4a3b03f5e2958e0a8b99d5ca6fa067fccfbbbb8bcf1a4573ace2e1b5a455f6956af9eaccb35a + languageName: node + linkType: hard + +"@chevrotain/regexp-to-ast@npm:11.0.3": + version: 11.0.3 + resolution: "@chevrotain/regexp-to-ast@npm:11.0.3" + checksum: 10/7387a1c61c5a052de41e1172b33eaaedea166fcdb1ffe4c381b86d00051a8014855a031d28fb658768a62c833ef5f5b0689d0c40de3d7bed556f8fea24396e69 + languageName: node + linkType: hard + +"@chevrotain/types@npm:11.0.3": + version: 11.0.3 + resolution: "@chevrotain/types@npm:11.0.3" + checksum: 10/49a82b71d2de8ceb2383ff2709fa61d245f2ab2e42790b70c57102c80846edaa318d0b3645aedc904d23ea7bd9be8a58f2397b1341760a15eb5aa95a1336e2a9 + languageName: node + linkType: hard + +"@chevrotain/utils@npm:11.0.3": + version: 11.0.3 + resolution: "@chevrotain/utils@npm:11.0.3" + checksum: 10/29b5d84373a7761ad055c53e2f540a67b5b56550d5be1c473149f6b8923eef87ff391ce021c06ac7653843b0149f6ff0cf30b5e48c3f825d295eb06a6c517bd3 + languageName: node + linkType: hard + "@commitlint/cli@npm:^19.7.1": version: 19.7.1 resolution: "@commitlint/cli@npm:19.7.1" @@ -334,6 +376,16 @@ __metadata: languageName: node linkType: hard +"@cross-policy/target-cel@npm:^0.1.0": + version: 0.1.0 + resolution: "@cross-policy/target-cel@npm:0.1.0" + dependencies: + "@cross-policy/core": "npm:^0.1.0" + cel-js: "npm:^0.3.0" + checksum: 10/fedc0646ac9867994f4552e66e739205cae60da6222da2e984cadfe73698518ccb21962116daf21ec65363ba846ecc30dff766fe32123aa660081ab9e263edc0 + languageName: node + linkType: hard + "@cross-policy/target-opa-wasm@npm:^0.1.0": version: 0.1.0 resolution: "@cross-policy/target-opa-wasm@npm:0.1.0" @@ -2325,6 +2377,16 @@ __metadata: languageName: node linkType: hard +"cel-js@npm:^0.3.0": + version: 0.3.1 + resolution: "cel-js@npm:0.3.1" + dependencies: + chevrotain: "npm:11.0.3" + ramda: "npm:0.30.0" + checksum: 10/10dee5175fa9e3c6cf1165977fe3c99008f1f163967aaa91854cc549658e572ea182cfc5c5c3272a385c196c82fb6861306e4dd9564dabdb3fd66f7cc6f6d96e + languageName: node + linkType: hard + "chai@npm:^5.1.2": version: 5.2.0 resolution: "chai@npm:5.2.0" @@ -2373,6 +2435,20 @@ __metadata: languageName: node linkType: hard +"chevrotain@npm:11.0.3": + version: 11.0.3 + resolution: "chevrotain@npm:11.0.3" + dependencies: + "@chevrotain/cst-dts-gen": "npm:11.0.3" + "@chevrotain/gast": "npm:11.0.3" + "@chevrotain/regexp-to-ast": "npm:11.0.3" + "@chevrotain/types": "npm:11.0.3" + "@chevrotain/utils": "npm:11.0.3" + lodash-es: "npm:4.17.21" + checksum: 10/8fa6253e51320dd4c3d386315b925734943e509d7954a2cd917746c0604461191bea57b0fb8fbab1903e0508fd94bfd35ebd0f8eace77cd0f3f42a9ee4f8f676 + languageName: node + linkType: hard + "chokidar@npm:^3.5.2": version: 3.6.0 resolution: "chokidar@npm:3.6.0" @@ -3982,6 +4058,7 @@ __metadata: "@abinnovision/prettier-config": "npm:^2.1.3" "@commitlint/cli": "npm:^19.7.1" "@cross-policy/core": "npm:^0.1.0" + "@cross-policy/target-cel": "npm:^0.1.0" "@cross-policy/target-opa-wasm": "npm:^0.1.0" "@octokit/auth-app": "npm:^7.1.1" "@octokit/auth-callback": "npm:^5.0.1" @@ -4902,6 +4979,13 @@ __metadata: languageName: node linkType: hard +"lodash-es@npm:4.17.21": + version: 4.17.21 + resolution: "lodash-es@npm:4.17.21" + checksum: 10/03f39878ea1e42b3199bd3f478150ab723f93cc8730ad86fec1f2804f4a07c6e30deaac73cad53a88e9c3db33348bb8ceeb274552390e7a75d7849021c02df43 + languageName: node + linkType: hard + "lodash.camelcase@npm:^4.3.0": version: 4.3.0 resolution: "lodash.camelcase@npm:4.3.0" @@ -6023,6 +6107,13 @@ __metadata: languageName: node linkType: hard +"ramda@npm:0.30.0": + version: 0.30.0 + resolution: "ramda@npm:0.30.0" + checksum: 10/18112bc9328bbbdc7b1e59ad5e548e7636813defe00446a083720d7fe8247df23b9b049348f7a10edeec0d2be6813fc19427a5cff792f6e1404079f4136d75f8 + languageName: node + linkType: hard + "range-parser@npm:~1.2.1": version: 1.2.1 resolution: "range-parser@npm:1.2.1"