diff --git a/web/src/constants/project.test.ts b/web/src/constants/project.test.ts new file mode 100644 index 0000000000..40b3aeed1f --- /dev/null +++ b/web/src/constants/project.test.ts @@ -0,0 +1,37 @@ +import { POLICIES_STRING_REGEX } from "./project"; + +describe("POLICIES_STRING_REGEX", () => { + it.each([ + "resources=*;actions=*", + "resources=application;actions=get", + "resources=application,piped;actions=get,list", + "resources=application;actions=get,list,create,update,delete", + ])("matches policy without labels: %s", (policy) => { + expect(POLICIES_STRING_REGEX.test(policy)).toBe(true); + }); + + // Regression test for https://github.com/pipe-cd/pipecd/issues/7172 + // Label scoped resources (resources=NAME{key:value}) were rejected by the + // form validation even though parseRBACPolicies/formalizePoliciesList + // already supported them. + it.each([ + "resources=application{env:prod};actions=get", + "resources=application{env:prod,team:foo};actions=get", + "resources=application{env:prod},piped;actions=get", + "resources=application{env:prod},piped{env:prod,team:foo};actions=get,list", + ])("matches policy with label scoped resources: %s", (policy) => { + expect(POLICIES_STRING_REGEX.test(policy)).toBe(true); + }); + + it.each([ + "resources=application{env:prod;actions=get", // unclosed brace + "resources=bogus;actions=get", // unknown resource type + "resources=application;actions=bogus", // unknown action + "resources=application", // missing actions + "resources=application{a=b;c:d};actions=get", // semicolon escapes the label block + "resources=application{};actions=get", // empty label block + "resources=application{env:pr\n\nod};actions=get", // newline injection in label value + ])("does not match invalid policy: %s", (policy) => { + expect(POLICIES_STRING_REGEX.test(policy)).toBe(false); + }); +}); diff --git a/web/src/constants/project.ts b/web/src/constants/project.ts index d299f1a7be..0ec384ed12 100644 --- a/web/src/constants/project.ts +++ b/web/src/constants/project.ts @@ -65,11 +65,21 @@ export const TEXT_TO_RBAC_ACTION_TYPE: Record< delete: ProjectRBACPolicy.Action.DELETE, }; -// example: resources=(\*|application|deployment|event|piped|deploymentChain|project|apiKey|insight|,)+;\s*actions=(\*|get|list|create|update|delete|,)+ +// A single label pair, e.g. "env:prod". Key/value exclude the characters +// that are structurally significant elsewhere in the grammar (`;{}:,`), as +// well as whitespace, so a label block can't accidentally swallow the +// resources/actions separator or use a newline (policies are split on +// blank lines) to escape the label block. +const LABEL_PAIR = "[^;{}:,\\s]+:[^;{}:,\\s]+"; +// A label block requires at least one valid key:value pair; empty ("{}") +// or malformed ("{garbage}") label content is rejected. +const LABEL_BLOCK = `(\\{${LABEL_PAIR}(,${LABEL_PAIR})*\\})?`; + +// example: resources=(\*|application(\{key:value(,key:value)*\})?|deployment(\{key:value(,key:value)*\})?|...|,)+;\s*actions=(\*|get|list|create|update|delete|,)+ export const POLICIES_STRING_REGEX = new RegExp( "resources=(" + rbacResourceTypes() - .map((v) => v.replace(/\*/, "\\*")) + .map((v) => v.replace(/\*/, "\\*") + LABEL_BLOCK) .join("|") + "|,)+;\\s*actions=(" + rbacActionTypes()