From 8d2612c9145e6dff138d5e8b599a3d3695baa9d6 Mon Sep 17 00:00:00 2001 From: Jessica He Date: Mon, 13 Apr 2026 15:11:18 -0400 Subject: [PATCH 1/3] remove rbac-backend Signed-off-by: Jessica He --- build/containerfiles/Containerfile | 2 +- packages/backend/package.json | 1 - packages/backend/src/index.ts | 2 +- .../src/modules/rbacDynamicPluginsModule.ts | 6 +- yarn.lock | 406 +++++++----------- 5 files changed, 164 insertions(+), 253 deletions(-) diff --git a/build/containerfiles/Containerfile b/build/containerfiles/Containerfile index 22b6321b21..142ebded77 100644 --- a/build/containerfiles/Containerfile +++ b/build/containerfiles/Containerfile @@ -323,6 +323,6 @@ COPY --chown=1001:1001 $EXTERNAL_SOURCE_NESTED/packages/backend/src/instrumentat RUN chmod a=r ./instrumentation.js ENV NODE_OPTIONS="--no-node-snapshot" -ENTRYPOINT ["node", "--require", "./instrumentation.js", "packages/backend", "--config", "app-config.yaml", "--config", "app-config.example.yaml", "--config", "app-config.example.production.yaml"] +ENTRYPOINT ["node", "--require", "./instrumentation.js", "packages/backend"] # append Brew metadata here diff --git a/packages/backend/package.json b/packages/backend/package.json index b6157f00a9..5cc7ddac6c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -21,7 +21,6 @@ "prettier:fix": "prettier --ignore-unknown --write ." }, "dependencies": { - "@backstage-community/plugin-rbac-backend": "7.12.5", "@backstage-community/plugin-rbac-node": "1.20.1", "@backstage-community/plugin-scaffolder-backend-module-annotator": "2.16.1", "@backstage/backend-app-api": "1.6.0", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 7f0acf4ca6..c263f26e4a 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -144,10 +144,10 @@ backend.add(import('@backstage/plugin-search-backend-module-catalog')); backend.add(import('@backstage/plugin-events-backend')); backend.add(import('@backstage/plugin-permission-backend')); -backend.add(import('@backstage-community/plugin-rbac-backend')); backend.add( import('@backstage-community/plugin-scaffolder-backend-module-annotator'), ); + backend.add(pluginIDProviderService); backend.add(rbacDynamicPluginsProvider); diff --git a/packages/backend/src/modules/rbacDynamicPluginsModule.ts b/packages/backend/src/modules/rbacDynamicPluginsModule.ts index 23675db4af..4de6259000 100644 --- a/packages/backend/src/modules/rbacDynamicPluginsModule.ts +++ b/packages/backend/src/modules/rbacDynamicPluginsModule.ts @@ -8,8 +8,10 @@ import { createServiceRef, } from '@backstage/backend-plugin-api'; -import { PluginIdProvider } from '@backstage-community/plugin-rbac-backend'; -import { pluginIdProviderExtensionPoint } from '@backstage-community/plugin-rbac-node'; +import { + PluginIdProvider, + pluginIdProviderExtensionPoint, +} from '@backstage-community/plugin-rbac-node'; const pluginIDProviderServiceRef = createServiceRef({ id: 'pluginIDProvider', diff --git a/yarn.lock b/yarn.lock index 0de1c4209c..c7a47284b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1527,7 +1527,18 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.15, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.26.2, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.26.2, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/parser@npm:7.29.0" + dependencies: + "@babel/types": "npm:^7.29.0" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/333b2aa761264b91577a74bee86141ef733f9f9f6d4fc52548e4847dc35dfbf821f58c46832c637bfa761a6d9909d6a68f7d1ed59e17e4ffbb958dc510c17b62 + languageName: node + linkType: hard + +"@babel/parser@npm:^7.20.15": version: 7.29.2 resolution: "@babel/parser@npm:7.29.2" dependencies: @@ -2668,35 +2679,6 @@ __metadata: languageName: node linkType: hard -"@backstage-community/plugin-rbac-backend@npm:7.12.5": - version: 7.12.5 - resolution: "@backstage-community/plugin-rbac-backend@npm:7.12.5" - dependencies: - "@backstage-community/plugin-rbac-common": "npm:^1.26.1" - "@backstage-community/plugin-rbac-node": "npm:^1.20.1" - "@backstage/backend-defaults": "npm:^0.16.0" - "@backstage/backend-plugin-api": "npm:^1.8.0" - "@backstage/catalog-client": "npm:^1.14.0" - "@backstage/catalog-model": "npm:^1.7.7" - "@backstage/config": "npm:^1.3.6" - "@backstage/errors": "npm:^1.2.7" - "@backstage/plugin-permission-common": "npm:^0.9.7" - "@backstage/plugin-permission-node": "npm:^0.10.11" - "@dagrejs/graphlib": "npm:^4.0.0" - casbin: "npm:^5.27.1" - chokidar: "npm:^3.6.0" - csv-parse: "npm:^6.0.0" - express: "npm:^4.18.2" - express-promise-router: "npm:^4.1.0" - js-yaml: "npm:^4.1.0" - knex: "npm:^3.0.0" - lodash: "npm:^4.17.21" - typeorm-adapter: "npm:^1.6.1" - zod: "npm:^4.3.6" - checksum: 10c0/ee2b60a75568241d808fbc8f79e0c823bc832bf57ac01b0364c8056a0107b8b62f69c9321abf88cb7e8a1d2ccf13065f392f205dbb662fb5ff151f6070e7710c - languageName: node - linkType: hard - "@backstage-community/plugin-rbac-common@npm:1.26.1, @backstage-community/plugin-rbac-common@npm:^1.26.1": version: 1.26.1 resolution: "@backstage-community/plugin-rbac-common@npm:1.26.1" @@ -2707,7 +2689,7 @@ __metadata: languageName: node linkType: hard -"@backstage-community/plugin-rbac-node@npm:1.20.1, @backstage-community/plugin-rbac-node@npm:^1.20.1": +"@backstage-community/plugin-rbac-node@npm:1.20.1": version: 1.20.1 resolution: "@backstage-community/plugin-rbac-node@npm:1.20.1" dependencies: @@ -5974,15 +5956,6 @@ __metadata: languageName: node linkType: hard -"@casbin/expression-eval@npm:^5.3.0": - version: 5.3.0 - resolution: "@casbin/expression-eval@npm:5.3.0" - dependencies: - jsep: "npm:^0.3.0" - checksum: 10c0/1fa2fd703036b065821fbeb8d0f0c274ba50331737d19b3a77b7c9cd571f5df2580145bda1d90f2dd46863a66aae9f5256974eb168b7ccbb9facbcb796f5cb7a - languageName: node - linkType: hard - "@changesets/types@npm:^4.0.1": version: 4.1.0 resolution: "@changesets/types@npm:4.1.0" @@ -6188,13 +6161,6 @@ __metadata: languageName: node linkType: hard -"@dagrejs/graphlib@npm:^4.0.0": - version: 4.0.1 - resolution: "@dagrejs/graphlib@npm:4.0.1" - checksum: 10c0/03ab574f2eb7d87173af0b9d8bbae87c10e225778b8144a800c663afe307ff71d851c13d96d32ec91db85e325d64914cdabbab1ce76fb043e0a5538e60bb51bd - languageName: node - linkType: hard - "@date-io/core@npm:1.x, @date-io/core@npm:^1.3.13": version: 1.3.13 resolution: "@date-io/core@npm:1.3.13" @@ -10041,6 +10007,17 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/core@npm:2.5.1": + version: 2.5.1 + resolution: "@opentelemetry/core@npm:2.5.1" + dependencies: + "@opentelemetry/semantic-conventions": "npm:^1.29.0" + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: 10c0/cbaf36953364d1295ef2ff4587c3f99eca121c7c2dbd2553699100ccbd91017f20fb1a710ac76fad832d9762dc98ae009ce0e96ab8fb00e5b539dc401d57f217 + languageName: node + linkType: hard + "@opentelemetry/core@npm:2.7.1, @opentelemetry/core@npm:^2.0.0": version: 2.7.1 resolution: "@opentelemetry/core@npm:2.7.1" @@ -10908,7 +10885,7 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/resources@npm:2.7.1, @opentelemetry/resources@npm:^2.0.0": +"@opentelemetry/resources@npm:2.7.1": version: 2.7.1 resolution: "@opentelemetry/resources@npm:2.7.1" dependencies: @@ -10920,6 +10897,18 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/resources@npm:^2.0.0": + version: 2.5.1 + resolution: "@opentelemetry/resources@npm:2.5.1" + dependencies: + "@opentelemetry/core": "npm:2.5.1" + "@opentelemetry/semantic-conventions": "npm:^1.29.0" + peerDependencies: + "@opentelemetry/api": ">=1.3.0 <1.10.0" + checksum: 10c0/c336d5066fa7457272bcffb5a9826f090e1e07c2a70c5976942cf2bb188be685842658982a0f323ddfc1d6fbc364f123b6b0e433e230b023aefd88ec60062ba4 + languageName: node + linkType: hard + "@opentelemetry/sdk-logs@npm:0.218.0": version: 0.218.0 resolution: "@opentelemetry/sdk-logs@npm:0.218.0" @@ -11007,13 +10996,20 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/semantic-conventions@npm:^1.24.0, @opentelemetry/semantic-conventions@npm:^1.27.0, @opentelemetry/semantic-conventions@npm:^1.29.0, @opentelemetry/semantic-conventions@npm:^1.30.0, @opentelemetry/semantic-conventions@npm:^1.33.0, @opentelemetry/semantic-conventions@npm:^1.33.1, @opentelemetry/semantic-conventions@npm:^1.34.0, @opentelemetry/semantic-conventions@npm:^1.36.0, @opentelemetry/semantic-conventions@npm:^1.37.0": +"@opentelemetry/semantic-conventions@npm:^1.24.0, @opentelemetry/semantic-conventions@npm:^1.33.0, @opentelemetry/semantic-conventions@npm:^1.33.1, @opentelemetry/semantic-conventions@npm:^1.34.0, @opentelemetry/semantic-conventions@npm:^1.36.0, @opentelemetry/semantic-conventions@npm:^1.37.0": version: 1.41.1 resolution: "@opentelemetry/semantic-conventions@npm:1.41.1" checksum: 10c0/c54b1edf845766e93026d30fd95e15da9dba8d7a5b58f8c320c5d36ab542c77b37868f3e8e3d78ec162da8ee2afd24781f0a65934c9bdbc1aea86b47b12f074c languageName: node linkType: hard +"@opentelemetry/semantic-conventions@npm:^1.27.0, @opentelemetry/semantic-conventions@npm:^1.29.0, @opentelemetry/semantic-conventions@npm:^1.30.0": + version: 1.40.0 + resolution: "@opentelemetry/semantic-conventions@npm:1.40.0" + checksum: 10c0/3259de0ea11b52eb70e44c12eba21448392baf9cb74c37b62071c4a5ed7fb89b61e194f3898d40ac6bfa7293617a0e132876cb6e355472b66de0cdb13c50b529 + languageName: node + linkType: hard + "@opentelemetry/sql-common@npm:^0.41.2": version: 0.41.2 resolution: "@opentelemetry/sql-common@npm:0.41.2" @@ -12007,7 +12003,7 @@ __metadata: languageName: node linkType: hard -"@react-aria/button@npm:^3.14.3, @react-aria/button@npm:^3.14.4": +"@react-aria/button@npm:^3.14.3": version: 3.14.5 resolution: "@react-aria/button@npm:3.14.5" dependencies: @@ -12025,6 +12021,24 @@ __metadata: languageName: node linkType: hard +"@react-aria/button@npm:^3.14.4": + version: 3.14.4 + resolution: "@react-aria/button@npm:3.14.4" + dependencies: + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/toolbar": "npm:3.0.0-beta.23" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/toggle": "npm:^3.9.4" + "@react-types/button": "npm:^3.15.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/99b640d9d50478c36c57eb0be05ad6dc86f9ac0f80501c5d73c1ae316d958ed07bfb4ec8b61fd31093fb6b62491df5422e3bff229eb86be1e43dcda7cdd41350 + languageName: node + linkType: hard + "@react-aria/calendar@npm:^3.9.4": version: 3.9.4 resolution: "@react-aria/calendar@npm:3.9.4" @@ -12346,7 +12360,7 @@ __metadata: languageName: node linkType: hard -"@react-aria/landmark@npm:^3.0.10, @react-aria/landmark@npm:^3.0.9": +"@react-aria/landmark@npm:^3.0.10": version: 3.0.10 resolution: "@react-aria/landmark@npm:3.0.10" dependencies: @@ -12361,6 +12375,21 @@ __metadata: languageName: node linkType: hard +"@react-aria/landmark@npm:^3.0.9": + version: 3.0.9 + resolution: "@react-aria/landmark@npm:3.0.9" + dependencies: + "@react-aria/utils": "npm:^3.33.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + use-sync-external-store: "npm:^1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/e430a5cf7517d6a674ea6ac8a76d55cede471f4991385a1acae70771b136fb21636fb28cca2e5dfe4d362a557eb9abc9200421fb2749f3cf5ed51980a06ad744 + languageName: node + linkType: hard + "@react-aria/link@npm:^3.8.8": version: 3.8.8 resolution: "@react-aria/link@npm:3.8.8" @@ -15080,13 +15109,6 @@ __metadata: languageName: node linkType: hard -"@sqltools/formatter@npm:^1.2.5": - version: 1.2.5 - resolution: "@sqltools/formatter@npm:1.2.5" - checksum: 10c0/4b4fa62b8cd4880784b71cc5edd4a13da04fda0a915c14282765a8ec1a900a495e69b322704413e2052d221b5646d9fb0e20e87911f9a8f438f33180eecb11a4 - languageName: node - linkType: hard - "@standard-schema/spec@npm:^1.0.0, @standard-schema/spec@npm:^1.1.0": version: 1.1.0 resolution: "@standard-schema/spec@npm:1.1.0" @@ -18597,13 +18619,6 @@ __metadata: languageName: node linkType: hard -"ansis@npm:^4.2.0": - version: 4.2.0 - resolution: "ansis@npm:4.2.0" - checksum: 10c0/cd6a7a681ecd36e72e0d79c1e34f1f3bcb1b15bcbb6f0f8969b4228062d3bfebbef468e09771b00d93b2294370b34f707599d4a113542a876de26823b795b5d2 - languageName: node - linkType: hard - "any-promise@npm:^1.0.0": version: 1.3.0 resolution: "any-promise@npm:1.3.0" @@ -18689,13 +18704,6 @@ __metadata: languageName: unknown linkType: soft -"app-root-path@npm:^3.1.0": - version: 3.1.0 - resolution: "app-root-path@npm:3.1.0" - checksum: 10c0/4a0fd976de1bffcdb18a5e1f8050091f15d0780e0582bca99aaa9d52de71f0e08e5185355fcffc781180bfb898499e787a2f5ed79b9c448b942b31dc947acaa9 - languageName: node - linkType: hard - "app@workspace:*, app@workspace:packages/app": version: 0.0.0-use.local resolution: "app@workspace:packages/app" @@ -19159,13 +19167,6 @@ __metadata: languageName: node linkType: hard -"await-lock@npm:^2.0.1": - version: 2.2.2 - resolution: "await-lock@npm:2.2.2" - checksum: 10c0/bedf00dad44c6325a655bf3bd523ab9e1ce41023da6a8c379990c76ac1d942ac7e5301627ab84ba37917ab5247506ba429b7f6e4bf77074093f255571b9ad5ee - languageName: node - linkType: hard - "aws-ssl-profiles@npm:^1.1.2": version: 1.1.2 resolution: "aws-ssl-profiles@npm:1.1.2" @@ -19180,15 +19181,25 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.12.0, axios@npm:^1.12.2, axios@npm:^1.7.3, axios@npm:^1.7.4": - version: 1.16.1 - resolution: "axios@npm:1.16.1" +"axios@npm:^1.12.0, axios@npm:^1.12.2, axios@npm:^1.7.4": + version: 1.13.5 + resolution: "axios@npm:1.13.5" dependencies: - follow-redirects: "npm:^1.16.0" + follow-redirects: "npm:^1.15.11" form-data: "npm:^4.0.5" - https-proxy-agent: "npm:^5.0.1" - proxy-from-env: "npm:^2.1.0" - checksum: 10c0/2f77e37e6552bbff8a772d058fb09500198e9188c6b20dc799d82dbe12a8cb506f6eed4e4e62a9ba612a35cbab496faa26d68f9bff14a53af6d15c3e136391a7 + proxy-from-env: "npm:^1.1.0" + checksum: 10c0/abf468c34f2d145f3dc7dbc0f1be67e520630624307bda69a41bbe8d386bd672d87b4405c4ee77f9ff54b235ab02f96a9968fb00e75b13ce64706e352a3068fd + languageName: node + linkType: hard + +"axios@npm:^1.7.3": + version: 1.13.6 + resolution: "axios@npm:1.13.6" + dependencies: + follow-redirects: "npm:^1.15.11" + form-data: "npm:^4.0.5" + proxy-from-env: "npm:^1.1.0" + checksum: 10c0/51fb5af055c3b85662fa97df17d986ae2c37d13bf86d50b6bb36b6b3a2dec6966a1d3a14ab3774b71707b155ae3597ed9b7babdf1a1a863d1a31840cb8e7ec71 languageName: node linkType: hard @@ -19338,7 +19349,6 @@ __metadata: version: 0.0.0-use.local resolution: "backend@workspace:packages/backend" dependencies: - "@backstage-community/plugin-rbac-backend": "npm:7.12.5" "@backstage-community/plugin-rbac-node": "npm:1.20.1" "@backstage-community/plugin-scaffolder-backend-module-annotator": "npm:2.16.1" "@backstage/backend-app-api": "npm:1.6.0" @@ -20204,19 +20214,6 @@ __metadata: languageName: node linkType: hard -"casbin@npm:^5.27.0, casbin@npm:^5.27.1": - version: 5.49.0 - resolution: "casbin@npm:5.49.0" - dependencies: - "@casbin/expression-eval": "npm:^5.3.0" - await-lock: "npm:^2.0.1" - buffer: "npm:^6.0.3" - csv-parse: "npm:^5.5.6" - minimatch: "npm:^10.2.1" - checksum: 10c0/100e9370e7672c57561ebfb444db7d557ebf09abb96e2262735a2543e60059d74f7e159fc88de0558cf03ac1c7ab9422d7d59e33feb8e0aeb68ebd63b907f8ea - languageName: node - linkType: hard - "catharsis@npm:^0.9.0": version: 0.9.0 resolution: "catharsis@npm:0.9.0" @@ -21624,20 +21621,6 @@ __metadata: languageName: node linkType: hard -"csv-parse@npm:^5.5.6": - version: 5.6.0 - resolution: "csv-parse@npm:5.6.0" - checksum: 10c0/52f5e6c45359902e0c8e57fc2eeed41366dc6b6d283b495b538dd50c8e8510413d6f924096ea056319cbbb8ed26e111c3a3485d7985c021bcf5abaa9e92425c7 - languageName: node - linkType: hard - -"csv-parse@npm:^6.0.0": - version: 6.2.1 - resolution: "csv-parse@npm:6.2.1" - checksum: 10c0/8b6f14b244ca62476d4217aac721131ba0ada3e4ed7614e43ebc99203807564dcb054144d1de4ef22ee8b0c63b431640f75d46a0e1e0f72853a954b279ba1c61 - languageName: node - linkType: hard - "ctrlc-windows@npm:^2.1.0": version: 2.2.0 resolution: "ctrlc-windows@npm:2.2.0" @@ -21840,13 +21823,6 @@ __metadata: languageName: node linkType: hard -"dayjs@npm:^1.11.19": - version: 1.11.19 - resolution: "dayjs@npm:1.11.19" - checksum: 10c0/7d8a6074a343f821f81ea284d700bd34ea6c7abbe8d93bce7aba818948957c1b7f56131702e5e890a5622cdfc05dcebe8aed0b8313bdc6838a594d7846b0b000 - languageName: node - linkType: hard - "debounce-promise@npm:^3.1.2": version: 3.1.2 resolution: "debounce-promise@npm:3.1.2" @@ -21928,7 +21904,7 @@ __metadata: languageName: node linkType: hard -"dedent@npm:^1.6.0, dedent@npm:^1.7.0": +"dedent@npm:^1.6.0": version: 1.7.1 resolution: "dedent@npm:1.7.1" peerDependencies: @@ -22460,13 +22436,6 @@ __metadata: languageName: node linkType: hard -"dotenv@npm:^16.6.1": - version: 16.6.1 - resolution: "dotenv@npm:16.6.1" - checksum: 10c0/15ce56608326ea0d1d9414a5c8ee6dcf0fffc79d2c16422b4ac2268e7e2d76ff5a572d37ffe747c377de12005f14b3cc22361e79fc7f1061cce81f77d2c973dc - languageName: node - linkType: hard - "drange@npm:^1.0.2": version: 1.1.1 resolution: "drange@npm:1.1.1" @@ -23935,7 +23904,7 @@ __metadata: languageName: node linkType: hard -"express@npm:4.22.1, express@npm:^4.14.0, express@npm:^4.17.3, express@npm:^4.18.2, express@npm:^4.22.0, express@npm:^4.22.1": +"express@npm:4.22.1, express@npm:^4.14.0, express@npm:^4.17.3, express@npm:^4.22.0, express@npm:^4.22.1": version: 4.22.1 resolution: "express@npm:4.22.1" dependencies: @@ -24132,6 +24101,13 @@ __metadata: languageName: node linkType: hard +"fast-xml-builder@npm:^1.0.0": + version: 1.0.0 + resolution: "fast-xml-builder@npm:1.0.0" + checksum: 10c0/2631fda265c81e8008884d08944eeed4e284430116faa5b8b7a43a3602af367223b7bf01c933215c9ad2358b8666e45041bc038d64877156a2f88821841b3014 + languageName: node + linkType: hard + "fast-xml-builder@npm:^1.1.5": version: 1.1.5 resolution: "fast-xml-builder@npm:1.1.5" @@ -24141,7 +24117,7 @@ __metadata: languageName: node linkType: hard -"fast-xml-parser@npm:5.7.1, fast-xml-parser@npm:^5.0.7, fast-xml-parser@npm:^5.3.4": +"fast-xml-parser@npm:5.7.1": version: 5.7.1 resolution: "fast-xml-parser@npm:5.7.1" dependencies: @@ -24155,6 +24131,18 @@ __metadata: languageName: node linkType: hard +"fast-xml-parser@npm:^5.0.7, fast-xml-parser@npm:^5.3.4": + version: 5.4.1 + resolution: "fast-xml-parser@npm:5.4.1" + dependencies: + fast-xml-builder: "npm:^1.0.0" + strnum: "npm:^2.1.2" + bin: + fxparser: src/cli/cli.js + checksum: 10c0/8c696438a0c64135faf93ea6a93879208d649b7c9a3293d30d6eb750dc7f766fd083c0df5a82786b60809c3ead64fad155f28dbed25efea91017aaf9f64c91e5 + languageName: node + linkType: hard + "fastest-stable-stringify@npm:^2.0.2": version: 2.0.2 resolution: "fastest-stable-stringify@npm:2.0.2" @@ -24450,7 +24438,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.16.0": +"follow-redirects@npm:^1.0.0": version: 1.16.0 resolution: "follow-redirects@npm:1.16.0" peerDependenciesMeta: @@ -24460,6 +24448,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.11": + version: 1.15.11 + resolution: "follow-redirects@npm:1.15.11" + peerDependenciesMeta: + debug: + optional: true + checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343 + languageName: node + linkType: hard + "for-each@npm:^0.3.3, for-each@npm:^0.3.5": version: 0.3.5 resolution: "for-each@npm:0.3.5" @@ -28099,13 +28097,6 @@ __metadata: languageName: node linkType: hard -"jsep@npm:^0.3.0": - version: 0.3.5 - resolution: "jsep@npm:0.3.5" - checksum: 10c0/fb5def7a4ba1cee41d144ebdd0d477785dc84b6bc1fed6cf5169f106de980dbe363bf99cb36a450435d7fd952d22b1d76e1609aeb5c7e7cbbbdb6d15fad03614 - languageName: node - linkType: hard - "jsep@npm:^1.2.0, jsep@npm:^1.4.0": version: 1.4.0 resolution: "jsep@npm:1.4.0" @@ -33287,10 +33278,10 @@ __metadata: languageName: node linkType: hard -"proxy-from-env@npm:^2.1.0": - version: 2.1.0 - resolution: "proxy-from-env@npm:2.1.0" - checksum: 10c0/ed01729fd4d094eab619cd7e17ce3698b3413b31eb102c4904f9875e677cd207392795d5b4adee9cec359dfd31c44d5ad7595a3a3ad51c40250e141512281c58 +"proxy-from-env@npm:^1.1.0": + version: 1.1.0 + resolution: "proxy-from-env@npm:1.1.0" + checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b languageName: node linkType: hard @@ -34521,13 +34512,6 @@ __metadata: languageName: node linkType: hard -"reflect-metadata@npm:^0.1.13": - version: 0.1.14 - resolution: "reflect-metadata@npm:0.1.14" - checksum: 10c0/3a6190c7f6cb224f26a012d11f9e329360c01c1945e2cbefea23976a8bacf9db6b794aeb5bf18adcb673c448a234fbc06fc41853c00a6c206b30f0777ecf019e - languageName: node - linkType: hard - "reflect-metadata@npm:^0.2.2": version: 0.2.2 resolution: "reflect-metadata@npm:0.2.2" @@ -36221,13 +36205,6 @@ __metadata: languageName: node linkType: hard -"sql-highlight@npm:^6.1.0": - version: 6.1.0 - resolution: "sql-highlight@npm:6.1.0" - checksum: 10c0/9614f4608bfde8ea7bf9b2fe9233dcc99a619c91cbc3f5cd85a6fb5ad4b2177f4ac8ca4a0191f4243ff8aea3b6f2a1229efc88635298269e0049b2ac08bde263 - languageName: node - linkType: hard - "ssh-remote-port-forward@npm:^1.0.4": version: 1.0.4 resolution: "ssh-remote-port-forward@npm:1.0.4" @@ -36733,6 +36710,13 @@ __metadata: languageName: node linkType: hard +"strnum@npm:^2.1.2": + version: 2.1.2 + resolution: "strnum@npm:2.1.2" + checksum: 10c0/4e04753b793540d79cd13b2c3e59e298440477bae2b853ab78d548138385193b37d766d95b63b7046475d68d44fb1fca692f0a3f72b03f4168af076c7b246df9 + languageName: node + linkType: hard + "strnum@npm:^2.2.3": version: 2.2.3 resolution: "strnum@npm:2.2.3" @@ -38027,94 +38011,6 @@ __metadata: languageName: node linkType: hard -"typeorm-adapter@npm:^1.6.1": - version: 1.9.0 - resolution: "typeorm-adapter@npm:1.9.0" - dependencies: - casbin: "npm:^5.27.0" - reflect-metadata: "npm:^0.1.13" - typeorm: "npm:^0.3.17" - checksum: 10c0/13a8cfdad81b0b262c2b38ca83bece96e4ca6c703a40ce1594b186b08e2d8afa8614af864c24b809a4f8b14df04f213fa322303dfaa8ea11401215fa9bd33f85 - languageName: node - linkType: hard - -"typeorm@npm:^0.3.17": - version: 0.3.28 - resolution: "typeorm@npm:0.3.28" - dependencies: - "@sqltools/formatter": "npm:^1.2.5" - ansis: "npm:^4.2.0" - app-root-path: "npm:^3.1.0" - buffer: "npm:^6.0.3" - dayjs: "npm:^1.11.19" - debug: "npm:^4.4.3" - dedent: "npm:^1.7.0" - dotenv: "npm:^16.6.1" - glob: "npm:^10.5.0" - reflect-metadata: "npm:^0.2.2" - sha.js: "npm:^2.4.12" - sql-highlight: "npm:^6.1.0" - tslib: "npm:^2.8.1" - uuid: "npm:^11.1.0" - yargs: "npm:^17.7.2" - peerDependencies: - "@google-cloud/spanner": ^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - "@sap/hana-client": ^2.14.22 - better-sqlite3: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 - ioredis: ^5.0.4 - mongodb: ^5.8.0 || ^6.0.0 - mssql: ^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0 - mysql2: ^2.2.5 || ^3.0.1 - oracledb: ^6.3.0 - pg: ^8.5.1 - pg-native: ^3.0.0 - pg-query-stream: ^4.0.0 - redis: ^3.1.1 || ^4.0.0 || ^5.0.14 - sql.js: ^1.4.0 - sqlite3: ^5.0.3 - ts-node: ^10.7.0 - typeorm-aurora-data-api-driver: ^2.0.0 || ^3.0.0 - peerDependenciesMeta: - "@google-cloud/spanner": - optional: true - "@sap/hana-client": - optional: true - better-sqlite3: - optional: true - ioredis: - optional: true - mongodb: - optional: true - mssql: - optional: true - mysql2: - optional: true - oracledb: - optional: true - pg: - optional: true - pg-native: - optional: true - pg-query-stream: - optional: true - redis: - optional: true - sql.js: - optional: true - sqlite3: - optional: true - ts-node: - optional: true - typeorm-aurora-data-api-driver: - optional: true - bin: - typeorm: cli.js - typeorm-ts-node-commonjs: cli-ts-node-commonjs.js - typeorm-ts-node-esm: cli-ts-node-esm.js - checksum: 10c0/b850b2f76ed576f9eae3deb39617466c527572328cb2727cb962d263822aabf289b52fe3f070d779e9cde5c164eed7486e73d77aef91e69a919b21e59a2e6122 - languageName: node - linkType: hard - "types-ramda@npm:^0.30.1": version: 0.30.1 resolution: "types-ramda@npm:0.30.1" @@ -38321,13 +38217,27 @@ __metadata: languageName: node linkType: hard -"undici@npm:7.25.0, undici@npm:^7.1.1, undici@npm:^7.16.0, undici@npm:^7.2.3, undici@npm:^7.21.0, undici@npm:^7.22.0": +"undici@npm:7.25.0, undici@npm:^7.1.1": version: 7.25.0 resolution: "undici@npm:7.25.0" checksum: 10c0/02a0b45dc14eb91bc488948750232450fe52f27a6b08086d6ac6736bb47908d600fe3a96d346f12eab24729c782e5c2f693bc8e8eca6696d4e4c09b1ed4cb4ec languageName: node linkType: hard +"undici@npm:^7.16.0": + version: 7.24.6 + resolution: "undici@npm:7.24.6" + checksum: 10c0/0f5413ccb20bafe27637a3a02cada731c53ee75f1df79029099db3af1eaaed410488489d9f430c09bd30bf0b925cb75fc30c39dff0689f656fd6fb7d75ded95f + languageName: node + linkType: hard + +"undici@npm:^7.2.3, undici@npm:^7.21.0, undici@npm:^7.22.0": + version: 7.22.0 + resolution: "undici@npm:7.22.0" + checksum: 10c0/09777c06f3f18f761f03e3a4c9c04fd9fcca8ad02ccea43602ee4adf73fcba082806f1afb637f6ea714ef6279c5323c25b16d435814c63db720f63bfc20d316b + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.1 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.1" @@ -38842,7 +38752,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^11.0.0, uuid@npm:^11.0.2, uuid@npm:^11.1.0": +"uuid@npm:^11.0.0, uuid@npm:^11.0.2": version: 11.1.0 resolution: "uuid@npm:11.1.0" bin: @@ -39972,7 +39882,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:4.3.6, zod@npm:^4.1.13, zod@npm:^4.3.6": +"zod@npm:4.3.6, zod@npm:^4.1.13": version: 4.3.6 resolution: "zod@npm:4.3.6" checksum: 10c0/860d25a81ab41d33aa25f8d0d07b091a04acb426e605f396227a796e9e800c44723ed96d0f53a512b57be3d1520f45bf69c0cb3b378a232a00787a2609625307 From 263a466e97cc20cc5a49480198c5f9818a08035c Mon Sep 17 00:00:00 2001 From: Jessica He Date: Tue, 26 May 2026 15:23:22 -0400 Subject: [PATCH 2/3] add as backend internal plugin Signed-off-by: Jessica He --- package.json | 1 + packages/backend/package.json | 1 + packages/backend/src/index.ts | 1 + packages/backend/tsconfig.json | 3 +- plugins/rbac-backend/.eslintignore | 4 + plugins/rbac-backend/.eslintrc.js | 25 + plugins/rbac-backend/.prettierignore | 14 + plugins/rbac-backend/.prettierrc.js | 34 + plugins/rbac-backend/CHANGELOG.md | 1171 +++++ plugins/rbac-backend/README.md | 364 ++ .../__fixtures__/auditor-test-utils.ts | 76 + .../__fixtures__/data/hierarchy/groups.ts | 893 ++++ .../data/hierarchy/rbac-policy.csv | 245 + .../__fixtures__/data/hierarchy/users.ts | 357 ++ .../bad-conditions-yaml.yaml | 1 + .../data/invalid-conditions/invalid-yaml.yaml | 1 + .../data/invalid-csv/deprecated-policy.csv | 1 + .../data/invalid-csv/duplicate-policy.csv | 17 + .../data/invalid-csv/error-policy.csv | 10 + .../data/valid-conditions/conditions.yaml | 28 + .../valid-conditions/empty-conditions.yaml | 0 .../extra-delimiter-conditions.yaml | 30 + .../valid-csv/basic-and-resource-policies.csv | 21 + .../data/valid-csv/policy-checks.csv | 67 + .../data/valid-csv/rbac-policy.csv | 17 + .../data/valid-csv/simple-policy.csv | 2 + .../data/valid-csv/uppercase-policy.csv | 7 + .../rbac-backend/__fixtures__/mock-utils.ts | 199 + .../rbac-backend/__fixtures__/test-utils.ts | 236 + plugins/rbac-backend/catalog-info.yaml | 28 + plugins/rbac-backend/config.d.ts | 99 + plugins/rbac-backend/docs/apis.md | 986 ++++ plugins/rbac-backend/docs/audit-log.md | 252 + plugins/rbac-backend/docs/conditions.md | 388 ++ plugins/rbac-backend/docs/group-hierarchy.md | 236 + .../docs/images/group-hierarchy-1.svg | 1 + .../docs/images/group-hierarchy-2.svg | 1 + .../docs/images/group-hierarchy-3.svg | 1 + .../docs/images/group-hierarchy-4.svg | 1 + plugins/rbac-backend/docs/multitenancy.md | 176 + plugins/rbac-backend/docs/permissions.md | 153 + plugins/rbac-backend/docs/providers.md | 350 ++ plugins/rbac-backend/knexfile.js | 28 + plugins/rbac-backend/knip-report.md | 2 + .../migrations/20231015161232_migrations.js | 41 + .../migrations/20231212224526_migrations.js | 84 + .../migrations/20231221113214_migrations.js | 60 + .../migrations/20240201144429_migrations.js | 37 + .../migrations/20240215154456_migrations.js | 143 + .../migrations/20240308134410_migrations.js | 31 + .../migrations/20240308134941_migrations.js | 43 + .../migrations/20240404111242_migrations.js | 53 + .../migrations/20240611092136_migrations.js | 29 + .../migrations/20241108093910_migrations.js | 35 + .../migrations/20250305155143_migration.js | 73 + .../migrations/20250509110032_migrations.js | 29 + ...6100000_add_is_default_to_role_metadata.js | 43 + plugins/rbac-backend/openapi.yaml | 760 +++ plugins/rbac-backend/package.json | 102 + plugins/rbac-backend/report.api.md | 76 + .../admin-permissions/admin-creation.test.ts | 211 + .../src/admin-permissions/admin-creation.ts | 212 + plugins/rbac-backend/src/auditor/auditor.ts | 111 + .../src/auditor/rest-interceptor.ts | 189 + .../alias-resolver.test.ts | 648 +++ .../src/conditional-aliases/alias-resolver.ts | 123 + .../database/casbin-adapter-factory.test.ts | 612 +++ .../src/database/casbin-adapter-factory.ts | 228 + .../src/database/conditional-storage.test.ts | 669 +++ .../src/database/conditional-storage.ts | 298 ++ ...permission-enabled-plugins-storage.test.ts | 92 + ...xtra-permission-enabled-plugins-storage.ts | 58 + .../rbac-backend/src/database/migration.ts | 34 + .../src/database/role-metadata.test.ts | 965 ++++ .../src/database/role-metadata.ts | 261 + .../default-permissions.test.ts | 561 +++ .../default-permissions.ts | 181 + .../file-permissions/csv-file-watcher.test.ts | 845 ++++ .../src/file-permissions/csv-file-watcher.ts | 622 +++ .../src/file-permissions/file-watcher.ts | 76 + .../lowercase-file-adapter.ts | 55 + .../yaml-conditional-file-watcher.test.ts | 629 +++ .../yaml-conditional-file-watcher.ts | 267 ++ plugins/rbac-backend/src/helper.test.ts | 827 ++++ plugins/rbac-backend/src/helper.ts | 354 ++ plugins/rbac-backend/src/index.ts | 23 + .../src/permissions/conditions.ts | 54 + plugins/rbac-backend/src/permissions/index.ts | 17 + .../rbac-backend/src/permissions/resource.ts | 32 + plugins/rbac-backend/src/permissions/rules.ts | 94 + plugins/rbac-backend/src/plugin.ts | 123 + .../src/policies/allow-all-policy.test.ts | 82 + .../src/policies/allow-all-policy.ts | 33 + .../permission-policy.hierarchy.test.ts | 1123 +++++ .../src/policies/permission-policy.test.ts | 2499 ++++++++++ .../src/policies/permission-policy.ts | 384 ++ .../src/providers/connect-providers.test.ts | 851 ++++ .../src/providers/connect-providers.ts | 438 ++ .../role-manager/ancestor-search-factory.ts | 51 + .../ancestor-search-memo-pg.test.ts | 238 + .../role-manager/ancestor-search-memo-pg.ts | 84 + .../ancestor-search-memo-sqlite.test.ts | 151 + .../ancestor-search-memo-sqlite.ts | 108 + .../src/role-manager/ancestor-search-memo.ts | 83 + .../src/role-manager/member-list.test.ts | 151 + .../src/role-manager/member-list.ts | 142 + .../src/role-manager/role-manager.test.ts | 626 +++ .../src/role-manager/role-manager.ts | 348 ++ .../src/service/enforcer-delegate.test.ts | 1305 +++++ .../src/service/enforcer-delegate.ts | 742 +++ .../service/extendable-id-provider.test.ts | 138 + .../src/service/extendable-id-provider.ts | 56 + .../permission-definition-routes.test.ts | 452 ++ .../service/permission-definition-routes.ts | 166 + .../src/service/permission-model.ts | 31 + .../src/service/plugin-endpoint.test.ts | 543 +++ .../src/service/plugin-endpoints.ts | 207 + .../policies-rest-api.conditions.test.ts | 993 ++++ .../src/service/policies-rest-api.test.ts | 4239 +++++++++++++++++ .../src/service/policies-rest-api.ts | 1324 +++++ .../src/service/policy-builder.test.ts | 273 ++ .../src/service/policy-builder.ts | 250 + .../rbac-backend/src/service/router.test.ts | 46 + plugins/rbac-backend/src/service/router.ts | 51 + plugins/rbac-backend/src/setupTests.ts | 16 + .../validation/condition-validation.test.ts | 988 ++++ .../src/validation/condition-validation.ts | 196 + .../src/validation/plugin-validation.test.ts | 44 + .../src/validation/plugin-validation.ts | 32 + .../validation/policies-validation.test.ts | 410 ++ .../src/validation/policies-validation.ts | 305 ++ plugins/rbac-backend/tsconfig.json | 12 + plugins/rbac-backend/turbo.json | 8 + tsconfig.json | 3 +- yarn.lock | 2375 ++------- 135 files changed, 38330 insertions(+), 1905 deletions(-) create mode 100644 plugins/rbac-backend/.eslintignore create mode 100644 plugins/rbac-backend/.eslintrc.js create mode 100644 plugins/rbac-backend/.prettierignore create mode 100644 plugins/rbac-backend/.prettierrc.js create mode 100644 plugins/rbac-backend/CHANGELOG.md create mode 100644 plugins/rbac-backend/README.md create mode 100644 plugins/rbac-backend/__fixtures__/auditor-test-utils.ts create mode 100644 plugins/rbac-backend/__fixtures__/data/hierarchy/groups.ts create mode 100644 plugins/rbac-backend/__fixtures__/data/hierarchy/rbac-policy.csv create mode 100644 plugins/rbac-backend/__fixtures__/data/hierarchy/users.ts create mode 100644 plugins/rbac-backend/__fixtures__/data/invalid-conditions/bad-conditions-yaml.yaml create mode 100644 plugins/rbac-backend/__fixtures__/data/invalid-conditions/invalid-yaml.yaml create mode 100644 plugins/rbac-backend/__fixtures__/data/invalid-csv/deprecated-policy.csv create mode 100644 plugins/rbac-backend/__fixtures__/data/invalid-csv/duplicate-policy.csv create mode 100644 plugins/rbac-backend/__fixtures__/data/invalid-csv/error-policy.csv create mode 100644 plugins/rbac-backend/__fixtures__/data/valid-conditions/conditions.yaml create mode 100644 plugins/rbac-backend/__fixtures__/data/valid-conditions/empty-conditions.yaml create mode 100644 plugins/rbac-backend/__fixtures__/data/valid-conditions/extra-delimiter-conditions.yaml create mode 100644 plugins/rbac-backend/__fixtures__/data/valid-csv/basic-and-resource-policies.csv create mode 100644 plugins/rbac-backend/__fixtures__/data/valid-csv/policy-checks.csv create mode 100644 plugins/rbac-backend/__fixtures__/data/valid-csv/rbac-policy.csv create mode 100644 plugins/rbac-backend/__fixtures__/data/valid-csv/simple-policy.csv create mode 100644 plugins/rbac-backend/__fixtures__/data/valid-csv/uppercase-policy.csv create mode 100644 plugins/rbac-backend/__fixtures__/mock-utils.ts create mode 100644 plugins/rbac-backend/__fixtures__/test-utils.ts create mode 100644 plugins/rbac-backend/catalog-info.yaml create mode 100644 plugins/rbac-backend/config.d.ts create mode 100644 plugins/rbac-backend/docs/apis.md create mode 100644 plugins/rbac-backend/docs/audit-log.md create mode 100644 plugins/rbac-backend/docs/conditions.md create mode 100644 plugins/rbac-backend/docs/group-hierarchy.md create mode 100644 plugins/rbac-backend/docs/images/group-hierarchy-1.svg create mode 100644 plugins/rbac-backend/docs/images/group-hierarchy-2.svg create mode 100644 plugins/rbac-backend/docs/images/group-hierarchy-3.svg create mode 100644 plugins/rbac-backend/docs/images/group-hierarchy-4.svg create mode 100644 plugins/rbac-backend/docs/multitenancy.md create mode 100644 plugins/rbac-backend/docs/permissions.md create mode 100644 plugins/rbac-backend/docs/providers.md create mode 100644 plugins/rbac-backend/knexfile.js create mode 100644 plugins/rbac-backend/knip-report.md create mode 100644 plugins/rbac-backend/migrations/20231015161232_migrations.js create mode 100644 plugins/rbac-backend/migrations/20231212224526_migrations.js create mode 100644 plugins/rbac-backend/migrations/20231221113214_migrations.js create mode 100644 plugins/rbac-backend/migrations/20240201144429_migrations.js create mode 100644 plugins/rbac-backend/migrations/20240215154456_migrations.js create mode 100644 plugins/rbac-backend/migrations/20240308134410_migrations.js create mode 100644 plugins/rbac-backend/migrations/20240308134941_migrations.js create mode 100644 plugins/rbac-backend/migrations/20240404111242_migrations.js create mode 100644 plugins/rbac-backend/migrations/20240611092136_migrations.js create mode 100644 plugins/rbac-backend/migrations/20241108093910_migrations.js create mode 100644 plugins/rbac-backend/migrations/20250305155143_migration.js create mode 100644 plugins/rbac-backend/migrations/20250509110032_migrations.js create mode 100644 plugins/rbac-backend/migrations/20260216100000_add_is_default_to_role_metadata.js create mode 100644 plugins/rbac-backend/openapi.yaml create mode 100644 plugins/rbac-backend/package.json create mode 100644 plugins/rbac-backend/report.api.md create mode 100644 plugins/rbac-backend/src/admin-permissions/admin-creation.test.ts create mode 100644 plugins/rbac-backend/src/admin-permissions/admin-creation.ts create mode 100644 plugins/rbac-backend/src/auditor/auditor.ts create mode 100644 plugins/rbac-backend/src/auditor/rest-interceptor.ts create mode 100644 plugins/rbac-backend/src/conditional-aliases/alias-resolver.test.ts create mode 100644 plugins/rbac-backend/src/conditional-aliases/alias-resolver.ts create mode 100644 plugins/rbac-backend/src/database/casbin-adapter-factory.test.ts create mode 100644 plugins/rbac-backend/src/database/casbin-adapter-factory.ts create mode 100644 plugins/rbac-backend/src/database/conditional-storage.test.ts create mode 100644 plugins/rbac-backend/src/database/conditional-storage.ts create mode 100644 plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.test.ts create mode 100644 plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.ts create mode 100644 plugins/rbac-backend/src/database/migration.ts create mode 100644 plugins/rbac-backend/src/database/role-metadata.test.ts create mode 100644 plugins/rbac-backend/src/database/role-metadata.ts create mode 100644 plugins/rbac-backend/src/default-permissions/default-permissions.test.ts create mode 100644 plugins/rbac-backend/src/default-permissions/default-permissions.ts create mode 100644 plugins/rbac-backend/src/file-permissions/csv-file-watcher.test.ts create mode 100644 plugins/rbac-backend/src/file-permissions/csv-file-watcher.ts create mode 100644 plugins/rbac-backend/src/file-permissions/file-watcher.ts create mode 100644 plugins/rbac-backend/src/file-permissions/lowercase-file-adapter.ts create mode 100644 plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.test.ts create mode 100644 plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.ts create mode 100644 plugins/rbac-backend/src/helper.test.ts create mode 100644 plugins/rbac-backend/src/helper.ts create mode 100644 plugins/rbac-backend/src/index.ts create mode 100644 plugins/rbac-backend/src/permissions/conditions.ts create mode 100644 plugins/rbac-backend/src/permissions/index.ts create mode 100644 plugins/rbac-backend/src/permissions/resource.ts create mode 100644 plugins/rbac-backend/src/permissions/rules.ts create mode 100644 plugins/rbac-backend/src/plugin.ts create mode 100644 plugins/rbac-backend/src/policies/allow-all-policy.test.ts create mode 100644 plugins/rbac-backend/src/policies/allow-all-policy.ts create mode 100644 plugins/rbac-backend/src/policies/permission-policy.hierarchy.test.ts create mode 100644 plugins/rbac-backend/src/policies/permission-policy.test.ts create mode 100644 plugins/rbac-backend/src/policies/permission-policy.ts create mode 100644 plugins/rbac-backend/src/providers/connect-providers.test.ts create mode 100644 plugins/rbac-backend/src/providers/connect-providers.ts create mode 100644 plugins/rbac-backend/src/role-manager/ancestor-search-factory.ts create mode 100644 plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.test.ts create mode 100644 plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.ts create mode 100644 plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.test.ts create mode 100644 plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.ts create mode 100644 plugins/rbac-backend/src/role-manager/ancestor-search-memo.ts create mode 100644 plugins/rbac-backend/src/role-manager/member-list.test.ts create mode 100644 plugins/rbac-backend/src/role-manager/member-list.ts create mode 100644 plugins/rbac-backend/src/role-manager/role-manager.test.ts create mode 100644 plugins/rbac-backend/src/role-manager/role-manager.ts create mode 100644 plugins/rbac-backend/src/service/enforcer-delegate.test.ts create mode 100644 plugins/rbac-backend/src/service/enforcer-delegate.ts create mode 100644 plugins/rbac-backend/src/service/extendable-id-provider.test.ts create mode 100644 plugins/rbac-backend/src/service/extendable-id-provider.ts create mode 100644 plugins/rbac-backend/src/service/permission-definition-routes.test.ts create mode 100644 plugins/rbac-backend/src/service/permission-definition-routes.ts create mode 100644 plugins/rbac-backend/src/service/permission-model.ts create mode 100644 plugins/rbac-backend/src/service/plugin-endpoint.test.ts create mode 100644 plugins/rbac-backend/src/service/plugin-endpoints.ts create mode 100644 plugins/rbac-backend/src/service/policies-rest-api.conditions.test.ts create mode 100644 plugins/rbac-backend/src/service/policies-rest-api.test.ts create mode 100644 plugins/rbac-backend/src/service/policies-rest-api.ts create mode 100644 plugins/rbac-backend/src/service/policy-builder.test.ts create mode 100644 plugins/rbac-backend/src/service/policy-builder.ts create mode 100644 plugins/rbac-backend/src/service/router.test.ts create mode 100644 plugins/rbac-backend/src/service/router.ts create mode 100644 plugins/rbac-backend/src/setupTests.ts create mode 100644 plugins/rbac-backend/src/validation/condition-validation.test.ts create mode 100644 plugins/rbac-backend/src/validation/condition-validation.ts create mode 100644 plugins/rbac-backend/src/validation/plugin-validation.test.ts create mode 100644 plugins/rbac-backend/src/validation/plugin-validation.ts create mode 100644 plugins/rbac-backend/src/validation/policies-validation.test.ts create mode 100644 plugins/rbac-backend/src/validation/policies-validation.ts create mode 100644 plugins/rbac-backend/tsconfig.json create mode 100644 plugins/rbac-backend/turbo.json diff --git a/package.json b/package.json index d0bf447023..19dd3a8c6e 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "zod@^3.25.76": "3.25.76", "zod@^3.25.76 || ^4.0.0": "3.25.76", "zod@^3.25 || ^4.0": "3.25.76", + "@internal/plugin-rbac-backend@workspace:plugins/rbac-backend>zod": "4.3.6", "infinispan": "0.13.0", "@protobufjs/inquire": "1.1.0" }, diff --git a/packages/backend/package.json b/packages/backend/package.json index 5cc7ddac6c..f223ac1b19 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -65,6 +65,7 @@ "@backstage/plugin-user-settings-backend": "0.4.1", "@internal/plugin-dynamic-plugins-info-backend": "*", "@internal/plugin-licensed-users-info-backend": "*", + "@internal/plugin-rbac-backend": "*", "@internal/plugin-scalprum-backend": "*", "@opentelemetry/api": "1.9.1", "@opentelemetry/auto-instrumentations-node": "0.76.0", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index c263f26e4a..b0597721d4 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -148,6 +148,7 @@ backend.add( import('@backstage-community/plugin-scaffolder-backend-module-annotator'), ); +backend.add(import('@internal/plugin-rbac-backend')); backend.add(pluginIDProviderService); backend.add(rbacDynamicPluginsProvider); diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index a8b622c7f5..e7312099e0 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -4,6 +4,7 @@ "exclude": ["node_modules"], "compilerOptions": { "outDir": "../../dist-types/packages/backend", - "rootDir": "." + "rootDir": ".", + "useUnknownInCatchVariables": false } } diff --git a/plugins/rbac-backend/.eslintignore b/plugins/rbac-backend/.eslintignore new file mode 100644 index 0000000000..6a77e2728b --- /dev/null +++ b/plugins/rbac-backend/.eslintignore @@ -0,0 +1,4 @@ +dist-dynamic +dist-scalprum +!.eslintrc.js +!.prettierrc.js \ No newline at end of file diff --git a/plugins/rbac-backend/.eslintrc.js b/plugins/rbac-backend/.eslintrc.js new file mode 100644 index 0000000000..0e688f094f --- /dev/null +++ b/plugins/rbac-backend/.eslintrc.js @@ -0,0 +1,25 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, { + rules: { + 'jest/expect-expect': [ + 'error', + { + assertFunctionNames: ['expect*'], + }, + ], + }, +}); diff --git a/plugins/rbac-backend/.prettierignore b/plugins/rbac-backend/.prettierignore new file mode 100644 index 0000000000..87deff0f89 --- /dev/null +++ b/plugins/rbac-backend/.prettierignore @@ -0,0 +1,14 @@ +dist +dist-types +coverage +.vscode +CHANGELOG.md +generated +templates +*.hbs +renovate.json +dist-dynamic +dist-scalprum +playwright-report +report.api.md +knip-report.md diff --git a/plugins/rbac-backend/.prettierrc.js b/plugins/rbac-backend/.prettierrc.js new file mode 100644 index 0000000000..c45fe04c2b --- /dev/null +++ b/plugins/rbac-backend/.prettierrc.js @@ -0,0 +1,34 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */ +module.exports = { + ...require('@backstage/cli/config/prettier'), + plugins: ['@ianvs/prettier-plugin-sort-imports'], + importOrder: [ + '^react(.*)$', + '', + '^@backstage/(.*)$', + '', + '', + '', + '^@backstage-community/(.*)$', + '', + '', + '', + '^[.]', + ], +}; diff --git a/plugins/rbac-backend/CHANGELOG.md b/plugins/rbac-backend/CHANGELOG.md new file mode 100644 index 0000000000..84cfdf5044 --- /dev/null +++ b/plugins/rbac-backend/CHANGELOG.md @@ -0,0 +1,1171 @@ +# @backstage-community/plugin-rbac-backend + +## 7.12.4 + +### Patch Changes + +- 170f85d: Migrate to Jest 30 and fix backend test assertion compatibility +- Updated dependencies [170f85d] + - @backstage-community/plugin-rbac-common@1.26.1 + - @backstage-community/plugin-rbac-node@1.20.1 + +## 7.12.3 + +### Patch Changes + +- fb2a770: Made postgres username and password optional in casbin adapter factory to support passwordless authentication + +## 7.12.2 + +### Patch Changes + +- 39272f8: Updated dependency `csv-parse` to `^6.0.0`. +- 70e6333: Updated dependency `@dagrejs/graphlib` to `^4.0.0`. +- a559dfb: Updated dependency `@types/node` to `22.19.17`. +- 8846adf: Updated dependency `qs` to `6.15.1`. + +## 7.12.1 + +### Patch Changes + +- 40e44bb: Updated dependency `qs` to `6.14.2`. + +## 7.12.0 + +### Minor Changes + +- 8993474: Backstage version bump to v1.49.2 + +### Patch Changes + +- Updated dependencies [8993474] + - @backstage-community/plugin-rbac-common@1.26.0 + - @backstage-community/plugin-rbac-node@1.20.0 + +## 7.11.0 + +### Minor Changes + +- 50e194d: Add support for a default role and permissions for authenticated users in RBAC backend + + - Introduced a new `defaultRole` and `basicPermissions` configuration options to assign a default role to all authenticated users. + + ```diff + permission: + rbac: + + defaultPermissions: + + defaultRole: role:default/my-default-role + + basicPermissions: + + - permission: catalog.entity.read + + action: read + ``` + + - Updated the RBAC permission policy to include the default role in user roles if not already present. + +### Patch Changes + +- Updated dependencies [50e194d] + - @backstage-community/plugin-rbac-common@1.25.0 + - @backstage-community/plugin-rbac-node@1.19.1 + +## 7.10.0 + +### Minor Changes + +- 133eae6: Add support for loading conditional permissions from a remote provider (fix #6412) + +### Patch Changes + +- Updated dependencies [133eae6] + - @backstage-community/plugin-rbac-node@1.19.0 + +## 7.9.1 + +### Patch Changes + +- d737494: Backstage version bump to v1.48.5 +- Updated dependencies [d737494] + - @backstage-community/plugin-rbac-common@1.24.1 + - @backstage-community/plugin-rbac-node@1.18.1 + +## 7.9.0 + +### Minor Changes + +- da170a1: Add support for group reference in superUsers list, using direct membership only + +### Patch Changes + +- 8a6b81c: Updated dependency `@types/supertest` to `^7.0.0`. + +## 7.8.0 + +### Minor Changes + +- 843bbe2: Backstage version bump to v1.48.4 + +### Patch Changes + +- Updated dependencies [843bbe2] + - @backstage-community/plugin-rbac-common@1.24.0 + - @backstage-community/plugin-rbac-node@1.18.0 + +## 7.7.2 + +### Patch Changes + +- 8c7bddb: Added NFS support +- af998b7: Updated dependency `supertest` to `7.2.2`. + +## 7.7.1 + +### Patch Changes + +- b133c9d: Updated dependency `@types/supertest` to `^6.0.0`. +- 497d5c6: Updated dependency `@types/node` to `22.19.11`. +- 9c7ae87: Fix - stop error on upgrade v1.47.x - allow all plugins in the arry to show + +## 7.7.0 + +### Minor Changes + +- e6dbf70: Backstage version bump to v1.47.2 + +### Patch Changes + +- e6dbf70: updated the permissionFactory to use the `FetchUrlReader.fromConfig` +- a184943: Updated dependency `@types/node` to `22.19.7`. +- Updated dependencies [e6dbf70] + - @backstage-community/plugin-rbac-common@1.23.0 + - @backstage-community/plugin-rbac-node@1.17.0 + +## 7.6.2 + +### Patch Changes + +- 9a07184: Backport: Remove usage of breaking imports from @backstage/backend-defaults + + This backports the fix from commit 9c7ae87 to avoid compatibility issue when @backstage/backend-defaults resolves to 0.13.2, which introduced breaking changes to address a CVE. By removing the problematic import, this plugin remains compatible with both 0.13.1 and 0.13.2 and does not use the code containing the CVE. + +## 7.6.1 + +### Patch Changes + +- 6d3ed24: Updated dependency `supertest` to `^7.0.0`. +- 9714391: Updated dependency `qs` to `6.14.1`. +- efdad9e: Updated dependency `@types/node` to `22.19.3`. + +## 7.6.0 + +### Minor Changes + +- e2d17e1: Backstage version bump to v1.45.1 + +### Patch Changes + +- 636525d: Updated dependency `@types/express` to `4.17.25`. +- Updated dependencies [e2d17e1] + - @backstage-community/plugin-rbac-common@1.22.0 + - @backstage-community/plugin-rbac-node@1.16.0 + +## 7.5.1 + +### Patch Changes + +- 0743ffa: Backport: Remove usage of breaking imports from @backstage/backend-defaults + + This backports the fix from commit 9c7ae87 to avoid compatibility issues when @backstage backend-defaults resolves to 0.13.2, which introduced breaking changes to address a CVE. By removing the problematic import, this plugin remains compatible with both 0.13.1 and 0.13.2 and does not use the code containing the CVE. + +## 7.5.0 + +### Minor Changes + +- 2d1f63f: Backstage version bump to v1.44.2 + +### Patch Changes + +- Updated dependencies [2d1f63f] + - @backstage-community/plugin-rbac-common@1.21.0 + - @backstage-community/plugin-rbac-node@1.15.0 + +## 7.4.3 + +### Patch Changes + +- 05801c1: Backport: Remove usage of breaking imports from @backstage/backend-defaults + + This backports the fix from commit 9c7ae87 to avoid compatibility issues when @backstage backend-defaults resolves to 0.13.2, which introduced breaking changes to address a CVE. By removing the problematic import, this plugin remains compatible with both 0.13.1 and 0.13.2 and does not use the code containing the CVE. + +## 7.4.2 + +### Patch Changes + +- de412d4: Fix issue with extra delimiter in conditional yaml. +- 93ce408: Compare parent reference in sqlite memo using entity ref + +## 7.4.1 + +### Patch Changes + +- db1ab9d: Updated dependency `knex-mock-client` to `3.0.2`. + +## 7.4.0 + +### Minor Changes + +- 232a84d: Backstage version bump to v1.42.5 + +### Patch Changes + +- Updated dependencies [232a84d] + - @backstage-community/plugin-rbac-common@1.20.0 + - @backstage-community/plugin-rbac-node@1.14.0 + +## 7.3.0 + +### Minor Changes + +- 5260b5c: support config to set permission vs conditional policy evaluation order + +## 7.2.0 + +### Minor Changes + +- 2f4d9ff: Backstage version bump to v1.41.1 + +### Patch Changes + +- e843699: Added missing configSchema into package.json +- Updated dependencies [2f4d9ff] + - @backstage-community/plugin-rbac-common@1.19.0 + - @backstage-community/plugin-rbac-node@1.13.0 + +## 7.1.0 + +### Minor Changes + +- 8db28a0: Updated readme example on conditional policy yaml to be well formed (removed quotes) + +### Patch Changes + +- 4c49556: Updated dependency `@types/express` to `4.17.23`. + +## 7.0.0 + +### Major Changes + +- 2e732e8: **BREAKING**: Removal of the deprecated createRouter from @backstage/plugin-permission-backend. This results in a new requirement of having the permission plugin installed alongside the RBAC backend plugin. + + Recent changes to the @backstage/plugin-permission-backend resulted in the deprecating and removal of `createRouter` which was primarily used as a way to start both the permission backend plugin and the RBAC backend plugin at the same time. This removal now results in the requirement of having the permission backend plugin installed separately to ensure that the RBAC backend plugin works accordingly. + + Changes required to `packages/backend/src/index.ts` + + ```diff + // permission plugin + + backend.add(import('@backstage/plugin-permission-backend')); + backend.add(import('@backstage-community/plugin-rbac-backend')); + ``` + +### Minor Changes + +- 4b58a1d: Backstage version bump to v1.39.0 + +### Patch Changes + +- 6a59fcf: remove support and lifecycle keywords in package.json +- Updated dependencies [6a59fcf] +- Updated dependencies [4b58a1d] + - @backstage-community/plugin-rbac-common@1.18.0 + - @backstage-community/plugin-rbac-node@1.12.0 + +## 6.3.0 + +### Minor Changes + +- a42945e: Introduce API to store additional plugin ID list +- 3e3f346: Migrate rbac-backend to use permission registry service. + +### Patch Changes + +- 098b200: Updated dependency `@types/express` to `4.17.22`. +- e958f2f: Updated dependency `@types/node` to `22.15.29`. +- Updated dependencies [a42945e] + - @backstage-community/plugin-rbac-common@1.17.0 + +## 6.2.6 + +### Patch Changes + +- fcc57ec: Updated dependency `@types/node` to `22.14.1`. + +## 6.2.5 + +### Patch Changes + +- 658c51c: chore: Remove usage of @spotify/prettier-config +- Updated dependencies [658c51c] + - @backstage-community/plugin-rbac-common@1.16.1 + - @backstage-community/plugin-rbac-node@1.11.1 + +## 6.2.4 + +### Patch Changes + +- 298b1d4: Avoid unnecessary query to check 'relations' table in the role manager + +## 6.2.3 + +### Patch Changes + +- 9436665: Reduce rbac-backend requests to credentials API. + +## 6.2.2 + +### Patch Changes + +- c92a50c: Fixed a bug where updating a role name via the `PUT ` endpoint did not propagate changes to metadata, permissions and conditions, leaving them mapped to the old role name. + +## 6.2.1 + +### Patch Changes + +- 10b9919: Avoid filter's args duplication. + +## 6.2.0 + +### Minor Changes + +- e8755f6: Backstage version bump to v1.38.1 + +### Patch Changes + +- Updated dependencies [e8755f6] + - @backstage-community/plugin-rbac-common@1.16.0 + - @backstage-community/plugin-rbac-node@1.11.0 + +## 6.1.1 + +### Patch Changes + +- 10a0d31: Fixes an issue where the correct permission name was not selected while processing new conditional policies to be added. This scenario happens whenever a plugin exports multiple permissions that have different resource types but similar actions. What would end up happening is the first matched action would be the one selected during processing even though it was not the correct permission and used for the conditional policy. This problem has been fixed and now the correct permission name and action are selected. + +## 6.1.0 + +### Minor Changes + +- d278b4c: Adds the ability to assign ownership to roles that can then be used to conditionally filter roles, permission policies, and conditional policies. The conditional filter can now be accomplished through the use of the new RBAC conditional rule `IS_OWNER`. + + `IS_OWNER` can be used to grant limited access to the RBAC plugins where in admins might want leads to control their own team's access. + + Removed the resource type from the `policy.entity.create` permission to prevent conditional rules being applied to the permission. At the moment, the plugins will still continue to work as expected. However, it is strongly recommended updating all permission policies that utilize the resource type `policy-entity` with the action `create` (ex. `role:default/some_role, policy-entity, create, allow` to `role:default/some_role, policy.entity.create, create, allow`) to prevent any future degradation in service. A migration has been supplied to automatically update all permission policies that have not originated from the CSV file. The CSV file was skipped as a duplication event could happen during reloads / restarts. This means that the CSV file will need to be updated manually to ensure that all references to the old permission policy, resource type `policy-entity` with an action of `create`, have been updated to the named permission `policy.entity.create` with an action of `create`. + +### Patch Changes + +- Updated dependencies [d278b4c] + - @backstage-community/plugin-rbac-common@1.15.0 + +## 6.0.1 + +### Patch Changes + +- f84ad73: chore: remove homepage field from package.json +- Updated dependencies [f84ad73] + - @backstage-community/plugin-rbac-common@1.14.1 + - @backstage-community/plugin-rbac-node@1.10.1 + +## 6.0.0 + +### Major Changes + +- 9cccb0d: **BREAKING**: Migration to the core Auditor service. The Auditor format has been updated. Audit fields and event names (ids) have been updated to conform with the new Auditor service conventions. Filtering queries based on the old format may no longer work. + +## 5.6.1 + +### Patch Changes + +- b2a5daa: Updated dependency `qs` to `6.14.0`. + +## 5.6.0 + +### Minor Changes + +- 0253db6: Backstage version bump to v1.36.1 + +### Patch Changes + +- Updated dependencies [0253db6] + - @backstage-community/plugin-rbac-common@1.14.0 + - @backstage-community/plugin-rbac-node@1.10.0 + +## 5.5.3 + +### Patch Changes + +- 973a5ef: remove prettier from devDevpendencies +- Updated dependencies [973a5ef] + - @backstage-community/plugin-rbac-node@1.9.1 + +## 5.5.2 + +### Patch Changes + +- 9aa839a: Fixes two issues that were impact the performance, the first was that we were individually adding and removing roles and the second was we were removing all policies and roles regardless of whether they should actually be removed. + +## 5.5.1 + +### Patch Changes + +- fcfaf89: Fixed an issue where aliases would not be applied across all conditional policy rules. + +## 5.5.0 + +### Minor Changes + +- 36e2c6c: Reduces the number of times that we build the group hierarchy graphs during evaluation. Originally, during time of evaluation, we would build a graph to of all of the groups that a user was directly or indirectly a member of. Now, we only build the graph once and pass along all of the roles that the user is directly or indirectly attached to. + +## 5.4.0 + +### Minor Changes + +- 5d5c02a: Backstage version bump to v1.35.0 + +### Patch Changes + +- Updated dependencies [5d5c02a] + - @backstage-community/plugin-rbac-common@1.13.0 + - @backstage-community/plugin-rbac-node@1.9.0 + +## 5.3.1 + +### Patch Changes + +- 1d5dd17: Evaluate the permissions for a superuser earlier in the process to avoid the unintended consequence of having conditional permissions policies applied to a superuser. + +## 5.3.0 + +### Minor Changes + +- 53daff0: Roles and permissions were not correctly applied for users and groups with names containing uppercase letters. To address this issue, we now convert user and group references in all user inputs to lowercase. This change migrates `v0` column in `casbin_rule` table in `backstage_plugin_permission` database. Conditions containing claims with uppercase letters are not resolved yet. + +## 5.2.10 + +### Patch Changes + +- ba4b3e9: Use loadPolicy to keep the enforcer in sync for edit operations. It should keep the RBAC plugin in sync when the Backstage instance is scaled to multiple deployment replicas. Reuse the maximum database pool size value from the application configuration in the RBAC Casbin adapter. + +## 5.2.9 + +### Patch Changes + +- 5b19b0d: Update documentation information about `pluginsWithPermission` setting. In order for the RBAC UI to display available permissions provided by installed plugins, this setting needs to be configured. + +## 5.2.8 + +### Patch Changes + +- 0f5c451: Updated dependency `prettier` to `3.4.2`. +- 18f9d9d: Updated dependency `@types/node` to `18.19.68`. +- Updated dependencies [0f5c451] + - @backstage-community/plugin-rbac-node@1.8.4 + +## 5.2.7 + +### Patch Changes + +- 7843798: Updated dependency `qs` to `6.13.1`. +- 4b3653a: Clean up api report warnings and remove unnecessary files +- Updated dependencies [4b3653a] + - @backstage-community/plugin-rbac-common@1.12.3 + - @backstage-community/plugin-rbac-node@1.8.3 + +## 5.2.6 + +### Patch Changes + +- 4084738: Ensures that the permissions and roles are properly synced during request handling. This is important in high availability scenarios as we need to ensure data is up to date during scaling. + +## 5.2.5 + +### Patch Changes + +- a6e850f: Updated dependency `msw` to `1.3.5`. + +## 5.2.4 + +### Patch Changes + +- dd0e2b4: chore: use workspace dependencies +- b7c2fa1: Updated supported-versions to ^1.28.4. +- Updated dependencies [b7c2fa1] + - @backstage-community/plugin-rbac-common@1.12.2 + - @backstage-community/plugin-rbac-node@1.8.2 + +## 5.2.3 + +### Patch Changes + +- 2249d08: bump rbac plugins to include latest changes in janus + +## 5.2.2 + +### Patch Changes + +- 019f010: Migrated from [janus-idp/backstage-plugins](https://github.com/janus-idp/backstage-plugins). +- Updated dependencies [019f010] + - @backstage-community/plugin-rbac-common@1.12.1 + - @backstage-community/plugin-rbac-node@1.8.1 + +## 5.2.1 + +### Patch Changes + +- 0646434: Fix broken plugin startup: don't attempt to store permission policies that are already stored. + +## 5.2.0 + +### Minor Changes + +- 8244f28: chore(deps): update to backstage 1.32 + +### Patch Changes + +- Updated dependencies [8244f28] + - @janus-idp/backstage-plugin-audit-log-node@1.7.0 + - @backstage-community/plugin-rbac-common@1.12.0 + - @backstage-community/plugin-rbac-node@1.8.0 + +## 5.1.2 + +### Patch Changes + +- 7342e9b: chore: remove @janus-idp/cli dep and relink local packages + + This update removes `@janus-idp/cli` from all plugins, as it’s no longer necessary. Additionally, packages are now correctly linked with a specified version. + +## 5.1.1 + +### Patch Changes + +- e6ef910: Refactors the rbac backend plugin to prevent the creation of permission policies and roles whenever the plugin and permission framework is disabled + +## 5.1.0 + +### Minor Changes + +- d9551ae: feat(deps): update to backstage 1.31 + +### Patch Changes + +- d9551ae: Refactors the rbac backend plugin to move the admin role and admin permission creation to a separate file +- d9551ae: Change local package references to a `*` +- d9551ae: upgrade to yarn v3 +- Updated dependencies [d9551ae] +- Updated dependencies [d9551ae] +- Updated dependencies [d9551ae] + - @backstage-community/plugin-rbac-common@1.11.0 + - @janus-idp/backstage-plugin-audit-log-node@1.6.0 + - @backstage-community/plugin-rbac-node@1.7.0 + +* **@janus-idp/backstage-plugin-audit-log-node:** upgraded to 1.5.1 + +### Dependencies + +- **@janus-idp/backstage-plugin-audit-log-node:** upgraded to 1.5.0 +- **@backstage-community/plugin-rbac-common:** upgraded to 1.10.0 +- **@backstage-community/plugin-rbac-node:** upgraded to 1.6.0 + +### Dependencies + +- **@janus-idp/backstage-plugin-audit-log-node:** upgraded to 1.4.1 + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.9.0 +- **@backstage-community/plugin-rbac-node:** upgraded to 1.5.0 + +## @backstage-community/plugin-rbac-backend [4.7.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.7.2...@backstage-community/plugin-rbac-backend@4.7.3) (2024-08-06) + +### Bug Fixes + +- **rbac:** implement conditional aliases ([#1847](https://github.com/janus-idp/backstage-plugins/issues/1847)) ([dbc9a0b](https://github.com/janus-idp/backstage-plugins/commit/dbc9a0bc92f19a4382e406f83b4889905dc6e33d)) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.8.2 + +## @backstage-community/plugin-rbac-backend [4.7.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.7.1...@backstage-community/plugin-rbac-backend@4.7.2) (2024-08-05) + +### Bug Fixes + +- **rbac:** add additional validation for permission policies ([#1908](https://github.com/janus-idp/backstage-plugins/issues/1908)) ([592498f](https://github.com/janus-idp/backstage-plugins/commit/592498f34a3b605162d3c242184aa6877b0360e8)), closes [#1939](https://github.com/janus-idp/backstage-plugins/issues/1939) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.8.1 + +## @backstage-community/plugin-rbac-backend [4.7.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.7.0...@backstage-community/plugin-rbac-backend@4.7.1) (2024-08-02) + +### Bug Fixes + +- **rbac:** log when plugin has no permissions ([#1917](https://github.com/janus-idp/backstage-plugins/issues/1917)) ([cc8752b](https://github.com/janus-idp/backstage-plugins/commit/cc8752b159364fdab62e7bbdaa51ca811288197b)) + +## @backstage-community/plugin-rbac-backend [4.7.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.6.1...@backstage-community/plugin-rbac-backend@4.7.0) (2024-07-30) + +### Features + +- **argocd:** add permission support for argocd ([#1855](https://github.com/janus-idp/backstage-plugins/issues/1855)) ([3b78237](https://github.com/janus-idp/backstage-plugins/commit/3b782377683605ea4d584c43bea14be2f435003d)) + +## @backstage-community/plugin-rbac-backend [4.6.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.6.0...@backstage-community/plugin-rbac-backend@4.6.1) (2024-07-29) + +### Bug Fixes + +- **rbac:** fix uncommited knex transaction in the addGroupingPolicies ([#1968](https://github.com/janus-idp/backstage-plugins/issues/1968)) ([24d5eef](https://github.com/janus-idp/backstage-plugins/commit/24d5eeffbce685bbe05f8895fe3a69ee26a4eb8a)) + +## @backstage-community/plugin-rbac-backend [4.6.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.5.0...@backstage-community/plugin-rbac-backend@4.6.0) (2024-07-26) + +### Features + +- **tekton:** add permissions support for tekton plugin ([#1854](https://github.com/janus-idp/backstage-plugins/issues/1854)) ([f744896](https://github.com/janus-idp/backstage-plugins/commit/f7448963c252574e0309a091563c19e1ed9a58fd)) + +## @backstage-community/plugin-rbac-backend [4.5.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.4.3...@backstage-community/plugin-rbac-backend@4.5.0) (2024-07-26) + +### Features + +- **deps:** update to backstage 1.29 ([#1900](https://github.com/janus-idp/backstage-plugins/issues/1900)) ([f53677f](https://github.com/janus-idp/backstage-plugins/commit/f53677fb02d6df43a9de98c43a9f101a6db76802)) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.8.0 +- **@backstage-community/plugin-rbac-node:** upgraded to 1.4.0 + +## @backstage-community/plugin-rbac-backend [4.4.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.4.2...@backstage-community/plugin-rbac-backend@4.4.3) (2024-07-25) + +### Documentation + +- **rbac:** add curl request examples ([#1913](https://github.com/janus-idp/backstage-plugins/issues/1913)) ([e496eb7](https://github.com/janus-idp/backstage-plugins/commit/e496eb73349987d43caba86a29e4c98c86179250)) + +## @backstage-community/plugin-rbac-backend [4.4.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.4.1...@backstage-community/plugin-rbac-backend@4.4.2) (2024-07-24) + +### Bug Fixes + +- **deps:** rollback unreleased plugins ([#1951](https://github.com/janus-idp/backstage-plugins/issues/1951)) ([8b77969](https://github.com/janus-idp/backstage-plugins/commit/8b779694f02f8125587296305276b84cdfeeaebe)) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.7.2 +- **@backstage-community/plugin-rbac-node:** upgraded to 1.3.1 + +## @backstage-community/plugin-rbac-backend [4.4.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.4.0...@backstage-community/plugin-rbac-backend@4.4.1) (2024-07-24) + +### Bug Fixes + +- **rbac:** don't start transaction if there no group policies ([#1923](https://github.com/janus-idp/backstage-plugins/issues/1923)) ([dffa964](https://github.com/janus-idp/backstage-plugins/commit/dffa9643b500a19dc70c66cedf9016508cdb5947)) + +## @backstage-community/plugin-rbac-backend [4.4.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.3.4...@backstage-community/plugin-rbac-backend@4.4.0) (2024-07-24) + +### Features + +- **deps:** update to backstage 1.28 ([#1891](https://github.com/janus-idp/backstage-plugins/issues/1891)) ([1ba1108](https://github.com/janus-idp/backstage-plugins/commit/1ba11088e0de60e90d138944267b83600dc446e5)) + +### Bug Fixes + +- **deps:** fix rbac dependencies ([#1918](https://github.com/janus-idp/backstage-plugins/issues/1918)) ([fcc4e1d](https://github.com/janus-idp/backstage-plugins/commit/fcc4e1dde55bc0fb2dd284d256330c7f9f928036)) +- **deps:** move backend-test-utils to devDependencies ([#1944](https://github.com/janus-idp/backstage-plugins/issues/1944)) ([9052a3f](https://github.com/janus-idp/backstage-plugins/commit/9052a3f41cae1cd57fb8f52033ea2c6f752f64fe)) + +### Documentation + +- added OpenAPI spec for rbac-backend ([#1830](https://github.com/janus-idp/backstage-plugins/issues/1830)) ([4eb2035](https://github.com/janus-idp/backstage-plugins/commit/4eb20351bf9713355cb79905a2e49aeec9ad6ec9)) +- **rbac:** fix condition rules api url ([#1914](https://github.com/janus-idp/backstage-plugins/issues/1914)) ([e6fa0ae](https://github.com/janus-idp/backstage-plugins/commit/e6fa0ae7265ea56b50fffbf1466540a61d714ed8)) + +## @backstage-community/plugin-rbac-backend [4.3.4](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.3.3...@backstage-community/plugin-rbac-backend@4.3.4) (2024-07-17) + +### Bug Fixes + +- **rbac:** simplify db logic ([#1842](https://github.com/janus-idp/backstage-plugins/issues/1842)) ([cbe263b](https://github.com/janus-idp/backstage-plugins/commit/cbe263b2901c0d57105667caf2d3ab7c0583468a)) + +## @backstage-community/plugin-rbac-backend [4.3.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.3.2...@backstage-community/plugin-rbac-backend@4.3.3) (2024-07-16) + +### Bug Fixes + +- **rbac:** catch errors whenever a plugin token is not generated ([#1866](https://github.com/janus-idp/backstage-plugins/issues/1866)) ([c9abf44](https://github.com/janus-idp/backstage-plugins/commit/c9abf441591347753fe94fe2590b8059804baeb7)) + +## @backstage-community/plugin-rbac-backend [4.3.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.3.1...@backstage-community/plugin-rbac-backend@4.3.2) (2024-07-05) + +### Bug Fixes + +- **rbac:** casbinDBAdapterFactory supporting postgres schema configuration ([#1841](https://github.com/janus-idp/backstage-plugins/issues/1841)) ([c0e63f9](https://github.com/janus-idp/backstage-plugins/commit/c0e63f9541edc121c77d6569d6fe6958ce937c0b)) +- **rbac:** correct plugin ID matching to permission policy ([#1795](https://github.com/janus-idp/backstage-plugins/issues/1795)) ([6dc4b1c](https://github.com/janus-idp/backstage-plugins/commit/6dc4b1c23d22252f394eecd8b795ac15507ecc50)) +- **rbac:** update rbac common to fix compilation ([#1858](https://github.com/janus-idp/backstage-plugins/issues/1858)) ([48f142b](https://github.com/janus-idp/backstage-plugins/commit/48f142b447f0d1677ba3f16b2a3c8972b22d0588)) + +## @backstage-community/plugin-rbac-backend [4.3.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.3.0...@backstage-community/plugin-rbac-backend@4.3.1) (2024-06-19) + +## @backstage-community/plugin-rbac-backend [4.3.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.2.0...@backstage-community/plugin-rbac-backend@4.3.0) (2024-06-13) + +### Features + +- **deps:** update to backstage 1.27 ([#1683](https://github.com/janus-idp/backstage-plugins/issues/1683)) ([a14869c](https://github.com/janus-idp/backstage-plugins/commit/a14869c3f4177049cb8d6552b36c3ffd17e7997d)) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.6.0 +- **@backstage-community/plugin-rbac-node:** upgraded to 1.2.0 +- **@janus-idp/backstage-plugin-audit-log-node:** upgraded to 1.2.0 + +## @backstage-community/plugin-rbac-backend [4.2.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.1.0...@backstage-community/plugin-rbac-backend@4.2.0) (2024-06-05) + +### Features + +- **rbac:** add type checks with generics for audit log ([#1789](https://github.com/janus-idp/backstage-plugins/issues/1789)) ([ac69838](https://github.com/janus-idp/backstage-plugins/commit/ac698382f64fe91e0f9f9232dd3eecd9cc9247be)) + +### Dependencies + +- **@janus-idp/backstage-plugin-audit-log-node:** upgraded to 1.1.0 + +## @backstage-community/plugin-rbac-backend [4.1.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.0.2...@backstage-community/plugin-rbac-backend@4.1.0) (2024-06-04) + +### Features + +- **rbac:** add audit log for RBAC backend ([#1726](https://github.com/janus-idp/backstage-plugins/issues/1726)) ([e50464b](https://github.com/janus-idp/backstage-plugins/commit/e50464bcb38e9897ddfe208fdeef699e4bfeda3a)) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.5.0 +- **@backstage-community/plugin-rbac-node:** upgraded to 1.1.2 +- **@janus-idp/backstage-plugin-audit-log-node:** upgraded to 1.0.3 + +## @backstage-community/plugin-rbac-backend [4.0.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.0.1...@backstage-community/plugin-rbac-backend@4.0.2) (2024-06-04) + +### Bug Fixes + +- **rbac:** fix handling condition action conflicts ([#1781](https://github.com/janus-idp/backstage-plugins/issues/1781)) ([966b2b2](https://github.com/janus-idp/backstage-plugins/commit/966b2b200e0ade0ce600901a7853a4a94751df22)) + +## @backstage-community/plugin-rbac-backend [4.0.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.0.0...@backstage-community/plugin-rbac-backend@4.0.1) (2024-06-03) + +### Bug Fixes + +- **rbac:** add support for scaling ([#1757](https://github.com/janus-idp/backstage-plugins/issues/1757)) ([caddc83](https://github.com/janus-idp/backstage-plugins/commit/caddc832e0df5199a455539d3538635448691c2d)) + +## @backstage-community/plugin-rbac-backend [4.0.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@3.3.0...@backstage-community/plugin-rbac-backend@4.0.0) (2024-05-31) + +### ⚠ BREAKING CHANGES + +- **rbac:** This will lead to more strict validation on the source of permission policies and roles based on the where the first role is defined. + +Improves the validation of the different sources of permission policies and roles. Aims to make policy definition more consistent. + +Now checks if a permission policy or role with new member matches the originating role's source and prevents any action if the sources do not match. Exception includes the event of adding +new permission policies to the RBAC Admin role defined by the configuration file. Sources include 'REST, 'CSV', 'Configuration', and 'legacy'. + +Before updating, ensure that you have attempted to migrate all permission policies and roles to a single source. This can be done by checking source information through the REST API and +by querying the database. Make updates through one of the available avenues: REST API, CSV file, and the database. + +To view the originating source for a particular role, query the role-metadata table or use the GET roles endpoint. + +- feat(rbac): remove the ability to add permission policies to configuration role + +- feat(rbac): remove no longer needed check for source in EnforcerDelegate + +- feat(rbac): update yarn lock + +- feat(rbac): address review comments + +### Features + +- **rbac:** improve validation from source ([#1643](https://github.com/janus-idp/backstage-plugins/issues/1643)) ([5f983cb](https://github.com/janus-idp/backstage-plugins/commit/5f983cbc0184e0a8e74f7e89cdff71d5ed5cd2fa)) + +## @backstage-community/plugin-rbac-backend [3.3.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@3.2.0...@backstage-community/plugin-rbac-backend@3.3.0) (2024-05-29) + +### Features + +- **rbac:** improve conditional policy validation ([#1673](https://github.com/janus-idp/backstage-plugins/issues/1673)) ([15dac91](https://github.com/janus-idp/backstage-plugins/commit/15dac91b673c63a4e7ac41f95296651df2ef8053)) + +## @backstage-community/plugin-rbac-backend [3.2.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@3.1.1...@backstage-community/plugin-rbac-backend@3.2.0) (2024-05-21) + +### Features + +- **topology:** add permissions to topology plugin ([#1665](https://github.com/janus-idp/backstage-plugins/issues/1665)) ([9d8f244](https://github.com/janus-idp/backstage-plugins/commit/9d8f244ae136cdf1980a5abf416180bce3f235ea)) + +## @backstage-community/plugin-rbac-backend [3.1.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@3.1.0...@backstage-community/plugin-rbac-backend@3.1.1) (2024-05-16) + +## @backstage-community/plugin-rbac-backend [3.1.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@3.0.0...@backstage-community/plugin-rbac-backend@3.1.0) (2024-05-14) + +### Features + +- **rbac:** implement a file watcher for csv reloads ([#1587](https://github.com/janus-idp/backstage-plugins/issues/1587)) ([62fcafc](https://github.com/janus-idp/backstage-plugins/commit/62fcafcdb3ab3cb308b16b8fab0a14916b921b82)) + +### Bug Fixes + +- **rbac:** fix sonar cloud issues for rbac-backend plugin ([#1619](https://github.com/janus-idp/backstage-plugins/issues/1619)) ([bf93354](https://github.com/janus-idp/backstage-plugins/commit/bf9335404232f8ec66253f56387d3432d8839406)) + +## @backstage-community/plugin-rbac-backend [3.0.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.8.2...@backstage-community/plugin-rbac-backend@3.0.0) (2024-05-10) + +### ⚠ BREAKING CHANGES + +- **rbac:** remove token manager for auth service (#1632) + +### Bug Fixes + +- **rbac:** remove token manager for auth service ([#1632](https://github.com/janus-idp/backstage-plugins/issues/1632)) ([2f19655](https://github.com/janus-idp/backstage-plugins/commit/2f196556cffc61c83239721b1cd51d6a2c64eee7)) + +## @backstage-community/plugin-rbac-backend [2.8.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.8.1...@backstage-community/plugin-rbac-backend@2.8.2) (2024-05-09) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.4.2 +- **@backstage-community/plugin-rbac-node:** upgraded to 1.1.1 + +## @backstage-community/plugin-rbac-backend [2.8.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.8.0...@backstage-community/plugin-rbac-backend@2.8.1) (2024-05-07) + +### Bug Fixes + +- **rbac:** implement ability to disable rbac-backend plugin ([#1501](https://github.com/janus-idp/backstage-plugins/issues/1501)) ([6367965](https://github.com/janus-idp/backstage-plugins/commit/6367965c550286dc8423b0942341ecee178dc6c1)) + +## @backstage-community/plugin-rbac-backend [2.8.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.7.1...@backstage-community/plugin-rbac-backend@2.8.0) (2024-05-07) + +### Features + +- **rbac:** add support for the new backend services ([#1607](https://github.com/janus-idp/backstage-plugins/issues/1607)) ([2892709](https://github.com/janus-idp/backstage-plugins/commit/2892709860987c6f4b36d821afa2e612b220d030)) + +## @backstage-community/plugin-rbac-backend [2.7.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.7.0...@backstage-community/plugin-rbac-backend@2.7.1) (2024-05-06) + +### Bug Fixes + +- **ocm:** update ocm frontend plugin readme ([#1611](https://github.com/janus-idp/backstage-plugins/issues/1611)) ([9960cc0](https://github.com/janus-idp/backstage-plugins/commit/9960cc0c2d611cdd1ee10a82ed02b7be9becefcf)) + +## @backstage-community/plugin-rbac-backend [2.7.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.6.4...@backstage-community/plugin-rbac-backend@2.7.0) (2024-04-25) + +### Features + +- **rbac:** add the optional maxDepth feature ([#1486](https://github.com/janus-idp/backstage-plugins/issues/1486)) ([ea87f34](https://github.com/janus-idp/backstage-plugins/commit/ea87f3412eb374123ea623332de0648d4c7bda5c)) +- **rbac:** lazy load temporary enforcer ([#1513](https://github.com/janus-idp/backstage-plugins/issues/1513)) ([b5f1552](https://github.com/janus-idp/backstage-plugins/commit/b5f1552f069068af43a4ca2756a5a38187f6d453)) + +## @backstage-community/plugin-rbac-backend [2.6.4](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.6.3...@backstage-community/plugin-rbac-backend@2.6.4) (2024-04-17) + +### Bug Fixes + +- **rbac:** reduce the number of permissions returned, add isResourced flag ([#1474](https://github.com/janus-idp/backstage-plugins/issues/1474)) ([e5dda95](https://github.com/janus-idp/backstage-plugins/commit/e5dda95bfc87d1d5d404726cbbe05c8bfdb73845)) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.4.1 + +## @backstage-community/plugin-rbac-backend [2.6.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.6.2...@backstage-community/plugin-rbac-backend@2.6.3) (2024-04-15) + +### Dependencies + +- **@backstage-community/plugin-rbac-node:** upgraded to 1.1.0 + +## @backstage-community/plugin-rbac-backend [2.6.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.6.1...@backstage-community/plugin-rbac-backend@2.6.2) (2024-04-09) + +### Dependencies + +- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.6 + +## @backstage-community/plugin-rbac-backend [2.6.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.6.0...@backstage-community/plugin-rbac-backend@2.6.1) (2024-04-08) + +### Dependencies + +- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.5 + +## @backstage-community/plugin-rbac-backend [2.6.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.5.1...@backstage-community/plugin-rbac-backend@2.6.0) (2024-04-05) + +### Features + +- **rbac:** save role modification information to the metadata ([#1280](https://github.com/janus-idp/backstage-plugins/issues/1280)) ([0454509](https://github.com/janus-idp/backstage-plugins/commit/0454509e41db2ae332d1b2bf8f72d34241483efd)) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.4.0 +- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.5 + +## @backstage-community/plugin-rbac-backend [2.5.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.5.0...@backstage-community/plugin-rbac-backend@2.5.1) (2024-04-04) + +### Bug Fixes + +- **rbac:** rework condition policies to bound them to RBAC roles ([#1330](https://github.com/janus-idp/backstage-plugins/issues/1330)) ([55c00b2](https://github.com/janus-idp/backstage-plugins/commit/55c00b21b27b449cb0e5100c7b64a6ae742536ac)) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.3.2 + +## @backstage-community/plugin-rbac-backend [2.5.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.4.1...@backstage-community/plugin-rbac-backend@2.5.0) (2024-03-29) + +### Features + +- **rbac:** load filtered policies before enforcing ([#1387](https://github.com/janus-idp/backstage-plugins/issues/1387)) ([66980ba](https://github.com/janus-idp/backstage-plugins/commit/66980baebd4d8b5b398646bcab1750c0edec715e)) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.3.1 +- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.4 + +## @backstage-community/plugin-rbac-backend [2.4.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.4.0...@backstage-community/plugin-rbac-backend@2.4.1) (2024-03-19) + +### Bug Fixes + +- **rbac:** pass token to readUrl for well-known permission endpoint ([#1342](https://github.com/janus-idp/backstage-plugins/issues/1342)) ([36b7c77](https://github.com/janus-idp/backstage-plugins/commit/36b7c7739753bd1cc55d10aa68d41ed7e15162e6)) + +## @backstage-community/plugin-rbac-backend [2.4.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.3.5...@backstage-community/plugin-rbac-backend@2.4.0) (2024-03-14) + +### Features + +- **rbac:** query the catalog database when building graph ([#1298](https://github.com/janus-idp/backstage-plugins/issues/1298)) ([c2c9e22](https://github.com/janus-idp/backstage-plugins/commit/c2c9e22e90a594e2a44d1683a05d3111c4baa97b)) + +### Bug Fixes + +- **rbac:** remove admin metadata, when all admins removed from config ([#1314](https://github.com/janus-idp/backstage-plugins/issues/1314)) ([cc6555e](https://github.com/janus-idp/backstage-plugins/commit/cc6555ea22a191c9f9f554b1909b67e517deee71)) + +## @backstage-community/plugin-rbac-backend [2.3.5](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.3.4...@backstage-community/plugin-rbac-backend@2.3.5) (2024-03-07) + +### Bug Fixes + +- **rbac:** check source before throwing duplicate warning ([#1278](https://github.com/janus-idp/backstage-plugins/issues/1278)) ([a100eef](https://github.com/janus-idp/backstage-plugins/commit/a100eef67983ba73d929864f0b64991de69718d0)) + +## @backstage-community/plugin-rbac-backend [2.3.4](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.3.3...@backstage-community/plugin-rbac-backend@2.3.4) (2024-03-04) + +### Dependencies + +- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.3 + +## @backstage-community/plugin-rbac-backend [2.3.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.3.2...@backstage-community/plugin-rbac-backend@2.3.3) (2024-02-29) + +### Documentation + +- **rbac:** update to the rbac documentation ([#1268](https://github.com/janus-idp/backstage-plugins/issues/1268)) ([5c7253b](https://github.com/janus-idp/backstage-plugins/commit/5c7253b7d0646433c55f185092648f0816aee88e)) + +## @backstage-community/plugin-rbac-backend [2.3.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.3.1...@backstage-community/plugin-rbac-backend@2.3.2) (2024-02-28) + +### Bug Fixes + +- **rbac:** improve error handling in retrieving permission metadata. ([#1285](https://github.com/janus-idp/backstage-plugins/issues/1285)) ([77f5f0e](https://github.com/janus-idp/backstage-plugins/commit/77f5f0efaadf1873b68876f11ca633646ce882b9)) + +## @backstage-community/plugin-rbac-backend [2.3.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.3.0...@backstage-community/plugin-rbac-backend@2.3.1) (2024-02-27) + +### Dependencies + +- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.2 + +## @backstage-community/plugin-rbac-backend [2.3.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.2.4...@backstage-community/plugin-rbac-backend@2.3.0) (2024-02-21) + +### Features + +- **rbac:** backend part - store role description to the database ([#1178](https://github.com/janus-idp/backstage-plugins/issues/1178)) ([ec8b1c2](https://github.com/janus-idp/backstage-plugins/commit/ec8b1c27cce5c36997f84a068dc4cc5cc542f428)) + +### Bug Fixes + +- **rbac:** reduce the catalog calls when build graph ([#1203](https://github.com/janus-idp/backstage-plugins/issues/1203)) ([e63aac2](https://github.com/janus-idp/backstage-plugins/commit/e63aac2a8e7513974a5aabb3ce25c838d6b34dde)) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.3.0 +- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.1 + +## @backstage-community/plugin-rbac-backend [2.2.4](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.2.3...@backstage-community/plugin-rbac-backend@2.2.4) (2024-02-20) + +### Bug Fixes + +- **rbac:** drop database disabled mode ([#1214](https://github.com/janus-idp/backstage-plugins/issues/1214)) ([b18d80d](https://github.com/janus-idp/backstage-plugins/commit/b18d80dd14e6b7f4f9c90d72ec418609ff1f6a67)) + +## @backstage-community/plugin-rbac-backend [2.2.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.2.2...@backstage-community/plugin-rbac-backend@2.2.3) (2024-02-14) + +### Bug Fixes + +- **rbac:** allow for super users to have allow all access ([#1208](https://github.com/janus-idp/backstage-plugins/issues/1208)) ([c02a4b0](https://github.com/janus-idp/backstage-plugins/commit/c02a4b029a800b1bcf1f2e2722185faae1e5837e)) + +## @backstage-community/plugin-rbac-backend [2.2.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.2.1...@backstage-community/plugin-rbac-backend@2.2.2) (2024-02-13) + +### Bug Fixes + +- **rbac:** display resource typed permissions by name too ([#1197](https://github.com/janus-idp/backstage-plugins/issues/1197)) ([bc4e8e7](https://github.com/janus-idp/backstage-plugins/commit/bc4e8e783b1acd8088a45ffed4d902fd9515c2e8)) + +## @backstage-community/plugin-rbac-backend [2.2.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.2.0...@backstage-community/plugin-rbac-backend@2.2.1) (2024-02-12) + +### Bug Fixes + +- **rbac:** csv updates no longer require server restarts ([#1171](https://github.com/janus-idp/backstage-plugins/issues/1171)) ([ed6fe65](https://github.com/janus-idp/backstage-plugins/commit/ed6fe65d99a2c2facf832a84d29dabc8d339e328)) + +## @backstage-community/plugin-rbac-backend [2.2.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.1.3...@backstage-community/plugin-rbac-backend@2.2.0) (2024-02-08) + +### Features + +- add support for the new backend system to the `rbac-backend` plugin ([#1179](https://github.com/janus-idp/backstage-plugins/issues/1179)) ([d625cb2](https://github.com/janus-idp/backstage-plugins/commit/d625cb2470513862027e048c70944275043ce70a)) + +### Dependencies + +- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.0 + +## @backstage-community/plugin-rbac-backend [2.1.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.1.2...@backstage-community/plugin-rbac-backend@2.1.3) (2024-02-02) + +### Bug Fixes + +- **rbac:** set up higher jest timeout for rbac db tests ([#1163](https://github.com/janus-idp/backstage-plugins/issues/1163)) ([b8541f3](https://github.com/janus-idp/backstage-plugins/commit/b8541f3ac149446238dc07432116fafc23a48a82)) +- **rbac:** split policies and roles by source ([#1042](https://github.com/janus-idp/backstage-plugins/issues/1042)) ([03a678d](https://github.com/janus-idp/backstage-plugins/commit/03a678d96deeb1d42448e94ac95d735e61393a40)), closes [#1103](https://github.com/janus-idp/backstage-plugins/issues/1103) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.2.1 + +## @backstage-community/plugin-rbac-backend [2.1.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.1.1...@backstage-community/plugin-rbac-backend@2.1.2) (2024-01-30) + +### Bug Fixes + +- **rbac:** enable create button for default role:default/rbac_admin ([#1137](https://github.com/janus-idp/backstage-plugins/issues/1137)) ([9926463](https://github.com/janus-idp/backstage-plugins/commit/9926463c8c46871b823796adf77bbd52eb8e6758)) + +## @backstage-community/plugin-rbac-backend [2.1.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.1.0...@backstage-community/plugin-rbac-backend@2.1.1) (2024-01-23) + +### Bug Fixes + +- **rbac:** fix work resource permission specified by name ([#940](https://github.com/janus-idp/backstage-plugins/issues/940)) ([3601eb8](https://github.com/janus-idp/backstage-plugins/commit/3601eb8d0c19e0aad27031ab61f1afa0edc78945)) + +## @backstage-community/plugin-rbac-backend [2.1.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.0.0...@backstage-community/plugin-rbac-backend@2.1.0) (2024-01-17) + +### Features + +- **Notifications:** new notifications FE plugin, API and backend ([#933](https://github.com/janus-idp/backstage-plugins/issues/933)) ([4d4cb78](https://github.com/janus-idp/backstage-plugins/commit/4d4cb781ca9fc331a2c621583e9203f9e4585ee7)) +- **rbac:** add doc about RBAC backend conditions API ([#1027](https://github.com/janus-idp/backstage-plugins/issues/1027)) ([fc9ad53](https://github.com/janus-idp/backstage-plugins/commit/fc9ad5348d768423cbce0df7e2a4239c9a24a11e)) + +### Bug Fixes + +- **rbac:** fix role validation ([#1020](https://github.com/janus-idp/backstage-plugins/issues/1020)) ([49c7975](https://github.com/janus-idp/backstage-plugins/commit/49c7975f74a1791e205fe3a322f1efe6504212ed)) + +## @backstage-community/plugin-rbac-backend [2.0.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.7.1...@backstage-community/plugin-rbac-backend@2.0.0) (2023-12-14) + +### ⚠ BREAKING CHANGES + +- **rbac:** add support for multiple policies CRUD (#984) + +### Features + +- **rbac:** add support for multiple policies CRUD ([#984](https://github.com/janus-idp/backstage-plugins/issues/984)) ([518c767](https://github.com/janus-idp/backstage-plugins/commit/518c7674aa037669fe9c2fc6f8dc9be5f0c8fa84)) + +## @backstage-community/plugin-rbac-backend [1.7.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.7.0...@backstage-community/plugin-rbac-backend@1.7.1) (2023-12-08) + +### Documentation + +- **rbac:** add documentation for api and known permissions ([#1000](https://github.com/janus-idp/backstage-plugins/issues/1000)) ([8f8133f](https://github.com/janus-idp/backstage-plugins/commit/8f8133f12d2a74dc6503f7545942f11c40b52092)) + +## @backstage-community/plugin-rbac-backend [1.7.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.6.6...@backstage-community/plugin-rbac-backend@1.7.0) (2023-12-07) + +### Features + +- **rbac:** list roles with no permission policies ([#998](https://github.com/janus-idp/backstage-plugins/issues/998)) ([217b7b0](https://github.com/janus-idp/backstage-plugins/commit/217b7b0db3414788c8e77247f378a51cf0eeda0d)) + +## @backstage-community/plugin-rbac-backend [1.6.6](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.6.5...@backstage-community/plugin-rbac-backend@1.6.6) (2023-12-05) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.2.0 + +## @backstage-community/plugin-rbac-backend [1.6.5](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.6.4...@backstage-community/plugin-rbac-backend@1.6.5) (2023-12-04) + +### Documentation + +- **rbac:** additional docs for backend configuration ([#982](https://github.com/janus-idp/backstage-plugins/issues/982)) ([17b95a0](https://github.com/janus-idp/backstage-plugins/commit/17b95a0c51e97ee5a9160dc7bec7559c075eca88)) + +## @backstage-community/plugin-rbac-backend [1.6.4](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.6.3...@backstage-community/plugin-rbac-backend@1.6.4) (2023-11-20) + +### Bug Fixes + +- **aap+3scale+ocm:** don't log sensitive data from errors ([#945](https://github.com/janus-idp/backstage-plugins/issues/945)) ([7a5e7b8](https://github.com/janus-idp/backstage-plugins/commit/7a5e7b8a57c9841003d9b16e1a65fb62e101fbf1)) + +## @backstage-community/plugin-rbac-backend [1.6.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.6.2...@backstage-community/plugin-rbac-backend@1.6.3) (2023-11-13) + +### Bug Fixes + +- **rbac:** use the same Knex version with Backstage ([#929](https://github.com/janus-idp/backstage-plugins/issues/929)) ([6923ce0](https://github.com/janus-idp/backstage-plugins/commit/6923ce07d787ea6edd911ab348704ba6b9f95ada)) + +## @backstage-community/plugin-rbac-backend [1.6.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.6.1...@backstage-community/plugin-rbac-backend@1.6.2) (2023-11-10) + +### Bug Fixes + +- **rbac:** handle postgres ssl connection for rbac backend plugin ([#923](https://github.com/janus-idp/backstage-plugins/issues/923)) ([deb2026](https://github.com/janus-idp/backstage-plugins/commit/deb202642f456cda446a99f55a475eeaddc59e7c)) + +## @backstage-community/plugin-rbac-backend [1.6.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.6.0...@backstage-community/plugin-rbac-backend@1.6.1) (2023-11-01) + +### Bug Fixes + +- **rbac:** add migration folder to rbac-backend package ([#897](https://github.com/janus-idp/backstage-plugins/issues/897)) ([694a9d6](https://github.com/janus-idp/backstage-plugins/commit/694a9d65bd986eb8e7fde3d66e012963033741af)) + +## @backstage-community/plugin-rbac-backend [1.6.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.5.1...@backstage-community/plugin-rbac-backend@1.6.0) (2023-10-31) + +### Features + +- **rbac:** implement REST method to list all plugin permission policies ([#808](https://github.com/janus-idp/backstage-plugins/issues/808)) ([0a17e67](https://github.com/janus-idp/backstage-plugins/commit/0a17e67cbb72416176e978fc3ed8868855375a8b)) + +## @backstage-community/plugin-rbac-backend [1.5.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.5.0...@backstage-community/plugin-rbac-backend@1.5.1) (2023-10-30) + +### Bug Fixes + +- **rbac:** fix service to service requests for RBAC CRUD ([#886](https://github.com/janus-idp/backstage-plugins/issues/886)) ([0b72d73](https://github.com/janus-idp/backstage-plugins/commit/0b72d7373dddc3f4d8c5076ca3800745bf619d85)) + +## @backstage-community/plugin-rbac-backend [1.5.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.4.0...@backstage-community/plugin-rbac-backend@1.5.0) (2023-10-30) + +### Features + +- **rbac:** implement conditional policies feature. ([#833](https://github.com/janus-idp/backstage-plugins/issues/833)) ([3c0675b](https://github.com/janus-idp/backstage-plugins/commit/3c0675ba6ebf91274848981fa1e6eab9e4a1e659)) + +## @backstage-community/plugin-rbac-backend [1.4.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.3.0...@backstage-community/plugin-rbac-backend@1.4.0) (2023-10-30) + +### Features + +- **rbac:** add role support for policies-csv-file ([#894](https://github.com/janus-idp/backstage-plugins/issues/894)) ([7ad4902](https://github.com/janus-idp/backstage-plugins/commit/7ad4902be12a9900149a73427a6c52cbb65659f3)) + +## @backstage-community/plugin-rbac-backend [1.3.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.2.1...@backstage-community/plugin-rbac-backend@1.3.0) (2023-10-27) + +### Features + +- **rbac:** implement the concept of roles in rbac ([#867](https://github.com/janus-idp/backstage-plugins/issues/867)) ([4d878a2](https://github.com/janus-idp/backstage-plugins/commit/4d878a29babd86bd7896d69e6b2b63392b6e6cc8)) + +### Bug Fixes + +- **rbac:** add models folder and config.d.ts to package ([#891](https://github.com/janus-idp/backstage-plugins/issues/891)) ([406c147](https://github.com/janus-idp/backstage-plugins/commit/406c14703110018c702834482d32fdd4f8a36cef)) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.1.0 + +## @backstage-community/plugin-rbac-backend [1.2.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.2.0...@backstage-community/plugin-rbac-backend@1.2.1) (2023-10-24) + +### Bug Fixes + +- **rbac:** use token manager for catalog requests ([#866](https://github.com/janus-idp/backstage-plugins/issues/866)) ([8ad3480](https://github.com/janus-idp/backstage-plugins/commit/8ad348029cec4eabf605c7065e76a5305be3cac8)) + +## @backstage-community/plugin-rbac-backend [1.2.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.1.1...@backstage-community/plugin-rbac-backend@1.2.0) (2023-10-23) + +### Features + +- **cli:** add frontend dynamic plugins base build config ([#747](https://github.com/janus-idp/backstage-plugins/issues/747)) ([91e06da](https://github.com/janus-idp/backstage-plugins/commit/91e06da8ab108c17fd2a6531f25e01c7a7350276)), closes [#831](https://github.com/janus-idp/backstage-plugins/issues/831) + +## @backstage-community/plugin-rbac-backend [1.1.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.1.0...@backstage-community/plugin-rbac-backend@1.1.1) (2023-10-19) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.0.1 + +## @backstage-community/plugin-rbac-backend [1.1.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.0.2...@backstage-community/plugin-rbac-backend@1.1.0) (2023-10-06) + +### Features + +- **rbac:** implement RBAC group support ([#803](https://github.com/janus-idp/backstage-plugins/issues/803)) ([4c72f5c](https://github.com/janus-idp/backstage-plugins/commit/4c72f5c23324ea2f7538b406d60730ea224ae758)) + +## @backstage-community/plugin-rbac-backend [1.0.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.0.1...@backstage-community/plugin-rbac-backend@1.0.2) (2023-10-04) + +### Bug Fixes + +- **rbac:** add models folder to package ([#823](https://github.com/janus-idp/backstage-plugins/issues/823)) ([e2bc66e](https://github.com/janus-idp/backstage-plugins/commit/e2bc66edac61a16ec92f75fb48c8ad459f24a23a)) + +## @backstage-community/plugin-rbac-backend [1.0.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.0.0...@backstage-community/plugin-rbac-backend@1.0.1) (2023-10-03) + +### Documentation + +- **rbac:** initial documentation for RBAC ([#814](https://github.com/janus-idp/backstage-plugins/issues/814)) ([d5cd566](https://github.com/janus-idp/backstage-plugins/commit/d5cd5666c43be5ca2790b1c548f56350ef50c96c)) + +## @backstage-community/plugin-rbac-backend 1.0.0 (2023-09-29) + +### Bug Fixes + +- **rbac:** remove private package ([#809](https://github.com/janus-idp/backstage-plugins/issues/809)) ([cf59d6d](https://github.com/janus-idp/backstage-plugins/commit/cf59d6d1c5a65363a7ccdd7490d3148d665e7d46)) + +### Dependencies + +- **@backstage-community/plugin-rbac-common:** upgraded to 1.0.0 diff --git a/plugins/rbac-backend/README.md b/plugins/rbac-backend/README.md new file mode 100644 index 0000000000..4e421ab598 --- /dev/null +++ b/plugins/rbac-backend/README.md @@ -0,0 +1,364 @@ +# RBAC backend plugin for Backstage + +This plugin seamlessly integrates with the [Backstage permission framework](https://backstage.io/docs/permissions/overview/) to empower you with robust role-based access control capabilities within your Backstage environment. + +The Backstage permission framework is a core component of the Backstage project, designed to provide meticulous control over resource and action access. Our RBAC plugin harnesses the power of this framework, allowing you to tailor access permissions without the need for coding. Instead, you can effortlessly manage your access policies through User interface embedded within Backstage or via the configuration files. + +With the RBAC plugin, you'll have the means to efficiently administer permissions within your Backstage instance by assigning them to users and groups. + +## Prerequisites + +Before you dive into utilizing the RBAC plugin for Backstage, there are a few essential prerequisites to ensure a seamless experience. Please review the following requirements to make sure your environment is properly set up + +### Setup Permission Framework + +**NOTE**: This section is only relevant if you are still on the old backend system. + +To effectively utilize the RBAC plugin, you must have the Backstage permission framework in place. If you're using the Red Hat Developer Hub, some of these steps may have already been completed for you. However, for other Backstage application instances, please verify that the following prerequisites are satisfied: + +You need to [set up the permission framework in Backstage](https://backstage.io/docs/permissions/getting-started/).Since this plugin provides a dynamic policy that replaces the traditional one, there's no need to create a policy manually. Please note that one of the requirements for permission framework is enabling the [service-to-service authentication](https://backstage.io/docs/auth/service-to-service-auth/#setup). Ensure that you complete these authentication setup steps as well. + +### Identity resolver + +The permission framework, and consequently, this RBAC plugin, rely on the concept of group membership. To ensure smooth operation, please follow the [Sign-in identities and resolvers](https://backstage.io/docs/auth/identity-resolver/) documentation. It's crucial that when populating groups, you include any groups that you plan to assign permissions to. + +## Installation + +To integrate the RBAC plugin into your Backstage instance, follow these steps. + +### Installing the plugin + +Add the RBAC plugin packages as dependencies by running the following command. + +```SHELL +yarn workspace backend add @backstage-community/plugin-rbac-backend +``` + +**NOTE**: If you are using Red Hat Developer Hub, backend plugin is pre-installed and you do not need this step. + +### Configuring the Backend + +#### New Backend System + +The RBAC plugin supports the integration with the new backend system. + +Add the RBAC plugin to the `packages/backend/src/index.ts` file and remove the Allow All Permission policy module. + +```diff +// permission plugin +backend.add(import('@backstage/plugin-permission-backend')); +- backend.add( +- import('@backstage/plugin-permission-backend-module-allow-all-policy'), +- ); ++ backend.add(import('@backstage-community/plugin-rbac-backend')); +``` + +### Configure policy admins + +The RBAC plugin empowers you to manage permission policies for users and groups with a designated group of individuals known as policy administrators. These administrators are granted access to the RBAC plugin's REST API and user interface as well as the ability to read from the catalog. + +You can specify the policy administrators in your application configuration as follows: + +```YAML +permission: + enabled: true + rbac: + admin: + users: + - name: user:default/alice + - name: group:default/admins +``` + +The RBAC plugin also enables you to grant users the title of 'super user,' which provides them with unrestricted access throughout the Backstage instance. + +You can specify the super users in your application configuration as follows: + +```YAML +permission: + enabled: true + rbac: + admin: + superUsers: + - name: user:default/alice + - name: user:default/mike + - name: group:default/admins +``` + +> **Note:** **Transient memberships are not supported for `superUsers`.** Meaning, when a group is specified as a super user, only direct group memberships are taken into account. Users who belong to a sub-group of a configured super user group will not be granted super user access. + +For more information on the available API endpoints accessible to the policy administrators, refer to the [API documentation](./docs/apis.md). + +### Configure default role + +You can optionally assign a default role to all authenticated users by using `defaultPermissions.defaultRole`. +This ensures that every authenticated user receives the specified role in addition to any other roles they may have. +You can also define baseline permissions for that role using `defaultPermissions.basicPermissions`. +This is especially useful when using [Sign-In without Users in the Catalog](https://backstage.io/docs/auth/identity-resolver/#sign-in-without-users-in-the-catalog). + +```YAML +permission: + rbac: + defaultPermissions: + defaultRole: role:default/my-default-role + basicPermissions: + - permission: catalog.entity.read + action: read + - permission: catalog-entity + action: read + - permission: catalog.entity.create + action: create +``` + +If configured, the RBAC backend will automatically include the default role in each authenticated user's roles and evaluate the configured `basicPermissions` for that role. +When `defaultPermissions.defaultRole` is set, `defaultPermissions.basicPermissions` must contain at least one permission entry. + +### Configure plugins with permission + +In order for the RBAC UI to display the available permissions provided by installed plugins, you must supply the corresponding list of plugin IDs. There are two ways to achieve this: + +- Application configuration(`app-config.yaml`) +- REST API + +#### Configure plugins with Application configuration + +You can specify the plugins with permissions in your application configuration as follows: + +```YAML +permission: + enabled: true + rbac: + pluginsWithPermission: + - catalog + - scaffolder + - permission + admin: + users: + - name: user:default/alice + - name: group:default/admins +``` + +#### Configure plugins with REST API + +You can specify the plugins with permissions using the corresponding [REST API](./docs/apis.md#plugin-ids-that-support-the-backstage-permission-framework). + +Curl Examples: + +Get the object containing the list of plugin IDs: + +``` +curl -X GET "http://localhost:7007/api/permission/plugins/id" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $token" -v +``` + +Add more plugin IDs: + +``` +curl -X POST "http://localhost:7007/api/permission/plugins/id" \ + -d '{ "ids": [ "permission", "scaffolder" ] }' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $token" -v +``` + +Remove plugin IDs: + +``` +curl -X DELETE "http://localhost:7007/api/permission/plugins/id" \ + -d '{ "ids": [ "permission", "scaffolder" ] }' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $token" -v +``` + +Notice: The REST API does not allow deletion of plugin IDs that were provided via application configuration, in order to prevent an inconsistent state after a deployment restart. These ID values can only be removed through the configuration file. + +For more information on the available permissions, refer to the [RBAC permissions documentation](./docs/permissions.md). + +### Configuring policies via file + +The RBAC plugin also allows you to import policies from an external file. These policies are defined in the [Casbin rules format](https://casbin.org/docs/category/the-basics), known for its simplicity and clarity. For a quick start, please refer to the format details in the provided link. + +Here's an example of an external permission policies configuration file named `rbac-policy.csv`: + +```CSV +p, role:default/team_a, catalog-entity, read, deny +p, role:default/team_b, catalog.entity.create, create, deny + +g, user:default/bob, role:default/team_a + +g, group:default/team_b, role:default/team_b +``` + +--- + +**NOTE**: When you add a role in the permission policies configuration file, ensure that the role is associated with at least one permission policy with the `allow` effect. + +--- + +You can specify the path to this configuration file in your application configuration: + +```YAML +permission: + enabled: true + rbac: + policies-csv-file: /some/path/rbac-policy.csv +``` + +Also, there is an additional configuration value that allows for the reloading of the CSV file without the need to restart. + +```YAML +permission: + enabled: true + rbac: + policies-csv-file: /some/path/rbac-policy.csv + policyFileReload: true +``` + +For more information on the available permissions, refer to the [RBAC permissions documentation](./docs/permissions.md). + +We also have a fairly strict validation for permission policies and roles based on the originating role's source information, refer to the [api documentation](./docs/apis.md). + +### Configuring conditional policies via file + +The RBAC plugin allows you to import conditional policies from an external file. User can defined conditional policies for roles created with the help of the policies-csv-file. Conditional policies should be defined as object sequences in the YAML format. + +You can specify the path to this configuration file in your application configuration: + +```YAML +permission: + enabled: true + rbac: + conditionalPoliciesFile: /some/path/conditional-policies.yaml + policies-csv-file: /some/path/rbac-policy.csv +``` + +Also, there is an additional configuration value that allows for the reloading of the file without the need to restart. + +```YAML +permission: + enabled: true + rbac: + conditionalPoliciesFile: /some/path/conditional-policies.yaml + policies-csv-file: /some/path/rbac-policy.csv + policyFileReload: true +``` + +This feature supports nested conditional policies. + +Example of the conditional policies file: + +```yaml +--- +result: CONDITIONAL +roleEntityRef: role:default/test +pluginId: catalog +resourceType: catalog-entity +permissionMapping: + - read + - update +conditions: + rule: IS_ENTITY_OWNER + resourceType: catalog-entity + params: + claims: + - group:default/team-a + - group:default/team-b +--- +result: CONDITIONAL +roleEntityRef: role:default/test +pluginId: catalog +resourceType: catalog-entity +permissionMapping: + - delete +conditions: + rule: IS_ENTITY_OWNER + resourceType: catalog-entity + params: + claims: + - group:default/team-a +``` + +Information about condition policies format you can find in the doc: [Conditional policies documentation](./docs/conditions.md). There is only one difference: yaml format compare to json. But yaml and json are back convertiable. + +### Configuring Database Storage for policies + +The RBAC plugin offers the option to store policies in a database. It supports two database storage options: + +- sqlite3: Suitable for development environments. +- postgres: Recommended for production environments. + +Ensure that you have already configured the database backend for your Backstage instance, as the RBAC plugin utilizes the same database configuration. + +#### Azure PostgreSQL Passwordless Authentication + +For Azure Database for PostgreSQL, the RBAC plugin supports passwordless authentication using Microsoft Entra ID (formerly Azure Active Directory). This provides enhanced security by using Azure-managed identities or service principals instead of passwords. + +To enable Azure passwordless authentication, configure your database connection with `type: azure`: + +```yaml +backend: + database: + client: pg + connection: + type: azure + host: ${POSTGRES_HOST} + user: ${POSTGRES_USER} + ssl: + rejectUnauthorized: false + tokenCredential: + # Option 1: Use system-assigned managed identity (no config needed) + # Option 2: Use user-assigned managed identity + clientId: ${AZURE_CLIENT_ID} + # Option 3: Use service principal (requires all three) + clientId: ${AZURE_CLIENT_ID} + tenantId: ${AZURE_TENANT_ID} + clientSecret: ${AZURE_CLIENT_SECRET} +``` + +**Authentication methods:** + +1. **System-assigned managed identity**: Omit all `tokenCredential` properties +2. **User-assigned managed identity**: Provide only `clientId` +3. **Service principal**: Provide `clientId`, `tenantId`, and `clientSecret` + +**Token renewal:** + +The RBAC plugin automatically handles Azure AD token renewal by leveraging the pg driver's support for password functions. Fresh tokens are fetched on each new database connection, ensuring uninterrupted operation even as tokens expire (typically after 60 minutes). The connection pool is configured with a 50-minute idle timeout to force connection recycling before token expiry. + +**Important notes:** + +- Ensure your Azure PostgreSQL server has Microsoft Entra authentication enabled and the appropriate database roles configured. +- The username should be the Entra ID principal name (e.g., `myuser@myserver` for managed identities). +- The Azure credentials must be available to the application environment (managed identity, environment variables, or Azure CLI). + +### Optional maximum depth + +The RBAC plugin also includes an option max depth feature for organizations with potentially complex group hierarchy, this configuration value will ensure that the RBAC plugin will stop at a certain depth when building user graphs. + +```YAML +permission: + enabled: true + rbac: + maxDepth: 1 +``` + +The maxDepth must be greater than 0 to ensure that the graphs are built correctly. Also the graph will be built with a hierarchy of 1 + maxDepth. + +More information about group hierarchy can be found in the doc: [Group hierarchy](./docs/group-hierarchy.md). + +### Optional RBAC provider module support + +We also include the ability to create and load in RBAC backend plugin modules that can be used to make connections to third part access management tools. For more information, consult the [RBAC Providers documentation](./docs/providers.md). + +### Optional configuration to control policy decision precedence + +Controls the evaluation order between permission policies (basic) and conditional policies for resource permissions. + +- Default: `conditional` (conditional policies take precedence when present) +- Set to `basic` to evaluate basic permission policy first + +```YAML +permission: + enabled: true + rbac: + policyDecisionPrecedence: basic # or conditional +``` diff --git a/plugins/rbac-backend/__fixtures__/auditor-test-utils.ts b/plugins/rbac-backend/__fixtures__/auditor-test-utils.ts new file mode 100644 index 0000000000..7ff8b0e0ca --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/auditor-test-utils.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { AuditorServiceCreateEventOptions } from '@backstage/backend-plugin-api'; + +import { mockAuditorService, createEventMock } from './mock-utils'; +import { type JsonObject } from '@backstage/types'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { EvaluationEvents } from '../src/auditor/auditor'; + +export function expectAuditorLog( + events: { + event: AuditorServiceCreateEventOptions; + success?: { meta?: JsonObject }; + fail?: { meta?: JsonObject; error: Error }; + }[], +) { + const auditEvents = mockAuditorService.createEvent.mock.calls; + const succeededEvents = createEventMock.success.mock.calls; + const failedEvents = createEventMock.fail.mock.calls; + + expect(auditEvents.length).toBe(events.length); + for (let i = 0; i < events.length; i++) { + const expectedEvent = { ...events[i].event, severityLevel: 'medium' }; + expect(auditEvents[i][0]).toEqual(expectedEvent); // verifies also eventId + if (events[i].success) { + expect(succeededEvents[i][0]).toEqual(events[i].success); + } + if (events[i].fail) { + expect(failedEvents[i][0]).toEqual(events[i].fail); + } + } +} + +export function expectAuditorLogForPermission( + user: string | undefined, + permissionName: string, + resourceType: string | undefined, + action: string, + result: AuthorizeResult, +) { + const expectedUser = user ?? 'user without entity'; + const meta = { + action, + permissionName, + resourceType, + userEntityRef: expectedUser, + }; + expectAuditorLog([ + { + event: { eventId: EvaluationEvents.PERMISSION_EVALUATION, meta }, + success: { + meta: { result }, + }, + }, + ]); +} + +export function clearAuditorMock() { + mockAuditorService.createEvent.mockClear(); + createEventMock.fail.mockClear(); + createEventMock.success.mockClear(); +} diff --git a/plugins/rbac-backend/__fixtures__/data/hierarchy/groups.ts b/plugins/rbac-backend/__fixtures__/data/hierarchy/groups.ts new file mode 100644 index 0000000000..513256aec1 --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/hierarchy/groups.ts @@ -0,0 +1,893 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export const GROUPS_FOR_TESTS = [ + { + name: 'thor_group_0', + namespace: null, + title: 'Thor Group 0', + children: [], + parent: null, + hasMember: ['user:default/thor'], + }, + { + name: 'wasp_group_0', + namespace: null, + title: 'Wasp Group 0', + children: [], + parent: null, + hasMember: ['user:default/wasp'], + }, + { + name: 'captain_america_group_0', + namespace: null, + title: 'Captain America Group 0', + children: [], + parent: 'captain_america_group_1', + hasMember: ['user:default/captain_america'], + }, + { + name: 'captain_america_group_1', + namespace: null, + title: 'Captain America Group 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'hawkeye_group_0', + namespace: null, + title: 'Hawkeye Group 0', + children: [], + parent: 'hawkeye_group_1', + hasMember: ['user:default/hawkeye'], + }, + { + name: 'hawkeye_group_1', + namespace: null, + title: 'Hawkeye Group 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'quicksilver_group_0', + namespace: null, + title: 'Quicksilver Group 0', + children: [], + parent: 'quicksilver_group_1', + hasMember: ['user:default/quicksilver'], + }, + { + name: 'quicksilver_group_1', + namespace: null, + title: 'Quicksilver Group 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'scarlet_witch_group_0', + namespace: null, + title: 'Scarlet Witch Group 0', + children: [], + parent: 'scarlet_witch_group_1', + hasMember: ['user:default/scarlet_witch'], + }, + { + name: 'scarlet_witch_group_1', + namespace: null, + title: 'Scarlet Witch Group 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'swordsman_group_0', + namespace: null, + title: 'Swordsman Group 0', + children: [], + parent: null, + hasMember: ['user:default/swordsman'], + }, + { + name: 'hercules_group_0', + namespace: null, + title: 'Hercules Group 0', + children: [], + parent: null, + hasMember: ['user:default/hercules'], + }, + { + name: 'black_panther_group_0', + namespace: null, + title: 'Black Panther Group 0', + children: [], + parent: null, + hasMember: ['user:default/black_panther'], + }, + { + name: 'vision_group_0', + namespace: null, + title: 'Vision Group 0', + children: [], + parent: null, + hasMember: ['user:default/vision'], + }, + { + name: 'black_knight_group_a', + namespace: null, + title: 'Black Knight Group A', + children: [], + parent: null, + hasMember: ['user:default/black_knight'], + }, + { + name: 'black_knight_group_b', + namespace: null, + title: 'Black Knight Group B', + children: [], + parent: null, + hasMember: ['user:default/black_knight'], + }, + { + name: 'black_widow_group_a', + namespace: null, + title: 'Black Widow Group A', + children: [], + parent: null, + hasMember: ['user:default/black_widow'], + }, + { + name: 'black_widow_group_b', + namespace: null, + title: 'Black Widow Group B', + children: [], + parent: null, + hasMember: ['user:default/black_widow'], + }, + { + name: 'mantis_group_a', + namespace: null, + title: 'Mantis Group A', + children: [], + parent: null, + hasMember: ['user:default/mantis'], + }, + { + name: 'mantis_group_b', + namespace: null, + title: 'Mantis Group B', + children: [], + parent: null, + hasMember: ['user:default/mantis'], + }, + { + name: 'beast_group_a', + namespace: null, + title: 'Beast Group A', + children: [], + parent: null, + hasMember: ['user:default/beast'], + }, + { + name: 'beast_group_b', + namespace: null, + title: 'Beast Group B', + children: [], + parent: null, + hasMember: ['user:default/beast'], + }, + { + name: 'moondragon_group_a_0', + namespace: null, + title: 'Moondragon Group A 0', + children: [], + parent: 'moondragon_group_a_1', + hasMember: ['user:default/moondragon'], + }, + { + name: 'moondragon_group_a_1', + namespace: null, + title: 'Moondragon Group A 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'moondragon_group_b_0', + namespace: null, + title: 'Moondragon Group B 0', + children: [], + parent: 'moondragon_group_b_1', + hasMember: ['user:default/moondragon'], + }, + { + name: 'moondragon_group_b_1', + namespace: null, + title: 'Moondragon Group B 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'hellcat_group_a_0', + namespace: null, + title: 'Hellcat Group A 0', + children: [], + parent: 'hellcat_group_a_1', + hasMember: ['user:default/hellcat'], + }, + { + name: 'hellcat_group_a_1', + namespace: null, + title: 'Hellcat Group A 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'hellcat_group_b_0', + namespace: null, + title: 'Hellcat Group B 0', + children: [], + parent: 'hellcat_group_b_1', + hasMember: ['user:default/hellcat'], + }, + { + name: 'hellcat_group_b_1', + namespace: null, + title: 'Hellcat Group B 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'captain_marvel_group_a_0', + namespace: null, + title: 'Captain Marvel Group A 0', + children: [], + parent: 'captain_marvel_group_a_1', + hasMember: ['user:default/captain_marvel'], + }, + { + name: 'captain_marvel_group_a_1', + namespace: null, + title: 'Captain Marvel Group A 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'captain_marvel_group_b_0', + namespace: null, + title: 'Captain Marvel Group B 0', + children: [], + parent: 'captain_marvel_group_b_1', + hasMember: ['user:default/captain_marvel'], + }, + { + name: 'captain_marvel_group_b_1', + namespace: null, + title: 'Captain Marvel Group B 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'falcon_group_a_0', + namespace: null, + title: 'Falcon Group A 0', + children: [], + parent: 'falcon_group_a_1', + hasMember: ['user:default/falcon'], + }, + { + name: 'falcon_group_a_1', + namespace: null, + title: 'Falcon Group A 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'falcon_group_b_0', + namespace: null, + title: 'Falcon Group B 0', + children: [], + parent: 'falcon_group_b_1', + hasMember: ['user:default/falcon'], + }, + { + name: 'falcon_group_b_1', + namespace: null, + title: 'Falcon Group B 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'wonder_man_group_0', + namespace: null, + title: 'Wonder Man Group 0', + children: [], + parent: 'wonder_man_group_1', + hasMember: ['user:default/wonder_man'], + }, + { + name: 'wonder_man_group_1', + namespace: null, + title: 'Wonder Man Group 1', + children: [], + parent: 'wonder_man_group_0', + hasMember: [], + }, + { + name: 'tigra_group_0', + namespace: null, + title: 'Tigra Group 0', + children: [], + parent: 'tigra_group_1', + hasMember: ['user:default/tigra'], + }, + { + name: 'tigra_group_1', + namespace: null, + title: 'Tigra Group 1', + children: [], + parent: 'tigra_group_0', + hasMember: [], + }, + { + name: 'she_hulk_group_0', + namespace: null, + title: 'She-Hulk Group 0', + children: [], + parent: 'she_hulk_group_1', + hasMember: ['user:default/she_hulk'], + }, + { + name: 'she_hulk_group_1', + namespace: null, + title: 'She-Hulk Group 1', + children: [], + parent: 'she_hulk_group_0', + hasMember: [], + }, + { + name: 'starfox_group_0', + namespace: null, + title: 'Starfox Group 0', + children: [], + parent: 'starfox_group_1', + hasMember: ['user:default/starfox'], + }, + { + name: 'starfox_group_1', + namespace: null, + title: 'Starfox Group 1', + children: [], + parent: 'starfox_group_0', + hasMember: [], + }, + { + name: 'mockingbird_group_0', + namespace: null, + title: 'Mockingbird Group 0', + children: [], + parent: 'mockingbird_group_1', + hasMember: ['user:default/mockingbird'], + }, + { + name: 'mockingbird_group_1', + namespace: null, + title: 'Mockingbird Group 1', + children: [], + parent: 'mockingbird_group_0', + hasMember: [], + }, + { + name: 'war_machine_group_0', + namespace: null, + title: 'War Machine Group 0', + children: [], + parent: 'war_machine_group_1', + hasMember: ['user:default/war_machine'], + }, + { + name: 'war_machine_group_1', + namespace: null, + title: 'War Machine Group 1', + children: [], + parent: 'war_machine_group_0', + hasMember: [], + }, + { + name: 'namor_group_a_0', + namespace: null, + title: 'Namor Group A 0', + children: [], + parent: 'namor_group_a_1', + hasMember: ['user:default/namor'], + }, + { + name: 'namor_group_a_1', + namespace: null, + title: 'Namor Group A 1', + children: [], + parent: 'namor_group_a_0', + hasMember: [], + }, + { + name: 'namor_group_b_0', + namespace: null, + title: 'Namor Group B 0', + children: [], + parent: 'namor_group_b_1', + hasMember: ['user:default/namor'], + }, + { + name: 'namor_group_b_1', + namespace: null, + title: 'Namor Group B 1', + children: [], + parent: 'namor_group_b_0', + hasMember: [], + }, + { + name: 'thing_group_a_0', + namespace: null, + title: 'Thing Group A 0', + children: [], + parent: 'thing_group_a_1', + hasMember: ['user:default/thing'], + }, + { + name: 'thing_group_a_1', + namespace: null, + title: 'Thing Group A 1', + children: [], + parent: 'thing_group_a_0', + hasMember: [], + }, + { + name: 'thing_group_b_0', + namespace: null, + title: 'Thing Group B 0', + children: [], + parent: 'thing_b_1', + hasMember: ['user:default/thing'], + }, + { + name: 'thing_group_b_1', + namespace: null, + title: 'Thing Group B 1', + children: [], + parent: 'thing_b_0', + hasMember: [], + }, + { + name: 'doctor_druid_group_a_0', + namespace: null, + title: 'Doctor Druid Group A 0', + children: [], + parent: 'doctor_druid_group_a_1', + hasMember: ['user:default/doctor_druid'], + }, + { + name: 'doctor_druid_group_a_1', + namespace: null, + title: 'Doctor Druid Group A 1', + children: [], + parent: 'doctor_druid_group_a_0', + hasMember: [], + }, + { + name: 'doctor_druid_group_b_0', + namespace: null, + title: 'Doctor Druid Group B 0', + children: [], + parent: 'doctor_druid_group_b_1', + hasMember: ['user:default/doctor_druid'], + }, + { + name: 'doctor_druid_group_b_1', + namespace: null, + title: 'Doctor Druid Group B 1', + children: [], + parent: 'doctor_druid_group_b_0', + hasMember: [], + }, + { + name: 'firebird_group_a_0', + namespace: null, + title: 'Firebird Group A 0', + children: [], + parent: 'firebird_group_a_1', + hasMember: ['user:default/firebird'], + }, + { + name: 'firebird_group_a_1', + namespace: null, + title: 'Firebird Group A 1', + children: [], + parent: 'firebird_group_a_0', + hasMember: [], + }, + { + name: 'firebird_group_b_0', + namespace: null, + title: 'Firebird Group B 0', + children: [], + parent: 'firebird_group_b_1', + hasMember: ['user:default/firebird'], + }, + { + name: 'firebird_group_b_1', + namespace: null, + title: 'Firebird Group B 1', + children: [], + parent: 'firebird_group_b_0', + hasMember: [], + }, + { + name: 'valkyrie_group_a_0', + namespace: null, + title: 'Valkyrie Group A 0', + children: [], + parent: 'valkyrie_group_a_1', + hasMember: ['user:default/valkyrie'], + }, + { + name: 'valkyrie_group_a_1', + namespace: null, + title: 'Valkyrie Group A 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'valkyrie_group_b_0', + namespace: null, + title: 'Valkyrie Group B 0', + children: [], + parent: 'valkyrie_group_b_1', + hasMember: ['user:default/valkyrie'], + }, + { + name: 'valkyrie_group_b_1', + namespace: null, + title: 'Valkyrie Group B 1', + children: [], + parent: 'valkyrie_group_b_0', + hasMember: [], + }, + { + name: 'nova_group_a_0', + namespace: null, + title: 'Nova Group A 0', + children: [], + parent: 'nova_group_a_1', + hasMember: ['user:default/nova'], + }, + { + name: 'nova_group_a_1', + namespace: null, + title: 'Nova Group A 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'nova_group_b_0', + namespace: null, + title: 'Nova Group B 0', + children: [], + parent: 'nova_group_b_1', + hasMember: ['user:default/nova'], + }, + { + name: 'nova_group_b_1', + namespace: null, + title: 'Nova Group B 1', + children: [], + parent: 'nova_group_b_0', + hasMember: [], + }, + { + name: 'storm_group_a_0', + namespace: null, + title: 'Storm Group A 0', + children: [], + parent: 'storm_group_a_1', + hasMember: ['user:default/storm'], + }, + { + name: 'storm_group_a_1', + namespace: null, + title: 'Storm Group A 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'storm_group_b_0', + namespace: null, + title: 'Storm Group B 0', + children: [], + parent: 'storm_group_b_1', + hasMember: ['user:default/storm'], + }, + { + name: 'storm_group_b_1', + namespace: null, + title: 'Storm Group B 1', + children: [], + parent: 'storm_group_b_0', + hasMember: [], + }, + { + name: 'daredevil_group_a_0', + namespace: null, + title: 'Daredevil Group A 0', + children: [], + parent: 'daredevil_group_a_1', + hasMember: ['user:default/daredevil'], + }, + { + name: 'daredevil_group_a_1', + namespace: null, + title: 'Daredevil Group A 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'daredevil_group_b_0', + namespace: null, + title: 'Daredevil Group B 0', + children: [], + parent: 'daredevil_group_b_1', + hasMember: ['user:default/daredevil'], + }, + { + name: 'daredevil_group_b_1', + namespace: null, + title: 'Daredevil Group B 1', + children: [], + parent: 'daredevil_group_b_0', + hasMember: [], + }, + { + name: 'spiderman_group_0', + namespace: null, + title: 'Spiderman Group 0', + children: [], + parent: 'spiderman_group_1', + hasMember: ['user:default/spiderman'], + }, + { + name: 'spiderman_group_1', + namespace: null, + title: 'Spiderman Group 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'moon_knight_group_0', + namespace: null, + title: 'Moon Knight Group 0', + children: [], + parent: 'moon_knight_group_1', + hasMember: ['user:default/moon_knight'], + }, + { + name: 'moon_knight_group_1', + namespace: null, + title: 'Moon Knight Group 1', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'cable_group_0', + namespace: null, + title: 'Cable Group 0', + children: [], + parent: null, + hasMember: ['user:default/cable'], + }, + { + name: 'ghost_rider_group_0', + namespace: null, + title: 'Ghost Rider Group 0', + children: [], + parent: null, + hasMember: ['user:default/ghost_rider'], + }, + { + name: 'admin', + namespace: null, + title: 'Admin', + children: [], + parent: null, + hasMember: ['user:default/admin_one'], + }, + { + name: 'team-a', + namespace: null, + title: 'Team A', + children: [], + parent: 'root-group', + hasMember: ['user:default/tor', 'user:default/adam'], + }, + { + name: 'team-b', + namespace: null, + title: 'Team B', + children: [], + parent: 'team-a', + hasMember: ['user:default/mike'], + }, + { + name: 'team-C', + namespace: null, + title: 'Team C', + children: [], + parent: 'team-a', + hasMember: ['user:default/tor', 'user:default/tom'], + }, + { + name: 'team-d', + namespace: null, + title: 'Team D', + children: [], + parent: 'team-a', + hasMember: ['user:default/george', 'user:default/john'], + }, + { + name: 'team-e', + namespace: null, + title: 'Team E', + children: [], + parent: 'team-f', + hasMember: [], + }, + { + name: 'team-f', + namespace: null, + title: 'Team F', + children: [], + parent: 'team-e', + hasMember: ['user:default/john'], + }, + { + name: 'team-g', + namespace: null, + title: 'Team G', + children: [], + parent: 'team-f', + hasMember: ['user:default/bill'], + }, + { + name: 'team-z', + namespace: null, + title: 'Team Z', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'team-y', + namespace: null, + title: 'Team Y', + children: [], + parent: 'team-z', + hasMember: ['user:default/mike'], + }, + { + name: 'team-x', + namespace: null, + title: 'Team X', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'root-group', + namespace: null, + title: 'Root Group', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'data_admin', + namespace: null, + title: 'Data Admin', + children: [], + parent: null, + hasMember: [ + 'user:default/alice', + 'user:default/akira', + 'user:default/antey', + ], + }, + { + name: 'data_read_admin', + namespace: null, + title: 'Data Read Admin', + children: [], + parent: 'data_parent_admin', + hasMember: ['user:default/mike', 'user:default/tom'], + }, + { + name: 'data_parent_admin', + namespace: null, + title: 'Data Parent Admin', + children: [], + parent: null, + hasMember: [], + }, + { + name: 'test-group', + namespace: null, + title: 'Test Group', + children: [], + parent: null, + hasMember: ['user:default/mike'], + }, + { + name: 'qa', + namespace: null, + title: 'QA Group', + children: [], + parent: null, + hasMember: ['user:default/mike'], + }, + { + name: 'team-hr', + namespace: null, + title: 'HR Group', + children: [], + parent: 'team-management', + hasMember: ['user:default/sally'], + }, + { + name: 'team-management', + namespace: null, + title: 'Management Group', + children: [], + parent: 'group:hq/team-management', + hasMember: [], + }, + { + name: 'team-management', + namespace: 'hq', + title: 'Management Group', + children: [], + parent: 'team-administration', + hasMember: [], + }, + { + name: 'team-administration', + namespace: 'hq', + title: 'Administration Group', + children: [], + parent: 'group:default/root-group', + hasMember: [], + }, +]; diff --git a/plugins/rbac-backend/__fixtures__/data/hierarchy/rbac-policy.csv b/plugins/rbac-backend/__fixtures__/data/hierarchy/rbac-policy.csv new file mode 100644 index 0000000000..9e74a2676f --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/hierarchy/rbac-policy.csv @@ -0,0 +1,245 @@ +# Test one: user:default/ant_man, expect allow +g, user:default/ant_man, role:default/ant_man +p, role:default/ant_man, catalog-entity, read, allow + +# Test two: user:default/hulk, expect deny +g, user:default/hulk, role:default/hulk +p, role:default/hulk, catalog-entity, read, deny + +# Test three: user:default/thor, expect allow +g, group:default/thor_group_0, role:default/thor_group_0 +p, role:default/thor_group_0, catalog-entity, read, allow + +# Test four: user:default/wasp, expect deny +g, group:default/wasp_group_0, role:default/wasp_group_0 +p, role:default/wasp_group_0, catalog-entity, read, deny + +# Test five: user:default/moon_knight, expect allow +g, group:default/moon_knight_group_1, role:default/moon_knight_group_1 +p, role:default/moon_knight_group_1, catalog-entity, read, allow + +# Test six: user:default/spiderman, expect deny +g, group:default/spiderman_group_1, role:default/spiderman_group_1 +p, role:default/spiderman_group_1, catalog-entity, read, deny + +# Test seven: user:default/captain_america, expect allow +g, group:default/captain_america_group_0, role:default/captain_america_group_0 +p, role:default/captain_america_group_0, catalog-entity, read, allow + +g, group:default/captain_america_group_1, role:default/captain_america_group_1 +p, role:default/captain_america_group_1, catalog-entity, read, allow + +# Test eight: user:default/hawkeye, expect deny +g, group:default/hawkeye_group_0, role:default/hawkeye_group_0 +p, role:default/hawkeye_group_0, catalog-entity, read, deny + +g, group:default/hawkeye_group_1, role:default/hawkeye_group_1 +p, role:default/hawkeye_group_1, catalog-entity, read, deny + +# Test nine: user:default/quicksilver, expect deny +g, group:default/quicksilver_group_0, role:default/quicksilver_group_0 +p, role:default/quicksilver_group_0, catalog-entity, read, deny + +g, group:default/quicksilver_group_1, role:default/quicksilver_group_1 +p, role:default/quicksilver_group_1, catalog-entity, read, allow + +# Test ten: user:default/scarlet_witch, expect deny +g, group:default/scarlet_witch_group_0, role:default/scarlet_witch_group_0 +p, role:default/scarlet_witch_group_0, catalog-entity, read, allow + +g, group:default/scarlet_witch_group_1, role:default/scarlet_witch_group_1 +p, role:default/scarlet_witch_group_1, catalog-entity, read, deny + +# Test eleven: user:default/swordsman, expect allow +g, user:default/swordsman, role:default/swordsman +p, role:default/swordsman, catalog-entity, read, allow + +g, group:default/swordsman_group_0, role:default/swordsman_group_0 +p, role:default/swordsman_group_0, catalog-entity, read, allow + +# Test twelve: user:default/hercules, expect deny +g, user:default/hercules, role:default/hercules +p, role:default/hercules, catalog-entity, read, deny + +g, group:default/hercules_group_0, role:default/hercules_group_0 +p, role:default/hercules_group_0, catalog-entity, read, deny + +# Test thriteen: user:default/black_panther, expect deny +g, user:default/black_panther, role:default/black_panther +p, role:default/black_panther, catalog-entity, read, deny + +g, group:default/black_panther_group_0, role:default/black_panther_group_0 +p, role:default/black_panther_group_0, catalog-entity, read, allow + +# Test fourteen: user:default/vision, expect deny +g, user:default/vision, role:default/vision +p, role:default/vision, catalog-entity, read, allow + +g, group:default/vision_group_0, role:default/vision_group_0 +p, role:default/vision_group_0, catalog-entity, read, deny + +# Test fifteen: user:default/black_knight, expect allow +g, group:default/black_knight_group_a, role:default/black_knight_group_a +p, role:default/black_knight_group_a, catalog-entity, read, allow + +g, group:default/black_knight_group_b, role:default/black_knight_group_b +p, role:default/black_knight_group_b, catalog-entity, read, allow + +# Test sixteen: user:default/black_widow, expect deny +g, group:default/black_widow_group_a, role:default/black_widow_group_a +p, role:default/black_widow_group_a, catalog-entity, read, deny + +g, group:default/black_widow_group_b, role:default/black_widow_group_b +p, role:default/black_widow_group_b, catalog-entity, read, deny + +# Test seventeen: user:default/mantis, expect deny +g, group:default/mantis_group_a, role:default/mantis_group_a +p, role:default/mantis_group_a, catalog-entity, read, deny + +g, group:default/mantis_group_b, role:default/mantis_group_b +p, role:default/mantis_group_b, catalog-entity, read, allow + +# Test eighteen: user:default/beast, expect deny +g, group:default/beast_group_a, role:default/beast_group_a +p, role:default/beast_group_a, catalog-entity, read, allow + +g, group:default/beast_group_b, role:default/beast_group_b +p, role:default/beast_group_b, catalog-entity, read, deny + +# Test nineteen: user:default/moondragon, expect allow +g, group:default/moondragon_group_a_1, role:default/moondragon_group_a_1 +p, role:default/moondragon_group_a_1, catalog-entity, read, allow + +g, group:default/moondragon_group_b_1, role:default/moondragon_group_b_1 +p, role:default/moondragon_group_b_1, catalog-entity, read, allow + +# Test twenty: user:default/hellcat, expect deny +g, group:default/hellcat_group_a_1, role:default/hellcat_group_a_1 +p, role:default/hellcat_group_a_1, catalog-entity, read, deny + +g, group:default/hellcat_group_b_1, role:default/hellcat_group_b_1 +p, role:default/hellcat_group_b_1, catalog-entity, read, deny + +# Test twenty one: user:default/captain_marvel, expect deny +g, group:default/captain_marvel_group_a_1, role:default/captain_marvel_group_a_1 +p, role:default/captain_marvel_group_a_1, catalog-entity, read, deny + +g, group:default/captain_marvel_group_b_1, role:default/captain_marvel_group_b_1 +p, role:default/captain_marvel_group_b_1, catalog-entity, read, allow + +# Test twenty two: user:default/falcon, expect deny +g, group:default/falcon_group_a_1, role:default/falcon_group_a_1 +p, role:default/falcon_group_a_1, catalog-entity, read, allow + +g, group:default/falcon_group_b_1, role:default/falcon_group_b_1 +p, role:default/falcon_group_b_1, catalog-entity, read, deny + +# Test twenty three: user:default/wonder_man, expect deny +g, group:default/wonder_man_group_1, role:default/wonder_man_group_1 +p, role:default/wonder_man_group_1, catalog-entity, read, allow + +# Test twenty four: user:default/tigra, expect deny +g, group:default/tigra_group_1, role:default/tigra_group_1 +p, role:default/tigra_group_1, catalog-entity, read, deny + +# Test twenty five: user:default/she_hulk, expect deny +g, group:default/she_hulk_group_0, role:default/she_hulk_group_0 +p, role:default/she_hulk_group_0, catalog-entity, read, allow + +g, group:default/she_hulk_group_1, role:default/she_hulk_group_1 +p, role:default/she_hulk_group_1, catalog-entity, read, allow + +# Test twenty six: user:default/starfox, expect deny +g, group:default/starfox_group_0, role:default/starfox_group_0 +p, role:default/starfox_group_0, catalog-entity, read, deny + +g, group:default/starfox_group_1, role:default/starfox_group_1 +p, role:default/starfox_group_1, catalog-entity, read, deny + +# Test twenty seven: user:default/mockingbird, expect deny +g, group:default/mockingbird_group_0, role:default/mockingbird_group_0 +p, role:default/mockingbird_group_0, catalog-entity, read, deny + +g, group:default/mockingbird_group_1, role:default/mockingbird_group_1 +p, role:default/mockingbird_group_1, catalog-entity, read, allow + +# Test twenty eight: user:default/war_machine, expect deny +g, group:default/war_machine_group_0, role:default/war_machine_group_0 +p, role:default/war_machine_group_0, catalog-entity, read, allow + +g, group:default/war_machine_group_1, role:default/war_machine_group_1 +p, role:default/war_machine_group_1, catalog-entity, read, deny + +# Test twenty nine: user:default/namor, expect deny +g, group:default/namor_group_a_1, role:default/namor_group_a_1 +p, role:default/namor_group_a_1, catalog-entity, read, allow + +g, group:default/namor_group_b_1, role:default/namor_group_b_1 +p, role:default/namor_group_b_1, catalog-entity, read, allow + +# Test thirty: user:default/thing, expect deny +g, group:default/thing_group_a_1, role:default/thing_group_a_1 +p, role:default/thing_group_a_1, catalog-entity, read, deny + +g, group:default/thing_group_b_1, role:default/thing_group_b_1 +p, role:default/thing_group_b_1, catalog-entity, read, deny + +# Test thirty one: user:default/doctor_druid, expect deny +g, group:default/doctor_druid_group_a_1, role:default/doctor_druid_group_a_1 +p, role:default/doctor_druid_group_a_1, catalog-entity, read, deny + +g, group:default/doctor_druid_group_b_1, role:default/doctor_druid_group_b_1 +p, role:default/doctor_druid_group_b_1, catalog-entity, read, allow + +# Test thirty two: user:default/firebird, expect deny +g, group:default/firebird_group_a_1, role:default/firebird_group_a_1 +p, role:default/firebird_group_a_1, catalog-entity, read, allow + +g, group:default/firebird_group_b_1, role:default/firebird_group_b_1 +p, role:default/firebird_group_b_1, catalog-entity, read, deny + +# Test thirty three: user:default/valkyrie, expect deny +g, group:default/valkyrie_group_a_1, role:default/valkyrie_group_a_1 +p, role:default/valkyrie_group_a_1, catalog-entity, read, allow + +g, group:default/valkyrie_group_b_1, role:default/valkyrie_group_b_1 +p, role:default/valkyrie_group_b_1, catalog-entity, read, allow + +# Test thirty four: user:default/nova, expect deny +g, group:default/nova_group_a_1, role:default/nova_group_a_1 +p, role:default/nova_group_a_1, catalog-entity, read, deny + +g, group:default/nova_group_b_1, role:default/nova_group_b_1 +p, role:default/nova_group_b_1, catalog-entity, read, deny + +# Test thirty five: user:default/storm, expect deny +g, group:default/storm_group_a_1, role:default/storm_group_a_1 +p, role:default/storm_group_a_1, catalog-entity, read, deny + +g, group:default/storm_group_b_1, role:default/storm_group_b_1 +p, role:default/storm_group_b_1, catalog-entity, read, allow + +# Test thirty six: user:default/daredevil, expect deny +g, group:default/daredevil_group_a_1, role:default/daredevil_group_a_1 +p, role:default/daredevil_group_a_1, catalog-entity, read, allow + +g, group:default/daredevil_group_b_1, role:default/daredevil_group_b_1 +p, role:default/daredevil_group_b_1, catalog-entity, read, deny + +# Test thirty seven: user:default/psylocke, expect allow +p, user:default/psylocke, catalog-entity, read, allow + +# Test thirty eight: user:default/penance, expect deny +p, user:default/penance, catalog-entity, read, deny + +# Test thirty nine: user:default/cable, expect allow +p, group:default/cable_group_0, catalog-entity, read, allow + +# Test fourty: user:default/ghost_rider, expect deny +p, group:default/ghost_rider_group_0, catalog-entity, read, deny + +# Test fourty One: user:default/super_user: expect allow +# Set user:default/super_user to `rbac.admin.superUsers` + +# Test fourty Two: user:default/admin: expect allow +# Set user:default/admin to `rbac.admin.users` \ No newline at end of file diff --git a/plugins/rbac-backend/__fixtures__/data/hierarchy/users.ts b/plugins/rbac-backend/__fixtures__/data/hierarchy/users.ts new file mode 100644 index 0000000000..47a196c858 --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/hierarchy/users.ts @@ -0,0 +1,357 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const USERS_FOR_TEST = [ + { + name: 'ant_man', + memberOf: [], + displayName: 'Ant Man', + email: 'ant_man@example.com', + }, + { + name: 'hulk', + memberOf: [], + displayName: 'Hulk', + email: 'hulk@example.com', + }, + { + name: 'thor', + memberOf: ['group:default/thor_group_0'], + displayName: 'Thor', + email: 'thor@example.com', + }, + { + name: 'wasp', + memberOf: ['group:default/wasp_group_0'], + displayName: 'Wasp', + email: 'wasp@example.com', + }, + { + name: 'captain_america', + memberOf: ['group:default/captain_america_group_0'], + displayName: 'Captain America', + email: 'captain_america@example.com', + }, + { + name: 'hawkeye', + memberOf: ['group:default/hawkeye_group_0'], + displayName: 'Hawkeye', + email: 'hawkeye@example.com', + }, + { + name: 'quicksilver', + memberOf: ['group:default/quicksilver_group_0'], + displayName: 'Quicksilver', + email: 'quicksilver@example.com', + }, + { + name: 'scarlet_witch', + memberOf: ['group:default/scarlet_witch_group_0'], + displayName: 'Scarlet Witch', + email: 'scarlet_witch@example.com', + }, + { + name: 'swordsman', + memberOf: ['group:default/swordsman_group_0'], + displayName: 'Swordsman', + email: 'swordsman@example.com', + }, + { + name: 'hercules', + memberOf: ['group:default/hercules_group_0'], + displayName: 'Hercules', + email: 'hercules@example.com', + }, + { + name: 'black_panther', + memberOf: ['group:default/black_panther_group_0'], + displayName: 'Black Panther', + email: 'black_panther@example.com', + }, + { + name: 'vision', + memberOf: ['group:default/vision_group_0'], + displayName: 'Vision', + email: 'vision@example.com', + }, + { + name: 'black_knight', + memberOf: [ + 'group:default/black_knight_group_a', + 'group:default/black_knight_group_b', + ], + displayName: 'Black Knight', + email: 'black_knight@example.com', + }, + { + name: 'black_widow', + memberOf: [ + 'group:default/black_widow_group_a', + 'group:default/black_widow_group_b', + ], + displayName: 'Black Widow', + email: 'black_widow@example.com', + }, + { + name: 'mantis', + memberOf: ['group:default/mantis_group_a', 'group:default/mantis_group_b'], + displayName: 'Mantis', + email: 'mantis@example.com', + }, + { + name: 'beast', + memberOf: ['group:default/beast_group_a', 'group:default/beast_group_b'], + displayName: 'Beast', + email: 'beast@example.com', + }, + { + name: 'moondragon', + memberOf: [ + 'group:default/moondragon_group_a_0', + 'group:defaultmoondragon_group_b_0', + ], + displayName: 'Moondragon', + email: 'moondragon@example.com', + }, + { + name: 'hellcat', + memberOf: [ + 'group:default/hellcat_group_a_0', + 'group:default/hellcat_group_b_0', + ], + displayName: 'Hellcat', + email: 'hellcat@example.com', + }, + { + name: 'captain_marvel', + memberOf: [ + 'group:default/captain_marvel_group_a_0', + 'group:default/captain_marvel_group_b_0', + ], + displayName: 'Captain Marvel', + email: 'captain_marvel@example.com', + }, + { + name: 'falcon', + memberOf: [ + 'group:default/falcon_group_a_0', + 'group:default/falcon_group_b_0', + ], + displayName: 'Falcon', + email: 'falcon@example.com', + }, + { + name: 'wonder_man', + memberOf: ['group:default/wonder_man_group_0'], + displayName: 'Wonder Man', + email: 'wonder_man@example.com', + }, + { + name: 'tigra', + memberOf: ['group:default/tigra_group_0'], + displayName: 'Tigra', + email: 'tigra@example.com', + }, + { + name: 'she_hulk', + memberOf: ['group:default/she_hulk_group_0'], + displayName: 'She-Hulk', + email: 'she_hulk@example.com', + }, + { + name: 'starfox', + memberOf: ['group:default/starfox_group_0'], + displayName: 'Starfox', + email: 'starfox@example.com', + }, + { + name: 'mockingbird', + memberOf: ['group:default/mockingbird_group_0'], + displayName: 'Mockingbird', + email: 'mockingbird@example.com', + }, + { + name: 'war_machine', + memberOf: ['group:default/war_machine_group_0'], + displayName: 'War Machine', + email: 'war_machine@example.com', + }, + { + name: 'namor', + memberOf: [ + 'group:default/namor_group_a_0', + 'group:default/namor_group_b_0', + ], + displayName: 'Namor', + email: 'namor@example.com', + }, + { + name: 'thing', + memberOf: [ + 'group:default/thing_group_a_0', + 'group:default/thing_group_b_0', + ], + displayName: 'Thing', + email: 'thing@example.com', + }, + { + name: 'doctor_druid', + memberOf: [ + 'group:default/doctor_druid_group_a_0', + 'group:default/doctor_druid_group_b_0', + ], + displayName: 'Doctor Druid', + email: 'doctor_druid@example.com', + }, + { + name: 'firebird', + memberOf: [ + 'group:default/firebird_group_a_0', + 'group:default/firebird_group_b_0', + ], + displayName: 'Firebird', + email: 'firebird@example.com', + }, + { + name: 'moon_knight', + memberOf: ['group:default/moon_knight_group_0'], + displayName: 'Moon Knight', + email: 'moon_knight@example.com', + }, + { + name: 'spiderman', + memberOf: ['group:default/spiderman_group_0'], + displayName: 'Spiderman', + email: 'spiderman@example.com', + }, + { + name: 'valkyrie', + memberOf: [ + 'group:default/valkyrie_group_a_0', + 'group:default/valkyrie_group_b_0', + ], + displayName: 'Valkyrie', + email: 'valkyrie@example.com', + }, + { + name: 'nova', + memberOf: ['group:default/nova_group_a_0', 'group:default/nova_group_b_0'], + displayName: 'Nova', + email: 'nova@example.com', + }, + { + name: 'storm', + memberOf: [ + 'group:default/storm_group_a_0', + 'group:default/storm_group_b_0', + ], + displayName: 'Storm', + email: 'storm@example.com', + }, + { + name: 'daredevil', + memberOf: [ + 'group:default/daredevil_group_a_0', + 'group:default/daredevil_group_b_0', + ], + displayName: 'Daredevil', + email: 'daredevil@example.com', + }, + { + name: 'psylocke', + memberOf: [], + displayName: 'Psylocke', + email: 'psylocke@example.com', + }, + { + name: 'penance', + memberOf: [], + displayName: 'Penance', + email: 'penance@example.com', + }, + { + name: 'cable', + memberOf: ['group:default/cable_group_0'], + displayName: 'Cable', + email: 'cable@example.com', + }, + { + name: 'ghost_rider', + memberOf: ['group:default/ghost_rider_group_0'], + displayName: 'Ghost Rider', + email: 'ghost_rider@example.com', + }, + { + name: 'admin', + memberOf: [], + displayName: 'Admin', + email: 'admin@example.com', + }, + { + name: 'admin_one', + memberOf: ['group:default/admin'], + displayName: 'Admin One', + email: 'admin_one@example.com', + }, + { + name: 'super_user', + memberOf: [], + displayName: 'Super User', + email: 'super_user@example.com', + }, + { + name: 'tor', + memberOf: ['group:default/team-a', 'group:default/team-C'], + displayName: 'Tor', + email: 'tor@example.com', + }, + { + name: 'mike', + memberOf: ['group:default/team-b', 'group:default/team-y'], + displayName: 'Mike', + email: 'mike@example.com', + }, + { + name: 'tom', + memberOf: ['group:default/team-c'], + displayName: 'Tom', + email: 'tom@example.com', + }, + { + name: 'bill', + memberOf: ['group:default/team-g'], + displayName: 'Bill', + email: 'bill@example.com', + }, + { + name: 'john', + memberOf: ['group:default/team-d', 'group:defaul/team-f'], + displayName: 'John', + email: 'john@example.com', + }, + { + name: 'bob', + memberOf: [], + displayName: 'Bob', + email: 'bob@example.com', + }, + { + name: 'sally', + memberOf: ['group:default/hr'], + displayName: 'Sally', + email: 'sally@example.com', + }, +]; diff --git a/plugins/rbac-backend/__fixtures__/data/invalid-conditions/bad-conditions-yaml.yaml b/plugins/rbac-backend/__fixtures__/data/invalid-conditions/bad-conditions-yaml.yaml new file mode 100644 index 0000000000..862169502d --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/invalid-conditions/bad-conditions-yaml.yaml @@ -0,0 +1 @@ +some bad yaml.... diff --git a/plugins/rbac-backend/__fixtures__/data/invalid-conditions/invalid-yaml.yaml b/plugins/rbac-backend/__fixtures__/data/invalid-conditions/invalid-yaml.yaml new file mode 100644 index 0000000000..1575881403 --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/invalid-conditions/invalid-yaml.yaml @@ -0,0 +1 @@ +result: diff --git a/plugins/rbac-backend/__fixtures__/data/invalid-csv/deprecated-policy.csv b/plugins/rbac-backend/__fixtures__/data/invalid-csv/deprecated-policy.csv new file mode 100644 index 0000000000..ceb3791502 --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/invalid-csv/deprecated-policy.csv @@ -0,0 +1 @@ +p, role:default/some_role, policy-entity, create, allow \ No newline at end of file diff --git a/plugins/rbac-backend/__fixtures__/data/invalid-csv/duplicate-policy.csv b/plugins/rbac-backend/__fixtures__/data/invalid-csv/duplicate-policy.csv new file mode 100644 index 0000000000..23e7ef1077 --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/invalid-csv/duplicate-policy.csv @@ -0,0 +1,17 @@ +g, user:default/guest, role:default/catalog-deleter +g, user:default/guest, role:default/catalog-deleter + +g, user:default/guest, role:default/catalog-updater + +g, group:default/READER-GROUP, role:default/CATALOG-USER +g, group:default/READER-GROUP, role:default/CATALOG-USER + +p, role:default/catalog-writer, catalog.entity.create, use, allow +p, role:default/catalog-writer, catalog.entity.create, use, allow + +p, role:default/catalog-writer, catalog-entity, delete, allow + +p, role:default/duplication-effect, catalog-entity, update, allow +p, role:default/duplication-effect, catalog-entity, update, deny + +p, role:default/CATALOG-USER, catalog-entity, read, allow diff --git a/plugins/rbac-backend/__fixtures__/data/invalid-csv/error-policy.csv b/plugins/rbac-backend/__fixtures__/data/invalid-csv/error-policy.csv new file mode 100644 index 0000000000..963ad4cceb --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/invalid-csv/error-policy.csv @@ -0,0 +1,10 @@ +g, user:default/, role:default/catalog-deleter +g, user:default/test, role:default/ +p, role:default/, catalog.entity.create, use, allow +p, role:default/test, catalog.entity.create, delete, temp + +p, role:default/rest, catalog-entity, update, allow +g, user:default/guest, role:default/rest + +p, role:default/config, catalog-entity, update, allow +g, user:default/guest, role:default/config diff --git a/plugins/rbac-backend/__fixtures__/data/valid-conditions/conditions.yaml b/plugins/rbac-backend/__fixtures__/data/valid-conditions/conditions.yaml new file mode 100644 index 0000000000..0af4cab20b --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/valid-conditions/conditions.yaml @@ -0,0 +1,28 @@ +--- +result: CONDITIONAL +roleEntityRef: 'role:default/test' +pluginId: catalog +resourceType: catalog-entity +permissionMapping: + - update +conditions: + rule: IS_ENTITY_OWNER + resourceType: catalog-entity + params: + claims: + - 'group:default/team-a' +--- +result: CONDITIONAL +roleEntityRef: 'role:default/test' +pluginId: catalog +resourceType: catalog-entity +permissionMapping: + - read + - delete +conditions: + rule: IS_ENTITY_OWNER + resourceType: catalog-entity + params: + claims: + - 'group:default/team-a' + - 'group:default/team-b' diff --git a/plugins/rbac-backend/__fixtures__/data/valid-conditions/empty-conditions.yaml b/plugins/rbac-backend/__fixtures__/data/valid-conditions/empty-conditions.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/rbac-backend/__fixtures__/data/valid-conditions/extra-delimiter-conditions.yaml b/plugins/rbac-backend/__fixtures__/data/valid-conditions/extra-delimiter-conditions.yaml new file mode 100644 index 0000000000..be93fc5132 --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/valid-conditions/extra-delimiter-conditions.yaml @@ -0,0 +1,30 @@ +--- +result: CONDITIONAL +roleEntityRef: 'role:default/test-2' +pluginId: catalog +resourceType: catalog-entity +permissionMapping: + - update +conditions: + rule: IS_ENTITY_OWNER + resourceType: catalog-entity + params: + claims: + - 'group:default/team-a' +--- +result: CONDITIONAL +roleEntityRef: 'role:default/test-3' +pluginId: catalog +resourceType: catalog-entity +permissionMapping: + - read + - delete +conditions: + rule: IS_ENTITY_OWNER + resourceType: catalog-entity + params: + claims: + - 'group:default/team-a' + - 'group:default/team-b' +--- + diff --git a/plugins/rbac-backend/__fixtures__/data/valid-csv/basic-and-resource-policies.csv b/plugins/rbac-backend/__fixtures__/data/valid-csv/basic-and-resource-policies.csv new file mode 100644 index 0000000000..7615d8aa63 --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/valid-csv/basic-and-resource-policies.csv @@ -0,0 +1,21 @@ +# ========== basic type permission policies ========== # +# case 1 +p, user:default/known_user, test.resource.deny, use, deny +# case 2 is about user without listed permissions +# case 3 +p, user:default/duplicated, test.resource, use, allow +p, user:default/duplicated, test.resource, use, deny +# case 4 +p, user:default/known_user, test.resource, use, allow +# case 5 +unknown user + +# ========== resource type permission policies ========== # +# case 1 +p, user:default/known_user, test-resource-deny, update, deny +# case 2 is about user without listed permissions +# case 3 +p, user:default/duplicated, test-resource, update, allow +p, user:default/duplicated, test-resource, update, deny +# case 4 +p, user:default/known_user, test-resource, update, allow diff --git a/plugins/rbac-backend/__fixtures__/data/valid-csv/policy-checks.csv b/plugins/rbac-backend/__fixtures__/data/valid-csv/policy-checks.csv new file mode 100644 index 0000000000..96aa84f201 --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/valid-csv/policy-checks.csv @@ -0,0 +1,67 @@ +# basic type permission policies +### Let's deny 'use' action for 'test.resource' for group:default/data_admin +p, group:default/data_admin, test.resource, use, deny + +# case1: +# g, user:default/alice, group:default/data_admin +p, user:default/alice, test.resource, use, allow + +# case2: +# g, user:default/akira, group:default/data_admin + +# case3: +# g, user:default/antey, group:default/data_admin +p, user:default/antey, test.resource, use, deny + +### Let's allow 'use' action for 'test.resource' for group:default/data_read_admin +p, group:default/data_read_admin, test.resource, use, allow + +# case4: +# g, user:default/julia, group:default/data_read_admin +p, user:default/julia, test.resource, use, allow + +# case5: +# g, user:default/mike, group:default/data_read_admin + +# case6: +# g, user:default/tom, group:default/data_read_admin +p, user:default/tom, test.resource, use, deny + + +# resource type permission policies +### Let's deny 'read' action for 'test.resource' permission for group:default/data_admin +p, group:default/data_admin, test-resource, read, deny + +# case1: +# g, user:default/alice, group:default/data_admin +p, user:default/alice, test-resource, read, allow + +# case2: +# g, user:default/akira, group:default/data_admin + +# case3: +# g, user:default/antey, group:default/data_admin +p, user:default/antey, test-resource, read, deny + +### Let's allow 'read' action for 'test-resource' permission for group:default/data_read_admin +p, group:default/data_read_admin, test-resource, read, allow + +# case4: +# g, user:default/julia, group:default/data_read_admin +p, user:default/julia, test-resource, read, allow + +# case5: +# g, user:default/mike, group:default/data_read_admin + +# case6: +# g, user:default/tom, group:default/data_read_admin +p, user:default/tom, test-resource, read, deny + + +# group inheritance: +# g, group:default/data-read-admin, group:default/data_parent_admin +# and we know case5: +# g, user:default/mike, data-read-admin + +p, group:default/data_parent_admin, test.resource.2, use, allow +p, group:default/data_parent_admin, test-resource, create, allow diff --git a/plugins/rbac-backend/__fixtures__/data/valid-csv/rbac-policy.csv b/plugins/rbac-backend/__fixtures__/data/valid-csv/rbac-policy.csv new file mode 100644 index 0000000000..619a199543 --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/valid-csv/rbac-policy.csv @@ -0,0 +1,17 @@ +g, user:default/guest, role:default/catalog-writer +g, user:default/guest, role:default/legacy +g, user:default/guest, role:default/catalog-reader +g, user:default/guest, role:default/catalog-deleter + +p, role:default/catalog-writer, catalog-entity, update, allow +p, role:default/legacy, catalog-entity, update, allow +p, role:default/catalog-writer, catalog-entity, read, allow +p, role:default/catalog-writer, catalog.entity.create, use, allow +p, role:default/catalog-deleter, catalog-entity, delete, deny +p, role:default/CATALOG-USER, catalog-entity, read, allow + +p, role:default/known_role, test.resource.deny, use, allow + +g, user:default/known_user, role:default/known_role +g, user:default/TOM, role:default/CATALOG-USER +g, group:default/READER-GROUP, role:default/CATALOG-USER diff --git a/plugins/rbac-backend/__fixtures__/data/valid-csv/simple-policy.csv b/plugins/rbac-backend/__fixtures__/data/valid-csv/simple-policy.csv new file mode 100644 index 0000000000..15c68d545a --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/valid-csv/simple-policy.csv @@ -0,0 +1,2 @@ +g, user:default/guest, role:default/catalog-writer +p, role:default/catalog-writer, catalog-entity, update, allow diff --git a/plugins/rbac-backend/__fixtures__/data/valid-csv/uppercase-policy.csv b/plugins/rbac-backend/__fixtures__/data/valid-csv/uppercase-policy.csv new file mode 100644 index 0000000000..c69f67019e --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/data/valid-csv/uppercase-policy.csv @@ -0,0 +1,7 @@ +p, role:default/CATALOG-USER, catalog-entity, read, allow +p, role:default/known_role, test.resource.deny, use, allow + +g, user:default/known_user, role:default/known_role +g, user:default/TOM, role:default/CATALOG-USER +g, group:default/READER-GROUP, role:default/CATALOG-USER +g, group:default/READER-GROUP, role:default/known_role diff --git a/plugins/rbac-backend/__fixtures__/mock-utils.ts b/plugins/rbac-backend/__fixtures__/mock-utils.ts new file mode 100644 index 0000000000..2de553dc50 --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/mock-utils.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + mockCredentials, + mockServices, + ServiceMock, +} from '@backstage/backend-test-utils'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { AuditorService } from '@backstage/backend-plugin-api'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +import type { Enforcer } from 'casbin'; +import * as Knex from 'knex'; +import { MockClient } from 'knex-mock-client'; +import { resolve } from 'path'; +import type TypeORMAdapter from 'typeorm-adapter'; + +import type { RBACProvider } from '@backstage-community/plugin-rbac-node'; + +import { CasbinDBAdapterFactory } from '../src/database/casbin-adapter-factory'; +import { ConditionalStorage } from '../src/database/conditional-storage'; +import { RoleMetadataStorage } from '../src/database/role-metadata'; +import { + EnforcerDelegate, + RoleEventEmitter, + RoleEvents, +} from '../src/service/enforcer-delegate'; +import { PluginPermissionMetadataCollector } from '../src/service/plugin-endpoints'; +import { PermissionDependentPluginStore } from '../src/database/extra-permission-enabled-plugins-storage'; +import { ExtendablePluginIdProvider } from '../src/service/extendable-id-provider'; +import { convertGroupsToEntity, convertUsersToEntity } from './test-utils'; + +export const conditionalStorageMock: ConditionalStorage = { + filterConditions: jest.fn().mockImplementation(() => []), + createCondition: jest.fn().mockImplementation(), + checkConflictedConditions: jest.fn().mockImplementation(), + getCondition: jest.fn().mockImplementation(), + deleteCondition: jest.fn().mockImplementation(), + updateCondition: jest.fn().mockImplementation(), +}; + +export const roleMetadataStorageMock: RoleMetadataStorage = { + filterRoleMetadata: jest.fn().mockImplementation(() => []), + filterForOwnerRoleMetadata: jest.fn().mockImplementation(), + findRoleMetadata: jest.fn().mockImplementation(), + createRoleMetadata: jest.fn().mockImplementation(), + updateRoleMetadata: jest.fn().mockImplementation(), + removeRoleMetadata: jest.fn().mockImplementation(), + getCachedDefaultRoleMetadata: jest.fn().mockImplementation(() => undefined), + getDefaultRole: jest.fn().mockResolvedValue(undefined), + syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), +}; + +export const pluginMetadataCollectorMock: Partial = + { + getPluginConditionRules: jest.fn().mockImplementation(), + getPluginPolicies: jest.fn().mockImplementation(), + getMetadataByPluginId: jest.fn().mockImplementation(), + }; + +export const permissionDependentPluginStoreMock: PermissionDependentPluginStore = + { + getPlugins: jest + .fn() + .mockImplementation(async () => [ + { pluginId: 'jenkins' }, + { pluginId: 'sonarqube' }, + ]), + addPlugins: jest.fn().mockImplementation(), + deletePlugins: jest.fn().mockImplementation(), + }; + +export const pluginIdProviderMock = { + getPluginIds: jest.fn().mockImplementation(() => []), +}; + +export const extendablePluginIdProviderMock: Partial = + { + isConfiguredPluginId: jest.fn().mockImplementation(), + getPluginIds: jest.fn().mockImplementation(async () => ['catalog']), + handleConflictedPluginIds: jest.fn().mockImplementation(), + }; + +export const roleEventEmitterMock: RoleEventEmitter = { + on: jest.fn().mockImplementation(), +}; + +export const enforcerMock: Partial = { + loadPolicy: jest.fn().mockImplementation(async () => {}), + enableAutoSave: jest.fn().mockImplementation(() => {}), + setRoleManager: jest.fn().mockImplementation(() => {}), + enableAutoBuildRoleLinks: jest.fn().mockImplementation(() => {}), + buildRoleLinks: jest.fn().mockImplementation(() => {}), +}; + +export const enforcerDelegateMock: Partial = { + hasPolicy: jest.fn().mockImplementation(), + hasGroupingPolicy: jest.fn().mockImplementation(), + getPolicy: jest.fn().mockImplementation(), + getGroupingPolicy: jest.fn().mockImplementation(), + getFilteredPolicy: jest.fn().mockImplementation(), + getFilteredGroupingPolicy: jest.fn().mockImplementation(), + addPolicy: jest.fn().mockImplementation(), + addPolicies: jest.fn().mockImplementation(), + addGroupingPolicies: jest.fn().mockImplementation(), + removePolicy: jest.fn().mockImplementation(), + removePolicies: jest.fn().mockImplementation(), + removeGroupingPolicy: jest.fn().mockImplementation(), + removeGroupingPolicies: jest.fn().mockImplementation(), + updatePolicies: jest.fn().mockImplementation(), + updateGroupingPolicies: jest.fn().mockImplementation(), +}; + +export const dataBaseAdapterFactoryMock: Partial = { + createAdapter: jest.fn((): Promise => { + return Promise.resolve({} as TypeORMAdapter); + }), +}; + +export const providerMock: RBACProvider = { + getProviderName: jest.fn().mockImplementation(() => `testProvider`), + connect: jest.fn().mockImplementation(), + refresh: jest.fn().mockImplementation(), +}; + +export const mockClientKnex = Knex.knex({ client: MockClient }); + +export const mockHttpAuth = mockServices.httpAuth(); +export const mockAuthService = mockServices.auth(); + +export const createEventMock = { + success: jest.fn(), + fail: jest.fn(), +}; +export const mockAuditorService: ServiceMock = + mockServices.auditor.mock({ + createEvent: jest.fn(async _ => { + return createEventMock; + }), + }); + +export const credentials = mockCredentials.user(); +export const mockLoggerService = mockServices.logger.mock(); +export const mockPermissionRegistry = mockServices.permissionsRegistry.mock({ + getPermissionRuleset: jest.fn(resourceRef => { + return { + getRules: () => [ + { + resourceRef, + rules: [], + }, + ], + getRuleByName: jest.fn(), + }; + }), +}); + +export const mockedAuthorize = jest.fn().mockImplementation(async () => [ + { + result: AuthorizeResult.ALLOW, + }, +]); + +export const mockedAuthorizeConditional = jest + .fn() + .mockImplementation(async () => [ + { + result: AuthorizeResult.ALLOW, + }, + ]); + +export const mockPermissionEvaluator = { + authorize: mockedAuthorize, + authorizeConditional: mockedAuthorizeConditional, +}; + +export const testUsers = convertUsersToEntity(); +export const testGroups = convertGroupsToEntity(); +export const catalogMock = catalogServiceMock({ + entities: [...testGroups, ...testUsers], +}); + +export const csvPermFile = resolve( + __dirname, + './../__fixtures__/data/valid-csv/rbac-policy.csv', +); diff --git a/plugins/rbac-backend/__fixtures__/test-utils.ts b/plugins/rbac-backend/__fixtures__/test-utils.ts new file mode 100644 index 0000000000..6d37e8cff8 --- /dev/null +++ b/plugins/rbac-backend/__fixtures__/test-utils.ts @@ -0,0 +1,236 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { LoggerService } from '@backstage/backend-plugin-api'; +import { mockServices } from '@backstage/backend-test-utils'; +import { Config } from '@backstage/config'; + +import { + Adapter, + Enforcer, + Model, + newEnforcer, + newModelFromString, +} from 'casbin'; +import * as Knex from 'knex'; +import { MockClient } from 'knex-mock-client'; + +import { CasbinDBAdapterFactory } from '../src/database/casbin-adapter-factory'; +import { RoleMetadataStorage } from '../src/database/role-metadata'; +import { RBACPermissionPolicy } from '../src/policies/permission-policy'; +import { BackstageRoleManager } from '../src/role-manager/role-manager'; +import { DefaultPermissionsReader } from '../src/default-permissions/default-permissions'; +import { EnforcerDelegate } from '../src/service/enforcer-delegate'; +import { MODEL } from '../src/service/permission-model'; +import { PluginPermissionMetadataCollector } from '../src/service/plugin-endpoints'; +import { + mockAuditorService, + conditionalStorageMock, + csvPermFile, + mockAuthService, + mockClientKnex, + pluginMetadataCollectorMock, + roleMetadataStorageMock, +} from './mock-utils'; +import { clearAuditorMock } from './auditor-test-utils'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { USERS_FOR_TEST } from './data/hierarchy/users'; +import { + Entity, + GroupEntityV1alpha1, + UserEntityV1alpha1, +} from '@backstage/catalog-model'; +import { GROUPS_FOR_TESTS } from './data/hierarchy/groups'; + +export function newConfig( + permFile?: string, + users?: Array<{ name: string }>, + superUsers?: Array<{ name: string }>, +): Config { + const testUsers = [ + { + name: 'user:default/guest', + }, + { + name: 'group:default/guests', + }, + ]; + + return mockServices.rootConfig({ + data: { + permission: { + rbac: { + 'policies-csv-file': permFile || csvPermFile, + policyFileReload: true, + admin: { + users: users || testUsers, + superUsers: superUsers, + }, + }, + }, + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }, + }); +} + +export async function newAdapter(config: Config): Promise { + return await new CasbinDBAdapterFactory( + config, + mockClientKnex, + ).createAdapter(); +} + +export async function createEnforcer( + theModel: Model, + adapter: Adapter, + logger: LoggerService, + config: Config, +): Promise { + const catalogDBClient = Knex.knex({ client: MockClient }); + const rbacDBClient = Knex.knex({ client: MockClient }); + const enf = await newEnforcer(theModel, adapter); + + const rm = new BackstageRoleManager( + catalogServiceMock.mock(), + logger, + catalogDBClient, + rbacDBClient, + config, + mockAuthService, + new DefaultPermissionsReader(config), + ); + enf.setRoleManager(rm); + enf.enableAutoBuildRoleLinks(false); + await enf.buildRoleLinks(); + + return enf; +} + +export async function newEnforcerDelegate( + adapter: Adapter, + config: Config, + storedPolicies?: string[][], + storedGroupingPolicies?: string[][], +): Promise { + const theModel = newModelFromString(MODEL); + const logger = mockServices.logger.mock(); + + const enf = await createEnforcer(theModel, adapter, logger, config); + + if (storedPolicies) { + await enf.addPolicies(storedPolicies); + } + + if (storedGroupingPolicies) { + await enf.addGroupingPolicies(storedGroupingPolicies); + } + + return new EnforcerDelegate( + enf, + mockAuditorService, + conditionalStorageMock, + roleMetadataStorageMock, + mockClientKnex, + ); +} + +export async function newPermissionPolicy( + config: Config, + enfDelegate: EnforcerDelegate, + roleMock?: RoleMetadataStorage, +): Promise { + const logger = mockServices.logger.mock(); + const permissionPolicy = await RBACPermissionPolicy.build( + logger, + mockAuditorService, + config, + conditionalStorageMock, + enfDelegate, + roleMock || roleMetadataStorageMock, + mockClientKnex, + pluginMetadataCollectorMock as PluginPermissionMetadataCollector, + mockAuthService, + ); + clearAuditorMock(); + return permissionPolicy; +} + +export function convertGroupsToEntity( + groups?: { + name: string; + namespace?: string | null; + title: string; + children: never[]; + parent: string | null; + hasMember: string[]; + }[], +): Entity[] { + const groupsForTests = groups ?? GROUPS_FOR_TESTS; + const groupsMocked = groupsForTests.map(group => { + const entityMock: GroupEntityV1alpha1 = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: group.name, + namespace: group.namespace ?? 'default', + title: group.title, + }, + spec: { + children: group.children, + parent: group.parent!, + type: 'team', + }, + relations: [ + ...group.hasMember.map(member => ({ + type: 'hasMember', + targetRef: member, + })), + ], + }; + return entityMock; + }); + return groupsMocked; +} + +export function convertUsersToEntity(): Entity[] { + const usersMocked = USERS_FOR_TEST.map(user => { + const entityMock: UserEntityV1alpha1 = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: user.name, + namespace: 'default', + }, + spec: { + memberOf: user.memberOf, + profile: { + displayName: user.displayName, + email: user.email, + }, + }, + relations: user.memberOf.map(member => ({ + type: 'memberOf', + targetRef: member, + })), + }; + return entityMock; + }); + return usersMocked; +} diff --git a/plugins/rbac-backend/catalog-info.yaml b/plugins/rbac-backend/catalog-info.yaml new file mode 100644 index 0000000000..1b58a835cd --- /dev/null +++ b/plugins/rbac-backend/catalog-info.yaml @@ -0,0 +1,28 @@ +# https://backstage.io/docs/features/software-catalog/descriptor-format#kind-component +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-community-rbac-backend + title: '@backstage-community/backstage-plugin-rbac-backend' + description: RBAC backend plugin for Backstage + annotations: + backstage.io/source-location: url:https://github.com/backstage/community-plugins/tree/main/workspaces/rbac/plugins/rbac-backend + backstage.io/view-url: https://github.com/backstage/community-plugins/blob/main/workspaces/rbac/plugins/rbac-backend/catalog-info.yaml + backstage.io/edit-url: https://github.com/backstage/community-plugins/edit/main/workspaces/rbac/plugins/rbac-backend/catalog-info.yaml + github.com/project-slug: backstage-community/backstage-plugins + github.com/team-slug: backstage/maintainers-plugins + sonarqube.org/project-key: backstage-community_plugins + tags: + - security + - rbac + links: + - url: https://github.com/backstage/community-plugins/tree/main/workspaces/rbac/plugins/rbac-backend + title: GitHub Source + icon: source + type: source +spec: + type: backstage-backend-plugin + lifecycle: production + owner: backstage-team + system: backstage + subcomponentOf: backstage-community-rbac diff --git a/plugins/rbac-backend/config.d.ts b/plugins/rbac-backend/config.d.ts new file mode 100644 index 0000000000..ba53eda566 --- /dev/null +++ b/plugins/rbac-backend/config.d.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export interface Config { + permission: { + rbac: { + 'policies-csv-file'?: string; + /** + * The path to the yaml file containing the conditional policies + * @visibility frontend + */ + conditionalPoliciesFile?: string; + /** + * Allow for reloading of the CSV and conditional policies files. + * @visibility frontend + */ + policyFileReload?: boolean; + /** + * Optional configuration for admins + * @visibility frontend + */ + admin?: { + /** + * The list of users and / or groups with admin access + * @visibility frontend + */ + users?: Array<{ + /** + * @visibility frontend + */ + name: string; + }>; + /** + * The list of super users that will have allow all access, should be a list of only users + * @visibility frontend + */ + superUsers?: Array<{ + /** + * @visibility frontend + */ + name: string; + }>; + }; + /** + * An optional list of plugin IDs. + * The RBAC plugin will handle access control for plugins included in this list. + */ + pluginsWithPermission?: string[]; + /** + * An optional value that limits the depth when building the hierarchy group graph + * @visibility frontend + */ + maxDepth?: number; + /** + * An optional value that controls evaluation order between basic permission policy and conditional policy for permissions. + * - Default: "conditional" + * - "basic": prefer permission policy first + * - "conditional": prefer conditional policies first + * @visibility frontend + */ + policyDecisionPrecedence?: 'basic' | 'conditional'; + /** + * Configuration for assigning a default role with permissions + * to all authenticated users. + */ + defaultPermissions?: { + /** + * The default role to assign to all authenticated users. + */ + defaultRole: string; + /** + * The list of baseline basic permissions assigned to the default role. + */ + basicPermissions: Array<{ + /** + * Permission name or resource type, for example `catalog.entity.read` or `catalog-entity`. + */ + permission: string; + /** + * Action for the permission. Defaults to `use` when omitted. + */ + action: 'create' | 'read' | 'update' | 'delete' | 'use'; + }>; + }; + }; + }; +} diff --git a/plugins/rbac-backend/docs/apis.md b/plugins/rbac-backend/docs/apis.md new file mode 100644 index 0000000000..322bee82ac --- /dev/null +++ b/plugins/rbac-backend/docs/apis.md @@ -0,0 +1,986 @@ +# APIs + +## Requirements + +To access the APIs for the RBAC Backend plugin, a user will need to have admin access. Refer to the [README](../README.md#configure-policy-admins) on how to set up admin access. + +Each endpoint also requires an Authorization header with the Bearer token that was generated by Backstage. If not using the RBAC Frontend plugin, then to access this token, traverse to your deployed instance and inspect the web page. Here are two places and example network calls that will have the Bearer token + +- From the Homepage, the network call `query?term=` + +- From the Catalog, any network call with `entity-facets` + +## Source + +Each permission policy and role that is created through the RBAC Backend plugin will have a source associated with it. When adding new permission policies and roles, we evaluate the ability to modify them based on the location of the first role that was defined. This means that permissions policies that are associated with a role that was created in the CSV file will also need to be defined in the CSV file. + +This strictness helps to keep the integrity and consistency of the data. A permission policy defined in by the REST API with a role in the CSV file can lead to issues in the event that the role was removed from the CSV file. The permission policy would be hanging without a role and would not show up in the RBAC Frontend. + +We have four options for source locations: CSV file, Configuration file, REST API, and legacy. The CSV file and REST API are fairly straightforward and involve modifying the role and permissions policies from that particular source only. Configuration file involves the `role:default/rbac_admin` role where the role can only be modified from the `app-config.yaml`. You are unable to add permission policies to the `role:default/rbac_admin` role. This is because we consider this as a default role for starting out. It is encouraged to either use the `superUsers` configuration feature or to craft your own admin role with the permission policies that are required of your admins. + +Finally, the legacy source is a source that may appear if your permission policies and roles were defined prior to RBAC Backend plugin `2.1.3`. It is recommended to update these legacy sourced roles and permission polices to a source of REST API or CSV file. This can be done by redefining these permission policies and roles using one of the available options. Remember that future permission policies and members of the role will be based on the first originating role with the new source. Be sure to add the role first through one of the described methods, then proceed to add additional members and permission policies to the roles. + +## Role + +### GET role + +GET + +Lists all roles. + +Returns: + +```json +[ + { + "memberReferences": ["user:default/adam"], + "name": "role:default/rbac_admin", + "metadata": { + "source": "configuration", + "description": null + } + }, + { + "memberReferences": [ + "group:default/backstage-community-authors", + "user:default/matt" + ], + "name": "role:default/test", + "metadata": { + "source": "csv-file", + "description": null + } + } +] +``` + +--- + +GET +ex. + +List the single role and the members associated with that role. + +Request Parameters: + +| Parameter name | Description | Type | +| -------------- | --------------------- | ------ | +| kind | role | String | +| namespace | Namespace of the role | String | +| name | name of the role | String | + +Returns: + +```json +[ + { + "memberReferences": [ + "group:default/backstage-community-authors", + "user:default/matt" + ], + "name": "role:default/test", + "metadata": { + "source": "csv-file", + "description": null + } + } +] +``` + +--- + +### POST role + +POST + +Creates a new role. + +Request Parameters: + +| Parameter name | Description | Type | +| -------------------- | ---------------------------------------------------------------- | ------ | +| memberReferences | users / groups to be added to the role `:/` | Array | +| name | name of the role | String | +| metadata.description | description of the role | String | + +body: + +```json +{ + "memberReferences": ["group:default/test"], + "name": "role:default/test_admin", + "metadata": { + "description": "This is a test admin role" + } +} +``` + +Returns a status code of 201 upon success. + +--- + +### PUT role + +PUT +ex. + +Updates a specified role. + +Request Parameters: + +| Parameter name | Description | Type | +| -------------- | --------------------- | ------ | +| kind | role | String | +| namespace | Namespace of the role | String | +| name | name of the role | String | + +Request Parameters for oldRole and newRole: + +| Parameter name | Description | Type | +| -------------------- | ---------------------------------------------------------------- | ------ | +| memberReferences | users / groups to be added to the role `:/` | Array | +| name | name of the role | String | +| metadata.description | description of the role | String | + +body: + +```json +{ + "oldRole": { + "memberReferences": ["group:default/test"], + "name": "role:default/test_admin", + "metadata": { + "description": "This is a test admin role" + } + }, + "newRole": { + "memberReferences": ["group:default/test", "user:default/test2"], + "name": "role:default/test_admin", + "metadata": { + "description": "This is a test admin role with a group and user" + } + } +} +``` + +Returns a status code of 200 upon success. + +--- + +### DELETE role + +DELETE +ex. + +Deletes a single user / group from a role. + +Request Parameters: + +| Parameter name | Description | Type | +| ---------------- | ---------------------------------------------------------------- | ------ | +| kind | role | String | +| namespace | Namespace of the role | String | +| name | name of the role | String | +| memberReferences | users / groups to be added to the role `:/` | String | +| name | name of the role | String | + +before: + +```json +{ + "memberReferences": ["group:default/test, user:default/test2"], + "name": "role:default/test_admin", + "metadata": { + "description": "This is a test admin role with a group and user" + } +} +``` + +after: + +```json +{ + "memberReferences": ["group:default/test"], + "name": "role:default/test_admin", + "metadata": { + "description": "This is a test admin role with a group and user" + } +} +``` + +Returns a status code of 204 upon success. + +--- + +DELETE +ex. + +Deletes a single role and all users associated with that role. + +Request Parameters: + +| Parameter name | Description | Type | +| -------------- | --------------------- | ------ | +| kind | role | String | +| namespace | Namespace of the role | String | +| name | name of the role | String | + +Returns a status code of 204 upon success. + +--- + +## Permission + +### GET permission + +GET + +Lists all permission polices. + +Returns: + +```json +[ + { + "entityReference": "role:default/test", + "permission": "catalog-entity", + "policy": "read", + "effect": "allow", + "metadata": { + "source": "csv-file" + } + }, + { + "entityReference": "role:default/test", + "permission": "catalog.entity.create", + "policy": "create", + "effect": "allow", + "metadata": { + "source": "csv-file" + } + }, + ... +] +``` + +--- + +GET +ex. + +List permission policies related to the specified entity reference `:/`. + +Request parameters: + +| Parameter name | Description | Type | +| -------------- | ----------------------- | ------ | +| kind | Kind of the entity | String | +| namespace | Namespace of the entity | String | +| name | Username of the entity | String | + +Returns: + +```json +[ + { + "entityReference": "role:default/test", + "permission": "catalog-entity", + "policy": "read", + "effect": "allow", + "metadata": { + "source": "csv-file" + } + }, + { + "entityReference": "role:default/test", + "permission": "catalog.entity.create", + "policy": "create", + "effect": "allow", + "metadata": { + "source": "csv-file" + } + } +] +``` + +--- + +### POST permission + +POST + +Creates one or more permission policies for a specified entity. + +Request parameters: + +| Parameter name | Description | Type | +| --------------- | ----------------------------------------------------------------------------- | ------ | +| entityReference | Entity `:/` | String | +| permission | Permission from a specific plugin, Resource type or name | String | +| policy | Policy action for the permission, `create`, `read`, `update`, `delete`, `use` | String | +| effect | `allow` or `deny` | String | + +body: + +```json +[ + { + "entityReference": "role:default/test", + "permission": "catalog-entity", + "policy": "delete", + "effect": "allow" + } +] +``` + +Returns a status code of 201 upon success. + +--- + +### PUT permission + +PUT +ex. + +Updates one or more permission policies for a specified entity. + +Request parameters: + +| Parameter name | Description | Type | +| -------------- | ----------------------- | ------ | +| kind | Kind of the entity | String | +| namespace | Namespace of the entity | String | +| name | Username of the entity | String | + +Request parameters for oldPolicy and newPolicy objects: + +| Parameter name | Description | Type | +| -------------- | ----------------------------------------------------------------------------- | ------ | +| permission | Permission from a specific plugin, Resource type or name | String | +| policy | Policy action for the permission, `create`, `read`, `update`, `delete`, `use` | String | +| effect | `allow` or `deny` | String | + +body: + +```json +{ + "oldPolicy": [ + { + "permission": "catalog-entity", + "policy": "read", + "effect": "allow" + }, + { + "permission": "catalog.entity.create", + "policy": "create", + "effect": "allow" + } + ], + "newPolicy": [ + { + "permission": "catalog-entity", + "policy": "read", + "effect": "deny" + }, + { + "permission": "policy-entity", + "policy": "create", + "effect": "allow" + } + ] +} +``` + +Returns a status code of 200 upon success. + +--- + +### Delete permission + +DELETE +ex. + +Deletes a permission policy of a specified entity. + +Returns a status code of 204 upon success. + +--- + +DELETE +ex. + +Deletes a group of permission policies of a specified entity. + +Request Parameters: + +| Parameter name | Description | Type | +| -------------- | ----------------------- | ------ | +| kind | Kind of the entity | String | +| namespace | Namespace of the entity | String | +| name | Username of the entity | String | + +body: + +```json +[ + { + "entityReference": "role:default/test", + "permission": "catalog-entity", + "policy": "delete", + "effect": "allow" + }, + { + "entityReference": "role:default/test", + "permission": "catalog-entity", + "policy": "update", + "effect": "allow" + } +] +``` + +Returns a status code of 204 upon success. + +--- + +## Plugin + +### GET plugin permission policies + +GET + +Lists all plugin permission policies from plugins installed in your Backstage instance. + +Returns: + +```json +[ + { + "pluginId": "catalog", + "policies": [ + { + "name": "catalog.entity.read", + "policy": "read", + "resourceType": "catalog-entity" + }, + { + "name": "catalog.entity.create", + "policy": "create" + }, + { + "name": "catalog.entity.delete", + "policy": "delete", + "resourceType": "catalog-entity" + }, + { + "name": "catalog.entity.refresh", + "policy": "update", + "resourceType": "catalog-entity" + }, + { + "name": "catalog.location.read", + "policy": "read" + }, + { + "name": "catalog.location.create", + "policy": "create" + }, + { + "name": "catalog.location.delete", + "policy": "delete" + } + ] + }, + ... +] +``` + +--- + +## Conditions + +Conditional permission policies are fairly complex. For more information on how to structure your conditional policies, consult our documentation on [conditions](./conditions.md). + +### GET conditional rules + +GET + +Provides conditional rule parameter schemas. + +```json +[ + { + "pluginId": "catalog", + "rules": [ + { + "name": "HAS_ANNOTATION", + "description": "Allow entities with the specified annotation", + "resourceType": "catalog-entity", + "paramsSchema": { + "type": "object", + "properties": { + "annotation": { + "type": "string", + "description": "Name of the annotation to match on" + }, + "value": { + "type": "string", + "description": "Value of the annotation to match on" + } + }, + "required": [ + "annotation" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "HAS_LABEL", + "description": "Allow entities with the specified label", + "resourceType": "catalog-entity", + "paramsSchema": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Name of the label to match on" + } + }, + "required": [ + "label" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "HAS_METADATA", + "description": "Allow entities with the specified metadata subfield", + "resourceType": "catalog-entity", + "paramsSchema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property within the entities metadata to match on" + }, + "value": { + "type": "string", + "description": "Value of the given property to match on" + } + }, + "required": [ + "key" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "HAS_SPEC", + "description": "Allow entities with the specified spec subfield", + "resourceType": "catalog-entity", + "paramsSchema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property within the entities spec to match on" + }, + "value": { + "type": "string", + "description": "Value of the given property to match on" + } + }, + "required": [ + "key" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "IS_ENTITY_KIND", + "description": "Allow entities matching a specified kind", + "resourceType": "catalog-entity", + "paramsSchema": { + "type": "object", + "properties": { + "kinds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of kinds to match at least one of" + } + }, + "required": [ + "kinds" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "IS_ENTITY_OWNER", + "description": "Allow entities owned by a specified claim", + "resourceType": "catalog-entity", + "paramsSchema": { + "type": "object", + "properties": { + "claims": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of claims to match at least one on within ownedBy" + } + }, + "required": [ + "claims" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } + ] + } + ... +] +``` + +--- + +### POST condition + +POST + +Creates a new condition. + +Request Parameters: condition object in json format described above. + +body: + +```json +{ + "result": "CONDITIONAL", + "roleEntityRef": "role:default/test", + "pluginId": "catalog", + "resourceType": "catalog-entity", + "permissionMapping": ["read"], + "conditions": { + "rule": "IS_ENTITY_OWNER", + "resourceType": "catalog-entity", + "params": { + "claims": ["group:default/team-a"] + } + } +} +``` + +Returns a status code of 201 and json with id upon success: + +```json +{ + "id": 1 +} +``` + +--- + +### PUT condition + +PUT + +Update conditions by id. + +Request Parameters: condition object in json format described above. + +body: + +```json +{ + "result": "CONDITIONAL", + "roleEntityRef": "role:default/test", + "pluginId": "catalog", + "resourceType": "catalog-entity", + "permissionMapping": ["read"], + "conditions": { + "anyOf": [ + { + "rule": "IS_ENTITY_OWNER", + "resourceType": "catalog-entity", + "params": { + "claims": ["group:default/team-a"] + } + }, + { + "rule": "IS_ENTITY_KIND", + "resourceType": "catalog-entity", + "params": { + "kinds": ["Group"] + } + } + ] + } +} +``` + +Returns a status code of 200 upon success. + +--- + +### Get condition by id + +GET + +Returns condition by id: + +```json +{ + "id": 1, + "result": "CONDITIONAL", + "roleEntityRef": "role:default/test", + "pluginId": "catalog", + "resourceType": "catalog-entity", + "permissionMapping": ["read"], + "conditions": { + "anyOf": [ + { + "rule": "IS_ENTITY_OWNER", + "resourceType": "catalog-entity", + "params": { + "claims": ["group:default/team-a"] + } + }, + { + "rule": "IS_ENTITY_KIND", + "resourceType": "catalog-entity", + "params": { + "kinds": ["Group"] + } + } + ] + } +} +``` + +Returns a status code of 200 upon success. + +--- + +### GET conditions + +GET + +Returns lists all conditions: + +```json +[ + { + "id": 1, + "result": "CONDITIONAL", + "roleEntityRef": "role:default/test", + "pluginId": "catalog", + "resourceType": "catalog-entity", + "permissionMapping": ["read"], + "conditions": { + "anyOf": [ + { + "rule": "IS_ENTITY_OWNER", + "resourceType": "catalog-entity", + "params": { + "claims": ["group:default/team-a"] + } + }, + { + "rule": "IS_ENTITY_KIND", + "resourceType": "catalog-entity", + "params": { + "kinds": ["Group"] + } + } + ] + } + } +] +``` + +Returns a status code of 200 upon success. + +--- + +### DELETE condition by id + +DELETE + +Deletes condition by id. + +Returns a status code of 204 upon success. + +--- + +## Refresh permission policies provider API + +The API to update permissions allows triggering the Provider to refresh the permissions list. + +### POST RBAC permission policies + +POST + +Refreshes RBAC permission policies by provider id. + +Request Parameters: provider 'id' in the url path. + +Returns a status code of 200 upon success. + +--- + +## Plugin IDs that support the Backstage permission framework + +API to manage the list of permission IDs that support the Backstage permission framework at runtime without a server restart. This API is important to control rendering the plugin list in the UI. + +List plugins IDs stored in the object: + +| Parameter name | Description | Type | +| -------------- | ---------------- | ------------ | +| ids | list plugins IDs | String Array | + +### GET object with list plugin IDs + +GET + +Returns object with list plugin IDs: + +```json +{ + "ids": ["catalog", "permission"] +} +``` + +Returns a status code of 200 upon success. + +--- + +### POST object with list plugin IDs + +POST + +Add more plugins IDs defined in the request object. + +Request Parameters: object in json format described above. + +body: + +```json +{ + "ids": ["scaffolder"] +} +``` + +Returns a status code of 200 and json with actual object stored in the server: + +```json +{ + "ids": ["catalog", "permission", "scaffolder"] +} +``` + +--- + +### DELETE plugin IDs + +DELETE + +Delete plugins IDs defined in the request object. + +Request Parameters: object in json format described above. + +body: + +```json +{ + "ids": ["scaffolder"] +} +``` + +Returns a status code of 200 and json with actual object stored in the server: + +```json +{ + "ids": ["catalog", "permission"] +} +``` + +## HTTP status codes + +| Code | Descriptions | +| ---- | ----------------------------------------------- | +| 200 | Request was successful | +| 201 | New resource was successfully created | +| 204 | No additional content to send in response | +| 400 | Input Error | +| 401 | Lacks valid authentication | +| 403 | Refusal to authorize | +| 404 | Could not find resource | +| 409 | Conflict with current state and target resource | + +--- + +## Curl Request Examples + +Create role `role:default/test` for `group:default/example`: + +```bash +curl -X POST "http://localhost:7007/api/permission/roles" \ + -d '{ + "memberReferences": [ + "group:default/example" + ], + "name": "role:default/test", + "metadata": { + "description": "This is a test role" + } + }' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $token" \ + -v +``` + +Create permission policy for `role:default/test`: + +```bash +curl -X POST "http://localhost:7007/api/permission/policies" \ + -d '[{ + "entityReference": "role:default/test", + "permission": "catalog-entity", + "policy": "read", + "effect": "allow" + }]' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $token" \ + -v +``` + +Create conditional permission policy for `role:default/test`: + +```bash +curl -X POST "http://localhost:7007/api/permission/roles/conditions" \ + -d '{ + "result": "CONDITIONAL", + "roleEntityRef": "role:default/test", + "pluginId": "catalog", + "resourceType": "catalog-entity", + "permissionMapping": ["read"], + "conditions": { + "rule": "IS_ENTITY_OWNER", + "resourceType": "catalog-entity", + "params": { + "claims": ["group:default/backstage-community-authors"] + } + } + }' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $token" \ + -v +``` diff --git a/plugins/rbac-backend/docs/audit-log.md b/plugins/rbac-backend/docs/audit-log.md new file mode 100644 index 0000000000..04e9f800f7 --- /dev/null +++ b/plugins/rbac-backend/docs/audit-log.md @@ -0,0 +1,252 @@ +# Audit logging + +The RBAC backend plugin supports audit logging with the help of the Auditor Service from [`@backstage/backend-plugin-api`](https://www.npmjs.com/package/@backstage/backend-plugin-api) package. Audit logging helps to track the latest changes and events from the RBAC plugin: + +- RBAC role changes; +- RBAC permissions changes; +- RBAC conditions changes; +- Changes causing modification of application configuration; +- Changes causing modification of the permission policy file; +- GET requests for RBAC permission information; +- User authorization results to RBAC resources. + +The RBAC backend plugin logging doesn't provide information about the actual state of the permissions. The actual state of RBAC permissions can be found in the RBAC UI. Audit logging provides information about what operations were performed, by whom, when, and on which resources. Each operation to audit is recorded as an event with an `eventId` that represents the logical group of the action, such as `role-write`. The event contains information about event id, RBAC permission changes, the actor who made these changes, time, severityLevel, some part of the request if applicable, response if applicable, and so on. You can use this information like a history of the RBAC operations. + +Notice: RBAC permissions and conditions are bound to RBAC roles. However, the RBAC backend plugin logs information about permissions and conditions with the help of separated log messages. That's because for now, the RBAC plugin has a separated API for RBAC roles, RBAC permissions, and RBAC conditions. + +## Audit log actor + +The audit log actor can be a real REST API user or the RBAC plugin itself. When the actor is a REST API user, then the RBAC plugin logs the user's IP, browser agent, and hostname. The RBAC plugin can also be the actor of the events. In this case, the actor has an actorId: "plugin:permission". In this case, the plugin typically applies changes from the configuration or permission policy file. Application configuration and permission policy files usually mount to the application deployment with the help of config maps. Unfortunately, the RBAC plugin cannot track who originally made modifications to these resources. But you can enable Kubernetes API audit log: https://kubernetes.io/docs/tasks/debug/debug-cluster/audit. Then you can match RBAC plugin audit log events to the events from Kubernetes logs by time. + +## Audit log format + +The RBAC plugin prints information to the backend log. The format of these messages is defined in the `@backstage/backend-plugin-api` library. Each audit log line contains the key "isAuditEvent". + +Example logged RBAC events: + +a) RBAC role created with corresponding basic permissions and conditional permission: + +```json +[backend]: 2025-03-25T17:24:17.438Z permission info permission.role-write isAuditEvent=true eventId="role-write" severityLevel="medium" actor={"actorId":"user:default/dzemanov","ip":"::1","hostname":"localhost","us +erAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"} request={"url":"/api/permission/roles","method":"POST"} meta={"actionType":"create", "source":"rest"} status= +"initiated" +[backend]: 2025-03-25T17:24:17.458Z permission info permission.role-write isAuditEvent=true eventId="role-write" severityLevel="medium" actor={"actorId":"user:default/dzemanov","ip":"::1","hostname":"localhost","us +erAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"} request={"url":"/api/permission/roles","method":"POST"} meta={"actionType":"create", "source":"rest","respons +e":{"status":201}, "roleEntityRef":"role:default/test","description":"some test role","author":"user:default/dzemanov","modifiedBy":"user:default/dzemanov","createdAt":"Tue, 25 Mar 2025 17:24:17 GMT","lastModified":"T +ue, 25 Mar 2025 17:24:17 GMT","members":["user:default/dzemanov"]} status="succeeded" + +[backend]: 2025-03-25T17:24:17.461Z permission info permission.policy-write isAuditEvent=true eventId="policy-write" severityLevel="medium" actor={"actorId":"user:default/dzemanov","ip":"::1","hostname":"localhost" +,"userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"} request={"url":"/api/permission/policies","method":"POST"} meta={"actionType":"create", "source":"rest"} +status="initiated" +[backend]: 2025-03-25T17:24:17.473Z permission info permission.policy-write isAuditEvent=true eventId="policy-write" severityLevel="medium" actor={"actorId":"user:default/dzemanov","ip":"::1","hostname":"localhost" +,"userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"} request={"url":"/api/permission/policies","method":"POST"} meta={"actionType":"create", "source":"rest"," +response":{"status":201},"policies":[["role:default/test","catalog.entity.read","read","allow"],["role:default/test","catalog.entity.create","create","allow"],["role:default/test","catalog.entity.refresh","update","a +llow"],["role:default/test","scaffolder.task.create","create","allow"],["role:default/test","scaffolder.task.read","read","allow"]]} status="succeeded" + +[backend]: 2025-03-25T17:24:17.476Z permission info permission.condition-write isAuditEvent=true eventId="condition-write" severityLevel="medium" actor={"actorId":"user:default/dzemanov","ip":"::1","hostname":"loca +lhost","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"} request={"url":"/api/permission/roles/conditions","method":"POST"} meta={"actionType":"create", "so +urce":"rest"} status="initiated" +[backend]: 2025-03-25T17:24:17.488Z permission info permission.condition-write isAuditEvent=true eventId="condition-write" severityLevel="medium" actor={"actorId":"user:default/dzemanov","ip":"::1","hostname":"loca +lhost","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"} request={"url":"/api/permission/roles/conditions","method":"POST"} meta={"actionType":"create", "so +urce":"rest","response":{"status":201},"condition":{"result":"CONDITIONAL","roleEntityRef":"role:default/test","pluginId":"catalog","resourceType":"catalog-entity","permissionMapping":["delete"],"conditions":{"rule": +"IS_ENTITY_OWNER","resourceType":"catalog-entity","params":{"claims":["group:default/team-a"]}}}} status="succeeded" +``` + +b) Check access user to application resource: + +```json +[backend]: 2025-03-25T17:24:29.154Z permission info permission.permission-evaluation isAuditEvent=true eventId="permission-evaluation" severityLevel="medium" actor={"actorId":"plugin:permission"} request=undefined meta={"userEntityRef":"user:default/dzemanov","permissionName":"scaffolder.task.create","action":"create"} status="initiated" +[backend]: 2025-03-25T17:24:29.171Z permission info permission.permission-evaluation isAuditEvent=true eventId="permission-evaluation" severityLevel="medium" actor={"actorId":"plugin:permission"} request=undefined meta={"userEntityRef":"user:default/dzemanov","permissionName":"scaffolder.task.create","action":"create","result":"ALLOW"} status="succeeded" + +[backend]: 2025-03-25T17:24:17.509Z permission info permission.permission-evaluation isAuditEvent=true eventId="permission-evaluation" severityLevel="medium" actor={"actorId":"plugin:permission"} request=undefined me +ta={"userEntityRef":"user:default/dzemanov","permissionName":"policy.entity.delete","action":"delete","resourceType":"policy-entity"} status="initiated" +[backend]: 2025-03-25T17:24:17.522Z permission info permission.permission-evaluation isAuditEvent=true eventId="permission-evaluation" severityLevel="medium" actor={"actorId":"plugin:permission"} request=undefined me +ta={"userEntityRef":"user:default/dzemanov","permissionName":"policy.entity.delete","action":"delete","resourceType":"policy-entity","result":"ALLOW"} status="succeeded" +``` + +Most audit log lines contain a metadata object. The RBAC plugin includes information about RBAC roles, permissions, conditions, and authorization results in this metadata. + +Notice: You need to properly configure the logger to see nested JSON objects in the audit log lines. + +## RBAC audit events + +The RBAC backend emits audit events for various operations. Events are grouped logically by `eventId`. Audit event begins in the `initiated` state. The event then transitions to either `succeeded` state or `failed` state. All events can contain `meta` field with additional information. Event that is `succeeded` or `failed` can contain additional data in its `meta` field, in addition to event `meta`. +Failed events contain `error` information. + +### Role Events + +- **`role-write`**: Modifies roles. + + **Role Event meta for `role-write`:** + - source: string (source emitting the event, `rest`, `csv-file`, `configuration`, `externalProviderPluginId`) + - actionType: string (further specifies type of modify action, `create`, `update`, `delete`, `create_or_update`) + + **Role Event fail/success meta for `role-write`:** + - source: string (source emitting the event, `rest`, `csv-file`, `configuration`, `externalProviderPluginId`) + - actionType: string (further specifies type of modify action, `create`, `update`, `delete`, `create_or_update`) + - roleEntityRef: string + - description?: string + - members: string[] + + Filter on `actionType`. + - **`create`**: Creates roles. (POST `/roles`, extension point `applyRoles`, `rbac_admin` role from `configuration`) + - **`update`**: Updates roles. (PUT `/roles`) + - **`delete`**: Deletes roles. (DELETE `/roles`, extension point `applyRoles`) + - **`create_or_update`**: Bulk creates or updates roles. (loading roles from `csv file`) + + **Role Event fail/success meta for `create_or_update`:** + - addedPolicies: string[][] + - updatedPolicies: string[][] + - failedPolicies: string[][] + +- **`role-read`**: Reads roles. (GET `/roles`) + + **Role Event meta for `role-read`:** + - source: string (source emitting the event, `rest`) + - queryType: string (specifies type of query, `all`, `by-role`) + + **Role Event fail/success meta for `role-read`:** + - source: string (source emitting the event, `rest`) + - queryType: string (specifies type of query, `all`, `by-role`) + + Filter on `queryType`. + - **`all`**: Read all roles. (GET `/roles`) + - **`by-role`**: Read concrete role. (GET `/roles/:kind/:namespace/:name`) + + **Role Event meta for `by-role`:** + - entityRef: string (role entity reference) + +### Permission Events + +- **`policy-write`**: Modifies permissions. + + **Permission Event meta for `policy-write`:** + - source: string (source emitting the event, `rest`, `csv-file`, `configuration`, `externalProviderPluginId`) + - actionType: string (further specifies type of modify action, `create`, `update`, `delete`) + + **Permission Event fail/success meta for `policy-write`:** + - source: string (source emitting the event, `rest`, `csv-file`, `configuration`, `externalProviderPluginId`) + - actionType: string (further specifies type of modify action, `create`, `update`, `delete`) + - policies: string[][] (modified permissions) + + Filter on `actionType`. + - **`create`**: Creates permissions. (POST `/policies`, extension point `applyPermissions`) + - **`update`**: Updates permissions. (PUT `/policies`) + - **`delete`**: Deletes permissions. (DELETE `/policies`, extension point `applyPermissions`) + +- **`policy-read`**: Reads permissions. (GET `/policies`) + + **Policy Event meta for `policy-read`:** + - source: string (source emitting the event, `rest`) + - queryType: string (specifies type of query, `all`, `by-role`, `by-query`) + + **Policy Event fail/success meta for `policy-read`:** + - source: string (source emitting the event, `rest`) + - queryType: string (specifies type of query, `all`, `by-role`, `by-query`) + + Filter on `queryType`. + - **`all`**: Read all policies. (GET `/policies`) + - **`by-role`**: Read all policies associated with a role. (GET `/policies/:kind/:namespace/:name`) + - **`by-query`**: Read all policies that match query filter criteria. (GET `/policies`) + + **Policy Event meta for `by-role`:** + - entityRef: string (role entity reference) + + **Policy Event meta for `by-query`:** + - query: string + +### Condition Events + +- **`condition-write`**: Modifies conditions. + + **Condition Event meta for `condition-write`:** + - source?: string (source emitting the event, `rest` or not included for conditions from `yaml-conditional-file`) + - actionType: string (further specifies type of modify action, `create`, `update`, `delete`) + + **Condition Event fail/success meta for `condition-write`:** + - source?: string (source emitting the event, `rest` or not included for conditions from `yaml-conditional-file`) + - actionType: string (further specifies type of modify action, `create`, `update`, `delete`) + - condition: RoleConditionalPolicyDecision<"create" | "read" | "update" | "delete" | "use"> + + Filter on `actionType`. + - **`create`**: Creates conditions. (POST `/roles/conditions`, extension point `applyPermissions`) + - **`update`**: Updates conditions. (PUT `/roles/conditions`) + - **`delete`**: Deletes conditions. (DELETE `/roles/conditions`, extension point `applyPermissions`) + +- **`condition-read`**: Reads conditions. (GET `/roles/conditions`) + + **Condition Event meta for `condition-read`:** + - source: string (source emitting the event, `rest`) + - queryType: string (specifies type of query, `all`, `by-id`, `by-query`) + + **Condition Event fail/success meta for `condition-read`:** + - source: string (source emitting the event, `rest`) + - queryType: string (specifies type of query, `all`, `by-id`, `by-query`) + + Filter on `queryType`. + - **`all`**: Read all conditions. (GET `/roles/conditions`) + - **`by-id`**: Read condition with id. (GET `/roles/conditions/:id`) + - **`by-query`**: Read all conditions that match query filter criteria. (GET `/policies`) + + **Condition Event meta for `by-id`:** + - id: string (condition id) + + **Condition Event meta for `by-query`:** + - query: string + +### Conditional File Events + +- **`conditional-policies-file-not-found`**: Conditional policies file was not found. + +- **`conditional-policies-file-change`**: Conditional policies file changed. + +### Permission Evaluation Events + +- **`permission-evaluation`**: Evaluation of permissions. + + **Permission Evaluation Event meta for `permission-evaluation`:** + - userEntityRef: string + - permissionName: string + - action: PermissionAction + - resourceType?: string + - decision?: PolicyDecision + + **Permission Evaluation Success/Fail meta for `permission-evaluation`:** + - userEntityRef: string + - permissionName: string + - action: PermissionAction + - resourceType?: string + - decision?: PolicyDecision + - result: AuthorizeResult + +### Plugins Events + +- **`plugin-policies-read`**: List available plugin permission policies. (GET `/plugins/policies`) + +- **`condition-rules-read`**: List conditional rule parameter schema. (GET `/plugins/condition-rules`) + +**Plugins Event meta:** + +- source: string (source emitting the event, `rest`) + +**Plugins Event fail/success meta:** + +- source: string (source emitting the event, `rest`) + +### Plugin IDs events + +-**`plugin-ids-read`**: Lists the plugins that support the Backstage permission framework. + +-**`plugin-ids-write`**: Updates the list of plugins that support the Backstage permission framework. + +**Plugins IDs Event meta:** + +- source: string (source emitting the event, `rest`) + +**Plugins IDs Event fail/success meta:** + +- source: string (source emitting the event, `rest`) + +for `plugin-ids-write` will be included also: + +- ids: string[] diff --git a/plugins/rbac-backend/docs/conditions.md b/plugins/rbac-backend/docs/conditions.md new file mode 100644 index 0000000000..985e6b5551 --- /dev/null +++ b/plugins/rbac-backend/docs/conditions.md @@ -0,0 +1,388 @@ +# Conditional Permission Policies + +The Backstage permission framework provides conditions, and the RBAC backend plugin supports this feature. Conditions work like content filters for Backstage resources (provided by plugins). The RBAC backend API stores conditions assigned to the role in the database. When a user requests access to the frontend resources, the RBAC backend API searches for corresponding conditions and delegates the condition for this resource to the corresponding plugin by its plugin ID. If a user was assigned to multiple roles, and each of these roles contains its own condition, the RBAC backend merges conditions using the anyOf criteria. + +The corresponding plugin analyzes conditional parameters and makes a decision about which part of the content the user should see. Consequently, the user can view not all resource content but only some allowed parts. The RBAC backend plugin supports conditions bounded to the RBAC role. + +A Backstage condition can be a simple condition with a rule and parameters. But also a Backstage condition could consists of a parameter or an array of parameters joined by criteria. The list of supported conditional criteria includes: + +- allOf +- anyOf +- not + +The plugin defines the supported condition parameters. API users can retrieve the conditional object schema from the RBAC API endpoint to determine how to build a condition JSON object and utilize it through the RBAC backend plugin API. + +The structure of the condition JSON object is as follows: + +| Json field | Description | Type | +| ----------------- | --------------------------------------------------------------------- | ------------ | +| result | Always has the value "CONDITIONAL" | String | +| roleEntityRef | String entity reference to the RBAC role ('role:default/dev') | String | +| pluginId | Corresponding plugin ID (e.g., "catalog") | String | +| permissionMapping | Array permission actions (['read', 'update', 'delete']) | String array | +| resourceType | Resource type provided by the plugin (e.g., "catalog-entity") | String | +| conditions | Condition JSON with parameters or array parameters joined by criteria | JSON | + +To get the available conditional rules that can be used to create conditional permission policies, use the GET API request `api/permission/plugins/condition-rules` as seen below. + +GET + +Provides condition parameters schemas. + +```json +[ + { + "pluginId": "catalog", + "rules": [ + { + "name": "HAS_ANNOTATION", + "description": "Allow entities with the specified annotation", + "resourceType": "catalog-entity", + "paramsSchema": { + "type": "object", + "properties": { + "annotation": { + "type": "string", + "description": "Name of the annotation to match on" + }, + "value": { + "type": "string", + "description": "Value of the annotation to match on" + } + }, + "required": [ + "annotation" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "HAS_LABEL", + "description": "Allow entities with the specified label", + "resourceType": "catalog-entity", + "paramsSchema": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Name of the label to match on" + } + }, + "required": [ + "label" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "HAS_METADATA", + "description": "Allow entities with the specified metadata subfield", + "resourceType": "catalog-entity", + "paramsSchema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property within the entities metadata to match on" + }, + "value": { + "type": "string", + "description": "Value of the given property to match on" + } + }, + "required": [ + "key" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "HAS_SPEC", + "description": "Allow entities with the specified spec subfield", + "resourceType": "catalog-entity", + "paramsSchema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property within the entities spec to match on" + }, + "value": { + "type": "string", + "description": "Value of the given property to match on" + } + }, + "required": [ + "key" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "IS_ENTITY_KIND", + "description": "Allow entities matching a specified kind", + "resourceType": "catalog-entity", + "paramsSchema": { + "type": "object", + "properties": { + "kinds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of kinds to match at least one of" + } + }, + "required": [ + "kinds" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "IS_ENTITY_OWNER", + "description": "Allow entities owned by a specified claim", + "resourceType": "catalog-entity", + "paramsSchema": { + "type": "object", + "properties": { + "claims": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of claims to match at least one on within ownedBy" + } + }, + "required": [ + "claims" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } + ] + } + ... +] +``` + +From this condition schema, the RBAC backend API user can determine how to build a condition JSON object. + +For example, consider a condition without criteria: displaying catalogs only if the user is a member of the owner group. The Catalog plugin schema "IS_ENTITY_OWNER" can be utilized to achieve this goal. To construct the condition JSON object based on this schema, the following information should be used: + +- rule: the parameter name is "IS_ENTITY_OWNER" in this case +- resourceType: "catalog-entity" +- criteria: in this example, criteria are not used since we need to use only one conditional parameter +- params: from the schema, it is evident that it should be an object named "claims" with a string array. This string array constitutes a list of user or group string entity references. + +Based on the above schema condition is: + +```json +{ + "rule": "IS_ENTITY_OWNER", + "resourceType": "catalog-entity", + "params": { + "claims": ["group:default/team-a"] + } +} +``` + +To utilize this condition to the RBAC REST api you need to wrap it with more info + +```json +{ + "result": "CONDITIONAL", + "roleEntityRef": "role:default/test", + "pluginId": "catalog", + "resourceType": "catalog-entity", + "permissionMapping": ["read"], + "conditions": { + "rule": "IS_ENTITY_OWNER", + "resourceType": "catalog-entity", + "params": { + "claims": ["group:default/team-a"] + } + } +} +``` + +**Example condition with criteria**: display catalogs only if user is a member of owner group "OR" display list of all catalog user groups. + +We can reuse previous condition parameter to display catalogs only for owner. Also we can use one more condition "IS_ENTITY_KIND" to display catalog groups for any user: + +- rule - the parameter name is "IS_ENTITY_KIND" in this case. +- resource type: "catalog-entity". +- criteria - "anyOf". +- params - from the schema, it is evident that it should be an object named "kinds" with string array. This string array is a list of catalog kinds. It should be array with single element "Group" in our case. + +Based on the above schema: + +```json +{ + "anyOf": [ + { + "rule": "IS_ENTITY_OWNER", + "resourceType": "catalog-entity", + "params": { + "claims": ["group:default/team-a"] + } + }, + { + "rule": "IS_ENTITY_KIND", + "resourceType": "catalog-entity", + "params": { + "kinds": ["Group"] + } + } + ] +} +``` + +To utilize this condition to the RBAC REST api you need to wrap it with more info: + +```json +{ + "result": "CONDITIONAL", + "roleEntityRef": "role:default/test", + "pluginId": "catalog", + "resourceType": "catalog-entity", + "permissionMapping": ["read"], + "conditions": { + "anyOf": [ + { + "rule": "IS_ENTITY_OWNER", + "resourceType": "catalog-entity", + "params": { + "claims": ["group:default/team-a"] + } + }, + { + "rule": "IS_ENTITY_KIND", + "resourceType": "catalog-entity", + "params": { + "kinds": ["Group"] + } + } + ] + } +} +``` + +## Conditional Policy Aliases + +The RBAC-backend plugin allows for the use of aliases in the conditional policy rule parameters. These aliases are dynamically replaced with corresponding values during the policy evaluation process. Each alias is prefixed with a `$` sign to denote its special function. + +### Supported Aliases + +1. **`$currentUser`**: + - **Description**: This alias is replaced with the user entity reference for the user currently requesting access to the resource. + - **Example**: If the user "Tom" from the "default" namespace is requesting access, `$currentUser` will be replaced with `user:default/tom`. + +2. **`$ownerRefs`**: + - **Description**: This alias is replaced with ownership references, typically in the form of an array. The array usually contains the user entity reference and the user's parent group entity reference. + - **Example**: For a user "Tom" who belongs to "team-a", `$ownerRefs` will be replaced with `['user:default/tom', 'group:default/team-a']`. + +### Example of a Conditional Policy Object with Alias + +This condition should allow members of the `role:default/developer` to delete only their own catalogs and no others: + +```json +{ + "result": "CONDITIONAL", + "roleEntityRef": "role:default/developer", + "pluginId": "catalog", + "resourceType": "catalog-entity", + "permissionMapping": ["delete"], + "conditions": { + "rule": "IS_ENTITY_OWNER", + "resourceType": "catalog-entity", + "params": { + "claims": ["$currentUser"] + } + } +} +``` + +## Examples of Conditional Policies + +Below are a few examples that can be used on some of the Janus IDP plugins. These can help in determining how based to define conditional policies + +### Keycloak plugin + +```json +{ + "result": "CONDITIONAL", + "roleEntityRef": "role:default/developer", + "pluginId": "catalog", + "resourceType": "catalog-entity", + "permissionMapping": ["update", "delete"], + "conditions": { + "not": { + "rule": "HAS_ANNOTATION", + "resourceType": "catalog-entity", + "params": { "annotation": "keycloak.org/realm", "value": "" } + } + } +} +``` + +This example will prevent users in the role `role:default/developer` from updating or deleting users that ingested into the catalog from the Keycloak plugin. + +Notice the use of the annotation `keycloak.org/realm` requires the value of `` + +### Quay Actions + +```json +{ + "result": "CONDITIONAL", + "roleEntityRef": "role:default/developer", + "pluginId": "scaffolder", + "resourceType": "scaffolder-action", + "permissionMapping": ["use"], + "conditions": { + "not": { + "rule": "HAS_ACTION_ID", + "resourceType": "scaffolder-action", + "params": { "actionId": "quay:create-repository" } + } + } +} +``` + +This example will prevent users from using the Quay scaffolder action if they are a part of the role `role:default/developer`. + +Notice, we use the `permissionMapping` field with `use`. This is because the `scaffolder-action` resource type permission does not have a permission policy. More information can be found in our documentation on [permissions](./permissions.md). + +**NOTE**: We do not support the ability to run conditions in parallel during creation. An example can be found below, notice that `anyOf` and `not` are on the same level. Consider making separate condition requests, or nest your conditions based on the available criteria. + +```json +{ + "anyOf": [ + { + "rule": "IS_ENTITY_OWNER", + "resourceType": "catalog-entity", + "params": { + "claims": ["group:default/team-a"] + } + }, + { + "rule": "IS_ENTITY_KIND", + "resourceType": "catalog-entity", + "params": { + "kinds": ["Group"] + } + } + ], + "not": { + "rule": "IS_ENTITY_KIND", + "resourceType": "catalog-entity", + "params": { "kinds": ["Api"] } + } +} +``` diff --git a/plugins/rbac-backend/docs/group-hierarchy.md b/plugins/rbac-backend/docs/group-hierarchy.md new file mode 100644 index 0000000000..13f4a3bb28 --- /dev/null +++ b/plugins/rbac-backend/docs/group-hierarchy.md @@ -0,0 +1,236 @@ +# Group Hierarchy + +RBAC access control is configured by defining roles and their associated permission policies, which +are then assigned to users or groups. Leveraging group hierarchy can greatly simplify RBAC management, +making it more scalable and flexible. + +## Group-Based Role Assignment + +Role can be assigned to a specific group. If a user is a member of that group, or a member of any of +its child groups, the role (and its associated permissions) will automatically be applied to that user. + +Examples: + +- Sam will inherit `role:default/test` from `team-group` via `subteam-group`. + + ![Group hierarchy diagram with sam as a member of subteam-group that is child of team-group](./images/group-hierarchy-1.svg) + + ```yaml + # catalog-entity.yaml + apiVersion: backstage.io/v1alpha1 + kind: Group + metadata: + name: team-group + spec: + type: team + children: [subteam-group] + --- + apiVersion: backstage.io/v1alpha1 + kind: Group + metadata: + name: subteam-group + spec: + type: team + children: [] + parent: team-group + --- + apiVersion: backstage.io/v1alpha1 + kind: User + metadata: + name: sam + spec: + memberOf: + - subteam-group + ``` + + ```CSV + g, group:default/team-group, role:default/test + p, role:default/test, catalog-entity, read, allow + ``` + +- Sam will have `role:default/test` via `team-group`. + + ![Group hierarchy diagram with sam as a member of team-group](./images/group-hierarchy-2.svg) + + ```yaml + # catalog-entity.yaml + apiVersion: backstage.io/v1alpha1 + kind: User + metadata: + name: sam + --- + apiVersion: backstage.io/v1alpha1 + kind: Group + metadata: + name: team-group + spec: + type: team + children: [] + members: + - sam + ``` + + ```CSV + g, group:default/team-group, role:default/test + p, role:default/test, catalog-entity, read, allow + ``` + +- Sam will inherit `role:default/role-a` from `group-a` and `role:default/role-c` from `group-c`. + + ![Group hierarchy diagram with sam as a member of group-b and group-c, group-a is parent of group-b](./images/group-hierarchy-3.svg) + + ```yaml + # catalog-entity.yaml + apiVersion: backstage.io/v1alpha1 + kind: Group + metadata: + name: group-a + spec: + type: team + children: [group-b] + --- + apiVersion: backstage.io/v1alpha1 + kind: Group + metadata: + name: group-b + spec: + type: team + children: [] + --- + apiVersion: backstage.io/v1alpha1 + kind: Group + metadata: + name: group-c + spec: + type: team + children: [] + --- + apiVersion: backstage.io/v1alpha1 + kind: User + metadata: + name: sam + spec: + memberOf: + - group-b + - group-c + ``` + + ```CSV + g, group:default/group-a, role:default/role-a + g, group:default/group-c, role:default/role-c + p, role:default/role-a, catalog-entity, read, allow + p, role:default/role-c, catalog-entity, delete, allow + ``` + +## Managing Group Hierarchy Depth + +While group hierarchy provides powerful inheritance features, it can have performance implications. +Organizations with potentially complex group hierarchy can specify `maxDepth` configuration value, +that will ensure that the RBAC plugin will stop at a certain depth when building user graphs. + +```YAML +permission: + enabled: true + rbac: + maxDepth: 1 +``` + +The `maxDepth` must be greater than or equal to 0 to ensure that the graphs are built correctly. Also the graph +will be built with a hierarchy of 1 + maxDepth. + +A value of 0 for maxDepth disables the group inheritance feature. + +## Non-Existent Groups in the Hierarchy + +For group hierarchy to function, groups don't need to be present in the catalog as long as the group +has an existing parent group or is a member of existing group or an existing user is a member of +that group. +(Note that this does not work with in-memory database.) + +Examples: + +- Sam will inherit `role:default/test`, although `team-group` isn't explicitly defined. + + ![Group hierarchy diagram with sam as a member of team-group](./images/group-hierarchy-2.svg) + + ```yaml + # catalog-entity.yaml + apiVersion: backstage.io/v1alpha1 + kind: User + metadata: + name: sam + spec: + memberOf: + - team-group + ``` + + ```CSV + g, group:default/team-group, role:default/test + p, role:default/test, catalog-entity, read, allow + ``` + +- Sam will inherit `role:default/test` via `subteam-group` that is a child of `team-group`, although `subteam-group` isn't explicitly defined. + + ![Group hierarchy diagram with sam as a member of subteam-group that is child of team-group](./images/group-hierarchy-1.svg) + + ```yaml + # catalog-entity.yaml + apiVersion: backstage.io/v1alpha1 + kind: User + metadata: + name: sam + spec: + memberOf: + - subteam-group + --- + apiVersion: backstage.io/v1alpha1 + kind: Group + metadata: + name: team-group + spec: + type: team + children: [subteam-group] + ``` + + ```CSV + g, group:default/team-group, role:default/test + p, role:default/test, catalog-entity, read, allow + ``` + +- Sam will inherit `role:default/test` via `group-d` <- `group-c` <- `group-b` <- `group-a`, + although `group-d` and `group-b` aren't explicitly defined. + + ![Group hierarchy diagram with sam as a member of group-a with parent group-b with parent group-c with parent group-d](./images/group-hierarchy-4.svg) + + ```yaml + # catalog-entity.yaml + apiVersion: backstage.io/v1alpha1 + kind: User + metadata: + name: sam + spec: + memberOf: + - group-d + --- + apiVersion: backstage.io/v1alpha1 + kind: Group + metadata: + name: group-c + spec: + type: team + children: [group-d] + parent: group-b + --- + apiVersion: backstage.io/v1alpha1 + kind: Group + metadata: + name: group-a + spec: + type: team + children: [group-b] + ``` + + ```CSV + g, group:default/group-a, role:default/test + p, role:default/test, catalog-entity, read, allow + ``` diff --git a/plugins/rbac-backend/docs/images/group-hierarchy-1.svg b/plugins/rbac-backend/docs/images/group-hierarchy-1.svg new file mode 100644 index 0000000000..573bb26c68 --- /dev/null +++ b/plugins/rbac-backend/docs/images/group-hierarchy-1.svg @@ -0,0 +1 @@ +

role:default/test

group:default/team-group

group:default/subteam-group

user:default/sam

\ No newline at end of file diff --git a/plugins/rbac-backend/docs/images/group-hierarchy-2.svg b/plugins/rbac-backend/docs/images/group-hierarchy-2.svg new file mode 100644 index 0000000000..86156c7fcd --- /dev/null +++ b/plugins/rbac-backend/docs/images/group-hierarchy-2.svg @@ -0,0 +1 @@ +

role:default/test

group:default/team-group

user:default/sam

\ No newline at end of file diff --git a/plugins/rbac-backend/docs/images/group-hierarchy-3.svg b/plugins/rbac-backend/docs/images/group-hierarchy-3.svg new file mode 100644 index 0000000000..62f819f0d0 --- /dev/null +++ b/plugins/rbac-backend/docs/images/group-hierarchy-3.svg @@ -0,0 +1 @@ +

role:default/role-a

role:default/role-c

group:default/group-a

group:default/group-b

user:default/sam

group:default/group-c

\ No newline at end of file diff --git a/plugins/rbac-backend/docs/images/group-hierarchy-4.svg b/plugins/rbac-backend/docs/images/group-hierarchy-4.svg new file mode 100644 index 0000000000..3fd7d85ee8 --- /dev/null +++ b/plugins/rbac-backend/docs/images/group-hierarchy-4.svg @@ -0,0 +1 @@ +

role:default/test

group:default/group-a

group:default/group-b

group:default/group-c

group:default/group-d

user:default/sam

diff --git a/plugins/rbac-backend/docs/multitenancy.md b/plugins/rbac-backend/docs/multitenancy.md new file mode 100644 index 0000000000..f398ea8096 --- /dev/null +++ b/plugins/rbac-backend/docs/multitenancy.md @@ -0,0 +1,176 @@ +# Multitenancy + +The RBAC backend plugin has support for multitenancy through the use of its own conditional rule `IS_OWNER`. This rule will allow users the ability to perform actions against roles and permissions in which they are an owner. An example where this conditional rule could be helpful is where admins would like to grant team leads the ability to manage their own roles and permissions for their team. + +## Conditional rule + +```yaml + { + "pluginId": "permission", + "rules": [ + { + "name": "IS_OWNER", + "description": "Should allow access to RBAC roles and Permissions through ownership", + "resourceType": "policy-entity", + "paramsSchema": { + "type": "object", + "properties": { + "owners": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of entity refs to match against" + } + }, + "required": [ + "owners" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } + ] + }, +``` + +## Example + +### Admin + +The following can be used as an example on how to set up multitenancy from the admin's point of view. In this example, we are going to create a role, assign a user, and assign the `catalog.entity.read` permission as well as a conditional policy for permission policies and roles. + +1. Create a new role for the team lead: + + ```bash + curl -X POST 'http://localhost:7007/api/permission/roles' \ + --header "Authorization: Bearer $ADMIN_TOKEN" \ + --header "Content-Type: application/json" \ + --data '{ + "memberReferences": ["user:default/team_lead"], + "name": "role:default/team_lead", + "metadata": { + "description": "This is an example team lead role" + } + }' + ``` + +2. Create a permission policy to grant the team lead read access to the catalog and create access to the RBAC backend plugin: + + ```bash + curl -X POST 'http://localhost:7007/api/permission/policies' \ + --header "Authorization: Bearer $ADMIN_TOKEN" \ + --header "Content-Type: application/json" \ + --data '[ + { + "entityReference": "role:default/team_lead", + "permission": "policy-entity", + "policy": "create", + "effect": "allow" + }, + { + "entityReference": "role:default/team_lead", + "permission": "catalog-entity", + "policy": "read", + "effect": "allow" + } + ]' + ``` + +3. Create a conditional policy to grant the team lead access to the RBAC backend plugin: + + ```bash + curl -X POST 'http://localhost:7007/api/permission/roles/conditions' \ + --header "Authorization: Bearer $ADMIN_TOKEN" \ + --header "Content-Type: application/json" \ + --data '{ + "result": "CONDITIONAL", + "pluginId": "permission", + "resourceType": "policy-entity", + "conditions": { + "rule": "IS_OWNER", + "resourceType": "policy-entity", + "params": { + "owners": [ + "user:default/team_lead" + ] + } + }, + "roleEntityRef": "role:default/team_lead", + "permissionMapping": [ + "read", + "update", + "delete" + ] + }' + ``` + +### Team Lead + +The following is an example from the team lead's point of view after they have been granted conditional access to the RBAC backend plugin. In this example: + +- We will check that we are unable to see any roles prior to performing any actions. +- Create a role, assign a user, and assign the `catalog.entity.read` permission. +- And finally check that we are able to read the new role and permission policy after creation. + +1. Query the roles to see that we are unable to see any other roles: + + ```bash + curl -X GET 'http://localhost:7007/api/permission/roles' \ + --header "Authorization: Bearer $TEAM_LEAD_TOKEN" + ``` + +2. Query the permission policies to see that we are unable to see any other policies: + + ```bash + curl -X GET 'http://localhost:7007/api/permission/policies' \ + --header "Authorization: Bearer $TEAM_LEAD_TOKEN" + ``` + +3. Create a new role for your team, ensuring you set yourself as the owner: + + **NOTE**: Ownership is automatically assigned to the user during creation but can be updated at anytime. + + ```bash + curl -X POST 'http://localhost:7007/api/permission/roles' \ + --header "Authorization: Bearer $TEAM_LEAD_TOKEN" \ + --header "Content-Type: application/json" \ + --data '{ + "memberReferences": ["user:default/team_member"], + "name": "role:default/team_a", + "metadata": { + "description": "This is an example team_a role", + "owner": "user:default/team_lead" + } + }' + ``` + +4. Create a permission policy for your new role: + + ```bash + curl -X POST 'http://localhost:7007/api/permission/policies' \ + --header "Authorization: Bearer $ADMIN_TOKEN" \ + --header "Content-Type: application/json" \ + --data '[ + { + "entityReference": "role:default/team_a", + "permission": "catalog-entity", + "policy": "read", + "effect": "allow" + } + ]' + ``` + +5. Re-query the roles to see our new created role: + + ```bash + curl -X GET 'http://localhost:7007/api/permission/roles' \ + --header "Authorization: Bearer $TEAM_LEAD_TOKEN" + ``` + +6. Re-query the permission policies to see our new created policy: + + ```bash + curl -X GET 'http://localhost:7007/api/permission/policies' \ + --header "Authorization: Bearer $TEAM_LEAD_TOKEN" + ``` diff --git a/plugins/rbac-backend/docs/permissions.md b/plugins/rbac-backend/docs/permissions.md new file mode 100644 index 0000000000..c15dc72515 --- /dev/null +++ b/plugins/rbac-backend/docs/permissions.md @@ -0,0 +1,153 @@ +# Example permissions within Showcase / RHDH + +Note: The requirements section primarily pertains to the frontend and may not be strictly necessary for the backend. + +When defining a permission for the RBAC Backend plugin to consume, follow these guidelines: + +- Permission policies defined using the name of the permission will have higher priority over permission policies that are defined using the resource type. + - Example: + + ```CSV + p, role:default/myrole, catalog-entity, read, allow + p, role:default/myrole, catalog.entity.read, read, deny + g, user:default/myuser, role:default/myrole + ``` + + Where 'myuser' will have a deny for reading catalog entities, because the permission name takes priority over the permission resource type. + +- If the permission does not have a policy associated with it, use the keyword `use` in its place. + - Example: `p, role:default/test, kubernetes.proxy, use, allow` + +## Resource Type vs Basic Named Permissions + +There are two types of permissions within Backstage that can be defined using the RBAC Backend plugin. These are resource permissions and basic named permissions. The difference between the two is whether or not a permission has a resource type. Resource type permissions can be defined either using their associated resource type or their name. Basic named permissions must use their name. + +Basic name permissions are simple permissions that handle most use cases for plugins. These permissions on require a name and an attribute during creation. While the name and attribute for the basic named permission are required, the actions under the attributes are optional. These actions are what we consider policies within the RBAC Backend plugin. + +- Example of the `catalog.location.read` permission and how it would be defined using the RBAC Backend plugin: + + ```ts + export const catalogLocationReadPermission = createPermission({ + name: 'catalog.location.read', + attributes: { + action: 'read', + }, + }); + ``` + + ```CSV + p, role:default/myrole, catalog.location.read, read, allow + g, user:default/myuser, role:default/myrole + ``` + +Resource type permissions on the other hand are basic named permissions with a resource type. These permissions are typically associated with conditional permission rules based on that particular resource type. We can define these permissions using either their name or resource type. + +- Example of the `catalog.entity.read` permission and two ways that we can define its permissions using the RBAC Backend plugin: + + ```ts + export const RESOURCE_TYPE_CATALOG_ENTITY = 'catalog-entity'; + + export const catalogEntityReadPermission = createPermission({ + name: 'catalog.entity.read', + attributes: { + action: 'read', + }, + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, + }); + ``` + + ```CSV + p, role:default/myrole, catalog.entity.read, read, allow + g, user:default/myuser, role:default/myrole + + p, role:default/another-role, catalog-entity, read, allow + g, user:default/another-user, role:default/another-role + ``` + +## Catalog + +| Name | Resource Type | Policy | Description | Requirements | +| ----------------------- | -------------- | ------ | ------------------------------------------------------- | ----------------------- | +| catalog.entity.read | catalog-entity | read | Allows the user to read from the catalog | X | +| catalog.entity.create | | create | Allows the user to create catalog entities | catalog.location.create | +| catalog.entity.refresh | catalog-entity | update | Allows the user to refresh one or more catalog entities | catalog.entity.read | +| catalog.entity.delete | catalog-entity | delete | Allows the user to delete one or more catalog entities | catalog.entity.read | +| catalog.location.read | | read | Allows the user to read one or more catalog locations | catalog.entity.read | +| catalog.location.create | | create | Allows the user to create one or more catalog locations | catalog.entity.create | +| catalog.location.delete | | delete | Allows the user to delete one or more catalog locations | catalog.entity.delete | + +## Jenkins + +| Name | Resource Type | Policy | Description | Requirements | +| --------------- | -------------- | ------ | ---------------------------------------------------------- | ------------------- | +| jenkins.execute | catalog-entity | update | Allows the user to execute an action in the Jenkins plugin | catalog.entity.read | + +## Kubernetes + +| Name | Resource Type | Policy | Description | Requirements | +| ------------------------- | ------------- | ------ | ----------------------------------------------------------------------------------------------------------- | ------------------- | +| kubernetes.clusters.read | | read | Allows the user to read Kubernetes clusters information under `/clusters` | catalog.entity.read | +| kubernetes.resources.read | | read | Allows the user to read Kubernetes resources information under `/services/:serviceId` and `/resources` | catalog.entity.read | +| kubernetes.proxy | | | Allows the user to access the proxy endpoint (ability to read pod logs and events within Showcase and RHDH) | catalog.entity.read | + +## RBAC + +| Name | Resource Type | Policy | Description | Requirements | +| -------------------- | ------------- | ------ | ----------------------------------------------------- | ------------ | +| policy.entity.read | policy-entity | read | Allows the user to read permission policies / roles | X | +| policy.entity.create | policy-entity | create | Allows the user to create permission policies / roles | X | +| policy.entity.update | policy-entity | update | Allow the user to update permission policies / roles | X | +| policy.entity.delete | policy-entity | delete | Allow the user to delete permission policies / roles | X | + +## Scaffolder + +| Name | Resource Type | Policy | Description | Requirements | +| ---------------------------------- | ------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| scaffolder.action.execute | scaffolder-action | | Allows the execution of an action from a template | scaffolder.template.parameter.read, scaffolder.template.step.read | +| scaffolder.template.parameter.read | scaffolder-template | read | Allows the user to read parameters of a template | scaffolder.template.step.read | +| scaffolder.template.step.read | scaffolder-template | read | Allows the user to read steps of a template | scaffolder.template.paramater.read | +| scaffolder.task.create | | create | This permission is used to authorize actions that involve the creation of tasks in the scaffolder | scaffolder.template.parameter.read, scaffolder.template.step.read | +| scaffolder.task.read | | read | This permission is used to authorize actions that involve reading one or more tasks in the scaffolder and reading logs of tasks | scaffolder.template.parameter.read, scaffolder.template.step.read | +| scaffolder.task.cancel | | use | This permission is used to authorize actions that involve the cancellation of tasks in the scaffolder | scaffolder.template.parameter.read, scaffolder.template.step.read | +| scaffolder.template.management | | use | Allows a user or role to access frontend template management features, including editing, previewing, and trying templates, forms, and custom fields. | | + +## OCM + +| Name | Resource Type | Policy | Description | Requirements | +| ---------------- | ------------- | ------ | ----------------------------------------------------------------- | ------------ | +| ocm.entity.read | | read | Allows the user to read from the ocm plugin | X | +| ocm.cluster.read | | read | Allows the user to read the cluster information in the ocm plugin | X | + +## Tekton + +| Name | Resource Type | Policy | Description | Requirements | +| ------------------------- | ------------- | ------ | ------------------------------------------------------------------------------------------------------------------ | ------------------- | +| kubernetes.clusters.read | | read | Allows the user to read Kubernetes clusters information under `/clusters` | catalog.entity.read | +| kubernetes.resources.read | | read | Allows the user to read Kubernetes resources information under `/services/:serviceId` and `/resources` | catalog.entity.read | +| kubernetes.proxy | | | Allows the user to access the proxy endpoint (ability to read tekton pod logs and events within Showcase and RHDH) | catalog.entity.read | + +## Topology + +| Name | Resource Type | Policy | Description | Requirements | +| ------------------------- | ------------- | ------ | ----------------------------------------------------------------------------------------------------------- | ------------------- | +| kubernetes.clusters.read | | read | Allows the user to read Kubernetes clusters information under `/clusters` | catalog.entity.read | +| kubernetes.resources.read | | read | Allows the user to read Kubernetes resources information under `/services/:serviceId` and `/resources` | catalog.entity.read | +| kubernetes.proxy | | | Allows the user to access the proxy endpoint (ability to read pod logs and events within Showcase and RHDH) | catalog.entity.read | + +## Argocd + +| Name | Resource Type | Policy | Description | Requirements | +| ---------------- | ------------- | ------ | ----------------------------------------- | ------------------- | +| argocd.view.read | | read | Allows the user to view the argocd plugin | catalog.entity.read | + +## Quay + +| Name | Resource Type | Policy | Description | Requirements | +| -------------- | ------------- | ------ | --------------------------------------- | ------------------- | +| quay.view.read | | read | Allows the user to view the quay plugin | catalog.entity.read | + +## Bulk Import + +| Name | Resource Type | Policy | Description | Requirements | +| ----------- | ------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------ | +| bulk.import | bulk-import | | Allows the user to access the bulk import endpoints (listing all repositories and organizations accessible by all GitHub integrations, as well as managing the import requests, ...) | X | diff --git a/plugins/rbac-backend/docs/providers.md b/plugins/rbac-backend/docs/providers.md new file mode 100644 index 0000000000..a3f6c6b0bd --- /dev/null +++ b/plugins/rbac-backend/docs/providers.md @@ -0,0 +1,350 @@ +# RBAC Providers + +The RBAC plugins also has the ability to apply roles and permissions from third party access management tools through the use of the RBAC extension points. These extension points allow you to create a backend plugin module that connects your third part access management tool to the RBAC backend plugin. In this documentation, we will discuss how to create a simple RBAC backend module that will be used to apply roles and permissions. + +## Getting started + +Our first step is to create an RBAC backend module using the following command: + +```bash +yarn new +``` + +This will start an interactive setup to create a new plugin. The following are what will need to be selected to create the new plugin module: + +```bash +? What do you want to create? backend-module - A new backend module +? Enter the ID of the plugin [required] permission +? Enter the ID of the module [required] test +? Enter an owner to add to CODEOWNERS [optional] +``` + +This will then create a simple backend plugin module that is ready to updated based on your needs. + +## Creating the Test Provider + +Add the dependencies `@backstage-community/plugin-rbac-node` and `@backstage/config` to your newly created backend module using `yarn --cwd plugins/rbac-backend-module-test add @backstage-community/plugin-rbac-node @backstage/config`. + +Add the test provider to the newly created plugin module `/plugins/rbac-backend-module-test/TestProvider.ts` and populate it with the following: + +```ts +import { LoggerService } from '@backstage/backend-plugin-api'; + +import { + RBACProvider, + RBACProviderConnection, +} from '@backstage-community/plugin-rbac-node'; + +export class TestProvider implements RBACProvider { + private readonly logger: LoggerService; + private connection?: RBACProviderConnection; + + private constructor(logger: LoggerService) { + this.logger = logger.child({ + target: this.getProviderName(), + }); + } + + // The name of the provider, used to distinguish between multiple providers + getProviderName(): string { + return `testProvider`; + } + + // Used to connect the RBACProvider to the RBAC backend plugin + async connect(connection: RBACProviderConnection): Promise {} + + // Used to manually refresh the RBACProvider using an endpoint available in the RBAC backend plugin + async refresh(): Promise {} +} +``` + +Now, we will include a `run` method that will add a new role and permissions (one simple and one conditional) to the RBAC backend plugin through the use of the extension points. + +```ts +export class TestProvider implements RBACProvider { + // Addition code above + private async run(): Promise { + if (!this.connection) { + throw new Error('Not initialized'); + } + + const roles: string[][] = [ + ['user:default/tony', 'role:default/test-provider-role'], + ]; + const permissions: string[][] = [ + ['role:default/test-provider-role', 'catalog-entity', 'read', 'allow'], + ]; + + const conditionalPermissions: RoleConditionalPolicyDecision[] = + [ + { + id: 0, // The id is ignored, so it can be any number + result: 'CONDITIONAL', + roleEntityRef: 'role:default/test-provider-role', + pluginId: 'catalog', + resourceType: 'catalog-entity', + permissionMapping: [ + { name: 'catalog.entity.delete', action: 'delete' }, + ], + conditions: { + rule: 'HAS_LABEL', + resourceType: 'catalog-entity', + params: { + label: 'role', + value: 'deletable', + }, + }, + }, + ]; + + await this.connection.applyRoles(roles); + await this.connection.applyPermissions(permissions); + await this.connection.applyConditionalPermissions(conditionalPermissions); + } +} +``` + +Next, we will provider a scheduler option so that we can ensure our provider will be periodically synced. But first we want to include an option to read this schedule from the `app-config`. + +```ts +import { + LoggerService, + readSchedulerServiceTaskScheduleDefinitionFromConfig, + SchedulerServiceTaskScheduleDefinition, +} from '@backstage/backend-plugin-api'; +import { Config } from '@backstage/config'; + +// Additional imports above + +export class TestProvider implements RBACProvider { + private readonly logger: LoggerService; + private connection?: RBACProviderConnection; + + private constructor( + logger: LoggerService, + schedulerServiceTaskRunner: SchedulerServiceTaskRunner, + ) { + this.logger = logger.child({ + target: this.getProviderName(), + }); + } + + static fromConfig( + config: Config, + options: { + logger: LoggerService; + schedule?: SchedulerServiceTaskRunner; + scheduler?: SchedulerService; + }, + ): TestProvider { + const providerSchedule = readProviderConfig(config); + let schedulerServiceTaskRunner; + + if (options.scheduler && providerSchedule) { + schedulerServiceTaskRunner = + options.scheduler.createScheduledTaskRunner(providerSchedule); + } else if (options.schedule) { + schedulerServiceTaskRunner = options.schedule; + } else { + throw new Error('Neither schedule nor scheduler is provided.'); + } + + return new TestProvider(options.logger, schedulerServiceTaskRunner); + } + // Additional code below +} + +function readProviderConfig( + config: Config, +): SchedulerServiceTaskScheduleDefinition | undefined { + const rbacConfig = config.getOptionalConfig('permission.rbac.providers.test'); + if (!rbacConfig) { + return undefined; + } + + const schedule = rbacConfig.has('schedule') + ? readSchedulerServiceTaskScheduleDefinitionFromConfig( + rbacConfig.getConfig('schedule'), + ) + : undefined; + + return schedule; +} +``` + +We can then began to create our schedule function that will ensure we sync based on the schedule that is provider. + +```ts +// Additional imports above +export class TestProvider implements RBACProvider { + private readonly logger: LoggerService; + private connection?: RBACProviderConnection; + private readonly scheduleFn: () => Promise; + + private constructor( + logger: LoggerService, + schedulerServiceTaskRunner: SchedulerServiceTaskRunner, + ) { + this.logger = logger.child({ + target: this.getProviderName(), + }); + + this.scheduleFn = this.createScheduleFN(schedulerServiceTaskRunner); + } + + // Additional code + + // Creates our schedule function that will periodically call our run method + private createScheduleFN( + schedulerServiceTaskRunner: SchedulerServiceTaskRunner, + ): () => Promise { + return async () => { + const taskId = `${this.getProviderName()}:run`; + return schedulerServiceTaskRunner.run({ + id: taskId, + fn: async () => { + try { + await this.run(); + } catch (error: any) { + this.logger.error(`Error occurred, here is the error ${error}`); + } + }, + }); + }; + } +} +``` + +After setting up the scheduler, we can supply the option to manually refresh the module. + +```ts +// Additional imports above + +export class TestProvider implements RBACProvider { + // Addition code + + // Used to manually refresh the RBACProvider using an endpoint available in the RBAC backend plugin + async refresh(): Promise { + try { + await this.run(); + } catch (error: any) { + this.logger.error(`Error occurred, here is the error ${error}`); + } + } +} +``` + +Finally, we just need to supply the logic for the connection. + +```ts +// Additional imports above + +export class TestProvider implements RBACProvider { + // Addition code + + // Used to connect the RBACProvider to the RBAC backend plugin + async connect(connection: RBACProviderConnection): Promise { + this.connection = connection; + this.scheduleFn(); + } +} +``` + +## Updating the Module + +Our final step is to update the dependencies that will be supplied and add the provider in `module.ts`. + +```ts +import { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; + +import { rbacProviderExtensionPoint } from '@backstage-community/plugin-rbac-node'; + +import { TestProvider } from './TestProvider'; + +/** + * The test backend module for the rbac plugin. + * + * @alpha + */ +export const rbacModuleTest = createBackendModule({ + pluginId: 'permission', + moduleId: 'test', + register(reg) { + reg.registerInit({ + deps: { + logger: coreServices.logger, + rbac: rbacProviderExtensionPoint, + scheduler: coreServices.scheduler, + config: coreServices.rootConfig, + }, + async init({ logger, rbac, scheduler, config }) { + rbac.addRBACProvider( + TestProvider.fromConfig(config, { + logger, + scheduler: scheduler, + schedule: scheduler.createScheduledTaskRunner({ + frequency: { minutes: 30 }, + timeout: { minutes: 3 }, + }), + }), + ); + }, + }); + }, +}); +``` + +## Testing your newly created backend module + +Install the provider and add it to `packages/backend/src/index.ts`. + +```bash +yarn --cwd packages/app add @backstage-community/plugin-rbac-backend-module-test +``` + +```ts +backend.add( + import('@backstage-community/plugin-rbac-backend-module-test/alpha'), +); +``` + +Configure the test provider in the `app-config`. + +```yaml +permission: + rbac: + providers: + test: + schedule: + frequency: { minutes: 1 } + timeout: { minutes: 1 } + initialDelay: { seconds: 1 } +``` + +This will set the provider schedule to apply the roles and permissions every minute. + +Finally, to test the manual refresh capability update the config to adjust the frequency of the schedule. + +```yaml +permission: + rbac: + providers: + test: + schedule: + frequency: { minutes: 10 } + timeout: { minutes: 1 } + initialDelay: { seconds: 1 } +``` + +10 Minutes should give you enough time to manually trigger refresh. + +Call the refresh endpoint. + +```bash +curl -X POST "http://localhost:7007/api/permission/refresh/testProvider" -H "Authorization: Bearer $token" -v +``` + +Should return a 200. diff --git a/plugins/rbac-backend/knexfile.js b/plugins/rbac-backend/knexfile.js new file mode 100644 index 0000000000..c0245f5723 --- /dev/null +++ b/plugins/rbac-backend/knexfile.js @@ -0,0 +1,28 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// To create new migration file use: "yarn knex migrate:make migrations", +// open generated new migration file and edit it to complete code. +// To run new migration use: "yarn knex migrate:make some_file_name" + +module.exports = { + client: 'better-sqlite3', + connection: ':memory:', + useNullAsDefault: true, + migrations: { + directory: './migrations', + }, +}; diff --git a/plugins/rbac-backend/knip-report.md b/plugins/rbac-backend/knip-report.md new file mode 100644 index 0000000000..2661c35327 --- /dev/null +++ b/plugins/rbac-backend/knip-report.md @@ -0,0 +1,2 @@ +# Knip report + diff --git a/plugins/rbac-backend/migrations/20231015161232_migrations.js b/plugins/rbac-backend/migrations/20231015161232_migrations.js new file mode 100644 index 0000000000..e084a54824 --- /dev/null +++ b/plugins/rbac-backend/migrations/20231015161232_migrations.js @@ -0,0 +1,41 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + await knex.schema.createTable('policy-conditions', table => { + table.increments('id').primary(); + table.string('result'); + table.string('pluginId'); + table.string('resourceType'); + // Conditions is potentially long json. + // In the future maybe we can use `json` or `jsonb` type instead of `text`: + // table.json('conditions') or table.jsonb('conditions'). + // But let's start with text type. + // Data type "text" can be unlimited by size for Postgres. + // Also postgres has a lot of build in features for this data type. + table.text('conditionsJson'); + }); +}; + +/** + * down - reverts(undo) migration. + * + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('policy-conditions'); +}; diff --git a/plugins/rbac-backend/migrations/20231212224526_migrations.js b/plugins/rbac-backend/migrations/20231212224526_migrations.js new file mode 100644 index 0000000000..18daaa19d3 --- /dev/null +++ b/plugins/rbac-backend/migrations/20231212224526_migrations.js @@ -0,0 +1,84 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + const casbinDoesExist = await knex.schema.hasTable('casbin_rule'); + const policyMetadataDoesExist = await knex.schema.hasTable('policy-metadata'); + let policies = []; + let groupPolicies = []; + + if (casbinDoesExist) { + policies = await knex + .select('*') + .from('casbin_rule') + .where('ptype', 'p') + .then(listPolicies => { + const allPolicies = []; + for (const policy of listPolicies) { + const { v0, v1, v2, v3 } = policy; + allPolicies.push(`[${v0}, ${v1}, ${v2}, ${v3}]`); + } + return allPolicies; + }); + groupPolicies = await knex + .select('*') + .from('casbin_rule') + .where('ptype', 'g') + .then(listGroupPolicies => { + const allGroupPolicies = []; + for (const groupPolicy of listGroupPolicies) { + const { v0, v1 } = groupPolicy; + allGroupPolicies.push(`[${v0}, ${v1}]`); + } + return allGroupPolicies; + }); + } + + if (!policyMetadataDoesExist) { + await knex.schema + .createTable('policy-metadata', table => { + table.increments('id').primary(); + table.string('policy').primary(); + table.string('source'); + }) + .then(async () => { + const metadata = []; + for (const policy of policies) { + metadata.push({ source: 'legacy', policy: policy }); + } + if (metadata.length > 0) { + await knex.table('policy-metadata').insert(metadata); + } + }) + .then(async () => { + const metadata = []; + for (const groupPolicy of groupPolicies) { + metadata.push({ source: 'legacy', policy: groupPolicy }); + } + if (metadata.length > 0) { + await knex.table('policy-metadata').insert(metadata); + } + }); + } +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('policy-metadata'); +}; diff --git a/plugins/rbac-backend/migrations/20231221113214_migrations.js b/plugins/rbac-backend/migrations/20231221113214_migrations.js new file mode 100644 index 0000000000..6fbbc621da --- /dev/null +++ b/plugins/rbac-backend/migrations/20231221113214_migrations.js @@ -0,0 +1,60 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + const casbinDoesExist = await knex.schema.hasTable('casbin_rule'); + const roleMetadataDoesExist = await knex.schema.hasTable('role-metadata'); + const groupPolicies = new Set(); + + if (casbinDoesExist) { + await knex + .select('*') + .from('casbin_rule') + .where('ptype', 'g') + .then(listGroupPolicies => { + for (const groupPolicy of listGroupPolicies) { + const { v1 } = groupPolicy; + groupPolicies.add(v1); + } + }); + } + + if (!roleMetadataDoesExist) { + await knex.schema + .createTable('role-metadata', table => { + table.increments('id').primary(); + table.string('roleEntityRef').primary(); + table.string('source'); + }) + .then(async () => { + const metadata = []; + for (const groupPolicy of groupPolicies) { + metadata.push({ source: 'legacy', roleEntityRef: groupPolicy }); + } + if (metadata.length > 0) { + await knex.table('role-metadata').insert(metadata); + } + }); + } +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('role-metadata'); +}; diff --git a/plugins/rbac-backend/migrations/20240201144429_migrations.js b/plugins/rbac-backend/migrations/20240201144429_migrations.js new file mode 100644 index 0000000000..143a4a6364 --- /dev/null +++ b/plugins/rbac-backend/migrations/20240201144429_migrations.js @@ -0,0 +1,37 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + const isRoleMetaDataExist = await knex.schema.hasTable('role-metadata'); + if (isRoleMetaDataExist) { + await knex.schema.alterTable('role-metadata', table => { + table.string('description'); + }); + } +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + const isRoleMetaDataExist = await knex.schema.hasTable('role-metadata'); + if (isRoleMetaDataExist) { + await knex.schema.alterTable('role-metadata', table => { + table.dropColumn('description'); + }); + } +}; diff --git a/plugins/rbac-backend/migrations/20240215154456_migrations.js b/plugins/rbac-backend/migrations/20240215154456_migrations.js new file mode 100644 index 0000000000..9df4f5e3d2 --- /dev/null +++ b/plugins/rbac-backend/migrations/20240215154456_migrations.js @@ -0,0 +1,143 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + const casbinDoesExist = await knex.schema.hasTable('casbin_rule'); + const policyMetadataExist = await knex.schema.hasTable('policy-metadata'); + const roleMetadataExist = await knex.schema.hasTable('role-metadata'); + + if (casbinDoesExist && policyMetadataExist) { + const policyMetadataColumns = await knex('policy-metadata').select( + 'id', + 'policy', + ); + + const policiesToCheck = policyMetadataColumns.map(metadataColumn => { + const policy = metadataColumn.policy + .replace(/\[/g, '') + .replace(/\]/g, '') + .split(',') + .map(str => str.trim()); + return { policy, id: metadataColumn.id }; + }); + + const existingPolicies = await knex('casbin_rule') + .whereIn( + 'v0', + policiesToCheck.map(policyToCheck => policyToCheck.policy[0]), + ) + .whereIn( + 'v1', + policiesToCheck.map(policyToCheck => policyToCheck.policy[1]), + ) + .andWhere(query => { + query + .where(innerQuery => { + innerQuery.whereNotNull('v2').whereIn( + 'v2', + policiesToCheck + .filter(policy => policy.policy.length === 4) + .map(policy => policy.policy[2]), + ); + }) + .orWhereNull('v2'); + }) + .andWhere(query => { + query + .where(innerQuery => { + innerQuery.whereNotNull('v3').whereIn( + 'v3', + policiesToCheck + .filter(policy => policy.policy.length === 4) + .map(policy => policy.policy[3]), + ); + }) + .orWhereNull('v3'); + }) + .select('v0', 'v1', 'v2', 'v3'); + + const existingPoliciesSet = new Set( + existingPolicies.map(policy => + policy.v2 + ? `${policy.v0},${policy.v1},${policy.v2},${policy.v3}` + : `${policy.v0},${policy.v1}`, + ), + ); + + const policiesToDelete = policiesToCheck.filter( + policyToCheck => !existingPoliciesSet.has(policyToCheck.policy.join(',')), + ); + + if (policiesToDelete.length > 0) { + await knex('policy-metadata') + .whereIn( + 'id', + policiesToDelete.map(policyToDel => policyToDel.id), + ) + .del(); + console.log( + `Deleted inconsistent policy metadata ${JSON.stringify( + policiesToDelete, + )} from 'policy-metadata' table.`, + ); + } + } + + if (casbinDoesExist && roleMetadataExist) { + const roleMetadataColumns = await knex('role-metadata').select( + 'id', + 'roleEntityRef', + ); + const roleMetadata = roleMetadataColumns.map(rm => { + return { roleEntityRef: rm.roleEntityRef, id: rm.id }; + }); + const existingPoliciesForRoles = await knex('casbin_rule') + .orWhereIn( + 'v1', + roleMetadata.map(rm => rm.roleEntityRef), + ) + .select('v1'); + + const existingRoles = new Set( + existingPoliciesForRoles.map(policy => policy.v1), + ); + const rolesMetadataToDelete = roleMetadata.filter( + rm => !existingRoles.has(rm.roleEntityRef), + ); + + if (rolesMetadataToDelete.length > 0) { + await knex('role-metadata') + .whereIn( + 'id', + rolesMetadataToDelete.map(rm => rm.id), + ) + .del(); + console.log( + `Deleted inconsistent role metadata ${JSON.stringify( + rolesMetadataToDelete, + )} from 'role-metadata' table.`, + ); + } + } +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function down(_knex) { + // do nothing +}; diff --git a/plugins/rbac-backend/migrations/20240308134410_migrations.js b/plugins/rbac-backend/migrations/20240308134410_migrations.js new file mode 100644 index 0000000000..f5b5ca08ab --- /dev/null +++ b/plugins/rbac-backend/migrations/20240308134410_migrations.js @@ -0,0 +1,31 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + const policyConditionsExist = await knex.schema.hasTable('policy-conditions'); + + if (policyConditionsExist) { + // We drop policy condition table, because we decided to rework this feature + // and bound policy condition to the role + await knex.schema.dropTable('policy-conditions'); + } +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(_knex) {}; diff --git a/plugins/rbac-backend/migrations/20240308134941_migrations.js b/plugins/rbac-backend/migrations/20240308134941_migrations.js new file mode 100644 index 0000000000..0516e48691 --- /dev/null +++ b/plugins/rbac-backend/migrations/20240308134941_migrations.js @@ -0,0 +1,43 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + await knex.schema.createTable('role-condition-policies', table => { + table.increments('id').primary(); + table.string('roleEntityRef'); + table.string('result'); + table.string('pluginId'); + table.string('resourceType'); + table.string('permissions'); + // Conditions is potentially long json. + // In the future maybe we can use `json` or `jsonb` type instead of `text`: + // table.json('conditions') or table.jsonb('conditions'). + // But let's start with text type. + // Data type "text" can be unlimited by size for Postgres. + // Also postgres has a lot of build in features for this data type. + table.text('conditionsJson'); + }); +}; + +/** + * down - reverts(undo) migration. + * + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('policy-conditions'); +}; diff --git a/plugins/rbac-backend/migrations/20240404111242_migrations.js b/plugins/rbac-backend/migrations/20240404111242_migrations.js new file mode 100644 index 0000000000..5fda96eccd --- /dev/null +++ b/plugins/rbac-backend/migrations/20240404111242_migrations.js @@ -0,0 +1,53 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + const isRoleMetaDataExist = await knex.schema.hasTable('role-metadata'); + if (isRoleMetaDataExist) { + await knex.schema.alterTable('role-metadata', table => { + table.string('author'); + table.string('modifiedBy'); + table.dateTime('createdAt'); + table.dateTime('lastModified'); + }); + + await knex('role-metadata') + .update({ + description: + 'The default permission policy for the admin role allows for the creation, deletion, updating, and reading of roles and permission policies.', + author: 'application configuration', + modifiedBy: 'application configuration', + lastModified: new Date().toUTCString(), + }) + .where('roleEntityRef', 'role:default/rbac_admin'); + } +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + const isRoleMetaDataExist = await knex.schema.hasTable('role-metadata'); + if (isRoleMetaDataExist) { + await knex.schema.alterTable('role-metadata', table => { + table.dropColumn('author'); + table.dropColumn('modifiedBy'); + table.dropColumn('createdAt'); + table.dropColumn('lastModified'); + }); + } +}; diff --git a/plugins/rbac-backend/migrations/20240611092136_migrations.js b/plugins/rbac-backend/migrations/20240611092136_migrations.js new file mode 100644 index 0000000000..65ad48666c --- /dev/null +++ b/plugins/rbac-backend/migrations/20240611092136_migrations.js @@ -0,0 +1,29 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + const policyMetadataExist = await knex.schema.hasTable('policy-metadata'); + + if (policyMetadataExist) { + await knex.schema.dropTable('policy-metadata'); + } +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function down(_knex) {}; diff --git a/plugins/rbac-backend/migrations/20241108093910_migrations.js b/plugins/rbac-backend/migrations/20241108093910_migrations.js new file mode 100644 index 0000000000..a84da53e25 --- /dev/null +++ b/plugins/rbac-backend/migrations/20241108093910_migrations.js @@ -0,0 +1,35 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + const casbinExists = await knex.schema.hasTable('casbin_rule'); + if (casbinExists) { + await knex('casbin_rule') + .whereNotNull('v0') + .where(function groups() { + this.where('v0', 'like', 'user:%').orWhere('v0', 'like', 'group:%'); + }) + .update({ + v0: knex.raw('LOWER(??)', ['v0']), + }); + } +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(_knex) {}; diff --git a/plugins/rbac-backend/migrations/20250305155143_migration.js b/plugins/rbac-backend/migrations/20250305155143_migration.js new file mode 100644 index 0000000000..4cad332234 --- /dev/null +++ b/plugins/rbac-backend/migrations/20250305155143_migration.js @@ -0,0 +1,73 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + const roleMetaDataExist = await knex.schema.hasTable('role-metadata'); + const casbinExists = await knex.schema.hasTable('casbin_rule'); + if (roleMetaDataExist) { + // Add the owner field to the role-metadata field + await knex.schema.alterTable('role-metadata', table => { + table.string('owner'); + }); + } + + if (casbinExists && roleMetaDataExist) { + // Get the policies for resource type policy-entity and action create + const policyEntityCreateRoles = await knex + .from('casbin_rule') + .where('v1', 'policy-entity') + .where('v2', 'create') + .pluck('v0'); + + // Ensure that we are only updating the rest api and configuration policies only + const rolesFromConfigAndRest = await knex + .from('role-metadata') + .whereNot('source', 'csv-file') + .whereIn('roleEntityRef', policyEntityCreateRoles) + .pluck('roleEntityRef'); + + // Update the polices from the config and rest from resource type policy-entity and action create + // to policy.entity.create and action create + await knex + .from('casbin_rule') + .whereIn('v0', rolesFromConfigAndRest) + .where('v2', 'create') + .update({ v1: 'policy.entity.create' }); + + const rolesFromCSV = await knex + .from('role-metadata') + .where('source', 'csv-file') + .whereIn('roleEntityRef', policyEntityCreateRoles) + .pluck('roleEntityRef'); + + console.log( + `The following roles: ${rolesFromCSV} have the permission policy 'policy-entity, create' and will need to be updated within the CSV file to 'policy.entity.create, create'`, + ); + } +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + const isRoleMetaDataExist = await knex.schema.hasTable('role-metadata'); + if (isRoleMetaDataExist) { + await knex.schema.alterTable('role-metadata', table => { + table.dropColumn('owner'); + }); + } +}; diff --git a/plugins/rbac-backend/migrations/20250509110032_migrations.js b/plugins/rbac-backend/migrations/20250509110032_migrations.js new file mode 100644 index 0000000000..d7f42f1a8c --- /dev/null +++ b/plugins/rbac-backend/migrations/20250509110032_migrations.js @@ -0,0 +1,29 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + await knex.schema.createTable('extra_permission_enabled_plugins', table => { + table.string('pluginId').primary(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('extra_permission_enabled_plugins'); +}; diff --git a/plugins/rbac-backend/migrations/20260216100000_add_is_default_to_role_metadata.js b/plugins/rbac-backend/migrations/20260216100000_add_is_default_to_role_metadata.js new file mode 100644 index 0000000000..51ed564a2a --- /dev/null +++ b/plugins/rbac-backend/migrations/20260216100000_add_is_default_to_role_metadata.js @@ -0,0 +1,43 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + const roleMetadataExist = await knex.schema.hasTable('role-metadata'); + if (roleMetadataExist) { + const hasColumn = await knex.schema.hasColumn('role-metadata', 'isDefault'); + if (!hasColumn) { + await knex.schema.alterTable('role-metadata', table => { + table.boolean('isDefault').defaultTo(false); + }); + } + } +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + const roleMetadataExist = await knex.schema.hasTable('role-metadata'); + if (roleMetadataExist) { + const hasColumn = await knex.schema.hasColumn('role-metadata', 'isDefault'); + if (hasColumn) { + await knex.schema.alterTable('role-metadata', table => { + table.dropColumn('isDefault'); + }); + } + } +}; diff --git a/plugins/rbac-backend/openapi.yaml b/plugins/rbac-backend/openapi.yaml new file mode 100644 index 0000000000..459c808ff9 --- /dev/null +++ b/plugins/rbac-backend/openapi.yaml @@ -0,0 +1,760 @@ +openapi: 3.0.0 +info: + title: RBAC Backend API + description: >- + Harnesses the power of the Backstage permission framework to empower you + with robust role-based access control capabilities within your Backstage + environment. + version: latest +servers: + - url: 'http://localhost:7007' +components: + schemas: + RoleResponse: + type: array + items: + type: object + properties: + memberReferences: + type: array + description: Users / groups to be added to the role :/. + items: + type: string + name: + type: string + description: The name of the role. + metadata: + type: object + description: Metadata about the role. + properties: + author: + type: string + description: The author of the role. + createdAt: + type: string + description: The date and time the role was created. + lastModified: + type: string + description: The date and time the role was last modified. + modifiedBy: + type: string + description: The user who last modified the role. + source: + type: string + description: The source from which the role was defined. + description: + type: string + description: A description of the role.``` + Role: + type: object + properties: + memberReferences: + type: array + description: Users / groups to be added to the role :/. + items: + type: string + name: + type: string + description: The name of the role. + metadata: + type: object + description: Metadata about the role. + properties: + description: + type: string + description: A description of the role. + Condition: + type: object + oneOf: + - properties: + anyOf: + type: array + items: + $ref: '#/components/schemas/Condition' + required: [anyOf] + - properties: + allOf: + type: array + items: + $ref: '#/components/schemas/Condition' + required: [allOf] + - properties: + not: + $ref: '#/components/schemas/Condition' + required: [not] + - properties: + rule: + type: string + resourceType: + type: string + params: + type: object + required: [rule, resourceType, params] + PropertyObject: + type: object + properties: + type: + type: string + description: + type: string + required: [type, description] + PropertyArray: + type: object + properties: + type: + type: string + description: + type: string + items: + type: object + properties: + type: + type: string + required: [type, description, items] + + PermissionPolicy: + type: object + properties: + entityReference: + type: string + description: Entity :/. + permission: + type: string + description: Permission from a specific plugin, Resource type or name + policy: + type: string + description: 'Policy action for the permission: create, read, update, delete, use' + effect: + type: string + description: allow or deny + + PermissionResponse: + type: object + properties: + entityReference: + type: string + description: Entity :/. + permission: + type: string + description: Permission from a specific plugin, Resource type or name + policy: + type: string + description: 'Policy action for the permission: create, read, update, delete, use' + effect: + type: string + description: allow or deny + metadata: + type: object + description: Metadata about the role. + properties: + source: + type: string + description: The source from which the permission policy was defined. + PluginIds: + type: object + properties: + ids: + type: array + description: List of plugin ids, which support Backstage permission framework. + items: + type: string + + parameters: + nameParam: + name: name + in: path + description: Name of the role. + required: true + schema: + type: string + namespaceParam: + name: namespace + in: path + description: Namespace of the role. + required: true + schema: + type: string + kindParam: + name: kind + in: path + description: role + required: true + schema: + type: string + memberReferencesParam: + name: memberReferences + in: query + description: users / groups to be deleted from the role :/ + required: false + schema: + type: array + description: Users / groups to be added to the role :/. + items: + type: string +paths: + /api/permission/roles: + get: + description: Lists all roles + responses: + '200': + description: Request was successful. + content: + application/json: + schema: + type: object + '$ref': '#/components/schemas/RoleResponse' + '403': + description: Refusal to authorize + post: + description: Creates a new role. + requestBody: + required: true + content: + application/json: + schema: + '$ref': '#/components/schemas/Role' + responses: + '201': + description: New resource was successfully created. + '400': + description: Invalid role definition. + '403': + description: Refusal to authorize + '409': + description: Conflict with current state and target resource. + /api/permission/roles/{kind}/{namespace}/{name}: + get: + description: List the single role and the members associated with that role. + parameters: + - $ref: '#/components/parameters/nameParam' + - $ref: '#/components/parameters/namespaceParam' + - $ref: '#/components/parameters/kindParam' + responses: + '200': + description: Request was successful. + content: + application/json: + schema: + '$ref': '#/components/schemas/RoleResponse' + '403': + description: Refusal to authorize + '404': + description: Could not find resource + put: + description: Updates a specified role. + parameters: + - $ref: '#/components/parameters/nameParam' + - $ref: '#/components/parameters/namespaceParam' + - $ref: '#/components/parameters/kindParam' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + oldRole: + '$ref': '#/components/schemas/Role' + newRole: + '$ref': '#/components/schemas/Role' + responses: + '200': + description: Request was successful. + '400': + description: Input Error + '403': + description: Refusal to authorize + '404': + description: Could not find resource + '409': + description: Conflict with current state and target resource. + delete: + description: >- + Deletes a single role and all users associated with that role if no + memberReferences is specified. Otherwise deletes the single user/group + specified in the memberReferences parameter. + parameters: + - $ref: '#/components/parameters/nameParam' + - $ref: '#/components/parameters/namespaceParam' + - $ref: '#/components/parameters/kindParam' + - $ref: '#/components/parameters/memberReferencesParam' + responses: + '204': + description: ok + '403': + description: Refusal to authorize + '404': + description: Could not find resource. + /api/permission/policies: + get: + description: Lists all permission polices. + parameters: + - name: entityReference + in: query + description: Entity :/. + required: false + schema: + type: string + - name: permission + in: query + description: Permission from a specific plugin, Resource type or name + required: false + schema: + type: string + - name: policy + in: query + description: 'Policy action for the permission: create, read, update, delete, use' + required: false + schema: + type: string + - name: effect + in: query + description: allow or deny + required: false + schema: + type: string + responses: + '200': + description: Request was successful. + content: + application/json: + schema: + type: array + items: + '$ref': '#/components/schemas/PermissionResponse' + '403': + description: Refusal to authorize + post: + description: Creates one or more permission policies for a specified entity. + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + '$ref': '#/components/schemas/PermissionPolicy' + responses: + '201': + description: New resource was successfully created. + '400': + description: Input Error + '403': + description: Refusal to authorize + /api/permission/policies/{kind}/{namespace}/{name}: + get: + description: List permission policies related to the specified entity reference + parameters: + - $ref: '#/components/parameters/nameParam' + - $ref: '#/components/parameters/namespaceParam' + - $ref: '#/components/parameters/kindParam' + responses: + '200': + description: Request was successful. + content: + application/json: + schema: + type: array + items: + '$ref': '#/components/schemas/PermissionResponse' + '403': + description: Refusal to authorize + '404': + description: Could not find resource + put: + description: Updates one or more permission policies for a specified entity. + parameters: + - $ref: '#/components/parameters/nameParam' + - $ref: '#/components/parameters/namespaceParam' + - $ref: '#/components/parameters/kindParam' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + oldPolicy: + type: array + items: + type: object + properties: + permission: + type: string + description: >- + Permission from a specific plugin, Resource type or + name + policy: + type: string + description: >- + Policy action for the permission: create, read, + update, delete, use + effect: + type: string + description: allow or deny + newPolicy: + type: array + items: + type: object + properties: + permission: + type: string + description: >- + Permission from a specific plugin, Resource type or + name + policy: + type: string + description: >- + Policy action for the permission: create, read, + update, delete, use + effect: + type: string + description: allow or deny + responses: + '200': + description: Request was successful. + '400': + description: Input Error + '403': + description: Refusal to authorize + delete: + description: >- + Deletes a permission policy or a group of permission policies of a + specified entity. + parameters: + - $ref: '#/components/parameters/nameParam' + - $ref: '#/components/parameters/namespaceParam' + - $ref: '#/components/parameters/kindParam' + requestBody: + required: false + content: + application/json: + schema: + type: array + items: + '$ref': '#/components/schemas/PermissionPolicy' + responses: + '204': + description: ok + '400': + description: Input Error + '403': + description: Refusal to authorize + /api/permission/plugins/policies: + get: + description: >- + Lists all plugin permission policies from plugins installed in your + Backstage instance. + responses: + '200': + description: Request was successful + content: + application/json: + schema: + type: array + items: + type: object + properties: + pluginId: + type: string + policies: + type: array + items: + type: object + properties: + name: + type: string + description: Permission from a specific plugin. + resourceType: + type: string + description: Resource type. + policy: + type: string + description: >- + Policy action for the permission: create, read, + update, delete, use. + required: [name, policy] + '403': + description: Refusal to authorize + /api/permission/plugins/condition-rules: + get: + description: Provides conditional rule parameter schemas. + responses: + '200': + description: Request was successful + content: + application/json: + schema: + type: array + items: + type: object + properties: + pluginId: + type: string + rules: + type: array + items: + type: object + properties: + name: + type: string + description: + type: string + resourceType: + type: string + paramsSchema: + type: object + properties: + $schema: + type: string + additionalProperties: + type: boolean + required: + type: string + type: + type: string + oneOf: + - properties: + properties: + type: object + additionalProperties: + $ref: '#/components/schemas/PropertyArray' + - properties: + properties: + type: object + additionalProperties: + $ref: '#/components/schemas/PropertyObject' + '403': + description: Refusal to authorize + /api/permission/plugins/id: + get: + description: Returns plugin IDs that support the Backstage permission framework. + responses: + '200': + description: Request was successful + content: + application/json: + schema: + '$ref': '#/components/schemas/PluginIds' + '403': + description: Refusal to authorize + + post: + description: Add additional plugin IDs. + requestBody: + required: true + content: + application/json: + schema: + '$ref': '#/components/schemas/PluginIds' + responses: + '200': + description: Plugin IDs were successfully added. Returns updated list. + content: + application/json: + schema: + '$ref': '#/components/schemas/PluginIds' + '409': + description: Conflict with current state and target resource. + '403': + description: Refusal to authorize + + delete: + description: Delete some additional plugin IDs. + requestBody: + required: true + content: + application/json: + schema: + '$ref': '#/components/schemas/PluginIds' + responses: + '200': + description: Plugin IDs were successfully removed. Returns updated list. + content: + application/json: + schema: + '$ref': '#/components/schemas/PluginIds' + '404': + description: Could not find resource + '403': + description: Refusal to authorize + /api/permission/roles/conditions: + get: + description: Lists all conditions + responses: + '200': + description: Request was successful + content: + application/json: + schema: + type: array + items: + type: object + required: + [ + result, + roleEntityRef, + pluginId, + resourceType, + permissionMapping, + conditions, + ] + properties: + id: + type: integer + result: + type: string + roleEntityRef: + type: string + pluginId: + type: string + resourceType: + type: string + permissionMapping: + type: array + items: + type: string + conditions: + $ref: '#/components/schemas/Condition' + '403': + description: Refusal to authorize + post: + description: Creates a new condition. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + result: + type: string + roleEntityRef: + type: string + pluginId: + type: string + resourceType: + type: string + permissionMapping: + type: array + items: + type: string + conditions: + $ref: '#/components/schemas/Condition' + responses: + '201': + description: New resource was successfully created. + content: + application/json: + schema: + type: object + properties: + id: + type: integer + '403': + description: Refusal to authorize + /api/permission/roles/conditions/{id}: + get: + description: Returns condition by id. + responses: + '200': + description: Request was successful + content: + application/json: + schema: + type: object + properties: + id: + type: integer + result: + type: string + roleEntityRef: + type: string + pluginId: + type: string + resourceType: + type: string + permissionMapping: + type: array + items: + type: string + conditions: + $ref: '#/components/schemas/Condition' + '400': + description: Input Error + '403': + description: Refusal to authorize + '404': + description: Could not find resource + put: + description: Update conditions by id. + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + result: + type: string + roleEntityRef: + type: string + pluginId: + type: string + resourceType: + type: string + permissionMapping: + type: array + items: + type: string + conditions: + $ref: '#/components/schemas/Condition' + responses: + '200': + description: Request was successful + '400': + description: Id is not a valid number. + '403': + description: Refusal to authorize + '404': + description: Id is not a valid number. + delete: + description: Deletes condition by id. + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '204': + description: ok + '400': + description: Id is not a valid number. + '403': + description: Refusal to authorize + '404': + description: Could not find resource. + /api/permission/refresh/{id}: + post: + description: >- + Refreshes RBAC permission policies by provider id. + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Request was successful + '403': + description: Refusal to authorize + '404': + description: Could not find resource. diff --git a/plugins/rbac-backend/package.json b/plugins/rbac-backend/package.json new file mode 100644 index 0000000000..a691444e32 --- /dev/null +++ b/plugins/rbac-backend/package.json @@ -0,0 +1,102 @@ +{ + "name": "@internal/plugin-rbac-backend", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin", + "pluginId": "rbac", + "pluginPackages": [ + "@internal/plugin-rbac-backend" + ] + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "tsc": "tsc", + "prettier:check": "prettier --ignore-unknown --check .", + "prettier:fix": "prettier --ignore-unknown --write .", + "lint:check": "backstage-cli package lint", + "lint:fix": "backstage-cli package lint --fix", + "test": "backstage-cli package test --passWithNoTests --coverage", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@azure/identity": "^4.0.0", + "@backstage-community/plugin-rbac-common": "1.26.1", + "@backstage-community/plugin-rbac-node": "1.20.1", + "@backstage/backend-defaults": "0.16.0", + "@backstage/backend-plugin-api": "1.8.0", + "@backstage/catalog-client": "1.14.0", + "@backstage/catalog-model": "1.7.7", + "@backstage/config": "1.3.6", + "@backstage/errors": "^1.2.7", + "@backstage/plugin-permission-common": "0.9.7", + "@backstage/plugin-permission-node": "0.10.11", + "@dagrejs/graphlib": "^4.0.0", + "casbin": "5.27.1", + "chokidar": "^3.6.0", + "csv-parse": "^6.0.0", + "express": "^4.18.2", + "express-promise-router": "^4.1.0", + "js-yaml": "^4.1.0", + "knex": "^3.0.0", + "lodash": "^4.17.21", + "typeorm-adapter": "^1.6.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@backstage/backend-test-utils": "1.11.1", + "@backstage/cli": "0.36.0", + "@backstage/core-plugin-api": "1.12.4", + "@backstage/plugin-catalog-node": "2.1.0", + "@backstage/types": "^1.2.2", + "@types/express": "4.17.25", + "@types/js-yaml": "^4.0.9", + "@types/lodash": "^4.14.151", + "@types/node": "22.19.17", + "@types/supertest": "7.2.0", + "knex-mock-client": "3.0.2", + "qs": "6.15.1", + "supertest": "7.2.2" + }, + "files": [ + "dist", + "config.d.ts", + "migrations" + ], + "configSchema": "config.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/backstage/community-plugins", + "directory": "workspaces/rbac/plugins/rbac-backend" + }, + "keywords": [ + "backstage", + "plugin" + ], + "bugs": "https://github.com/backstage/community-plugins/issues", + "maintainers": [ + "@PatAKnight" + ], + "author": "Red Hat", + "prettier": "@backstage/cli/config/prettier", + "lint-staged": { + "*.{js,jsx,ts,tsx,mjs,cjs}": [ + "eslint --fix", + "prettier --write" + ], + "*.{json,md}": [ + "prettier --write" + ] + } +} diff --git a/plugins/rbac-backend/report.api.md b/plugins/rbac-backend/report.api.md new file mode 100644 index 0000000000..5003438513 --- /dev/null +++ b/plugins/rbac-backend/report.api.md @@ -0,0 +1,76 @@ +## API Report File for "@backstage-community/plugin-rbac-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import type { AuditorService } from '@backstage/backend-plugin-api'; +import type { AuthService } from '@backstage/backend-plugin-api'; +import { BackendFeature } from '@backstage/backend-plugin-api'; +import type { Config } from '@backstage/config'; +import type { DiscoveryService } from '@backstage/backend-plugin-api'; +import express from 'express'; +import type { HttpAuthService } from '@backstage/backend-plugin-api'; +import type { LifecycleService } from '@backstage/backend-plugin-api'; +import type { LoggerService } from '@backstage/backend-plugin-api'; +import type { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import type { PermissionsRegistryService } from '@backstage/backend-plugin-api'; +import type { PermissionsService } from '@backstage/backend-plugin-api'; +import { PluginIdProvider } from '@backstage-community/plugin-rbac-node'; +import { PolicyExtensionPoint } from '@backstage/plugin-permission-node/alpha'; +import type { RBACProvider } from '@backstage-community/plugin-rbac-node'; +import type { Router } from 'express'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export type EnvOptions = { + config: Config; + logger: LoggerService; + discovery: DiscoveryService; + permissions: PermissionEvaluator; + auth: AuthService; + httpAuth: HttpAuthService; + auditor: AuditorService; + lifecycle: LifecycleService; + permissionsRegistry: PermissionsRegistryService; + policy: PolicyExtensionPoint; +}; + +export { PluginIdProvider }; + +// @public (undocumented) +export class PolicyBuilder { + // (undocumented) + static build( + env: EnvOptions, + pluginIdProvider?: PluginIdProvider, + rbacProviders?: Array, + ): Promise; +} + +// @public +const rbacPlugin: BackendFeature; +export default rbacPlugin; + +// @public (undocumented) +export type RBACRouterOptions = { + config: Config; + logger: LoggerService; + auth: AuthService; + httpAuth: HttpAuthService; + permissions: PermissionsService; + permissionsRegistry: PermissionsRegistryService; + auditor: AuditorService; +}; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + config: Config; + // (undocumented) + logger: LoggerService; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/rbac-backend/src/admin-permissions/admin-creation.test.ts b/plugins/rbac-backend/src/admin-permissions/admin-creation.test.ts new file mode 100644 index 0000000000..ad531f28dd --- /dev/null +++ b/plugins/rbac-backend/src/admin-permissions/admin-creation.test.ts @@ -0,0 +1,211 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices } from '@backstage/backend-test-utils'; +import { Config } from '@backstage/config'; + +import * as Knex from 'knex'; + +import type { RoleMetadata } from '@backstage-community/plugin-rbac-common'; + +import { + mockAuditorService, + csvPermFile, + mockClientKnex, + roleMetadataStorageMock, +} from '../../__fixtures__/mock-utils'; +import { + newAdapter, + newConfig, + newEnforcerDelegate, + newPermissionPolicy, +} from '../../__fixtures__/test-utils'; +import { RoleMetadataDao } from '../database/role-metadata'; +import { EnforcerDelegate } from '../service/enforcer-delegate'; +import { + ADMIN_ROLE_NAME, + setAdminPermissions, + useAdminsFromConfig, +} from './admin-creation'; + +const modifiedBy = 'user:default/some-admin'; +const adminRole = 'role:default/rbac_admin'; +const groupPolicy = [['user:default/test_admin', 'role:default/rbac_admin']]; +const permissions = [ + ['role:default/rbac_admin', 'policy-entity', 'read', 'allow'], + ['role:default/rbac_admin', 'policy.entity.create', 'create', 'allow'], + ['role:default/rbac_admin', 'policy-entity', 'delete', 'allow'], + ['role:default/rbac_admin', 'policy-entity', 'update', 'allow'], + ['role:default/rbac_admin', 'catalog-entity', 'read', 'allow'], +]; +const oldGroupPolicy = ['user:default/old_admin', 'role:default/rbac_admin']; + +describe('Admin Creation', () => { + describe('Admin role and permission creation to a user', () => { + let enfDelegate: EnforcerDelegate; + let config: Config; + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async ( + _roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + return { + roleEntityRef: 'role:default/catalog-writer', + source: 'legacy', + modifiedBy, + }; + }, + ); + + const admins = new Array<{ name: string }>(); + admins.push({ name: 'user:default/test_admin' }); + const superUser = new Array<{ name: string }>(); + superUser.push({ name: 'user:default/super_user' }); + + beforeEach(async () => { + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async ( + _roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + return { + roleEntityRef: 'role:default/catalog-writer', + source: 'legacy', + modifiedBy, + }; + }, + ); + + config = newConfig(csvPermFile, admins, superUser); + const adapter = await newAdapter(config); + + enfDelegate = await newEnforcerDelegate(adapter, config); + + await enfDelegate.addGroupingPolicy(oldGroupPolicy, { + source: 'configuration', + roleEntityRef: ADMIN_ROLE_NAME, + modifiedBy: `user:default/tom`, + }); + + const adminUsers = config.getOptionalConfigArray( + 'permission.rbac.admin.users', + ); + await useAdminsFromConfig( + adminUsers || [], + enfDelegate, + mockAuditorService, + roleMetadataStorageMock, + mockClientKnex, + ); + await setAdminPermissions(enfDelegate, mockAuditorService); + }); + + it('should assign an admin to the admin role and permissions', async () => { + const enfRole = await enfDelegate.getFilteredGroupingPolicy(1, adminRole); + const enfPermission = await enfDelegate.getFilteredPolicy(0, adminRole); + expect(enfRole).toEqual(groupPolicy); + expect(enfPermission).toEqual(permissions); + }); + + it(`should not assign an admin to the permissions if permissions are already assigned`, async () => { + await expect(async () => { + await setAdminPermissions(enfDelegate, mockAuditorService); + }).not.toThrow(); + }); + + it(`should assign an admin to the new permission`, async () => { + const newDefaultPermission = [ + adminRole, + 'something-new', + 'create', + 'allow', + ]; + await enfDelegate.addPolicy(newDefaultPermission); + await setAdminPermissions(enfDelegate, mockAuditorService); + const enfPermission = await enfDelegate.getFilteredPolicy( + 0, + ...newDefaultPermission, + ); + expect(enfPermission.length).toEqual(1); + }); + + it('should fail to build the admin permissions, problem with creating role metadata', async () => { + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation(async (): Promise => { + return undefined; + }); + + roleMetadataStorageMock.createRoleMetadata = jest + .fn() + .mockImplementation(async (): Promise => { + throw new Error(`Failed to create`); + }); + + config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + 'policies-csv-file': csvPermFile, + policyFileReload: true, + }, + }, + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }, + }); + + await expect( + newPermissionPolicy(config, enfDelegate, roleMetadataStorageMock), + ).rejects.toThrow('Failed to create'); + }); + + it('should build and update a legacy admin permission', async () => { + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementationOnce( + async ( + _roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + return { source: 'legacy' }; + }, + ); + + const enfRole = await enfDelegate.getFilteredGroupingPolicy(1, adminRole); + const enfPermission = await enfDelegate.getFilteredPolicy(0, adminRole); + + expect(enfRole).toEqual(groupPolicy); + expect(enfPermission).toEqual(permissions); + expect(roleMetadataStorageMock.updateRoleMetadata).toHaveBeenCalled(); + }); + + it('should remove users that are no longer in the config file', async () => { + const enfRole = await enfDelegate.getFilteredGroupingPolicy(1, adminRole); + const enfPermission = await enfDelegate.getFilteredPolicy(0, adminRole); + expect(enfRole).toEqual(groupPolicy); + expect(enfRole).not.toContain(oldGroupPolicy); + expect(enfPermission).toEqual(permissions); + }); + }); +}); diff --git a/plugins/rbac-backend/src/admin-permissions/admin-creation.ts b/plugins/rbac-backend/src/admin-permissions/admin-creation.ts new file mode 100644 index 0000000000..5a77535758 --- /dev/null +++ b/plugins/rbac-backend/src/admin-permissions/admin-creation.ts @@ -0,0 +1,212 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { Config } from '@backstage/config'; + +import { Knex } from 'knex'; + +import { ActionType, PermissionEvents, RoleEvents } from '../auditor/auditor'; + +import { + RoleMetadataDao, + RoleMetadataStorage, +} from '../database/role-metadata'; +import { removeTheDifference } from '../helper'; +import { EnforcerDelegate } from '../service/enforcer-delegate'; +import { validateEntityReference } from '../validation/policies-validation'; +import { AuditorService } from '@backstage/backend-plugin-api'; + +export const ADMIN_ROLE_NAME = 'role:default/rbac_admin'; +export const ADMIN_ROLE_AUTHOR = 'application configuration'; +const DEF_ADMIN_ROLE_DESCRIPTION = + 'The default permission policy for the admin role allows for the creation, deletion, updating, and reading of roles and permission policies.'; + +const getAdminRoleMetadata = (): RoleMetadataDao => { + const currentDate: Date = new Date(); + return { + source: 'configuration', + roleEntityRef: ADMIN_ROLE_NAME, + description: DEF_ADMIN_ROLE_DESCRIPTION, + author: ADMIN_ROLE_AUTHOR, + modifiedBy: ADMIN_ROLE_AUTHOR, + lastModified: currentDate.toUTCString(), + createdAt: currentDate.toUTCString(), + }; +}; + +export const useAdminsFromConfig = async ( + admins: Config[], + enf: EnforcerDelegate, + auditor: AuditorService, + roleMetadataStorage: RoleMetadataStorage, + knex: Knex, +) => { + const addedGroupPolicies = new Map(); + const newGroupPolicies = new Map(); + + for (const admin of admins) { + const entityRef = admin.getString('name').toLocaleLowerCase('en-US'); + validateEntityReference(entityRef); + + addedGroupPolicies.set(entityRef, ADMIN_ROLE_NAME); + + if (!(await enf.hasGroupingPolicy(...[entityRef, ADMIN_ROLE_NAME]))) { + newGroupPolicies.set(entityRef, ADMIN_ROLE_NAME); + } + } + + const adminRoleMeta = + await roleMetadataStorage.findRoleMetadata(ADMIN_ROLE_NAME); + const addedRoleMembers = Array.from(newGroupPolicies.entries()); + const meta = { + ...getAdminRoleMetadata(), + members: addedRoleMembers.map(gp => gp[0]), + }; + const auditorEvent = await auditor.createEvent({ + eventId: RoleEvents.ROLE_WRITE, + severityLevel: 'medium', + meta: { + actionType: adminRoleMeta ? ActionType.UPDATE : ActionType.CREATE, + source: meta.source, + }, + }); + + const trx = await knex.transaction(); + try { + if (!adminRoleMeta) { + // even if there are no user, we still create default role metadata for admins + await roleMetadataStorage.createRoleMetadata(getAdminRoleMetadata(), trx); + } else if (adminRoleMeta.source === 'legacy') { + await roleMetadataStorage.updateRoleMetadata( + getAdminRoleMetadata(), + ADMIN_ROLE_NAME, + trx, + ); + } + + await enf.addGroupingPolicies( + addedRoleMembers, + getAdminRoleMetadata(), + undefined, + trx, + ); + + await trx.commit(); + await auditorEvent.success({ + meta, + }); + } catch (error) { + await trx.rollback(error); + await auditorEvent.fail({ + error, + meta, + }); + throw error; + } + + const configGroupPolicies = await enf.getFilteredGroupingPolicy( + 1, + ADMIN_ROLE_NAME, + ); + + await removeTheDifference( + configGroupPolicies.map(gp => gp[0]), + Array.from(addedGroupPolicies.keys()), + 'configuration', + ADMIN_ROLE_NAME, + enf, + auditor, + ADMIN_ROLE_AUTHOR, + ); +}; + +const addAdminPermissions = async ( + policies: string[][], + enf: EnforcerDelegate, + auditor: AuditorService, +) => { + const policiesToAdd: string[][] = []; + for (const policy of policies) { + if (!(await enf.hasPolicy(...policy))) { + policiesToAdd.push(policy); + } + } + + const auditorEvent = await auditor.createEvent({ + eventId: PermissionEvents.POLICY_WRITE, + severityLevel: 'medium', + meta: { actionType: ActionType.CREATE, source: 'configuration' }, + }); + + try { + await enf.addPolicies(policiesToAdd); + await auditorEvent.success({ + meta: { policies: policiesToAdd }, + }); + } catch (error) { + await auditorEvent.fail({ + error, + meta: { policies: policiesToAdd }, + }); + } +}; + +const removeOldCreateAdminPermissions = async ( + enf: EnforcerDelegate, + auditor: AuditorService, +) => { + const policyEntityCreate = [ + 'role:default/rbac_admin', + 'policy-entity', + 'create', + 'allow', + ]; + if (await enf.hasPolicy(...policyEntityCreate)) { + const auditorEvent = await auditor.createEvent({ + eventId: PermissionEvents.POLICY_WRITE, + severityLevel: 'medium', + meta: { actionType: ActionType.DELETE, source: 'configuration' }, + }); + + try { + await enf.removePolicy(policyEntityCreate); + await auditorEvent.success({ + meta: { policy: policyEntityCreate }, + }); + } catch (error) { + await auditorEvent.fail({ + error, + meta: { policy: policyEntityCreate }, + }); + } + } +}; + +export const setAdminPermissions = async ( + enf: EnforcerDelegate, + auditor: AuditorService, +) => { + // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` + await removeOldCreateAdminPermissions(enf, auditor); + const adminPermissions = [ + [ADMIN_ROLE_NAME, 'policy-entity', 'read', 'allow'], + [ADMIN_ROLE_NAME, 'policy.entity.create', 'create', 'allow'], + [ADMIN_ROLE_NAME, 'policy-entity', 'delete', 'allow'], + [ADMIN_ROLE_NAME, 'policy-entity', 'update', 'allow'], + // Needed for the RBAC frontend plugin. + [ADMIN_ROLE_NAME, 'catalog-entity', 'read', 'allow'], + ]; + await addAdminPermissions(adminPermissions, enf, auditor); +}; diff --git a/plugins/rbac-backend/src/auditor/auditor.ts b/plugins/rbac-backend/src/auditor/auditor.ts new file mode 100644 index 0000000000..d6ab047e76 --- /dev/null +++ b/plugins/rbac-backend/src/auditor/auditor.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + PolicyDecision, + ResourcePermission, +} from '@backstage/plugin-permission-common'; +import type { PolicyQuery } from '@backstage/plugin-permission-node'; + +import { + PermissionAction, + toPermissionAction, +} from '@backstage-community/plugin-rbac-common'; +import { + AuditorService, + AuditorServiceEvent, +} from '@backstage/backend-plugin-api'; + +export const ActionType = { + CREATE: 'create', + CREATE_OR_UPDATE: 'create_or_update', + UPDATE: 'update', + DELETE: 'delete', +}; + +export const RoleEvents = { + ROLE_WRITE: 'role-write', + ROLE_READ: 'role-read', +} as const; + +export const PermissionEvents = { + POLICY_WRITE: 'policy-write', + POLICY_READ: 'policy-read', +} as const; + +export const EvaluationEvents = { + PERMISSION_EVALUATION: 'permission-evaluation', +} as const; + +export const ListPluginPoliciesEvents = { + PLUGIN_POLICIES_READ: 'plugin-policies-read', +}; + +export const ListConditionEvents = { + CONDITION_RULES_READ: 'condition-rules-read', +}; + +export const ListPluginIDsEvents = { + PLUGIN_IDS_READ: 'plugin-ids-read', + PLUGIN_IDS_WRITE: 'plugin-ids-write', +}; + +export type EvaluationAuditInfo = { + userEntityRef: string; + permissionName: string; + action: PermissionAction; + resourceType?: string; + decision?: PolicyDecision; +}; + +export const PoliciesData = { + PERMISSIONS_READ: 'permissions-read', +}; + +export const ConditionEvents = { + CONDITION_WRITE: 'condition-write', + CONDITION_READ: 'condition-read', + CONDITIONAL_POLICIES_FILE_NOT_FOUND: 'conditional-policies-file-not-found', + CONDITIONAL_POLICIES_FILE_CHANGE: 'conditional-policies-file-change', +}; + +export async function createPermissionEvaluationAuditorEvent( + auditor: AuditorService, + userEntityRef: string, + request: PolicyQuery, + policyDecision?: PolicyDecision, +): Promise { + const auditInfo: EvaluationAuditInfo = { + userEntityRef, + permissionName: request.permission.name, + action: toPermissionAction(request.permission.attributes), + }; + + const resourceType = (request.permission as ResourcePermission).resourceType; + if (resourceType) { + auditInfo.resourceType = resourceType; + } + if (policyDecision) { + auditInfo.decision = policyDecision; + } + + return await auditor.createEvent({ + eventId: EvaluationEvents.PERMISSION_EVALUATION, + severityLevel: 'medium', + meta: { + ...auditInfo, + }, + }); +} diff --git a/plugins/rbac-backend/src/auditor/rest-interceptor.ts b/plugins/rbac-backend/src/auditor/rest-interceptor.ts new file mode 100644 index 0000000000..a2fde3cb6a --- /dev/null +++ b/plugins/rbac-backend/src/auditor/rest-interceptor.ts @@ -0,0 +1,189 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + type RequestHandler, + type NextFunction, + type Request, + type Response, + type ErrorRequestHandler, +} from 'express'; + +import { + ActionType, + ConditionEvents, + ListConditionEvents, + ListPluginIDsEvents, + ListPluginPoliciesEvents, + PermissionEvents, + RoleEvents, +} from './auditor'; +import { + AuditorService, + AuditorServiceEvent, +} from '@backstage/backend-plugin-api'; +import type { JsonObject } from '@backstage/types'; + +// Mapping paths and methods to corresponding events and messages +const eventMap: { + [key: string]: { [key: string]: string }; +} = { + '/policies': { + POST: PermissionEvents.POLICY_WRITE, + PUT: PermissionEvents.POLICY_WRITE, + DELETE: PermissionEvents.POLICY_WRITE, + GET: PermissionEvents.POLICY_READ, + }, + '/roles/conditions': { + POST: ConditionEvents.CONDITION_WRITE, + PUT: ConditionEvents.CONDITION_WRITE, + DELETE: ConditionEvents.CONDITION_WRITE, + GET: ConditionEvents.CONDITION_READ, + }, + '/roles': { + POST: RoleEvents.ROLE_WRITE, + PUT: RoleEvents.ROLE_WRITE, + DELETE: RoleEvents.ROLE_WRITE, + GET: RoleEvents.ROLE_READ, + }, + '/plugins/policies': { + GET: ListPluginPoliciesEvents.PLUGIN_POLICIES_READ, + }, + '/plugins/condition-rules': { + GET: ListConditionEvents.CONDITION_RULES_READ, + }, + '/plugins/id': { + GET: ListPluginIDsEvents.PLUGIN_IDS_READ, + POST: ListPluginIDsEvents.PLUGIN_IDS_WRITE, + DELETE: ListPluginIDsEvents.PLUGIN_IDS_WRITE, + }, +}; + +const eventToActionMap: { + [key: string]: string; +} = { + POST: ActionType.CREATE, + PUT: ActionType.UPDATE, + DELETE: ActionType.DELETE, +}; + +function getRequestAuditorMeta(req: Request, eventId: string): JsonObject { + const meta = { + ...(req.method in eventToActionMap + ? { actionType: eventToActionMap[req.method] } + : {}), + source: 'rest', + }; + + if (req.method !== 'GET') { + return meta; + } + + let extraMeta = {}; + const hasQuery = Object.keys(req.query).length > 0; + const hasParams = Object.keys(req.params).length > 0; + switch (eventId) { + case PermissionEvents.POLICY_READ: + if (hasParams) { + extraMeta = { + queryType: 'by-role', + entityRef: `${req.params.kind}:${req.params.namespace}/${req.params.name}`, + }; + break; + } + extraMeta = { + queryType: hasQuery ? 'by-query' : 'all', + ...(hasQuery ? { query: req.query } : {}), + }; + break; + case RoleEvents.ROLE_READ: + extraMeta = { + queryType: hasParams ? 'by-role' : 'all', + ...(hasParams + ? { + entityRef: `${req.params.kind}:${req.params.namespace}/${req.params.name}`, + } + : {}), + }; + break; + case ConditionEvents.CONDITION_READ: + if (hasParams) { + extraMeta = { + queryType: 'by-id', + id: req.params.id, + }; + break; + } + extraMeta = { + queryType: hasQuery ? 'by-query' : 'all', + ...(hasQuery ? { query: req.query } : {}), + }; + break; + default: + break; + } + return { ...meta, ...extraMeta }; +} + +export function logAuditorEvent(auditor: AuditorService): RequestHandler { + return async (req: Request, resp: Response, next: NextFunction) => { + let auditorEvent: AuditorServiceEvent | undefined; + const matchedPath = Object.keys(eventMap).find(path => + req.path.startsWith(path), + ); + if (matchedPath) { + const methodEvent = eventMap[matchedPath][req.method]; + if (methodEvent) { + const meta = getRequestAuditorMeta(req, methodEvent); + auditorEvent = await auditor.createEvent({ + eventId: methodEvent, + severityLevel: 'medium', + request: req, + meta, + }); + } + } + + resp.on('finish', async () => { + const meta = { + response: { status: resp.statusCode }, + ...(resp.locals.meta ?? {}), + }; + if (resp.statusCode < 400) { + await auditorEvent?.success({ meta }); + } else { + const error = resp.locals.error ?? new Error(resp.statusMessage); + await auditorEvent?.fail({ + error, + meta, + }); + } + }); + + next(); + }; +} + +export function setAuditorError(): ErrorRequestHandler { + return async ( + err: Error, + _req: Request, + resp: Response, + next: NextFunction, + ) => { + resp.locals.error = err; + next(err); + }; +} diff --git a/plugins/rbac-backend/src/conditional-aliases/alias-resolver.test.ts b/plugins/rbac-backend/src/conditional-aliases/alias-resolver.test.ts new file mode 100644 index 0000000000..cfbfb3c8f2 --- /dev/null +++ b/plugins/rbac-backend/src/conditional-aliases/alias-resolver.test.ts @@ -0,0 +1,648 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { + PermissionCondition, + PermissionCriteria, + PermissionRuleParams, +} from '@backstage/plugin-permission-common'; + +import { replaceAliases } from './alias-resolver'; + +describe('replaceAliases', () => { + describe('should replace "currentUser" aliases', () => { + it('should replace aliases in the string value', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + rule: 'TEST', + resourceType: 'test-entity', + params: { + test: '$currentUser', + }, + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + rule: 'TEST', + resourceType: 'test-entity', + params: { + test: 'user:default/tim', + }, + }); + }); + }); + + it('should replace aliases in the string array', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim'], + }, + }); + }); + + it('should replace aliases with criteria not', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }, + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim'], + }, + }, + }); + }); + + it('should replace aliases with criteria anyOf', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim'], + }, + }, + ], + }); + }); + + it('should replace aliases with criteria anyOf and few values', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }); + }); + + it('should replace aliases with criteria allOf', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim'], + }, + }, + ], + }); + }); + + it('should replace aliases with criteria allOf and few values', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }); + }); + + it('should replace aliases with nested criteria', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + allOf: [ + { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + allOf: [ + { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim'], + }, + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }); + }); + + describe('should replace "ownerRefs" aliases', () => { + it('should replace aliases without criteria', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }); + }); + + it('should replace aliases with criteria not', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }, + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }, + }); + }); + + it('should replace aliases with criteria anyOf', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }, + ], + }); + }); + + it('should replace aliases with criteria anyOf and few values', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }); + }); + + it('should replace aliases with criteria anyOf and few values in a different order', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + anyOf: [ + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + anyOf: [ + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }, + ], + }); + }); + + it('should replace aliases with criteria anyOf and few values for other rules', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + anyOf: [ + { + rule: 'HAS_ANNOTATION', + resourceType: 'catalog-entity', + params: { value: '$currentUser', annotation: 'template/creator' }, + }, + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + anyOf: [ + { + rule: 'HAS_ANNOTATION', + resourceType: 'catalog-entity', + params: { + value: 'user:default/tim', + annotation: 'template/creator', + }, + }, + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }, + ], + }); + }); + + it('should replace aliases with criteria allOf', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }, + ], + }); + }); + + it('should replace aliases with criteria allOf and few values', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }); + }); + + it('should replace aliases with nested criteria', () => { + const conditionParam: PermissionCriteria< + PermissionCondition + > = { + allOf: [ + { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$ownerRefs'], + }, + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }; + + replaceAliases(conditionParam, { + userEntityRef: 'user:default/tim', + ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], + }); + + expect(conditionParam).toEqual({ + allOf: [ + { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/tim', 'group:default/team-a'], + }, + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }); + }); + }); +}); diff --git a/plugins/rbac-backend/src/conditional-aliases/alias-resolver.ts b/plugins/rbac-backend/src/conditional-aliases/alias-resolver.ts new file mode 100644 index 0000000000..9700057c45 --- /dev/null +++ b/plugins/rbac-backend/src/conditional-aliases/alias-resolver.ts @@ -0,0 +1,123 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { BackstageUserInfo } from '@backstage/backend-plugin-api'; +import type { + PermissionCondition, + PermissionCriteria, + PermissionRuleParam, + PermissionRuleParams, +} from '@backstage/plugin-permission-common'; +import type { JsonPrimitive } from '@backstage/types'; + +import { + CONDITION_ALIAS_SIGN, + ConditionalAliases, +} from '@backstage-community/plugin-rbac-common'; + +interface Predicate { + (item: T): boolean; +} + +function isOwnerRefsAlias(value: PermissionRuleParam): boolean { + const alias = `${CONDITION_ALIAS_SIGN}${ConditionalAliases.OWNER_REFS}`; + return value === alias; +} + +function isCurrentUserAlias(value: PermissionRuleParam): boolean { + const alias = `${CONDITION_ALIAS_SIGN}${ConditionalAliases.CURRENT_USER}`; + return value === alias; +} + +function replaceAliasWithValue< + K extends string, + V extends JsonPrimitive | JsonPrimitive[], +>( + params: Record, + key: K, + predicate: Predicate, + newValue: V, +): Record { + if (!params) { + return params; + } + + if (Array.isArray(params[key])) { + const oldValues = params[key] as JsonPrimitive[]; + const nonAliasValues: JsonPrimitive[] = []; + for (const oldValue of oldValues) { + const isAliasMatched = predicate(oldValue); + if (isAliasMatched) { + const newValues = Array.isArray(newValue) ? newValue : [newValue]; + nonAliasValues.push(...newValues); + } else { + nonAliasValues.push(oldValue); + } + } + return { ...params, [key]: nonAliasValues }; + } + + const oldValue = params[key] as JsonPrimitive; + const isAliasMatched = predicate(oldValue); + if (isAliasMatched && !Array.isArray(newValue)) { + return { ...params, [key]: newValue }; + } + + return params; +} + +export function replaceAliases( + conditions: PermissionCriteria< + PermissionCondition + >, + userInfo: BackstageUserInfo, +) { + if ('not' in conditions) { + replaceAliases(conditions.not, userInfo); + return; + } + if ('allOf' in conditions) { + for (const condition of conditions.allOf) { + replaceAliases(condition, userInfo); + } + return; + } + if ('anyOf' in conditions) { + for (const condition of conditions.anyOf) { + replaceAliases(condition, userInfo); + } + return; + } + + if (conditions.params) { + for (const key of Object.keys(conditions.params)) { + let modifiedParams: PermissionRuleParams = replaceAliasWithValue( + conditions.params, + key, + isCurrentUserAlias, + userInfo.userEntityRef, + ); + + modifiedParams = replaceAliasWithValue( + modifiedParams, + key, + isOwnerRefsAlias, + userInfo.ownershipEntityRefs, + ); + + conditions.params = modifiedParams; + } + } +} diff --git a/plugins/rbac-backend/src/database/casbin-adapter-factory.test.ts b/plugins/rbac-backend/src/database/casbin-adapter-factory.test.ts new file mode 100644 index 0000000000..27f90e101c --- /dev/null +++ b/plugins/rbac-backend/src/database/casbin-adapter-factory.test.ts @@ -0,0 +1,612 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices } from '@backstage/backend-test-utils'; + +import knex, { Knex } from 'knex'; +import TypeORMAdapter from 'typeorm-adapter'; + +import { CasbinDBAdapterFactory } from './casbin-adapter-factory'; + +jest.mock('typeorm-adapter', () => { + return { + newAdapter: jest.fn((): Promise => { + return Promise.resolve({} as TypeORMAdapter); + }), + }; +}); + +// Mock Azure Identity +const mockGetToken = jest.fn(); + +jest.mock('@azure/identity', () => { + const mockDefaultAzureCredential = jest.fn(); + const mockManagedIdentityCredential = jest.fn(); + const mockClientSecretCredential = jest.fn(); + + return { + DefaultAzureCredential: mockDefaultAzureCredential, + ManagedIdentityCredential: mockManagedIdentityCredential, + ClientSecretCredential: mockClientSecretCredential, + }; +}); + +describe('CasbinAdapterFactory', () => { + let newAdapterMock: jest.Mock>; + let db: Knex; + + beforeEach(() => { + newAdapterMock = TypeORMAdapter.newAdapter as jest.Mock< + Promise + >; + jest.clearAllMocks(); + }); + it('test building an adapter using a better-sqlite3 configuration.', async () => { + db = knex.knex({ + client: 'better-sqlite3', + connection: ':memory', + }); + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }, + }); + const adapterFactory = new CasbinDBAdapterFactory(config, db); + const adapter = adapterFactory.createAdapter(); + expect(adapter).not.toBeNull(); + expect(newAdapterMock).toHaveBeenCalled(); + }); + + describe('build adapter with postgres configuration', () => { + beforeEach(() => { + db = knex.knex({ + client: 'pg', + connection: { + database: 'test-database', + }, + }); + process.env.TEST = 'test'; + }); + + it('test building an adapter using a PostgreSQL configuration.', async () => { + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + port: '5432', + schema: 'public', + user: 'postgresUser', + password: process.env.TEST, + }, + }, + }, + }, + }); + const factory = new CasbinDBAdapterFactory(config, db); + const adapter = await factory.createAdapter(); + expect(adapter).not.toBeNull(); + expect(newAdapterMock).toHaveBeenCalledWith({ + type: 'postgres', + host: 'localhost', + port: 5432, + schema: 'public', + username: 'postgresUser', + password: process.env.TEST, + database: 'test-database', + ssl: undefined, + }); + }); + + it('test building an adapter using a PostgreSQL configuration with enabled ssl.', async () => { + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + port: '5432', + schema: 'public', + user: 'postgresUser', + password: process.env.TEST, + ssl: true, + }, + }, + }, + }, + }); + const factory = new CasbinDBAdapterFactory(config, db); + const adapter = await factory.createAdapter(); + expect(adapter).not.toBeNull(); + expect(newAdapterMock).toHaveBeenCalledWith({ + type: 'postgres', + host: 'localhost', + port: 5432, + schema: 'public', + username: 'postgresUser', + password: process.env.TEST, + database: 'test-database', + ssl: true, + }); + }); + + it('test building an adapter using a PostgreSQL configuration without explicit credentials.', async () => { + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + port: '5432', + schema: 'public', + }, + }, + }, + }, + }); + const factory = new CasbinDBAdapterFactory(config, db); + const adapter = await factory.createAdapter(); + expect(adapter).not.toBeNull(); + expect(newAdapterMock).toHaveBeenCalledWith({ + type: 'postgres', + host: 'localhost', + port: 5432, + schema: 'public', + username: undefined, + password: undefined, + database: 'test-database', + ssl: undefined, + }); + }); + + it('test building an adapter using a PostgreSQL configuration with intentionally disabled ssl.', async () => { + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + port: '5432', + schema: 'public', + user: 'postgresUser', + password: process.env.TEST, + ssl: false, + }, + }, + }, + }, + }); + const factory = new CasbinDBAdapterFactory(config, db); + const adapter = await factory.createAdapter(); + expect(adapter).not.toBeNull(); + expect(newAdapterMock).toHaveBeenCalledWith({ + type: 'postgres', + host: 'localhost', + port: 5432, + schema: 'public', + username: 'postgresUser', + password: process.env.TEST, + database: 'test-database', + ssl: false, + }); + }); + + it('test building an adapter using a PostgreSQL configuration with intentionally ssl and ca cert.', async () => { + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + port: '5432', + schema: 'public', + user: 'postgresUser', + password: process.env.TEST, + ssl: { + ca: 'abc', + }, + }, + }, + }, + }, + }); + const factory = new CasbinDBAdapterFactory(config, db); + const adapter = await factory.createAdapter(); + expect(adapter).not.toBeNull(); + expect(newAdapterMock).toHaveBeenCalledWith({ + type: 'postgres', + host: 'localhost', + port: 5432, + schema: 'public', + username: 'postgresUser', + password: process.env.TEST, + database: 'test-database', + ssl: { + ca: 'abc', + }, + }); + }); + + it('test building an adapter using a PostgreSQL configuration with intentionally ssl and TLS options.', async () => { + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + port: '5432', + user: 'postgresUser', + password: process.env.TEST, + ssl: { + ca: 'abc', + rejectUnauthorized: false, + }, + }, + }, + }, + }, + }); + const factory = new CasbinDBAdapterFactory(config, db); + const adapter = await factory.createAdapter(); + expect(adapter).not.toBeNull(); + expect(newAdapterMock).toHaveBeenCalledWith({ + type: 'postgres', + host: 'localhost', + port: 5432, + schema: 'public', + username: 'postgresUser', + password: process.env.TEST, + database: 'test-database', + ssl: { + ca: 'abc', + rejectUnauthorized: false, + }, + }); + }); + + it('test building an adapter using a PostgreSQL configuration with intentionally ssl without CA.', async () => { + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + port: '5432', + user: 'postgresUser', + password: process.env.TEST, + ssl: { + rejectUnauthorized: false, + }, + }, + }, + }, + }, + }); + const factory = new CasbinDBAdapterFactory(config, db); + const adapter = await factory.createAdapter(); + expect(adapter).not.toBeNull(); + expect(newAdapterMock).toHaveBeenCalledWith({ + type: 'postgres', + host: 'localhost', + port: 5432, + schema: 'public', + username: 'postgresUser', + password: process.env.TEST, + database: 'test-database', + ssl: { + rejectUnauthorized: false, + }, + }); + }); + }); + + describe('build adapter with Azure PostgreSQL passwordless authentication', () => { + let mockDefaultAzureCredential: jest.Mock; + let mockManagedIdentityCredential: jest.Mock; + let mockClientSecretCredential: jest.Mock; + + beforeEach(() => { + db = knex.knex({ + client: 'pg', + connection: { + database: 'test-database', + }, + }); + jest.clearAllMocks(); + + // Get the mocked Azure Identity constructors + const azureIdentity = require('@azure/identity'); + mockDefaultAzureCredential = + azureIdentity.DefaultAzureCredential as jest.Mock; + mockManagedIdentityCredential = + azureIdentity.ManagedIdentityCredential as jest.Mock; + mockClientSecretCredential = + azureIdentity.ClientSecretCredential as jest.Mock; + + // Setup mock credential with getToken + mockGetToken.mockResolvedValue({ + token: 'mock-azure-token-1234567890', + expiresOnTimestamp: Date.now() + 3600000, // 1 hour from now + }); + + mockDefaultAzureCredential.mockImplementation(() => ({ + getToken: mockGetToken, + })); + mockManagedIdentityCredential.mockImplementation(() => ({ + getToken: mockGetToken, + })); + mockClientSecretCredential.mockImplementation(() => ({ + getToken: mockGetToken, + })); + }); + + it('should use DefaultAzureCredential when no credentials are provided (system-assigned managed identity)', async () => { + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'pg', + connection: { + type: 'azure', + host: 'myserver.postgres.database.azure.com', + port: '5432', + user: 'myuser@myserver', + ssl: { + rejectUnauthorized: false, + }, + }, + }, + }, + }, + }); + + const factory = new CasbinDBAdapterFactory(config, db); + await factory.createAdapter(); + + // Verify DefaultAzureCredential was instantiated + expect(mockDefaultAzureCredential).toHaveBeenCalled(); + expect(mockManagedIdentityCredential).not.toHaveBeenCalled(); + expect(mockClientSecretCredential).not.toHaveBeenCalled(); + + // Verify TypeORMAdapter.newAdapter was called + expect(newAdapterMock).toHaveBeenCalled(); + const adapterConfig = newAdapterMock.mock.calls[0][0]; + + // Verify password is a function + expect(typeof adapterConfig.password).toBe('function'); + + // Call the password function to verify it works + const passwordFn = adapterConfig.password; + const tokenResult = await passwordFn(); + + // Verify getToken was called with correct scope when password function is invoked + expect(mockGetToken).toHaveBeenCalledWith( + 'https://ossrdbms-aad.database.windows.net/.default', + ); + expect(tokenResult).toBe('mock-azure-token-1234567890'); + + // Verify other config + expect(adapterConfig.type).toBe('postgres'); + expect(adapterConfig.host).toBe('myserver.postgres.database.azure.com'); + expect(adapterConfig.port).toBe(5432); + expect(adapterConfig.username).toBe('myuser@myserver'); + expect(adapterConfig.database).toBe('test-database'); + expect(adapterConfig.ssl).toEqual({ rejectUnauthorized: false }); + expect(adapterConfig.extra).toEqual({ + idleTimeoutMillis: 50 * 60 * 1000, + }); + }); + + it('should use ManagedIdentityCredential with clientId (user-assigned managed identity)', async () => { + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'pg', + connection: { + type: 'azure', + host: 'myserver.postgres.database.azure.com', + port: '5432', + user: 'myuser@myserver', + tokenCredential: { + clientId: 'my-client-id', + }, + }, + }, + }, + }, + }); + + const factory = new CasbinDBAdapterFactory(config, db); + await factory.createAdapter(); + + // Verify ManagedIdentityCredential was instantiated with clientId + expect(mockManagedIdentityCredential).toHaveBeenCalledWith( + 'my-client-id', + ); + expect(mockDefaultAzureCredential).not.toHaveBeenCalled(); + expect(mockClientSecretCredential).not.toHaveBeenCalled(); + + // Call the password function to verify getToken is invoked + const adapterConfig = newAdapterMock.mock.calls[0][0]; + const passwordFn = adapterConfig.password; + await passwordFn(); + + // Verify getToken was called when password function is invoked + expect(mockGetToken).toHaveBeenCalledWith( + 'https://ossrdbms-aad.database.windows.net/.default', + ); + }); + + it('should use ClientSecretCredential with clientId, tenantId, and clientSecret (service principal)', async () => { + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'pg', + connection: { + type: 'azure', + host: 'myserver.postgres.database.azure.com', + port: '5432', + user: 'myuser@myserver', + tokenCredential: { + clientId: 'my-client-id', + tenantId: 'my-tenant-id', + clientSecret: 'my-client-secret', + }, + }, + }, + }, + }, + }); + + const factory = new CasbinDBAdapterFactory(config, db); + await factory.createAdapter(); + + // Verify ClientSecretCredential was instantiated with all three parameters + expect(mockClientSecretCredential).toHaveBeenCalledWith( + 'my-tenant-id', + 'my-client-id', + 'my-client-secret', + ); + expect(mockDefaultAzureCredential).not.toHaveBeenCalled(); + expect(mockManagedIdentityCredential).not.toHaveBeenCalled(); + + // Call the password function to verify getToken is invoked + const adapterConfig = newAdapterMock.mock.calls[0][0]; + const passwordFn = adapterConfig.password; + await passwordFn(); + + // Verify getToken was called when password function is invoked + expect(mockGetToken).toHaveBeenCalledWith( + 'https://ossrdbms-aad.database.windows.net/.default', + ); + }); + + it('should call password function and return token when invoked', async () => { + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'pg', + connection: { + type: 'azure', + host: 'myserver.postgres.database.azure.com', + port: '5432', + user: 'myuser@myserver', + }, + }, + }, + }, + }); + + const factory = new CasbinDBAdapterFactory(config, db); + await factory.createAdapter(); + + // Get the password function that was passed to TypeORMAdapter + const adapterConfig = newAdapterMock.mock.calls[0][0]; + const passwordFn = adapterConfig.password; + + // Clear previous calls + mockGetToken.mockClear(); + + // Call the password function + const result = await passwordFn(); + + // Verify it returns the token + expect(result).toBe('mock-azure-token-1234567890'); + + // Verify getToken was called when we invoked the password function + expect(mockGetToken).toHaveBeenCalledTimes(1); + expect(mockGetToken).toHaveBeenCalledWith( + 'https://ossrdbms-aad.database.windows.net/.default', + ); + + // Call it again to verify it fetches a fresh token each time + mockGetToken.mockResolvedValue({ + token: 'new-token-different', + expiresOnTimestamp: Date.now() + 3600000, + }); + const result2 = await passwordFn(); + expect(result2).toBe('new-token-different'); + expect(mockGetToken).toHaveBeenCalledTimes(2); + }); + + it('should throw error when Azure token acquisition fails', async () => { + mockGetToken.mockResolvedValue(null); + + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'pg', + connection: { + type: 'azure', + host: 'myserver.postgres.database.azure.com', + port: '5432', + user: 'myuser@myserver', + }, + }, + }, + }, + }); + + const factory = new CasbinDBAdapterFactory(config, db); + await factory.createAdapter(); + + // Get the password function + const adapterConfig = newAdapterMock.mock.calls[0][0]; + const passwordFn = adapterConfig.password; + + // The error should be thrown when the password function is called + await expect(passwordFn()).rejects.toThrow( + 'Failed to acquire Azure access token for database authentication', + ); + }); + }); + + it('ensure that building an adapter with an unknown configuration fails.', async () => { + const client = 'unknown-db'; + const expectedError = new Error(`Unsupported database client ${client}`); + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client, + }, + }, + }, + }); + const adapterFactory = new CasbinDBAdapterFactory(config, db); + + await expect(adapterFactory.createAdapter()).rejects.toStrictEqual( + expectedError, + ); + expect(newAdapterMock).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/rbac-backend/src/database/casbin-adapter-factory.ts b/plugins/rbac-backend/src/database/casbin-adapter-factory.ts new file mode 100644 index 0000000000..0ad0a95f39 --- /dev/null +++ b/plugins/rbac-backend/src/database/casbin-adapter-factory.ts @@ -0,0 +1,228 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { Config } from '@backstage/config'; +import type { ConfigApi } from '@backstage/core-plugin-api'; + +import { Knex } from 'knex'; +import TypeORMAdapter from 'typeorm-adapter'; + +import { resolve } from 'path'; +import type { ConnectionOptions, TlsOptions } from 'tls'; + +import '@backstage/backend-defaults/database'; + +const DEFAULT_SQLITE3_STORAGE_FILE_NAME = 'rbac.sqlite'; + +/* Note: the following type definition is intentionally duplicated in + * config.d.ts so the clientSecret property can be annotated with + * "@visibility secret" there. + */ +export type AzureTokenCredentialConfig = { + /** + * The client ID of a user-assigned managed identity. + * If not provided, the system-assigned managed identity is used. + */ + clientId?: string; + /** + * The client secret for service principal authentication. + */ + clientSecret?: string; + /** + * The Azure Active Directory tenant ID. + */ + tenantId?: string; +}; + +export class CasbinDBAdapterFactory { + public constructor( + private readonly config: ConfigApi, + private readonly databaseClient: Knex, + ) {} + + public async createAdapter(): Promise { + const databaseConfig = this.config.getOptionalConfig('backend.database'); + const client = databaseConfig?.getOptionalString('client'); + + let adapter; + if (client === 'pg') { + const dbName = + await this.databaseClient.client.config.connection.database; + const schema = + (await this.databaseClient.client.searchPath?.[0]) ?? 'public'; + + const connectionType = + databaseConfig?.getOptionalString('connection.type'); + + if (connectionType === 'azure') { + adapter = await this.createAzureAdapter( + databaseConfig!, + dbName, + schema, + ); + } else { + const ssl = this.handlePostgresSSL(databaseConfig!); + + adapter = await TypeORMAdapter.newAdapter({ + type: 'postgres', + host: databaseConfig?.getString('connection.host'), + port: databaseConfig?.getNumber('connection.port'), + username: databaseConfig?.getOptionalString('connection.user'), + password: databaseConfig?.getOptionalString('connection.password'), + ssl, + database: dbName, + schema: schema, + poolSize: databaseConfig?.getOptionalNumber('knexConfig.pool.max'), + }); + } + } + + if (client === 'better-sqlite3') { + let storage; + if (typeof databaseConfig?.get('connection')?.valueOf() === 'string') { + storage = databaseConfig?.getString('connection'); + } else if (databaseConfig?.has('connection.directory')) { + const storageDir = databaseConfig?.getString('connection.directory'); + storage = resolve(storageDir, DEFAULT_SQLITE3_STORAGE_FILE_NAME); + } + + adapter = await TypeORMAdapter.newAdapter({ + type: 'better-sqlite3', + // Storage type or path to the storage. + database: storage || ':memory:', + }); + } + + if (!adapter) { + throw new Error(`Unsupported database client ${client}`); + } + + return adapter; + } + + private async createAzureAdapter( + dbConfig: Config, + dbName: string, + schema: string, + ): Promise { + // eslint-disable-next-line @backstage/no-forbidden-package-imports + const { + DefaultAzureCredential, + ManagedIdentityCredential, + ClientSecretCredential, + } = require('@azure/identity'); + + const tokenConfig = dbConfig.getOptionalConfig( + 'connection.tokenCredential', + ); + + const clientId = tokenConfig?.getOptionalString('clientId'); + const tenantId = tokenConfig?.getOptionalString('tenantId'); + const clientSecret = tokenConfig?.getOptionalString('clientSecret'); + let credential; + + /** + * Determine which TokenCredential to use based on provided config + * 1. If clientId, tenantId and clientSecret are provided, use ClientSecretCredential + * 2. If only clientId is provided, use ManagedIdentityCredential with user-assigned identity + * 3. Otherwise, use DefaultAzureCredential (which may use system-assigned identity among other methods) + */ + if (clientId && tenantId && clientSecret) { + credential = new ClientSecretCredential(tenantId, clientId, clientSecret); + } else if (clientId) { + credential = new ManagedIdentityCredential(clientId); + } else { + credential = new DefaultAzureCredential(); + } + + const ssl = this.handlePostgresSSL(dbConfig); + + // Create a password function that fetches fresh Azure AD tokens + // The pg driver supports async password functions, enabling automatic token renewal + const passwordFn = async () => { + const token = await credential.getToken( + 'https://ossrdbms-aad.database.windows.net/.default', + ); + + if (!token) { + throw new Error( + 'Failed to acquire Azure access token for database authentication', + ); + } + + return token.token; + }; + + // Create adapter with Azure AD token function for automatic renewal + // The pg driver will call passwordFn on each new connection, ensuring fresh tokens + return TypeORMAdapter.newAdapter({ + type: 'postgres', + host: dbConfig.getString('connection.host'), + port: dbConfig.getNumber('connection.port'), + username: dbConfig.getString('connection.user'), + password: passwordFn as any, // TypeORM types don't include function, but pg driver supports it + ssl, + database: dbName, + schema: schema, + poolSize: dbConfig.getOptionalNumber('knexConfig.pool.max'), + extra: { + // Set max connection lifetime to 50 minutes (tokens expire after ~60 minutes) + // This ensures connections are recycled before tokens expire + idleTimeoutMillis: 50 * 60 * 1000, + }, + }); + } + + private handlePostgresSSL( + dbConfig: Config, + ): boolean | TlsOptions | undefined { + const connection = dbConfig.getOptional( + 'connection', + ); + if (!connection) { + return undefined; + } + + if (typeof connection === 'string' || connection instanceof String) { + throw new Error( + `rbac backend plugin doesn't support postgres connection in a string format yet`, + ); + } + + const ssl: boolean | ConnectionOptions | undefined = connection.ssl; + + if (ssl === undefined) { + return undefined; + } + + if (typeof ssl === 'boolean') { + return ssl; + } + + if (typeof ssl === 'object') { + const { ca, rejectUnauthorized } = ssl as ConnectionOptions; + const tlsOpts = { ca, rejectUnauthorized }; + + // SSL object was defined with some options that we don't support yet. + if (Object.values(tlsOpts).every(el => el === undefined)) { + return true; + } + + return tlsOpts; + } + + return undefined; + } +} diff --git a/plugins/rbac-backend/src/database/conditional-storage.test.ts b/plugins/rbac-backend/src/database/conditional-storage.test.ts new file mode 100644 index 0000000000..de3725a82e --- /dev/null +++ b/plugins/rbac-backend/src/database/conditional-storage.test.ts @@ -0,0 +1,669 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + mockServices, + TestDatabaseId, + TestDatabases, +} from '@backstage/backend-test-utils'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +import * as Knex from 'knex'; +import { createTracker, MockClient } from 'knex-mock-client'; + +import type { + PermissionInfo, + RoleConditionalPolicyDecision, +} from '@backstage-community/plugin-rbac-common'; + +import { + CONDITIONAL_TABLE, + ConditionalPolicyDecisionDAO, + DataBaseConditionalStorage, +} from './conditional-storage'; +import { migrate } from './migration'; + +jest.setTimeout(60000); + +describe('DataBaseConditionalStorage', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'SQLITE_3'], + }); + + const conditionDao1: ConditionalPolicyDecisionDAO = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + permissions: '[{"action":"read","name":"catalog.entity.read"}]', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + conditionsJson: + `{` + + `"rule":"IS_ENTITY_OWNER",` + + `"resourceType":"catalog-entity",` + + `"params":{"claims":["group:default/test-group"]}` + + `}`, + }; + const conditionDao2: ConditionalPolicyDecisionDAO = { + pluginId: 'test', + resourceType: 'test-entity', + permissions: '[{"action": "delete", "name": "catalog.entity.delete"}]', + roleEntityRef: 'role:default/test-2', + result: AuthorizeResult.CONDITIONAL, + conditionsJson: + `{` + + `"rule": "IS_ENTITY_OWNER",` + + `"resourceType": "test-entity",` + + `"params": {"claims": ["group:default/test-group"]}` + + `}`, + }; + const condition1: RoleConditionalPolicyDecision = { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + permissionMapping: [{ action: 'read', name: 'catalog.entity.read' }], + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/test-group'], + }, + }, + }; + const condition2: RoleConditionalPolicyDecision = { + id: 2, + pluginId: 'test', + resourceType: 'test-entity', + permissionMapping: [{ action: 'delete', name: 'catalog.entity.delete' }], + roleEntityRef: 'role:default/test-2', + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'test-entity', + params: { + claims: ['group:default/test-group'], + }, + }, + }; + + async function createDatabase(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + const mockDatabaseService = mockServices.database.mock({ + getClient: async () => knex, + migrations: { skip: false }, + }); + + await migrate(mockDatabaseService); + return { + knex, + db: new DataBaseConditionalStorage(knex), + }; + } + + describe('filterConditions', () => { + it.each(databases.eachSupportedId())( + 'should return all conditions', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + await knex(CONDITIONAL_TABLE).insert( + conditionDao1, + ); + await knex(CONDITIONAL_TABLE).insert( + conditionDao2, + ); + + const conditions = await db.filterConditions(); + expect(conditions.length).toEqual(2); + + expect(conditions[0]).toEqual(condition1); + expect(conditions[1]).toEqual(condition2); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return condition by roleEntityRef', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + await knex(CONDITIONAL_TABLE).insert( + conditionDao1, + ); + await knex(CONDITIONAL_TABLE).insert( + conditionDao2, + ); + + const conditions = await db.filterConditions(`role:default/test`); + expect(conditions.length).toEqual(1); + + expect(conditions[0]).toEqual(condition1); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return condition by pluginId', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + await knex(CONDITIONAL_TABLE).insert( + conditionDao1, + ); + await knex(CONDITIONAL_TABLE).insert( + conditionDao2, + ); + + const conditions = await db.filterConditions(undefined, 'catalog'); + expect(conditions.length).toEqual(1); + + expect(conditions[0]).toEqual(condition1); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return condition by pluginId', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + await knex(CONDITIONAL_TABLE).insert( + conditionDao1, + ); + await knex(CONDITIONAL_TABLE).insert( + conditionDao2, + ); + + const conditions = await db.filterConditions( + undefined, + undefined, + 'catalog-entity', + ); + expect(conditions.length).toEqual(1); + + expect(conditions[0]).toEqual(condition1); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return condition by action', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + await knex(CONDITIONAL_TABLE).insert( + conditionDao1, + ); + await knex(CONDITIONAL_TABLE).insert( + conditionDao2, + ); + + const conditions = await db.filterConditions( + undefined, + undefined, + undefined, + ['read'], + ); + expect(conditions.length).toEqual(1); + + expect(conditions[0]).toEqual(condition1); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return condition by permission name', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + await knex(CONDITIONAL_TABLE).insert( + conditionDao1, + ); + await knex(CONDITIONAL_TABLE).insert( + conditionDao2, + ); + + const conditions = await db.filterConditions( + undefined, + undefined, + undefined, + undefined, + ['catalog.entity.read'], + ); + expect(conditions.length).toEqual(1); + + expect(conditions[0]).toEqual(condition1); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return condition by all arguments', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + await knex(CONDITIONAL_TABLE).insert( + conditionDao1, + ); + await knex(CONDITIONAL_TABLE).insert( + conditionDao2, + ); + + const conditions = await db.filterConditions( + 'role:default/test', + 'catalog', + 'catalog-entity', + ['read'], + ['catalog.entity.read'], + ); + expect(conditions.length).toEqual(1); + + expect(conditions[0]).toEqual(condition1); + }, + ); + }); + + describe('createCondition', () => { + it.each(databases.eachSupportedId())( + 'should successfully create new conditional policy', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + const id = await db.createCondition(condition1); + + const condition = await knex( + CONDITIONAL_TABLE, + ).where('id', id); + expect(condition.length).toEqual(1); + expect(condition[0]).toEqual({ + id: 1, + ...conditionDao1, + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should throw conflict error', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await knex(CONDITIONAL_TABLE).insert( + conditionDao1, + ); + + await expect(async () => { + await db.createCondition(condition1); + }).rejects.toThrow( + `Found condition with conflicted permission action '["read"]'. Role could have multiple conditions for the same resource type 'catalog-entity', but with different permission action sets.`, + ); + }, + ); + + it('should throw failed to create metadata error, because inserted result is undefined', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(CONDITIONAL_TABLE).response(undefined); + tracker.on.insert(CONDITIONAL_TABLE).response(undefined); + + const db = new DataBaseConditionalStorage(knex); + + await expect(async () => { + await db.createCondition(condition1); + }).rejects.toThrow(`Failed to create the condition.`); + }); + }); + + describe('checkConflictedConditions', () => { + it.each(databases.eachSupportedId())( + 'should check conflicted condition', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + await knex(CONDITIONAL_TABLE).insert( + conditionDao1, + ); + + await expect(async () => { + await db.checkConflictedConditions( + 'role:default/test', + 'catalog-entity', + 'catalog', + ['read'], + ); + }).rejects.toThrow( + `Found condition with conflicted permission action '["read"]'. Role could have multiple conditions for the same resource type 'catalog-entity', but with different permission action sets.`, + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'should fail check, when there is condition with one conflicted action "read"', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + const conditionDaoWithFewActions = { + ...conditionDao1, + permissions: + '[{"action":"read","name":"catalog.entity.read"}, {"action":"delete","name":"catalog.entity.delete"}]', + }; + await knex(CONDITIONAL_TABLE).insert( + conditionDaoWithFewActions, + ); + + await expect(async () => { + await db.checkConflictedConditions( + 'role:default/test', + 'catalog-entity', + 'catalog', + ['read'], + ); + }).rejects.toThrow( + `Found condition with conflicted permission action '["read"]'. Role could have multiple conditions for the same resource type 'catalog-entity', but with different permission action sets.`, + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'should fail check, when there is one condition with two conflicted actions "read" and "update"', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + const conditionDaoWithFewActions = { + ...conditionDao1, + permissions: + '[{"action":"read","name":"catalog.entity.read"}, {"action":"delete","name":"catalog.entity.delete"}, {"action":"update","name":"catalog.entity.update"}]', + }; + await knex(CONDITIONAL_TABLE).insert( + conditionDaoWithFewActions, + ); + + await expect(async () => { + await db.checkConflictedConditions( + 'role:default/test', + 'catalog-entity', + 'catalog', + ['read', 'update'], + ); + }).rejects.toThrow( + `Found condition with conflicted permission action '["read","update"]'. Role could have multiple conditions for the same resource type 'catalog-entity', but with different permission action sets.`, + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'should fail check, when there is condition with three conflicted actions "read", "update", "delete"', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + const conditionDaoWithFewActions = { + ...conditionDao1, + permissions: + '[{"action":"read","name":"catalog.entity.read"}, {"action":"delete","name":"catalog.entity.delete"}, {"action":"update","name":"catalog.entity.update"}]', + }; + await knex(CONDITIONAL_TABLE).insert( + conditionDaoWithFewActions, + ); + + await expect(async () => { + await db.checkConflictedConditions( + 'role:default/test', + 'catalog-entity', + 'catalog', + ['read', 'update', 'delete'], + ); + }).rejects.toThrow( + `Found condition with conflicted permission action '["read","update","delete"]'. Role could have multiple conditions for the same resource type 'catalog-entity', but with different permission action sets.`, + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'should pass check, when there is one non conflicted condition', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + const filterConditionsSpy = jest.spyOn(db, 'filterConditions'); + + const conditionDaoWithFewActions = { + ...conditionDao1, + permissions: + '[{"action":"read","name":"catalog.entity.read"}, {"action":"update","name":"catalog.entity.update"}]', + }; + await knex(CONDITIONAL_TABLE).insert( + conditionDaoWithFewActions, + ); + + await db.checkConflictedConditions( + 'role:default/test', + 'catalog-entity', + 'catalog', + ['delete'], + ); + + expect(filterConditionsSpy).toHaveBeenCalledTimes(1); + const result = await filterConditionsSpy.mock.results[0].value; + expect(result).toEqual([ + { + ...condition1, + permissionMapping: [ + { name: 'catalog.entity.read', action: 'read' }, + { name: 'catalog.entity.update', action: 'update' }, + ], + }, + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should pass check, when there are no conditions', + async databasesId => { + const { db } = await createDatabase(databasesId); + const filterConditionsSpy = jest.spyOn(db, 'filterConditions'); + + await db.checkConflictedConditions( + 'role:default/test', + 'catalog-entity', + 'catalog', + ['read'], + ); + + expect(filterConditionsSpy).toHaveBeenCalledTimes(1); + const result = await filterConditionsSpy.mock.results[0].value; + expect(result).toEqual([]); + }, + ); + }); + + describe('getCondition', () => { + it.each(databases.eachSupportedId())( + 'should return condition by id', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + await knex(CONDITIONAL_TABLE).insert( + conditionDao1, + ); + + const condition = await db.getCondition(1); + + expect(condition).toEqual(condition1); + }, + ); + + it.each(databases.eachSupportedId())( + 'should not find condition', + async databasesId => { + const { db } = await createDatabase(databasesId); + + const condition = await db.getCondition(1); + + expect(condition).toBeUndefined(); + }, + ); + }); + + describe('deleteCondition', () => { + it.each(databases.eachSupportedId())( + 'should delete condition by id', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + await knex(CONDITIONAL_TABLE).insert( + conditionDao1, + ); + + await db.deleteCondition(1); + + const conditions = await knex + .table(CONDITIONAL_TABLE) + .select(); + expect(conditions.length).toEqual(0); + }, + ); + + it.each(databases.eachSupportedId())( + 'should not find condition', + async databasesId => { + const { db } = await createDatabase(databasesId); + + await expect(async () => { + await db.deleteCondition(1); + }).rejects.toThrow('Condition with id 1 was not found'); + }, + ); + }); + + describe('updateCondition', () => { + it.each(databases.eachSupportedId())( + 'should update condition with added new action', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + await knex(CONDITIONAL_TABLE).insert( + conditionDao1, + ); + + const updateCondition: RoleConditionalPolicyDecision = { + ...condition1, + permissionMapping: [ + { name: 'catalog.entity.read', action: 'read' }, + { name: 'catalog.entity.delete', action: 'delete' }, + ], + }; + await db.updateCondition(1, updateCondition); + + const condition = await knex + .table(CONDITIONAL_TABLE) + .select() + .where('id', 1); + expect(condition).toEqual([ + { + ...conditionDao1, + permissions: + '[{"name":"catalog.entity.read","action":"read"},{"name":"catalog.entity.delete","action":"delete"}]', + id: 1, + }, + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should update condition with removed one action', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + await knex(CONDITIONAL_TABLE).insert({ + ...conditionDao1, + permissions: + '[{"action":"read","name":"catalog.entity.read"}, {"action":"delete","name":"catalog.entity.delete"}]', + }); + + const updateCondition: RoleConditionalPolicyDecision = { + ...condition1, + permissionMapping: [{ name: 'catalog.entity.read', action: 'read' }], + }; + await db.updateCondition(1, updateCondition); + + const condition = await knex + .table(CONDITIONAL_TABLE) + .select() + .where('id', 1); + expect(condition).toEqual([ + { + ...conditionDao1, + permissions: '[{"name":"catalog.entity.read","action":"read"}]', + id: 1, + }, + ]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should fail to update condition, because condition not found', + async databasesId => { + const { db } = await createDatabase(databasesId); + + const updateCondition: RoleConditionalPolicyDecision = { + ...condition1, + permissionMapping: [ + { name: 'catalog.entity.name', action: 'read' }, + { name: 'catalog.entity.delete', action: 'delete' }, + ], + }; + await expect(async () => { + await db.updateCondition(1, updateCondition); + }).rejects.toThrow('Condition with id 1 was not found'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should fail to update condition, because found condition with conflict', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await knex(CONDITIONAL_TABLE).insert( + conditionDao1, + ); + await knex(CONDITIONAL_TABLE).insert({ + ...conditionDao1, + permissions: + '[{"name": "catalog.entity.delete", "action": "delete"}]', + }); + + const updateCondition: RoleConditionalPolicyDecision = { + ...condition1, + permissionMapping: [ + { name: 'catalog.entity.read', action: 'read' }, + { name: 'catalog.entity.delete', action: 'delete' }, + ], + }; + await expect(async () => { + await db.updateCondition(1, updateCondition); + }).rejects.toThrow( + `Found condition with conflicted permission action '["delete"]'. Role could have multiple conditions for the same resource type 'catalog-entity', but with different permission action sets.`, + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'should fail to update condition, because found condition with two conflicted actions', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await knex(CONDITIONAL_TABLE).insert( + conditionDao1, + ); + await knex(CONDITIONAL_TABLE).insert({ + ...conditionDao1, + permissions: + '[{"name": "catalog.entity.delete", "action": "delete"}, {"name": "catalog.entity.read", "action": "read"}]', + }); + + const updateCondition: RoleConditionalPolicyDecision = { + ...condition1, + permissionMapping: [ + { name: 'catalog.entity.read', action: 'read' }, + { name: 'catalog.entity.delete', action: 'delete' }, + ], + }; + await expect(async () => { + await db.updateCondition(1, updateCondition); + }).rejects.toThrow( + `Found condition with conflicted permission action '["read","delete"]'. Role could have multiple ` + + `conditions for the same resource type 'catalog-entity', but with different permission action sets.`, + ); + }, + ); + }); +}); diff --git a/plugins/rbac-backend/src/database/conditional-storage.ts b/plugins/rbac-backend/src/database/conditional-storage.ts new file mode 100644 index 0000000000..1d09c152b7 --- /dev/null +++ b/plugins/rbac-backend/src/database/conditional-storage.ts @@ -0,0 +1,298 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ConflictError, InputError, NotFoundError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +import { Knex } from 'knex'; + +import type { + PermissionAction, + PermissionInfo, + RoleConditionalPolicyDecision, +} from '@backstage-community/plugin-rbac-common'; + +export const CONDITIONAL_TABLE = 'role-condition-policies'; + +export interface ConditionalPolicyDecisionDAO { + result: AuthorizeResult.CONDITIONAL; + id?: number; + roleEntityRef: string; + permissions: string; + pluginId: string; + resourceType: string; + conditionsJson: string; +} + +export interface ConditionalStorage { + filterConditions( + roleEntityRef?: string | string[], + pluginId?: string, + resourceType?: string, + actions?: PermissionAction[], + permissionNames?: string[], + trx?: Knex.Transaction | Knex, + ): Promise[]>; + createCondition( + conditionalDecision: RoleConditionalPolicyDecision, + ): Promise; + checkConflictedConditions( + roleEntityRef: string, + resourceType: string, + pluginId: string, + queryPermissionNames: string[], + idToExclude?: number, + ): Promise; + getCondition( + id: number, + trx?: Knex.Transaction | Knex, + ): Promise | undefined>; + deleteCondition(id: number): Promise; + updateCondition( + id: number, + conditionalDecision: RoleConditionalPolicyDecision, + trx?: Knex.Transaction, + ): Promise; +} + +export class DataBaseConditionalStorage implements ConditionalStorage { + public constructor(private readonly knex: Knex) {} + + async filterConditions( + roleEntityRef?: string | string[], + pluginId?: string, + resourceType?: string, + actions?: PermissionAction[], + permissionNames?: string[], + trx?: Knex.Transaction | Knex, + ): Promise[]> { + const db = trx ?? this.knex; + const daoRaws = await db.table(CONDITIONAL_TABLE).where(builder => { + if (pluginId) { + builder.where('pluginId', pluginId); + } + if (resourceType) { + builder.where('resourceType', resourceType); + } + if (roleEntityRef) { + if (Array.isArray(roleEntityRef)) { + builder.whereIn('roleEntityRef', roleEntityRef); + } else { + builder.where('roleEntityRef', roleEntityRef); + } + } + }); + + let conditions: RoleConditionalPolicyDecision[] = []; + if (daoRaws) { + conditions = daoRaws.map(dao => this.daoToConditionalDecision(dao)); + } + + if (permissionNames && permissionNames.length > 0) { + conditions = conditions.filter(condition => { + return permissionNames.every(permissionName => + condition.permissionMapping + .map(permInfo => permInfo.name) + .includes(permissionName), + ); + }); + } + + if (actions && actions.length > 0) { + conditions = conditions.filter(condition => { + return actions.every(action => + condition.permissionMapping + .map(permInfo => permInfo.action) + .includes(action), + ); + }); + } + + return conditions; + } + + async createCondition( + conditionalDecision: RoleConditionalPolicyDecision, + ): Promise { + await this.checkConflictedConditions( + conditionalDecision.roleEntityRef, + conditionalDecision.resourceType, + conditionalDecision.pluginId, + conditionalDecision.permissionMapping.map(permInfo => permInfo.action), + ); + + const conditionRaw = this.toDAO(conditionalDecision); + const result = await this.knex + .table(CONDITIONAL_TABLE) + .insert(conditionRaw) + .returning('id'); + if (result && result?.length > 0) { + return result[0].id; + } + + throw new Error(`Failed to create the condition.`); + } + + async checkConflictedConditions( + roleEntityRef: string, + resourceType: string, + pluginId: string, + queryConditionActions: PermissionAction[], + idToExclude?: number, + trx?: Knex.Transaction | Knex, + ): Promise { + const db = trx ?? this.knex; + let conditionsForTheSameResource = await this.filterConditions( + roleEntityRef, + pluginId, + resourceType, + undefined, + undefined, + db, + ); + conditionsForTheSameResource = conditionsForTheSameResource.filter( + c => c.id !== idToExclude, + ); + + if (conditionsForTheSameResource) { + const conflictedCondition = conditionsForTheSameResource.find( + condition => { + const conditionActions = condition.permissionMapping.map( + permInfo => permInfo.action, + ); + return queryConditionActions.some(action => + conditionActions.includes(action), + ); + }, + ); + + if (conflictedCondition) { + const conflictedActions = queryConditionActions.filter(action => + conflictedCondition.permissionMapping.some(p => p.action === action), + ); + throw new ConflictError( + `Found condition with conflicted permission action '${JSON.stringify( + conflictedActions, + )}'. Role could have multiple ` + + `conditions for the same resource type '${conflictedCondition.resourceType}', but with different permission action sets.`, + ); + } + } + } + + async getCondition( + id: number, + trx?: Knex.Transaction | Knex, + ): Promise | undefined> { + const db = trx ?? this.knex; + const daoRaw = await db.table(CONDITIONAL_TABLE).where('id', id).first(); + + if (daoRaw) { + return this.daoToConditionalDecision(daoRaw); + } + return undefined; + } + + async deleteCondition(id: number): Promise { + const condition = await this.getCondition(id); + if (!condition) { + throw new NotFoundError(`Condition with id ${id} was not found`); + } + await this.knex?.table(CONDITIONAL_TABLE).delete().whereIn('id', [id]); + } + + async updateCondition( + id: number, + conditionalDecision: RoleConditionalPolicyDecision, + trx?: Knex.Transaction, + ): Promise { + const db = trx ?? this.knex; + const condition = await this.getCondition(id, db); + if (!condition) { + throw new NotFoundError(`Condition with id ${id} was not found`); + } + + await this.checkConflictedConditions( + conditionalDecision.roleEntityRef, + conditionalDecision.resourceType, + conditionalDecision.pluginId, + conditionalDecision.permissionMapping.map(perm => perm.action), + id, + db, + ); + + const conditionRaw = this.toDAO(conditionalDecision); + conditionRaw.id = id; + const result = await db + .table(CONDITIONAL_TABLE) + .where('id', conditionRaw.id) + .update(conditionRaw) + .returning('id'); + + if (!result || result.length === 0) { + throw new Error(`Failed to update the condition with id: ${id}.`); + } + } + + private toDAO( + conditionalDecision: RoleConditionalPolicyDecision, + ): ConditionalPolicyDecisionDAO { + const { + result, + pluginId, + resourceType, + conditions, + roleEntityRef, + permissionMapping, + } = conditionalDecision; + const conditionsJson = JSON.stringify(conditions); + return { + result, + pluginId, + resourceType, + conditionsJson, + roleEntityRef, + permissions: JSON.stringify(permissionMapping), + }; + } + + private daoToConditionalDecision( + dao: ConditionalPolicyDecisionDAO, + ): RoleConditionalPolicyDecision { + if (!dao.id) { + throw new InputError(`Missed id in the dao object: ${dao}`); + } + const { + id, + result, + pluginId, + resourceType, + conditionsJson, + roleEntityRef, + permissions, + } = dao; + + const conditions = JSON.parse(conditionsJson); + return { + id, + result, + pluginId, + resourceType, + conditions, + roleEntityRef, + permissionMapping: JSON.parse(permissions), + }; + } +} diff --git a/plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.test.ts b/plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.test.ts new file mode 100644 index 0000000000..461085adea --- /dev/null +++ b/plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + mockServices, + TestDatabaseId, + TestDatabases, +} from '@backstage/backend-test-utils'; +import { migrate } from './migration'; +import { + PermissionDependentPluginDatabaseStore, + PermissionDependentPluginDTO, + PLUGINS_TABLE, +} from './extra-permission-enabled-plugins-storage'; + +describe('PermissionDependentPluginDatabaseStore', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'SQLITE_3'], + }); + + async function createDatabase(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + const mockDatabaseService = mockServices.database.mock({ + getClient: async () => knex, + migrations: { skip: false }, + }); + + await migrate(mockDatabaseService); + return { + knex, + db: new PermissionDependentPluginDatabaseStore(knex), + }; + } + + it.each(databases.eachSupportedId())( + 'should return list plugins', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + const expectedPlugin = { pluginId: 'catalog' }; + await knex(PLUGINS_TABLE).insert( + expectedPlugin, + ); + + const plugins = await db.getPlugins(); + + expect(plugins).toEqual([expectedPlugin]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should delete plugin', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + const expectedPlugin = { pluginId: 'catalog' }; + await knex(PLUGINS_TABLE).insert( + expectedPlugin, + ); + + await db.deletePlugins(['catalog']); + const plugins = await db.getPlugins(); + + expect(plugins).toEqual([]); + }, + ); + + it.each(databases.eachSupportedId())( + 'should add plugin', + async databasesId => { + const { db } = await createDatabase(databasesId); + const expectedPlugin = { pluginId: 'catalog' }; + + await db.addPlugins([expectedPlugin]); + + const plugins = await db.getPlugins(); + + expect(plugins).toEqual([expectedPlugin]); + }, + ); +}); diff --git a/plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.ts b/plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.ts new file mode 100644 index 0000000000..5db4d55601 --- /dev/null +++ b/plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Knex } from 'knex'; + +export const PLUGINS_TABLE = 'extra_permission_enabled_plugins'; + +export interface PermissionDependentPluginDTO { + pluginId: string; +} + +/** + * This interface defines the methods for managing the extra permission-enabled plugins in the database. + */ +export interface PermissionDependentPluginStore { + // Fetches the extra plugin list from database. + // This list contains information about extra plugins that supports Backstage permissions framework. + getPlugins(): Promise; + + // Adds the plugins to the database. + addPlugins(plugins: PermissionDependentPluginDTO[]): Promise; + + // Removes plugins from the database by pluginIds. + deletePlugins(pluginIds: string[]): Promise; +} + +export class PermissionDependentPluginDatabaseStore implements PermissionDependentPluginStore { + public constructor(private readonly knex: Knex) {} + + async getPlugins(): Promise { + return await this.knex + .table(PLUGINS_TABLE) + .select('pluginId'); + } + + async addPlugins(plugins: PermissionDependentPluginDTO[]): Promise { + await this.knex.table(PLUGINS_TABLE).insert(plugins); + } + + async deletePlugins(pluginIds: string[]): Promise { + await this.knex + .table(PLUGINS_TABLE) + .whereIn('pluginId', pluginIds) + .delete(); + } +} diff --git a/plugins/rbac-backend/src/database/migration.ts b/plugins/rbac-backend/src/database/migration.ts new file mode 100644 index 0000000000..ebdd04ccbe --- /dev/null +++ b/plugins/rbac-backend/src/database/migration.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + DatabaseService, + resolvePackagePath, +} from '@backstage/backend-plugin-api'; + +const migrationsDir = resolvePackagePath( + '@internal/plugin-rbac-backend', // Package name + 'migrations', // Migrations directory +); + +export async function migrate(databaseManager: DatabaseService) { + const knex = await databaseManager.getClient(); + + if (!databaseManager.migrations?.skip) { + await knex.migrate.latest({ + directory: migrationsDir, + }); + } +} diff --git a/plugins/rbac-backend/src/database/role-metadata.test.ts b/plugins/rbac-backend/src/database/role-metadata.test.ts new file mode 100644 index 0000000000..2ddb690cda --- /dev/null +++ b/plugins/rbac-backend/src/database/role-metadata.test.ts @@ -0,0 +1,965 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + mockServices, + TestDatabaseId, + TestDatabases, +} from '@backstage/backend-test-utils'; + +import * as Knex from 'knex'; +import { createTracker, MockClient } from 'knex-mock-client'; + +import { migrate } from './migration'; +import { + DataBaseRoleMetadataStorage, + ROLE_METADATA_TABLE, + RoleMetadataDao, +} from './role-metadata'; +import { RBACFilter } from '../permissions'; + +jest.setTimeout(60000); + +describe('role-metadata-db-table', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'SQLITE_3'], + }); + const modifiedBy = 'user:default/some-user'; + + async function createDatabase(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + const mockDatabaseService = mockServices.database.mock({ + getClient: async () => knex, + migrations: { skip: false }, + }); + + await migrate(mockDatabaseService); + const config = mockServices.rootConfig(); + return { + knex, + config, + db: new DataBaseRoleMetadataStorage(knex), + }; + } + + async function createDatabaseWithDefaultRole(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + const mockDatabaseService = mockServices.database.mock({ + getClient: async () => knex, + migrations: { skip: false }, + }); + + await migrate(mockDatabaseService); + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: { + defaultRole: 'role:default/default-role', + basicPermissions: [ + { permission: 'catalog.entity.read', policy: 'read' }, + ], + }, + }, + }, + }, + }); + return { + knex, + config, + db: new DataBaseRoleMetadataStorage(knex), + }; + } + + /** Normalize DAO isDefault (0/1 from SQLite) to boolean for assertion. */ + function withBooleanIsDefault( + rows: RoleMetadataDao[], + ): (RoleMetadataDao & { isDefault?: boolean })[] { + return rows.map(r => ({ ...r, isDefault: Boolean(r.isDefault) })); + } + + describe('syncDefaultRoleMetadata', () => { + it.each(databases.eachSupportedId())( + 'should remove default role from DB when not in config', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/default-role', + source: 'configuration', + modifiedBy, + isDefault: true, + }); + await db.syncDefaultRoleMetadata(); + const found = await db.findRoleMetadata('role:default/default-role'); + expect(found).toBeUndefined(); + expect(db.getCachedDefaultRoleMetadata()).toBeUndefined(); + }, + ); + + it.each(databases.eachSupportedId())( + 'should insert default role in DB when in config', + async databasesId => { + const { db } = await createDatabaseWithDefaultRole(databasesId); + await db.syncDefaultRoleMetadata('role:default/default-role'); + const found = await db.findRoleMetadata('role:default/default-role'); + expect(found).toBeDefined(); + expect(found!.roleEntityRef).toBe('role:default/default-role'); + expect(found!.source).toBe('configuration'); + expect(found!.isDefault).toBeTruthy(); + expect(db.getCachedDefaultRoleMetadata()?.roleEntityRef).toBe( + 'role:default/default-role', + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'should delete old default role from DB when config has a new default role', + async databasesId => { + const { knex } = await createDatabase(databasesId); + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/old-default', + source: 'configuration', + modifiedBy, + isDefault: true, + }); + const db = new DataBaseRoleMetadataStorage(knex); + await db.syncDefaultRoleMetadata('role:default/new-default'); + + const oldFound = await db.findRoleMetadata('role:default/old-default'); + expect(oldFound).toBeUndefined(); + + const newFound = await db.findRoleMetadata('role:default/new-default'); + expect(newFound).toBeDefined(); + expect(newFound!.roleEntityRef).toBe('role:default/new-default'); + expect(newFound!.isDefault).toBeTruthy(); + expect(db.getCachedDefaultRoleMetadata()?.roleEntityRef).toBe( + 'role:default/new-default', + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'should throw error when role exists with incompatible source', + async databasesId => { + const { knex } = await createDatabase(databasesId); + + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/csv-role', + source: 'csv-file', + modifiedBy, + isDefault: false, + }); + const db = new DataBaseRoleMetadataStorage(knex); + + // Try to sync this role as default (which requires 'configuration' source) + await expect( + db.syncDefaultRoleMetadata('role:default/csv-role'), + ).rejects.toThrow( + "Role 'role:default/csv-role' has incompatible source. Expected 'configuration' source value", + ); + }, + ); + }); + + describe('findRoleMetadata', () => { + it.each(databases.eachSupportedId())( + 'should return undefined', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + const trx = await knex.transaction(); + try { + const roleMetadata = await db.findRoleMetadata( + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + expect(roleMetadata).toBeUndefined(); + } catch (err) { + await trx.rollback(); + throw err; + } + }, + ); + + it.each(databases.eachSupportedId())( + 'should return found metadata', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'rest', + modifiedBy, + }); + + const trx = await knex.transaction(); + try { + const roleMetadata = await db.findRoleMetadata( + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + expect( + withBooleanIsDefault(roleMetadata ? [roleMetadata] : [])[0], + ).toEqual({ + author: null, + createdAt: null, + description: null, + id: 1, + isDefault: false, + lastModified: null, + modifiedBy, + owner: null, + roleEntityRef: 'role:default/some-super-important-role', + source: 'rest', + }); + } catch (err) { + await trx.rollback(); + throw err; + } + }, + ); + }); + + describe('filterRoleMetadata', () => { + it.each(databases.eachSupportedId())( + 'should return undefined', + async databasesId => { + const { db } = await createDatabase(databasesId); + try { + const roleMetadata = await db.filterRoleMetadata('rest'); + expect(roleMetadata).toEqual([]); + } catch (err) { + throw err; + } + }, + ); + + it.each(databases.eachSupportedId())( + 'should return found metadata', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'rest', + modifiedBy, + }); + + try { + const roleMetadata = await db.filterRoleMetadata('rest'); + expect(withBooleanIsDefault(roleMetadata)).toEqual([ + { + author: null, + createdAt: null, + description: null, + id: 1, + isDefault: false, + lastModified: null, + modifiedBy, + owner: null, + roleEntityRef: 'role:default/some-super-important-role', + source: 'rest', + }, + ]); + } catch (err) { + throw err; + } + }, + ); + }); + + describe('filterForOwnerRoleMetadata', () => { + it.each(databases.eachSupportedId())( + 'should return undefined', + async databasesId => { + const rbacFilter: RBACFilter = { + key: 'owner', + values: ['user:default/some_user'], + }; + const { db } = await createDatabase(databasesId); + try { + const roleMetadata = await db.filterForOwnerRoleMetadata({ + anyOf: [rbacFilter], + }); + expect(roleMetadata).toEqual([]); + } catch (err) { + throw err; + } + }, + ); + + it.each(databases.eachSupportedId())( + 'should return found metadata for specified filter', + async databasesId => { + const rbacFilter: RBACFilter = { + key: 'owner', + values: ['user:default/some_user'], + }; + const { knex, db } = await createDatabase(databasesId); + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'rest', + modifiedBy, + owner: 'user:default/some_user', + }); + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/role:default/some-important-role', + source: 'rest', + modifiedBy, + owner: 'user:default/some_other_user', + }); + + try { + const roleMetadata = await db.filterForOwnerRoleMetadata({ + anyOf: [rbacFilter], + }); + expect(withBooleanIsDefault(roleMetadata)).toEqual([ + { + author: null, + createdAt: null, + description: null, + id: 1, + isDefault: false, + lastModified: null, + modifiedBy, + owner: 'user:default/some_user', + roleEntityRef: 'role:default/some-super-important-role', + source: 'rest', + }, + ]); + } catch (err) { + throw err; + } + }, + ); + + it.each(databases.eachSupportedId())( + 'should return found metadata for no filter', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'rest', + modifiedBy, + owner: 'user:default/some_user', + }); + + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-important-role', + source: 'rest', + modifiedBy, + owner: 'user:default/some_other_user', + }); + + try { + const roleMetadata = await db.filterForOwnerRoleMetadata(); + expect(withBooleanIsDefault(roleMetadata)).toEqual([ + { + author: null, + createdAt: null, + description: null, + id: 1, + isDefault: false, + lastModified: null, + modifiedBy, + owner: 'user:default/some_user', + roleEntityRef: 'role:default/some-super-important-role', + source: 'rest', + }, + { + author: null, + createdAt: null, + description: null, + id: 2, + isDefault: false, + lastModified: null, + modifiedBy, + owner: 'user:default/some_other_user', + roleEntityRef: 'role:default/some-important-role', + source: 'rest', + }, + ]); + } catch (err) { + throw err; + } + }, + ); + + it.each(databases.eachSupportedId())( + 'should include cached default role in filtered results', + async databasesId => { + const rbacFilter: RBACFilter = { + key: 'owner', + values: ['user:default/some_user'], + }; + const { knex, db } = await createDatabase(databasesId); + + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/regular-role', + source: 'rest', + modifiedBy, + owner: 'user:default/some_user', + }); + + await db.syncDefaultRoleMetadata('role:default/default-role'); + + try { + const roleMetadata = await db.filterForOwnerRoleMetadata({ + anyOf: [rbacFilter], + }); + + // Should return regular role + cached default role + expect(roleMetadata.length).toBe(2); + expect(roleMetadata.map(r => r.roleEntityRef).sort()).toEqual([ + 'role:default/default-role', + 'role:default/regular-role', + ]); + + const defaultRole = roleMetadata.find( + r => r.roleEntityRef === 'role:default/default-role', + ); + expect(defaultRole?.isDefault).toBeTruthy(); + expect(defaultRole?.source).toBe('configuration'); + } catch (err) { + throw err; + } + }, + ); + }); + + describe('createRoleMetadata', () => { + it.each(databases.eachSupportedId())( + 'should successfully create new role metadata', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + const trx = await knex.transaction(); + let id; + try { + id = await db.createRoleMetadata( + { + source: 'configuration', + roleEntityRef: 'role:default/some-super-important-role', + modifiedBy, + }, + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + + const metadata = await knex(ROLE_METADATA_TABLE).where( + 'id', + id, + ); + expect(metadata.length).toEqual(1); + expect(metadata[0]).toMatchObject({ + author: null, + createdAt: null, + roleEntityRef: 'role:default/some-super-important-role', + description: null, + id: 1, + lastModified: null, + modifiedBy, + owner: null, + source: 'configuration', + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should throw conflict error', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'configuration', + }); + + const trx = await knex.transaction(); + await expect(async () => { + try { + await db.createRoleMetadata( + { + source: 'configuration', + roleEntityRef: 'role:default/some-super-important-role', + modifiedBy, + }, + + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + }).rejects.toThrow( + `A metadata for role role:default/some-super-important-role has already been stored`, + ); + }, + ); + + it.each(databases.eachSupportedId())( + 'should throw ConflictError when creating role with default role name', + async databasesId => { + const { knex, db } = await createDatabaseWithDefaultRole(databasesId); + await db.syncDefaultRoleMetadata('role:default/default-role'); + + const trx = await knex.transaction(); + await expect(async () => { + try { + await db.createRoleMetadata( + { + source: 'configuration', + roleEntityRef: 'role:default/default-role', + modifiedBy, + }, + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + }).rejects.toThrow( + `A metadata for role role:default/default-role has already been stored`, + ); + }, + ); + + it('should throw failed to create metadata error, because inserted result is an empty array.', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(ROLE_METADATA_TABLE).response(undefined); + tracker.on.insert(ROLE_METADATA_TABLE).response([]); + + const db = new DataBaseRoleMetadataStorage(knex); + const trx = await knex.transaction(); + + await expect( + db.createRoleMetadata( + { + source: 'configuration', + roleEntityRef: 'role:default/some-super-important-role', + modifiedBy, + }, + trx, + ), + ).rejects.toThrow( + `Failed to create the role metadata: '{"source":"configuration","roleEntityRef":"role:default/some-super-important-role","modifiedBy":"user:default/some-user"}'.`, + ); + }); + + it('should throw failed to create metadata error, because inserted result is undefined.', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(ROLE_METADATA_TABLE).response(undefined); + tracker.on.insert(ROLE_METADATA_TABLE).response(undefined); + + const db = new DataBaseRoleMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.createRoleMetadata( + { + source: 'configuration', + roleEntityRef: 'role:default/some-super-important-role', + modifiedBy, + }, + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow( + `Failed to create the role metadata: '{"source":"configuration","roleEntityRef":"role:default/some-super-important-role","modifiedBy":"user:default/some-user"}'.`, + ); + }); + + it('should throw an error on insert metadata operation', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(ROLE_METADATA_TABLE).response(undefined); + tracker.on + .insert(ROLE_METADATA_TABLE) + .simulateError('connection refused error'); + + const db = new DataBaseRoleMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.createRoleMetadata( + { + source: 'configuration', + roleEntityRef: 'role:default/some-super-important-role', + modifiedBy, + }, + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow('connection refused error'); + }); + }); + + describe('updateRoleMetadata', () => { + it.each(databases.eachSupportedId())( + 'should successfully update role metadata from legacy source to new value', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'legacy', + }); + + const trx = await knex.transaction(); + try { + await db.updateRoleMetadata( + { + roleEntityRef: 'role:default/some-super-important-role', + source: 'rest', + modifiedBy, + }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + + const metadata = await knex(ROLE_METADATA_TABLE).where( + 'id', + 1, + ); + expect(metadata.length).toEqual(1); + expect(metadata[0]).toMatchObject({ + author: null, + createdAt: null, + description: null, + source: 'rest', + roleEntityRef: 'role:default/some-super-important-role', + id: 1, + lastModified: null, + modifiedBy, + owner: null, + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should fail to update role metadata source to new value, because source is not legacy', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'rest', + }); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.updateRoleMetadata( + { + roleEntityRef: 'role:default/some-super-important-role', + source: 'configuration', + modifiedBy, + }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + }).rejects.toThrow(`The RoleMetadata.source field is 'read-only'`); + }, + ); + + it.each(databases.eachSupportedId())( + 'should successfully update role metadata with the new name', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'configuration', + }); + + const trx = await knex.transaction(); + try { + await db.updateRoleMetadata( + { + roleEntityRef: 'role:default/important-role', + source: 'configuration', + modifiedBy, + }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + + const metadata = await knex(ROLE_METADATA_TABLE).where( + 'id', + 1, + ); + expect(metadata.length).toEqual(1); + expect(metadata[0]).toMatchObject({ + author: null, + createdAt: null, + description: null, + source: 'configuration', + roleEntityRef: 'role:default/important-role', + id: 1, + lastModified: null, + modifiedBy, + owner: null, + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should fail to update role metadata, because role metadata was not found', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.updateRoleMetadata( + { + roleEntityRef: 'role:default/important-role', + source: 'configuration', + modifiedBy, + }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + }).rejects.toThrow( + `A metadata for role 'role:default/some-super-important-role' was not found`, + ); + }, + ); + + it('should throw failed to update metadata error, because update result is an empty array.', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(ROLE_METADATA_TABLE).response({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'configuration', + id: 1, + }); + tracker.on.update(ROLE_METADATA_TABLE).response([]); + + const db = new DataBaseRoleMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.updateRoleMetadata( + { + roleEntityRef: 'role:default/important-role', + source: 'configuration', + modifiedBy, + }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow( + `Failed to update the role metadata '{"roleEntityRef":"role:default/some-super-important-role","source":"configuration","id":1}' with new value: '{"roleEntityRef":"role:default/important-role","source":"configuration","modifiedBy":"user:default/some-user"}'.`, + ); + }); + + it('should throw failed to update metadata error, because update result is undefined.', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(ROLE_METADATA_TABLE).response({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'configuration', + id: 1, + }); + tracker.on.update(ROLE_METADATA_TABLE).response(undefined); + + const db = new DataBaseRoleMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.updateRoleMetadata( + { + roleEntityRef: 'role:default/important-role', + source: 'configuration', + modifiedBy, + }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow( + `Failed to update the role metadata '{"roleEntityRef":"role:default/some-super-important-role","source":"configuration","id":1}' with new value: '{"roleEntityRef":"role:default/important-role","source":"configuration","modifiedBy":"user:default/some-user"}'.`, + ); + }); + + it('should throw on insert metadata operation', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(ROLE_METADATA_TABLE).response({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'configuration', + id: 1, + }); + tracker.on + .update(ROLE_METADATA_TABLE) + .simulateError('connection refused error'); + + const db = new DataBaseRoleMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.updateRoleMetadata( + { + roleEntityRef: 'role:default/important-role', + source: 'configuration', + modifiedBy, + }, + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow('connection refused error'); + }); + }); + + describe('removeRoleMetadata', () => { + it.each(databases.eachSupportedId())( + 'should successfully delete role metadata', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + await knex(ROLE_METADATA_TABLE).insert({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'legacy', + }); + + const trx = await knex.transaction(); + try { + await db.removeRoleMetadata( + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + + const metadata = await knex(ROLE_METADATA_TABLE).where( + 'id', + 1, + ); + expect(metadata.length).toEqual(0); + }, + ); + + it.each(databases.eachSupportedId())( + 'should fail to delete role metadata, because nothing to delete', + async databasesId => { + const { knex, db } = await createDatabase(databasesId); + + const trx = await knex.transaction(); + + await expect(async () => { + try { + await db.removeRoleMetadata( + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(); + throw err; + } + }).rejects.toThrow( + `A metadata for role 'role:default/some-super-important-role' was not found`, + ); + }, + ); + + it('should throw an error on delete metadata operation', async () => { + const knex = Knex.knex({ client: MockClient }); + const tracker = createTracker(knex); + tracker.on.select(ROLE_METADATA_TABLE).response({ + roleEntityRef: 'role:default/some-super-important-role', + source: 'configuration', + id: 1, + }); + tracker.on + .delete(ROLE_METADATA_TABLE) + .simulateError('connection refused error'); + + const db = new DataBaseRoleMetadataStorage(knex); + + await expect(async () => { + const trx = await knex.transaction(); + try { + await db.removeRoleMetadata( + 'role:default/some-super-important-role', + trx, + ); + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + }).rejects.toThrow('connection refused error'); + }); + }); +}); diff --git a/plugins/rbac-backend/src/database/role-metadata.ts b/plugins/rbac-backend/src/database/role-metadata.ts new file mode 100644 index 0000000000..c14a26bc8e --- /dev/null +++ b/plugins/rbac-backend/src/database/role-metadata.ts @@ -0,0 +1,261 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ConflictError, InputError, NotFoundError } from '@backstage/errors'; + +import { Knex } from 'knex'; + +import type { + RoleMetadata, + Source, +} from '@backstage-community/plugin-rbac-common'; + +import { deepSortedEqual } from '../helper'; +import { RBACFilters } from '../permissions'; +import { matches } from '../helper'; +import { buildDefaultRoleMetadata } from '../default-permissions/default-permissions'; +import { validateSource } from '../validation/policies-validation'; + +export const ROLE_METADATA_TABLE = 'role-metadata'; + +export interface RoleMetadataDao { + id?: number; + roleEntityRef: string; + source: Source; + modifiedBy: string; + description?: string; + author?: string; + lastModified?: string; + createdAt?: string; + owner?: string; + /** Postgres has a real boolean type; SQLite stores and may return 0/1. Optional when creating. */ + isDefault?: boolean | 0 | 1; +} + +export interface RoleMetadataStorage { + filterRoleMetadata(source?: Source): Promise; + filterForOwnerRoleMetadata(filter?: RBACFilters): Promise; + findRoleMetadata( + roleEntityRef: string, + trx?: Knex.Transaction, + ): Promise; + createRoleMetadata( + roleMetadata: RoleMetadataDao, + trx: Knex.Transaction, + ): Promise; + updateRoleMetadata( + roleMetadata: RoleMetadataDao, + oldRoleEntityRef: string, + externalTrx?: Knex.Transaction, + ): Promise; + removeRoleMetadata( + roleEntityRef: string, + trx?: Knex.Transaction, + ): Promise; + getCachedDefaultRoleMetadata(): RoleMetadataDao | undefined; + /** Returns the default role from the database (isDefault = true), if any. */ + getDefaultRole(trx?: Knex.Transaction): Promise; + syncDefaultRoleMetadata(actualDefRoleRef?: string): Promise; +} + +export class DataBaseRoleMetadataStorage implements RoleMetadataStorage { + private cachedDefaultRoleMeta: RoleMetadataDao | undefined; + constructor(private readonly knex: Knex) {} + + async filterRoleMetadata(source?: Source): Promise { + return await this.knex.table(ROLE_METADATA_TABLE).where(builder => { + if (source) { + builder.where('source', source); + } + }); + } + + async syncDefaultRoleMetadata(actualDefRoleRef?: string): Promise { + if (!actualDefRoleRef) { + await this.knex(ROLE_METADATA_TABLE).where('isDefault', true).delete(); + this.cachedDefaultRoleMeta = undefined; + return; + } + await this.knex.transaction(async trx => { + const currentDefaultRole = await this.getDefaultRole(trx); + if ( + currentDefaultRole && + currentDefaultRole.roleEntityRef !== actualDefRoleRef + ) { + await trx(ROLE_METADATA_TABLE).where('isDefault', true).delete(); + } + const existing = await this.findRoleMetadata(actualDefRoleRef, trx); + if (!existing) { + const newDefaultRole = buildDefaultRoleMetadata(actualDefRoleRef); + await this.createRoleMetadata(newDefaultRole, trx); + } else { + const err = await validateSource('configuration', existing); + if (err) { + throw new Error( + `Role '${actualDefRoleRef}' has incompatible source. Expected 'configuration' source value. Cause: ${err.message}`, + ); + } + } + }); + const row = await this.findRoleMetadata(actualDefRoleRef); + this.cachedDefaultRoleMeta = row; + } + + getCachedDefaultRoleMetadata(): RoleMetadataDao | undefined { + return this.cachedDefaultRoleMeta; + } + + async getDefaultRole( + trx?: Knex.Transaction, + ): Promise { + const db = trx || this.knex; + return await db(ROLE_METADATA_TABLE) + .where('isDefault', true) + .first(); + } + + async filterForOwnerRoleMetadata( + filter?: RBACFilters, + ): Promise { + const roleMetadata = + await this.knex.table(ROLE_METADATA_TABLE); + + if (filter) { + const ownerRoles = roleMetadata.filter(role => { + return matches(role as RoleMetadata, filter); + }); + if (this.cachedDefaultRoleMeta) { + ownerRoles.push(this.cachedDefaultRoleMeta); + } + return ownerRoles; + } + + return roleMetadata; + } + + async findRoleMetadata( + roleEntityRef: string, + trx?: Knex.Transaction, + ): Promise { + const db = trx || this.knex; + return await db + .table(ROLE_METADATA_TABLE) + .where('roleEntityRef', roleEntityRef) + // roleEntityRef should be unique. + .first(); + } + + async createRoleMetadata( + metadata: RoleMetadataDao, + trx: Knex.Transaction, + ): Promise { + if (await this.findRoleMetadata(metadata.roleEntityRef, trx)) { + throw new ConflictError( + `A metadata for role ${metadata.roleEntityRef} has already been stored`, + ); + } + + const result = await trx(ROLE_METADATA_TABLE) + .insert(metadata) + .returning<[{ id: number }]>('id'); + if (result && result?.length > 0) { + return result[0].id; + } + + throw new Error( + `Failed to create the role metadata: '${JSON.stringify(metadata)}'.`, + ); + } + + async updateRoleMetadata( + newRoleMetadata: RoleMetadataDao, + oldRoleEntityRef: string, + externalTrx?: Knex.Transaction, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + const currentMetadataDao = await this.findRoleMetadata( + oldRoleEntityRef, + trx, + ); + + if (!currentMetadataDao) { + throw new NotFoundError( + `A metadata for role '${oldRoleEntityRef}' was not found`, + ); + } + + if ( + currentMetadataDao.source !== 'legacy' && + currentMetadataDao.source !== newRoleMetadata.source + ) { + throw new InputError(`The RoleMetadata.source field is 'read-only'.`); + } + + if (deepSortedEqual(currentMetadataDao, newRoleMetadata)) { + return; + } + + const result = await trx(ROLE_METADATA_TABLE) + .where('id', currentMetadataDao.id) + .update(newRoleMetadata) + .returning('id'); + + if (!externalTrx) { + await trx.commit(); + } + + if (!result || result.length === 0) { + throw new Error( + `Failed to update the role metadata '${JSON.stringify( + currentMetadataDao, + )}' with new value: '${JSON.stringify(newRoleMetadata)}'.`, + ); + } + } + + async removeRoleMetadata( + roleEntityRef: string, + externalTrx?: Knex.Transaction, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + const metadataDao = await this.findRoleMetadata(roleEntityRef, trx); + if (!metadataDao) { + throw new NotFoundError( + `A metadata for role '${roleEntityRef}' was not found`, + ); + } + + await trx(ROLE_METADATA_TABLE) + .delete() + .whereIn('id', [metadataDao.id!]); + + if (!externalTrx) { + await trx.commit(); + } + } +} + +export function daoToMetadata(dao: RoleMetadataDao): RoleMetadata { + return { + source: dao.source, + description: dao.description, + owner: dao.owner, + author: dao.author, + modifiedBy: dao.modifiedBy, + createdAt: dao.createdAt, + lastModified: dao.lastModified, + isDefault: dao.isDefault === true || dao.isDefault === 1 ? true : false, + }; +} diff --git a/plugins/rbac-backend/src/default-permissions/default-permissions.test.ts b/plugins/rbac-backend/src/default-permissions/default-permissions.test.ts new file mode 100644 index 0000000000..bf8cb9711a --- /dev/null +++ b/plugins/rbac-backend/src/default-permissions/default-permissions.test.ts @@ -0,0 +1,561 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices } from '@backstage/backend-test-utils'; + +import { EnforcerDelegate } from '../service/enforcer-delegate'; +import { + DefaultPermissionsReader, + DefaultPermissionsSyncher, + buildDefaultRoleMetadata, +} from './default-permissions'; + +const mockValidateEntityReference = jest.fn(); +jest.mock('../validation/policies-validation', () => { + const actual = jest.requireActual('../validation/policies-validation'); + return { + ...actual, + validateEntityReference: (...args: any[]) => + mockValidateEntityReference(...args), + }; +}); + +describe('DefaultPermissionsReader', () => { + beforeEach(() => { + mockValidateEntityReference.mockReturnValue(undefined); + }); + + afterEach(() => { + mockValidateEntityReference.mockClear(); + }); + + describe('readRole', () => { + it('returns undefined when no defaultPermissions config', () => { + const config = mockServices.rootConfig({ data: {} }); + const reader = new DefaultPermissionsReader(config); + expect(reader.readRole()).toBeUndefined(); + }); + + it('throws when defaultRole is not set but defaultPermissions section exists', () => { + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: {}, + }, + }, + }, + }); + const reader = new DefaultPermissionsReader(config); + expect(() => reader.readRole()).toThrow( + 'Default role is mandatory for defaultPermissions configuration. Please set a valid default role in the configuration.', + ); + }); + + it('returns role when defaultRole is set', () => { + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: { + defaultRole: 'role:default/catalog-reader', + }, + }, + }, + }, + }); + const reader = new DefaultPermissionsReader(config); + expect(reader.readRole()).toBe('role:default/catalog-reader'); + }); + + it('throws when defaultRole is empty or missing in defaultPermissions section', () => { + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: { + defaultRole: '', + }, + }, + }, + }, + }); + const reader = new DefaultPermissionsReader(config); + // Either config layer or readRole throws when defaultRole is empty/missing + expect(() => reader.readRole()).toThrow(); + }); + + it('should validate role using validateEntityReference with role=true', () => { + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: { + defaultRole: 'role:default/catalog-reader', + }, + }, + }, + }, + }); + const reader = new DefaultPermissionsReader(config); + reader.readRole(); + + expect(mockValidateEntityReference).toHaveBeenCalledWith( + 'role:default/catalog-reader', + true, + ); + }); + + it('throws when validateEntityReference returns an error', () => { + const mockError = new Error('Invalid role format'); + mockValidateEntityReference.mockReturnValue(mockError); + + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: { + defaultRole: 'invalid-role', + }, + }, + }, + }, + }); + const reader = new DefaultPermissionsReader(config); + expect(() => reader.readRole()).toThrow( + "Invalid default role 'invalid-role': Invalid role format", + ); + }); + }); + + describe('readPolicies', () => { + it('returns empty array when no defaultPermissions config', () => { + const config = mockServices.rootConfig({ data: {} }); + const reader = new DefaultPermissionsReader(config); + expect(reader.readPolicies()).toEqual([]); + }); + + it('throws when defaultRole is set but basicPermissions is missing', () => { + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: { + defaultRole: 'role:default/catalog-reader', + }, + }, + }, + }, + }); + const reader = new DefaultPermissionsReader(config); + expect(() => reader.readPolicies()).toThrow( + "The default role 'role:default/catalog-reader' requires at least one entry in permission.rbac.defaultPermissions.basicPermissions.", + ); + }); + + it('throws when defaultRole is set but basicPermissions is empty array', () => { + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: { + defaultRole: 'role:default/catalog-reader', + basicPermissions: [], + }, + }, + }, + }, + }); + const reader = new DefaultPermissionsReader(config); + expect(() => reader.readPolicies()).toThrow( + "The default role 'role:default/catalog-reader' requires at least one entry in permission.rbac.defaultPermissions.basicPermissions.", + ); + }); + + it('returns policies with default action and effect', () => { + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: { + defaultRole: 'role:default/catalog-reader', + basicPermissions: [ + { + permission: 'catalog.entity.read', + }, + ], + }, + }, + }, + }, + }); + const reader = new DefaultPermissionsReader(config); + expect(reader.readPolicies()).toEqual([ + { + entityReference: 'role:default/catalog-reader', + permission: 'catalog.entity.read', + policy: 'use', + effect: 'allow', + }, + ]); + }); + + it('returns policies with custom action (effect is always allow)', () => { + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: { + defaultRole: 'role:default/guest', + basicPermissions: [ + { + permission: 'catalog.entity.read', + action: 'read', + }, + { + permission: 'catalog.entity.delete', + action: 'delete', + }, + ], + }, + }, + }, + }, + }); + const reader = new DefaultPermissionsReader(config); + expect(reader.readPolicies()).toEqual([ + { + entityReference: 'role:default/guest', + permission: 'catalog.entity.read', + policy: 'read', + effect: 'allow', + }, + { + entityReference: 'role:default/guest', + permission: 'catalog.entity.delete', + policy: 'delete', + effect: 'allow', + }, + ]); + }); + + it('throws when action is invalid', () => { + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: { + defaultRole: 'role:default/guest', + basicPermissions: [ + { + permission: 'catalog.entity.read', + action: 'invalid-action', + }, + ], + }, + }, + }, + }, + }); + const reader = new DefaultPermissionsReader(config); + expect(() => reader.readPolicies()).toThrow( + "Invalid action 'invalid-action' for permission 'catalog.entity.read'.", + ); + }); + }); +}); + +describe('DefaultPermissionsSyncher', () => { + function createRoleMetadataStorageMock() { + return { + getDefaultRole: jest.fn().mockResolvedValue(undefined), + syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), + removeRoleMetadata: jest.fn().mockResolvedValue(undefined), + filterRoleMetadata: jest.fn(), + filterForOwnerRoleMetadata: jest.fn(), + findRoleMetadata: jest.fn(), + createRoleMetadata: jest.fn(), + updateRoleMetadata: jest.fn(), + getCachedDefaultRoleMetadata: jest.fn(), + }; + } + + function createEnforcerMock() { + return { + getFilteredPolicy: jest.fn().mockResolvedValue([]), + removePolicies: jest.fn().mockResolvedValue(undefined), + addPolicies: jest.fn().mockResolvedValue(undefined), + }; + } + + it('returns early when no roleEntityRef and no previous default role', async () => { + const config = mockServices.rootConfig({ data: {} }); + const reader = new DefaultPermissionsReader(config); + const storage = createRoleMetadataStorageMock(); + const enforcer = createEnforcerMock(); + + const syncher = new DefaultPermissionsSyncher( + storage, + enforcer as unknown as EnforcerDelegate, + reader, + ); + await syncher.sync(); + + expect(storage.getDefaultRole).toHaveBeenCalled(); + expect(storage.syncDefaultRoleMetadata).not.toHaveBeenCalled(); + expect(storage.removeRoleMetadata).not.toHaveBeenCalled(); + expect(enforcer.getFilteredPolicy).not.toHaveBeenCalled(); + }); + + it('removes previous default role when no roleEntityRef but prevDefRole exists', async () => { + const config = mockServices.rootConfig({ data: {} }); + const reader = new DefaultPermissionsReader(config); + const storage = createRoleMetadataStorageMock(); + storage.getDefaultRole.mockResolvedValue({ + roleEntityRef: 'role:default/old-default', + source: 'configuration', + modifiedBy: 'config', + }); + const enforcer = createEnforcerMock(); + enforcer.getFilteredPolicy.mockResolvedValue([ + ['role:default/old-default', 'catalog.entity.read', 'read', 'allow'], + ]); + + const syncher = new DefaultPermissionsSyncher( + storage, + enforcer as unknown as EnforcerDelegate, + reader, + ); + await syncher.sync(); + + expect(enforcer.removePolicies).toHaveBeenCalledWith([ + ['role:default/old-default', 'catalog.entity.read', 'read', 'allow'], + ]); + expect(storage.removeRoleMetadata).toHaveBeenCalledWith( + 'role:default/old-default', + ); + expect(storage.syncDefaultRoleMetadata).not.toHaveBeenCalled(); + }); + + it('syncs metadata and policies when roleEntityRef is set and no prevDefRole', async () => { + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: { + defaultRole: 'role:default/catalog-reader', + basicPermissions: [ + { permission: 'catalog.entity.read', action: 'read' }, + ], + }, + }, + }, + }, + }); + const reader = new DefaultPermissionsReader(config); + const storage = createRoleMetadataStorageMock(); + const enforcer = createEnforcerMock(); + + const syncher = new DefaultPermissionsSyncher( + storage, + enforcer as unknown as EnforcerDelegate, + reader, + ); + await syncher.sync(); + + expect(storage.syncDefaultRoleMetadata).toHaveBeenCalledWith( + 'role:default/catalog-reader', + ); + expect(enforcer.getFilteredPolicy).toHaveBeenCalled(); + expect(enforcer.addPolicies).toHaveBeenCalled(); + }); + + it('throws when prevDefRole has incompatible source', async () => { + const config = mockServices.rootConfig({ data: {} }); + const reader = new DefaultPermissionsReader(config); + const storage = createRoleMetadataStorageMock(); + storage.getDefaultRole.mockResolvedValue({ + roleEntityRef: 'role:default/csv-role', + source: 'csv-file', + modifiedBy: 'file', + }); + const enforcer = createEnforcerMock(); + + const syncher = new DefaultPermissionsSyncher( + storage, + enforcer as unknown as EnforcerDelegate, + reader, + ); + await expect(syncher.sync()).rejects.toThrow( + 'Detected previous default role with incompatible source:', + ); + }); + + describe('sync - role name changes', () => { + it('should clean up old role permissions when role name changes', async () => { + const storage = createRoleMetadataStorageMock(); + storage.getDefaultRole = jest.fn().mockResolvedValue({ + roleEntityRef: 'role:default/old-role', + source: 'configuration', + isDefault: true, + }); + + const enforcer = createEnforcerMock(); + enforcer.getFilteredPolicy = jest.fn().mockResolvedValue([ + ['role:default/old-role', 'catalog-entity', 'read', 'allow'], + ['role:default/old-role', 'catalog.entity.create', 'use', 'allow'], + ]); + + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: { + defaultRole: 'role:default/new-role', + basicPermissions: [ + { permission: 'catalog-entity', action: 'read' }, + ], + }, + }, + }, + }, + }); + + const reader = new DefaultPermissionsReader(config); + const syncher = new DefaultPermissionsSyncher( + storage as any, + enforcer as any, + reader, + ); + + await syncher.sync(); + + // Should remove old role's permissions + expect(enforcer.getFilteredPolicy).toHaveBeenCalledWith( + 0, + 'role:default/old-role', + ); + expect(enforcer.removePolicies).toHaveBeenCalledWith([ + ['role:default/old-role', 'catalog-entity', 'read', 'allow'], + ['role:default/old-role', 'catalog.entity.create', 'use', 'allow'], + ]); + + // Should sync new role metadata and policies + expect(storage.syncDefaultRoleMetadata).toHaveBeenCalledWith( + 'role:default/new-role', + ); + }); + + it('should not clean up when role name is unchanged', async () => { + const storage = createRoleMetadataStorageMock(); + storage.getDefaultRole = jest.fn().mockResolvedValue({ + roleEntityRef: 'role:default/same-role', + source: 'configuration', + isDefault: true, + }); + const enforcer = createEnforcerMock(); + enforcer.getFilteredPolicy = jest.fn().mockResolvedValue([]); + + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: { + defaultRole: 'role:default/same-role', + basicPermissions: [ + { permission: 'catalog-entity', action: 'read' }, + ], + }, + }, + }, + }, + }); + + const reader = new DefaultPermissionsReader(config); + const syncher = new DefaultPermissionsSyncher( + storage as any, + enforcer as any, + reader, + ); + + await syncher.sync(); + + // Should NOT call removePolicies for cleanup since role name hasn't changed + expect(enforcer.removePolicies).not.toHaveBeenCalled(); + }); + + it('should handle cleanup when old role has no permissions', async () => { + const storage = createRoleMetadataStorageMock(); + storage.getDefaultRole = jest.fn().mockResolvedValue({ + roleEntityRef: 'role:default/old-role', + source: 'configuration', + isDefault: true, + }); + + const enforcer = createEnforcerMock(); + enforcer.getFilteredPolicy = jest.fn().mockResolvedValue([]); + + const config = mockServices.rootConfig({ + data: { + permission: { + rbac: { + defaultPermissions: { + defaultRole: 'role:default/new-role', + basicPermissions: [ + { permission: 'catalog-entity', action: 'read' }, + ], + }, + }, + }, + }, + }); + + const reader = new DefaultPermissionsReader(config); + const syncher = new DefaultPermissionsSyncher( + storage as any, + enforcer as any, + reader, + ); + + await syncher.sync(); + + expect(enforcer.getFilteredPolicy).toHaveBeenCalledWith( + 0, + 'role:default/old-role', + ); + expect(enforcer.removePolicies).not.toHaveBeenCalled(); + }); + }); +}); + +describe('buildDefaultRoleMetadata', () => { + it('returns RoleMetadataDao with all required fields', () => { + const roleRef = 'role:default/catalog-reader'; + const meta = buildDefaultRoleMetadata(roleRef); + + expect(meta.roleEntityRef).toBe(roleRef); + expect(meta.author).toBe('application configuration'); + expect(meta.source).toBe('configuration'); + expect(meta.isDefault).toBe(true); + expect(meta.description).toBe( + 'Role with default permissions for all users and groups.', + ); + expect(meta.modifiedBy).toBe('application configuration'); + expect(meta.lastModified).toBeDefined(); + expect(meta.createdAt).toBeDefined(); + expect(typeof meta.lastModified).toBe('string'); + expect(typeof meta.createdAt).toBe('string'); + }); +}); diff --git a/plugins/rbac-backend/src/default-permissions/default-permissions.ts b/plugins/rbac-backend/src/default-permissions/default-permissions.ts new file mode 100644 index 0000000000..832a4cf466 --- /dev/null +++ b/plugins/rbac-backend/src/default-permissions/default-permissions.ts @@ -0,0 +1,181 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Config } from '@backstage/config'; + +import { + RoleBasedPolicy, + isValidPermissionAction, +} from '@backstage-community/plugin-rbac-common'; + +import type { + RoleMetadataDao, + RoleMetadataStorage, +} from '../database/role-metadata'; +import type { EnforcerDelegate } from '../service/enforcer-delegate'; +import { syncRolePolicies } from '../helper'; +import { ADMIN_ROLE_AUTHOR } from '../admin-permissions/admin-creation'; +import { + validateSource, + validateEntityReference, +} from '../validation/policies-validation'; + +const DEFAULT_ROLE_DESCRIPTION = + 'Role with default permissions for all users and groups.'; + +const DEFAULT_PERMISSIONS_CONF = 'permission.rbac.defaultPermissions'; + +export class DefaultPermissionsReader { + constructor(private readonly config: Config) {} + + readRole(): string | undefined { + const defPermissionsConfig = this.config.getOptionalConfig( + DEFAULT_PERMISSIONS_CONF, + ); + let role: string | undefined; + + if (defPermissionsConfig) { + role = defPermissionsConfig.getOptionalString('defaultRole'); + + if (!role) { + throw new Error( + 'Default role is mandatory for defaultPermissions configuration. Please set a valid default role in the configuration.', + ); + } + + const validationError = validateEntityReference(role, true); + if (validationError) { + throw new Error( + `Invalid default role '${role}': ${validationError.message}`, + ); + } + } + + return role; + } + + readPolicies(): RoleBasedPolicy[] { + const defPermissionsConfig = this.config.getOptionalConfig( + DEFAULT_PERMISSIONS_CONF, + ); + const role = this.readRole(); + + let policies: RoleBasedPolicy[] = []; + if (defPermissionsConfig) { + const basicPermissions = + defPermissionsConfig.getOptionalConfigArray('basicPermissions'); + if (!basicPermissions || basicPermissions.length === 0) { + throw new Error( + `The default role '${role}' requires at least one entry in permission.rbac.defaultPermissions.basicPermissions.`, + ); + } + + policies = basicPermissions.map(permission => { + const permissionName = permission.getString('permission'); + const action = permission.getOptionalString('action'); + + if (action && !isValidPermissionAction(action)) { + throw new Error( + `Invalid action '${action}' for permission '${permissionName}'.`, + ); + } + + return { + entityReference: role, + permission: permissionName, + policy: action || 'use', + effect: 'allow', + }; + }); + } + + return policies; + } +} + +export class DefaultPermissionsSyncher { + constructor( + private readonly roleMetadataStorage: RoleMetadataStorage, + private readonly enforcer: EnforcerDelegate, + private readonly defaultPermissionsReader: DefaultPermissionsReader, + ) {} + + public async sync() { + const policies = this.defaultPermissionsReader.readPolicies(); + const roleEntityRef = this.defaultPermissionsReader.readRole(); + + const prevDefRole = await this.roleMetadataStorage.getDefaultRole(); + + const err = await validateSource('configuration', prevDefRole); + if (err) { + throw new Error( + `Detected previous default role with incompatible source: ${err.message}`, + ); + } + + if (!roleEntityRef) { + if (prevDefRole) { + const pls = await this.enforcer.getFilteredPolicy( + 0, + prevDefRole.roleEntityRef, + ); + if (pls.length > 0) { + await this.enforcer.removePolicies(pls); + } + await this.roleMetadataStorage.removeRoleMetadata( + prevDefRole.roleEntityRef, + ); + } + + return; + } + + // Clean up orphaned permissions if role name changed + if (prevDefRole && prevDefRole.roleEntityRef !== roleEntityRef) { + const oldPolicies = await this.enforcer.getFilteredPolicy( + 0, + prevDefRole.roleEntityRef, + ); + if (oldPolicies.length > 0) { + await this.enforcer.removePolicies(oldPolicies); + } + } + + const casbinPolicies: string[][] = policies.map(p => [ + p.entityReference!, + p.permission!, + p.policy!, + p.effect!, + ]); + + await this.roleMetadataStorage.syncDefaultRoleMetadata(roleEntityRef); + await syncRolePolicies(this.enforcer, roleEntityRef, casbinPolicies); + } +} + +export function buildDefaultRoleMetadata( + defaultRoleRef: string, +): RoleMetadataDao { + return { + roleEntityRef: defaultRoleRef, + author: ADMIN_ROLE_AUTHOR, + source: 'configuration', + isDefault: true, + description: DEFAULT_ROLE_DESCRIPTION, + modifiedBy: ADMIN_ROLE_AUTHOR, + lastModified: new Date().toUTCString(), + createdAt: new Date().toUTCString(), + }; +} diff --git a/plugins/rbac-backend/src/file-permissions/csv-file-watcher.test.ts b/plugins/rbac-backend/src/file-permissions/csv-file-watcher.test.ts new file mode 100644 index 0000000000..e3a0780135 --- /dev/null +++ b/plugins/rbac-backend/src/file-permissions/csv-file-watcher.test.ts @@ -0,0 +1,845 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { LoggerService } from '@backstage/backend-plugin-api'; +import { mockServices } from '@backstage/backend-test-utils'; +import type { Config } from '@backstage/config'; + +import { + Adapter, + Enforcer, + Model, + newEnforcer, + newModelFromString, +} from 'casbin'; +import * as Knex from 'knex'; +import { MockClient } from 'knex-mock-client'; + +import type { Source } from '@backstage-community/plugin-rbac-common'; + +import { resolve } from 'path'; + +import { ADMIN_ROLE_AUTHOR } from '../admin-permissions/admin-creation'; +import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; +import { + RoleMetadataDao, + RoleMetadataStorage, +} from '../database/role-metadata'; +import { BackstageRoleManager } from '../role-manager/role-manager'; +import { DefaultPermissionsReader } from '../default-permissions/default-permissions'; +import { EnforcerDelegate } from '../service/enforcer-delegate'; +import { MODEL } from '../service/permission-model'; +import { CSVFileWatcher } from './csv-file-watcher'; +import { mockAuditorService } from '../../__fixtures__/mock-utils'; +import { conditionalStorageMock } from '../../__fixtures__/mock-utils'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; + +const legacyPermission = [ + 'role:default/legacy', + 'catalog-entity', + 'update', + 'allow', +]; + +const legacyRole = ['user:default/guest', 'role:default/legacy']; + +const restPermission = [ + 'role:default/rest', + 'catalog-entity', + 'update', + 'allow', +]; + +const restRole = ['user:default/guest', 'role:default/rest']; + +const configPermission = [ + 'role:default/config', + 'catalog-entity', + 'update', + 'allow', +]; + +const configRole = ['user:default/guest', 'role:default/config']; + +const mockLoggerService = mockServices.logger.mock(); + +const modifiedBy = 'user:default/some-admin'; + +const legacyRoleMetadata: RoleMetadataDao = { + roleEntityRef: legacyPermission[0], + source: 'legacy', + modifiedBy, +}; + +const roleMetadataStorageMock: RoleMetadataStorage = { + filterRoleMetadata: jest.fn().mockImplementation(() => []), + filterForOwnerRoleMetadata: jest.fn().mockImplementation(), + findRoleMetadata: jest + .fn() + .mockImplementation( + async ( + roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + if (roleEntityRef === legacyPermission[0]) { + return legacyRoleMetadata; + } else if (roleEntityRef === restPermission[0]) { + return { + roleEntityRef: restPermission[0], + source: 'rest', + modifiedBy, + }; + } + if (roleEntityRef === configPermission[0]) { + return { + roleEntityRef: configPermission[0], + source: 'configuration', + modifiedBy, + }; + } + return { roleEntityRef: '', source: 'csv-file', modifiedBy }; + }, + ), + createRoleMetadata: jest.fn().mockImplementation(), + updateRoleMetadata: jest.fn().mockImplementation(), + removeRoleMetadata: jest.fn().mockImplementation(), + getCachedDefaultRoleMetadata: jest.fn().mockImplementation(() => undefined), + getDefaultRole: jest.fn().mockResolvedValue(undefined), + syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), +}; + +const mockClientKnex = Knex.knex({ client: MockClient }); + +const mockAuthService = mockServices.auth(); + +const currentPermissionPolicies = [ + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ['role:default/legacy', 'catalog-entity', 'update', 'allow'], + ['role:default/catalog-writer', 'catalog-entity', 'read', 'allow'], + ['role:default/catalog-writer', 'catalog.entity.create', 'use', 'allow'], + ['role:default/catalog-deleter', 'catalog-entity', 'delete', 'deny'], + ['role:default/CATALOG-USER', 'catalog-entity', 'read', 'allow'], + ['role:default/known_role', 'test.resource.deny', 'use', 'allow'], +]; + +const currentRoles = [ + ['user:default/guest', 'role:default/catalog-writer'], + ['user:default/guest', 'role:default/legacy'], + ['user:default/guest', 'role:default/catalog-reader'], + ['user:default/guest', 'role:default/catalog-deleter'], + ['user:default/known_user', 'role:default/known_role'], + ['user:default/tom', 'role:default/CATALOG-USER'], + ['group:default/reader-group', 'role:default/CATALOG-USER'], +]; + +describe('CSVFileWatcher', () => { + let enforcerDelegate: EnforcerDelegate; + let csvFileName: string; + + beforeEach(async () => { + csvFileName = resolve( + __dirname, + '../../__fixtures__/data/valid-csv/rbac-policy.csv', + ); + + const config = newConfig(); + + const adapter = await new CasbinDBAdapterFactory( + config, + mockClientKnex, + ).createAdapter(); + + const stringModel = newModelFromString(MODEL); + const enf = await createEnforcer(stringModel, adapter, mockLoggerService); + + const knex = Knex.knex({ client: MockClient }); + + enforcerDelegate = new EnforcerDelegate( + enf, + mockAuditorService, + conditionalStorageMock, + roleMetadataStorageMock, + knex, + ); + + (roleMetadataStorageMock.updateRoleMetadata as jest.Mock).mockClear(); + }); + + afterEach(() => { + (mockLoggerService.warn as jest.Mock).mockReset(); + (roleMetadataStorageMock.removeRoleMetadata as jest.Mock).mockReset(); + }); + + function createCSVFileWatcher(fileName?: string): CSVFileWatcher { + return new CSVFileWatcher( + fileName, + false, + mockLoggerService, + enforcerDelegate, + roleMetadataStorageMock, + mockAuditorService, + ); + } + + describe('parse', () => { + test('should parse users and groups in lowercase', async () => { + csvFileName = resolve( + __dirname, + '../../__fixtures__/data/valid-csv/uppercase-policy.csv', + ); + + const csvFileWatcher = createCSVFileWatcher(csvFileName); + const content = await csvFileWatcher.parse(); + const expected = [ + ['p', 'role:default/CATALOG-USER', 'catalog-entity', 'read', 'allow'], + ['p', 'role:default/known_role', 'test.resource.deny', 'use', 'allow'], + ['g', 'user:default/known_user', 'role:default/known_role'], + ['g', 'user:default/tom', 'role:default/CATALOG-USER'], + ['g', 'group:default/reader-group', 'role:default/CATALOG-USER'], + ['g', 'group:default/reader-group', 'role:default/known_role'], + ]; + expect(content).toStrictEqual(expected); + }); + }); + + describe('initialize', () => { + it('should be able to add permission policies during initialization', async () => { + const csvFileWatcher = createCSVFileWatcher(csvFileName); + await csvFileWatcher.initialize(); + + const enfPolicies = await enforcerDelegate.getPolicy(); + + expect(enfPolicies).toStrictEqual(currentPermissionPolicies); + }); + + it('should be able to add roles during initialization', async () => { + const csvFileWatcher = createCSVFileWatcher(csvFileName); + await csvFileWatcher.initialize(); + + const enfRoles = await enforcerDelegate.getGroupingPolicy(); + + expect(enfRoles).toStrictEqual(currentRoles); + }); + + it('should be able to update legacy role metadata during initialization', async () => { + const permissionPolicies = [ + ['role:default/legacy', 'catalog-entity', 'update', 'allow'], + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ['role:default/catalog-writer', 'catalog-entity', 'read', 'allow'], + [ + 'role:default/catalog-writer', + 'catalog.entity.create', + 'use', + 'allow', + ], + ['role:default/catalog-deleter', 'catalog-entity', 'delete', 'deny'], + ['role:default/CATALOG-USER', 'catalog-entity', 'read', 'allow'], + ['role:default/known_role', 'test.resource.deny', 'use', 'allow'], + ]; + + await enforcerDelegate.addPolicy(legacyPermission); + await enforcerDelegate.addGroupingPolicies( + [['user:default/guest', 'role:default/legacy']], + legacyRoleMetadata!, + ); + roleMetadataStorageMock.filterRoleMetadata = jest + .fn() + .mockImplementation((source: Source) => { + if (source === 'legacy') { + return [legacyRoleMetadata]; + } + return []; + }); + (roleMetadataStorageMock.updateRoleMetadata as jest.Mock).mockReset(); + + const csvFileWatcher = createCSVFileWatcher(csvFileName); + await csvFileWatcher.initialize(); + + const enfPolicies = await enforcerDelegate.getPolicy(); + + const legacyMetadatas = ( + roleMetadataStorageMock.updateRoleMetadata as jest.Mock + ).mock.calls + .map(call => call[0]) + .filter(metadata => metadata.roleEntityRef === 'role:default/legacy'); + expect(legacyMetadatas.length).toEqual(1); + // legacy source should be updated from legacy to csv-file + expect(legacyMetadatas[0].source).toEqual('csv-file'); + expect(enfPolicies).toStrictEqual(permissionPolicies); + }); + + it('should be able to update legacy roles during initialization', async () => { + const roles = [ + ['user:default/guest', 'role:default/legacy'], + ['user:default/guest', 'role:default/catalog-writer'], + ['user:default/guest', 'role:default/catalog-reader'], + ['user:default/guest', 'role:default/catalog-deleter'], + ['user:default/known_user', 'role:default/known_role'], + ['user:default/tom', 'role:default/CATALOG-USER'], + ['group:default/reader-group', 'role:default/CATALOG-USER'], + ]; + + await enforcerDelegate.addGroupingPolicy(legacyRole, legacyRoleMetadata); + roleMetadataStorageMock.filterRoleMetadata = jest + .fn() + .mockImplementation((source: Source) => { + if (source === 'legacy') { + return [legacyRoleMetadata]; + } + return []; + }); + (roleMetadataStorageMock.updateRoleMetadata as jest.Mock).mockReset(); + + const csvFileWatcher = createCSVFileWatcher(csvFileName); + await csvFileWatcher.initialize(); + + const enfPolicies = await enforcerDelegate.getGroupingPolicy(); + + const legacyMetadatas = ( + roleMetadataStorageMock.updateRoleMetadata as jest.Mock + ).mock.calls + .map(call => call[0]) + .filter(metadata => metadata.roleEntityRef === 'role:default/legacy'); + expect(legacyMetadatas.length).toEqual(1); + // legacy source should be updated from legacy to csv-file + expect(legacyMetadatas[0].source).toEqual('csv-file'); + + expect(enfPolicies).toStrictEqual(roles); + }); + + // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` + it('should be able to add `policy-entity, create` permissions but log a warning roles during creation', async () => { + csvFileName = resolve( + __dirname, + '../../__fixtures__/data/invalid-csv/deprecated-policy.csv', + ); + const csvFileWatcher = createCSVFileWatcher(csvFileName); + + const deprecatedPolicy = [ + 'role:default/some_role', + 'policy-entity', + 'create', + 'allow', + ]; + + await csvFileWatcher.initialize(); + + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 1, + `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${deprecatedPolicy} to use 'policy.entity.create' instead of 'policy-entity' from source csv-file`, + ); + + const enfPolicies = await enforcerDelegate.getPolicy(); + + expect(enfPolicies).toStrictEqual([deprecatedPolicy]); + }); + + // Failing tests + it('should fail to add duplicate policies', async () => { + csvFileName = resolve( + __dirname, + '../../__fixtures__/data/invalid-csv/duplicate-policy.csv', + ); + const csvFileWatcher = createCSVFileWatcher(csvFileName); + + const duplicatePolicy = [ + 'role:default/catalog-writer', + 'catalog.entity.create', + 'use', + 'allow', + ]; + const duplicateRole = [ + 'user:default/guest', + 'role:default/catalog-deleter', + ]; + const duplicateGroupRole = [ + 'group:default/reader-group', // changed to lowercase + 'role:default/CATALOG-USER', + ]; + + const duplicatePolicyWithDifferentEffect = [ + 'role:default/duplication-effect', + 'catalog-entity', + 'update', + ]; + + await csvFileWatcher.initialize(); + + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 1, + `Duplicate policy: ${duplicatePolicy} found in the file ${csvFileName}`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 2, + `Duplicate policy: ${duplicatePolicy} found in the file ${csvFileName}`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 3, + `Duplicate policy: ${duplicatePolicyWithDifferentEffect[0]}, ${duplicatePolicyWithDifferentEffect[1]}, ${duplicatePolicyWithDifferentEffect[2]} with different effect found in the file ${csvFileName}`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 4, + `Duplicate policy: ${duplicatePolicyWithDifferentEffect[0]}, ${duplicatePolicyWithDifferentEffect[1]}, ${duplicatePolicyWithDifferentEffect[2]} with different effect found in the file ${csvFileName}`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 5, + `Duplicate role: ${duplicateRole} found in the file ${csvFileName}`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 6, + `Duplicate role: ${duplicateRole} found in the file ${csvFileName}`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 7, + `Duplicate role: ${duplicateGroupRole} found in the file ${csvFileName}`, + ); + }); + + it('should fail to add policies with errors', async () => { + csvFileName = resolve( + __dirname, + '../../__fixtures__/data/invalid-csv/error-policy.csv', + ); + const csvFileWatcher = createCSVFileWatcher(csvFileName); + + const entityRoleError = ['user:default/', 'role:default/catalog-deleter']; + const roleError = ['user:default/test', 'role:default/']; + + const roleErrorPolicy = [ + 'role:default/', + 'catalog.entity.create', + 'use', + 'allow', + ]; + const allowErrorPolicy = [ + 'role:default/test', + 'catalog.entity.create', + 'delete', + 'temp', + ]; + + await csvFileWatcher.initialize(); + + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 1, + `Failed to validate policy from file ${csvFileName}. Cause: Entity reference "${roleErrorPolicy[0]}" was not on the form [:][/]`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 2, + `Failed to validate policy from file ${csvFileName}. Cause: 'effect' has invalid value: '${allowErrorPolicy[3]}'. It should be: 'allow' or 'deny'`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 3, + `Unable to add policy ${restPermission} from file ${csvFileName}. Cause: source does not match originating role ${restPermission[0]}, consider making changes to the 'REST'`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 4, + `Unable to add policy ${configPermission} from file ${csvFileName}. Cause: source does not match originating role ${configPermission[0]}, consider making changes to the 'CONFIGURATION'`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 5, + `Failed to validate group policy ${entityRoleError}. Cause: Entity reference "${entityRoleError[0]}" was not on the form [:][/], error originates from file ${csvFileName}`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 6, + `Failed to validate group policy ${roleError}. Cause: Entity reference "${roleError[1]}" was not on the form [:][/], error originates from file ${csvFileName}`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 7, + `Unable to validate role ${restRole}. Cause: source does not match originating role ${restRole[1]}, consider making changes to the 'REST', error originates from file ${csvFileName}`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 8, + `Unable to validate role ${configRole}. Cause: source does not match originating role ${configRole[1]}, consider making changes to the 'CONFIGURATION', error originates from file ${csvFileName}`, + ); + }); + }); + + describe('onChange', () => { + let csvFileWatcher: CSVFileWatcher; + + beforeEach(async () => { + csvFileName = resolve( + __dirname, + '../../__fixtures__/data/valid-csv/simple-policy.csv', + ); + csvFileWatcher = createCSVFileWatcher(csvFileName); + await csvFileWatcher.initialize(); + }); + + afterEach(() => { + (csvFileWatcher.parse as jest.Mock).mockReset(); + }); + + it('should add new permission policies on change', async () => { + const addContents = [ + ['g', 'user:default/guest', 'role:default/catalog-writer'], + [ + 'p', + 'role:default/catalog-writer', + 'catalog-entity', + 'update', + 'allow', + ], + [ + 'p', + 'role:default/catalog-writer', + 'catalog-entity', + 'delete', + 'allow', + ], + ]; + + const policies = [ + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ['role:default/catalog-writer', 'catalog-entity', 'delete', 'allow'], + ]; + + csvFileWatcher.parse = jest.fn().mockImplementation(() => { + return addContents; + }); + + await csvFileWatcher.onChange(); + + const enfPolicies = await enforcerDelegate.getPolicy(); + + expect(enfPolicies).toStrictEqual(policies); + }); + + // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` + it('should be able to add `policy-entity, create` permissions but log a warning roles on change', async () => { + const addContents = [ + ['p', 'role:default/some_role', 'policy-entity', 'create', 'allow'], + ]; + + csvFileWatcher.parse = jest.fn().mockImplementation(() => { + return addContents; + }); + + await csvFileWatcher.onChange(); + + const deprecatedPolicy = [ + 'role:default/some_role', + 'policy-entity', + 'create', + 'allow', + ]; + + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 1, + `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${deprecatedPolicy} to use 'policy.entity.create' instead of 'policy-entity' from source csv-file`, + ); + + const enfPolicies = await enforcerDelegate.getPolicy(); + + expect(enfPolicies).toStrictEqual([deprecatedPolicy]); + }); + + it('should add new roles on change', async () => { + const addContents = [ + ['g', 'user:default/guest', 'role:default/catalog-writer'], + [ + 'p', + 'role:default/catalog-writer', + 'catalog-entity', + 'update', + 'allow', + ], + ['g', 'user:default/test', 'role:default/catalog-writer'], + ]; + + const roles = [ + ['user:default/guest', 'role:default/catalog-writer'], + ['user:default/test', 'role:default/catalog-writer'], + ]; + + csvFileWatcher.parse = jest.fn().mockImplementation(() => { + return addContents; + }); + + await csvFileWatcher.onChange(); + + const enfRoles = await enforcerDelegate.getGroupingPolicy(); + + expect(enfRoles).toStrictEqual(roles); + }); + + it('should fail to add new permission policies on change if there is a mismatch in source', async () => { + const addContents = [ + ['g', 'user:default/guest', 'role:default/catalog-writer'], + [ + 'p', + 'role:default/catalog-writer', + 'catalog-entity', + 'update', + 'allow', + ], + ['p', 'role:default/config', 'catalog-entity', 'update', 'allow'], + ['p', 'role:default/rest', 'catalog-entity', 'update', 'allow'], + ]; + + const policies = [ + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ['role:default/config', 'catalog-entity', 'update', 'allow'], + ['role:default/rest', 'catalog-entity', 'update', 'allow'], + ]; + + await enforcerDelegate.addPolicy(configPermission); + await enforcerDelegate.addPolicy(restPermission); + + csvFileWatcher.parse = jest.fn().mockImplementation(() => { + return addContents; + }); + + await csvFileWatcher.onChange(); + + const enfPolicies = await enforcerDelegate.getPolicy(); + + expect(enfPolicies).toStrictEqual(policies); + + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 1, + `Unable to add policy ${configPermission} from file ${csvFileName}. Cause: source does not match originating role ${configPermission[0]}, consider making changes to the 'CONFIGURATION'`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 2, + `Unable to add policy ${restPermission} from file ${csvFileName}. Cause: source does not match originating role ${restPermission[0]}, consider making changes to the 'REST'`, + ); + }); + + it('should fail to add new roles on change if there is a mismatch in source', async () => { + const addContents = [ + ['g', 'user:default/guest', 'role:default/catalog-writer'], + ['g', 'user:default/guest', 'role:default/rest'], + ['g', 'user:default/guest', 'role:default/config'], + ]; + + const roles = [ + ['user:default/guest', 'role:default/catalog-writer'], + ['user:default/guest', 'role:default/config'], + ['user:default/guest', 'role:default/rest'], + ]; + + await enforcerDelegate.addGroupingPolicy(configRole, { + roleEntityRef: configRole[1], + source: 'configuration', + modifiedBy: ADMIN_ROLE_AUTHOR, + }); + await enforcerDelegate.addGroupingPolicy(restRole, { + roleEntityRef: restRole[1], + source: 'rest', + modifiedBy, + }); + + csvFileWatcher.parse = jest.fn().mockImplementation(() => { + return addContents; + }); + + await csvFileWatcher.onChange(); + + const enfRoles = await enforcerDelegate.getGroupingPolicy(); + + expect(enfRoles).toStrictEqual(roles); + + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 1, + `Unable to validate role ${restRole}. Cause: source does not match originating role ${restRole[1]}, consider making changes to the 'REST', error originates from file ${csvFileName}`, + ); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 2, + `Unable to validate role ${configRole}. Cause: source does not match originating role ${configRole[1]}, consider making changes to the 'CONFIGURATION', error originates from file ${csvFileName}`, + ); + }); + + it('should remove old permission policies on change', async () => { + const addContents = [ + ['g', 'user:default/guest', 'role:default/catalog-writer'], + ]; + + csvFileWatcher.parse = jest.fn().mockImplementation(() => { + return addContents; + }); + + await csvFileWatcher.onChange(); + + const enfPolicies = await enforcerDelegate.getPolicy(); + + expect(enfPolicies).toStrictEqual([]); + }); + + it('should remove old roles on change', async () => { + const addContents = [ + [ + 'p', + 'role:default/catalog-writer', + 'catalog-entity', + 'update', + 'allow', + ], + ]; + + csvFileWatcher.parse = jest.fn().mockImplementation(() => { + return addContents; + }); + + await csvFileWatcher.onChange(); + + const enfRoles = await enforcerDelegate.getGroupingPolicy(); + + expect(enfRoles).toStrictEqual([]); + }); + + it('should do nothing if there is no change', async () => { + const addContents = [ + ['g', 'user:default/guest', 'role:default/catalog-writer'], + [ + 'p', + 'role:default/catalog-writer', + 'catalog-entity', + 'update', + 'allow', + ], + ]; + + csvFileWatcher.parse = jest.fn().mockImplementation(() => { + return addContents; + }); + + await csvFileWatcher.onChange(); + + const enfRoles = await enforcerDelegate.getGroupingPolicy(); + const enfPolicies = await enforcerDelegate.getPolicy(); + + expect(enfRoles).toStrictEqual([ + ['user:default/guest', 'role:default/catalog-writer'], + ]); + expect(enfPolicies).toStrictEqual([ + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ]); + }); + }); + + describe('cleanUpRolesAndPolicies', () => { + let csvFileWatcher: CSVFileWatcher; + + const roleMetadata: RoleMetadataDao = { + roleEntityRef: 'role:default/dev', + source: 'csv-file', + modifiedBy, + }; + + beforeEach(async () => { + csvFileWatcher = createCSVFileWatcher(); + await csvFileWatcher.initialize(); + }); + + it('should remove all roles and policies', async () => { + const permissionPolicies = [ + ['role:default/dev', 'catalog-entity', 'update', 'allow'], + ['role:default/dev', 'catalog-entity', 'allow', 'allow'], + ]; + + await enforcerDelegate.addPolicies(permissionPolicies); + await enforcerDelegate.addGroupingPolicies( + [['user:default/guest', 'role:default/dev']], + roleMetadata, + ); + roleMetadataStorageMock.filterRoleMetadata = jest + .fn() + .mockImplementation((source: Source) => { + if (source === 'csv-file') { + return [roleMetadata]; + } + return []; + }); + + await csvFileWatcher.cleanUpRolesAndPolicies(); + const enfPolicies = await enforcerDelegate.getPolicy(); + + expect(enfPolicies).toStrictEqual([]); + + const enfRoles = await enforcerDelegate.getGroupingPolicy(); + expect(enfRoles).toStrictEqual([]); + + expect( + roleMetadataStorageMock.removeRoleMetadata, + ).toHaveBeenNthCalledWith( + 1, + roleMetadata.roleEntityRef, + expect.anything(), + ); + }); + }); +}); + +async function createEnforcer( + theModel: Model, + adapter: Adapter, + logger: LoggerService, +): Promise { + const catalogDBClient = Knex.knex({ client: MockClient }); + const rbacDBClient = Knex.knex({ client: MockClient }); + const enf = await newEnforcer(theModel, adapter); + + const config = newConfig(); + + const rm = new BackstageRoleManager( + catalogServiceMock.mock(), + logger, + catalogDBClient, + rbacDBClient, + config, + mockAuthService, + new DefaultPermissionsReader(config), + ); + enf.setRoleManager(rm); + enf.enableAutoBuildRoleLinks(false); + await enf.buildRoleLinks(); + + return enf; +} + +function newConfig( + users?: Array<{ name: string }>, + superUsers?: Array<{ name: string }>, +): Config { + const testUsers = [ + { + name: 'user:default/guest', + }, + { + name: 'group:default/guests', + }, + ]; + + return mockServices.rootConfig({ + data: { + permission: { + rbac: { + admin: { + users: users || testUsers, + superUsers: superUsers, + }, + }, + }, + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }, + }); +} diff --git a/plugins/rbac-backend/src/file-permissions/csv-file-watcher.ts b/plugins/rbac-backend/src/file-permissions/csv-file-watcher.ts new file mode 100644 index 0000000000..3bbda9d743 --- /dev/null +++ b/plugins/rbac-backend/src/file-permissions/csv-file-watcher.ts @@ -0,0 +1,622 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { + AuditorService, + LoggerService, +} from '@backstage/backend-plugin-api'; + +import { Enforcer, newEnforcer, newModelFromString } from 'casbin'; +import { parse } from 'csv-parse/sync'; +import { difference } from 'lodash'; + +import { ActionType, PermissionEvents, RoleEvents } from '../auditor/auditor'; +import { + RoleMetadataDao, + RoleMetadataStorage, +} from '../database/role-metadata'; +import { + mergeRoleMetadata, + metadataStringToPolicy, + policyToString, + transformArrayToPolicy, + transformPolicyGroupToLowercase, +} from '../helper'; +import { EnforcerDelegate } from '../service/enforcer-delegate'; +import { MODEL } from '../service/permission-model'; +import { + checkForDuplicateGroupPolicies, + checkForDuplicatePolicies, + validateGroupingPolicy, + validatePolicy, + validateSource, +} from '../validation/policies-validation'; +import { AbstractFileWatcher } from './file-watcher'; +import { LowercaseFileAdapter } from './lowercase-file-adapter'; + +export const CSV_PERMISSION_POLICY_FILE_AUTHOR = 'csv permission policy file'; + +type CSVFilePolicies = { + addedPolicies: string[][]; + removedPolicies: string[][]; + addedGroupPolicies: Map; + removedGroupPolicies: Map; +}; + +export class CSVFileWatcher extends AbstractFileWatcher { + private currentContent: string[][]; + private csvFilePolicies: CSVFilePolicies; + + constructor( + filePath: string | undefined, + allowReload: boolean, + logger: LoggerService, + private readonly enforcer: EnforcerDelegate, + private readonly roleMetadataStorage: RoleMetadataStorage, + private readonly auditor: AuditorService, + ) { + super(filePath, allowReload, logger); + this.currentContent = []; + this.csvFilePolicies = { + addedPolicies: [], + removedPolicies: [], + addedGroupPolicies: new Map(), + removedGroupPolicies: new Map(), + }; + } + + /** + * parse is used to parse the current contents of the CSV file. + * @returns The CSV file parsed into a string[][]. + */ + parse(): string[][] { + const content = this.getCurrentContents(); + const data = parse(content, { + skip_empty_lines: true, + relax_column_count: true, + trim: true, + }); + + for (const policy of data) { + transformPolicyGroupToLowercase(policy); + } + + return data; + } + + /** + * initialize will initialize the CSV file by loading all of the permission policies and roles into + * the enforcer. + * First, we will remove all roles and permission policies if they do not exist in the temporary file enforcer. + * Next, we will add all roles and permission polices if they are new to the CSV file + * Finally, we will set the file to be watched if allow reload is set + * @param csvFileName The name of the csvFile + * @param allowReload Whether or not we will allow reloads of the CSV file + */ + async initialize(): Promise { + if (!this.filePath) { + return; + } + let content: string[][] = []; + // If the file is set load the file contents + content = this.parse(); + + const tempEnforcer = await newEnforcer( + newModelFromString(MODEL), + new LowercaseFileAdapter(this.filePath), + ); + + // Check for any old policies that will need to be removed + await this.filterPoliciesAndRoles( + this.enforcer, + tempEnforcer, + this.csvFilePolicies.removedPolicies, + this.csvFilePolicies.removedGroupPolicies, + true, + ); + + await this.filterPoliciesAndRoles( + tempEnforcer, + this.enforcer, + this.csvFilePolicies.addedPolicies, + this.csvFilePolicies.addedGroupPolicies, + ); + + await this.migrateLegacyMetadata(tempEnforcer); + + // We pass current here because this is during initialization and it has not changed yet + await this.updatePolicies(content); + + if (this.allowReload) { + this.watchFile(); + } + } + + // Check for policies that might need to be updated + // This will involve update "legacy" source in the role metadata if it exist in both the + // temp enforcer (csv file) and a role metadata storage. + // We will update role metadata with the new source "csv-file" + private async migrateLegacyMetadata(tempEnforcer: Enforcer) { + let legacyRolesMetadata = + await this.roleMetadataStorage.filterRoleMetadata('legacy'); + const legacyRoles = legacyRolesMetadata.map(meta => meta.roleEntityRef); + if (legacyRoles.length > 0) { + const legacyGroupPolicies = await tempEnforcer.getFilteredGroupingPolicy( + 1, + ...legacyRoles, + ); + const legacyPolicies = await tempEnforcer.getFilteredPolicy( + 0, + ...legacyRoles, + ); + const legacyRolesFromFile = new Set([ + ...legacyGroupPolicies.map(gp => gp[1]), + ...legacyPolicies.map(p => p[0]), + ]); + legacyRolesMetadata = legacyRolesMetadata.filter(meta => + legacyRolesFromFile.has(meta.roleEntityRef), + ); + for (const legacyRoleMeta of legacyRolesMetadata) { + const nonLegacyRole = mergeRoleMetadata(legacyRoleMeta, { + modifiedBy: CSV_PERMISSION_POLICY_FILE_AUTHOR, + source: 'csv-file', + roleEntityRef: legacyRoleMeta.roleEntityRef, + }); + await this.roleMetadataStorage.updateRoleMetadata( + nonLegacyRole, + legacyRoleMeta.roleEntityRef, + ); + } + } + } + + /** + * onChange is called whenever there is a change to the CSV file. + * It will parse the current and new contents of the CSV file and process the roles and permission policies present. + * Afterwards, it will find the difference between the current and new contents of the CSV file + * and sort them into added / removed, permission policies / roles. + * It will finally call updatePolicies with the new content. + */ + async onChange(): Promise { + const newContent = this.parse(); + + const tempEnforcer = await newEnforcer( + newModelFromString(MODEL), + new LowercaseFileAdapter(this.filePath!), + ); + + const currentFlatContent = this.currentContent.flatMap(data => { + return policyToString(data); + }); + const newFlatContent = newContent.flatMap(data => { + return policyToString(data); + }); + + await this.findFileContentDiff( + currentFlatContent, + newFlatContent, + tempEnforcer, + ); + + await this.updatePolicies(newContent); + } + + /** + * updatePolicies is used to update all of the permission policies and roles within a CSV file. + * It will check the number of added and removed permissions policies and roles and call the appropriate + * methods for these. + * It will also update the current contents of the CSV file to the most recent + * @param newContent The new content present in the CSV file + */ + private async updatePolicies(newContent: string[][]): Promise { + this.currentContent = newContent; + + if (this.csvFilePolicies.addedPolicies.length > 0) + await this.addPermissionPolicies(); + if (this.csvFilePolicies.removedPolicies.length > 0) + await this.removePermissionPolicies(); + if (this.csvFilePolicies.addedGroupPolicies.size > 0) await this.addRoles(); + if (this.csvFilePolicies.removedGroupPolicies.size > 0) + await this.removeRoles(); + } + + /** + * addPermissionPolicies will add the new permission policies that are present in the CSV file. + */ + private async addPermissionPolicies(): Promise { + const auditorEvent = await this.auditor.createEvent({ + eventId: PermissionEvents.POLICY_WRITE, + severityLevel: 'medium', + meta: { actionType: ActionType.CREATE, source: 'csv-file' }, + }); + + try { + await this.enforcer.addPolicies(this.csvFilePolicies.addedPolicies); + await auditorEvent.success({ + meta: { policies: this.csvFilePolicies.addedPolicies }, + }); + } catch (e) { + await auditorEvent.fail({ + meta: { policies: this.csvFilePolicies.addedPolicies }, + error: e, + }); + } + + this.csvFilePolicies.addedPolicies = []; + } + + /** + * removePermissionPolicies will remove the permission policies that are no longer present in the CSV file. + */ + private async removePermissionPolicies(): Promise { + const auditorEvent = await this.auditor.createEvent({ + eventId: PermissionEvents.POLICY_WRITE, + severityLevel: 'medium', + meta: { actionType: ActionType.DELETE, source: 'csv-file' }, + }); + + try { + await this.enforcer.removePolicies(this.csvFilePolicies.removedPolicies); + await auditorEvent.success({ + meta: { policies: this.csvFilePolicies.removedPolicies }, + }); + } catch (e) { + await auditorEvent.fail({ + meta: { policies: this.csvFilePolicies.removedPolicies }, + error: e, + }); + } + + this.csvFilePolicies.removedPolicies = []; + } + + /** + * addRoles will add the new roles that are present in the CSV file. + */ + private async addRoles(): Promise { + const changedPolicies: { + addedPolicies: string[][]; + updatedPolicies: string[][]; + failedPolicies: { error: string; policies: string[][] }[]; + } = { + addedPolicies: [], + updatedPolicies: [], + failedPolicies: [], + }; + + const auditorEvent = await this.auditor.createEvent({ + eventId: RoleEvents.ROLE_WRITE, + severityLevel: 'medium', + meta: { actionType: ActionType.CREATE_OR_UPDATE, source: 'csv-file' }, + }); + + for (const [key, value] of this.csvFilePolicies.addedGroupPolicies) { + const groupPolicies = value.map(member => { + return [member, key]; + }); + + const roleMetadata: RoleMetadataDao = { + source: 'csv-file', + roleEntityRef: key, + author: CSV_PERMISSION_POLICY_FILE_AUTHOR, + modifiedBy: CSV_PERMISSION_POLICY_FILE_AUTHOR, + }; + + try { + const currentMetadata = await this.roleMetadataStorage.findRoleMetadata( + roleMetadata.roleEntityRef, + ); + + await this.enforcer.addGroupingPolicies(groupPolicies, roleMetadata); + + if (currentMetadata) { + changedPolicies.updatedPolicies.push(...groupPolicies); + } else { + changedPolicies.addedPolicies.push(...groupPolicies); + } + } catch (e) { + changedPolicies.failedPolicies.push({ + error: e, + policies: groupPolicies, + }); + } + } + + if (changedPolicies.failedPolicies.length > 0) { + await auditorEvent.fail({ + error: new Error( + `Failed to add or update group policies after modification ${this.filePath}.`, + ), + meta: { ...changedPolicies }, + }); + } else { + await auditorEvent.success({ + meta: { + addedPolicies: changedPolicies.addedPolicies, + updatedPolicies: changedPolicies.updatedPolicies, + }, + }); + } + + this.csvFilePolicies.addedGroupPolicies = new Map(); + } + + /** + * removeRoles will remove the roles that are no longer present in the CSV file. + * If the role exists with multiple groups and or users, we will update it role information. + * Otherwise, we will remove the role completely. + */ + private async removeRoles(): Promise { + for (const [key, value] of this.csvFilePolicies.removedGroupPolicies) { + // This requires knowledge of whether or not it is an update + const oldGroupingPolicies = await this.enforcer.getFilteredGroupingPolicy( + 1, + key, + ); + const groupPolicies = value.map(member => { + return [member, key]; + }); + + const roleMetadata: RoleMetadataDao = { + source: 'csv-file', + roleEntityRef: key, + author: CSV_PERMISSION_POLICY_FILE_AUTHOR, + modifiedBy: CSV_PERMISSION_POLICY_FILE_AUTHOR, + }; + const isUpdate = + oldGroupingPolicies.length > 1 && + oldGroupingPolicies.length !== groupPolicies.length; + const actionType = isUpdate ? ActionType.UPDATE : ActionType.DELETE; + + const meta = { + ...roleMetadata, + members: value, + }; + const auditorEvent = await this.auditor.createEvent({ + eventId: RoleEvents.ROLE_WRITE, + severityLevel: 'medium', + meta: { actionType, source: meta.source }, + }); + + try { + await this.enforcer.removeGroupingPolicies( + groupPolicies, + roleMetadata, + isUpdate, + ); + await auditorEvent.success({ meta }); + } catch (e) { + await auditorEvent.fail({ + meta, + error: e, + }); + } + } + + this.csvFilePolicies.removedGroupPolicies = new Map(); + } + + async cleanUpRolesAndPolicies(): Promise { + const roleMetadatas = + await this.roleMetadataStorage.filterRoleMetadata('csv-file'); + const fileRoles = roleMetadatas.map(meta => meta.roleEntityRef); + + if (fileRoles.length > 0) { + for (const fileRole of fileRoles) { + const filteredPolicies = await this.enforcer.getFilteredGroupingPolicy( + 1, + fileRole, + ); + for (const groupPolicy of filteredPolicies) { + this.addGroupPolicyToMap( + this.csvFilePolicies.removedGroupPolicies, + groupPolicy[1], + groupPolicy[0], + ); + } + this.csvFilePolicies.removedPolicies.push( + ...(await this.enforcer.getFilteredPolicy(0, fileRole)), + ); + } + } + await this.removePermissionPolicies(); + await this.removeRoles(); + } + + async filterPoliciesAndRoles( + enforcerOne: Enforcer | EnforcerDelegate, + enforcerTwo: Enforcer | EnforcerDelegate, + policies: string[][], + groupPolicies: Map, + remove?: boolean, + ) { + // Check for any policies that need to be edited by comparing policies from + // one enforcer to the other + const policiesToEdit = await enforcerOne.getPolicy(); + const groupPoliciesToEdit = await enforcerOne.getGroupingPolicy(); + + for (const policy of policiesToEdit) { + if ( + !(await enforcerTwo.hasPolicy(...policy)) && + (await this.validateAddedPolicy( + policy, + enforcerOne as Enforcer, + remove, + )) + ) { + policies.push(policy); + } + + // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` + if (policy[1] === 'policy-entity' && policy[2] === 'create' && !remove) { + this.logger.warn( + `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${policy} to use 'policy.entity.create' instead of 'policy-entity' from source csv-file`, + ); + } + } + + for (const groupPolicy of groupPoliciesToEdit) { + if ( + !(await enforcerTwo.hasGroupingPolicy(...groupPolicy)) && + (await this.validateAddedGroupPolicy( + groupPolicy, + enforcerOne as Enforcer, + remove, + )) + ) { + this.addGroupPolicyToMap(groupPolicies, groupPolicy[1], groupPolicy[0]); + } + } + } + + async validateAddedPolicy( + policy: string[], + tempEnforcer: Enforcer, + remove?: boolean, + ): Promise { + const transformedPolicy = transformArrayToPolicy(policy); + const metadata = await this.roleMetadataStorage.findRoleMetadata(policy[0]); + + if (remove) { + return metadata?.source === 'csv-file'; + } + + let err = validatePolicy(transformedPolicy); + if (err) { + this.logger.warn( + `Failed to validate policy from file ${this.filePath}. Cause: ${err.message}`, + ); + return false; + } + + err = await validateSource('csv-file', metadata); + if (err) { + this.logger.warn( + `Unable to add policy ${policy} from file ${this.filePath}. Cause: ${err.message}`, + ); + return false; + } + + err = await checkForDuplicatePolicies(tempEnforcer, policy, this.filePath!); + if (err) { + this.logger.warn(err.message); + return false; + } + + return true; + } + + async validateAddedGroupPolicy( + groupPolicy: string[], + tempEnforcer: Enforcer, + remove?: boolean, + ): Promise { + const metadata = await this.roleMetadataStorage.findRoleMetadata( + groupPolicy[1], + ); + + if (remove) { + return metadata?.source === 'csv-file'; + } + + let err = await validateGroupingPolicy(groupPolicy, metadata, 'csv-file'); + if (err) { + this.logger.warn( + `${err.message}, error originates from file ${this.filePath}`, + ); + return false; + } + + err = await checkForDuplicateGroupPolicies( + tempEnforcer, + groupPolicy, + this.filePath!, + ); + if (err) { + this.logger.warn(err.message); + return false; + } + + return true; + } + + async findFileContentDiff( + currentFlatContent: string[], + newFlatContent: string[], + tempEnforcer: Enforcer, + ) { + const diffRemoved = difference(currentFlatContent, newFlatContent); // policy was removed + const diffAdded = difference(newFlatContent, currentFlatContent); // policy was added + + await this.migrateLegacyMetadata(tempEnforcer); + + if (diffRemoved.length === 0 && diffAdded.length === 0) { + return; + } + + diffRemoved.forEach(policy => { + const convertedPolicy = metadataStringToPolicy(policy); + if (convertedPolicy[0] === 'p') { + convertedPolicy.splice(0, 1); + this.csvFilePolicies.removedPolicies.push(convertedPolicy); + } else if (convertedPolicy[0] === 'g') { + convertedPolicy.splice(0, 1); + this.addGroupPolicyToMap( + this.csvFilePolicies.removedGroupPolicies, + convertedPolicy[1], + convertedPolicy[0], + ); + } + }); + + for (const policy of diffAdded) { + const convertedPolicy = metadataStringToPolicy(policy); + if (convertedPolicy[0] === 'p') { + convertedPolicy.splice(0, 1); + if (await this.validateAddedPolicy(convertedPolicy, tempEnforcer)) + this.csvFilePolicies.addedPolicies.push(convertedPolicy); + + // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` + if ( + convertedPolicy[1] === 'policy-entity' && + convertedPolicy[2] === 'create' + ) { + this.logger.warn( + `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${convertedPolicy} to use 'policy.entity.create' instead of 'policy-entity' from source csv-file`, + ); + } + } else if (convertedPolicy[0] === 'g') { + convertedPolicy.splice(0, 1); + if (await this.validateAddedGroupPolicy(convertedPolicy, tempEnforcer)) + this.addGroupPolicyToMap( + this.csvFilePolicies.addedGroupPolicies, + convertedPolicy[1], + convertedPolicy[0], + ); + } + } + } + + addGroupPolicyToMap( + groupPolicyMap: Map, + key: string, + value: string, + ) { + if (!groupPolicyMap.has(key)) { + groupPolicyMap.set(key, []); + } + groupPolicyMap.get(key)?.push(value); + } +} diff --git a/plugins/rbac-backend/src/file-permissions/file-watcher.ts b/plugins/rbac-backend/src/file-permissions/file-watcher.ts new file mode 100644 index 0000000000..06debeb1ed --- /dev/null +++ b/plugins/rbac-backend/src/file-permissions/file-watcher.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { LoggerService } from '@backstage/backend-plugin-api'; + +import chokidar from 'chokidar'; + +import fs from 'fs'; + +/** + * Represents a file watcher that can be used to monitor changes in a file. + */ +export abstract class AbstractFileWatcher { + constructor( + protected readonly filePath: string | undefined, + protected readonly allowReload: boolean, + protected readonly logger: LoggerService, + ) {} + + /** + * Initializes the file watcher and starts watching the specified file. + */ + abstract initialize(): Promise; + + /** + * watchFile initializes the file watcher and sets it to begin watching for changes. + */ + watchFile(): void { + if (!this.filePath) { + throw new Error('File path is not specified'); + } + const watcher = chokidar.watch(this.filePath); + watcher.on('change', async path => { + this.logger.info(`file ${path} has changed`); + await this.onChange(); + }); + watcher.on('error', error => { + this.logger.error(`error watching file ${this.filePath}: ${error}`); + }); + } + + /** + * Handles the change event when the watched file is modified. + * @returns A promise that resolves when the change event is handled. + */ + abstract onChange(): Promise; + + /** + * getCurrentContents reads the current contents of the CSV file. + * @returns The current contents of the file. + */ + getCurrentContents(): string { + if (!this.filePath) { + throw new Error('File path is not specified'); + } + return fs.readFileSync(this.filePath, 'utf-8'); + } + + /** + * parse is used to parse the current contents of the file. + * @returns The file parsed into a type . + */ + abstract parse(): T; +} diff --git a/plugins/rbac-backend/src/file-permissions/lowercase-file-adapter.ts b/plugins/rbac-backend/src/file-permissions/lowercase-file-adapter.ts new file mode 100644 index 0000000000..c4c003c9e9 --- /dev/null +++ b/plugins/rbac-backend/src/file-permissions/lowercase-file-adapter.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { FileAdapter, Helper, Model, mustGetDefaultFileSystem } from 'casbin'; + +export class LowercaseFileAdapter extends FileAdapter { + public async loadPolicy(model: Model): Promise { + if (!this.filePath) { + return; + } + await this.loadLowercasePolicyFile(model, Helper.loadPolicyLine); + } + + private transformLineToLowercaseGroupsUsers(line: string): string { + if (line.trim().startsWith('g')) { + const policyArray = line.split(','); + if (policyArray.length >= 1 && policyArray[0].trim().startsWith('g')) { + policyArray[1] = policyArray[1].toLocaleLowerCase('en-US'); + } + return policyArray.join(','); + } + return line; + } + + private async loadLowercasePolicyFile( + model: Model, + handler: (line: string, model: Model) => void, + ): Promise { + // Reference: https://github.com/casbin/node-casbin/blob/master/src/persist/fileAdapter.ts#L34-#L43 + const bodyBuf = await ( + this.fs ? this.fs : mustGetDefaultFileSystem() + ).readFileSync(this.filePath); + const lines = bodyBuf.toString().split('\n'); + + lines.forEach((line: string) => { + if (!line) { + return; + } + const lowercasedLine = this.transformLineToLowercaseGroupsUsers(line); + handler(lowercasedLine, model); + }); + } +} diff --git a/plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.test.ts b/plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.test.ts new file mode 100644 index 0000000000..b9b7036c2f --- /dev/null +++ b/plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.test.ts @@ -0,0 +1,629 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices } from '@backstage/backend-test-utils'; +import { + AuthorizeResult, + type MetadataResponse, +} from '@backstage/plugin-permission-common'; + +import { resolve } from 'path'; + +import { ActionType, ConditionEvents } from '../auditor/auditor'; +import { DataBaseConditionalStorage } from '../database/conditional-storage'; +import { + RoleMetadataDao, + RoleMetadataStorage, +} from '../database/role-metadata'; +import { RoleEventEmitter, RoleEvents } from '../service/enforcer-delegate'; +import { PluginPermissionMetadataCollector } from '../service/plugin-endpoints'; +import { YamlConditinalPoliciesFileWatcher } from './yaml-conditional-file-watcher'; // Adjust the import path as necessary +import { mockAuditorService } from '../../__fixtures__/mock-utils'; +import { expectAuditorLog } from '../../__fixtures__/auditor-test-utils'; +import { + PermissionInfo, + RoleConditionalPolicyDecision, +} from '@backstage-community/plugin-rbac-common'; +import { JsonObject } from '@backstage/types'; +import { NotFoundError } from '@backstage/errors'; + +const mockLoggerService = mockServices.logger.mock(); + +let loggerWarnSpy: jest.SpyInstance; + +const conditionalStorageMock: Partial = { + filterConditions: jest.fn().mockImplementation(), + createCondition: jest.fn().mockImplementation(), + checkConflictedConditions: jest.fn().mockImplementation(), + getCondition: jest.fn().mockImplementation(), + deleteCondition: jest.fn().mockImplementation(), + updateCondition: jest.fn().mockImplementation(), +}; + +const mockAuthService = mockServices.auth(); + +const testPluginMetadataResp: MetadataResponse = { + permissions: [ + { + type: 'resource', + name: 'catalog.entity.read', + attributes: { + action: 'read', + }, + resourceType: 'catalog-entity', + }, + { + type: 'basic', + name: 'catalog.entity.create', + attributes: { + action: 'create', + }, + }, + { + type: 'resource', + name: 'catalog.entity.delete', + attributes: { + action: 'delete', + }, + resourceType: 'catalog-entity', + }, + { + type: 'resource', + name: 'catalog.entity.refresh', + attributes: { + action: 'update', + }, + resourceType: 'catalog-entity', + }, + ], + rules: [ + { + name: 'IS_ENTITY_OWNER', + description: 'Allow entities owned by a specified claim', + resourceType: 'catalog-entity', + paramsSchema: { + type: 'object', + properties: { + claims: { + type: 'array', + items: { + type: 'string', + }, + description: + 'List of claims to match at least one on within ownedBy', + }, + }, + required: ['claims'], + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', + }, + }, + ], +}; + +const conditionToStore1: Partial< + RoleConditionalPolicyDecision +> & + Required< + Pick, 'permissionMapping'> + > = { + result: AuthorizeResult.CONDITIONAL, + roleEntityRef: 'role:default/test', + pluginId: 'catalog', + resourceType: 'catalog-entity', + permissionMapping: [{ name: 'catalog.entity.refresh', action: 'update' }], + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/team-a'], + }, + }, +}; + +const conditionToStore2: Partial< + RoleConditionalPolicyDecision +> & + Required< + Pick, 'permissionMapping'> + > = { + result: AuthorizeResult.CONDITIONAL, + roleEntityRef: 'role:default/test', + pluginId: 'catalog', + resourceType: 'catalog-entity', + permissionMapping: [ + { name: 'catalog.entity.read', action: 'read' }, + { name: 'catalog.entity.delete', action: 'delete' }, + ], + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/team-a', 'group:default/team-b'], + }, + }, +}; + +const conditionToRemove: Partial< + RoleConditionalPolicyDecision +> & + Required< + Pick, 'permissionMapping'> + > = { + id: 2, + result: AuthorizeResult.CONDITIONAL, + roleEntityRef: 'role:default/dev', + pluginId: 'catalog', + resourceType: 'catalog-entity', + permissionMapping: [{ name: 'catalog.entity.read', action: 'read' }], + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/team-dev'], + }, + }, +}; + +const pluginMetadataCollectorMock: Partial = + { + getPluginConditionRules: jest.fn().mockImplementation(), + getPluginPolicies: jest.fn().mockImplementation(), + getMetadataByPluginId: jest + .fn() + .mockImplementation(async () => testPluginMetadataResp), + }; + +const roleMetadataStorageMock: RoleMetadataStorage = { + filterRoleMetadata: jest.fn().mockImplementation(() => []), + filterForOwnerRoleMetadata: jest.fn().mockImplementation(), + findRoleMetadata: jest.fn().mockImplementation(), + createRoleMetadata: jest.fn().mockImplementation(), + updateRoleMetadata: jest.fn().mockImplementation(), + removeRoleMetadata: jest.fn().mockImplementation(), + getCachedDefaultRoleMetadata: jest.fn().mockImplementation(() => undefined), + getDefaultRole: jest.fn().mockResolvedValue(undefined), + syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), +}; + +const roleEventEmitterMock: RoleEventEmitter = { + on: jest.fn().mockImplementation(), +}; + +describe('YamlConditionalFileWatcher', () => { + let csvFileName: string; + + const csvFileRoles: RoleMetadataDao[] = [ + { + roleEntityRef: 'role:default/test', + source: 'csv-file', + author: 'user:default/tom', + modifiedBy: 'user:default/tom', + createdAt: '2021-09-01T00:00:00Z', + }, + ]; + + beforeEach(() => { + csvFileName = resolve( + __dirname, + '../../__fixtures__/data/valid-conditions/conditions.yaml', + ); + + loggerWarnSpy = jest.spyOn(mockLoggerService, 'warn'); + + conditionalStorageMock.createCondition = jest.fn().mockImplementation(); + conditionalStorageMock.deleteCondition = jest.fn().mockImplementation(); + jest.clearAllMocks(); + }); + + function createWatcher(filePath?: string): YamlConditinalPoliciesFileWatcher { + return new YamlConditinalPoliciesFileWatcher( + filePath, + false, + mockLoggerService, + conditionalStorageMock as DataBaseConditionalStorage, + mockAuditorService, + mockAuthService, + pluginMetadataCollectorMock as PluginPermissionMetadataCollector, + roleMetadataStorageMock, + roleEventEmitterMock, + ); + } + + test('handles errors for invalid file paths', async () => { + const invalidFilePath = 'invalid-file-path.yaml'; + const watcher = createWatcher(invalidFilePath); + await watcher.initialize(); + + expectAuditorLog([ + { + event: { eventId: ConditionEvents.CONDITIONAL_POLICIES_FILE_NOT_FOUND }, + fail: { error: new Error(`File '${invalidFilePath}' was not found`) }, + }, + ]); + }); + + test('handles error on parse invalid yaml file', async () => { + const invalidFilePath = resolve( + __dirname, + '../../__fixtures__/data/invalid-conditions/invalid-yaml.yaml', + ); + const watcher = createWatcher(invalidFilePath); + await watcher.initialize(); + + expectAuditorLog([ + { + event: { eventId: ConditionEvents.CONDITIONAL_POLICIES_FILE_CHANGE }, + fail: { + error: new Error( + `'roleEntityRef' must be specified in the role condition`, + ), + }, + }, + ]); + }); + + test('should handle error on create condition', async () => { + conditionalStorageMock.filterConditions = jest + .fn() + .mockImplementation(() => []); + roleMetadataStorageMock.filterRoleMetadata = jest + .fn() + .mockImplementation(() => csvFileRoles); + conditionalStorageMock.createCondition = jest + .fn() + .mockImplementationOnce(() => { + throw new Error('unknown error message 1'); + }) + .mockImplementationOnce(() => { + throw new Error('unknown error message 2'); + }); + + const watcher = createWatcher(csvFileName); + await watcher.initialize(); + + expect(conditionalStorageMock.createCondition).toHaveBeenCalled(); + expectAuditorLog([ + { + event: { + eventId: ConditionEvents.CONDITION_WRITE, + meta: { actionType: ActionType.CREATE }, + }, + fail: { + error: new Error('unknown error message 1'), + ...mappedConditionMeta(conditionToStore1), + }, + }, + { + event: { + eventId: ConditionEvents.CONDITION_WRITE, + meta: { actionType: ActionType.CREATE }, + }, + fail: { + error: new Error('unknown error message 2'), + ...mappedConditionMeta(conditionToStore2), + }, + }, + ]); + }); + + test('should add conditional policies from the file on initialization', async () => { + conditionalStorageMock.filterConditions = jest + .fn() + .mockImplementation(() => []); + roleMetadataStorageMock.filterRoleMetadata = jest + .fn() + .mockImplementation(() => csvFileRoles); + + const watcher = createWatcher(csvFileName); + await watcher.initialize(); + + expect(conditionalStorageMock.createCondition).toHaveBeenCalledWith( + conditionToStore1, + ); + expect(conditionalStorageMock.createCondition).toHaveBeenCalledWith( + conditionToStore2, + ); + expectAuditorLog([ + { + event: { + eventId: ConditionEvents.CONDITION_WRITE, + meta: { actionType: ActionType.CREATE }, + }, + success: { ...mappedConditionMeta(conditionToStore1) }, + }, + { + event: { + eventId: ConditionEvents.CONDITION_WRITE, + meta: { actionType: ActionType.CREATE }, + }, + success: { ...mappedConditionMeta(conditionToStore2) }, + }, + ]); + }); + + test('should not fail on initialization, when conditional policies contains empty array', async () => { + conditionalStorageMock.filterConditions = jest + .fn() + .mockImplementation(() => []); + roleMetadataStorageMock.filterRoleMetadata = jest + .fn() + .mockImplementation(() => csvFileRoles); + + csvFileName = resolve( + __dirname, + '../../__fixtures__/data/valid-conditions/empty-conditions.yaml', + ); + const watcher = createWatcher(csvFileName); + await watcher.initialize(); + expectAuditorLog([]); + }); + + test('should not fail on initialization, when conditional policies file contains extra delimiter', async () => { + conditionalStorageMock.filterConditions = jest + .fn() + .mockImplementation(() => []); + roleMetadataStorageMock.filterRoleMetadata = jest + .fn() + .mockImplementation(() => [ + { + roleEntityRef: 'role:default/test-2', + source: 'csv-file', + author: 'user:default/tom', + modifiedBy: 'user:default/tom', + createdAt: '2021-09-01T00:00:00Z', + }, + { + roleEntityRef: 'role:default/test-3', + source: 'csv-file', + author: 'user:default/tom', + modifiedBy: 'user:default/tom', + createdAt: '2021-09-01T00:00:00Z', + }, + ]); + + csvFileName = resolve( + __dirname, + '../../__fixtures__/data/valid-conditions/extra-delimiter-conditions.yaml', + ); + const watcher = createWatcher(csvFileName); + await watcher.initialize(); + const expectedCondition1 = { + result: AuthorizeResult.CONDITIONAL, + roleEntityRef: 'role:default/test-2', + pluginId: 'catalog', + resourceType: 'catalog-entity', + permissionMapping: [{ name: 'catalog.entity.refresh', action: 'update' }], + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/team-a'], + }, + }, + }; + const expectedCondition2 = { + result: AuthorizeResult.CONDITIONAL, + roleEntityRef: 'role:default/test-3', + pluginId: 'catalog', + resourceType: 'catalog-entity', + permissionMapping: [ + { name: 'catalog.entity.read', action: 'read' }, + { name: 'catalog.entity.delete', action: 'delete' }, + ], + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/team-a', 'group:default/team-b'], + }, + }, + }; + + expect(conditionalStorageMock.createCondition).toHaveBeenCalledWith( + expectedCondition1, + ); + expect(conditionalStorageMock.createCondition).toHaveBeenCalledWith( + expectedCondition2, + ); + expectAuditorLog([ + { + event: { + eventId: ConditionEvents.CONDITION_WRITE, + meta: { actionType: ActionType.CREATE }, + }, + success: { ...mappedConditionMeta(expectedCondition1 as any) }, + }, + { + event: { + eventId: ConditionEvents.CONDITION_WRITE, + meta: { actionType: ActionType.CREATE }, + }, + success: { ...mappedConditionMeta(expectedCondition2 as any) }, + }, + ]); + }); + + test(`should not apply conditions if corresponding role is present, but with non 'csv-file' source`, async () => { + conditionalStorageMock.filterConditions = jest + .fn() + .mockImplementation(() => []); + roleMetadataStorageMock.filterRoleMetadata = jest + .fn() + .mockImplementation(() => [ + { + ...csvFileRoles[0], + source: 'rest', + }, + ]); + + const watcher = createWatcher(csvFileName); + await watcher.initialize(); + + expect(conditionalStorageMock.createCondition).not.toHaveBeenCalled(); + + expectAuditorLog([]); + expect(loggerWarnSpy).toHaveBeenNthCalledWith( + 1, + `skip to add condition for role 'role:default/test'. Role is not from csv-file`, + ); + expect(loggerWarnSpy).toHaveBeenNthCalledWith( + 2, + `skip to add condition for role 'role:default/test'. Role is not from csv-file`, + ); + }); + + test('should not apply conditions if corresponding role is absent', async () => { + conditionalStorageMock.filterConditions = jest + .fn() + .mockImplementation(() => []); + roleMetadataStorageMock.filterRoleMetadata = jest + .fn() + .mockImplementation(() => []); + + const watcher = createWatcher(csvFileName); + await watcher.initialize(); + + expect(conditionalStorageMock.createCondition).not.toHaveBeenCalled(); + + expectAuditorLog([]); + expect(loggerWarnSpy).toHaveBeenNthCalledWith( + 1, + `skip to add condition for role 'role:default/test'. The role either does not exist or was not created from a CSV file.`, + ); + expect(loggerWarnSpy).toHaveBeenNthCalledWith( + 2, + `skip to add condition for role 'role:default/test'. The role either does not exist or was not created from a CSV file.`, + ); + }); + + test('should remove conditions, which is not included to yaml any more', async () => { + conditionalStorageMock.filterConditions = jest + .fn() + .mockImplementation(() => [conditionToRemove]); + roleMetadataStorageMock.filterRoleMetadata = jest + .fn() + .mockImplementation(() => []); + + const watcher = createWatcher(csvFileName); + await watcher.initialize(); + + expect(conditionalStorageMock.createCondition).not.toHaveBeenCalled(); + + expectAuditorLog([ + { + event: { + eventId: ConditionEvents.CONDITION_WRITE, + meta: { actionType: ActionType.DELETE }, + }, + success: { ...mappedConditionMeta(conditionToRemove) }, + }, + ]); + expect(conditionalStorageMock.deleteCondition).toHaveBeenCalledWith(2); + }); + + test('should handle error on delete condition', async () => { + conditionalStorageMock.filterConditions = jest + .fn() + .mockImplementation(() => [conditionToRemove]); + roleMetadataStorageMock.filterRoleMetadata = jest + .fn() + .mockImplementation(() => []); + conditionalStorageMock.deleteCondition = jest + .fn() + .mockImplementation(() => { + throw new NotFoundError('Condition was not found'); + }); + + const watcher = createWatcher(csvFileName); + await watcher.initialize(); + + expect(conditionalStorageMock.createCondition).not.toHaveBeenCalled(); + + expect(conditionalStorageMock.deleteCondition).toHaveBeenCalled(); + expectAuditorLog([ + { + event: { + eventId: ConditionEvents.CONDITION_WRITE, + meta: { actionType: ActionType.DELETE }, + }, + fail: { + error: new NotFoundError('Condition was not found'), + ...mappedConditionMeta(conditionToRemove), + }, + }, + ]); + }); + + test('should clean up conditions if conditional file was not specified', async () => { + conditionalStorageMock.filterConditions = jest + .fn() + .mockImplementation(() => [conditionToRemove]); + roleMetadataStorageMock.filterRoleMetadata = jest + .fn() + .mockImplementation(() => csvFileRoles); + + const watcher = createWatcher(); + await watcher.initialize(); + await watcher.cleanUpConditionalPolicies(); + + expect(conditionalStorageMock.createCondition).not.toHaveBeenCalled(); + expectAuditorLog([ + { + event: { + eventId: ConditionEvents.CONDITION_WRITE, + meta: { actionType: ActionType.DELETE }, + }, + success: { ...mappedConditionMeta(conditionToRemove) }, + }, + ]); + expect(conditionalStorageMock.deleteCondition).toHaveBeenNthCalledWith( + 1, + 2, + ); + }); + + test('should not clean up conditions if list conditions is empty', async () => { + conditionalStorageMock.filterConditions = jest + .fn() + .mockImplementation(() => []); + roleMetadataStorageMock.filterRoleMetadata = jest + .fn() + .mockImplementation(() => csvFileRoles); + + const watcher = createWatcher(); + await watcher.initialize(); + await watcher.cleanUpConditionalPolicies(); + + expect(conditionalStorageMock.createCondition).not.toHaveBeenCalled(); + expectAuditorLog([]); + expect(conditionalStorageMock.deleteCondition).not.toHaveBeenCalled(); + }); +}); + +function mappedConditionMeta( + condition: Required< + Pick, 'permissionMapping'> + >, +): JsonObject { + return { + meta: { + condition: { + ...condition, + permissionMapping: condition.permissionMapping.map(pm => pm.action), + }, + }, + }; +} diff --git a/plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.ts b/plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.ts new file mode 100644 index 0000000000..6f81dc31d3 --- /dev/null +++ b/plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.ts @@ -0,0 +1,267 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { + AuditorService, + AuthService, + LoggerService, +} from '@backstage/backend-plugin-api'; + +import yaml from 'js-yaml'; +import { omit } from 'lodash'; + +import type { + PermissionAction, + RoleConditionalPolicyDecision, +} from '@backstage-community/plugin-rbac-common'; + +import fs from 'fs'; + +import { ActionType, ConditionEvents } from '../auditor/auditor'; +import { ConditionalStorage } from '../database/conditional-storage'; +import { RoleMetadataStorage } from '../database/role-metadata'; +import { deepSortEqual, processConditionMapping } from '../helper'; +import { RoleEventEmitter, RoleEvents } from '../service/enforcer-delegate'; +import { PluginPermissionMetadataCollector } from '../service/plugin-endpoints'; +import { validateRoleCondition } from '../validation/condition-validation'; +import { AbstractFileWatcher } from './file-watcher'; + +type ConditionalPoliciesDiff = { + addedConditions: RoleConditionalPolicyDecision[]; + removedConditions: RoleConditionalPolicyDecision[]; +}; + +export class YamlConditinalPoliciesFileWatcher extends AbstractFileWatcher< + RoleConditionalPolicyDecision[] +> { + private conditionsDiff: ConditionalPoliciesDiff; + + constructor( + filePath: string | undefined, + allowReload: boolean, + logger: LoggerService, + private readonly conditionalStorage: ConditionalStorage, + private readonly auditor: AuditorService, + private readonly auth: AuthService, + private readonly pluginMetadataCollector: PluginPermissionMetadataCollector, + private readonly roleMetadataStorage: RoleMetadataStorage, + private readonly roleEventEmitter: RoleEventEmitter, + ) { + super(filePath, allowReload, logger); + + this.conditionsDiff = { + addedConditions: [], + removedConditions: [], + }; + } + + async initialize(): Promise { + if (!this.filePath) { + return; + } + const fileExists = fs.existsSync(this.filePath); + if (!fileExists) { + const auditorEvent = await this.auditor.createEvent({ + eventId: ConditionEvents.CONDITIONAL_POLICIES_FILE_NOT_FOUND, + severityLevel: 'medium', + }); + await auditorEvent.fail({ + error: new Error(`File '${this.filePath}' was not found`), + }); + return; + } + + this.roleEventEmitter.on('roleAdded', this.onChange.bind(this)); + await this.onChange(); + + if (this.allowReload) { + this.watchFile(); + } + } + + async onChange(): Promise { + try { + const newConds = this.parse(); + + const addedConds: RoleConditionalPolicyDecision[] = []; + const removedConds: RoleConditionalPolicyDecision[] = + []; + + const csvFileRoles = + await this.roleMetadataStorage.filterRoleMetadata('csv-file'); + const existedFileConds = ( + await this.conditionalStorage.filterConditions( + csvFileRoles.map(role => role.roleEntityRef), + ) + ).map(condition => { + return { + ...condition, + permissionMapping: condition.permissionMapping.map(pm => pm.action), + }; + }); + + // Find added conditions + for (const condition of newConds) { + const roleMetadata = csvFileRoles.find( + role => condition.roleEntityRef === role.roleEntityRef, + ); + if (!roleMetadata) { + this.logger.warn( + `skip to add condition for role '${condition.roleEntityRef}'. The role either does not exist or was not created from a CSV file.`, + ); + continue; + } + if (roleMetadata.source !== 'csv-file') { + this.logger.warn( + `skip to add condition for role '${condition.roleEntityRef}'. Role is not from csv-file`, + ); + continue; + } + + const existingCondition = existedFileConds.find(c => + deepSortEqual(omit(c, ['id']), omit(condition, ['id'])), + ); + + if (!existingCondition) { + addedConds.push(condition); + } + } + + // Find removed conditions + for (const condition of existedFileConds) { + if ( + !newConds.find(c => + deepSortEqual(omit(c, ['id']), omit(condition, ['id'])), + ) + ) { + removedConds.push(condition); + } + } + + this.conditionsDiff = { + addedConditions: addedConds, + removedConditions: removedConds, + }; + + await this.handleFileChanges(); + } catch (error) { + const auditorEvent = await this.auditor.createEvent({ + eventId: ConditionEvents.CONDITIONAL_POLICIES_FILE_CHANGE, + severityLevel: 'medium', + }); + await auditorEvent.fail({ + error, + }); + } + } + + /** + * Reads the current contents of the file and parses it. + * @returns parsed data. + */ + parse(): RoleConditionalPolicyDecision[] { + const fileContents = this.getCurrentContents(); + const data = yaml + .loadAll(fileContents) + .filter( + doc => doc !== null, + ) as RoleConditionalPolicyDecision[]; + + for (const condition of data) { + validateRoleCondition(condition); + } + + return data; + } + + private async handleFileChanges(): Promise { + await this.removeConditions(); + await this.addConditions(); + } + + private async addConditions(): Promise { + for (const condition of this.conditionsDiff.addedConditions) { + const auditorEvent = await this.auditor.createEvent({ + eventId: ConditionEvents.CONDITION_WRITE, + severityLevel: 'medium', + meta: { actionType: ActionType.CREATE }, + }); + + try { + const conditionToCreate = await processConditionMapping( + condition, + this.pluginMetadataCollector, + this.auth, + ); + + await this.conditionalStorage.createCondition(conditionToCreate); + await auditorEvent.success({ + meta: { condition }, + }); + } catch (error) { + await auditorEvent.fail({ error, meta: { condition } }); + } + } + + this.conditionsDiff.addedConditions = []; + } + + private async removeConditions(): Promise { + for (const condition of this.conditionsDiff.removedConditions) { + const auditorEvent = await this.auditor.createEvent({ + eventId: ConditionEvents.CONDITION_WRITE, + severityLevel: 'medium', + meta: { actionType: ActionType.DELETE }, + }); + + try { + const conditionToDelete = ( + await this.conditionalStorage.filterConditions( + condition.roleEntityRef, + condition.pluginId, + condition.resourceType, + condition.permissionMapping, + ) + )[0]; + await this.conditionalStorage.deleteCondition(conditionToDelete.id!); + await auditorEvent.success({ meta: { condition } }); + } catch (error) { + await auditorEvent.fail({ + error, + meta: { condition }, + }); + } + } + + this.conditionsDiff.removedConditions = []; + } + + async cleanUpConditionalPolicies(): Promise { + const csvFileRoles = + await this.roleMetadataStorage.filterRoleMetadata('csv-file'); + const existedFileConds = ( + await this.conditionalStorage.filterConditions( + csvFileRoles.map(role => role.roleEntityRef), + ) + ).map(condition => { + return { + ...condition, + permissionMapping: condition.permissionMapping.map(pm => pm.action), + }; + }); + this.conditionsDiff.removedConditions = existedFileConds; + await this.removeConditions(); + } +} diff --git a/plugins/rbac-backend/src/helper.test.ts b/plugins/rbac-backend/src/helper.test.ts new file mode 100644 index 0000000000..4b4e003611 --- /dev/null +++ b/plugins/rbac-backend/src/helper.test.ts @@ -0,0 +1,827 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { RoleMetadata } from '@backstage-community/plugin-rbac-common'; +import { clearAuditorMock } from '../__fixtures__/auditor-test-utils'; +import { mockAuditorService } from '../__fixtures__/mock-utils'; +import { ADMIN_ROLE_AUTHOR } from './admin-permissions/admin-creation'; +import { RoleMetadataDao } from './database/role-metadata'; +import { + deepSortedEqual, + isPermissionAction, + matches, + mergeRoleMetadata, + metadataStringToPolicy, + policiesToString, + policyToString, + removeTheDifference, + syncRolePolicies, + transformArrayToPolicy, + transformPolicyGroupToLowercase, + transformRolesGroupToLowercase, + typedPoliciesToString, + typedPolicyToString, +} from './helper'; +import { RBACFilters } from './permissions'; +// Import the function to test +import { EnforcerDelegate } from './service/enforcer-delegate'; + +const modifiedBy = 'user:default/some-user'; + +describe('helper.ts', () => { + describe('policyToString', () => { + it('should convert permission policy to string', () => { + const policy = [ + 'user:default/some-user', + 'catalog-entity', + 'read', + 'allow', + ]; + const expectedString = + '[user:default/some-user, catalog-entity, read, allow]'; + expect(policyToString(policy)).toEqual(expectedString); + }); + }); + + describe('typedPolicyToString', () => { + it('should convert permission policy to string', () => { + const policy = [ + 'user:default/some-user', + 'catalog-entity', + 'read', + 'allow', + ]; + const type = 'p'; + const expectedString = + 'p, user:default/some-user, catalog-entity, read, allow'; + expect(typedPolicyToString(policy, type)).toEqual(expectedString); + }); + }); + + describe('policiesToString', () => { + it('should convert one permission policy to string', () => { + const policies = [ + ['user:default/some-user', 'catalog-entity', 'read', 'allow'], + ]; + const expectedString = + '[[user:default/some-user, catalog-entity, read, allow]]'; + expect(policiesToString(policies)).toEqual(expectedString); + }); + + it('should convert empty permission policy array to string', () => { + const policies = [[]]; + const expectedString = '[[]]'; + expect(policiesToString(policies)).toEqual(expectedString); + }); + }); + + describe('typedPoliciesToString', () => { + it('should convert one permission policy to string', () => { + const policies = [ + ['user:default/some-user', 'catalog-entity', 'read', 'allow'], + ]; + const type = 'p'; + const expectedString = `\n p, user:default/some-user, catalog-entity, read, allow\n `; + + expect(typedPoliciesToString(policies, type)).toEqual(expectedString); + }); + + it('should convert empty permission policy array to string', () => { + const policies = [[]]; + const expectedString = `\n \n `; + const type = 'p'; + expect(typedPoliciesToString(policies, type)).toEqual(expectedString); + }); + }); + + describe('metadataStringToPolicy', () => { + it('parses a permission policy string', () => { + const policy = '[user:default/some-user, catalog-entity, read, allow]'; + const expectedPolicy = [ + 'user:default/some-user', + 'catalog-entity', + 'read', + 'allow', + ]; + expect(metadataStringToPolicy(policy)).toEqual(expectedPolicy); + }); + + it('parses a grouping policy', () => { + const policy = '[user:default/some-user, role:default/dev]'; + const expectedPolicy = ['user:default/some-user', 'role:default/dev']; + expect(metadataStringToPolicy(policy)).toEqual(expectedPolicy); + }); + }); + + describe('syncRolePolicies', () => { + it('should add new policies when they are not in the enforcer', async () => { + const mockEnforcer = { + getFilteredPolicy: jest + .fn() + .mockResolvedValue([ + ['role:default/test', 'catalog-entity', 'read', 'allow'], + ]), + addPolicies: jest.fn().mockResolvedValue(true), + removePolicies: jest.fn().mockResolvedValue(true), + } as unknown as EnforcerDelegate; + + const desiredPolicies = [ + ['role:default/test', 'catalog-entity', 'read', 'allow'], + ['role:default/test', 'catalog-entity', 'update', 'allow'], + ]; + + await syncRolePolicies( + mockEnforcer, + 'role:default/test', + desiredPolicies, + ); + + expect(mockEnforcer.getFilteredPolicy).toHaveBeenCalledWith( + 0, + 'role:default/test', + ); + expect(mockEnforcer.addPolicies).toHaveBeenCalledWith([ + ['role:default/test', 'catalog-entity', 'update', 'allow'], + ]); + expect(mockEnforcer.removePolicies).not.toHaveBeenCalled(); + }); + + it('should remove old policies when they are not in desired', async () => { + const mockEnforcer = { + getFilteredPolicy: jest.fn().mockResolvedValue([ + ['role:default/test', 'catalog-entity', 'read', 'allow'], + ['role:default/test', 'catalog-entity', 'delete', 'allow'], + ]), + addPolicies: jest.fn().mockResolvedValue(true), + removePolicies: jest.fn().mockResolvedValue(true), + } as unknown as EnforcerDelegate; + + const desiredPolicies = [ + ['role:default/test', 'catalog-entity', 'read', 'allow'], + ]; + + await syncRolePolicies( + mockEnforcer, + 'role:default/test', + desiredPolicies, + ); + + expect(mockEnforcer.getFilteredPolicy).toHaveBeenCalledWith( + 0, + 'role:default/test', + ); + expect(mockEnforcer.removePolicies).toHaveBeenCalledWith([ + ['role:default/test', 'catalog-entity', 'delete', 'allow'], + ]); + expect(mockEnforcer.addPolicies).not.toHaveBeenCalled(); + }); + + it('should add and remove policies to sync to desired state', async () => { + const mockEnforcer = { + getFilteredPolicy: jest.fn().mockResolvedValue([ + ['role:default/test', 'catalog-entity', 'read', 'allow'], + ['role:default/test', 'catalog-entity', 'delete', 'allow'], + ]), + addPolicies: jest.fn().mockResolvedValue(true), + removePolicies: jest.fn().mockResolvedValue(true), + } as unknown as EnforcerDelegate; + + const desiredPolicies = [ + ['role:default/test', 'catalog-entity', 'read', 'allow'], + ['role:default/test', 'catalog-entity', 'update', 'allow'], + ]; + + await syncRolePolicies( + mockEnforcer, + 'role:default/test', + desiredPolicies, + ); + + expect(mockEnforcer.getFilteredPolicy).toHaveBeenCalledWith( + 0, + 'role:default/test', + ); + expect(mockEnforcer.addPolicies).toHaveBeenCalledWith([ + ['role:default/test', 'catalog-entity', 'update', 'allow'], + ]); + expect(mockEnforcer.removePolicies).toHaveBeenCalledWith([ + ['role:default/test', 'catalog-entity', 'delete', 'allow'], + ]); + }); + + it('should do nothing when current and desired policies match', async () => { + const mockEnforcer = { + getFilteredPolicy: jest.fn().mockResolvedValue([ + ['role:default/test', 'catalog-entity', 'read', 'allow'], + ['role:default/test', 'catalog-entity', 'update', 'allow'], + ]), + addPolicies: jest.fn().mockResolvedValue(true), + removePolicies: jest.fn().mockResolvedValue(true), + } as unknown as EnforcerDelegate; + + const desiredPolicies = [ + ['role:default/test', 'catalog-entity', 'read', 'allow'], + ['role:default/test', 'catalog-entity', 'update', 'allow'], + ]; + + await syncRolePolicies( + mockEnforcer, + 'role:default/test', + desiredPolicies, + ); + + expect(mockEnforcer.getFilteredPolicy).toHaveBeenCalledWith( + 0, + 'role:default/test', + ); + expect(mockEnforcer.addPolicies).not.toHaveBeenCalled(); + expect(mockEnforcer.removePolicies).not.toHaveBeenCalled(); + }); + + it('should handle empty current policies', async () => { + const mockEnforcer = { + getFilteredPolicy: jest.fn().mockResolvedValue([]), + addPolicies: jest.fn().mockResolvedValue(true), + removePolicies: jest.fn().mockResolvedValue(true), + } as unknown as EnforcerDelegate; + + const desiredPolicies = [ + ['role:default/test', 'catalog-entity', 'read', 'allow'], + ]; + + await syncRolePolicies( + mockEnforcer, + 'role:default/test', + desiredPolicies, + ); + + expect(mockEnforcer.addPolicies).toHaveBeenCalledWith(desiredPolicies); + expect(mockEnforcer.removePolicies).not.toHaveBeenCalled(); + }); + + it('should handle empty desired policies', async () => { + const mockEnforcer = { + getFilteredPolicy: jest + .fn() + .mockResolvedValue([ + ['role:default/test', 'catalog-entity', 'read', 'allow'], + ]), + addPolicies: jest.fn().mockResolvedValue(true), + removePolicies: jest.fn().mockResolvedValue(true), + } as unknown as EnforcerDelegate; + + const desiredPolicies: string[][] = []; + + await syncRolePolicies( + mockEnforcer, + 'role:default/test', + desiredPolicies, + ); + + expect(mockEnforcer.removePolicies).toHaveBeenCalledWith([ + ['role:default/test', 'catalog-entity', 'read', 'allow'], + ]); + expect(mockEnforcer.addPolicies).not.toHaveBeenCalled(); + }); + }); + + describe('transformPolicyGroupToLowercase', () => { + it.each([ + [ + ['g', 'user:default/TOM', 'role:default/CATALOG-USER'], + ['g', 'user:default/tom', 'role:default/CATALOG-USER'], + ], + [ + ['g', 'group:default/Developers', 'role:default/CATALOG-USER'], + ['g', 'group:default/developers', 'role:default/CATALOG-USER'], + ], + ])('should convert group in %s to lowercase', (input, expected) => { + transformPolicyGroupToLowercase(input); + expect(input).toEqual(expected); + }); + + it('should not transform policy to lowercase', () => { + const policyArray = [ + 'p', + 'role:default/CATALOG-USER', + 'catalog-entity', + 'read', + 'allow', + ]; + const expected = [...policyArray]; + transformPolicyGroupToLowercase(policyArray); + expect(policyArray).toEqual(expected); + }); + + it('should handle invalid input', () => { + const policyArray = ['g']; + transformPolicyGroupToLowercase(policyArray); + expect(policyArray).toEqual(['g']); + }); + }); + + describe('transformRolesGroupToLowercase', () => { + it('should convert users and groups in roles to lowercase', () => { + const roles = [ + ['user:default/test', 'role:default/test-provider'], + ['group:default/Developers', 'role:default/Reader'], + ]; + const expectedRoles = [ + ['user:default/test', 'role:default/test-provider'], + ['group:default/developers', 'role:default/Reader'], + ]; + expect(transformRolesGroupToLowercase(roles)).toEqual(expectedRoles); + }); + + it.each([[[['user:default/test']]], [[[]]]])( + 'should handle invalid input %d', + input => { + const result = transformRolesGroupToLowercase(input); + expect(result).toEqual(input); + }, + ); + }); + + describe('removeTheDifference', () => { + const mockEnforcerDelegate: Partial = { + removeGroupingPolicies: jest.fn().mockImplementation(), + getFilteredGroupingPolicy: jest.fn().mockReturnValue([]), + }; + + beforeEach(() => { + (mockEnforcerDelegate.removeGroupingPolicies as jest.Mock).mockClear(); + clearAuditorMock(); + }); + + it('removes the difference between originalGroup and addedGroup', async () => { + const originalGroup = [ + 'user:default/some-user', + 'user:default/dev', + 'user:default/admin', + ]; + const addedGroup = ['user:default/some-user', 'user:default/dev']; + const source = 'rest'; + const roleName = 'role:default/admin'; + + await removeTheDifference( + originalGroup, + addedGroup, + source, + roleName, + mockEnforcerDelegate as EnforcerDelegate, + mockAuditorService, + ADMIN_ROLE_AUTHOR, + ); + + expect(mockEnforcerDelegate.removeGroupingPolicies).toHaveBeenCalledWith( + [['user:default/admin', roleName]], + { + modifiedBy: ADMIN_ROLE_AUTHOR, + roleEntityRef: 'role:default/admin', + source: 'rest', + }, + false, + ); + }); + + it('does nothing when originalGroup and addedGroup are the same', async () => { + const originalGroup = ['user:default/some-user', 'user:default/dev']; + const addedGroup = ['user:default/some-user', 'user:default/dev']; + const source = 'rest'; + const roleName = 'role:default/admin'; + + await removeTheDifference( + originalGroup, + addedGroup, + source, + roleName, + mockEnforcerDelegate as EnforcerDelegate, + mockAuditorService, + ADMIN_ROLE_AUTHOR, + ); + + expect( + mockEnforcerDelegate.removeGroupingPolicies, + ).not.toHaveBeenCalled(); + }); + + it('does nothing when originalGroup is empty', async () => { + const originalGroup: string[] = []; + const addedGroup = ['user:default/some-user', 'role:default/dev']; + const source = 'rest'; + const roleName = 'admin'; + + await removeTheDifference( + originalGroup, + addedGroup, + source, + roleName, + mockEnforcerDelegate as EnforcerDelegate, + mockAuditorService, + ADMIN_ROLE_AUTHOR, + ); + + expect( + mockEnforcerDelegate.removeGroupingPolicies, + ).not.toHaveBeenCalled(); + }); + }); + + describe('transformArrayToPolicy', () => { + it('transforms array to RoleBasedPolicy object', () => { + const policyArray = [ + 'role:default/dev', + 'catalog-entity', + 'read', + 'allow', + ]; + const expectedPolicy = { + entityReference: 'role:default/dev', + permission: 'catalog-entity', + policy: 'read', + effect: 'allow', + }; + + const result = transformArrayToPolicy(policyArray); + + expect(result).toEqual(expectedPolicy); + }); + }); + + describe('deepSortedEqual', () => { + it('should return true for identical objects with nested properties in different order', () => { + const obj1: RoleMetadataDao = { + description: 'qa team', + id: 1, + roleEntityRef: 'role:default/qa', + source: 'rest', + modifiedBy, + }; + const obj2: RoleMetadataDao = { + roleEntityRef: 'role:default/qa', + description: 'qa team', + id: 1, + source: 'rest', + modifiedBy, + }; + expect(deepSortedEqual(obj1, obj2)).toBe(true); + }); + + it('should return true for identical objects with different ordering of top-level properties', () => { + const obj1: RoleMetadataDao = { + description: 'qa team', + id: 1, + roleEntityRef: 'role:default/qa', + source: 'rest', + modifiedBy, + }; + const obj2: RoleMetadataDao = { + id: 1, + description: 'qa team', + source: 'rest', + roleEntityRef: 'role:default/qa', + modifiedBy, + }; + expect(deepSortedEqual(obj1, obj2)).toBe(true); + }); + + it('should return true for identical objects with different ordering of top-level properties with exclude read only fields', () => { + const obj1: RoleMetadataDao = { + description: 'qa team', + id: 1, + roleEntityRef: 'role:default/qa', + source: 'rest', + // read only properties + author: 'role:default/some-role', + modifiedBy: 'role:default/some-role', + createdAt: '2024-02-26 12:25:31+00', + lastModified: '2024-02-26 12:25:31+00', + }; + const obj2: RoleMetadataDao = { + id: 1, + description: 'qa team', + source: 'rest', + roleEntityRef: 'role:default/qa', + modifiedBy, + }; + expect( + deepSortedEqual(obj1, obj2, [ + 'author', + 'modifiedBy', + 'createdAt', + 'lastModified', + ]), + ).toBe(true); + }); + + it('should return false for objects with different values', () => { + const obj1: RoleMetadataDao = { + description: 'qa', + id: 1, + roleEntityRef: 'role:default/qa', + source: 'rest', + modifiedBy, + }; + const obj2: RoleMetadataDao = { + description: 'great qa', + id: 1, + roleEntityRef: 'role:default/qa', + source: 'rest', + modifiedBy, + }; + expect(deepSortedEqual(obj1, obj2)).toBe(false); + }); + + it('should return false for objects with different source', () => { + const obj1: RoleMetadataDao = { + description: 'qa teams', + id: 1, + roleEntityRef: 'role:default/qa', + source: 'rest', + modifiedBy, + }; + const obj2: RoleMetadataDao = { + description: 'qa teams', + id: 1, + roleEntityRef: 'role:default/qa', + source: 'configuration', + modifiedBy, + }; + expect(deepSortedEqual(obj1, obj2)).toBe(false); + }); + + it('should return false for objects with different id', () => { + const obj1: RoleMetadataDao = { + description: 'qa teams', + id: 1, + roleEntityRef: 'role:default/qa', + source: 'rest', + modifiedBy, + }; + const obj2: RoleMetadataDao = { + description: 'qa teams', + id: 2, + roleEntityRef: 'role:default/qa', + source: 'rest', + modifiedBy, + }; + expect(deepSortedEqual(obj1, obj2)).toBe(false); + }); + + it('should return false for objects with different role entity reference', () => { + const obj1: RoleMetadataDao = { + description: 'qa teams', + id: 1, + roleEntityRef: 'role:default/qa', + source: 'rest', + modifiedBy, + }; + const obj2: RoleMetadataDao = { + description: 'qa teams', + id: 1, + roleEntityRef: 'role:default/dev', + source: 'rest', + modifiedBy, + }; + expect(deepSortedEqual(obj1, obj2)).toBe(false); + }); + }); + + describe('isPermissionAction', () => { + it('should return true', () => { + let result = isPermissionAction('create'); + expect(result).toBeTruthy(); + + result = isPermissionAction('read'); + expect(result).toBeTruthy(); + + result = isPermissionAction('update'); + expect(result).toBeTruthy(); + + result = isPermissionAction('delete'); + expect(result).toBeTruthy(); + + result = isPermissionAction('use'); + expect(result).toBeTruthy(); + }); + + it('should return false', () => { + const result = isPermissionAction('unknown'); + expect(result).toBeFalsy(); + }); + }); + + describe('matches', () => { + const anyOfFilter: RBACFilters = { + anyOf: [ + { + key: 'owner', + values: ['user:default/some_user'], + }, + ], + }; + + const allOfFilter: RBACFilters = { + allOf: [ + { + key: 'owner', + values: ['user:default/some_user'], + }, + ], + }; + + const notFilter: RBACFilters = { + not: { + key: 'owner', + values: ['user:default/some_user'], + }, + }; + + const matchedRole: RoleMetadata = { + owner: 'user:default/some_user', + }; + + const noMatchedRole: RoleMetadata = { + owner: 'user:default/some_other_user', + }; + + it('should return true when a filter is not supplied', () => { + expect(matches()).toBeTruthy(); + }); + + it('should return false whenever a filter is supplied but a role is not', () => { + expect(matches(undefined, anyOfFilter)).toBeFalsy(); + }); + + it('should return true with anyOf filter where role owner matches filter owner', () => { + expect(matches(matchedRole, anyOfFilter)).toBeTruthy(); + }); + + it('shoule return false with anyOf filter where role owner does not match filter owner', () => { + expect(matches(noMatchedRole, anyOfFilter)).toBeFalsy(); + }); + + it('should return true with allOf filter where role owner matches filter owner', () => { + expect(matches(matchedRole, allOfFilter)).toBeTruthy(); + }); + + it('shoule return false with allOf filter where role owner does not match filter owner', () => { + expect(matches(noMatchedRole, allOfFilter)).toBeFalsy(); + }); + + it('should return false with not filter where role owner matches filter owner', () => { + expect(matches(matchedRole, notFilter)).toBeFalsy(); + }); + + it('shoule return true with not filter where role owner does not match filter owner', () => { + expect(matches(noMatchedRole, notFilter)).toBeTruthy(); + }); + }); +}); + +describe('mergeRoleMetadata', () => { + it('should merge new metadata into current metadata', () => { + const currentMetadata: RoleMetadataDao = { + lastModified: '2021-01-01T00:00:00Z', + modifiedBy: 'user:default/user1', + description: 'Initial role description', + roleEntityRef: 'user:default/tim', + source: 'legacy', + }; + + const newMetadata: RoleMetadataDao = { + lastModified: '2022-01-01T00:00:00Z', + modifiedBy: 'user:default/user2', + description: 'Updated role description', + roleEntityRef: 'user:default/dev-team', + source: 'rest', + }; + + const expectedMergedMetadata: RoleMetadataDao = { + ...currentMetadata, + ...newMetadata, + }; + + const result = mergeRoleMetadata(currentMetadata, newMetadata); + + expect(result).toEqual(expectedMergedMetadata); + }); + + it('should use current metadata description if new metadata description is undefined', () => { + const currentMetadata: RoleMetadataDao = { + lastModified: '2021-01-01T00:00:00Z', + modifiedBy: 'user:default/user1', + description: 'Initial role description', + roleEntityRef: 'user:default/tim', + source: 'legacy', + }; + + const newMetadata: RoleMetadataDao = { + lastModified: '2022-01-01T00:00:00Z', + modifiedBy: 'user:default/user2', + roleEntityRef: 'user:default/dev-team', + source: 'csv-file', + }; + + const expectedMergedMetadata: RoleMetadataDao = { + ...currentMetadata, + ...newMetadata, + description: currentMetadata.description, + }; + + const result = mergeRoleMetadata(currentMetadata, newMetadata); + + expect(result).toEqual(expectedMergedMetadata); + }); + + it('should use current date if new metadata lastModified is undefined', () => { + const currentMetadata: RoleMetadataDao = { + lastModified: '2021-01-01T00:00:00Z', + modifiedBy: 'user:default/user1', + description: 'Initial role description', + roleEntityRef: 'user:default/tim', + source: 'legacy', + }; + + const newMetadata: RoleMetadataDao = { + modifiedBy: 'user:default/user2', + description: 'Updated role description', + roleEntityRef: 'user:default/dev-team', + source: 'configuration', + }; + + const result = mergeRoleMetadata(currentMetadata, newMetadata); + const resultDate = new Date(result.lastModified!); + expect(resultDate).toBeInstanceOf(Date); + expect(result.modifiedBy).toEqual(newMetadata.modifiedBy); + expect(result.description).toEqual(newMetadata.description); + expect(result.roleEntityRef).toEqual(newMetadata.roleEntityRef); + expect(result.source).toEqual(newMetadata.source); + }); + + it('should not modify original metadata objects', () => { + const currentMetadata: RoleMetadataDao = { + lastModified: '2021-01-01T00:00:00Z', + modifiedBy: 'user:default/user1', + description: 'Initial role description', + roleEntityRef: 'user:default/tim', + source: 'legacy', + }; + + const newMetadata: RoleMetadataDao = { + lastModified: '2022-01-01T00:00:00Z', + modifiedBy: 'user:default/user2', + description: 'Updated role description', + roleEntityRef: 'user:default/dev-team', + source: 'configuration', + }; + + const currentMetadataClone = { ...currentMetadata }; + const newMetadataClone = { ...newMetadata }; + + mergeRoleMetadata(currentMetadata, newMetadata); + + expect(currentMetadata).toEqual(currentMetadataClone); + expect(newMetadata).toEqual(newMetadataClone); + }); + + it('should use current date if new metadata createdAt is undefined', () => { + const currentMetadata: RoleMetadataDao = { + createdAt: '2021-01-01T00:00:00Z', + lastModified: '2021-01-01T00:00:00Z', + modifiedBy: 'user:default/user1', + description: 'Initial role description', + roleEntityRef: 'user:default/tim', + source: 'legacy', + }; + + const newMetadata: RoleMetadataDao = { + lastModified: '2022-01-01T00:00:00Z', + modifiedBy: 'user:default/user2', + description: 'Updated role description', + roleEntityRef: 'user:default/dev-team', + source: 'configuration', + }; + + const result = mergeRoleMetadata(currentMetadata, newMetadata); + const resultDate = new Date(result.createdAt!); + expect(resultDate).toBeInstanceOf(Date); + expect(result.lastModified).toEqual(newMetadata.lastModified); + expect(result.modifiedBy).toEqual(newMetadata.modifiedBy); + expect(result.description).toEqual(newMetadata.description); + expect(result.roleEntityRef).toEqual(newMetadata.roleEntityRef); + expect(result.source).toEqual(newMetadata.source); + }); +}); diff --git a/plugins/rbac-backend/src/helper.ts b/plugins/rbac-backend/src/helper.ts new file mode 100644 index 0000000000..f951534aae --- /dev/null +++ b/plugins/rbac-backend/src/helper.ts @@ -0,0 +1,354 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AuditorService, AuthService } from '@backstage/backend-plugin-api'; +import type { MetadataResponse } from '@backstage/plugin-permission-common'; + +import { + difference, + fromPairs, + isArray, + isEqual, + isPlainObject, + omitBy, + sortBy, + toPairs, + ValueKeyIteratee, +} from 'lodash'; + +import { + PermissionAction, + PermissionInfo, + RoleBasedPolicy, + RoleConditionalPolicyDecision, + Source, +} from '@backstage-community/plugin-rbac-common'; + +import { ActionType, RoleEvents } from './auditor/auditor'; +import { RoleMetadataDao, RoleMetadataStorage } from './database/role-metadata'; +import { EnforcerDelegate } from './service/enforcer-delegate'; +import { PluginPermissionMetadataCollector } from './service/plugin-endpoints'; +import { RoleMetadata } from '@backstage-community/plugin-rbac-common'; +import { RBACFilters } from './permissions'; + +export function policyToString(policy: string[]): string { + return `[${policy.join(', ')}]`; +} + +export function typedPolicyToString(policy: string[], type: string): string { + return `${type}, ${policy.join(', ')}`; +} + +export function policiesToString(policies: string[][]): string { + const policiesString = policies + .map(policy => policyToString(policy)) + .join(','); + return `[${policiesString}]`; +} + +export function typedPoliciesToString( + policies: string[][], + type: string, +): string { + const policiesString = policies + .map(policy => { + return policy.length !== 0 ? typedPolicyToString(policy, type) : ''; + }) + .join('\n'); + return ` + ${policiesString} + `; +} + +export function metadataStringToPolicy(policy: string): string[] { + return policy.replace('[', '').replace(']', '').split(', '); +} + +/** + * Compares two policy arrays (e.g. [entityRef, permission, policy, effect]) for equality. + */ +function policyArraysEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +/** + * Syncs permission policies for a role to match a desired set. + * - Adds policies that are in desired but not in the enforcer (addPolicies skips existing via hasPolicy). + * - Removes policies that are in the enforcer but not in desired. + * + * @param enforcerDelegate - Enforcer to read from and write to + * @param roleEntityRef - Role to sync (used to load current policies via getFilteredPolicy(0, roleEntityRef)) + * @param desiredPolicies - Desired policies in casbin format string[][] + */ +export async function syncRolePolicies( + enforcerDelegate: EnforcerDelegate, + roleEntityRef: string, + desiredPolicies: string[][], +): Promise { + const current = await enforcerDelegate.getFilteredPolicy(0, roleEntityRef); + + const toAdd = desiredPolicies.filter( + d => !current.some(c => policyArraysEqual(c, d)), + ); + const toRemove = current.filter( + c => !desiredPolicies.some(d => policyArraysEqual(c, d)), + ); + + if (toAdd.length > 0) { + await enforcerDelegate.addPolicies(toAdd); + } + if (toRemove.length > 0) { + await enforcerDelegate.removePolicies(toRemove); + } +} + +export async function removeTheDifference( + originalGroup: string[], + addedGroup: string[], + source: Source, + roleEntityRef: string, + enf: EnforcerDelegate, + auditor: AuditorService, + modifiedBy: string, +): Promise { + originalGroup.sort((a, b) => a.localeCompare(b)); + addedGroup.sort((a, b) => a.localeCompare(b)); + const missing = difference(originalGroup, addedGroup); + + const groupPolicies: string[][] = []; + for (const missingRole of missing) { + groupPolicies.push([missingRole, roleEntityRef]); + } + + if (groupPolicies.length === 0) { + return; + } + + const roleMetadata = { source, modifiedBy, roleEntityRef }; + const existingMembers = await enf.getFilteredGroupingPolicy(1, roleEntityRef); + const actionType = + existingMembers.length === missing.length + ? ActionType.DELETE + : ActionType.UPDATE; + const auditorMeta = { + ...roleMetadata, + members: groupPolicies.map(gp => gp[0]), + }; + const auditorEvent = await auditor.createEvent({ + eventId: RoleEvents.ROLE_WRITE, + severityLevel: 'medium', + meta: { actionType, source: auditorMeta.source }, + }); + + try { + await enf.removeGroupingPolicies(groupPolicies, roleMetadata, false); + await auditorEvent.success({ meta: auditorMeta }); + } catch (error) { + await auditorEvent.fail({ + error, + meta: auditorMeta, + }); + throw error; + } +} + +export function transformArrayToPolicy(policyArray: string[]): RoleBasedPolicy { + const [entityReference, permission, policy, effect] = policyArray; + return { entityReference, permission, policy, effect }; +} + +export function transformPolicyGroupToLowercase(policyArray: string[]) { + if ( + policyArray.length > 1 && + policyArray[0].startsWith('g') && + (policyArray[1].startsWith('user') || policyArray[1].startsWith('group')) + ) { + policyArray[1] = policyArray[1].toLocaleLowerCase('en-US'); + } +} + +export function transformRolesGroupToLowercase(roles: string[][]) { + return roles.map(role => + role.length >= 1 + ? [role[0].toLocaleLowerCase('en-US'), ...role.slice(1)] + : role, + ); +} + +export function deepSortedEqual( + obj1: Record, + obj2: Record, + excludeFields?: string[], +): boolean { + let copyObj1; + let copyObj2; + if (excludeFields) { + const excludeFieldsPredicate: ValueKeyIteratee = (_value, key) => { + for (const field of excludeFields) { + if (key === field) { + return true; + } + } + return false; + }; + copyObj1 = omitBy(obj1, excludeFieldsPredicate); + copyObj2 = omitBy(obj2, excludeFieldsPredicate); + } + + const sortedObj1 = sortBy(toPairs(copyObj1 || obj1), ([key]) => key); + const sortedObj2 = sortBy(toPairs(copyObj2 || obj2), ([key]) => key); + + return isEqual(sortedObj1, sortedObj2); +} + +export function isPermissionAction(action: string): action is PermissionAction { + return ['create', 'read', 'update', 'delete', 'use'].includes( + action as PermissionAction, + ); +} + +export async function buildRoleSourceMap( + policies: string[][], + roleMetadata: RoleMetadataStorage, +): Promise> { + return await policies.reduce( + async ( + acc: Promise>, + policy: string[], + ): Promise> => { + const roleEntityRef = policy[0]; + const acummulator = await acc; + if (!acummulator.has(roleEntityRef)) { + const metadata = await roleMetadata.findRoleMetadata(roleEntityRef); + acummulator.set(roleEntityRef, metadata?.source); + } + return acummulator; + }, + Promise.resolve(new Map()), + ); +} + +export function mergeRoleMetadata( + currentMetadata: RoleMetadataDao, + newMetadata: RoleMetadataDao, +): RoleMetadataDao { + const mergedMetaData: RoleMetadataDao = { ...currentMetadata }; + mergedMetaData.lastModified = + newMetadata.lastModified ?? new Date().toUTCString(); + mergedMetaData.modifiedBy = newMetadata.modifiedBy; + mergedMetaData.description = + newMetadata.description ?? currentMetadata.description; + mergedMetaData.roleEntityRef = newMetadata.roleEntityRef; + mergedMetaData.source = newMetadata.source; + mergedMetaData.owner = newMetadata.owner ?? currentMetadata.owner; + return mergedMetaData; +} + +export async function processConditionMapping( + roleConditionPolicy: RoleConditionalPolicyDecision, + pluginPermMetaData: PluginPermissionMetadataCollector, + auth: AuthService, +): Promise> { + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: await auth.getOwnServiceCredentials(), + targetPluginId: roleConditionPolicy.pluginId, + }); + + const rule: MetadataResponse | undefined = + await pluginPermMetaData.getMetadataByPluginId( + roleConditionPolicy.pluginId, + token, + ); + if (!rule?.permissions) { + throw new Error( + `Unable to get permission list for plugin ${roleConditionPolicy.pluginId}`, + ); + } + + const permInfo: PermissionInfo[] = []; + for (const action of roleConditionPolicy.permissionMapping) { + const perm = rule.permissions.find(permission => { + if (permission.type === 'resource') { + const isCorrectResourceType = + permission.resourceType === roleConditionPolicy.resourceType; + const isCorrectAction = action === permission.attributes.action; + const undefinedAction = + action === 'use' && permission.attributes.action === undefined; + + return isCorrectResourceType && (isCorrectAction || undefinedAction); + } + return false; + }); + + if (!perm) { + throw new Error( + `Unable to find permission to get permission name for resource type '${ + roleConditionPolicy.resourceType + }' and action ${JSON.stringify(action)}`, + ); + } + permInfo.push({ name: perm.name, action }); + } + + return { + ...roleConditionPolicy, + permissionMapping: permInfo, + }; +} + +export function deepSort(value: any): any { + if (isArray(value)) { + return sortBy(value.map(deepSort)); + } else if (isPlainObject(value)) { + return fromPairs( + sortBy( + toPairs(value).map(([k, v]: [string, any]) => [k, deepSort(v)]), + 0, + ), + ); + } + return value; +} + +export function deepSortEqual(obj1: any, obj2: any): boolean { + return isEqual(deepSort(obj1), deepSort(obj2)); +} + +export const matches = ( + role?: RoleMetadata, + filters?: RBACFilters, +): boolean => { + if (!filters) { + return true; + } + + if (!role) { + return false; + } + + if ('allOf' in filters) { + return filters.allOf.every(filter => matches(role, filter)); + } + + if ('anyOf' in filters) { + return filters.anyOf.some(filter => matches(role, filter)); + } + + if ('not' in filters) { + return !matches(role, filters.not); + } + + return filters.values.includes(role.owner); +}; diff --git a/plugins/rbac-backend/src/index.ts b/plugins/rbac-backend/src/index.ts new file mode 100644 index 0000000000..de60add2ae --- /dev/null +++ b/plugins/rbac-backend/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './service/router'; +export * from './service/policy-builder'; + +// To provide backward compatibility with client code implemented +// before PluginIdProvider was moved to @backstage-community/plugin-rbac-node. +export type { PluginIdProvider } from '@backstage-community/plugin-rbac-node'; + +export { rbacPlugin as default } from './plugin'; diff --git a/plugins/rbac-backend/src/permissions/conditions.ts b/plugins/rbac-backend/src/permissions/conditions.ts new file mode 100644 index 0000000000..2a55f8f232 --- /dev/null +++ b/plugins/rbac-backend/src/permissions/conditions.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { RESOURCE_TYPE_POLICY_ENTITY } from '@backstage-community/plugin-rbac-common'; +import type { + ConditionalPolicyDecision, + PermissionCondition, + PermissionCriteria, + ResourcePermission, +} from '@backstage/plugin-permission-common'; +import { + ConditionTransformer, + createConditionExports, + createConditionTransformer, +} from '@backstage/plugin-permission-node'; +import { PermissionsRegistryService } from '@backstage/backend-plugin-api'; + +import { permissionMetadataResourceRef } from './resource'; +import { rules, RBACFilter } from './rules'; + +const { conditions, createConditionalDecision } = createConditionExports({ + resourceRef: permissionMetadataResourceRef, + rules, +}); + +export const rbacConditions = conditions; + +export const createRBACConditionalDecision: ( + permission: ResourcePermission, + conditions: PermissionCriteria< + PermissionCondition + >, +) => ConditionalPolicyDecision = createConditionalDecision; + +export const conditionTransformerFunc: ( + permissionRegistry: PermissionsRegistryService, +) => ConditionTransformer = ( + permissionRegistry: PermissionsRegistryService, +) => + createConditionTransformer( + permissionRegistry.getPermissionRuleset(permissionMetadataResourceRef), + ); diff --git a/plugins/rbac-backend/src/permissions/index.ts b/plugins/rbac-backend/src/permissions/index.ts new file mode 100644 index 0000000000..d519cc542c --- /dev/null +++ b/plugins/rbac-backend/src/permissions/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './conditions'; +export * from './rules'; diff --git a/plugins/rbac-backend/src/permissions/resource.ts b/plugins/rbac-backend/src/permissions/resource.ts new file mode 100644 index 0000000000..8cdd9392e7 --- /dev/null +++ b/plugins/rbac-backend/src/permissions/resource.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { RESOURCE_TYPE_POLICY_ENTITY } from '@backstage-community/plugin-rbac-common'; +import { createPermissionResourceRef } from '@backstage/plugin-permission-node'; +import { RBACFilter } from './rules'; +import { RoleMetadataDao } from '../database/role-metadata'; + +/** + * Reference to the RBAC permission metadata resource. + * This is used to create RBAC permissions and conditions. + * + */ +export const permissionMetadataResourceRef = createPermissionResourceRef< + RoleMetadataDao, + RBACFilter +>().with({ + pluginId: 'permission', + resourceType: RESOURCE_TYPE_POLICY_ENTITY, +}); diff --git a/plugins/rbac-backend/src/permissions/rules.ts b/plugins/rbac-backend/src/permissions/rules.ts new file mode 100644 index 0000000000..531b1db750 --- /dev/null +++ b/plugins/rbac-backend/src/permissions/rules.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { RESOURCE_TYPE_POLICY_ENTITY } from '@backstage-community/plugin-rbac-common'; +import { PermissionRule } from '@backstage/plugin-permission-node'; +import { z as zod3 } from 'zod/v3'; +import { z as zod4 } from 'zod/v4'; + +import { RoleMetadataDao } from '../database/role-metadata'; + +/** + * The RBACFilter is a simple filter without any conditional criteria. + * + */ +export type RBACFilter = { + key: string; + values: any[]; +}; + +/** + * The RBACFilters type is a recursive type that can be used to create complex filter structures. + * It can be used to create filters that are a combination of other filters, or a negation of a filter. + * + */ +export type RBACFilters = + | { anyOf: RBACFilters[] } + | { allOf: RBACFilters[] } + | { not: RBACFilters } + | RBACFilter; + +type IsOwnerParams = { + owners: string[]; +}; + +const isOwnerParamsSchema: zod3.ZodType = zod3.object({ + owners: zod3 + .string() + .array() + .describe('List of entity refs to match against'), +}); + +const isOwner = { + name: 'IS_OWNER', + description: + 'Should allow access to RBAC roles and Permissions through ownership', + resourceType: RESOURCE_TYPE_POLICY_ENTITY, + paramsSchema: isOwnerParamsSchema, + apply: (roleMeta: RoleMetadataDao, { owners }: IsOwnerParams) => { + if (roleMeta.isDefault) { + return true; + } + if (!roleMeta.owner) { + return false; + } + return owners.includes(roleMeta.owner); + }, + toQuery: ({ owners }: IsOwnerParams) => ({ + key: 'owners', + values: owners, + }), +} as unknown as PermissionRule< + RoleMetadataDao, + RBACFilter, + typeof RESOURCE_TYPE_POLICY_ENTITY, + IsOwnerParams +>; + +export const rbacRules = { + name: isOwner.name, + description: isOwner.description, + resourceType: isOwner.resourceType, + paramsSchema: zod4 + .object({ + owners: zod4 + .string() + .array() + .describe('List of entity refs to match against'), + }) + .toJSONSchema(), +}; + +export const rules = { isOwner }; diff --git a/plugins/rbac-backend/src/plugin.ts b/plugins/rbac-backend/src/plugin.ts new file mode 100644 index 0000000000..3e9026fe86 --- /dev/null +++ b/plugins/rbac-backend/src/plugin.ts @@ -0,0 +1,123 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; + +import { PolicyBuilder } from './service/policy-builder'; +import { + PluginIdProvider, + PluginIdProviderExtensionPoint, + pluginIdProviderExtensionPoint, + RBACProvider, + rbacProviderExtensionPoint, +} from '@backstage-community/plugin-rbac-node'; + +import { policyExtensionPoint } from '@backstage/plugin-permission-node/alpha'; + +/** + * @public + * RBAC plugin + * + */ +export const rbacPlugin = createBackendModule({ + pluginId: 'permission', + moduleId: 'rbac', + register(env) { + const pluginIdProviderExtensionPointImpl = + new (class PluginIdProviderImpl implements PluginIdProviderExtensionPoint { + pluginIdProviders: PluginIdProvider[] = []; + + addPluginIdProvider(pluginIdProvider: PluginIdProvider): void { + this.pluginIdProviders.push(pluginIdProvider); + } + })(); + + env.registerExtensionPoint( + pluginIdProviderExtensionPoint, + pluginIdProviderExtensionPointImpl, + ); + + const rbacProviders = new Array(); + + env.registerExtensionPoint(rbacProviderExtensionPoint, { + addRBACProvider( + ...providers: Array> + ): void { + rbacProviders.push(...providers.flat()); + }, + }); + + env.registerInit({ + deps: { + http: coreServices.httpRouter, + config: coreServices.rootConfig, + logger: coreServices.logger, + discovery: coreServices.discovery, + permissions: coreServices.permissions, + auth: coreServices.auth, + httpAuth: coreServices.httpAuth, + auditor: coreServices.auditor, + userInfo: coreServices.userInfo, + lifecycle: coreServices.lifecycle, + permissionsRegistry: coreServices.permissionsRegistry, + policy: policyExtensionPoint, + }, + async init({ + http, + config, + logger, + discovery, + permissions, + auth, + httpAuth, + auditor, + lifecycle, + permissionsRegistry: permissionsRegistry, + policy, + }) { + http.use( + await PolicyBuilder.build( + { + config, + logger, + discovery, + permissions, + auth, + httpAuth, + auditor, + lifecycle, + permissionsRegistry: permissionsRegistry, + policy, + }, + { + getPluginIds: () => + Array.from( + new Set( + pluginIdProviderExtensionPointImpl.pluginIdProviders.flatMap( + p => p.getPluginIds(), + ), + ), + ), + }, + rbacProviders, + ), + ); + }, + }); + }, +}); diff --git a/plugins/rbac-backend/src/policies/allow-all-policy.test.ts b/plugins/rbac-backend/src/policies/allow-all-policy.test.ts new file mode 100644 index 0000000000..fad05fe2f8 --- /dev/null +++ b/plugins/rbac-backend/src/policies/allow-all-policy.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + AuthorizeResult, + createPermission, +} from '@backstage/plugin-permission-common'; +import { + PermissionPolicy, + PolicyQuery, + PolicyQueryUser, +} from '@backstage/plugin-permission-node'; + +import { AllowAllPolicy } from './allow-all-policy'; + +describe('Allow All Policy', () => { + describe('Allow all policy should allow all', () => { + let policy: PermissionPolicy; + beforeEach(() => { + policy = new AllowAllPolicy(); + }); + + it('should be able to create an allow all permission policy', () => { + expect(policy).not.toBeNull(); + }); + + it('should allow all when handle is called', async () => { + const result = await policy.handle( + newPolicyQueryWithBasicPermission('catalog.entity.create'), + newPolicyQueryUser('user:default/guest'), + ); + + expect(result).toStrictEqual({ result: AuthorizeResult.ALLOW }); + }); + }); +}); + +function newPolicyQueryWithBasicPermission(name: string): PolicyQuery { + const mockPermission = createPermission({ + name: name, + attributes: {}, + }); + return { permission: mockPermission }; +} + +function newPolicyQueryUser( + user?: string, + ownershipEntityRefs?: string[], +): PolicyQueryUser | undefined { + if (user) { + return { + identity: { + ownershipEntityRefs: ownershipEntityRefs ?? [], + type: 'user', + userEntityRef: user, + }, + credentials: { + $$type: '@backstage/BackstageCredentials', + principal: true, + expiresAt: new Date('2021-01-01T00:00:00Z'), + }, + info: { + userEntityRef: user, + ownershipEntityRefs: ownershipEntityRefs ?? [], + }, + token: 'token', + }; + } + return undefined; +} diff --git a/plugins/rbac-backend/src/policies/allow-all-policy.ts b/plugins/rbac-backend/src/policies/allow-all-policy.ts new file mode 100644 index 0000000000..99c962d2be --- /dev/null +++ b/plugins/rbac-backend/src/policies/allow-all-policy.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + AuthorizeResult, + PolicyDecision, +} from '@backstage/plugin-permission-common'; +import { + PermissionPolicy, + PolicyQuery, + PolicyQueryUser, +} from '@backstage/plugin-permission-node'; + +export class AllowAllPolicy implements PermissionPolicy { + async handle( + _request: PolicyQuery, + _user?: PolicyQueryUser, + ): Promise { + return { result: AuthorizeResult.ALLOW }; + } +} diff --git a/plugins/rbac-backend/src/policies/permission-policy.hierarchy.test.ts b/plugins/rbac-backend/src/policies/permission-policy.hierarchy.test.ts new file mode 100644 index 0000000000..10d6d2568a --- /dev/null +++ b/plugins/rbac-backend/src/policies/permission-policy.hierarchy.test.ts @@ -0,0 +1,1123 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { LoggerService } from '@backstage/backend-plugin-api'; +import { mockServices } from '@backstage/backend-test-utils'; +import { Config } from '@backstage/config'; +import { + AuthorizeResult, + createPermission, +} from '@backstage/plugin-permission-common'; +import type { + PolicyQuery, + PolicyQueryUser, +} from '@backstage/plugin-permission-node'; + +import { + Adapter, + Enforcer, + Model, + newEnforcer, + newModelFromString, +} from 'casbin'; +import * as Knex from 'knex'; +import { MockClient } from 'knex-mock-client'; + +import { resolve } from 'path'; + +import { + mockAuditorService, + conditionalStorageMock, + csvPermFile, + mockAuthService, + mockClientKnex, + pluginMetadataCollectorMock, + roleMetadataStorageMock, + catalogMock, +} from '../../__fixtures__/mock-utils'; +import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; +import { RoleMetadataStorage } from '../database/role-metadata'; +import { BackstageRoleManager } from '../role-manager/role-manager'; +import { DefaultPermissionsReader } from '../default-permissions/default-permissions'; +import { EnforcerDelegate } from '../service/enforcer-delegate'; +import { MODEL } from '../service/permission-model'; +import { PluginPermissionMetadataCollector } from '../service/plugin-endpoints'; +import { RBACPermissionPolicy } from './permission-policy'; +import { + clearAuditorMock, + expectAuditorLogForPermission, +} from '../../__fixtures__/auditor-test-utils'; + +type PermissionAction = 'create' | 'read' | 'update' | 'delete'; + +/** + * Group, user, role, and permission information can be found under `__fixtures__/data/hierarchy/` + * More information can be found at `examples/manual-tests/rbac` at the root of the workspace + * Included is a txt file with charts for the hierarchy levels for visualization + */ +describe('Policy checks for users and groups', () => { + let policy: RBACPermissionPolicy; + + beforeEach(async () => { + const policyChecksCSV = resolve( + __dirname, + '../../__fixtures__/data/hierarchy/rbac-policy.csv', + ); + const config = newConfig(policyChecksCSV); + const adapter = await newAdapter(config); + + const enfDelegate = await newEnforcerDelegate(adapter, config); + + policy = await newPermissionPolicy(config, enfDelegate); + }); + + // Simple user to role tests + it('case 1, user directly assigned to allow role', async () => { + const userEntity = 'user:default/ant_man'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('case 2, user directly assigned to deny role', async () => { + const userEntity = 'user:default/hulk'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + // Simple group to role tests + it('case 3, group assigned to allow role', async () => { + const userEntity = 'user:default/thor'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('case 4, group assigned to deny role', async () => { + const userEntity = 'user:default/wasp'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + // Group hierarchy tests with a two level hierarchy + it('case 5, group hierarchy test where furthest group is assigned to allow role', async () => { + const userEntity = 'user:default/moon_knight'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('case 6, group hierarchy test where furthest group is assigned to deny role', async () => { + const userEntity = 'user:default/spiderman'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 7, group hierarchy test where the closest group is assigned allow role, furthest group is assigned allow role', async () => { + const userEntity = 'user:default/captain_america'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('case 8, group hierarchy test where the closest group is assigned deny role, furthest group is assigned deny role', async () => { + const userEntity = 'user:default/hawkeye'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 9, group hierarchy test where the closest group is assigned deny role, furthest group is assigned allow role', async () => { + const userEntity = 'user:default/quicksilver'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 10, group hierarchy test where the closest group is assigned allow role, furthest group is assigned deny role', async () => { + const userEntity = 'user:default/scarlet_witch'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + // Branching tests + it('case 11, branching test where user is directly assigned to allow role and group is directly assigned to allow role', async () => { + const userEntity = 'user:default/swordsman'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('case 12, branching test where user is directly assigned to deny role and group is directly assigned to deny role', async () => { + const userEntity = 'user:default/hercules'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 13, branching test where user is directly assigned to deny role and group is directly assigned to allow role', async () => { + const userEntity = 'user:default/black_panther'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 14, branching test where user is directly assigned to allow role and group is directly assigned to deny role', async () => { + const userEntity = 'user:default/vision'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + // Branching tests with group role assignment + it('case 15, branching test where top group assigned to allow role and right group is assigned to allow role', async () => { + const userEntity = 'user:default/black_knight'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('case 16, branching test where top group assigned to deny role and right group is assigned to deny role', async () => { + const userEntity = 'user:default/black_widow'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 17, branching test where top group assigned to deny role and right group is assigned to allow role', async () => { + const userEntity = 'user:default/mantis'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 18, branching test where top group assigned to allow role and right group is assigned to deny role', async () => { + const userEntity = 'user:default/beast'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + // Branching tests with two level group role assignment + it('case 19, branching test where fruthest top group assigned to allow role and furthest right group is assigned to allow role', async () => { + const userEntity = 'user:default/moondragon'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('case 20, branching test where fruthest top group assigned to deny role and furthest right group is assigned to deny role', async () => { + const userEntity = 'user:default/hellcat'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 21, branching test where fruthest top group assigned to deny role and furthest right group is assigned to allow role', async () => { + const userEntity = 'user:default/captain_marvel'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 22, branching test where fruthest top group assigned to allow role and furthest right group is assigned to deny role', async () => { + const userEntity = 'user:default/falcon'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + // Group hierarchy with cyclic behavior + // TODO: get the logger for all cyclic behavior tests + it('case 23, cyclic behavior between two groups with one group assigned to allow role', async () => { + const userEntity = 'user:default/wonder_man'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 24, cyclic behavior between two groups with one group assigned to deny role', async () => { + const userEntity = 'user:default/tigra'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + // Branching tests with cyclic behavior + it('case 25, branching test where closest group is assigned to allow role and cyclic behavior between two groups with one group assigned to allow role', async () => { + const userEntity = 'user:default/she_hulk'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 26, branching test where closest group is assigned to deny role and cyclic behavior between two groups with one group assigned to deny role', async () => { + const userEntity = 'user:default/starfox'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 27, branching test where closest group is assigned to deny role and cyclic behavior between two groups with one group assigned to allow role', async () => { + const userEntity = 'user:default/mockingbird'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 28, branching test where closest group is assigned to allow role and cyclic behavior between two groups with one group assigned to deny role', async () => { + const userEntity = 'user:default/war_machine'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + // Branching tests with two level group hierarchy and both branches have cyclic behavior + it('case 29, branching test where top group is assigned to allow role and right group is assigned allow role, both branches have cyclic behavior', async () => { + const userEntity = 'user:default/namor'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 30, branching test where top group is assigned to deny role and right group is assigned deny role, both branches have cyclic behavior', async () => { + const userEntity = 'user:default/thing'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 31, branching test where top group is assigned to deny role and right group is assigned allow role, both branches have cyclic behavior', async () => { + const userEntity = 'user:default/doctor_druid'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 32, branching test where top group is assigned to allow role and right group is assigned deny role, both branches have cyclic behavior', async () => { + const userEntity = 'user:default/firebird'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + // Branching tests with two level group hierarchy and cyclic behavior + it('case 33, branching test where top group is assigned to allow role and right group is assigned allow role, right branch has cyclic behavior', async () => { + const userEntity = 'user:default/valkyrie'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 34, branching test where top group is assigned to deny role and right group is assigned deny role, right branch has cyclic behavior', async () => { + const userEntity = 'user:default/nova'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 35, branching test where top group is assigned to deny role and right group is assigned allow role, right branch has cyclic behavior', async () => { + const userEntity = 'user:default/storm'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('case 36, branching test where top group is assigned to allow role and right group is assigned deny role, right branch has cyclic behavior', async () => { + const userEntity = 'user:default/daredevil'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + // Simple user to role tests + it('case 37, user directly assigned to allow permission', async () => { + const userEntity = 'user:default/psylocke'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('case 38, user directly assigned to deny permission', async () => { + const userEntity = 'user:default/penance'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + // Simple group to role tests + it('case 39, group assigned to allow permission', async () => { + const userEntity = 'user:default/cable'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('case 40, group assigned to deny permission', async () => { + const userEntity = 'user:default/ghost_rider'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + // Admin test + it('case 37, user directly assigned to admin role through config', async () => { + const userEntity = 'user:default/admin'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + // Admin test + it('case 37, group directly assigned to admin role through config', async () => { + const userEntity = 'user:default/admin_one'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + // Super user test + it('case 37, super user assigned to superUsers through config', async () => { + const userEntity = 'user:default/super_user'; + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser(userEntity), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + userEntity, + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); +}); + +function newPolicyQueryWithResourcePermission( + name: string, + resource: string, + action: PermissionAction, +): PolicyQuery { + const mockPermission = createPermission({ + name: name, + attributes: {}, + resourceType: resource, + }); + if (action) { + mockPermission.attributes.action = action; + } + return { permission: mockPermission }; +} + +function newPolicyQueryUser( + user?: string, + ownershipEntityRefs?: string[], +): PolicyQueryUser | undefined { + if (user) { + return { + identity: { + ownershipEntityRefs: ownershipEntityRefs ?? [], + type: 'user', + userEntityRef: user, + }, + credentials: { + $$type: '@backstage/BackstageCredentials', + principal: true, + expiresAt: new Date('2021-01-01T00:00:00Z'), + }, + info: { + userEntityRef: user, + ownershipEntityRefs: ownershipEntityRefs ?? [], + }, + token: 'token', + }; + } + return undefined; +} + +function newConfig(permFile?: string): Config { + const adminUsers = [ + { + name: 'user:default/admin', + }, + { + name: 'group:default/admin', + }, + ]; + + const superUser = [ + { + name: 'user:default/super_user', + }, + ]; + + return mockServices.rootConfig({ + data: { + permission: { + rbac: { + 'policies-csv-file': permFile || csvPermFile, + policyFileReload: false, + admin: { + users: adminUsers, + superUsers: superUser, + }, + }, + }, + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }, + }); +} + +async function newAdapter(config: Config): Promise { + return await new CasbinDBAdapterFactory( + config, + mockClientKnex, + ).createAdapter(); +} + +async function createEnforcer( + theModel: Model, + adapter: Adapter, + logger: LoggerService, + config: Config, +): Promise { + const catalogDBClient = Knex.knex({ client: MockClient }); + const rbacDBClient = Knex.knex({ client: MockClient }); + const enf = await newEnforcer(theModel, adapter); + + const rm = new BackstageRoleManager( + catalogMock, + logger, + catalogDBClient, + rbacDBClient, + config, + mockAuthService, + new DefaultPermissionsReader(config), + ); + enf.setRoleManager(rm); + enf.enableAutoBuildRoleLinks(false); + await enf.buildRoleLinks(); + + return enf; +} + +async function newEnforcerDelegate( + adapter: Adapter, + config: Config, + storedPolicies?: string[][], + storedGroupingPolicies?: string[][], +): Promise { + const theModel = newModelFromString(MODEL); + const logger = mockServices.logger.mock(); + + const enf = await createEnforcer(theModel, adapter, logger, config); + + if (storedPolicies) { + await enf.addPolicies(storedPolicies); + } + + if (storedGroupingPolicies) { + await enf.addGroupingPolicies(storedGroupingPolicies); + } + + return new EnforcerDelegate( + enf, + mockAuditorService, + conditionalStorageMock, + roleMetadataStorageMock, + mockClientKnex, + ); +} + +async function newPermissionPolicy( + config: Config, + enfDelegate: EnforcerDelegate, + roleMock?: RoleMetadataStorage, +): Promise { + const logger = mockServices.logger.mock(); + const permissionPolicy = await RBACPermissionPolicy.build( + logger, + mockAuditorService, + config, + conditionalStorageMock, + enfDelegate, + roleMock || roleMetadataStorageMock, + mockClientKnex, + pluginMetadataCollectorMock as PluginPermissionMetadataCollector, + mockAuthService, + ); + clearAuditorMock(); + return permissionPolicy; +} diff --git a/plugins/rbac-backend/src/policies/permission-policy.test.ts b/plugins/rbac-backend/src/policies/permission-policy.test.ts new file mode 100644 index 0000000000..d25bf29932 --- /dev/null +++ b/plugins/rbac-backend/src/policies/permission-policy.test.ts @@ -0,0 +1,2499 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { LoggerService } from '@backstage/backend-plugin-api'; +import { mockServices } from '@backstage/backend-test-utils'; +import { Config } from '@backstage/config'; +import { + AuthorizeResult, + createPermission, +} from '@backstage/plugin-permission-common'; +import type { + PolicyQuery, + PolicyQueryUser, +} from '@backstage/plugin-permission-node'; + +import { + Adapter, + Enforcer, + Model, + newEnforcer, + newModelFromString, +} from 'casbin'; +import * as Knex from 'knex'; +import { MockClient } from 'knex-mock-client'; + +import type { + RoleBasedPolicy, + RoleMetadata, +} from '@backstage-community/plugin-rbac-common'; + +import { resolve } from 'path'; + +import { ADMIN_ROLE_NAME } from '../admin-permissions/admin-creation'; +import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; +import { ConditionalStorage } from '../database/conditional-storage'; +import { + RoleMetadataDao, + RoleMetadataStorage, +} from '../database/role-metadata'; +import { BackstageRoleManager } from '../role-manager/role-manager'; +import { DefaultPermissionsReader } from '../default-permissions/default-permissions'; +import { EnforcerDelegate } from '../service/enforcer-delegate'; +import { MODEL } from '../service/permission-model'; +import { PluginPermissionMetadataCollector } from '../service/plugin-endpoints'; +import { RBACPermissionPolicy } from './permission-policy'; +import { buildDefaultRoleMetadata } from '../default-permissions/default-permissions'; +import { catalogMock, mockAuditorService } from '../../__fixtures__/mock-utils'; +import { + clearAuditorMock, + expectAuditorLogForPermission, +} from '../../__fixtures__/auditor-test-utils'; + +type PermissionAction = 'create' | 'read' | 'update' | 'delete'; + +const conditionalStorageMock: ConditionalStorage = { + filterConditions: jest.fn().mockImplementation(() => []), + createCondition: jest.fn().mockImplementation(), + checkConflictedConditions: jest.fn().mockImplementation(), + getCondition: jest.fn().mockImplementation(), + deleteCondition: jest.fn().mockImplementation(), + updateCondition: jest.fn().mockImplementation(), +}; + +const roleMetadataStorageMock: RoleMetadataStorage = { + filterRoleMetadata: jest.fn().mockImplementation(() => []), + findRoleMetadata: jest + .fn() + .mockImplementation( + async ( + _roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + return { source: 'csv-file' }; + }, + ), + filterForOwnerRoleMetadata: jest.fn().mockImplementation(), + createRoleMetadata: jest.fn().mockImplementation(), + updateRoleMetadata: jest.fn().mockImplementation(), + removeRoleMetadata: jest.fn().mockImplementation(), + getCachedDefaultRoleMetadata: jest.fn().mockImplementation(() => undefined), + getDefaultRole: jest.fn().mockResolvedValue(undefined), + syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), +}; + +const csvPermFile = resolve( + __dirname, + '../../__fixtures__/data/valid-csv/rbac-policy.csv', +); + +const mockClientKnex = Knex.knex({ client: MockClient }); + +const mockAuthService = mockServices.auth(); + +const pluginMetadataCollectorMock: Partial = + { + getPluginConditionRules: jest.fn().mockImplementation(), + getPluginPolicies: jest.fn().mockImplementation(), + getMetadataByPluginId: jest.fn().mockImplementation(), + }; + +const modifiedBy = 'user:default/some-admin'; + +describe('RBACPermissionPolicy Tests', () => { + beforeEach(() => { + roleMetadataStorageMock.updateRoleMetadata = jest.fn().mockImplementation(); + jest.clearAllMocks(); + }); + + it('should build', async () => { + const config = newConfig(); + const adapter = await newAdapter(config); + const enfDelegate = await newEnforcerDelegate(adapter, config); + + const policy = await newPermissionPolicy(config, enfDelegate); + + expect(policy).not.toBeNull(); + }); + + it('should fail to build when creating admin role', async () => { + roleMetadataStorageMock.updateRoleMetadata = jest + .fn() + .mockImplementation(async (): Promise => { + throw new Error(`Failed to create`); + }); + + const config = newConfig(); + const adapter = await newAdapter(config); + const enfDelegate = await newEnforcerDelegate(adapter, config); + await enfDelegate.addPolicy([ + 'user:default/known_user', + 'test-resource', + 'update', + 'allow', + ]); + + await expect(newPermissionPolicy(config, enfDelegate)).rejects.toThrow( + 'Failed to create', + ); + }); + + describe('Policy checks from csv file', () => { + let enfDelegate: EnforcerDelegate; + let policy: RBACPermissionPolicy; + + beforeEach(async () => { + const config = newConfig(); + const adapter = await newAdapter(config); + enfDelegate = await newEnforcerDelegate(adapter, config); + policy = await newPermissionPolicy(config, enfDelegate); + }); + + // case1 + it('should allow read access to resource permission for user from csv file', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/guest'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/guest', + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + // case2 + it('should allow create access to resource permission for user from csv file', async () => { + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('catalog.entity.create'), + newPolicyQueryUser('user:default/guest'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/guest', + 'catalog.entity.create', + undefined, + 'use', + AuthorizeResult.ALLOW, + ); + }); + + // case3 + it('should allow deny access to resource permission for user:default/known_user', async () => { + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('test.resource.deny'), + newPolicyQueryUser('user:default/known_user'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/known_user', + 'test.resource.deny', + undefined, + 'use', + AuthorizeResult.ALLOW, + ); + }); + + // case1 with role + it('should allow update access to resource permission for user from csv file', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'update', + ), + newPolicyQueryUser('user:default/guest'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/guest', + 'catalog.entity.read', + 'catalog-entity', + 'update', + AuthorizeResult.ALLOW, + ); + }); + }); + + describe('Policy checks for clean up old policies for csv file', () => { + let config: Config; + let adapter: Adapter; + let enforcerDelegate: EnforcerDelegate; + let rbacPolicy: RBACPermissionPolicy; + const allEnfRoles = [ + 'role:default/some-role', + 'role:default/rbac_admin', + 'role:default/catalog-writer', + 'role:default/legacy', + 'role:default/catalog-reader', + 'role:default/catalog-deleter', + 'role:default/known_role', + 'role:default/CATALOG-USER', + ]; + + const allEnfGroupPolicies = [ + ['user:default/tester', 'role:default/some-role'], + ['user:default/guest', 'role:default/rbac_admin'], + ['group:default/guests', 'role:default/rbac_admin'], + ['user:default/guest', 'role:default/catalog-writer'], + ['user:default/guest', 'role:default/legacy'], + ['user:default/guest', 'role:default/catalog-reader'], + ['user:default/guest', 'role:default/catalog-deleter'], + ['user:default/known_user', 'role:default/known_role'], + ['user:default/tom', 'role:default/CATALOG-USER'], + ['group:default/reader-group', 'role:default/CATALOG-USER'], + ]; + + const allEnfPolicies = [ + // stored policy + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + // policies from csv file + ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], + ['role:default/legacy', 'catalog-entity', 'update', 'allow'], + ['role:default/catalog-writer', 'catalog-entity', 'read', 'allow'], + ['role:default/catalog-writer', 'catalog.entity.create', 'use', 'allow'], + ['role:default/catalog-deleter', 'catalog-entity', 'delete', 'deny'], + ['role:default/CATALOG-USER', 'catalog-entity', 'read', 'allow'], + ['role:default/known_role', 'test.resource.deny', 'use', 'allow'], + ]; + + beforeEach(async () => { + (roleMetadataStorageMock.removeRoleMetadata as jest.Mock).mockReset(); + + config = newConfig(); + adapter = await newAdapter(config); + }); + + it('should cleanup old group policies and metadata after re-attach policy file', async () => { + roleMetadataStorageMock.filterRoleMetadata = jest + .fn() + .mockImplementation(() => { + const roleMetadataDao: RoleMetadataDao = { + roleEntityRef: 'role:default/old-role', + source: 'csv-file', + modifiedBy: 'user:default/tom', + }; + return [roleMetadataDao]; + }); + + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async ( + roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + if (roleEntityRef.includes('rbac_admin')) { + return { source: 'configuration' }; + } + if (roleEntityRef.includes('some-role')) { + return { source: 'rest' }; + } + return { source: 'csv-file' }; + }, + ); + + const storedGroupPolicies = [ + // should be removed + ['user:default/user-old-1', 'role:default/old-role'], + ['group:default/team-a-old-1', 'role:default/old-role'], + + // should not be removed: + ['user:default/tester', 'role:default/some-role'], + ]; + const storedPolicies = [ + // should not be removed + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ]; + + enforcerDelegate = await newEnforcerDelegate( + adapter, + config, + storedPolicies, + storedGroupPolicies, + ); + + await newPermissionPolicy(config, enforcerDelegate); + + expect(await enforcerDelegate.getGroupingPolicy()).toEqual( + allEnfGroupPolicies, + ); + + expect(await enforcerDelegate.getAllRoles()).toEqual(allEnfRoles); + + const nonAdminPolicies = (await enforcerDelegate.getPolicy()).filter( + (policy: string[]) => policy[0] !== 'role:default/rbac_admin', + ); + + expect(nonAdminPolicies).toEqual(allEnfPolicies); + + // role metadata should be removed + expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( + 'role:default/old-role', + expect.anything(), + ); + }); + + it('should cleanup old policies and metadata after re-attach policy file', async () => { + const storedGroupPolicies = [ + // should not be removed: + ['user:default/tester', 'role:default/some-role'], + ]; + const storedPolicies = [ + // should be removed + ['role:default/old-role', 'test.some.resource', 'use', 'allow'], + + // should not be removed + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ]; + + enforcerDelegate = await newEnforcerDelegate( + adapter, + config, + storedPolicies, + storedGroupPolicies, + ); + + rbacPolicy = await newPermissionPolicy(config, enforcerDelegate); + + expect(await enforcerDelegate.getAllRoles()).toEqual(allEnfRoles); + + expect(await enforcerDelegate.getGroupingPolicy()).toEqual( + allEnfGroupPolicies, + ); + + const nonAdminPolicies = (await enforcerDelegate.getPolicy()).filter( + (p: string[]) => { + return p[0] !== 'role:default/rbac_admin'; + }, + ); + expect(nonAdminPolicies).toEqual(allEnfPolicies); + + // role metadata should not be removed + expect( + roleMetadataStorageMock.removeRoleMetadata, + ).not.toHaveBeenCalledWith('role:default/old-role', expect.anything()); + + const decision = await rbacPolicy.handle( + newPolicyQueryWithBasicPermission('test.some.resource'), + newPolicyQueryUser('user:default/user-old-1'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/user-old-1', + 'test.some.resource', + undefined, + 'use', + AuthorizeResult.DENY, + ); + }); + + it('should cleanup old policies and group policies and metadata after re-attach policy file', async () => { + const storedGroupPolicies = [ + // should be removed + ['user:default/user-old-1', 'role:default/old-role'], + ['user:default/user-old-2', 'role:default/old-role'], + ['group:default/team-a-old-1', 'role:default/old-role'], + ['group:default/team-a-old-2', 'role:default/old-role'], + + // should not be removed: + ['user:default/tester', 'role:default/some-role'], + ]; + const storedPolicies = [ + // should be removed + ['role:default/old-role', 'test.some.resource', 'use', 'allow'], + + // should not be removed + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ]; + + enforcerDelegate = await newEnforcerDelegate( + adapter, + config, + storedPolicies, + storedGroupPolicies, + ); + + await newPermissionPolicy(config, enforcerDelegate); + + expect(await enforcerDelegate.getAllRoles()).toEqual(allEnfRoles); + + expect(await enforcerDelegate.getGroupingPolicy()).toEqual( + allEnfGroupPolicies, + ); + + const nonAdminPolicies = (await enforcerDelegate.getPolicy()).filter( + (policy: string[]) => { + return policy[0] !== 'role:default/rbac_admin'; + }, + ); + expect(nonAdminPolicies).toEqual(allEnfPolicies); + + // role metadata should be removed + expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( + 'role:default/old-role', + expect.anything(), + ); + }); + + it('should cleanup old group policies and metadata after detach policy file', async () => { + const storedGroupPolicies = [ + // should be removed + ['user:default/user-old-1', 'role:default/old-role'], + ['group:default/team-a-old-1', 'role:default/old-role'], + + // should not be removed: + ['user:default/tester', 'role:default/some-role'], + ]; + const storedPolicies = [ + // should not be removed + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ]; + + enforcerDelegate = await newEnforcerDelegate( + adapter, + config, + storedPolicies, + storedGroupPolicies, + ); + + await newPermissionPolicy(config, enforcerDelegate); + + expect(await enforcerDelegate.getAllRoles()).toEqual(allEnfRoles); + + expect(await enforcerDelegate.getGroupingPolicy()).toEqual( + allEnfGroupPolicies, + ); + + const nonAdminPolicies = (await enforcerDelegate.getPolicy()).filter( + (policy: string[]) => { + return policy[0] !== 'role:default/rbac_admin'; + }, + ); + expect(nonAdminPolicies).toEqual(allEnfPolicies); + + // role metadata should be removed + expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( + 'role:default/old-role', + expect.anything(), + ); + }); + + it('should cleanup old policies after detach policy file', async () => { + const storedGroupPolicies = [ + // should not be removed: + ['user:default/tester', 'role:default/some-role'], + ]; + const storedPolicies = [ + // should be removed + ['role:default/old-role', 'test.some.resource', 'use', 'allow'], + + // should not be removed + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ]; + + enforcerDelegate = await newEnforcerDelegate( + adapter, + config, + storedPolicies, + storedGroupPolicies, + ); + + await newPermissionPolicy(config, enforcerDelegate); + + expect(await enforcerDelegate.getAllRoles()).toEqual(allEnfRoles); + + expect(await enforcerDelegate.getGroupingPolicy()).toEqual( + allEnfGroupPolicies, + ); + + const nonAdminPolicies = (await enforcerDelegate.getPolicy()).filter( + (policy: string[]) => { + return policy[0] !== 'role:default/rbac_admin'; + }, + ); + expect(nonAdminPolicies).toEqual(allEnfPolicies); + }); + + it('should cleanup old policies and group policies and metadata after detach policy file', async () => { + const storedGroupPolicies = [ + // should be removed + ['user:default/user-old-1', 'role:default/old-role'], + ['user:default/user-old-2', 'role:default/old-role'], + ['group:default/team-a-old-1', 'role:default/old-role'], + ['group:default/team-a-old-2', 'role:default/old-role'], + + // should not be removed: + ['user:default/tester', 'role:default/some-role'], + ]; + const storedPolicies = [ + // should be removed + ['role:default/old-role', 'test.some.resource', 'use', 'allow'], + + // should not be removed + ['role:default/some-role', 'test.some.resource', 'use', 'allow'], + ]; + + enforcerDelegate = await newEnforcerDelegate( + adapter, + config, + storedPolicies, + storedGroupPolicies, + ); + + await newPermissionPolicy(config, enforcerDelegate); + + expect(await enforcerDelegate.getAllRoles()).toEqual(allEnfRoles); + + expect(await enforcerDelegate.getGroupingPolicy()).toEqual( + allEnfGroupPolicies, + ); + + const nonAdminPolicies = (await enforcerDelegate.getPolicy()).filter( + (policy: string[]) => { + return policy[0] !== 'role:default/rbac_admin'; + }, + ); + expect(nonAdminPolicies).toEqual(allEnfPolicies); + + // role metadata should be removed + expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( + 'role:default/old-role', + expect.anything(), + ); + }); + }); + + describe('Policy checks for users', () => { + let policy: RBACPermissionPolicy; + let enfDelegate: EnforcerDelegate; + + const roleMetadataStorageTest: RoleMetadataStorage = { + filterRoleMetadata: jest.fn().mockImplementation(() => []), + findRoleMetadata: jest + .fn() + .mockImplementation( + async ( + roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + if (roleEntityRef.includes('rbac_admin')) { + return { source: 'configuration' }; + } + return { source: 'csv-file' }; + }, + ), + filterForOwnerRoleMetadata: jest.fn().mockImplementation(), + createRoleMetadata: jest.fn().mockImplementation(), + updateRoleMetadata: jest.fn().mockImplementation(), + removeRoleMetadata: jest.fn().mockImplementation(), + getCachedDefaultRoleMetadata: jest + .fn() + .mockImplementation(() => undefined), + getDefaultRole: jest.fn().mockResolvedValue(undefined), + syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), + }; + + beforeEach(async () => { + const basicAndResourcePermissions = resolve( + __dirname, + '../../__fixtures__/data/valid-csv/basic-and-resource-policies.csv', + ); + const config = newConfig(basicAndResourcePermissions); + const adapter = await newAdapter(config); + enfDelegate = await newEnforcerDelegate(adapter, config); + + policy = await newPermissionPolicy( + config, + enfDelegate, + roleMetadataStorageTest, + ); + }); + // +-------+------+------------------------------------+ + // | allow | deny | result | | + // +-------+------+--------------------------------+---| + // | N | Y | deny | 1 | + // | N | N | deny (user not listed) | 2 | + // | Y | Y | deny (user:default/duplicated) | 3 | + // | Y | N | allow | 4 | + + // Tests for Resource basic type permission + + // case1 + it('should deny access to basic permission for listed user with deny action', async () => { + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('test.resource.deny'), + newPolicyQueryUser('user:default/known_user'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/known_user', + 'test.resource.deny', + undefined, + 'use', + AuthorizeResult.DENY, + ); + }); + // case2 + it('should deny access to basic permission for unlisted user', async () => { + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('test.resource'), + newPolicyQueryUser('unuser:default/known_user'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'unuser:default/known_user', + 'test.resource', + undefined, + 'use', + AuthorizeResult.DENY, + ); + }); + // case3 + it('should deny access to basic permission for listed user deny and allow', async () => { + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('test.resource'), + newPolicyQueryUser('user:default/duplicated'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/duplicated', + 'test.resource', + undefined, + 'use', + AuthorizeResult.DENY, + ); + }); + // case4 + it('should allow access to basic permission for user listed on policy', async () => { + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('test.resource'), + newPolicyQueryUser('user:default/known_user'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/known_user', + 'test.resource', + undefined, + 'use', + AuthorizeResult.ALLOW, + ); + }); + // case5 + it('should deny access to undefined user', async () => { + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('test.resource'), + newPolicyQueryUser(), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + undefined, + 'test.resource', + undefined, + 'use', + AuthorizeResult.DENY, + ); + }); + + // Tests for Resource Permission type + + // case1 + it('should deny access to resource permission for user listed on policy', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'test.resource.deny', + 'test-resource-deny', + 'update', + ), + newPolicyQueryUser('user:default/known_user'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/known_user', + 'test.resource.deny', + 'test-resource-deny', + 'update', + AuthorizeResult.DENY, + ); + }); + // case 2 + it('should deny access to resource permission for user unlisted on policy', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'test.resource.update', + 'test-resource', + 'update', + ), + newPolicyQueryUser('unuser:default/known_user'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'unuser:default/known_user', + 'test.resource.update', + 'test-resource', + 'update', + AuthorizeResult.DENY, + ); + }); + // case 3 + it('should deny access to resource permission for user listed deny and allow', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'test.resource.update', + 'test-resource', + 'update', + ), + newPolicyQueryUser('user:default/duplicated'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/duplicated', + 'test.resource.update', + 'test-resource', + 'update', + AuthorizeResult.DENY, + ); + }); + // case 4 + it('should allow access to resource permission for user listed on policy', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'test.resource.update', + 'test-resource', + 'update', + ), + newPolicyQueryUser('user:default/known_user'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/known_user', + 'test.resource.update', + 'test-resource', + 'update', + AuthorizeResult.ALLOW, + ); + }); + // case 5 + // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` + it('should allow access to basic permission policy.entity.create even though it is defined as `policy-entity, create` for user listed on policy', async () => { + await enfDelegate.addPolicy([ + 'user:default/known_user', + 'policy-entity', + 'create', + 'allow', + ]); + + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('policy.entity.create', 'create'), + newPolicyQueryUser('user:default/known_user'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/known_user', + 'policy.entity.create', + undefined, + 'create', + AuthorizeResult.ALLOW, + ); + }); + // case 6 + // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` + it('should allow access to basic permission policy.entity.create even though it is defined as `policy-entity, create` for role', async () => { + await enfDelegate.addGroupingPolicy( + ['user:default/known_user', 'role:default/known_user'], + { + source: 'csv-file', + roleEntityRef: 'role:default/known_user', + modifiedBy, + }, + ); + await enfDelegate.addPolicy([ + 'role:default/known_user', + 'policy-entity', + 'create', + 'allow', + ]); + + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('policy.entity.create', 'create'), + newPolicyQueryUser('user:default/known_user'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/known_user', + 'policy.entity.create', + undefined, + 'create', + AuthorizeResult.ALLOW, + ); + }); + + // Tests for actions on resource permissions + it('should deny access to resource permission for unlisted action for user listed on policy', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'test.resource.update', + 'test-resource', + 'delete', + ), + newPolicyQueryUser('user:default/known_user'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + }); + + // Tests for admin added through app config + it('should allow access to permission resources for admin added through app config', async () => { + const adminPerm: { + name: string; + resource: string; + action: PermissionAction; + }[] = [ + { + name: 'policy.entity.read', + resource: 'policy-entity', + action: 'read', + }, + { + name: 'policy.entity.create', + resource: 'policy-entity', + action: 'create', + }, + { + name: 'policy.entity.update', + resource: 'policy-entity', + action: 'update', + }, + { + name: 'policy.entity.delete', + resource: 'policy-entity', + action: 'delete', + }, + { + name: 'catalog.entity.read', + resource: 'catalog-entity', + action: 'read', + }, + ]; + for (const perm of adminPerm) { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + perm.name, + perm.resource, + perm.action, + ), + newPolicyQueryUser('user:default/guest'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/guest', + perm.name, + perm.resource, + perm.action, + AuthorizeResult.ALLOW, + ); + clearAuditorMock(); + } + }); + }); + + describe('Policy checks from config file', () => { + let policy: RBACPermissionPolicy; + let enfDelegate: EnforcerDelegate; + const roleMetadataStorageTest: RoleMetadataStorage = { + filterRoleMetadata: jest.fn().mockImplementation(() => []), + findRoleMetadata: jest + .fn() + .mockImplementation( + async ( + _roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + return { + roleEntityRef: 'role:default/catalog-writer', + source: 'legacy', + modifiedBy, + }; + }, + ), + filterForOwnerRoleMetadata: jest.fn().mockImplementation(), + createRoleMetadata: jest.fn().mockImplementation(), + updateRoleMetadata: jest.fn().mockImplementation(), + removeRoleMetadata: jest.fn().mockImplementation(), + getCachedDefaultRoleMetadata: jest + .fn() + .mockImplementation(() => undefined), + getDefaultRole: jest.fn().mockResolvedValue(undefined), + syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), + }; + + const adminRole = 'role:default/rbac_admin'; + const groupPolicy = [ + ['user:default/test_admin', 'role:default/rbac_admin'], + ]; + const permissions = [ + ['role:default/rbac_admin', 'policy-entity', 'read', 'allow'], + ['role:default/rbac_admin', 'policy.entity.create', 'create', 'allow'], + ['role:default/rbac_admin', 'policy-entity', 'delete', 'allow'], + ['role:default/rbac_admin', 'policy-entity', 'update', 'allow'], + ['role:default/rbac_admin', 'catalog-entity', 'read', 'allow'], + ]; + const oldGroupPolicy = [ + 'user:default/old_admin', + 'role:default/rbac_admin', + ]; + const admins = new Array<{ name: string }>(); + admins.push({ name: 'user:default/test_admin' }); + const superUser = new Array<{ name: string }>(); + superUser.push({ name: 'user:default/super_user' }); + + beforeEach(async () => { + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async ( + _roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + return { + roleEntityRef: 'role:default/catalog-writer', + source: 'legacy', + modifiedBy, + }; + }, + ); + + const config = newConfig(csvPermFile, admins, superUser); + const adapter = await newAdapter(config); + + enfDelegate = await newEnforcerDelegate(adapter, config); + + await enfDelegate.addGroupingPolicy(oldGroupPolicy, { + source: 'configuration', + roleEntityRef: ADMIN_ROLE_NAME, + modifiedBy: `user:default/tom`, + }); + + policy = await newPermissionPolicy( + config, + enfDelegate, + roleMetadataStorageTest, + ); + }); + + it('should allow read access to resource permission for user from config file', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'policy.entity.read', + 'policy-entity', + 'read', + ), + newPolicyQueryUser('user:default/test_admin'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/test_admin', + 'policy.entity.read', + 'policy-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('should allow read access to resource permission for super user from config file', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'policy.entity.read', + 'policy-entity', + 'read', + ), + newPolicyQueryUser('user:default/super_user'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/super_user', + 'policy.entity.read', + 'policy-entity', + 'read', + AuthorizeResult.ALLOW, + ); + clearAuditorMock(); + const decision2 = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.delete', + 'catalog-entity', + 'delete', + ), + newPolicyQueryUser('user:default/super_user'), + ); + expect(decision2.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/super_user', + 'catalog.entity.delete', + 'catalog-entity', + 'delete', + AuthorizeResult.ALLOW, + ); + }); + + it('should allow access to a user who is a member of a group configured as super user', async () => { + const superUsersConfig = new Array<{ name: string }>(); + superUsersConfig.push({ name: 'group:default/super_users_group' }); + + const config = newConfig(csvPermFile, admins, superUsersConfig); + const adapter = await newAdapter(config); + const enfDelegateForTest = await newEnforcerDelegate(adapter, config); + const policyForTest = await newPermissionPolicy( + config, + enfDelegateForTest, + roleMetadataStorageTest, + ); + + const decision = await policyForTest.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.delete', + 'catalog-entity', + 'delete', + ), + newPolicyQueryUser('user:default/some_user', [ + 'group:default/super_users_group', + ]), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/some_user', + 'catalog.entity.delete', + 'catalog-entity', + 'delete', + AuthorizeResult.ALLOW, + ); + }); + + it('should deny access to a user who is not a member of a group configured as super user', async () => { + const superUsersConfig = new Array<{ name: string }>(); + superUsersConfig.push({ name: 'group:default/super_users_group' }); + + const config = newConfig(csvPermFile, admins, superUsersConfig); + const adapter = await newAdapter(config); + const enfDelegateForTest = await newEnforcerDelegate(adapter, config); + const policyForTest = await newPermissionPolicy( + config, + enfDelegateForTest, + roleMetadataStorageTest, + ); + + const decision = await policyForTest.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.delete', + 'catalog-entity', + 'delete', + ), + newPolicyQueryUser('user:default/some_user', [ + 'group:default/other_group', + ]), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/some_user', + 'catalog.entity.delete', + 'catalog-entity', + 'delete', + AuthorizeResult.DENY, + ); + }); + + it('should remove users that are no longer in the config file', async () => { + const enfRole = await enfDelegate.getFilteredGroupingPolicy(1, adminRole); + const enfPermission = await enfDelegate.getFilteredPolicy(0, adminRole); + expect(enfRole).toEqual(groupPolicy); + expect(enfRole).not.toContain(oldGroupPolicy); + expect(enfPermission).toEqual(permissions); + }); + }); +}); + +// Notice: There is corner case, when "resourced" permission policy can be defined not by resource type, but by name. +describe('Policy checks for resourced permissions defined by name', () => { + const roleMetadataStorageTest: RoleMetadataStorage = { + filterRoleMetadata: jest.fn().mockImplementation(() => []), + findRoleMetadata: jest + .fn() + .mockImplementation( + async ( + _roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + return { + roleEntityRef: 'role:default/catalog-writer', + source: 'legacy', + modifiedBy, + }; + }, + ), + filterForOwnerRoleMetadata: jest.fn().mockImplementation(), + createRoleMetadata: jest.fn().mockImplementation(), + updateRoleMetadata: jest.fn().mockImplementation(), + removeRoleMetadata: jest.fn().mockImplementation(), + getCachedDefaultRoleMetadata: jest.fn().mockImplementation(() => undefined), + getDefaultRole: jest.fn().mockResolvedValue(undefined), + syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), + }; + let enfDelegate: EnforcerDelegate; + let policy: RBACPermissionPolicy; + + beforeEach(async () => { + const config = newConfig(); + const adapter = await newAdapter(config); + enfDelegate = await newEnforcerDelegate(adapter, config); + policy = await newPermissionPolicy( + config, + enfDelegate, + roleMetadataStorageTest, + ); + }); + + it('should allow access to resourced permission assigned by name', async () => { + await enfDelegate.addGroupingPolicy( + ['user:default/tor', 'role:default/catalog_reader'], + { + source: 'csv-file', + roleEntityRef: 'role:default/catalog_reader', + modifiedBy, + }, + ); + await enfDelegate.addPolicy([ + 'role:default/catalog_reader', + 'catalog.entity.read', + 'read', + 'allow', + ]); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/tor'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + }); + + it('should allow access to resourced permission assigned by name, because it has higher priority then permission for the same resource assigned by resource type', async () => { + await enfDelegate.addGroupingPolicy( + ['user:default/tor', 'role:default/catalog_reader'], + { + source: 'csv-file', + roleEntityRef: 'role:default/catalog_reader', + modifiedBy, + }, + ); + await enfDelegate.addPolicies([ + ['role:default/catalog_reader', 'catalog.entity.read', 'read', 'allow'], + ['role:default/catalog_reader', 'catalog-entity', 'read', 'deny'], + ]); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/tor'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + }); + + it('should deny access to resourced permission assigned by name, because it has higher priority then permission for the same resource assigned by resource type', async () => { + await enfDelegate.addGroupingPolicy( + ['user:default/tor', 'role:default/catalog_reader'], + { + source: 'csv-file', + roleEntityRef: 'role:default/catalog_reader', + modifiedBy, + }, + ); + + await enfDelegate.addPolicies([ + ['role:default/catalog_reader', 'catalog.entity.read', 'read', 'deny'], + ['role:default/catalog_reader', 'catalog-entity', 'read', 'allow'], + ]); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/tor'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/tor', + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + + it('should allow access to resourced permission assigned by name, but user inherits policy from his group', async () => { + await enfDelegate.addGroupingPolicy( + ['group:default/team-a', 'role:default/catalog_user'], + { + source: 'csv-file', + roleEntityRef: 'role:default/catalog_user', + modifiedBy, + }, + ); + + await enfDelegate.addPolicies([ + ['role:default/catalog_user', 'catalog.entity.read', 'read', 'allow'], + ]); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/tor'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/tor', + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('should allow access to resourced permission assigned by name, but user inherits policy from uppercase group', async () => { + const name = 'team-C'; + await enfDelegate.addGroupingPolicy( + [ + `group:default/${name.toLocaleLowerCase('en-US')}`, + 'role:default/catalog_user', + ], + { + source: 'csv-file', + roleEntityRef: 'role:default/catalog_user', + modifiedBy, + }, + ); + + await enfDelegate.addPolicies([ + ['role:default/catalog_user', 'catalog.entity.read', 'read', 'allow'], + ]); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/tor'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/tor', + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('should allow access to resourced permission assigned by name, but user inherits policy from few groups', async () => { + await enfDelegate.addGroupingPolicy( + ['group:default/team-a', 'role:default/catalog_user'], + { + source: 'csv-file', + roleEntityRef: 'role:default/catalog_user', + modifiedBy, + }, + ); + await enfDelegate.addGroupingPolicy( + ['group:default/team-a', 'group:default/team-c'], + { + source: 'csv-file', + roleEntityRef: 'role:default/catalog_user', + modifiedBy, + }, + ); + + await enfDelegate.addPolicies([ + ['role:default/catalog_user', 'catalog.entity.read', 'read', 'allow'], + ]); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/tor'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/tor', + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); +}); + +describe('Policy checks for users and groups', () => { + let policy: RBACPermissionPolicy; + + beforeEach(async () => { + const policyChecksCSV = resolve( + __dirname, + '../../__fixtures__/data/valid-csv/policy-checks.csv', + ); + const config = newConfig(policyChecksCSV); + const adapter = await newAdapter(config); + + const enfDelegate = await newEnforcerDelegate(adapter, config); + + policy = await newPermissionPolicy(config, enfDelegate); + }); + + // User inherits permissions from groups and their parent groups. + // This behavior can be configured with `policy_effect` in the model. + // Also it can be customized using casbin function. + // Test suite table: + // +-------+---------+----------+-------+ + // | Group | User | result | case# | + // +-------+---------+----------+-------+ + // | deny | allow | deny | 1 | + + // | deny | - | deny | 2 | + + // | deny | deny | deny | 3 | + + // +-------+---------+----------+-------+ + // | allow | allow | allow | 4 | + + // | allow | - | allow | 5 | + + // | allow | deny | deny | 6 | + // +-------+---------+----------+-------+ + + // Basic type permissions + + // case1 + it('should deny access to basic permission for user Alice with "allow" "use" action, when her group "deny" this action', async () => { + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('test.resource'), + newPolicyQueryUser('user:default/alice'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/alice', + 'test.resource', + undefined, + 'use', + AuthorizeResult.DENY, + ); + }); + + // case2 + it('should deny access to basic permission for user Akira without("-") "use" action definition, when his group "deny" this action', async () => { + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('test.resource'), + newPolicyQueryUser('user:default/akira'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/akira', + 'test.resource', + undefined, + 'use', + AuthorizeResult.DENY, + ); + }); + + // case3 + it('should deny access to basic permission for user Antey with "deny" "use" action definition, when his group "deny" this action', async () => { + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('test.resource'), + newPolicyQueryUser('user:default/antey'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/antey', + 'test.resource', + undefined, + 'use', + AuthorizeResult.DENY, + ); + }); + + // case4 + it('should allow access to basic permission for user Julia with "allow" "use" action, when her group "allow" this action', async () => { + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('test.resource'), + newPolicyQueryUser('user:default/julia'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/julia', + 'test.resource', + undefined, + 'use', + AuthorizeResult.ALLOW, + ); + }); + + // case5 + it('should allow access to basic permission for user Mike without("-") "use" action definition, when his group "allow" this action', async () => { + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('test.resource'), + newPolicyQueryUser('user:default/mike'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/mike', + 'test.resource', + undefined, + 'use', + AuthorizeResult.ALLOW, + ); + }); + + // case6 + it('should deny access to basic permission for user Tom with "deny" "use" action definition, when his group "allow" this action', async () => { + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('test.resource'), + newPolicyQueryUser('user:default/tom'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/tom', + 'test.resource', + undefined, + 'use', + AuthorizeResult.DENY, + ); + }); + + // inheritance case + it('should allow access to basic permission to test.resource.2 for user Mike with "-" "use" action definition, when parent group of his group "allow" this action', async () => { + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('test.resource.2'), + newPolicyQueryUser('user:default/mike'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/mike', + 'test.resource.2', + undefined, + 'use', + AuthorizeResult.ALLOW, + ); + }); + + // Resource type permissions + + // case1 + it('should deny access to basic permission for user Alice with "allow" "read" action, when her group "deny" this action', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'test.resource.read', + 'test-resource', + 'read', + ), + newPolicyQueryUser('user:default/alice'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/alice', + 'test.resource.read', + 'test-resource', + 'read', + AuthorizeResult.DENY, + ); + }); + + // case2 + it('should deny access to basic permission for user Akira without("-") "read" action definition, when his group "deny" this action', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'test.resource.read', + 'test-resource', + 'read', + ), + newPolicyQueryUser('user:default/akira'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/akira', + 'test.resource.read', + 'test-resource', + 'read', + AuthorizeResult.DENY, + ); + }); + + // case3 + it('should deny access to basic permission for user Antey with "deny" "read" action definition, when his group "deny" this action', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'test.resource.read', + 'test-resource', + 'read', + ), + newPolicyQueryUser('user:default/antey'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/antey', + 'test.resource.read', + 'test-resource', + 'read', + AuthorizeResult.DENY, + ); + }); + + // case4 + it('should allow access to basic permission for user Julia with "allow" "read" action, when her group "allow" this action', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'test.resource.read', + 'test-resource', + 'read', + ), + newPolicyQueryUser('user:default/julia'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/julia', + 'test.resource.read', + 'test-resource', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + // case5 + it('should allow access to basic permission for user Mike without("-") "read" action definition, when his group "allow" this action', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'test.resource.read', + 'test-resource', + 'read', + ), + newPolicyQueryUser('user:default/mike'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/mike', + 'test.resource.read', + 'test-resource', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + // case6 + it('should deny access to basic permission for user Tom with "deny" "read" action definition, when his group "allow" this action', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'test.resource.read', + 'test-resource', + 'read', + ), + newPolicyQueryUser('user:default/tom'), + ); + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/tom', + 'test.resource.read', + 'test-resource', + 'read', + AuthorizeResult.DENY, + ); + }); + + // inheritance case + it('should allow access to resource permission to test-resource for user Mike with "-" "write" action definition, when parent group of his group "allow" this action', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'test.resource.create', + 'test-resource', + 'create', + ), + newPolicyQueryUser('user:default/mike'), + ); + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/mike', + 'test.resource.create', + 'test-resource', + 'create', + AuthorizeResult.ALLOW, + ); + }); +}); + +describe('Policy checks for conditional policies', () => { + let policy: RBACPermissionPolicy; + + beforeEach(async () => { + const config = newConfig(undefined, []); + const adapter = await newAdapter(config); + const theModel = newModelFromString(MODEL); + const logger = mockServices.logger.mock(); + const enf = await createEnforcer(theModel, adapter, logger, config); + const policies = [['role:default/test', 'catalog-entity', 'read', 'allow']]; + const groupPolicies = [ + ['group:default/test-group', 'role:default/test'], + ['group:default/qa', 'role:default/qa'], + ]; + await enf.addPolicies(policies); + await enf.addGroupingPolicies(groupPolicies); + + const enfDelegate = new EnforcerDelegate( + enf, + mockAuditorService, + conditionalStorageMock, + roleMetadataStorageMock, + mockClientKnex, + ); + + policy = await RBACPermissionPolicy.build( + logger, + mockAuditorService, + config, + conditionalStorageMock, + enfDelegate, + roleMetadataStorageMock, + mockClientKnex, + pluginMetadataCollectorMock as PluginPermissionMetadataCollector, + mockAuthService, + ); + }); + + it('should execute condition policy', async () => { + (conditionalStorageMock.filterConditions as jest.Mock).mockReturnValueOnce([ + { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + actions: ['read'], + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/test-group'], + }, + }, + }, + ]); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/mike'), + ); + expect(decision).toStrictEqual({ + pluginId: 'catalog', + resourceType: 'catalog-entity', + result: AuthorizeResult.CONDITIONAL, + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/test-group'], + }, + }, + ], + }, + }); + }); + + it('should execute condition policy with current user alias', async () => { + (conditionalStorageMock.filterConditions as jest.Mock).mockReturnValueOnce([ + { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + actions: ['read'], + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['$currentUser'], + }, + }, + }, + ]); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/mike', [ + 'user:default/mike', + 'group:default/team-a', + ]), + ); + expect(decision).toStrictEqual({ + pluginId: 'catalog', + resourceType: 'catalog-entity', + result: AuthorizeResult.CONDITIONAL, + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/mike'], + }, + }, + ], + }, + }); + }); + + it('should merge condition policies for user assigned to few roles', async () => { + (conditionalStorageMock.filterConditions as jest.Mock) + .mockReturnValueOnce([ + { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + actions: ['read'], + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/test-group'], + }, + }, + }, + ]) + .mockReturnValueOnce([ + { + id: 2, + pluginId: 'catalog', + resourceType: 'catalog-entity', + actions: ['read'], + roleEntityRef: 'role:default/qa', + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + }, + ]); + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/mike'), + ); + expect(decision).toStrictEqual({ + pluginId: 'catalog', + resourceType: 'catalog-entity', + result: AuthorizeResult.CONDITIONAL, + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/test-group'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group', 'User'] }, + }, + ], + }, + }); + }); + + it('should deny condition policy caused collision', async () => { + (conditionalStorageMock.filterConditions as jest.Mock).mockReturnValueOnce([ + { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + actions: ['read'], + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/test-group'], + }, + }, + }, + { + id: 2, + pluginId: 'catalog-fork', + resourceType: 'catalog-entity', + actions: ['read'], + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/test-group'], + }, + }, + }, + ]); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/mike'), + ); + expect(decision).toStrictEqual({ + result: AuthorizeResult.DENY, + }); + }); +}); + +describe('Policy checks with preferPermissionPolicy config', () => { + const allowReadAndCreatePolicies = [ + // allow read for all resources + ['role:default/all_resource_reader', 'catalog-entity', 'read', 'allow'], + ['role:default/all_resource_reader', 'catalog-entity', 'create', 'allow'], + ]; + + const allowCreateButDenyReadPolicies = [ + // deny read for all resources + ['role:default/all_resource_reader', 'catalog-entity', 'read', 'deny'], + ['role:default/all_resource_reader', 'catalog-entity', 'create', 'allow'], + ]; + + const allowOnlyCreateAndNoneReadPolicies = [ + ['role:default/all_resource_reader', 'catalog-entity', 'create', 'allow'], + ]; + + const groupPolicies = [ + ['user:default/mike', 'role:default/all_resource_reader'], + ['user:default/mike', 'role:default/owned_resource_reader'], + ]; + + const conditionalPolicy = [ + { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + actions: ['read'], + roleEntityRef: 'role:default/owned_resource_reader', + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/mike'], + }, + }, + }, + ]; + + it('should allow "catalog read operation" when preferPermissionPolicy is true (permission policy first) and read policy has "allow" value', async () => { + const config = newConfig(undefined, undefined, undefined, 'basic'); + const adapter = await newAdapter(config); + const enfDelegate = await newEnforcerDelegate( + adapter, + config, + allowReadAndCreatePolicies, + groupPolicies, + ); + const policy = await newPermissionPolicy(config, enfDelegate); + + // Mock conditionalStorage to return a conditional ALLOW for owned-reader + ( + conditionalStorageMock.filterConditions as jest.Mock + ).mockResolvedValueOnce(conditionalPolicy); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/mike', ['user:default/mike']), // user is owner + ); + expect(decision).toStrictEqual({ result: AuthorizeResult.ALLOW }); + }); + + it('should deny "catalog read operation" when preferPermissionPolicy is true (permission policy first) and read policy has "deny" value', async () => { + const config = newConfig(undefined, undefined, undefined, 'basic'); + const adapter = await newAdapter(config); + const enfDelegate = await newEnforcerDelegate( + adapter, + config, + allowCreateButDenyReadPolicies, + groupPolicies, + ); + const policy = await newPermissionPolicy(config, enfDelegate); + + // Mock conditionalStorage to return a conditional ALLOW for owned-reader + ( + conditionalStorageMock.filterConditions as jest.Mock + ).mockResolvedValueOnce(conditionalPolicy); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/mike', ['user:default/mike']), // user is owner + ); + expect(decision).toStrictEqual({ result: AuthorizeResult.DENY }); + }); + + it('should return conditional result for "catalog read operation" when preferPermissionPolicy is true (permission policy first) and there is no read policy value', async () => { + const config = newConfig(undefined, undefined, undefined, 'basic'); + const adapter = await newAdapter(config); + const enfDelegate = await newEnforcerDelegate( + adapter, + config, + allowOnlyCreateAndNoneReadPolicies, + groupPolicies, + ); + const policy = await newPermissionPolicy(config, enfDelegate); + + // Mock conditionalStorage to return a conditional ALLOW for owned-reader + ( + conditionalStorageMock.filterConditions as jest.Mock + ).mockResolvedValueOnce(conditionalPolicy); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/mike', ['user:default/mike']), // user is owner + ); + expect(decision).toStrictEqual({ + pluginId: 'catalog', + resourceType: 'catalog-entity', + result: AuthorizeResult.CONDITIONAL, + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/mike'], + }, + }, + ], + }, + }); + }); + + it('should NOT allow read when preferPermissionPolicy is false by default (conditional policy first)', async () => { + const config = newConfig(); + const adapter = await newAdapter(config); + const enfDelegate = await newEnforcerDelegate( + adapter, + config, + allowReadAndCreatePolicies, + groupPolicies, + ); + const policy = await newPermissionPolicy(config, enfDelegate); + + // Mock conditionalStorage to return a conditional ALLOW for owned-reader + ( + conditionalStorageMock.filterConditions as jest.Mock + ).mockResolvedValueOnce(conditionalPolicy); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/mike', ['user:default/mike']), + ); + expect(decision).toStrictEqual({ + pluginId: 'catalog', + resourceType: 'catalog-entity', + result: AuthorizeResult.CONDITIONAL, + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/mike'], + }, + }, + ], + }, + }); + }); +}); + +function newPolicyQueryWithBasicPermission( + name: string, + action?: 'create' | 'read' | 'update' | 'delete', +): PolicyQuery { + const mockPermission = createPermission({ + name: name, + attributes: { action }, + }); + return { permission: mockPermission }; +} + +function newPolicyQueryWithResourcePermission( + name: string, + resource: string, + action: PermissionAction, +): PolicyQuery { + const mockPermission = createPermission({ + name: name, + attributes: {}, + resourceType: resource, + }); + if (action) { + mockPermission.attributes.action = action; + } + return { permission: mockPermission }; +} + +function newPolicyQueryUser( + user?: string, + ownershipEntityRefs?: string[], +): PolicyQueryUser | undefined { + if (user) { + return { + identity: { + ownershipEntityRefs: ownershipEntityRefs ?? [], + type: 'user', + userEntityRef: user, + }, + credentials: { + $$type: '@backstage/BackstageCredentials', + principal: true, + expiresAt: new Date('2021-01-01T00:00:00Z'), + }, + info: { + userEntityRef: user, + ownershipEntityRefs: ownershipEntityRefs ?? [], + }, + token: 'token', + }; + } + return undefined; +} + +function newConfig( + permFile?: string, + users?: Array<{ name: string }>, + superUsers?: Array<{ name: string }>, + policyDecisionPrecedence?: 'basic' | 'conditional', +): Config { + const testUsers = [ + { + name: 'user:default/guest', + }, + { + name: 'group:default/guests', + }, + ]; + + return mockServices.rootConfig({ + data: { + permission: { + rbac: { + 'policies-csv-file': permFile || csvPermFile, + policyFileReload: false, + admin: { + users: users || testUsers, + superUsers: superUsers, + }, + policyDecisionPrecedence: policyDecisionPrecedence ?? 'conditional', + }, + }, + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }, + }); +} + +function newConfigWithDefaultRole( + defaultRole?: string, + permFile?: string, + users?: Array<{ name: string }>, + superUsers?: Array<{ name: string }>, +): Config { + const testUsers = [ + { + name: 'user:default/guest', + }, + { + name: 'group:default/guests', + }, + ]; + + const rbacConfig: any = { + 'policies-csv-file': permFile || csvPermFile, + policyFileReload: false, + admin: { + users: users || testUsers, + superUsers: superUsers, + }, + }; + + if (defaultRole !== undefined) { + rbacConfig.defaultPermissions = { + defaultRole, + basicPermissions: [ + { permission: 'catalog.entity.read', action: 'read' }, + { permission: 'catalog-entity', action: 'read' }, + { permission: 'catalog.entity.create', action: 'create' }, + ], + }; + } + + return mockServices.rootConfig({ + data: { + permission: { + rbac: rbacConfig, + }, + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }, + }); +} + +async function newAdapter(config: Config): Promise { + return await new CasbinDBAdapterFactory( + config, + mockClientKnex, + ).createAdapter(); +} + +async function createEnforcer( + theModel: Model, + adapter: Adapter, + logger: LoggerService, + config: Config, +): Promise { + const catalogDBClient = Knex.knex({ client: MockClient }); + const rbacDBClient = Knex.knex({ client: MockClient }); + const enf = await newEnforcer(theModel, adapter); + + const rm = new BackstageRoleManager( + catalogMock, + logger, + catalogDBClient, + rbacDBClient, + config, + mockAuthService, + new DefaultPermissionsReader(config), + ); + enf.setRoleManager(rm); + enf.enableAutoBuildRoleLinks(false); + await enf.buildRoleLinks(); + + return enf; +} + +async function newEnforcerDelegate( + adapter: Adapter, + config: Config, + storedPolicies?: string[][], + storedGroupingPolicies?: string[][], +): Promise { + const theModel = newModelFromString(MODEL); + const logger = mockServices.logger.mock(); + + const enf = await createEnforcer(theModel, adapter, logger, config); + + if (storedPolicies) { + await enf.addPolicies(storedPolicies); + } + + if (storedGroupingPolicies) { + await enf.addGroupingPolicies(storedGroupingPolicies); + } + + return new EnforcerDelegate( + enf, + mockAuditorService, + conditionalStorageMock, + roleMetadataStorageMock, + mockClientKnex, + ); +} + +async function newPermissionPolicy( + config: Config, + enfDelegate: EnforcerDelegate, + roleMock?: RoleMetadataStorage, + defaultPolicies: RoleBasedPolicy[] = [], +): Promise { + const defaultRoleRef = defaultPolicies[0]?.entityReference; + if (defaultPolicies.length > 0) { + const casbinPolicies = defaultPolicies.map(p => [ + p.entityReference!, + p.permission!, + p.policy!, + p.effect!, + ]); + await enfDelegate.addPolicies(casbinPolicies); + const storage = roleMock || roleMetadataStorageMock; + if (defaultRoleRef) { + (storage.getCachedDefaultRoleMetadata as jest.Mock).mockReturnValue( + buildDefaultRoleMetadata(defaultRoleRef), + ); + } + } else { + const storage = roleMock || roleMetadataStorageMock; + (storage.getCachedDefaultRoleMetadata as jest.Mock).mockReturnValue( + undefined, + ); + } + + const logger = mockServices.logger.mock(); + const permissionPolicy = await RBACPermissionPolicy.build( + logger, + mockAuditorService, + config, + conditionalStorageMock, + enfDelegate, + roleMock || roleMetadataStorageMock, + mockClientKnex, + pluginMetadataCollectorMock as PluginPermissionMetadataCollector, + mockAuthService, + ); + clearAuditorMock(); + return permissionPolicy; +} + +describe('Default Role Tests', () => { + let enfDelegate: EnforcerDelegate; + let policy: RBACPermissionPolicy; + + const defaultRolePolicies: RoleBasedPolicy[] = [ + { + entityReference: 'role:default/viewer', + permission: 'catalog.entity.read', + policy: 'read', + effect: 'allow', + }, + { + entityReference: 'role:default/viewer', + permission: 'catalog-entity', + policy: 'read', + effect: 'allow', + }, + { + entityReference: 'role:default/viewer', + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ]; + + describe('when defaultRole is configured', () => { + beforeEach(async () => { + const config = newConfigWithDefaultRole('role:default/viewer'); + const adapter = await newAdapter(config); + enfDelegate = await newEnforcerDelegate(adapter, config); + + policy = await newPermissionPolicy( + config, + enfDelegate, + undefined, + defaultRolePolicies, + ); + }); + + it('should add default role to user roles when user has no explicit roles', async () => { + // Create a user with no explicit roles assigned + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/noroles'), + ); + + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/noroles', + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('should add default role to user roles when user has existing roles but not the default one', async () => { + // Add user to another role first + await enfDelegate.addGroupingPolicy( + ['user:default/hasrole', 'role:default/custom'], + { + source: 'rest', + roleEntityRef: 'role:default/custom', + modifiedBy: 'test', + }, + ); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/hasrole'), + ); + + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/hasrole', + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('should not duplicate default role when user already has it assigned explicitly', async () => { + // Add user to the default role explicitly + await enfDelegate.addGroupingPolicy( + ['user:default/alreadyhas', 'role:default/viewer'], + { + source: 'rest', + roleEntityRef: 'role:default/viewer', + modifiedBy: 'test', + }, + ); + + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/alreadyhas'), + ); + + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/alreadyhas', + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.ALLOW, + ); + }); + + it('should work with basic permissions when default role is applied', async () => { + // Add a basic permission for the default role + await enfDelegate.addPolicy([ + 'role:default/viewer', + 'catalog.entity.create', + 'use', + 'allow', + ]); + + const decision = await policy.handle( + newPolicyQueryWithBasicPermission('catalog.entity.create'), + newPolicyQueryUser('user:default/basictest'), + ); + + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expectAuditorLogForPermission( + 'user:default/basictest', + 'catalog.entity.create', + undefined, + 'use', + AuthorizeResult.ALLOW, + ); + }); + }); + + describe('when defaultRole is not configured', () => { + beforeEach(async () => { + const config = newConfig(); // No default role + const adapter = await newAdapter(config); + enfDelegate = await newEnforcerDelegate(adapter, config); + policy = await newPermissionPolicy(config, enfDelegate); + }); + + it('should not add any default role when none is configured', async () => { + const decision = await policy.handle( + newPolicyQueryWithResourcePermission( + 'catalog.entity.read', + 'catalog-entity', + 'read', + ), + newPolicyQueryUser('user:default/nodefault'), + ); + + // Should deny since no permissions are granted and no default role + expect(decision.result).toBe(AuthorizeResult.DENY); + expectAuditorLogForPermission( + 'user:default/nodefault', + 'catalog.entity.read', + 'catalog-entity', + 'read', + AuthorizeResult.DENY, + ); + }); + }); +}); diff --git a/plugins/rbac-backend/src/policies/permission-policy.ts b/plugins/rbac-backend/src/policies/permission-policy.ts new file mode 100644 index 0000000000..c03b391336 --- /dev/null +++ b/plugins/rbac-backend/src/policies/permission-policy.ts @@ -0,0 +1,384 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { + AuditorService, + AuditorServiceEvent, + AuthService, + BackstageUserInfo, + LoggerService, +} from '@backstage/backend-plugin-api'; +import type { ConfigApi } from '@backstage/core-plugin-api'; +import { + AuthorizeResult, + ConditionalPolicyDecision, + isResourcePermission, + PermissionCondition, + PermissionCriteria, + PermissionRuleParams, + PolicyDecision, + ResourcePermission, +} from '@backstage/plugin-permission-common'; +import type { + PermissionPolicy, + PolicyQuery, + PolicyQueryUser, +} from '@backstage/plugin-permission-node'; + +import type { Knex } from 'knex'; + +import { + NonEmptyArray, + toPermissionAction, +} from '@backstage-community/plugin-rbac-common'; + +import { + setAdminPermissions, + useAdminsFromConfig, +} from '../admin-permissions/admin-creation'; +import { createPermissionEvaluationAuditorEvent } from '../auditor/auditor'; +import { replaceAliases } from '../conditional-aliases/alias-resolver'; +import { ConditionalStorage } from '../database/conditional-storage'; +import { RoleMetadataStorage } from '../database/role-metadata'; +import { CSVFileWatcher } from '../file-permissions/csv-file-watcher'; +import { YamlConditinalPoliciesFileWatcher } from '../file-permissions/yaml-conditional-file-watcher'; +import { EnforcerDelegate } from '../service/enforcer-delegate'; +import { PluginPermissionMetadataCollector } from '../service/plugin-endpoints'; + +export class RBACPermissionPolicy implements PermissionPolicy { + private readonly superUserList?: string[]; + private readonly preferPermissionPolicy: boolean; + + public static async build( + logger: LoggerService, + auditor: AuditorService, + configApi: ConfigApi, + conditionalStorage: ConditionalStorage, + enforcerDelegate: EnforcerDelegate, + roleMetadataStorage: RoleMetadataStorage, + knex: Knex, + pluginMetadataCollector: PluginPermissionMetadataCollector, + auth: AuthService, + ): Promise { + const superUserList: string[] = []; + const adminUsers = configApi.getOptionalConfigArray( + 'permission.rbac.admin.users', + ); + + const superUsers = configApi.getOptionalConfigArray( + 'permission.rbac.admin.superUsers', + ); + + const policiesFile = configApi.getOptionalString( + 'permission.rbac.policies-csv-file', + ); + + const allowReload = + configApi.getOptionalBoolean('permission.rbac.policyFileReload') || false; + + const conditionalPoliciesFile = configApi.getOptionalString( + 'permission.rbac.conditionalPoliciesFile', + ); + + const preferPermissionPolicy = + (configApi.getOptionalString( + 'permission.rbac.policyDecisionPrecedence', + ) ?? 'conditional') === 'basic'; + + if (superUsers && superUsers.length > 0) { + for (const user of superUsers) { + const userName = user.getString('name'); + superUserList.push(userName); + } + } + + await useAdminsFromConfig( + adminUsers || [], + enforcerDelegate, + auditor, + roleMetadataStorage, + knex, + ); + await setAdminPermissions(enforcerDelegate, auditor); + + if ( + (!adminUsers || adminUsers.length === 0) && + (!superUsers || superUsers.length === 0) + ) { + logger.warn( + 'There are no admins or super admins configured for the RBAC-backend plugin.', + ); + } + + const csvFile = new CSVFileWatcher( + policiesFile, + allowReload, + logger, + enforcerDelegate, + roleMetadataStorage, + auditor, + ); + await csvFile.initialize(); + + const conditionalFile = new YamlConditinalPoliciesFileWatcher( + conditionalPoliciesFile, + allowReload, + logger, + conditionalStorage, + auditor, + auth, + pluginMetadataCollector, + roleMetadataStorage, + enforcerDelegate, + ); + await conditionalFile.initialize(); + + if (!conditionalPoliciesFile) { + // clean up conditional policies corresponding to roles from csv file + logger.info('conditional policies file feature was disabled'); + await conditionalFile.cleanUpConditionalPolicies(); + } + if (!policiesFile) { + // remove roles and policies from csv file + logger.info('csv policies file feature was disabled'); + await csvFile.cleanUpRolesAndPolicies(); + } + + return new RBACPermissionPolicy( + enforcerDelegate, + auditor, + conditionalStorage, + preferPermissionPolicy, + superUserList, + ); + } + + private constructor( + private readonly enforcer: EnforcerDelegate, + private readonly auditor: AuditorService, + private readonly conditionStorage: ConditionalStorage, + preferPermissionPolicy: boolean, + superUserList?: string[], + ) { + this.superUserList = superUserList; + this.preferPermissionPolicy = preferPermissionPolicy; + } + + async handle( + request: PolicyQuery, + user?: PolicyQueryUser, + ): Promise { + const userEntityRef = user?.info.userEntityRef ?? `user without entity`; + + const auditorEvent = await createPermissionEvaluationAuditorEvent( + this.auditor, + userEntityRef, + request, + ); + + try { + let status = false; + const action = toPermissionAction(request.permission.attributes); + + if (!user) { + await auditorEvent.success({ + meta: { result: AuthorizeResult.DENY }, + }); + return { result: AuthorizeResult.DENY }; + } + + if ( + this.superUserList!.includes(userEntityRef) || + user.info.ownershipEntityRefs.some(ref => + this.superUserList!.includes(ref), + ) + ) { + await auditorEvent.success({ + meta: { result: AuthorizeResult.ALLOW }, + }); + return { result: AuthorizeResult.ALLOW }; + } + + const permissionName = request.permission.name; + const roles = await this.enforcer.getRolesForUser(userEntityRef); + // handle permission with 'resource' type + const hasNamedPermission = await this.hasImplicitPermission( + permissionName, + action, + roles, + ); + + // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` + if ( + request.permission.name === 'policy.entity.create' && + !hasNamedPermission + ) { + request.permission = { + attributes: { action: 'create' }, + type: 'resource', + resourceType: 'policy-entity', + name: 'policy.entity.create', + }; + } + + if (isResourcePermission(request.permission)) { + const resourceType = request.permission.resourceType; + // Let's set up higher priority for permission specified by name, than by resource type + const obj = hasNamedPermission ? permissionName : resourceType; + // handle conditions if they are present + const conditionResult = await this.handleConditions( + auditorEvent, + userEntityRef, + request, + roles, + user.info, + ); + + if (this.preferPermissionPolicy) { + const hasResourcedPermission = await this.hasImplicitPermission( + resourceType, + action, + roles, + ); + // Permission policy first + if (hasNamedPermission || hasResourcedPermission) { + status = await this.isAuthorized(userEntityRef, obj, action, roles); + } else if (conditionResult) { + return conditionResult; + } + } else { + if (conditionResult) return conditionResult; + status = await this.isAuthorized(userEntityRef, obj, action, roles); + } + } else { + // handle permission with 'basic' type + status = await this.isAuthorized( + userEntityRef, + permissionName, + action, + roles, + ); + } + + const result = status ? AuthorizeResult.ALLOW : AuthorizeResult.DENY; + + await auditorEvent.success({ meta: { result } }); + return { result }; + } catch (error) { + await auditorEvent.fail({ + error, + meta: { result: AuthorizeResult.DENY }, + }); + return { result: AuthorizeResult.DENY }; + } + } + + private async hasImplicitPermission( + permissionName: string, + action: string, + roles: string[], + ): Promise { + for (const role of roles) { + const perms = await this.enforcer.getFilteredPolicy( + 0, + role, + permissionName, + action, + ); + if (perms.length > 0) { + return true; + } + } + + return false; + } + + private isAuthorized = async ( + userIdentity: string, + permission: string, + action: string, + roles: string[], + ): Promise => { + return await this.enforcer.enforce(userIdentity, permission, action, roles); + }; + + private async handleConditions( + auditorEvent: AuditorServiceEvent, + userEntityRef: string, + request: PolicyQuery, + roles: string[], + userInfo: BackstageUserInfo, + ): Promise { + const permissionName = request.permission.name; + const resourceType = (request.permission as ResourcePermission) + .resourceType; + const action = toPermissionAction(request.permission.attributes); + + const conditions: PermissionCriteria< + PermissionCondition + >[] = []; + let pluginId = ''; + for (const role of roles) { + const conditionalDecisions = await this.conditionStorage.filterConditions( + role, + undefined, + resourceType, + [action], + [permissionName], + ); + + if (conditionalDecisions.length === 1) { + pluginId = conditionalDecisions[0].pluginId; + conditions.push(conditionalDecisions[0].conditions); + } + + // this error is unexpected and should not happen, but just in case handle it. + if (conditionalDecisions.length > 1) { + await auditorEvent.fail({ + error: new Error( + `Detected ${JSON.stringify( + conditionalDecisions, + )} collisions for conditional policies. Expected to find a stored single condition for permission with name ${permissionName}, resource type ${resourceType}, action ${action} for user ${userEntityRef}`, + ), + meta: { result: AuthorizeResult.DENY }, + }); + return { + result: AuthorizeResult.DENY, + }; + } + } + + if (conditions.length > 0) { + const result: ConditionalPolicyDecision = { + pluginId, + result: AuthorizeResult.CONDITIONAL, + resourceType, + conditions: { + anyOf: conditions as NonEmptyArray< + PermissionCriteria< + PermissionCondition + > + >, + }, + }; + + replaceAliases(result.conditions, userInfo); + + await auditorEvent.success({ meta: { ...result } }); + return result; + } + return undefined; + } +} diff --git a/plugins/rbac-backend/src/providers/connect-providers.test.ts b/plugins/rbac-backend/src/providers/connect-providers.test.ts new file mode 100644 index 0000000000..1967324a3d --- /dev/null +++ b/plugins/rbac-backend/src/providers/connect-providers.test.ts @@ -0,0 +1,851 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { LoggerService } from '@backstage/backend-plugin-api'; +import { mockServices } from '@backstage/backend-test-utils'; + +import { + Adapter, + Enforcer, + Model, + newEnforcer, + newModelFromString, +} from 'casbin'; +import * as Knex from 'knex'; +import { MockClient } from 'knex-mock-client'; + +import type { + RBACProvider, + RBACProviderConnection, +} from '@backstage-community/plugin-rbac-node'; + +import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; +import { + RoleMetadataDao, + RoleMetadataStorage, +} from '../database/role-metadata'; +import { BackstageRoleManager } from '../role-manager/role-manager'; +import { DefaultPermissionsReader } from '../default-permissions/default-permissions'; +import { EnforcerDelegate } from '../service/enforcer-delegate'; +import { MODEL } from '../service/permission-model'; +import { Connection, connectRBACProviders } from './connect-providers'; +import { + catalogMock, + mockAuditorService, + createEventMock, +} from '../../__fixtures__/mock-utils'; +import { + clearAuditorMock, + expectAuditorLog, +} from '../../__fixtures__/auditor-test-utils'; +import { + ActionType, + ConditionEvents, + PermissionEvents, +} from '../auditor/auditor'; +import { + PermissionAction, + PermissionInfo, + RoleConditionalPolicyDecision, +} from '@backstage-community/plugin-rbac-common'; +import { ConditionalStorage } from '../database/conditional-storage'; +import { ConflictError } from '@backstage/errors'; + +const mockLoggerService = mockServices.logger.mock(); + +const roleMetadataStorageMock: RoleMetadataStorage = { + filterRoleMetadata: jest + .fn() + .mockImplementation( + async ( + _roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + return [ + { + roleEntityRef: 'role:default/old-provider-role', + source: 'test', + modifiedBy: 'test', + }, + { + roleEntityRef: 'role:default/existing-provider-role', + source: 'test', + modifiedBy: 'test', + }, + ]; + }, + ), + findRoleMetadata: jest + .fn() + .mockImplementation( + async ( + roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + if (roleEntityRef === 'role:default/old-provider-role') { + return { + roleEntityRef: 'role:default/old-provider-role', + source: 'test', + modifiedBy: 'test', + }; + } else if (roleEntityRef === 'role:default/existing-provider-role') { + return { + roleEntityRef: 'role:default/existing-provider-role', + source: 'test', + modifiedBy: 'test', + }; + } else if (roleEntityRef === 'role:default/csv-role') { + return { + roleEntityRef: 'role:default/csv-role', + source: 'csv-file', + modifiedBy: 'csv-file', + }; + } + return undefined; + }, + ), + filterForOwnerRoleMetadata: jest.fn().mockImplementation(), + createRoleMetadata: jest.fn().mockImplementation(), + updateRoleMetadata: jest.fn().mockImplementation(), + removeRoleMetadata: jest.fn().mockImplementation(), + getCachedDefaultRoleMetadata: jest.fn().mockImplementation(), + getDefaultRole: jest.fn().mockResolvedValue(undefined), + syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), +}; + +const mockAuthService = mockServices.auth(); + +const mockClientKnex = Knex.knex({ client: MockClient }); + +const providerMock: RBACProvider = { + getProviderName: jest.fn().mockImplementation(), + connect: jest.fn().mockImplementation(), + refresh: jest.fn().mockImplementation(), +}; + +const roleToBeRemoved = ['user:default/old', 'role:default/old-provider-role']; +const roleMetaToBeRemoved = { + modifiedBy: 'test', + source: 'test', + roleEntityRef: roleToBeRemoved[1], +}; + +const existingRoles = [ + ['user:default/bruce', 'role:default/existing-provider-role'], + ['user:default/tony', 'role:default/existing-provider-role'], +]; +const existingRoleMetadata = { + modifiedBy: 'test', + source: 'test', + roleEntityRef: existingRoles[0][1], +}; +const existingPolicy = [ + ['role:default/existing-provider-role', 'catalog-entity', 'read', 'allow'], +]; +const existingConditionalPermission: RoleConditionalPolicyDecision[] = + [ + { + id: 1, + result: 'CONDITIONAL', + roleEntityRef: 'role:default/existing', + pluginId: 'catalog', + resourceType: 'catalog-entity', + permissionMapping: [{ name: 'read', action: 'read' }], + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/existing-team'], + }, + }, + }, + ]; + +const conditionalStorageMock: ConditionalStorage = { + filterConditions: jest + .fn() + .mockImplementation(() => existingConditionalPermission), + createCondition: jest.fn().mockImplementation(), + checkConflictedConditions: jest + .fn() + .mockImplementation( + async ( + roleEntityRef: string, + _resourceType: string, + _pluginId: string, + _queryConditionActions: PermissionAction[], + _idToExclude: number, + _trx?: Knex.Knex.Transaction, + ) => { + if (roleEntityRef === 'role:default/conflicting-role') { + throw new ConflictError(`Found conditional permission conflict.`); + } + }, + ), + getCondition: jest.fn().mockImplementation(), + deleteCondition: jest.fn().mockImplementation(), + updateCondition: jest.fn().mockImplementation(), +}; + +const config = mockServices.rootConfig({ + data: { + permission: { + rbac: {}, + }, + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }, +}); + +describe('Connection', () => { + let provider: Connection; + let enforcerDelegate: EnforcerDelegate; + + beforeEach(async () => { + const id = 'test'; + const adapter = await new CasbinDBAdapterFactory( + config, + mockClientKnex, + ).createAdapter(); + + const stringModel = newModelFromString(MODEL); + const enf = await createEnforcer(stringModel, adapter, mockLoggerService); + + const knex = Knex.knex({ client: MockClient }); + + enforcerDelegate = new EnforcerDelegate( + enf, + mockAuditorService, + conditionalStorageMock, + roleMetadataStorageMock, + knex, + ); + + await enforcerDelegate.addGroupingPolicy( + roleToBeRemoved, + roleMetaToBeRemoved, + ); + + await enforcerDelegate.addGroupingPolicies( + existingRoles, + existingRoleMetadata, + ); + + await enforcerDelegate.addPolicies(existingPolicy); + + for (const conditionalPermission of existingConditionalPermission) { + await conditionalStorageMock.createCondition(conditionalPermission); + } + + provider = new Connection( + id, + enforcerDelegate, + roleMetadataStorageMock, + conditionalStorageMock, + mockLoggerService, + mockAuditorService, + ); + + clearAuditorMock(); + }); + + it('should initialize', () => { + expect(provider).toBeDefined(); + }); + + describe('applyRoles', () => { + let enfAddGroupingPolicySpy: jest.SpyInstance< + Promise, + [ + policy: string[], + roleMetadata: RoleMetadataDao, + externalTrx?: Knex.Knex.Transaction | undefined, + ], + any + >; + let enfRemoveGroupingPolicySpy: jest.SpyInstance< + Promise, + [ + policy: string[], + roleMetadata: RoleMetadataDao, + isUpdate?: boolean | undefined, + externalTrx?: Knex.Knex.Transaction | undefined, + ], + any + >; + + afterEach(() => { + (mockLoggerService.warn as jest.Mock).mockReset(); + }); + + it('should add the new roles', async () => { + enfAddGroupingPolicySpy = jest.spyOn( + enforcerDelegate, + 'addGroupingPolicy', + ); + + const roles = [ + ['user:default/test', 'role:default/test-provider'], // to add + ['user:default/bruce', 'role:default/existing-provider-role'], + ['user:default/tony', 'role:default/existing-provider-role'], + ['user:default/Adam', 'role:default/test-provider'], // to add + ]; + + await provider.applyRoles(roles); + expect(enfAddGroupingPolicySpy).toHaveBeenNthCalledWith( + 1, + ['user:default/test', 'role:default/test-provider'], + expect.objectContaining({ + modifiedBy: 'test', + source: 'test', + roleEntityRef: 'role:default/test-provider', + }), + ); + expect(enfAddGroupingPolicySpy).toHaveBeenNthCalledWith( + 2, + ['user:default/adam', 'role:default/test-provider'], + expect.objectContaining({ + modifiedBy: 'test', + source: 'test', + roleEntityRef: 'role:default/test-provider', + }), + ); + }); + + it('should remove the old roles', async () => { + enfRemoveGroupingPolicySpy = jest.spyOn( + enforcerDelegate, + 'removeGroupingPolicy', + ); + + await provider.applyRoles([ + ['user:default/bruce', 'role:default/existing-provider-role'], + ['user:default/tony', 'role:default/existing-provider-role'], + ]); + expect(enfRemoveGroupingPolicySpy).toHaveBeenCalledWith( + roleToBeRemoved, + roleMetaToBeRemoved, + false, + ); + }); + + it('should add a role to an already existing role', async () => { + enfAddGroupingPolicySpy = jest.spyOn( + enforcerDelegate, + 'addGroupingPolicy', + ); + + const roles = [ + ['user:default/peter', 'role:default/existing-provider-role'], + ['user:default/bruce', 'role:default/existing-provider-role'], + ['user:default/tony', 'role:default/existing-provider-role'], + ]; + + const roleToAdd = [ + ['user:default/peter', 'role:default/existing-provider-role'], + ]; + const roleMeta = { + modifiedBy: 'test', + source: 'test', + roleEntityRef: roleToAdd[0][1], + }; + + await provider.applyRoles(roles); + expect(enfAddGroupingPolicySpy).toHaveBeenCalledWith( + ...roleToAdd, + roleMeta, + ); + }); + + it('should remove a role member from an already existing role', async () => { + enfRemoveGroupingPolicySpy = jest.spyOn( + enforcerDelegate, + 'removeGroupingPolicy', + ); + + await provider.applyRoles([ + ['user:default/tony', 'role:default/existing-provider-role'], + ]); + expect(enfRemoveGroupingPolicySpy).toHaveBeenNthCalledWith( + 1, + roleToBeRemoved, + roleMetaToBeRemoved, + false, + ); + expect(enfRemoveGroupingPolicySpy).toHaveBeenNthCalledWith( + 2, + existingRoles[0], + existingRoleMetadata, + true, + ); + }); + + it('should log an error if a role is not valid', async () => { + const roles = [ + ['user:default/test', 'role:default/'], + ['user:default/bruce', 'role:default/existing-provider-role'], + ['user:default/tony', 'role:default/existing-provider-role'], + ]; + + const roleToAdd = `user:default/test,role:default/`; + + await provider.applyRoles(roles); + expect(mockLoggerService.warn).toHaveBeenCalledWith( + `Failed to validate group policy ${roleToAdd}. Cause: Entity reference "role:default/" was not on the form [:][/]`, + ); + }); + + it('should still add new role, even if there is an invalid role in array', async () => { + enfAddGroupingPolicySpy = jest.spyOn( + enforcerDelegate, + 'addGroupingPolicy', + ); + + const roles = [ + ['user:default/test', 'role:default/'], + ['user:default/test', 'role:default/test-provider'], + ['user:default/bruce', 'role:default/existing-provider-role'], + ['user:default/tony', 'role:default/existing-provider-role'], + ]; + + const failingRoleToAdd = `user:default/test,role:default/`; + const roleToAdd = [['user:default/test', 'role:default/test-provider']]; + + await provider.applyRoles(roles); + expect(mockLoggerService.warn).toHaveBeenCalledWith( + `Failed to validate group policy ${failingRoleToAdd}. Cause: Entity reference "role:default/" was not on the form [:][/]`, + ); + // Verify the call was made with the correct role, but ignore timestamp fields + expect(enfAddGroupingPolicySpy).toHaveBeenCalledWith( + ...roleToAdd, + expect.objectContaining({ + modifiedBy: 'test', + source: 'test', + roleEntityRef: roleToAdd[0][1], + }), + ); + }); + }); + + describe('applyPermissions', () => { + let enfAddPolicySpy: jest.SpyInstance< + Promise, + [ + policy: string[], + externalTrx?: Knex.Knex.Transaction | undefined, + ], + any + >; + let enfRemovePolicySpy: jest.SpyInstance< + Promise, + [ + policy: string[], + externalTrx?: Knex.Knex.Transaction | undefined, + ], + any + >; + + afterEach(() => { + (mockLoggerService.warn as jest.Mock).mockReset(); + }); + + it('should add new permissions', async () => { + enfAddPolicySpy = jest.spyOn(enforcerDelegate, 'addPolicy'); + + const policies = [ + ['role:default/provider-role', 'catalog-entity', 'read', 'allow'], + ]; + + await provider.applyPermissions(policies); + expect(enfAddPolicySpy).toHaveBeenCalledWith(...policies); + }); + + // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` + it('should add new permissions but log warning about `policy-entity, create` permission', async () => { + enfAddPolicySpy = jest.spyOn(enforcerDelegate, 'addPolicy'); + + const policies = [ + ['role:default/provider-role', 'policy-entity', 'create', 'allow'], + ]; + + await provider.applyPermissions(policies); + expect(enfAddPolicySpy).toHaveBeenCalledWith(...policies); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 1, + `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${policies[0]} to use 'policy.entity.create' instead of 'policy-entity' from source test`, + ); + }); + + it('should remove old permissions', async () => { + enfRemovePolicySpy = jest.spyOn(enforcerDelegate, 'removePolicy'); + + const policies = [ + ['role:default/provider-role', 'catalog-entity', 'read', 'allow'], + ]; + + await provider.applyPermissions(policies); + expect(enfRemovePolicySpy).toHaveBeenCalledWith(...existingPolicy); + }); + + it('should audit log an error for an invalid permission', async () => { + enfAddPolicySpy = jest.spyOn(enforcerDelegate, 'addPolicy'); + + const policies = [ + ...existingPolicy, + ['role:default/provider-role', 'catalog-entity', 'read', 'temp'], + ]; + + await provider.applyPermissions(policies); + expectAuditorLog([ + { + event: { + eventId: PermissionEvents.POLICY_WRITE, + meta: { actionType: ActionType.CREATE, source: 'test' }, + }, + fail: { + error: new Error( + `'effect' has invalid value: 'temp'. It should be: 'allow' or 'deny'`, + ), + meta: { + policies: [policies[1]], + }, + }, + }, + ]); + }); + + it('should audit log an error for an invalid permission by source', async () => { + enfAddPolicySpy = jest.spyOn(enforcerDelegate, 'addPolicy'); + + const policies = [ + ...existingPolicy, + ['role:default/csv-role', 'catalog-entity', 'read', 'allow'], + ]; + + await provider.applyPermissions(policies); + expectAuditorLog([ + { + event: { + eventId: PermissionEvents.POLICY_WRITE, + meta: { actionType: ActionType.CREATE, source: 'test' }, + }, + fail: { + error: new Error( + `source does not match originating role role:default/csv-role, consider making changes to the 'CSV-FILE'`, + ), + meta: { + policies: [policies[1]], + }, + }, + }, + ]); + }); + + it('should still add new permission, even if there is an invalid permission in array', async () => { + enfAddPolicySpy = jest.spyOn(enforcerDelegate, 'addPolicy'); + + const policies = [ + ...existingPolicy, // Keep existing policy to avoid removal event + ['role:default/provider-role', 'catalog-entity', 'read', 'temp'], // invalid + ['role:default/provider-role', 'catalog-entity', 'create', 'allow'], // valid + ]; + + const invalidPolicy = policies[1]; + const validPolicyToAdd = [policies[2]]; + + await provider.applyPermissions(policies); + + // Verify that the invalid permission triggered a fail event + const failedEvents = createEventMock.fail.mock.calls; + const invalidPolicyFailed = failedEvents.some( + call => + call[0].error?.message === + `'effect' has invalid value: 'temp'. It should be: 'allow' or 'deny'` && + call[0].meta?.policies?.[0] === invalidPolicy, + ); + expect(invalidPolicyFailed).toBe(true); + + // Verify that the valid permission was still added despite the invalid one + expect(enfAddPolicySpy).toHaveBeenCalledWith(...validPolicyToAdd); + // Verify that only the valid policy was added (not the invalid one) + expect(enfAddPolicySpy).toHaveBeenCalledTimes(1); + + // Verify that a success event was also logged for the valid permission + const succeededEvents = createEventMock.success.mock.calls; + const validPolicySucceeded = succeededEvents.some( + call => call[0].meta?.policies?.[0] === validPolicyToAdd[0], + ); + expect(validPolicySucceeded).toBe(true); + }); + }); + + describe('applyConditionalPermissions', () => { + beforeEach(() => { + (conditionalStorageMock.createCondition as jest.Mock).mockReset(); + (conditionalStorageMock.deleteCondition as jest.Mock).mockReset(); + }); + + afterEach(() => { + (mockLoggerService.warn as jest.Mock).mockReset(); + (conditionalStorageMock.createCondition as jest.Mock).mockReset(); + (conditionalStorageMock.deleteCondition as jest.Mock).mockReset(); + }); + + it('should create conditional permissions', async () => { + const policies: RoleConditionalPolicyDecision[] = [ + { + id: 0, + result: 'CONDITIONAL', + roleEntityRef: 'role:default/test', + pluginId: 'catalog', + resourceType: 'catalog-entity', + permissionMapping: [{ name: 'read', action: 'read' }], + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/team-a'], + }, + }, + }, + ]; + await provider.applyConditionalPermissions(policies); + expect(conditionalStorageMock.createCondition).toHaveBeenCalledWith( + ...policies, + ); + }); + + it('should remove old conditional permissions', async () => { + const policies: RoleConditionalPolicyDecision[] = [ + { + id: 0, + result: 'CONDITIONAL', + roleEntityRef: 'role:default/test', + pluginId: 'catalog', + resourceType: 'catalog-entity', + permissionMapping: [{ name: 'read', action: 'read' }], + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/team-a'], + }, + }, + }, + ]; + + await provider.applyConditionalPermissions(policies); + expect(conditionalStorageMock.deleteCondition).toHaveBeenCalledWith( + ...existingConditionalPermission.map(it => it.id), + ); + }); + + it('should not add policies that exist already, if not changed', async () => { + const policies: RoleConditionalPolicyDecision[] = [ + ...existingConditionalPermission, + ]; + + await provider.applyConditionalPermissions(policies); + expect(conditionalStorageMock.createCondition).not.toHaveBeenCalled(); + }); + + it('should not remove existing policies if not changed', async () => { + const policies: RoleConditionalPolicyDecision[] = [ + { + id: 0, + result: 'CONDITIONAL', + roleEntityRef: 'role:default/test', + pluginId: 'catalog', + resourceType: 'catalog-entity', + permissionMapping: [{ name: 'read', action: 'read' }], + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/team-a'], + }, + }, + }, + ...existingConditionalPermission, + ]; + + await provider.applyConditionalPermissions(policies); + expect(conditionalStorageMock.deleteCondition).toHaveBeenCalledTimes(0); + }); + + it('should replace changed policies', async () => { + const policies: RoleConditionalPolicyDecision[] = [ + { + id: existingConditionalPermission[0].id, + result: existingConditionalPermission[0].result, + roleEntityRef: existingConditionalPermission[0].roleEntityRef, + pluginId: existingConditionalPermission[0].pluginId, + resourceType: existingConditionalPermission[0].resourceType, + permissionMapping: [ + { name: 'read', action: 'read' }, + { name: 'delete', action: 'delete' }, + ], + conditions: existingConditionalPermission[0].conditions, + }, + ]; + + await provider.applyConditionalPermissions(policies); + expect(conditionalStorageMock.deleteCondition).toHaveBeenCalledTimes(1); + expect(conditionalStorageMock.createCondition).toHaveBeenCalledTimes(1); + }); + it('should replace permissions with changed condition', async () => { + const policies: RoleConditionalPolicyDecision[] = [ + { + id: existingConditionalPermission[0].id, + result: existingConditionalPermission[0].result, + roleEntityRef: existingConditionalPermission[0].roleEntityRef, + pluginId: existingConditionalPermission[0].pluginId, + resourceType: existingConditionalPermission[0].resourceType, + permissionMapping: existingConditionalPermission[0].permissionMapping, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: [ + 'group:default/existing-team', + 'group:default/one-more-team', + ], + }, + }, + }, + ]; + + await provider.applyConditionalPermissions(policies); + expect(conditionalStorageMock.deleteCondition).toHaveBeenCalledTimes(1); + expect(conditionalStorageMock.createCondition).toHaveBeenCalledTimes(1); + }); + it('should reject policies from an invalid source', async () => { + const anotherProvider = new Connection( + 'another-provider', + enforcerDelegate, + roleMetadataStorageMock, + conditionalStorageMock, + mockLoggerService, + mockAuditorService, + ); + + const policies: RoleConditionalPolicyDecision[] = [ + { + id: 0, + result: 'CONDITIONAL', + roleEntityRef: 'role:default/existing-provider-role', + pluginId: 'catalog', + resourceType: 'catalog-entity', + permissionMapping: [{ name: 'read', action: 'read' }], + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/team-a'], + }, + }, + }, + ...existingConditionalPermission, + ]; + + await anotherProvider.applyConditionalPermissions(policies); + expectAuditorLog([ + { + event: { + eventId: ConditionEvents.CONDITION_WRITE, + meta: { actionType: ActionType.CREATE, source: 'another-provider' }, + }, + fail: { + error: new Error( + `source does not match originating role role:default/existing-provider-role, consider making changes to the 'TEST'`, + ), + meta: { + policies: [policies[0]], + }, + }, + }, + ]); + }); + }); +}); + +describe('connectRBACProviders', () => { + let connectSpy: jest.SpyInstance< + Promise, + [connection: RBACProviderConnection], + any + >; + it('should initialize rbac providers', async () => { + connectSpy = jest.spyOn(providerMock, 'connect'); + + const adapter = await new CasbinDBAdapterFactory( + config, + mockClientKnex, + ).createAdapter(); + + const stringModel = newModelFromString(MODEL); + const enf = await createEnforcer(stringModel, adapter, mockLoggerService); + + const knex = Knex.knex({ client: MockClient }); + + const enforcerDelegate = new EnforcerDelegate( + enf, + mockAuditorService, + conditionalStorageMock, + roleMetadataStorageMock, + knex, + ); + + await connectRBACProviders( + [providerMock], + enforcerDelegate, + roleMetadataStorageMock, + conditionalStorageMock, + mockLoggerService, + mockAuditorService, + ); + + expect(connectSpy).toHaveBeenCalled(); + }); +}); + +async function createEnforcer( + theModel: Model, + adapter: Adapter, + logger: LoggerService, +): Promise { + const catalogDBClient = Knex.knex({ client: MockClient }); + const rbacDBClient = Knex.knex({ client: MockClient }); + const enf = await newEnforcer(theModel, adapter); + + const rm = new BackstageRoleManager( + catalogMock, + logger, + catalogDBClient, + rbacDBClient, + config, + mockAuthService, + new DefaultPermissionsReader(config), + ); + enf.setRoleManager(rm); + enf.enableAutoBuildRoleLinks(false); + await enf.buildRoleLinks(); + + return enf; +} diff --git a/plugins/rbac-backend/src/providers/connect-providers.ts b/plugins/rbac-backend/src/providers/connect-providers.ts new file mode 100644 index 0000000000..52aa1b3b76 --- /dev/null +++ b/plugins/rbac-backend/src/providers/connect-providers.ts @@ -0,0 +1,438 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { + AuditorService, + LoggerService, +} from '@backstage/backend-plugin-api'; + +import { + Enforcer, + newEnforcer, + newModelFromString, + StringAdapter, +} from 'casbin'; + +import type { + RBACProvider, + RBACProviderConnection, +} from '@backstage-community/plugin-rbac-node'; + +import { + ActionType, + ConditionEvents, + PermissionEvents, + RoleEvents, +} from '../auditor/auditor'; +import { RoleMetadataStorage } from '../database/role-metadata'; +import { + transformArrayToPolicy, + transformRolesGroupToLowercase, + typedPoliciesToString, +} from '../helper'; +import { EnforcerDelegate } from '../service/enforcer-delegate'; +import { MODEL } from '../service/permission-model'; +import { + validateGroupingPolicy, + validatePolicy, + validateSource, +} from '../validation/policies-validation'; +import { ConditionalStorage } from '../database/conditional-storage'; +import { + PermissionInfo, + RoleConditionalPolicyDecision, +} from '@backstage-community/plugin-rbac-common'; +import { isEqual } from 'lodash'; + +export class Connection implements RBACProviderConnection { + constructor( + private readonly id: string, + private readonly enforcer: EnforcerDelegate, + private readonly roleMetadataStorage: RoleMetadataStorage, + private readonly conditionStorage: ConditionalStorage, + private readonly logger: LoggerService, + private readonly auditor: AuditorService, + ) {} + + async applyRoles(roles: string[][]): Promise { + const lowercasedRoles = transformRolesGroupToLowercase(roles); + const stringPolicy = typedPoliciesToString(lowercasedRoles, 'g'); + const providerRolesforRemoval: string[][] = []; + + const tempEnforcer = await newEnforcer( + newModelFromString(MODEL), + new StringAdapter(stringPolicy), + ); + + const providerRoles = await this.getProviderRoles(); + + await this.enforcer.loadPolicy(); + // Get the roles for this provider coming from rbac plugin + for (const providerRole of providerRoles) { + providerRolesforRemoval.push( + ...(await this.enforcer.getFilteredGroupingPolicy(1, providerRole)), + ); + } + + // Remove role + // role exists in rbac but does not exist in provider + await this.removeRoles(providerRolesforRemoval, tempEnforcer); + + // Add the role + // role exists in provider but does not exist in rbac + await this.addRoles(lowercasedRoles); + } + + async applyPermissions(permissions: string[][]): Promise { + const stringPolicy = typedPoliciesToString(permissions, 'p'); + + const providerPermissions: string[][] = []; + + const tempEnforcer = await newEnforcer( + newModelFromString(MODEL), + new StringAdapter(stringPolicy), + ); + + const providerRoles = await this.getProviderRoles(); + + await this.enforcer.loadPolicy(); + // Get the roles for this provider coming from rbac plugin + for (const providerRole of providerRoles) { + providerPermissions.push( + ...(await this.enforcer.getFilteredPolicy(0, providerRole)), + ); + } + + await this.removePermissions(providerPermissions, tempEnforcer); + + await this.addPermissions(permissions); + } + + async applyConditionalPermissions( + conditionalPermissions: RoleConditionalPolicyDecision[], + ): Promise { + const storedConditionalPermissions = + await this.conditionStorage.filterConditions(); + + const conditionsToBeAdded: RoleConditionalPolicyDecision[] = + conditionalPermissions.filter( + conditionalPermission => + !storedConditionalPermissions.some( + stored => + conditionalPermission.roleEntityRef === stored.roleEntityRef && + conditionalPermission.pluginId === stored.pluginId && + conditionalPermission.resourceType === stored.resourceType && + isEqual( + conditionalPermission.permissionMapping, + stored.permissionMapping, + ) && + isEqual(conditionalPermission.conditions, stored.conditions), + ), + ); + + // Updated policies fails the 'some' check due to permissionMapping differences + const conditionsToBeRemoved: RoleConditionalPolicyDecision[] = + storedConditionalPermissions.filter( + stored => + !conditionalPermissions.some( + conditionalPermission => + stored.roleEntityRef === conditionalPermission.roleEntityRef && + stored.pluginId === conditionalPermission.pluginId && + stored.resourceType === conditionalPermission.resourceType && + isEqual( + stored.permissionMapping, + conditionalPermission.permissionMapping, + ) && + isEqual(stored.conditions, conditionalPermission.conditions), + ), + ); + + await this.removeConditionalPermissions(conditionsToBeRemoved); + + await this.addConditionalPermissions(conditionsToBeAdded); + } + + private async addRoles(roles: string[][]): Promise { + for (const role of roles) { + if (!(await this.enforcer.hasGroupingPolicy(...role))) { + const metadata = await this.roleMetadataStorage.findRoleMetadata( + role[1], + ); + const err = await validateGroupingPolicy(role, metadata, this.id); + + if (err) { + this.logger.warn(err.message); + continue; // Skip adding this role as there was an error + } + + let roleMeta = await this.roleMetadataStorage.findRoleMetadata(role[1]); + // role does not exist in rbac, create the metadata for it + if (!roleMeta) { + roleMeta = { + modifiedBy: this.id, + source: this.id, + roleEntityRef: role[1], + }; + } + + const auditorMeta = { + ...roleMeta, + members: [role[0]], + }; + const auditorEvent = await this.auditor.createEvent({ + eventId: RoleEvents.ROLE_WRITE, + severityLevel: 'medium', + meta: { + actionType: roleMeta ? ActionType.UPDATE : ActionType.CREATE, + source: auditorMeta.source, + }, + }); + + try { + await this.enforcer.addGroupingPolicy(role, roleMeta); + await auditorEvent.success({ meta: auditorMeta }); + } catch (error) { + await auditorEvent.fail({ + error, + meta: auditorMeta, + }); + } + } + } + } + + private async removeRoles( + providerRoles: string[][], + tempEnforcer: Enforcer, + ): Promise { + // Remove role + // role exists in rbac but does not exist in provider + const lowercasedProviderRoles = + transformRolesGroupToLowercase(providerRoles); + for (const role of lowercasedProviderRoles) { + if (!(await tempEnforcer.hasGroupingPolicy(...role))) { + const roleMeta = await this.roleMetadataStorage.findRoleMetadata( + role[1], + ); + + const currentRole = await this.enforcer.getFilteredGroupingPolicy( + 1, + role[1], + ); + + if (!roleMeta) { + this.logger.warn('role does not exist'); + continue; + } + + const singleRole = roleMeta && currentRole.length === 1; + const actionType = singleRole ? ActionType.DELETE : ActionType.UPDATE; + + const auditorMeta = { ...roleMeta, members: [role[0]] }; + const auditorEvent = await this.auditor.createEvent({ + eventId: RoleEvents.ROLE_WRITE, + severityLevel: 'medium', + meta: { actionType, source: roleMeta.source }, + }); + + try { + await this.enforcer.removeGroupingPolicy( + role, + roleMeta, + actionType === ActionType.UPDATE, + ); + await auditorEvent.success({ meta: auditorMeta }); + } catch (error) { + await auditorEvent.fail({ + error, + meta: auditorMeta, + }); + } + } + } + } + + private async addPermissions(permissions: string[][]): Promise { + for (const permission of permissions) { + // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` + if (permission[1] === 'policy-entity' && permission[2] === 'create') { + this.logger.warn( + `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${permission} to use 'policy.entity.create' instead of 'policy-entity' from source ${this.id}`, + ); + } + + if (!(await this.enforcer.hasPolicy(...permission))) { + const transformedPolicy = transformArrayToPolicy(permission); + const metadata = await this.roleMetadataStorage.findRoleMetadata( + permission[0], + ); + + const auditorMeta = { + policies: [permission], + }; + const auditorEvent = await this.auditor.createEvent({ + eventId: PermissionEvents.POLICY_WRITE, + severityLevel: 'medium', + meta: { actionType: ActionType.CREATE, source: this.id }, + }); + + let err = validatePolicy(transformedPolicy); + if (err) { + auditorEvent.fail({ error: err, meta: auditorMeta }); + continue; // Skip this invalid permission policy + } + + err = await validateSource(this.id, metadata); + if (err) { + auditorEvent.fail({ error: err, meta: auditorMeta }); + continue; + } + + try { + await this.enforcer.addPolicy(permission); + await auditorEvent.success({ meta: auditorMeta }); + } catch (error) { + await auditorEvent.fail({ error, meta: auditorMeta }); + } + } + } + } + + private async removePermissions( + providerPermissions: string[][], + tempEnforcer: Enforcer, + ): Promise { + for (const permission of providerPermissions) { + if (!(await tempEnforcer.hasPolicy(...permission))) { + const auditorMeta = { + policies: [permission], + }; + const auditorEvent = await this.auditor?.createEvent({ + eventId: PermissionEvents.POLICY_WRITE, + severityLevel: 'medium', + meta: { actionType: ActionType.DELETE, source: this.id }, + }); + + try { + await this.enforcer.removePolicy(permission); + await auditorEvent.success({ meta: auditorMeta }); + } catch (error) { + await auditorEvent.fail({ + error, + meta: auditorMeta, + }); + } + } + } + } + + private async addConditionalPermissions( + conditionalPermissions: RoleConditionalPolicyDecision[], + ): Promise { + for (const condition of conditionalPermissions) { + const auditorMeta = { + policies: [condition], + }; + const auditorEvent = await this.auditor.createEvent({ + eventId: ConditionEvents.CONDITION_WRITE, + severityLevel: 'medium', + meta: { + actionType: ActionType.CREATE, + source: this.id, + }, + }); + try { + const metadata = await this.roleMetadataStorage.findRoleMetadata( + condition.roleEntityRef, + ); + const err = await validateSource(this.id, metadata); + if (err) { + throw err; + } + await this.conditionStorage.createCondition(condition); + await auditorEvent.success({ meta: auditorMeta }); + } catch (error) { + await auditorEvent.fail({ error, meta: auditorMeta }); + } + } + } + + private async removeConditionalPermissions( + conditionalPermissions: RoleConditionalPolicyDecision[], + ): Promise { + for (const conditionalPermission of conditionalPermissions) { + const auditorMeta = { + policies: [conditionalPermission], + }; + const auditorEvent = await this.auditor.createEvent({ + eventId: ConditionEvents.CONDITION_WRITE, + severityLevel: 'medium', + meta: { actionType: ActionType.DELETE, source: this.id }, + }); + try { + const metadata = await this.roleMetadataStorage.findRoleMetadata( + conditionalPermission.roleEntityRef, + ); + const err = await validateSource(this.id, metadata); + if (err) { + throw err; + } + await this.conditionStorage.deleteCondition(conditionalPermission.id); + await auditorEvent.success({ meta: auditorMeta }); + } catch (error) { + await auditorEvent.fail({ + error, + meta: auditorMeta, + }); + } + } + } + + private async getProviderRoles(): Promise { + const currentRoles = await this.roleMetadataStorage.filterRoleMetadata( + this.id, + ); + return currentRoles.map(meta => meta.roleEntityRef); + } +} + +export async function connectRBACProviders( + providers: RBACProvider[], + enforcer: EnforcerDelegate, + roleMetadataStorage: RoleMetadataStorage, + conditionStorage: ConditionalStorage, + logger: LoggerService, + auditor: AuditorService, +) { + await Promise.all( + providers.map(async provider => { + try { + const connection = new Connection( + provider.getProviderName(), + enforcer, + roleMetadataStorage, + conditionStorage, + logger, + auditor, + ); + return provider.connect(connection); + } catch (error) { + throw new Error( + `Unable to connect provider ${provider.getProviderName()}, ${error}`, + ); + } + }), + ); +} diff --git a/plugins/rbac-backend/src/role-manager/ancestor-search-factory.ts b/plugins/rbac-backend/src/role-manager/ancestor-search-factory.ts new file mode 100644 index 0000000000..7b6489ea18 --- /dev/null +++ b/plugins/rbac-backend/src/role-manager/ancestor-search-factory.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Knex } from 'knex'; +import { AncestorSearchMemo, ASMGroup } from './ancestor-search-memo'; +import { AncestorSearchMemoPG } from './ancestor-search-memo-pg'; +import { AncestorSearchMemoSQLite } from './ancestor-search-memo-sqlite'; +import type { AuthService } from '@backstage/backend-plugin-api'; +import type { CatalogApi } from '@backstage/catalog-client'; +import type { Config } from '@backstage/config'; + +export class AncestorSearchFactory { + static async createAncestorSearchMemo( + userEntityRef: string, + config: Config, + catalogAPI: CatalogApi, + catalogDBClient: Knex, + authService: AuthService, + maxDepth?: number, + ): Promise> { + const databaseConfig = config.getOptionalConfig('backend.database'); + const client = databaseConfig?.getOptionalString('client'); + + if (client === 'pg') { + return new AncestorSearchMemoPG(userEntityRef, catalogDBClient, maxDepth); + } + + if (client === 'better-sqlite3') { + return new AncestorSearchMemoSQLite( + userEntityRef, + catalogAPI, + authService, + maxDepth, + ); + } + + throw new Error(`Unsupported database: ${client}`); + } +} diff --git a/plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.test.ts b/plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.test.ts new file mode 100644 index 0000000000..544d6fd262 --- /dev/null +++ b/plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.test.ts @@ -0,0 +1,238 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as Knex from 'knex'; +import { createTracker, MockClient, Tracker } from 'knex-mock-client'; + +import { AncestorSearchMemoPG } from './ancestor-search-memo-pg'; +import { Relation } from './ancestor-search-memo'; + +describe('ancestor-search-memo', () => { + const userRelations = [ + { + source_entity_ref: 'user:default/adam', + target_entity_ref: 'group:default/team-a', + }, + ]; + + const allRelations = [ + { + source_entity_ref: 'user:default/adam', + target_entity_ref: 'group:default/team-a', + }, + { + source_entity_ref: 'group:default/team-a', + target_entity_ref: 'group:default/team-b', + }, + { + source_entity_ref: 'group:default/team-b', + target_entity_ref: 'group:default/team-c', + }, + { + source_entity_ref: 'user:default/george', + target_entity_ref: 'group:default/team-d', + }, + { + source_entity_ref: 'group:default/team-d', + target_entity_ref: 'group:default/team-e', + }, + { + source_entity_ref: 'group:default/team-e', + target_entity_ref: 'group:default/team-f', + }, + ]; + + const catalogDBClient = Knex.knex({ client: MockClient }); + + let asm: AncestorSearchMemoPG; + + beforeEach(() => { + asm = new AncestorSearchMemoPG('user:default/adam', catalogDBClient); + }); + + describe('getAllGroups and getAllRelations', () => { + let tracker: Tracker; + + beforeAll(() => { + tracker = createTracker(catalogDBClient); + }); + + afterEach(() => { + tracker.reset(); + }); + + it('should return all relations', async () => { + tracker.on + .select( + /select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/, + ) + .response(allRelations); + const allRelationsTest = await asm.getAllASMGroups(); + expect(allRelationsTest).toEqual(allRelations); + }); + + it('should fail to return anything when there is an error getting all relations', async () => { + const allRelationsTest = await asm.getAllASMGroups(); + expect(allRelationsTest).toEqual([]); + }); + }); + + describe('getUserGroups and getUserRelations', () => { + let tracker: Tracker; + + beforeAll(() => { + tracker = createTracker(catalogDBClient); + }); + + afterEach(() => { + tracker.reset(); + }); + + it('should return all user relations', async () => { + tracker.on + .select( + /select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/, + ) + .response(userRelations); + const relations = await asm.getUserASMGroups(); + + expect(relations).toEqual(userRelations); + }); + + it('should fail to return anything when there is an error getting user relations', async () => { + const relations = await asm.getUserASMGroups(); + + expect(relations).toEqual([]); + }); + }); + + describe('traverseRelations', () => { + let tracker: Tracker; + + beforeAll(() => { + tracker = createTracker(catalogDBClient); + }); + + afterEach(() => { + tracker.reset(); + }); + + // user:default/adam -> group:default/team-a -> group:default/team-b -> group:default/team-c + it('should build a graph for a particular user', async () => { + tracker.on + .select( + /select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/, + ) + .response(userRelations); + const userRelationsTest = await asm.getUserASMGroups(); + + tracker.reset(); + tracker.on + .select( + /select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/, + ) + .response(allRelations); + const allRelationsTest = await asm.getAllASMGroups(); + + userRelationsTest.forEach(relation => + asm.traverse(relation as Relation, allRelationsTest as Relation[], 0), + ); + + expect(asm.hasEntityRef('user:default/adam')).toBeTruthy(); + expect(asm.hasEntityRef('group:default/team-a')).toBeTruthy(); + expect(asm.hasEntityRef('group:default/team-b')).toBeTruthy(); + expect(asm.hasEntityRef('group:default/team-c')).toBeTruthy(); + expect(asm.hasEntityRef('group:default/team-d')).toBeFalsy(); + }); + + // maxDepth of one stops here + // | + // user:default/adam -> group:default/team-a -> group:default/team-b -> group:default/team-c + it('should build the graph but stop based on the maxDepth', async () => { + const asmMaxDepth = new AncestorSearchMemoPG( + 'user:default/adam', + catalogDBClient, + 1, + ); + + tracker.on + .select( + /select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/, + ) + .response(userRelations); + const userRelationsTest = await asmMaxDepth.getUserASMGroups(); + + tracker.reset(); + tracker.on + .select( + /select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/, + ) + .response(allRelations); + const allRelationsTest = await asmMaxDepth.getAllASMGroups(); + + userRelationsTest.forEach(relation => + asmMaxDepth.traverse( + relation as Relation, + allRelationsTest as Relation[], + 0, + ), + ); + + expect(asmMaxDepth.hasEntityRef('user:default/adam')).toBeTruthy(); + expect(asmMaxDepth.hasEntityRef('group:default/team-a')).toBeTruthy(); + expect(asmMaxDepth.hasEntityRef('group:default/team-b')).toBeTruthy(); + expect(asmMaxDepth.hasEntityRef('group:default/team-c')).toBeFalsy(); + expect(asmMaxDepth.hasEntityRef('group:default/team-d')).toBeFalsy(); + }); + }); + + describe('buildUserGraph', () => { + let tracker: Tracker; + + const asmUserGraph = new AncestorSearchMemoPG( + 'user:default/adam', + catalogDBClient, + ); + + const userRelationsSpy = jest + .spyOn(asmUserGraph, 'getUserASMGroups') + .mockImplementation(() => Promise.resolve(userRelations)); + const allRelationsSpy = jest + .spyOn(asmUserGraph, 'getAllASMGroups') + .mockImplementation(() => Promise.resolve(allRelations)); + + beforeAll(() => { + tracker = createTracker(catalogDBClient); + }); + + afterEach(() => { + tracker.reset(); + }); + + // user:default/adam -> group:default/team-a -> group:default/team-b -> group:default/team-c + it('should build the user graph using relations table', async () => { + await asmUserGraph.buildUserGraph(); + + expect(userRelationsSpy).toHaveBeenCalled(); + expect(allRelationsSpy).toHaveBeenCalled(); + expect(asmUserGraph.hasEntityRef('user:default/adam')).toBeTruthy(); + expect(asmUserGraph.hasEntityRef('group:default/team-a')).toBeTruthy(); + expect(asmUserGraph.hasEntityRef('group:default/team-b')).toBeTruthy(); + expect(asmUserGraph.hasEntityRef('group:default/team-c')).toBeTruthy(); + expect(asmUserGraph.hasEntityRef('group:default/team-d')).toBeFalsy(); + }); + }); +}); diff --git a/plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.ts b/plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.ts new file mode 100644 index 0000000000..59de0e2e09 --- /dev/null +++ b/plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Knex } from 'knex'; +import { AncestorSearchMemo, Relation } from './ancestor-search-memo'; + +export class AncestorSearchMemoPG extends AncestorSearchMemo { + constructor( + private readonly userEntityRef: string, + private readonly catalogDBClient: Knex, + private readonly maxDepth?: number, + ) { + super(); + } + + async getAllASMGroups(): Promise { + try { + const rows = await this.catalogDBClient('relations') + .select('source_entity_ref', 'target_entity_ref') + .where('type', 'childOf'); + return rows; + } catch (error) { + return []; + } + } + + async getUserASMGroups(): Promise { + try { + const rows = await this.catalogDBClient('relations') + .select('source_entity_ref', 'target_entity_ref') + .where({ type: 'memberOf', source_entity_ref: this.userEntityRef }); + return rows; + } catch (error) { + return []; + } + } + + traverse( + relation: Relation, + allRelations: Relation[], + current_depth: number, + ) { + // We add one to the maxDepth here because the user is considered the starting node + if (this.maxDepth !== undefined && current_depth >= this.maxDepth + 1) { + return; + } + const depth = current_depth + 1; + + if (!super.hasEntityRef(relation.source_entity_ref)) { + super.setNode(relation.source_entity_ref); + } + + super.setEdge(relation.target_entity_ref, relation.source_entity_ref); + + const parentGroup = allRelations.find( + g => g.source_entity_ref === relation.target_entity_ref, + ); + + if (parentGroup && super.isAcyclic()) { + this.traverse(parentGroup, allRelations, depth); + } + } + + async buildUserGraph() { + const userRelations = await this.getUserASMGroups(); + const allRelations = await this.getAllASMGroups(); + userRelations.forEach(group => + this.traverse(group as Relation, allRelations as Relation[], 0), + ); + } +} diff --git a/plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.test.ts b/plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.test.ts new file mode 100644 index 0000000000..2156ba7958 --- /dev/null +++ b/plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices } from '@backstage/backend-test-utils'; +import type { GroupEntity } from '@backstage/catalog-model'; + +import { AncestorSearchMemoSQLite } from './ancestor-search-memo-sqlite'; +import { catalogMock, testGroups } from '../../__fixtures__/mock-utils'; +import { convertGroupsToEntity } from '../../__fixtures__/test-utils'; + +const mockAuthService = mockServices.auth(); + +describe('ancestor-search-memo', () => { + const testUserGroups = convertGroupsToEntity([ + { + name: 'team-hr', + namespace: null, + title: 'HR Group', + children: [], + parent: 'team-management', + hasMember: ['user:default/sally'], + }, + ]); + + let asm: AncestorSearchMemoSQLite; + + beforeEach(() => { + asm = new AncestorSearchMemoSQLite( + 'user:default/sally', + catalogMock, + mockAuthService, + ); + }); + + describe('getAllGroups and getAllRelations', () => { + it('should return all groups', async () => { + const allGroupsTest = await asm.getAllASMGroups(); + // The map function aligns the entities with the `fields` definition + // used in `getAllASMGroups` for the `catalogApi.getEntities` call. + expect(allGroupsTest).toEqual( + testGroups.map(entity => ({ + kind: entity.kind, + metadata: { + name: entity.metadata.name, + namespace: entity.metadata.namespace, + }, + spec: { + parent: entity.spec?.parent, + }, + })), + ); + }); + }); + + describe('getUserGroups and getUserRelations', () => { + it('should return all user groups', async () => { + const userGroups = await asm.getUserASMGroups(); + // The map function aligns the entities with the `fields` definition + // used in `getUserASMGroups` for the `catalogApi.getEntities` call. + expect(userGroups).toEqual( + testUserGroups.map(entity => ({ + kind: entity.kind, + metadata: { + name: entity.metadata.name, + namespace: entity.metadata.namespace, + }, + spec: { + parent: entity.spec?.parent, + }, + })), + ); + }); + }); + + describe('traverseGroups', () => { + // user:default/sally + // |- group:default/team-hr + // |- group:default/team-management + // |- group:hq/team-management + // |- group:hq/team-administration + // |- group:default/root-group + it('should build a graph for a particular user', async () => { + const userGroupsTest = await asm.getUserASMGroups(); + + const allGroupsTest = await asm.getAllASMGroups(); + + userGroupsTest.forEach(group => + asm.traverse(group as GroupEntity, allGroupsTest as GroupEntity[], 0), + ); + + expect(asm.hasEntityRef('group:default/team-hr')).toBeTruthy(); + expect(asm.hasEntityRef('group:default/team-management')).toBeTruthy(); + expect(asm.hasEntityRef('group:hq/team-management')).toBeTruthy(); + expect(asm.hasEntityRef('group:hq/team-administration')).toBeTruthy(); + expect(asm.hasEntityRef('group:default/root-group')).toBeTruthy(); + expect(asm.hasEntityRef('group:default/team-b')).toBeFalsy(); + }); + + // maxDepth of one + // + // user:default/sally + // |- group:default/team-hr + // |- group:default/team-management <- stops here + // |- group:hq/team-management + // |- group:hq/team-administration + // |- group:default/root-group + it('should build the graph but stop based on the maxDepth', async () => { + const asmMaxDepth = new AncestorSearchMemoSQLite( + 'user:default/sally', + catalogMock, + mockAuthService, + 1, + ); + + const userGroupsTest = await asmMaxDepth.getUserASMGroups(); + + const allGroupsTest = await asmMaxDepth.getAllASMGroups(); + + userGroupsTest.forEach(group => + asmMaxDepth.traverse( + group as GroupEntity, + allGroupsTest as GroupEntity[], + 0, + ), + ); + + expect(asmMaxDepth.hasEntityRef('group:default/team-hr')).toBeTruthy(); + expect( + asmMaxDepth.hasEntityRef('group:default/team-management'), + ).toBeTruthy(); + expect(asmMaxDepth.hasEntityRef('group:hq/team-management')).toBeFalsy(); + expect( + asmMaxDepth.hasEntityRef('group:hq/team-administration'), + ).toBeFalsy(); + expect(asmMaxDepth.hasEntityRef('group:default/root-group')).toBeFalsy(); + expect(asmMaxDepth.hasEntityRef('group:default/team-b')).toBeFalsy(); + }); + }); +}); diff --git a/plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.ts b/plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.ts new file mode 100644 index 0000000000..4e2e6eef08 --- /dev/null +++ b/plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.ts @@ -0,0 +1,108 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { AuthService } from '@backstage/backend-plugin-api'; +import type { CatalogApi } from '@backstage/catalog-client'; +import type { Entity } from '@backstage/catalog-model'; +import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; + +import { AncestorSearchMemo } from './ancestor-search-memo'; + +export class AncestorSearchMemoSQLite extends AncestorSearchMemo { + constructor( + private readonly userEntityRef: string, + private readonly catalogApi: CatalogApi, + private readonly auth: AuthService, + private readonly maxDepth?: number, + ) { + super(); + } + + async getAllASMGroups(): Promise { + const { token } = await this.auth.getPluginRequestToken({ + onBehalfOf: await this.auth.getOwnServiceCredentials(), + targetPluginId: 'catalog', + }); + + const { items } = await this.catalogApi.getEntities( + { + filter: { kind: 'Group' }, + fields: ['kind', 'metadata.name', 'metadata.namespace', 'spec.parent'], + }, + { token }, + ); + return items; + } + + async getUserASMGroups(): Promise { + const { token } = await this.auth.getPluginRequestToken({ + onBehalfOf: await this.auth.getOwnServiceCredentials(), + targetPluginId: 'catalog', + }); + const { items } = await this.catalogApi.getEntities( + { + filter: { kind: 'Group', 'relations.hasMember': this.userEntityRef }, + fields: ['kind', 'metadata.name', 'metadata.namespace', 'spec.parent'], + }, + { token }, + ); + return items; + } + + traverse(group: Entity, allGroups: Entity[], current_depth: number) { + const groupRef = stringifyEntityRef(group); + + if (!super.hasEntityRef(groupRef)) { + super.setNode(groupRef); + } + + if (this.maxDepth !== undefined && current_depth >= this.maxDepth) { + return; + } + const depth = current_depth + 1; + + const parent = group.spec?.parent as string; + if (!parent) { + return; + } + + const parentRef = stringifyEntityRef( + parseEntityRef(parent, { + defaultKind: 'group', + defaultNamespace: group.metadata.namespace, + }), + ); + + const parentGroup = allGroups.find( + g => stringifyEntityRef(g) === parentRef, + ); + + if (parentGroup) { + super.setEdge(parentRef, groupRef); + + if (super.isAcyclic()) { + this.traverse(parentGroup, allGroups, depth); + } + } + } + + async buildUserGraph() { + const userGroups = await this.getUserASMGroups(); + const allGroups = await this.getAllASMGroups(); + userGroups.forEach(group => + this.traverse(group as Entity, allGroups as Entity[], 0), + ); + } +} diff --git a/plugins/rbac-backend/src/role-manager/ancestor-search-memo.ts b/plugins/rbac-backend/src/role-manager/ancestor-search-memo.ts new file mode 100644 index 0000000000..d6e7fc6649 --- /dev/null +++ b/plugins/rbac-backend/src/role-manager/ancestor-search-memo.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { LoggerService } from '@backstage/backend-plugin-api'; +import type { Entity } from '@backstage/catalog-model'; + +import { alg, Graph } from '@dagrejs/graphlib'; + +export interface Relation { + source_entity_ref: string; + target_entity_ref: string; +} + +export type ASMGroup = Relation | Entity; + +// AncestorSearchMemo - should be used to build group hierarchy graph for User entity reference. +// It supports search group entity reference link in the graph. +// Also AncestorSearchMemo supports detection cycle dependencies between groups in the graph. +// +export abstract class AncestorSearchMemo { + protected graph: Graph; + + constructor() { + this.graph = new Graph({ directed: true }); + } + + isAcyclic(): boolean { + return alg.isAcyclic(this.graph); + } + + findCycles(): string[][] { + return alg.findCycles(this.graph); + } + + setEdge(parentEntityRef: string, childEntityRef: string) { + this.graph.setEdge(parentEntityRef, childEntityRef); + } + + setNode(entityRef: string): void { + this.graph.setNode(entityRef); + } + + hasEntityRef(groupRef: string): boolean { + return this.graph.hasNode(groupRef); + } + + debugNodesAndEdges(logger: LoggerService, userEntity: string): void { + logger.debug( + `SubGraph edges: ${JSON.stringify(this.graph.edges())} for ${userEntity}`, + ); + logger.debug( + `SubGraph nodes: ${JSON.stringify(this.graph.nodes())} for ${userEntity}`, + ); + } + + getNodes(): string[] { + return this.graph.nodes(); + } + + abstract traverse( + relation: T, + allRelations: T[], + current_depth: number, + ): void; + + abstract buildUserGraph(): Promise; + + abstract getUserASMGroups(): Promise; + + abstract getAllASMGroups(): Promise; +} diff --git a/plugins/rbac-backend/src/role-manager/member-list.test.ts b/plugins/rbac-backend/src/role-manager/member-list.test.ts new file mode 100644 index 0000000000..e86cbfc399 --- /dev/null +++ b/plugins/rbac-backend/src/role-manager/member-list.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as Knex from 'knex'; +import { createTracker, MockClient, Tracker } from 'knex-mock-client'; + +import { RoleMemberList } from './member-list'; + +describe('RoleMemberList', () => { + const member = 'user:default/developer'; + + const rbacDBClient = Knex.knex({ client: MockClient }); + let roleList: RoleMemberList; + let newRole: RoleMemberList; + let memberList: RoleMemberList; + + beforeEach(() => { + roleList = new RoleMemberList('role:default/test'); + newRole = new RoleMemberList('role:default/extra'); + memberList = new RoleMemberList('user:default/test'); + }); + + describe('addMembers', () => { + it('should add members to the role', () => { + const members = ['user:default/test', 'user:default/developer']; + roleList.addMembers(members); + + expect(roleList.hasMember('user:default/test')).toBeTruthy(); + expect(roleList.hasMember('user:default/developer')).toBeTruthy(); + }); + }); + + describe('addMember', () => { + it('should add a single member to the role', () => { + roleList.addMember(member); + + expect(roleList.hasMember('user:default/developer')).toBeTruthy(); + }); + + it('should not add a duplicate of an existing member', () => { + roleList.addMember(member); + + expect(roleList.getMembers().length).toEqual(1); + + roleList.addMember(member); + expect(roleList.getMembers().length).not.toEqual(2); + }); + }); + + describe('deleteMember', () => { + it('should delete a member from a role', () => { + roleList.addMember(member); + + expect(roleList.getMembers().length).toEqual(1); + + roleList.deleteMember(member); + + expect(roleList.getMembers().length).not.toEqual(1); + }); + }); + + describe('buildMembers', () => { + let tracker: Tracker; + + beforeEach(() => { + tracker = createTracker(rbacDBClient); + }); + + afterEach(() => { + tracker.reset(); + }); + + it('should build the members associated with a role using the database', async () => { + const data = [{ v0: 'user:default/qa', v1: 'role:default/qa' }]; + + tracker.on.select('casbin_rule').response(data); + + await newRole.buildMembers(newRole, rbacDBClient); + expect(newRole.hasMember('user:default/qa')).toBeTruthy(); + }); + + it('should fail to retrieve users and log an error', async () => { + const error = new Error('test error'); + tracker.on.select('casbin_rule').simulateError(error); + + await expect( + newRole.buildMembers(newRole, rbacDBClient), + ).rejects.toMatchObject({ + message: expect.stringContaining('test error'), + }); + expect(newRole.getMembers().length).toEqual(0); + }); + }); + + describe('addRoles', () => { + it('should add roles to the role member list', () => { + const roles = ['role:default/test', 'role:default/developer']; + memberList.addRoles(roles); + + expect(memberList.getRoles().length).toEqual(2); + }); + }); + + describe('buildRoles', () => { + let tracker: Tracker; + const memberRoles = ['role:default/temp', 'role:default/qa']; + + beforeEach(() => { + tracker = createTracker(rbacDBClient); + }); + + afterEach(() => { + tracker.reset(); + }); + + it('should build the roles associated with a user using the database', async () => { + const data = [{ v0: 'user:default/test', v1: 'role:default/qa' }]; + + tracker.on.select('casbin_rule').response(data); + + await newRole.buildRoles(newRole, memberRoles, rbacDBClient); + const rolesExpect = newRole.getRoles(); + expect(rolesExpect.length).toEqual(1); + expect(rolesExpect[0]).toEqual('role:default/qa'); + }); + + it('should fail to retrieve roles and log an error', async () => { + const error = new Error('test error'); + tracker.on.select('casbin_rule').simulateError(error); + + await expect( + newRole.buildRoles(newRole, memberRoles, rbacDBClient), + ).rejects.toMatchObject({ + message: expect.stringContaining('test error'), + }); + expect(newRole.getRoles().length).toEqual(0); + }); + }); +}); diff --git a/plugins/rbac-backend/src/role-manager/member-list.ts b/plugins/rbac-backend/src/role-manager/member-list.ts new file mode 100644 index 0000000000..bb9517fdf9 --- /dev/null +++ b/plugins/rbac-backend/src/role-manager/member-list.ts @@ -0,0 +1,142 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Knex } from 'knex'; + +export class RoleMemberList { + public name: string; + + private members: string[]; + private roles: string[]; + + public constructor(name: string) { + this.name = name; + this.members = []; + this.roles = []; + } + + /** + * addMembers will add members to the RoleMemberList + * @param members The members to be added. + */ + public addMembers(members: string[]): void { + this.members = members; + } + + /** + * addMember will add a single member to the RoleMemberList, skips adding the user in the + * event that they already exist in the members array. + * @param member The member to be added. + */ + public addMember(member: string): void { + if (this.members.some(n => n === member)) { + return; + } + this.members.push(member); + } + + /** + * hasMember will check if a particular member exists in the members array. + * @param name The member to be checked for. + */ + public hasMember(name: string): boolean { + return this.members.includes(name); + } + + /** + * deleteMember will remove a user from the members array. + * @param member The member to be removed. + */ + public deleteMember(member: string): void { + this.members = this.members.filter(n => n !== member); + } + + /** + * buildMembers will query the `casbin_rule` database table to ensure that the role + * that we have cached is up to date. + * This is important in multi node scenarios where the cached roles in role manager can become + * out of sync with the database. + * @param roleMemberList The RoleMemberList to be updated. + * @param client The database client. + */ + public async buildMembers( + roleMemberList: RoleMemberList, + client: Knex, + ): Promise { + try { + const members: string[] = await client + .table('casbin_rule') + .where('v1', this.name) + .pluck('v0') + .distinct(); + + roleMemberList.addMembers(members); + } catch (error) { + throw new Error( + `Unable to find members for the role ${this.name}. Cause: ${error}`, + ); + } + } + + /** + * getMembers will return the members of the RoleMemberList + * @returns The members. + */ + getMembers(): string[] { + return this.members; + } + + /** + * addRoles will add roles to the RoleMemberList + * @param roles The roles to be added. + */ + public addRoles(roles: string[]): void { + this.roles = roles; + } + + /** + * buildRoles will query the `casbin_rule` database table to quickly grab all of the + * roles that a particular user is attached to. + * @param roleMemberList The RoleMemberList to be updated. + * @param userAndGroups The user and groups to query with. + * @param client The database client. + */ + public async buildRoles( + roleMemberList: RoleMemberList, + userAndGroups: string[], + client: Knex, + ): Promise { + try { + const roles: string[] = await client + .table('casbin_rule') + .where('ptype', 'g') + .whereIn('v0', userAndGroups) + .pluck('v1') + .distinct(); + + roleMemberList.addRoles(roles); + } catch (error) { + throw new Error(`Unable to find all roles. Cause: ${error}`); + } + } + + /** + * getRoles will return the roles of the RoleMemberList. + * @returns The roles. + */ + getRoles(): string[] { + return this.roles; + } +} diff --git a/plugins/rbac-backend/src/role-manager/role-manager.test.ts b/plugins/rbac-backend/src/role-manager/role-manager.test.ts new file mode 100644 index 0000000000..a8eb2f8870 --- /dev/null +++ b/plugins/rbac-backend/src/role-manager/role-manager.test.ts @@ -0,0 +1,626 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { LoggerService } from '@backstage/backend-plugin-api'; +import { mockServices } from '@backstage/backend-test-utils'; +import { Config } from '@backstage/config'; + +import * as Knex from 'knex'; +import { createTracker, MockClient, Tracker } from 'knex-mock-client'; + +import { BackstageRoleManager } from '../role-manager/role-manager'; +import { DefaultPermissionsReader } from '../default-permissions/default-permissions'; +import { catalogMock } from '../../__fixtures__/mock-utils'; + +describe('BackstageRoleManager', () => { + const catalogDBClient = Knex.knex({ client: MockClient }); + const rbacDBClient = Knex.knex({ client: MockClient }); + + const mockLoggerService = mockServices.logger.mock(); + + const mockAuthService = mockServices.auth(); + + let roleManager: BackstageRoleManager; + beforeEach(() => { + const config = newConfig(); + + roleManager = new BackstageRoleManager( + catalogMock, + mockLoggerService as LoggerService, + catalogDBClient, + rbacDBClient, + config, + mockAuthService, + new DefaultPermissionsReader(config), + ); + }); + + jest.spyOn(catalogMock, 'getEntities'); + + describe('initialize', () => { + it('should initialize', () => { + expect(roleManager).not.toBeUndefined(); + }); + + it('should throw an error whenever max depth is less than 0', () => { + let expectedError; + let errorRoleManager; + const config = newConfig(-1); + + try { + errorRoleManager = new BackstageRoleManager( + catalogMock, + mockLoggerService as LoggerService, + catalogDBClient, + rbacDBClient, + config, + mockAuthService, + new DefaultPermissionsReader(config), + ); + } catch (error) { + expectedError = error; + } + + expect(errorRoleManager).toBeUndefined(); + expect(expectedError).toMatchObject({ + message: + 'Max Depth for RBAC group hierarchy must be greater than or equal to zero', + }); + }); + }); + + describe('unimplemented methods', () => { + it('should throw an error for syncedHasLink', () => { + expect(() => + roleManager.syncedHasLink!('user:default/role1', 'user:default/role2'), + ).toThrow('Method "syncedHasLink" not implemented.'); + }); + + it('should throw an error for getUsers', async () => { + await expect(roleManager.getUsers('name')).rejects.toThrow( + 'Method "getUsers" not implemented.', + ); + }); + }); + + describe('addLink test', () => { + it('should create a link between two entities', async () => { + roleManager.addLink('user:default/test', 'role:default/rbac_admin'); + const result = await roleManager.hasLink( + 'user:default/test', + 'role:default/rbac_admin', + ); + expect(result).toBe(true); + }); + }); + + describe('deleteLink test', () => { + it('should delete a link', async () => { + roleManager.addLink('user:default/test', 'role:default/test', ''); + roleManager.addLink('user:default/test', 'role:default/test2', ''); + + let roles = await roleManager.getRoles('user:default/test'); + expect(roles).toStrictEqual(['role:default/test', 'role:default/test2']); + + roleManager.deleteLink('user:default/test', 'role:default/test'); + roles = await roleManager.getRoles('user:default/test'); + expect(roles).toStrictEqual(['role:default/test2']); + }); + }); + + describe('hasLink tests', () => { + afterEach(() => { + (mockLoggerService.warn as jest.Mock).mockReset(); + }); + + it('should throw an error for unsupported domain', async () => { + await expect( + roleManager.hasLink( + 'user:default/mike', + 'group:default/somegroup', + 'someDomain', + ), + ).rejects.toThrow('domain argument is not supported.'); + }); + + it('should return true for hasLink when names are the same', async () => { + const result = await roleManager.hasLink( + 'user:default/mike', + 'user:default/mike', + ); + expect(result).toBe(true); + }); + + it('should return false for hasLink when name2 has a user kind', async () => { + const result = await roleManager.hasLink( + 'user:default/mike', + 'user:default/some-user', + ); + expect(result).toBe(false); + }); + + // user:default/bob should not inherits from group:default/team-x + // + // Hierarchy: + // + // user:default/b -> user without group + // + it('should return false for hasLink when user without group', async () => { + const result = await roleManager.hasLink( + 'user:default/bob', + 'group:default/team-x', + ); + expect(catalogMock.getEntities).toHaveBeenCalledWith( + { + filter: { + kind: 'Group', + }, + fields: [ + 'kind', + 'metadata.name', + 'metadata.namespace', + 'spec.parent', + ], + }, + { + token: 'mock-service-token:{"sub":"plugin:test","target":"catalog"}', + }, + ); + expect(result).toBeFalsy(); + }); + + // user:default/mike should inherits from group:default/team-b + // + // Hierarchy: + // + // group:default/team-b + // | + // user:default/mike + // + it('should return true for hasLink when user:default/mike inherits from group:default/team-b', async () => { + const result = await roleManager.hasLink( + 'user:default/mike', + 'group:default/team-b', + ); + expect(result).toBeTruthy(); + }); + + // user:default/mike should not inherits from group:default/team-x + // + // Hierarchy: + // + // group:default/team-b + // | + // user:default/mike + // + it('should return false for hasLink when user:default/mike does not inherits group:default/team-x', async () => { + const result = await roleManager.hasLink( + 'user:default/mike', + 'group:default/team-x', + ); + expect(result).toBeFalsy(); + }); + + // user:default/mike should inherits from group:default/team-a + // + // Hierarchy: + // + // group:default/team-a + // | + // group:default/team-b + // | + // user:default/mike + // + it('should return true for hasLink, when user:default/mike inherits from group:default/team-a', async () => { + const result = await roleManager.hasLink( + 'user:default/mike', + 'group:default/team-a', + ); + expect(result).toBeTruthy(); + }); + + // user:default/mike should inherits from group:default/team-a + // + // Hierarchy: + // + // group:default/team-a + // | + // group:default/team-b + // | + // user:default/mike + // + it('should disable group inheritance when max-depth=0', async () => { + // max-depth=0 + const config = newConfig(0); + const rm = new BackstageRoleManager( + catalogMock, + mockLoggerService as LoggerService, + catalogDBClient, + rbacDBClient, + config, + mockAuthService, + new DefaultPermissionsReader(config), + ); + let result = await rm.hasLink( + 'user:default/mike', + 'group:default/team-b', + ); + expect(result).toBeTruthy(); + + result = await rm.hasLink('user:default/mike', 'group:default/team-a'); + expect(result).toBeFalsy(); + }); + + // user:default/mike should inherits from group:default/team-b. + // + // Hierarchy: + // + // |---------group:default/team-a---------| + // | | | + // user:default/team-c group:default/team-b group:default/team-d + // | | | + // user:default/tom user:default/mike user:default:john + // + it('should return true for hasLink, when user:default/mike inherits from group:default/team-b', async () => { + const result = await roleManager.hasLink( + 'user:default/mike', + 'group:default/team-a', + ); + expect(result).toBeTruthy(); + }); + + // user:default/mike should not inherits from group:default/team-c + // + // Hierarchy: + // + // group:default/team-a + // | + // group:default/team-b + // | + // user:default/mike + // + it('should return false for hasLink, when user:default/mike does not inherits from group:default/team-c', async () => { + const result = await roleManager.hasLink( + 'user:default/mike', + 'group:default/team-c', + ); + expect(result).toBeFalsy(); + }); + + // user:default/mike should inherits from group:default/team-a + // + // Hierarchy: + // + // group:default/team-a group:default/team-z + // | | + // group:default/team-c group:default/team-y + // | | + // user:default/mike + // + it('should return true for hasLink, when user:default/mike inherits group tree with group:default/team-a', async () => { + const result = await roleManager.hasLink( + 'user:default/mike', + 'group:default/team-a', + ); + expect(result).toBeTruthy(); + }); + + // user:default/mike should not inherits from group:default/team-e + // + // Hierarchy: + // + // group:default/team-a group:default/team-z + // | | + // group:default/team-c group:default/team-y + // | | + // user:default/mike + // + it('should return false for hasLink, when user:default/mike inherits from group:default/team-e', async () => { + const result = await roleManager.hasLink( + 'user:default/mike', + 'group:default/team-e', + ); + expect(result).toBeFalsy(); + }); + + // user:default/john should inherits from group:default/team-e and group:default/team-f, but we have cycle dependency. + // So return false on call hasLink. + // + // Hierarchy: + // + // group:default/team-e + // ↓ ↑ + // group:default/team-f + // ↓ + // user:default/john + // + it('should return false for hasLink, when user:default/john inherits from group:default/team-e and group:default/team-f, but we have cycle dependency', async () => { + let result = await roleManager.hasLink( + 'user:default/john', + 'group:default/team-f', + ); + expect(result).toBeFalsy(); + expect(mockLoggerService.warn).toHaveBeenCalledWith( + 'Detected cycle dependencies in the Group graph: [["group:default/team-e","group:default/team-f"]]. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: [["group:default/team-e","group:default/team-f"]]', + ); + + result = await roleManager.hasLink( + 'user:default/john', + 'group:default/team-e', + ); + expect(result).toBeFalsy(); + expect(mockLoggerService.warn).toHaveBeenCalledWith( + 'Detected cycle dependencies in the Group graph: [["group:default/team-e","group:default/team-f"]]. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: [["group:default/team-e","group:default/team-f"]]', + ); + }); + + // user:default/bill should inherits from group:default/team-e, group:default/team-f, group:default/team-g, but we have cycle dependency. + // So return false on call hasLink. + // + // Hierarchy: + // + // group:default/team-e + // ↓ ↑ + // group:default/team-f + // ↓ + // group:default/team-g + // ↓ + // user:default/bill + // + it('should return false for hasLink, when user:default/bill inherits from group:default/team-g, group:default/team-f, group:default/team-e, but we have cycle dependency', async () => { + let result = await roleManager.hasLink( + 'user:default/bill', + 'group:default/team-g', + ); + expect(result).toBeFalsy(); + expect(mockLoggerService.warn).toHaveBeenCalledWith( + 'Detected cycle dependencies in the Group graph: [["group:default/team-e","group:default/team-f"]]. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: [["group:default/team-e","group:default/team-f"]]', + ); + + result = await roleManager.hasLink( + 'user:default/bill', + 'group:default/team-e', + ); + expect(result).toBeFalsy(); + expect(mockLoggerService.warn).toHaveBeenCalledWith( + 'Detected cycle dependencies in the Group graph: [["group:default/team-e","group:default/team-f"]]. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: [["group:default/team-e","group:default/team-f"]]', + ); + + result = await roleManager.hasLink( + 'user:default/bill', + 'group:default/team-f', + ); + expect(result).toBeFalsy(); + expect(mockLoggerService.warn).toHaveBeenCalledWith( + 'Detected cycle dependencies in the Group graph: [["group:default/team-e","group:default/team-f"]]. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: [["group:default/team-e","group:default/team-f"]]', + ); + }); + + // user:default/john should inherits from group:default/team-a, but we have cycle dependency: team-e -> team-f. + // So return false on call hasLink. + // + // Hierarchy: + // + // group:default/team-e group:default/team-a + // ↓ ↑ ↓ + // group:default/team-f group:default/team-d + // ↓ ↓ + // user:default/john + // + it('should return false for hasLink, when user:default/mike inherits group tree with group:default/team-a, but we cycle dependency', async () => { + const result = await roleManager.hasLink( + 'user:default/john', + 'group:default/team-e', + ); + expect(result).toBeFalsy(); + expect(mockLoggerService.warn).toHaveBeenCalledWith( + 'Detected cycle dependencies in the Group graph: [["group:default/team-e","group:default/team-f"]]. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: [["group:default/team-e","group:default/team-f"]]', + ); + }); + + // user:default/john should inherits from group:default/team-e, but we have cycle dependency: team-e -> team-f. + // So return false on call hasLink. + // + // user:default/tom should inherits from group:default/team-a. Cycle dependency in the neighbor subgraph, should + // not affect evaluation user:default/tom inheritance. + // + // Hierarchy: + // + // group:default/root + // ↓ ↓ + // group:default/team-e group:default/team-a + // ↓ ↑ ↓ + // group:default/team-f group:default/team-c + // ↓ ↓ + // user:default/john user:default/tom + // + it('should return false for hasLink for user:default/john and group:default/team-e(cycle dependency), but should be true for user:default/tom and group:default/team-a', async () => { + let result = await roleManager.hasLink( + 'user:default/john', + 'group:default/team-e', + ); + expect(result).toBeFalsy(); + expect(mockLoggerService.warn).toHaveBeenCalledWith( + 'Detected cycle dependencies in the Group graph: [["group:default/team-e","group:default/team-f"]]. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: [["group:default/team-e","group:default/team-f"]]', + ); + + result = await roleManager.hasLink( + 'user:default/tom', + 'group:default/team-a', + ); + expect(result).toBeTruthy(); + }); + }); + + describe('getRoles returns roles per user', () => { + it('should returns role per user', async () => { + roleManager.addLink('user:default/test', 'role:default/rbac_admin'); + roleManager.addLink('user:default/test-two', 'role:default/rbac_admin'); + roleManager.addLink( + 'user:default/test-three', + 'role:default/rbac_admin_test', + ); + + let roles = await roleManager.getRoles('user:default/test'); + expect(roles.length).toBe(1); + expect(roles[0]).toEqual('role:default/rbac_admin'); + + roles = await roleManager.getRoles('user:default/test-two'); + expect(roles.length).toBe(1); + expect(roles[0]).toEqual('role:default/rbac_admin'); + + roles = await roleManager.getRoles('user:default/test-three'); + expect(roles.length).toBe(1); + expect(roles[0]).toEqual('role:default/rbac_admin_test'); + }); + + it('getRoles returns role for user inherited from group', async () => { + roleManager.addLink('group:default/team-a', 'role:default/rbac_admin'); + + let roles = await roleManager.getRoles('user:default/mike'); + expect(roles.length).toBe(1); + expect(roles[0]).toEqual('role:default/rbac_admin'); + + // should return empty array for group + roles = await roleManager.getRoles('group:default/team-a'); + expect(roles.length).toBe(0); + + // should return empty array for role + roles = await roleManager.getRoles('role:default/rbac_admin'); + expect(roles.length).toBe(0); + }); + }); + + describe('getRoles returns roles per user with database', () => { + let tracker: Tracker; + + beforeEach(() => { + tracker = createTracker(rbacDBClient); + }); + + afterEach(() => { + tracker.reset(); + }); + + it('should returns role per user', async () => { + roleManager.isPGClient = jest.fn().mockImplementation(() => true); + + roleManager.addLink('user:default/test', 'role:default/rbac_admin'); + + let data = [{ v0: 'user:default/test', v1: 'role:default/rbac_admin' }]; + + tracker.on.select('casbin_rule').response(data); + + let roles = await roleManager.getRoles('user:default/test'); + expect(roles.length).toBe(1); + expect(roles[0]).toEqual('role:default/rbac_admin'); + + roleManager.addLink('user:default/test-two', 'role:default/rbac_admin'); + + tracker.resetHandlers(); + + data = [{ v0: 'user:default/test-two', v1: 'role:default/rbac_admin' }]; + + tracker.on.select('casbin_rule').response(data); + + roles = await roleManager.getRoles('user:default/test-two'); + expect(roles.length).toBe(1); + expect(roles[0]).toEqual('role:default/rbac_admin'); + + roleManager.addLink( + 'user:default/test-three', + 'role:default/rbac_admin_test', + ); + + tracker.resetHandlers(); + + data = [ + { v0: 'user:default/test-three', v1: 'role:default/rbac_admin_test' }, + ]; + + tracker.on.select('casbin_rule').response(data); + + roles = await roleManager.getRoles('user:default/test-three'); + expect(roles.length).toBe(1); + expect(roles[0]).toEqual('role:default/rbac_admin_test'); + }); + + it('getRoles returns role for user inherited from group', async () => { + roleManager.isPGClient = jest.fn().mockImplementation(() => true); + roleManager.addLink('group:default/team-a', 'role:default/rbac_admin'); + + const data = [ + { v0: 'group:default/team-a', v1: 'role:default/rbac_admin' }, + ]; + + tracker.on.select('casbin_rule').response(data); + + let roles = await roleManager.getRoles('user:default/test'); + expect(roles.length).toBe(1); + expect(roles[0]).toEqual('role:default/rbac_admin'); + + tracker.on + .select('select "v1" from "casbin_rule" where "v0" = ?') + .response([]); + + // should return empty array for group + roles = await roleManager.getRoles('group:default/team-a'); + expect(roles.length).toBe(0); + + tracker.on + .select('select "v1" from "casbin_rule" where "v0" = ?') + .response([]); + + // should return empty array for role + roles = await roleManager.getRoles('role:default/rbac_admin'); + expect(roles.length).toBe(0); + }); + }); +}); + +function newConfig( + maxDepth?: number, + users?: Array<{ name: string }>, + superUsers?: Array<{ name: string }>, +): Config { + const testUsers = [ + { + name: 'user:default/guest', + }, + { + name: 'group:default/guests', + }, + ]; + + return mockServices.rootConfig({ + data: { + permission: { + rbac: { + admin: { + users: users || testUsers, + superUsers: superUsers, + }, + maxDepth, + }, + }, + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }, + }); +} diff --git a/plugins/rbac-backend/src/role-manager/role-manager.ts b/plugins/rbac-backend/src/role-manager/role-manager.ts new file mode 100644 index 0000000000..b82f1702cc --- /dev/null +++ b/plugins/rbac-backend/src/role-manager/role-manager.ts @@ -0,0 +1,348 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { AuthService, LoggerService } from '@backstage/backend-plugin-api'; +import type { CatalogApi } from '@backstage/catalog-client'; +import { parseEntityRef } from '@backstage/catalog-model'; +import type { Config } from '@backstage/config'; + +import { RoleManager } from 'casbin'; +import { Knex } from 'knex'; + +import { AncestorSearchMemo, ASMGroup } from './ancestor-search-memo'; +import { RoleMemberList } from './member-list'; +import { AncestorSearchFactory } from './ancestor-search-factory'; +import { DefaultPermissionsReader } from '../default-permissions/default-permissions'; + +export class BackstageRoleManager implements RoleManager { + private allRoles: Map; + private maxDepth?: number; + private defaultRoleRef?: string; + constructor( + private readonly catalogApi: CatalogApi, + private readonly logger: LoggerService, + private readonly catalogDBClient: Knex, + private readonly rbacDBClient: Knex, + private readonly config: Config, + private readonly auth: AuthService, + defaultPermissionReader: DefaultPermissionsReader, + ) { + this.allRoles = new Map(); + const rbacConfig = this.config.getOptionalConfig('permission.rbac'); + this.maxDepth = rbacConfig?.getOptionalNumber('maxDepth'); + this.defaultRoleRef = defaultPermissionReader.readRole(); + if (this.maxDepth !== undefined && this.maxDepth! < 0) { + throw new Error( + 'Max Depth for RBAC group hierarchy must be greater than or equal to zero', + ); + } + } + + /** + * clear clears all stored data and resets the role manager to the initial state. + */ + async clear(): Promise { + // do nothing + } + + /** + * addLink adds the inheritance link between name1 and role: name2. + * aka name1 inherits role: name2. + * The link that is established is based on the defined grouping policies that are added by the enforcer. + * + * ex. `g, name1, name2`. + * @param name1 User or group that will be assigned to a role. + * @param name2 The role that will be created or updated. + * @param _domain Unimplemented prefix to the role. + */ + async addLink( + name1: string, + name2: string, + ..._domain: string[] + ): Promise { + if (!this.isPGClient()) { + const role1 = this.getOrCreateRole(name2); + role1.addMember(name1); + } + } + + /** + * deleteLink deletes the inheritance link between name1 and role: name2. + * aka name1 does not inherit role: name2 any more. + * The link that is deleted is based on the defined grouping policies that are removed by the enforcer. + * + * ex. `g, name1, name2`. + * @param name1 User or group that will be removed from assignment of a role. + * @param name2 The role that will be deleted or updated. + * @param _domain Unimplemented. + */ + async deleteLink( + name1: string, + name2: string, + ..._domain: string[] + ): Promise { + if (!this.isPGClient()) { + const role1 = this.getOrCreateRole(name2); + role1.deleteMember(name1); + + // Clean up in the event that there are no more members in the role + if (role1.getMembers().length === 0) { + this.allRoles.delete(name2); + } + } + } + + /** + * hasLink determines whether name1 inherits role: name2. + * Before this check is called in the background by the enforcer, + * we filter out all roles that the user is not connected to + * directly or indirectly through the use of retrieving roles through + * enforcer.getRolesForUser and apply those roles to a tempEnforcer. + * + * This means that hasLink will almost always be true in the event that a user + * is assigned to a role (either directly or indirectly) + * + * In the event that a user or group is not assigned to a role and instead + * are assigned directly to permissions, then name2 will become either that + * user or group through the filtering. In this case we will build the graph + * if necessary for name2 group presence or evaulate based on the names matching. + * @param name1 The user that we are authorizing. + * @param name2 The name of the role that we are checking against. + * @param domain Unimplemented. + * @returns True if the user is directly or indirectly attached to the role. + */ + async hasLink( + name1: string, + name2: string, + ...domain: string[] + ): Promise { + if (domain.length > 0) { + throw new Error('domain argument is not supported.'); + } + + // Name2 can be an empty string in the event that there is not a role associated with the user + // This happens because of the filtering of the roles reduces the number of roles that we iterate through. + if (name2.length === 0) { + return false; + } + + if (name1 === name2) { + return true; + } + + // name1 is always user in our case. + // name2 is user or group. + // user(name1) couldn't inherit user(name2). + // We can use this fact for optimization. + const { kind } = parseEntityRef(name2); + if (kind.toLocaleLowerCase() === 'user') { + return false; + } + + // if it is a group, then we will have to build the graph, + if (kind.toLocaleLowerCase() === 'group') { + const memo = await AncestorSearchFactory.createAncestorSearchMemo( + name1, + this.config, + this.catalogApi, + this.catalogDBClient, + this.auth, + this.maxDepth, + ); + + await memo.buildUserGraph(); + memo.debugNodesAndEdges(this.logger, name1); + + if (!memo.isAcyclic()) { + const cycles = memo.findCycles(); + + this.logger.warn( + `Detected cycle dependencies in the Group graph: ${JSON.stringify( + cycles, + )}. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: ${JSON.stringify( + cycles, + )}`, + ); + return false; + } + + return memo.hasEntityRef(name2); + } + + return true; + } + + /** + * syncedHasLink determines whether role: name1 inherits role: name2. + * domain is a prefix to the roles. + */ + syncedHasLink?( + _name1: string, + _name2: string, + ..._domain: string[] + ): boolean { + throw new Error('Method "syncedHasLink" not implemented.'); + } + + /** + * getRoles gets the roles that a subject inherits. + * + * name - is a string entity reference, for example: user:default/tom, role:default/dev, + * so format is :/. + * GetRoles method supports only two kind values: 'user' and 'role'. + * + * domain - is a prefix to the roles, unused parameter. + * + * If name's kind === 'user' we return all inherited roles from groups and roles directly assigned to the user. + * if name's kind === 'role' we return empty array, because we don't support role inheritance. + * Case kind === 'group' - should not happen, because: + * 1) Method getRoles returns only role entity references, so casbin engine doesn't call this + * method again to ask about name with kind "group". + * 2) We implemented getRoles method only to use: + * 'await enforcer.getImplicitPermissionsForUser(userEntityRef)', + * so name argument can be only with kind 'user' or 'role'. + * + * Info: when we call 'await enforcer.getImplicitPermissionsForUser(userEntityRef)', + * then casbin engine executes 'getRoles' method few times. + * Firstly casbin asks about roles for 'userEntityRef'. + * Let's imagine, that 'getRoles' returned two roles for userEntityRef. + * Then casbin calls 'getRoles' two more times to + * find parent roles. But we return empty array for each such call, + * because we don't support role inheritance and we notify casbin about end of the role sub-tree. + */ + async getRoles(name: string, ..._domain: string[]): Promise { + const { kind } = parseEntityRef(name); + if (kind === 'user') { + const memo = await AncestorSearchFactory.createAncestorSearchMemo( + name, + this.config, + this.catalogApi, + this.catalogDBClient, + this.auth, + this.maxDepth, + ); + await memo.buildUserGraph(); + memo.debugNodesAndEdges(this.logger, name); + + // Account for the user not being in the graph (this can happen during direct assignment to roles) + memo.setNode(name); + + if (!memo.isAcyclic()) { + const cycles = memo.findCycles(); + + this.logger.warn( + `Detected cycle dependencies in the Group graph: ${JSON.stringify( + cycles, + )}. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: ${JSON.stringify( + cycles, + )}`, + ); + return Promise.resolve([]); + } + + if (this.isPGClient()) { + const currentRole = new RoleMemberList(name); + await currentRole.buildRoles( + currentRole, + memo.getNodes(), + this.rbacDBClient, + ); + const roles = currentRole.getRoles(); + if (this.defaultRoleRef) roles.push(this.defaultRoleRef); + return Promise.resolve(roles); + } + + const allRoles: string[] = []; + for (const value of this.allRoles.values()) { + if (this.hasMember(value, memo)) { + allRoles.push(value.name); + } + } + + if (this.defaultRoleRef) allRoles.push(this.defaultRoleRef); + return Promise.resolve(allRoles); + } + + return []; + } + + /** + * getUsers gets the users that inherits a subject. + * domain is an unreferenced parameter here, may be used in other implementations. + */ + async getUsers(_name: string, ..._domain: string[]): Promise { + throw new Error('Method "getUsers" not implemented.'); + } + + /** + * printRoles prints all the roles to log. + */ + async printRoles(): Promise { + // do nothing + } + + /** + * getOrCreateRole will get a role if it has already been cached + * or it will create a new role to be cached. + * This cache is a simple tree that is used to quickly compare + * users and groups to roles. + * @param name The user or group whose cache we will be getting / creating. + * @returns The cached role as a RoleList. + */ + private getOrCreateRole(name: string): RoleMemberList { + const role = this.allRoles.get(name); + if (role) { + return role; + } + const newRole = new RoleMemberList(name); + this.allRoles.set(name, newRole); + + return newRole; + } + + /** + * isPGClient checks what the current database client is at them time. + * This is to ensure that we are querying the database in the event of postgres + * or using in memory cache for better sqlite3. + * @returns True if the database client is pg. + */ + isPGClient(): boolean { + const client = this.rbacDBClient.client.config.client; + return client === 'pg'; + } + + /** + * hasMember checks if the members from a particular role is associated with the user + * that the AncestorSearchMemo graph is built for. + * @param role The role that we are getting the members from. + * @param memo The user graph that we are comparing members with. + * @returns True if a member from the role is also associated with the user. + */ + private hasMember( + role: RoleMemberList | undefined, + memo: AncestorSearchMemo, + ): boolean { + if (role === undefined) { + return false; + } + + for (const member of role.getMembers()) { + if (memo.hasEntityRef(member)) { + return true; + } + } + return false; + } +} diff --git a/plugins/rbac-backend/src/service/enforcer-delegate.test.ts b/plugins/rbac-backend/src/service/enforcer-delegate.test.ts new file mode 100644 index 0000000000..c58409670f --- /dev/null +++ b/plugins/rbac-backend/src/service/enforcer-delegate.test.ts @@ -0,0 +1,1305 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices } from '@backstage/backend-test-utils'; + +import { Model, newEnforcer, newModelFromString } from 'casbin'; +import * as Knex from 'knex'; +import { MockClient } from 'knex-mock-client'; + +import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; +import { + RoleMetadataDao, + RoleMetadataStorage, +} from '../database/role-metadata'; +import { BackstageRoleManager } from '../role-manager/role-manager'; +import { DefaultPermissionsReader } from '../default-permissions/default-permissions'; +import { EnforcerDelegate } from './enforcer-delegate'; +import { MODEL } from './permission-model'; +import { + catalogMock, + conditionalStorageMock, + mockAuditorService, +} from '../../__fixtures__/mock-utils'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { + PermissionInfo, + RoleConditionalPolicyDecision, +} from '@backstage-community/plugin-rbac-common'; + +const roleMetadataStorageMock: RoleMetadataStorage = { + filterRoleMetadata: jest.fn().mockImplementation(() => []), + filterForOwnerRoleMetadata: jest.fn().mockImplementation(), + findRoleMetadata: jest.fn().mockImplementation(), + createRoleMetadata: jest.fn().mockImplementation(), + updateRoleMetadata: jest.fn().mockImplementation(), + removeRoleMetadata: jest.fn().mockImplementation(), + getCachedDefaultRoleMetadata: jest.fn().mockImplementation(), + getDefaultRole: jest.fn().mockResolvedValue(undefined), + syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), +}; + +const mockClientKnex = Knex.knex({ client: MockClient }); + +const mockAuthService = mockServices.auth(); + +const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + permission: { + rbac: {}, + }, + }, +}); +const policy = ['role:default/dev-team', 'policy-entity', 'read', 'allow']; +const secondPolicy = [ + 'role:default/qa-team', + 'catalog-entity', + 'create', + 'allow', +]; + +const groupingPolicy = ['user:default/tom', 'role:default/dev-team']; +const secondGroupingPolicy = ['user:default/tim', 'role:default/qa-team']; + +describe('EnforcerDelegate', () => { + let enfRemovePolicySpy: jest.SpyInstance, string[], any>; + let enfRemovePoliciesSpy: jest.SpyInstance< + Promise, + [rules: string[][]], + any + >; + let enfRemoveGroupingPolicySpy: jest.SpyInstance< + Promise, + string[], + any + >; + let adapterLoaderFilterGroupingPolicySpy: jest.SpyInstance< + Promise, + [model: Model, filter: any], + any + >; + let enfRemoveGroupingPoliciesSpy: jest.SpyInstance< + Promise, + [rules: string[][]], + any + >; + let enfAddPolicySpy: jest.SpyInstance< + Promise, + [...policy: string[]], + any + >; + let enfAddGroupingPolicySpy: jest.SpyInstance< + Promise, + [...policy: string[]], + any + >; + let enfAddGroupingPoliciesSpy: jest.SpyInstance< + Promise, + [policy: string[][]], + any + >; + let enfAddPoliciesSpy: jest.SpyInstance< + Promise, + [rules: string[][]], + any + >; + + const modifiedBy = 'user:default/some-admin'; + + beforeEach(() => { + (roleMetadataStorageMock.createRoleMetadata as jest.Mock).mockReset(); + (roleMetadataStorageMock.updateRoleMetadata as jest.Mock).mockReset(); + (roleMetadataStorageMock.findRoleMetadata as jest.Mock).mockReset(); + (roleMetadataStorageMock.removeRoleMetadata as jest.Mock).mockReset(); + }); + + const knex = Knex.knex({ client: MockClient }); + + async function createEnfDelegate( + policies?: string[][], + groupingPolicies?: string[][], + ): Promise { + const theModel = newModelFromString(MODEL); + const logger = mockServices.logger.mock(); + + const sqliteInMemoryAdapter = await new CasbinDBAdapterFactory( + config, + mockClientKnex, + ).createAdapter(); + adapterLoaderFilterGroupingPolicySpy = jest.spyOn( + sqliteInMemoryAdapter, + 'loadFilteredPolicy', + ); + + const catalogDBClient = Knex.knex({ client: MockClient }); + const rbacDBClient = Knex.knex({ client: MockClient }); + const enf = await newEnforcer(theModel, sqliteInMemoryAdapter); + enfRemovePolicySpy = jest.spyOn(enf, 'removePolicy'); + enfRemovePoliciesSpy = jest.spyOn(enf, 'removePolicies'); + enfRemoveGroupingPolicySpy = jest.spyOn(enf, 'removeGroupingPolicy'); + enfRemoveGroupingPoliciesSpy = jest.spyOn(enf, 'removeGroupingPolicies'); + enfAddPolicySpy = jest.spyOn(enf, 'addPolicy'); + enfAddGroupingPolicySpy = jest.spyOn(enf, 'addGroupingPolicy'); + enfAddGroupingPoliciesSpy = jest.spyOn(enf, 'addGroupingPolicies'); + enfAddPoliciesSpy = jest.spyOn(enf, 'addPolicies'); + + const rm = new BackstageRoleManager( + catalogMock, + logger, + catalogDBClient, + rbacDBClient, + config, + mockAuthService, + new DefaultPermissionsReader(config), + ); + enf.setRoleManager(rm); + enf.enableAutoBuildRoleLinks(false); + await enf.buildRoleLinks(); + + if (policies && policies.length > 0) { + await enf.addPolicies(policies); + } + if (groupingPolicies && groupingPolicies.length > 0) { + await enf.addGroupingPolicies(groupingPolicies); + } + + return new EnforcerDelegate( + enf, + mockAuditorService, + conditionalStorageMock, + roleMetadataStorageMock, + knex, + ); + } + + describe('hasPolicy', () => { + it('has policy should return false', async () => { + const enfDelegate = await createEnfDelegate(); + const result = await enfDelegate.hasPolicy(...policy); + + expect(result).toBeFalsy(); + }); + + it('has policy should return true', async () => { + const enfDelegate = await createEnfDelegate([policy]); + + const result = await enfDelegate.hasPolicy(...policy); + + expect(result).toBeTruthy(); + }); + }); + + describe('hasGroupingPolicy', () => { + it('has policy should return false', async () => { + const enfDelegate = await createEnfDelegate([policy]); + const result = await enfDelegate.hasGroupingPolicy(...groupingPolicy); + + expect(result).toBeFalsy(); + }); + + it('has policy should return true', async () => { + const enfDelegate = await createEnfDelegate([], [groupingPolicy]); + + const result = await enfDelegate.hasGroupingPolicy(...groupingPolicy); + + expect(result).toBeTruthy(); + }); + }); + + describe('getPolicy', () => { + it('should return empty array', async () => { + const enfDelegate = await createEnfDelegate(); + const policies = await enfDelegate.getPolicy(); + + expect(policies.length).toEqual(0); + }); + + it('should return policy', async () => { + const enfDelegate = await createEnfDelegate([policy]); + + const policies = await enfDelegate.getPolicy(); + + expect(policies.length).toEqual(1); + expect(policies[0]).toEqual(policy); + }); + }); + + describe('getGroupingPolicy', () => { + it('should return empty array', async () => { + const enfDelegate = await createEnfDelegate(); + const groupingPolicies = await enfDelegate.getGroupingPolicy(); + + expect(groupingPolicies.length).toEqual(0); + }); + + it('should return grouping policy', async () => { + const enfDelegate = await createEnfDelegate([], [groupingPolicy]); + + const policies = await enfDelegate.getGroupingPolicy(); + + expect(policies.length).toEqual(1); + expect(policies[0]).toEqual(groupingPolicy); + }); + }); + + describe('getFilteredPolicy', () => { + it('should return empty array', async () => { + const enfDelegate = await createEnfDelegate(); + // filter by policy assignment person + const policies = await enfDelegate.getFilteredPolicy(0, policy[0]); + + expect(policies.length).toEqual(0); + }); + + it('should return filtered policy by role name', async () => { + const enfDelegate = await createEnfDelegate([policy, secondPolicy]); + + // filter by policy assignment person + const policies = await enfDelegate.getFilteredPolicy( + 0, + 'role:default/qa-team', + ); + + expect(policies.length).toEqual(1); + expect(policies[0]).toEqual(secondPolicy); + }); + + it('should return filtered policy by policy name', async () => { + const enfDelegate = await createEnfDelegate([policy, secondPolicy]); + + const policyName = policy[1]; + const policies = await enfDelegate.getFilteredPolicy(0, '', policyName); + + expect(policies.length).toEqual(1); + expect(policies[0]).toEqual(policy); + }); + + it('should return filtered policy by policy name with index offset', async () => { + const enfDelegate = await createEnfDelegate([policy, secondPolicy]); + + const policyName = policy[1]; + const policies = await enfDelegate.getFilteredPolicy(1, policyName); + + expect(policies.length).toEqual(1); + expect(policies[0]).toEqual(policy); + }); + + it('should return filtered policy by policy action', async () => { + const enfDelegate = await createEnfDelegate([policy, secondPolicy]); + + const policyAction = policy[2]; + const policies = await enfDelegate.getFilteredPolicy( + 0, + '', + '', + policyAction, + ); + + expect(policies.length).toEqual(1); + expect(policies[0]).toEqual(policy); + }); + + it('should return filtered policy by policy effect', async () => { + const enfDelegate = await createEnfDelegate([policy, secondPolicy]); + + const policyEffect = policy[3]; + const policies = await enfDelegate.getFilteredPolicy( + 0, + '', + '', + '', + policyEffect, + ); + + expect(policies.length).toEqual(2); + expect(policies[0]).toEqual(policy); + expect(policies[1]).toEqual(secondPolicy); + }); + }); + + describe('getFilteredGroupingPolicy', () => { + it('should return empty array', async () => { + const enfDelegate = await createEnfDelegate(); + // filter by policy assignment person + const policies = await enfDelegate.getFilteredGroupingPolicy( + 0, + 'user:default/tim', + ); + + expect(policies.length).toEqual(0); + }); + + it('should return filtered grouping policy by role member', async () => { + const enfDelegate = await createEnfDelegate( + [], + [groupingPolicy, secondGroupingPolicy], + ); + + // filter by policy assignment person + const policies = await enfDelegate.getFilteredGroupingPolicy( + 0, + 'user:default/tim', + ); + + expect(policies.length).toEqual(1); + expect(policies[0]).toEqual(secondGroupingPolicy); + }); + + it('should return filtered grouping policy by role name', async () => { + const enfDelegate = await createEnfDelegate( + [], + [groupingPolicy, secondGroupingPolicy], + ); + + // filter by policy assignment person + const policies = await enfDelegate.getFilteredGroupingPolicy( + 0, + '', + 'role:default/qa-team', + ); + + expect(policies.length).toEqual(1); + expect(policies[0]).toEqual(secondGroupingPolicy); + }); + + it('should return filtered grouping policy by role name with index offset', async () => { + const enfDelegate = await createEnfDelegate( + [], + [groupingPolicy, secondGroupingPolicy], + ); + + // filter by policy assignment person + const policies = await enfDelegate.getFilteredGroupingPolicy( + 1, + 'role:default/qa-team', + ); + + expect(policies.length).toEqual(1); + expect(policies[0]).toEqual(secondGroupingPolicy); + }); + }); + + describe('addPolicy', () => { + it('should add policy', async () => { + const enfDelegate = await createEnfDelegate(); + enfAddPolicySpy.mockClear(); + + await enfDelegate.addPolicy(policy); + + expect(enfAddPolicySpy).toHaveBeenCalledWith(...policy); + + expect(await enfDelegate.getPolicy()).toEqual([policy]); + }); + }); + + describe('addPolicies', () => { + it('should be added single policy', async () => { + const enfDelegate = await createEnfDelegate(); + + await enfDelegate.addPolicies([policy]); + + const storePolicies = await enfDelegate.getPolicy(); + + expect(storePolicies).toEqual([policy]); + expect(enfAddPoliciesSpy).toHaveBeenCalledWith([policy]); + }); + + it('should be added few policies', async () => { + const enfDelegate = await createEnfDelegate(); + + await enfDelegate.addPolicies([policy, secondPolicy]); + + const storePolicies = await enfDelegate.getPolicy(); + + expect(storePolicies.length).toEqual(2); + expect(storePolicies).toEqual( + expect.arrayContaining([policy, secondPolicy]), + ); + expect(enfAddPoliciesSpy).toHaveBeenCalledWith([policy, secondPolicy]); + }); + + it('should not fail, when argument is empty array', async () => { + const enfDelegate = await createEnfDelegate(); + + enfDelegate.addPolicies([]); + + expect(enfAddPoliciesSpy).not.toHaveBeenCalled(); + expect((await enfDelegate.getPolicy()).length).toEqual(0); + }); + }); + + describe('addGroupingPolicy', () => { + it('should add grouping policy and create role metadata', async () => { + (roleMetadataStorageMock.findRoleMetadata as jest.Mock).mockReturnValue( + Promise.resolve(undefined), + ); + + const enfDelegate = await createEnfDelegate(); + + const roleEntityRef = 'role:default/dev-team'; + await enfDelegate.addGroupingPolicy(groupingPolicy, { + source: 'rest', + roleEntityRef: roleEntityRef, + author: modifiedBy, + modifiedBy, + }); + + expect(enfAddGroupingPolicySpy).toHaveBeenCalledWith(...groupingPolicy); + expect(roleMetadataStorageMock.createRoleMetadata).toHaveBeenCalled(); + expect( + (roleMetadataStorageMock.createRoleMetadata as jest.Mock).mock.calls + .length, + ).toEqual(1); + const metadata: RoleMetadataDao = ( + roleMetadataStorageMock.createRoleMetadata as jest.Mock + ).mock.calls[0][0]; + const createdAtData = new Date(`${metadata.createdAt}`); + const lastModified = new Date(`${metadata.lastModified}`); + expect(lastModified).toEqual(createdAtData); + + expect(metadata.source).toEqual('rest'); + expect(metadata.roleEntityRef).toEqual('role:default/dev-team'); + }); + + it('should fail to add policy, caused role metadata storage error', async () => { + const enfDelegate = await createEnfDelegate(); + + roleMetadataStorageMock.createRoleMetadata = jest + .fn() + .mockImplementation(() => { + throw new Error('some unexpected error'); + }); + + await expect( + enfDelegate.addGroupingPolicy(groupingPolicy, { + source: 'rest', + roleEntityRef: 'role:default/dev-team', + author: modifiedBy, + modifiedBy, + }), + ).rejects.toThrow('some unexpected error'); + }); + + it('should update role metadata on addGroupingPolicy, because metadata has been created', async () => { + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async ( + _roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + return { + source: 'csv-file', + roleEntityRef: 'role:default/dev-team', + createdAt: '2024-03-01 00:23:41+00', + author: modifiedBy, + modifiedBy, + }; + }, + ); + + const enfDelegate = await createEnfDelegate(); + + const roleEntityRef = 'role:default/dev-team'; + await enfDelegate.addGroupingPolicy(groupingPolicy, { + source: 'rest', + roleEntityRef, + author: modifiedBy, + modifiedBy, + }); + + expect(enfAddGroupingPolicySpy).toHaveBeenCalledWith(...groupingPolicy); + + expect(roleMetadataStorageMock.createRoleMetadata).not.toHaveBeenCalled(); + const metadata: RoleMetadataDao = ( + roleMetadataStorageMock.updateRoleMetadata as jest.Mock + ).mock.calls[0][0]; + const createdAtData = new Date(`${metadata.createdAt}`); + const lastModified = new Date(`${metadata.lastModified}`); + expect(lastModified > createdAtData).toBeTruthy(); + + expect(metadata.source).toEqual('rest'); + expect(metadata.roleEntityRef).toEqual('role:default/dev-team'); + }); + }); + + describe('addGroupingPolicies', () => { + it('should add grouping policies and create role metadata', async () => { + const enfDelegate = await createEnfDelegate(); + + const roleMetadataDao: RoleMetadataDao = { + roleEntityRef: 'role:default/security', + source: 'rest', + author: modifiedBy, + modifiedBy, + }; + await enfDelegate.addGroupingPolicies( + [groupingPolicy, secondGroupingPolicy], + roleMetadataDao, + ); + + const storedPolicies = await enfDelegate.getGroupingPolicy(); + expect(storedPolicies).toEqual([groupingPolicy, secondGroupingPolicy]); + + expect(enfAddGroupingPoliciesSpy).toHaveBeenCalledWith([ + groupingPolicy, + secondGroupingPolicy, + ]); + + expect(roleMetadataStorageMock.createRoleMetadata).toHaveBeenCalledWith( + roleMetadataDao, + expect.anything(), + ); + + const metadata: RoleMetadataDao = ( + roleMetadataStorageMock.createRoleMetadata as jest.Mock + ).mock.calls[0][0]; + + const createdAtData = new Date(`${metadata.createdAt}`); + const lastModified = new Date(`${metadata.lastModified}`); + expect(lastModified).toEqual(createdAtData); + expect(metadata.author).toEqual(modifiedBy); + expect(metadata.roleEntityRef).toEqual('role:default/security'); + expect(metadata.source).toEqual('rest'); + expect(metadata.description).toBeUndefined(); + }); + + it('should add grouping policies and create role metadata with description', async () => { + const enfDelegate = await createEnfDelegate(); + + const description = 'Role for security engineers'; + const roleMetadataDao: RoleMetadataDao = { + roleEntityRef: 'role:default/security', + source: 'rest', + description, + author: modifiedBy, + modifiedBy, + }; + await enfDelegate.addGroupingPolicies( + [groupingPolicy, secondGroupingPolicy], + roleMetadataDao, + ); + + const storedPolicies = await enfDelegate.getGroupingPolicy(); + expect(storedPolicies).toEqual([groupingPolicy, secondGroupingPolicy]); + + expect(enfAddGroupingPoliciesSpy).toHaveBeenCalledWith([ + groupingPolicy, + secondGroupingPolicy, + ]); + + expect(roleMetadataStorageMock.createRoleMetadata).toHaveBeenCalledWith( + roleMetadataDao, + expect.anything(), + ); + + const metadata: RoleMetadataDao = ( + roleMetadataStorageMock.createRoleMetadata as jest.Mock + ).mock.calls[0][0]; + + const createdAtData = new Date(`${metadata.createdAt}`); + const lastModified = new Date(`${metadata.lastModified}`); + expect(lastModified).toEqual(createdAtData); + expect(metadata.roleEntityRef).toEqual('role:default/security'); + expect(metadata.source).toEqual('rest'); + expect(metadata.description).toEqual('Role for security engineers'); + }); + + it('should fail to add grouping policy, because fail to create role metadata', async () => { + roleMetadataStorageMock.createRoleMetadata = jest + .fn() + .mockImplementation(() => { + throw new Error('some unexpected error'); + }); + + const enfDelegate = await createEnfDelegate(); + + const roleMetadataDao: RoleMetadataDao = { + roleEntityRef: 'role:default/security', + source: 'rest', + author: 'user:default/some-user', + modifiedBy: 'user:default/some-user', + }; + await expect( + enfDelegate.addGroupingPolicies( + [groupingPolicy, secondGroupingPolicy], + roleMetadataDao, + ), + ).rejects.toThrow('some unexpected error'); + + // shouldn't store group policies + const storedPolicies = await enfDelegate.getGroupingPolicy(); + expect(storedPolicies).toEqual([]); + }); + + it('should update role metadata, because metadata has been created', async () => { + (roleMetadataStorageMock.findRoleMetadata as jest.Mock) = jest + .fn() + .mockReturnValueOnce({ + source: 'csv-file', + roleEntityRef: 'role:default/dev-team', + author: 'user:default/some-user', + description: 'Role for dev engineers', + createdAt: '2024-03-01 00:23:41+00', + }); + + const enfDelegate = await createEnfDelegate(); + + const roleMetadataDao: RoleMetadataDao = { + roleEntityRef: 'role:default/dev-team', + source: 'rest', + author: 'user:default/some-user', + modifiedBy, + }; + await enfDelegate.addGroupingPolicies( + [ + ['user:default/tom', 'role:default/dev-team'], + ['user:default/tim', 'role:default/dev-team'], + ], + roleMetadataDao, + ); + const storedPolicies = await enfDelegate.getGroupingPolicy(); + + expect(storedPolicies).toEqual([ + ['user:default/tom', 'role:default/dev-team'], + ['user:default/tim', 'role:default/dev-team'], + ]); + + expect(enfAddGroupingPoliciesSpy).toHaveBeenCalledWith([ + ['user:default/tom', 'role:default/dev-team'], + ['user:default/tim', 'role:default/dev-team'], + ]); + + expect(roleMetadataStorageMock.createRoleMetadata).not.toHaveBeenCalled(); + + const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) + .mock.calls[0][0]; + + const createdAtData = new Date(`${metadata.createdAt}`); + const lastModified = new Date(`${metadata.lastModified}`); + expect(lastModified > createdAtData).toBeTruthy(); + expect(metadata.author).toEqual('user:default/some-user'); + expect(metadata.description).toEqual('Role for dev engineers'); + expect(metadata.modifiedBy).toEqual(modifiedBy); + expect(metadata.roleEntityRef).toEqual('role:default/dev-team'); + expect(metadata.source).toEqual('rest'); + }); + }); + + describe('updateGroupingPolicies', () => { + it('should update grouping policies: add one more policy and update roleMetadata with new modifiedBy', async () => { + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation(async (): Promise => { + return { + source: 'rest', + roleEntityRef: 'role:default/dev-team', + author: 'user:default/tom', + modifiedBy: 'user:default/tom', + description: 'Role for dev engineers', + createdAt: '2024-03-01 00:23:41+00', + }; + }); + + const enfDelegate = await createEnfDelegate([], [groupingPolicy]); + + const roleMetadataDao: RoleMetadataDao = { + roleEntityRef: 'role:default/dev-team', + source: 'rest', + author: modifiedBy, + modifiedBy: 'user:default/system-admin', + }; + + await enfDelegate.updateGroupingPolicies( + [groupingPolicy], + [groupingPolicy, secondGroupingPolicy], + roleMetadataDao, + ); + + const storedPolicies = await enfDelegate.getGroupingPolicy(); + expect(storedPolicies.length).toEqual(2); + + expect(enfRemoveGroupingPoliciesSpy).toHaveBeenCalledWith([ + groupingPolicy, + ]); + expect(enfAddGroupingPoliciesSpy).toHaveBeenCalledWith([ + groupingPolicy, + secondGroupingPolicy, + ]); + + const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) + .mock.calls[0][0]; + + const createdAtData = new Date(`${metadata.createdAt}`); + const lastModified = new Date(`${metadata.lastModified}`); + expect(lastModified > createdAtData).toBeTruthy(); + expect(metadata.author).toEqual('user:default/tom'); + expect(metadata.description).toEqual('Role for dev engineers'); + expect(metadata.modifiedBy).toEqual('user:default/system-admin'); + expect(metadata.roleEntityRef).toEqual('role:default/dev-team'); + expect(metadata.source).toEqual('rest'); + }); + + it('should update grouping policies: one policy should be removed for updateGroupingPolicies', async () => { + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation(async (): Promise => { + return { + source: 'rest', + roleEntityRef: 'role:default/dev-team', + author: modifiedBy, + modifiedBy, + description: 'Role for dev engineers', + createdAt: '2024-03-01 00:23:41+00', + }; + }); + + const enfDelegate = await createEnfDelegate( + [], + [groupingPolicy, secondGroupingPolicy], + ); + + const roleMetadataDao: RoleMetadataDao = { + roleEntityRef: 'role:default/dev-team', + source: 'rest', + author: modifiedBy, + modifiedBy: 'user:default/system-admin', + }; + await enfDelegate.updateGroupingPolicies( + [groupingPolicy, secondGroupingPolicy], + [groupingPolicy], + roleMetadataDao, + ); + + const storedPolicies = await enfDelegate.getGroupingPolicy(); + expect(storedPolicies.length).toEqual(1); + + expect(enfRemoveGroupingPoliciesSpy).toHaveBeenCalledWith([ + groupingPolicy, + secondGroupingPolicy, + ]); + expect(enfAddGroupingPoliciesSpy).toHaveBeenCalledWith([groupingPolicy]); + + const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) + .mock.calls[0][0]; + + const createdAtData = new Date(`${metadata.createdAt}`); + const lastModified = new Date(`${metadata.lastModified}`); + expect(lastModified > createdAtData).toBeTruthy(); + expect(metadata.author).toEqual(modifiedBy); + expect(metadata.description).toEqual('Role for dev engineers'); + expect(metadata.modifiedBy).toEqual('user:default/system-admin'); + expect(metadata.roleEntityRef).toEqual('role:default/dev-team'); + expect(metadata.source).toEqual('rest'); + }); + + it('should update grouping policies: one policy should be removed and description updated', async () => { + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation(async (): Promise => { + return { + source: 'rest', + roleEntityRef: 'role:default/dev-team', + author: 'user:default/some-user', + modifiedBy: 'user:default/some-user', + description: 'Role for dev engineers', + createdAt: '2024-03-01 00:23:41+00', + }; + }); + + const enfDelegate = await createEnfDelegate( + [], + [groupingPolicy, secondGroupingPolicy], + ); + + const roleMetadataDao: RoleMetadataDao = { + roleEntityRef: 'role:default/dev-team', + source: 'rest', + author: modifiedBy, + modifiedBy: 'user:default/system-admin', + description: 'updated description', + }; + await enfDelegate.updateGroupingPolicies( + [groupingPolicy, secondGroupingPolicy], + [groupingPolicy], + roleMetadataDao, + ); + + const storedPolicies = await enfDelegate.getGroupingPolicy(); + expect(storedPolicies.length).toEqual(1); + + expect(enfRemoveGroupingPoliciesSpy).toHaveBeenCalledWith([ + groupingPolicy, + secondGroupingPolicy, + ]); + expect(enfAddGroupingPoliciesSpy).toHaveBeenCalledWith([groupingPolicy]); + + const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) + .mock.calls[0][0]; + + const createdAtData = new Date(`${metadata.createdAt}`); + const lastModified = new Date(`${metadata.lastModified}`); + expect(lastModified > createdAtData).toBeTruthy(); + expect(metadata.author).toEqual('user:default/some-user'); + expect(metadata.description).toEqual('updated description'); + expect(metadata.modifiedBy).toEqual('user:default/system-admin'); + expect(metadata.roleEntityRef).toEqual('role:default/dev-team'); + expect(metadata.source).toEqual('rest'); + }); + + it('should update grouping policies: role should be renamed', async () => { + const oldRoleName = 'role:default/dev-team'; + const newRoleName = 'role:default/new-team-name'; + + const oldCondition = { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + actions: ['read'], + roleEntityRef: oldRoleName, + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['group:default/team-a'], + }, + }, + }; + ( + conditionalStorageMock.filterConditions as jest.Mock + ).mockReturnValueOnce([oldCondition]); + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async ( + roleEntityRef: string, + _trx: Knex.Knex.Transaction, + ): Promise => { + if (roleEntityRef === oldRoleName) { + return { + source: 'rest', + roleEntityRef: oldRoleName, + author: modifiedBy, + modifiedBy, + description: 'Role for dev engineers', + createdAt: '2024-03-01 00:23:41+00', + }; + } + return undefined; + }, + ); + + const secondGroupingPolicyWithOldRole = ['user:default/tim', oldRoleName]; + const policyWithOldRole = [ + oldRoleName, + 'catalog-entity', + 'delete', + 'allow', + ]; + const expectedPolicies = [ + secondPolicy, + [newRoleName, 'policy-entity', 'read', 'allow'], + [newRoleName, 'catalog-entity', 'delete', 'allow'], + ]; + + const enfDelegate = await createEnfDelegate( + [policy, secondPolicy, policyWithOldRole], + [groupingPolicy, secondGroupingPolicy, secondGroupingPolicyWithOldRole], + ); + + const groupingPolicyWithRenamedRole = ['user:default/tom', newRoleName]; + const secondGroupingPolicyWithRenamedRole = [ + 'user:default/tim', + newRoleName, + ]; + + const roleMetadataDao: RoleMetadataDao = { + roleEntityRef: newRoleName, + source: 'rest', + modifiedBy, + }; + await enfDelegate.updateGroupingPolicies( + [groupingPolicy, secondGroupingPolicyWithOldRole], + [groupingPolicyWithRenamedRole, secondGroupingPolicyWithRenamedRole], + roleMetadataDao, + ); + + const storedPolicies = await enfDelegate.getGroupingPolicy(); + expect(storedPolicies.length).toEqual(3); + expect(storedPolicies[0]).toEqual(secondGroupingPolicy); // different role remained unchanged + expect(storedPolicies[1]).toEqual(groupingPolicyWithRenamedRole); + expect(storedPolicies[2]).toEqual(secondGroupingPolicyWithRenamedRole); + + expect(enfRemoveGroupingPoliciesSpy).toHaveBeenCalledWith([ + groupingPolicy, + secondGroupingPolicyWithOldRole, + ]); + expect(enfAddGroupingPoliciesSpy).toHaveBeenCalledWith([ + groupingPolicyWithRenamedRole, + secondGroupingPolicyWithRenamedRole, + ]); + + const updatedCondition: RoleConditionalPolicyDecision = ( + conditionalStorageMock.updateCondition as jest.Mock + ).mock.calls[0][1]; + expect(updatedCondition).toEqual({ + ...oldCondition, + roleEntityRef: newRoleName, + }); + + const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) + .mock.calls[0][0]; + + const createdAtData = new Date(`${metadata.createdAt}`); + const lastModified = new Date(`${metadata.lastModified}`); + expect(lastModified > createdAtData).toBeTruthy(); + expect(metadata.author).toEqual(modifiedBy); + expect(metadata.description).toEqual('Role for dev engineers'); + expect(metadata.modifiedBy).toEqual(modifiedBy); + expect(metadata.roleEntityRef).toEqual(newRoleName); + expect(metadata.source).toEqual('rest'); + expect(await enfDelegate.getPolicy()).toEqual(expectedPolicies); + }); + + it('should update grouping policies: should be updated role description and source', async () => { + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation(async (): Promise => { + return { + source: 'legacy', + roleEntityRef: 'role:default/dev-team', + author: modifiedBy, + description: 'Role for dev engineers', + createdAt: '2024-03-01 00:23:41+00', + modifiedBy, + }; + }); + + const enfDelegate = await createEnfDelegate([], [groupingPolicy]); + + const roleMetadataDao: RoleMetadataDao = { + roleEntityRef: 'role:default/dev-team', + source: 'rest', + modifiedBy, + description: 'some-new-description', + }; + await enfDelegate.updateGroupingPolicies( + [groupingPolicy], + [groupingPolicy], + roleMetadataDao, + ); + + const storedPolicies = await enfDelegate.getGroupingPolicy(); + expect(storedPolicies.length).toEqual(1); + expect(storedPolicies).toEqual([groupingPolicy]); + + const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) + .mock.calls[0][0]; + + const createdAtData = new Date(`${metadata.createdAt}`); + const lastModified = new Date(`${metadata.lastModified}`); + expect(lastModified > createdAtData).toBeTruthy(); + expect(metadata.author).toEqual(modifiedBy); + expect(metadata.description).toEqual('some-new-description'); + expect(metadata.modifiedBy).toEqual(modifiedBy); + expect(metadata.roleEntityRef).toEqual('role:default/dev-team'); + expect(metadata.source).toEqual('rest'); + }); + }); + + describe('updatePolicies', () => { + it('should be updated single policy', async () => { + const enfDelegate = await createEnfDelegate([policy]); + enfAddPolicySpy.mockClear(); + enfRemovePoliciesSpy.mockClear(); + + const newPolicy = ['user:default/tom', 'policy-entity', 'read', 'deny']; + + await enfDelegate.updatePolicies([policy], [newPolicy]); + + expect(enfRemovePoliciesSpy).toHaveBeenCalledWith([policy]); + expect(enfAddPoliciesSpy).toHaveBeenCalledWith([newPolicy]); + }); + + it('should be added few policies', async () => { + const enfDelegate = await createEnfDelegate([policy, secondPolicy]); + enfAddPolicySpy.mockClear(); + enfRemovePoliciesSpy.mockClear(); + + const newPolicy1 = ['user:default/tom', 'policy-entity', 'read', 'deny']; + const newPolicy2 = [ + 'user:default/tim', + 'catalog-entity', + 'write', + 'allow', + ]; + + await enfDelegate.updatePolicies( + [policy, secondPolicy], + [newPolicy1, newPolicy2], + ); + + expect(enfRemovePoliciesSpy).toHaveBeenCalledWith([policy, secondPolicy]); + expect(enfAddPoliciesSpy).toHaveBeenCalledWith([newPolicy1, newPolicy2]); + }); + }); + + describe('removePolicy', () => { + const policyToDelete = [ + 'user:default/some-user', + 'catalog-entity', + 'read', + 'allow', + ]; + + it('policy should be removed', async () => { + const enfDelegate = await createEnfDelegate([policyToDelete]); + await enfDelegate.removePolicy(policyToDelete); + + expect(enfRemovePolicySpy).toHaveBeenCalledWith(...policyToDelete); + }); + }); + + describe('removePolicies', () => { + const policiesToDelete = [ + ['user:default/some-user', 'catalog-entity', 'read', 'allow'], + ['user:default/some-user-2', 'catalog-entity', 'read', 'allow'], + ]; + it('policies should be removed', async () => { + const enfDelegate = await createEnfDelegate(policiesToDelete); + await enfDelegate.removePolicies(policiesToDelete); + + expect(enfRemovePoliciesSpy).toHaveBeenCalledWith(policiesToDelete); + }); + }); + + describe('removeGroupingPolicy', () => { + const groupingPolicyToDelete = [ + 'user:default/some-user', + 'role:default/team-dev', + ]; + + beforeEach(() => { + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation(() => { + return { + source: 'rest', + roleEntityRef: 'role:default/team-dev', + createdAt: '2024-03-01 00:23:41+00', + }; + }); + }); + + it('should remove grouping policy and remove role metadata', async () => { + const enfDelegate = await createEnfDelegate([], [groupingPolicyToDelete]); + await enfDelegate.removeGroupingPolicy( + groupingPolicyToDelete, + { source: 'rest', roleEntityRef: 'role:default/team-dev', modifiedBy }, + false, + ); + + expect(roleMetadataStorageMock.findRoleMetadata).toHaveBeenCalledTimes(1); + expect(adapterLoaderFilterGroupingPolicySpy).toHaveBeenCalledTimes(1); + + expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( + 'role:default/team-dev', + expect.anything(), + ); + }); + + it('should remove grouping policy and update role metadata', async () => { + const enfDelegate = await createEnfDelegate( + [], + [ + groupingPolicyToDelete, + ['group:default/team-a', 'role:default/team-dev'], + ], + ); + await enfDelegate.removeGroupingPolicy( + groupingPolicyToDelete, + { source: 'rest', roleEntityRef: 'role:default/team-dev', modifiedBy }, + false, + ); + + expect(roleMetadataStorageMock.findRoleMetadata).toHaveBeenCalledTimes(1); + expect(adapterLoaderFilterGroupingPolicySpy).toHaveBeenCalledTimes(1); + + const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) + .mock.calls[0][0]; + + const createdAtData = new Date(`${metadata.createdAt}`); + const lastModified = new Date(`${metadata.lastModified}`); + expect(lastModified > createdAtData).toBeTruthy(); + + expect(metadata.roleEntityRef).toEqual('role:default/team-dev'); + expect(metadata.source).toEqual('rest'); + }); + + it('should remove grouping policy and not update or remove role metadata, because isUpdate flag set to true', async () => { + const enfDelegate = await createEnfDelegate([], [groupingPolicyToDelete]); + await enfDelegate.removeGroupingPolicy( + groupingPolicyToDelete, + { + source: 'rest', + roleEntityRef: 'role:default/dev-team', + modifiedBy: 'user:default/some-user', + }, + true, + ); + + expect(enfRemoveGroupingPolicySpy).toHaveBeenCalledWith( + ...groupingPolicyToDelete, + ); + + expect(roleMetadataStorageMock.findRoleMetadata).not.toHaveBeenCalled(); + expect(adapterLoaderFilterGroupingPolicySpy).not.toHaveBeenCalled(); + expect(roleMetadataStorageMock.removeRoleMetadata).not.toHaveBeenCalled(); + expect(roleMetadataStorageMock.updateRoleMetadata).not.toHaveBeenCalled(); + }); + }); + + describe('removeGroupingPolicies', () => { + const groupingPoliciesToDelete = [ + ['user:default/some-user', 'role:default/team-dev'], + ['group:default/team-a', 'role:default/team-dev'], + ]; + + it('should remove grouping policies and remove role metadata', async () => { + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation(() => { + return { + source: 'rest', + roleEntityRef: 'role:default/team-dev', + }; + }); + enfRemoveGroupingPoliciesSpy.mockReset(); + adapterLoaderFilterGroupingPolicySpy.mockReset(); + + const enfDelegate = await createEnfDelegate([], groupingPoliciesToDelete); + await enfDelegate.removeGroupingPolicies( + groupingPoliciesToDelete, + { + roleEntityRef: 'role:default/team-dev', + source: 'rest', + modifiedBy, + }, + false, + ); + + expect(enfRemoveGroupingPoliciesSpy).toHaveBeenCalledWith( + groupingPoliciesToDelete, + ); + + expect(roleMetadataStorageMock.findRoleMetadata).toHaveBeenCalledTimes(1); + expect(adapterLoaderFilterGroupingPolicySpy).toHaveBeenCalledTimes(1); + + expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( + 'role:default/team-dev', + expect.anything(), + ); + }); + + it('should remove grouping policies and update role metadata', async () => { + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation(() => { + return { + source: 'rest', + roleEntityRef: 'role:default/team-dev', + createdAt: '2024-03-01 00:23:41+00', + }; + }); + enfRemoveGroupingPoliciesSpy.mockReset(); + adapterLoaderFilterGroupingPolicySpy.mockReset(); + + const remainingGroupPolicy = [ + 'user:default/some-user-2', + 'role:default/team-dev', + ]; + const enfDelegate = await createEnfDelegate( + [], + [...groupingPoliciesToDelete, remainingGroupPolicy], + ); + await enfDelegate.removeGroupingPolicies( + groupingPoliciesToDelete, + { + roleEntityRef: 'role:default/team-dev', + source: 'rest', + modifiedBy, + }, + false, + ); + + expect(enfRemoveGroupingPoliciesSpy).toHaveBeenCalledWith( + groupingPoliciesToDelete, + ); + + expect(roleMetadataStorageMock.findRoleMetadata).toHaveBeenCalledTimes(1); + expect(adapterLoaderFilterGroupingPolicySpy).toHaveBeenCalledTimes(1); + + const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) + .mock.calls[0][0]; + + const createdAtData = new Date(`${metadata.createdAt}`); + const lastModified = new Date(`${metadata.lastModified}`); + expect(lastModified > createdAtData).toBeTruthy(); + + expect(metadata.roleEntityRef).toEqual('role:default/team-dev'); + expect(metadata.source).toEqual('rest'); + }); + + it('should remove grouping policy and not update or remove role metadata, because isUpdate flag set to true', async () => { + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation(() => { + return { + source: 'rest', + roleEntityRef: 'role:default/team-dev', + }; + }); + enfRemoveGroupingPoliciesSpy.mockReset(); + adapterLoaderFilterGroupingPolicySpy.mockReset(); + + const enfDelegate = await createEnfDelegate([], groupingPoliciesToDelete); + await enfDelegate.removeGroupingPolicies( + groupingPoliciesToDelete, + { + roleEntityRef: 'role:default/team-dev', + source: 'rest', + modifiedBy: 'user:default/test-user', + }, + true, + ); + + expect(enfRemoveGroupingPoliciesSpy).toHaveBeenCalledWith( + groupingPoliciesToDelete, + ); + + expect(roleMetadataStorageMock.findRoleMetadata).not.toHaveBeenCalled(); + expect(adapterLoaderFilterGroupingPolicySpy).not.toHaveBeenCalled(); + expect(roleMetadataStorageMock.removeRoleMetadata).not.toHaveBeenCalled(); + expect(roleMetadataStorageMock.updateRoleMetadata).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/rbac-backend/src/service/enforcer-delegate.ts b/plugins/rbac-backend/src/service/enforcer-delegate.ts new file mode 100644 index 0000000000..57d6eb8b71 --- /dev/null +++ b/plugins/rbac-backend/src/service/enforcer-delegate.ts @@ -0,0 +1,742 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Enforcer, FilteredAdapter, newModelFromString } from 'casbin'; +import { Knex } from 'knex'; + +import EventEmitter from 'events'; + +import { ADMIN_ROLE_NAME } from '../admin-permissions/admin-creation'; +import { + RoleMetadataDao, + RoleMetadataStorage, +} from '../database/role-metadata'; +import { mergeRoleMetadata, policiesToString, policyToString } from '../helper'; +import { MODEL } from './permission-model'; +import { PoliciesData } from '../auditor/auditor'; +import { AuditorService } from '@backstage/backend-plugin-api'; +import { ConditionalStorage } from '../database/conditional-storage'; + +export type RoleEvents = 'roleAdded'; +export interface RoleEventEmitter { + on(event: T, listener: (roleEntityRef: string | string[]) => void): this; +} + +type EventMap = { + [event in RoleEvents]: any[]; +}; + +export class EnforcerDelegate implements RoleEventEmitter { + private readonly roleEventEmitter = new EventEmitter(); + + private loadPolicyPromise: Promise | null = null; + private editOperationsQueue: Promise[] = []; // Queue to track edit operations + + constructor( + private readonly enforcer: Enforcer, + private readonly auditor: AuditorService, + private readonly conditionalStorage: ConditionalStorage, + private readonly roleMetadataStorage: RoleMetadataStorage, + private readonly knex: Knex, + ) {} + + async loadPolicy(): Promise { + if (this.loadPolicyPromise) { + // If a load operation is already in progress, return the cached promise + return this.loadPolicyPromise; + } + + this.loadPolicyPromise = (async () => { + try { + await this.waitForEditOperationsToFinish(); + + await this.enforcer.loadPolicy(); + } catch (error) { + const auditorEvent = await this.auditor.createEvent({ + eventId: PoliciesData.PERMISSIONS_READ, + severityLevel: 'medium', + }); + await auditorEvent.fail({ error }); + } finally { + this.loadPolicyPromise = null; + } + })(); + + return this.loadPolicyPromise; + } + + private async waitForEditOperationsToFinish(): Promise { + await Promise.all(this.editOperationsQueue); + } + + async execOperation(operation: Promise): Promise { + this.editOperationsQueue.push(operation); + + let result; + try { + result = await operation; + } catch (err) { + throw err; + } finally { + const index = this.editOperationsQueue.indexOf(operation); + if (index !== -1) { + this.editOperationsQueue.splice(index, 1); + } + } + + return result; + } + + on(event: RoleEvents, listener: (role: string) => void): this { + this.roleEventEmitter.on(event, listener); + return this; + } + + async hasPolicy(...policy: string[]): Promise { + const tempModel = newModelFromString(MODEL); + await (this.enforcer.getAdapter() as FilteredAdapter).loadFilteredPolicy( + tempModel, + [ + { + ptype: 'p', + v0: policy[0], + v1: policy[1], + v2: policy[2], + v3: policy[3], + }, + ], + ); + return tempModel.hasPolicy('p', 'p', policy); + } + + async hasGroupingPolicy(...policy: string[]): Promise { + const tempModel = newModelFromString(MODEL); + await (this.enforcer.getAdapter() as FilteredAdapter).loadFilteredPolicy( + tempModel, + [ + { + ptype: 'g', + v0: policy[0], + v1: policy[1], + }, + ], + ); + return tempModel.hasPolicy('g', 'g', policy); + } + + async getPolicy(): Promise { + const tempModel = newModelFromString(MODEL); + await (this.enforcer.getAdapter() as FilteredAdapter).loadFilteredPolicy( + tempModel, + [{ ptype: 'p' }], + ); + return await tempModel.getPolicy('p', 'p'); + } + + async getGroupingPolicy(): Promise { + const tempModel = newModelFromString(MODEL); + await (this.enforcer.getAdapter() as FilteredAdapter).loadFilteredPolicy( + tempModel, + [{ ptype: 'g' }], + ); + return await tempModel.getPolicy('g', 'g'); + } + + async getRolesForUser(userEntityRef: string): Promise { + return await this.enforcer.getRolesForUser(userEntityRef); + } + + async getFilteredPolicy( + fieldIndex: number, + ...filter: string[] + ): Promise { + const tempModel = newModelFromString(MODEL); + + const filterObj: Record = { ptype: 'p' }; + for (let i = 0; i < filter.length; i++) { + if (filter[i]) { + filterObj[`v${i + fieldIndex}`] = filter[i]; + } + } + + await (this.enforcer.getAdapter() as FilteredAdapter).loadFilteredPolicy( + tempModel, + [filterObj], + ); + + return await tempModel.getPolicy('p', 'p'); + } + + async getFilteredGroupingPolicy( + fieldIndex: number, + ...filter: string[] + ): Promise { + const tempModel = newModelFromString(MODEL); + + const filterObj: Record = { ptype: 'g' }; + for (let i = 0; i < filter.length; i++) { + if (filter[i]) { + filterObj[`v${i + fieldIndex}`] = filter[i]; + } + } + + await (this.enforcer.getAdapter() as FilteredAdapter).loadFilteredPolicy( + tempModel, + [filterObj], + ); + + return await tempModel.getPolicy('g', 'g'); + } + + async addPolicy( + policy: string[], + externalTrx?: Knex.Transaction, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + + if (await this.hasPolicy(...policy)) { + return; + } + try { + const ok = await this.enforcer.addPolicy(...policy); + if (!ok) { + throw new Error(`failed to create policy ${policyToString(policy)}`); + } + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async addPolicies( + policies: string[][], + externalTrx?: Knex.Transaction, + ): Promise { + if (this.loadPolicyPromise) { + await this.loadPolicyPromise; + } else { + await this.loadPolicy(); + } + + const addPoliciesOperation = (async () => { + if (policies.length === 0) { + return; + } + + const trx = externalTrx || (await this.knex.transaction()); + + try { + const ok = await this.enforcer.addPolicies(policies); + if (!ok) { + throw new Error( + `Failed to store policies ${policiesToString(policies)}`, + ); + } + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + })(); + await this.execOperation(addPoliciesOperation); + } + + async addGroupingPolicy( + policy: string[], + roleMetadata: RoleMetadataDao, + externalTrx?: Knex.Transaction, + ): Promise { + if (this.loadPolicyPromise) { + await this.loadPolicyPromise; + } else { + await this.loadPolicy(); + } + + const addGroupingPolicyOperation = (async () => { + const trx = externalTrx ?? (await this.knex.transaction()); + const entityRef = roleMetadata.roleEntityRef; + + if (await this.hasGroupingPolicy(...policy)) { + return; + } + try { + let currentMetadata; + if (entityRef.startsWith(`role:`)) { + currentMetadata = await this.roleMetadataStorage.findRoleMetadata( + entityRef, + trx, + ); + } + + if (currentMetadata) { + await this.roleMetadataStorage.updateRoleMetadata( + mergeRoleMetadata(currentMetadata, roleMetadata), + entityRef, + trx, + ); + } else { + const currentDate: Date = new Date(); + roleMetadata.createdAt = currentDate.toUTCString(); + roleMetadata.lastModified = currentDate.toUTCString(); + await this.roleMetadataStorage.createRoleMetadata(roleMetadata, trx); + } + + const ok = await this.enforcer.addGroupingPolicy(...policy); + if (!ok) { + throw new Error(`failed to create policy ${policyToString(policy)}`); + } + if (!externalTrx) { + await trx.commit(); + } + if (!currentMetadata) { + this.roleEventEmitter.emit('roleAdded', roleMetadata.roleEntityRef); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + })(); + await this.execOperation(addGroupingPolicyOperation); + } + + async addGroupingPolicies( + policies: string[][], + roleMetadata: RoleMetadataDao, + oldRoleEntityRef?: string, + externalTrx?: Knex.Transaction, + ): Promise { + if (this.loadPolicyPromise) { + await this.loadPolicyPromise; + } else { + await this.loadPolicy(); + } + + const addGroupingPoliciesOperation = (async () => { + if (policies.length === 0) { + return; + } + + const trx = externalTrx ?? (await this.knex.transaction()); + + try { + const currentRoleMetadata = + await this.roleMetadataStorage.findRoleMetadata( + oldRoleEntityRef ?? roleMetadata.roleEntityRef, + trx, + ); + if (currentRoleMetadata) { + await this.roleMetadataStorage.updateRoleMetadata( + mergeRoleMetadata(currentRoleMetadata, roleMetadata), + oldRoleEntityRef ?? roleMetadata.roleEntityRef, + trx, + ); + } else { + const currentDate: Date = new Date(); + roleMetadata.createdAt = currentDate.toUTCString(); + roleMetadata.lastModified = currentDate.toUTCString(); + await this.roleMetadataStorage.createRoleMetadata(roleMetadata, trx); + } + + const ok = await this.enforcer.addGroupingPolicies(policies); + if (!ok) { + throw new Error( + `Failed to store policies ${policiesToString(policies)}`, + ); + } + + if (!externalTrx) { + await trx.commit(); + } + if (!currentRoleMetadata) { + this.roleEventEmitter.emit('roleAdded', roleMetadata.roleEntityRef); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + })(); + await this.execOperation(addGroupingPoliciesOperation); + } + + async updateGroupingPolicies( + oldRole: string[][], + newRole: string[][], + newRoleMetadata: RoleMetadataDao, + ): Promise { + const oldRoleName = oldRole.at(0)?.at(1)!; + + const trx = await this.knex.transaction(); + try { + const currentMetadata = await this.roleMetadataStorage.findRoleMetadata( + oldRoleName, + trx, + ); + if (!currentMetadata) { + throw new Error(`Role metadata ${oldRoleName} was not found`); + } + + await this.removeGroupingPolicies(oldRole, currentMetadata, true, trx); + await this.addGroupingPolicies( + newRole, + newRoleMetadata, + currentMetadata.roleEntityRef, + trx, + ); + + // Role name changed -> update roleEntityRef in policies + if (newRoleMetadata.roleEntityRef !== currentMetadata.roleEntityRef) { + const oldPolicies = await this.enforcer.getFilteredPolicy( + 0, + currentMetadata.roleEntityRef, + ); + const updatedPolicies = oldPolicies.map(oldPolicy => [ + newRoleMetadata.roleEntityRef, + ...oldPolicy.slice(1), + ]); + await this.updatePolicies(oldPolicies, updatedPolicies, trx); + + const oldConditions = await this.conditionalStorage.filterConditions( + currentMetadata.roleEntityRef, + undefined, + undefined, + undefined, + undefined, + trx, + ); + for (const condition of oldConditions) { + await this.conditionalStorage.updateCondition( + condition.id, + { + ...condition, + roleEntityRef: newRoleMetadata.roleEntityRef, + }, + trx, + ); + } + } + + await trx.commit(); + } catch (err) { + await trx.rollback(err); + throw err; + } + } + + async updatePolicies( + oldPolicies: string[][], + newPolicies: string[][], + externalTrx?: Knex.Transaction, + ): Promise { + const trx = externalTrx ?? (await this.knex.transaction()); + + try { + await this.removePolicies(oldPolicies, trx); + await this.addPolicies(newPolicies, trx); + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + } + + async removePolicy(policy: string[], externalTrx?: Knex.Transaction) { + if (this.loadPolicyPromise) { + await this.loadPolicyPromise; + } else { + await this.loadPolicy(); + } + + const removePolicyOperation = (async () => { + const trx = externalTrx ?? (await this.knex.transaction()); + + try { + const ok = await this.enforcer.removePolicy(...policy); + if (!ok) { + throw new Error(`fail to delete policy ${policy}`); + } + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + })(); + await this.execOperation(removePolicyOperation); + } + + async removePolicies( + policies: string[][], + externalTrx?: Knex.Transaction, + ): Promise { + if (this.loadPolicyPromise) { + await this.loadPolicyPromise; + } else { + await this.loadPolicy(); + } + + const removePoliciesOperation = (async () => { + const trx = externalTrx ?? (await this.knex.transaction()); + + try { + const ok = await this.enforcer.removePolicies(policies); + if (!ok) { + throw new Error( + `Failed to delete policies ${policiesToString(policies)}`, + ); + } + + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + })(); + await this.execOperation(removePoliciesOperation); + } + + async removeGroupingPolicy( + policy: string[], + roleMetadata: RoleMetadataDao, + isUpdate?: boolean, + externalTrx?: Knex.Transaction, + ): Promise { + if (this.loadPolicyPromise) { + await this.loadPolicyPromise; + } else { + await this.loadPolicy(); + } + + const removeGroupingPolicyOperation = (async () => { + const trx = externalTrx ?? (await this.knex.transaction()); + const roleEntity = policy[1]; + + try { + const ok = await this.enforcer.removeGroupingPolicy(...policy); + if (!ok) { + throw new Error(`Failed to delete policy ${policyToString(policy)}`); + } + + if (!isUpdate) { + const currentRoleMetadata = + await this.roleMetadataStorage.findRoleMetadata(roleEntity, trx); + const remainingGroupPolicies = await this.getFilteredGroupingPolicy( + 1, + roleEntity, + ); + if ( + currentRoleMetadata && + remainingGroupPolicies.length === 0 && + roleEntity !== ADMIN_ROLE_NAME + ) { + await this.roleMetadataStorage.removeRoleMetadata(roleEntity, trx); + } else if (currentRoleMetadata) { + await this.roleMetadataStorage.updateRoleMetadata( + mergeRoleMetadata(currentRoleMetadata, roleMetadata), + roleEntity, + trx, + ); + } + } + + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + })(); + await this.execOperation(removeGroupingPolicyOperation); + } + + async removeGroupingPolicies( + policies: string[][], + roleMetadata: RoleMetadataDao, + isUpdate?: boolean, + externalTrx?: Knex.Transaction, + ): Promise { + if (this.loadPolicyPromise) { + await this.loadPolicyPromise; + } else { + await this.loadPolicy(); + } + + const removeGroupingPolicyOperation = (async () => { + const trx = externalTrx ?? (await this.knex.transaction()); + const roleEntity = roleMetadata.roleEntityRef; + + try { + const ok = await this.enforcer.removeGroupingPolicies(policies); + if (!ok) { + throw new Error( + `Failed to delete grouping policies: ${policiesToString(policies)}`, + ); + } + + if (!isUpdate) { + const currentRoleMetadata = + await this.roleMetadataStorage.findRoleMetadata(roleEntity, trx); + const remainingGroupPolicies = await this.getFilteredGroupingPolicy( + 1, + roleEntity, + ); + + if ( + currentRoleMetadata && + remainingGroupPolicies.length === 0 && + roleEntity !== ADMIN_ROLE_NAME + ) { + await this.roleMetadataStorage.removeRoleMetadata(roleEntity, trx); + } else if (currentRoleMetadata) { + await this.roleMetadataStorage.updateRoleMetadata( + mergeRoleMetadata(currentRoleMetadata, roleMetadata), + roleEntity, + trx, + ); + } + } + + if (!externalTrx) { + await trx.commit(); + } + } catch (err) { + if (!externalTrx) { + await trx.rollback(err); + } + throw err; + } + })(); + await this.execOperation(removeGroupingPolicyOperation); + } + + /** + * enforce aims to enforce a particular permission policy based on the user that it receives. + * Under the hood, enforce uses the `enforce` method from the enforcer`. + * + * Before enforcement, a filter is set up to reduce the number of permission policies that will + * be loaded in. + * This will reduce the amount of checks that need to be made to determine if a user is authorize + * to perform an action + * + * A temporary enforcer will also be used while enforcing. + * This is to ensure that the filter does not interact with the base enforcer. + * The temporary enforcer has lazy loading of the permission policies enabled to reduce the amount + * of time it takes to initialize the temporary enforcer. + * The justification for lazy loading is because permission policies are already present in the + * role manager / database and it will be filtered and loaded whenever `getFilteredPolicy` is called + * and permissions / roles are applied to the temp enforcer + * @param entityRef The user to enforce + * @param resourceType The resource type / name of the permission policy + * @param action The action of the permission policy + * @param roles Any roles that the user is directly or indirectly attached to. + * Used for filtering permission policies. + * @returns True if the user is allowed based on the particular permission + */ + async enforce( + entityRef: string, + resourceType: string, + action: string, + roles: string[], + ): Promise { + const model = newModelFromString(MODEL); + let policies: string[][] = []; + if (roles.length > 0) { + for (const role of roles) { + const filteredPolicy = await this.getFilteredPolicy( + 0, + role, + resourceType, + action, + ); + policies.push(...filteredPolicy); + } + } else { + const enforcePolicies = await this.getFilteredPolicy( + 1, + resourceType, + action, + ); + policies = enforcePolicies.filter( + policy => + policy[0].startsWith('user:') || policy[0].startsWith('group:'), + ); + } + + const roleManager = this.enforcer.getRoleManager(); + const tempEnforcer = new Enforcer(); + + model.addPolicies('p', 'p', policies); + + await tempEnforcer.initWithModelAndAdapter(model); + tempEnforcer.setRoleManager(roleManager); + await tempEnforcer.buildRoleLinks(); + + return await tempEnforcer.enforce(entityRef, resourceType, action); + } + + async getImplicitPermissionsForUser(user: string): Promise { + if (this.loadPolicyPromise) { + await this.loadPolicyPromise; + } else { + await this.loadPolicy(); + } + + const getPermissionsForUserOperation = (async () => { + return this.enforcer.getImplicitPermissionsForUser(user); + })(); + + return await this.execOperation(getPermissionsForUserOperation); + } + + async getAllRoles(): Promise { + if (this.loadPolicyPromise) { + await this.loadPolicyPromise; + } else { + await this.loadPolicy(); + } + + const getRolesOperation = (async () => { + return this.enforcer.getAllRoles(); + })(); + + return await this.execOperation(getRolesOperation); + } +} diff --git a/plugins/rbac-backend/src/service/extendable-id-provider.test.ts b/plugins/rbac-backend/src/service/extendable-id-provider.test.ts new file mode 100644 index 0000000000..5cf63f5fa6 --- /dev/null +++ b/plugins/rbac-backend/src/service/extendable-id-provider.test.ts @@ -0,0 +1,138 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices } from '@backstage/backend-test-utils'; +import { + permissionDependentPluginStoreMock, + pluginIdProviderMock, +} from '../../__fixtures__/mock-utils'; +import { ExtendablePluginIdProvider } from './extendable-id-provider'; +import { Config } from '@backstage/config'; + +describe('ExtendableIdProvider', () => { + let config: Config; + + function createProvider(): ExtendablePluginIdProvider { + return new ExtendablePluginIdProvider( + permissionDependentPluginStoreMock, + pluginIdProviderMock, + config, + ); + } + + beforeEach(() => { + ( + permissionDependentPluginStoreMock.getPlugins as jest.Mock + ).mockResolvedValueOnce([]); + config = mockServices.rootConfig({ + data: { + permission: { + enabled: true, + rbac: { + pluginsWithPermission: ['argocd'], + }, + }, + }, + }); + }); + + it('should create an instance of ExtendableIdProvider', () => { + const extendableIdProvider = createProvider(); + expect(extendableIdProvider).toBeInstanceOf(ExtendablePluginIdProvider); + }); + + it('should return plugin ids only from application config', async () => { + const extendableIdProvider = createProvider(); + const pluginIds = await extendableIdProvider.getPluginIds(); + expect(pluginIds.length).toEqual(1); + expect(pluginIds).toContain('argocd'); + }); + + it('should merge plugin ids from application config and pluginIdProvider', async () => { + pluginIdProviderMock.getPluginIds.mockReturnValueOnce(['jenkins']); + + const extendableIdProvider = createProvider(); + const pluginIds = await extendableIdProvider.getPluginIds(); + expect(pluginIds.length).toEqual(2); + expect(pluginIds).toContain('argocd'); + expect(pluginIds).toContain('jenkins'); + }); + + it('should merge plugin ids from application config, pluginIdProvider and db storage', async () => { + (permissionDependentPluginStoreMock.getPlugins as jest.Mock).mockReset(); + ( + permissionDependentPluginStoreMock.getPlugins as jest.Mock + ).mockResolvedValueOnce([{ pluginId: 'scaffolder' }]); + pluginIdProviderMock.getPluginIds.mockReturnValueOnce(['jenkins']); + + const extendableIdProvider = createProvider(); + const pluginIds = await extendableIdProvider.getPluginIds(); + expect(pluginIds.length).toEqual(3); + expect(pluginIds).toContain('argocd'); + expect(pluginIds).toContain('jenkins'); + expect(pluginIds).toContain('scaffolder'); + }); + + it('should merge plugin ids from application config, pluginIdProvider and db storage without duplication', async () => { + (permissionDependentPluginStoreMock.getPlugins as jest.Mock).mockReset(); + ( + permissionDependentPluginStoreMock.getPlugins as jest.Mock + ).mockResolvedValueOnce([{ pluginId: 'jenkins' }]); + pluginIdProviderMock.getPluginIds.mockReturnValueOnce(['jenkins']); + + const extendableIdProvider = createProvider(); + const pluginIds = await extendableIdProvider.getPluginIds(); + expect(pluginIds.length).toEqual(2); + expect(pluginIds).toContain('argocd'); + expect(pluginIds).toContain('jenkins'); + }); + + it('should detect if plugin id is configured', () => { + (permissionDependentPluginStoreMock.getPlugins as jest.Mock).mockReset(); + ( + permissionDependentPluginStoreMock.getPlugins as jest.Mock + ).mockResolvedValueOnce([{ pluginId: 'scaffolder' }]); + pluginIdProviderMock.getPluginIds.mockReturnValueOnce(['jenkins']); + + const extendableIdProvider = createProvider(); + let isConfiguredPluginId = + extendableIdProvider.isConfiguredPluginId('argocd'); + expect(isConfiguredPluginId).toBe(true); + + isConfiguredPluginId = extendableIdProvider.isConfiguredPluginId('jenkins'); + expect(isConfiguredPluginId).toBe(true); + + isConfiguredPluginId = + extendableIdProvider.isConfiguredPluginId('scaffolder'); + expect(isConfiguredPluginId).toBe(false); + }); + + it('should remove conflicted plugin id, which came from database', async () => { + (permissionDependentPluginStoreMock.getPlugins as jest.Mock).mockReset(); + ( + permissionDependentPluginStoreMock.getPlugins as jest.Mock + ).mockResolvedValueOnce([{ pluginId: 'scaffolder' }]); + pluginIdProviderMock.getPluginIds.mockReturnValueOnce([ + 'jenkins', + 'scaffolder', + ]); + + const extendableIdProvider = createProvider(); + await extendableIdProvider.handleConflictedPluginIds(); + expect( + permissionDependentPluginStoreMock.deletePlugins, + ).toHaveBeenCalledWith(['scaffolder']); + }); +}); diff --git a/plugins/rbac-backend/src/service/extendable-id-provider.ts b/plugins/rbac-backend/src/service/extendable-id-provider.ts new file mode 100644 index 0000000000..32bb383f97 --- /dev/null +++ b/plugins/rbac-backend/src/service/extendable-id-provider.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { PluginIdProvider } from '@backstage-community/plugin-rbac-node'; +import { PermissionDependentPluginStore } from '../database/extra-permission-enabled-plugins-storage'; +import type { Config } from '@backstage/config'; +import { union } from 'lodash'; + +export class ExtendablePluginIdProvider { + // plugin ids which came from application config and PluginIdProvider + private readonly configurationPluginIds: string[]; + + constructor( + private readonly pluginStore: PermissionDependentPluginStore, + pluginIdProvider: PluginIdProvider, + config: Config, + ) { + const pluginIdsConfig = config.getOptionalStringArray( + 'permission.rbac.pluginsWithPermission', + ); + this.configurationPluginIds = pluginIdsConfig + ? union(pluginIdsConfig, pluginIdProvider.getPluginIds()) + : pluginIdProvider.getPluginIds(); + } + + isConfiguredPluginId(pluginId: string): boolean { + return this.configurationPluginIds.includes(pluginId); + } + + async handleConflictedPluginIds(): Promise { + const conflictedIds = await ( + await this.pluginStore.getPlugins() + ).filter(pId => this.configurationPluginIds.includes(pId.pluginId)); + await this.pluginStore.deletePlugins(conflictedIds.map(p => p.pluginId)); + } + + async getPluginIds(): Promise { + const extraPlugins = await this.pluginStore.getPlugins(); + return union( + this.configurationPluginIds, + extraPlugins.map(plugin => plugin.pluginId), + ); + } +} diff --git a/plugins/rbac-backend/src/service/permission-definition-routes.test.ts b/plugins/rbac-backend/src/service/permission-definition-routes.test.ts new file mode 100644 index 0000000000..40b7614947 --- /dev/null +++ b/plugins/rbac-backend/src/service/permission-definition-routes.test.ts @@ -0,0 +1,452 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + credentials, + extendablePluginIdProviderMock, + mockAuditorService, + mockAuthService, + mockedAuthorize, + mockedAuthorizeConditional, + mockHttpAuth, + mockPermissionEvaluator, + permissionDependentPluginStoreMock, + pluginMetadataCollectorMock, +} from '../../__fixtures__/mock-utils'; +import { registerPermissionDefinitionRoutes } from './permission-definition-routes'; +import express from 'express'; +import { PluginMetadataResponseSerializedRule } from './plugin-endpoints'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { + PluginPermissionMetaData, + policyEntityCreatePermission, + policyEntityDeletePermission, + policyEntityReadPermission, +} from '@backstage-community/plugin-rbac-common'; +import request from 'supertest'; +import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter'; +import { mockServices } from '@backstage/backend-test-utils'; +import { ExtendablePluginIdProvider } from './extendable-id-provider'; +import Router from 'express-promise-router'; + +describe('REST plugin policies metadata API', () => { + let app: express.Express; + + beforeEach(async () => { + const router = Router(); + + router.use(express.json()); + + registerPermissionDefinitionRoutes( + router, + pluginMetadataCollectorMock as any, + extendablePluginIdProviderMock as ExtendablePluginIdProvider, + permissionDependentPluginStoreMock, + { + auth: mockAuthService, + httpAuth: mockHttpAuth, + auditor: mockAuditorService, + permissions: mockPermissionEvaluator, + }, + ); + + const middleware = MiddlewareFactory.create({ + logger: mockServices.logger.mock(), + config: mockServices.rootConfig(), + }); + router.use(middleware.error()); + + app = express().use(router); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('list plugin permissions and condition rules API', () => { + it('should return list plugins permission', async () => { + const pluginMetadata: PluginPermissionMetaData[] = [ + { + pluginId: 'permissions', + policies: [ + { + name: 'catalog.entity.read', + resourceType: 'policy-entity', + policy: 'read', + }, + ], + }, + ]; + pluginMetadataCollectorMock.getPluginPolicies = jest + .fn() + .mockImplementation(async () => { + return pluginMetadata; + }); + const result = await request(app).get('/plugins/policies').send(); + expect(result.statusCode).toEqual(200); + expect(result.body).toEqual(pluginMetadata); + }); + + it('should return a status of Unauthorized for /plugins/policies', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app).get('/plugins/policies').send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityReadPermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should return list plugins condition rules', async () => { + const rules: PluginMetadataResponseSerializedRule[] = [ + { + pluginId: 'catalog', + rules: [ + { + description: 'Allow entities with the specified label', + name: 'HAS_LABEL', + paramsSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + label: { + description: 'Name of the label to match on', + type: 'string', + }, + }, + required: ['label'], + type: 'object', + }, + resourceType: 'catalog-entity', + }, + ], + }, + ]; + pluginMetadataCollectorMock.getPluginConditionRules = jest + .fn() + .mockImplementation(async () => { + return rules; + }); + const result = await request(app).get('/plugins/condition-rules').send(); + expect(result.statusCode).toEqual(200); + expect(result.body).toEqual(rules); + }); + + it('should return a status of Unauthorized for /plugins/condition-rules', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app).get('/plugins/condition-rules').send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityReadPermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + }); + + describe('plugin ids API', () => { + it('should return a status of Unauthorized for /plugins/id GET', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app).get('/plugins/id').send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityReadPermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + }); + + it('should return list plugin ids object /plugins/id GET', async () => { + const result = await request(app).get('/plugins/id').send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityReadPermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(200); + expect(result.body).toBeDefined(); + expect(result.body.ids).toContain('catalog'); + }); + + it('should return a status of Unauthorized for /plugins/id POST', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app) + .post('/plugins/id') + .send({ ids: ['catalog'] }); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: policyEntityCreatePermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should add more plugin ids with help of /plugins/id POST', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); + (extendablePluginIdProviderMock.getPluginIds as jest.Mock) + .mockResolvedValueOnce(['jenkins', 'catalog']) + .mockResolvedValueOnce(['jenkins', 'catalog', 'scaffolder']); + + const result = await request(app) + .post('/plugins/id') + .send({ ids: ['scaffolder'] }); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: policyEntityCreatePermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(permissionDependentPluginStoreMock.addPlugins).toHaveBeenCalledWith([ + { pluginId: 'scaffolder' }, + ]); + expect(result.statusCode).toBe(200); + expect(result.body).toBeDefined(); + expect(result.body.ids).toContain('jenkins'); + expect(result.body.ids).toContain('catalog'); + expect(result.body.ids).toContain('scaffolder'); + }); + + it('should fail to add more plugin ids, because of ConflictError', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); + ( + extendablePluginIdProviderMock.getPluginIds as jest.Mock + ).mockResolvedValueOnce(['jenkins', 'catalog', 'scaffolder']); + + const result = await request(app) + .post('/plugins/id') + .send({ ids: ['scaffolder'] }); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: policyEntityCreatePermission, + }, + ], + { + credentials: credentials, + }, + ); + expect( + permissionDependentPluginStoreMock.addPlugins, + ).not.toHaveBeenCalledWith([{ pluginId: 'scaffolder' }]); + expect(result.statusCode).toBe(409); + expect(result.body).toEqual({ + error: { + message: + 'Plugin IDs ["scaffolder"] already exist in the system. Please use a different set of plugin ids.', + name: 'ConflictError', + }, + request: { + method: 'POST', + url: '/plugins/id', + }, + response: { statusCode: 409 }, + }); + }); + + it('should return a status of Unauthorized for /plugins/id DELETE', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app).delete('/plugins/id').send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityDeletePermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should delete plugin id with help of /plugins/id DELETE', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); + (extendablePluginIdProviderMock.getPluginIds as jest.Mock) + .mockResolvedValueOnce(['jenkins', 'sonarqube', 'catalog']) + .mockResolvedValueOnce(['jenkins', 'sonarqube']); + + const result = await request(app) + .delete('/plugins/id') + .send({ ids: ['catalog'] }); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityDeletePermission, + }, + ], + { + credentials: credentials, + }, + ); + expect( + permissionDependentPluginStoreMock.deletePlugins, + ).toHaveBeenCalledWith(['catalog']); + expect(result.statusCode).toBe(200); + expect(result.body).toBeDefined(); + expect(result.body.ids).toContain('jenkins'); + expect(result.body.ids).toContain('sonarqube'); + expect(result.body.ids).not.toContain('catalog'); + }); + + it('should fail to delete plugin id with NotFoundError', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); + const result = await request(app) + .delete('/plugins/id') + .send({ ids: ['jenkins'] }); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityDeletePermission, + }, + ], + { + credentials: credentials, + }, + ); + expect( + permissionDependentPluginStoreMock.deletePlugins, + ).not.toHaveBeenCalledWith(['jenkins']); + expect(result.statusCode).toBe(404); + expect(result.body).toEqual({ + error: { + message: 'Plugin IDs ["jenkins"] were not found.', + name: 'NotFoundError', + }, + request: { + method: 'DELETE', + url: '/plugins/id', + }, + response: { statusCode: 404 }, + }); + }); + + it('should fail to deletion plugin id, because it was configured', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); + ( + extendablePluginIdProviderMock.isConfiguredPluginId as jest.Mock + ).mockReturnValueOnce(true); + const result = await request(app) + .delete('/plugins/id') + .send({ ids: ['jenkins'] }); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityDeletePermission, + }, + ], + { + credentials: credentials, + }, + ); + expect( + permissionDependentPluginStoreMock.deletePlugins, + ).not.toHaveBeenCalledWith(['jenkins']); + expect(result.statusCode).toBe(403); + expect(result.body).toEqual({ + error: { + message: + 'Plugin IDs ["jenkins"] can be removed only with help of configuration.', + name: 'NotAllowedError', + }, + request: { + method: 'DELETE', + url: '/plugins/id', + }, + response: { statusCode: 403 }, + }); + }); +}); diff --git a/plugins/rbac-backend/src/service/permission-definition-routes.ts b/plugins/rbac-backend/src/service/permission-definition-routes.ts new file mode 100644 index 0000000000..7081430f84 --- /dev/null +++ b/plugins/rbac-backend/src/service/permission-definition-routes.ts @@ -0,0 +1,166 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + PermissionDependentPluginList, + policyEntityCreatePermission, + policyEntityDeletePermission, + policyEntityReadPermission, +} from '@backstage-community/plugin-rbac-common'; +import { logAuditorEvent } from '../auditor/rest-interceptor'; +import { PluginPermissionMetadataCollector } from './plugin-endpoints'; +import { + PermissionDependentPluginDTO, + PermissionDependentPluginStore, +} from '../database/extra-permission-enabled-plugins-storage'; +import { authorizeConditional } from './policies-rest-api'; +import { + AuditorService, + AuthService, + HttpAuthService, + PermissionsService, +} from '@backstage/backend-plugin-api'; +import { ExtendablePluginIdProvider } from './extendable-id-provider'; +import { + ConflictError, + NotAllowedError, + NotFoundError, +} from '@backstage/errors'; +import { validatePermissionDependentPlugin } from '../validation/plugin-validation'; +import express from 'express'; + +export function registerPermissionDefinitionRoutes( + router: express.Router, + pluginPermMetaData: PluginPermissionMetadataCollector, + pluginIdProvider: ExtendablePluginIdProvider, + extraPluginsIdStorage: PermissionDependentPluginStore, + deps: { + auth: AuthService; + httpAuth: HttpAuthService; + auditor: AuditorService; + permissions: PermissionsService; + }, +) { + const { auth, auditor } = deps; + + router.get( + '/plugins/policies', + logAuditorEvent(auditor), + async (request, response) => { + await authorizeConditional(request, policyEntityReadPermission, deps); + + const body = await pluginPermMetaData.getPluginPolicies(auth); + + response.json(body); + }, + ); + + router.get( + '/plugins/condition-rules', + logAuditorEvent(auditor), + async (request, response) => { + await authorizeConditional(request, policyEntityReadPermission, deps); + + const body = await pluginPermMetaData.getPluginConditionRules(auth); + + response.json(body); + }, + ); + + router.get( + '/plugins/id', + logAuditorEvent(auditor), + async (request, response) => { + await authorizeConditional(request, policyEntityReadPermission, deps); + + const actualPluginIds = await pluginIdProvider.getPluginIds(); + response.status(200).json(pluginIdsToResponse(actualPluginIds)); + }, + ); + + router.post( + '/plugins/id', + logAuditorEvent(auditor), + async (request, response) => { + await authorizeConditional(request, policyEntityCreatePermission, deps); + const pluginIds: PermissionDependentPluginList = request.body; + validatePermissionDependentPlugin(pluginIds); + const pluginDtos = permissionDependentPluginListToDTO(pluginIds); + + let actualPluginIds = await pluginIdProvider.getPluginIds(); + const conflictedIds = pluginIds.ids.filter(id => + actualPluginIds.includes(id), + ); + if (conflictedIds.length > 0) { + throw new ConflictError( + `Plugin IDs ${JSON.stringify(conflictedIds)} already exist in the system. Please use a different set of plugin ids.`, + ); + } + await extraPluginsIdStorage.addPlugins(pluginDtos); + response.locals.meta = pluginIds; + + actualPluginIds = await pluginIdProvider.getPluginIds(); + response.status(200).json(pluginIdsToResponse(actualPluginIds)); + }, + ); + + router.delete( + '/plugins/id', + logAuditorEvent(auditor), + async (request, response) => { + await authorizeConditional(request, policyEntityDeletePermission, deps); + const pluginIds: PermissionDependentPluginList = request.body; + validatePermissionDependentPlugin(pluginIds); + const configuredPluginIds = pluginIds.ids.filter(pluginId => + pluginIdProvider.isConfiguredPluginId(pluginId), + ); + if (configuredPluginIds.length > 0) { + throw new NotAllowedError( + `Plugin IDs ${JSON.stringify(pluginIds.ids)} can be removed only with help of configuration.`, + ); + } + + let actualPluginIds = await pluginIdProvider.getPluginIds(); + const notFoundPlugins = pluginIds.ids.filter( + pluginToDel => !actualPluginIds.includes(pluginToDel), + ); + if (notFoundPlugins.length > 0) { + throw new NotFoundError( + `Plugin IDs ${JSON.stringify(notFoundPlugins)} were not found.`, + ); + } + + await extraPluginsIdStorage.deletePlugins(pluginIds.ids); + response.locals.meta = pluginIds; + + actualPluginIds = await pluginIdProvider.getPluginIds(); + response.status(200).json(pluginIdsToResponse(actualPluginIds)); + }, + ); +} + +export function pluginIdsToResponse( + pluginIds: string[], +): PermissionDependentPluginList { + return { ids: pluginIds }; +} + +export function permissionDependentPluginListToDTO( + pluginList: PermissionDependentPluginList, +): PermissionDependentPluginDTO[] { + return pluginList.ids.map(pluginId => { + return { pluginId }; + }); +} diff --git a/plugins/rbac-backend/src/service/permission-model.ts b/plugins/rbac-backend/src/service/permission-model.ts new file mode 100644 index 0000000000..e322a3f63d --- /dev/null +++ b/plugins/rbac-backend/src/service/permission-model.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export const MODEL = ` +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act, eft + +[policy_effect] +e = some(where (p.eft == allow)) && !some(where (p.eft == deny)) + +[role_definition] +g = _, _ + +[matchers] +m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act +`; diff --git a/plugins/rbac-backend/src/service/plugin-endpoint.test.ts b/plugins/rbac-backend/src/service/plugin-endpoint.test.ts new file mode 100644 index 0000000000..a5acd31ec8 --- /dev/null +++ b/plugins/rbac-backend/src/service/plugin-endpoint.test.ts @@ -0,0 +1,543 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices } from '@backstage/backend-test-utils'; +import { NotFoundError } from '@backstage/errors'; + +import { PluginPermissionMetadataCollector } from './plugin-endpoints'; +import { policyEntityPermissions } from '@backstage-community/plugin-rbac-common'; +import { rbacRules } from '../permissions'; +import { extendablePluginIdProviderMock } from '../../__fixtures__/mock-utils'; +import { ExtendablePluginIdProvider } from './extendable-id-provider'; + +describe('plugin-endpoint', () => { + const mockPluginEndpointDiscovery = mockServices.discovery.mock({ + getBaseUrl: async (pluginId: string) => { + return `https://localhost:7007/api/${pluginId}`; + }, + }); + + const fetchMock = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + fetchMock.mockReset(); + global.fetch = fetchMock as any; + }); + + afterAll(() => { + // clean up global pollution + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (global as any).fetch; + }); + + describe('Test list plugin policies', () => { + it('should return empty plugin policies list', async () => { + // asserts that when a plugin’s well-known endpoint is missing (404) + // the collector returns an empty policies list instead of throwing. + fetchMock.mockRejectedValueOnce(new NotFoundError()); + + const collector = new PluginPermissionMetadataCollector({ + deps: { + discovery: mockPluginEndpointDiscovery, + pluginIdProvider: + extendablePluginIdProviderMock as ExtendablePluginIdProvider, + logger: mockServices.logger.mock(), + config: mockServices.rootConfig(), + }, + }); + const policiesMetadata = await collector.getPluginPolicies( + mockServices.auth(), + ); + + expect(policiesMetadata.length).toEqual(0); + }); + + it('should return non empty plugin policies list with resourced permission', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => { + return { + permissions: [ + { + type: 'resource', + name: 'catalog.entity.read', + attributes: { action: 'read' }, + resourceType: 'catalog-entity', + }, + ], + }; + }, + } as any); + + const collector = new PluginPermissionMetadataCollector({ + deps: { + discovery: mockPluginEndpointDiscovery, + pluginIdProvider: + extendablePluginIdProviderMock as ExtendablePluginIdProvider, + logger: mockServices.logger.mock(), + config: mockServices.rootConfig(), + }, + }); + const policiesMetadata = await collector.getPluginPolicies( + mockServices.auth(), + ); + + expect(policiesMetadata.length).toEqual(1); + expect(policiesMetadata[0].pluginId).toEqual('catalog'); + expect(policiesMetadata[0].policies).toEqual([ + { + name: 'catalog.entity.read', + resourceType: 'catalog-entity', + policy: 'read', + }, + ]); + }); + + it('should return non empty plugin policies list with non resourced permission', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => { + return { + permissions: [ + { + type: 'basic', + name: 'catalog.entity.create', + attributes: { action: 'create' }, + }, + ], + }; + }, + } as any); + + const collector = new PluginPermissionMetadataCollector({ + deps: { + discovery: mockPluginEndpointDiscovery, + pluginIdProvider: + extendablePluginIdProviderMock as ExtendablePluginIdProvider, + logger: mockServices.logger.mock(), + config: mockServices.rootConfig(), + }, + }); + const policiesMetadata = await collector.getPluginPolicies( + mockServices.auth(), + ); + + expect(policiesMetadata.length).toEqual(1); + expect(policiesMetadata[0].pluginId).toEqual('catalog'); + expect(policiesMetadata[0].policies).toEqual([ + { + name: 'catalog.entity.create', + policy: 'create', + }, + ]); + }); + + it('should log warning for not found endpoint', async () => { + ( + extendablePluginIdProviderMock.getPluginIds as jest.Mock + ).mockReturnValueOnce(['catalog', 'unknown-plugin-id']); + + fetchMock.mockImplementation(async (wellKnownURL: string) => { + if ( + wellKnownURL === + 'https://localhost:7007/api/catalog/.well-known/backstage/permissions/metadata' + ) { + return { + ok: true, + json: async () => { + return { + permissions: [ + { + type: 'resource', + resourceType: 'catalog-entity', + name: 'catalog.entity.read', + attributes: { action: 'read' }, + }, + ], + }; + }, + } as any; + } + + throw new NotFoundError(); + }); + + const logger = mockServices.logger.mock(); + const errorSpy = jest.spyOn(logger, 'warn').mockClear(); + const collector = new PluginPermissionMetadataCollector({ + deps: { + discovery: mockPluginEndpointDiscovery, + pluginIdProvider: + extendablePluginIdProviderMock as ExtendablePluginIdProvider, + logger, + config: mockServices.rootConfig(), + }, + }); + const policiesMetadata = await collector.getPluginPolicies( + mockServices.auth(), + ); + + expect(policiesMetadata.length).toEqual(1); + expect(policiesMetadata[0].pluginId).toEqual('catalog'); + expect(policiesMetadata[0].policies).toEqual([ + { + name: 'catalog.entity.read', + resourceType: 'catalog-entity', + policy: 'read', + }, + ]); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'No permission metadata found for unknown-plugin-id. NotFoundError', + ), + ); + }); + + it('should log error when it is not possible to retrieve permission metadata for known endpoint', async () => { + ( + extendablePluginIdProviderMock.getPluginIds as jest.Mock + ).mockResolvedValueOnce(['scaffolder', 'catalog']); + + fetchMock.mockImplementation(async (wellKnownURL: string) => { + if ( + wellKnownURL === + 'https://localhost:7007/api/scaffolder/.well-known/backstage/permissions/metadata' + ) { + return { + ok: true, + json: async () => { + return { + permissions: [ + { + type: 'resource', + resourceType: 'scaffolder-template', + name: 'scaffolder.template.parameter.read', + attributes: { action: 'read' }, + }, + ], + }; + }, + } as any; + } + + throw new Error('Unexpected error'); + }); + + const logger = mockServices.logger.mock(); + const errorSpy = jest.spyOn(logger, 'error').mockClear(); + const collector = new PluginPermissionMetadataCollector({ + deps: { + discovery: mockPluginEndpointDiscovery, + pluginIdProvider: + extendablePluginIdProviderMock as ExtendablePluginIdProvider, + logger, + config: mockServices.rootConfig(), + }, + }); + + const policiesMetadata = await collector.getPluginPolicies( + mockServices.auth(), + ); + + expect(policiesMetadata.length).toEqual(1); + expect(policiesMetadata[0].pluginId).toEqual('scaffolder'); + expect(policiesMetadata[0].policies).toEqual([ + { + name: 'scaffolder.template.parameter.read', + resourceType: 'scaffolder-template', + policy: 'read', + }, + ]); + + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to retrieve permission metadata for catalog. Error: Unexpected error', + ); + }); + + it('should not log error caused by non json permission metadata for known endpoint', async () => { + ( + extendablePluginIdProviderMock.getPluginIds as jest.Mock + ).mockReturnValueOnce(['scaffolder', 'catalog']); + fetchMock.mockImplementation(async (wellKnownURL: string) => { + if ( + wellKnownURL === + 'https://localhost:7007/api/scaffolder/.well-known/backstage/permissions/metadata' + ) { + return { + ok: true, + json: async () => { + return { + permissions: [ + { + type: 'resource', + resourceType: 'scaffolder-template', + name: 'scaffolder.template.parameter.read', + attributes: { action: 'read' }, + }, + ], + }; + }, + } as any; + } + + if ( + wellKnownURL === + 'https://localhost:7007/api/catalog/.well-known/backstage/permissions/metadata' + ) { + return { + ok: true, + json: async () => { + throw new Error('invalid json'); + }, + } as any; + } + + throw new Error('Unexpected error'); + }); + + const logger = mockServices.logger.mock(); + const errorSpy = jest.spyOn(logger, 'error').mockClear(); + + const collector = new PluginPermissionMetadataCollector({ + deps: { + discovery: mockPluginEndpointDiscovery, + pluginIdProvider: + extendablePluginIdProviderMock as ExtendablePluginIdProvider, + logger, + config: mockServices.rootConfig(), + }, + }); + const policiesMetadata = await collector.getPluginPolicies( + mockServices.auth(), + ); + + expect(policiesMetadata.length).toEqual(1); + expect(policiesMetadata[0].pluginId).toEqual('scaffolder'); + expect(policiesMetadata[0].policies).toEqual([ + { + name: 'scaffolder.template.parameter.read', + resourceType: 'scaffolder-template', + policy: 'read', + }, + ]); + + // workaround for https://issues.redhat.com/browse/RHIDP-1456 + expect(errorSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Test list plugin condition rules', () => { + it('should return empty condition rule list', async () => { + ( + extendablePluginIdProviderMock.getPluginIds as jest.Mock + ).mockReturnValueOnce([]); + + const collector = new PluginPermissionMetadataCollector({ + deps: { + discovery: mockPluginEndpointDiscovery, + pluginIdProvider: + extendablePluginIdProviderMock as ExtendablePluginIdProvider, + logger: mockServices.logger.mock(), + config: mockServices.rootConfig(), + }, + }); + const conditionRulesMetadata = await collector.getPluginConditionRules( + mockServices.auth(), + ); + + expect(conditionRulesMetadata.length).toEqual(0); + }); + + it('should return non empty condition rule list', async () => { + ( + extendablePluginIdProviderMock.getPluginIds as jest.Mock + ).mockReturnValueOnce(['catalog']); + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => { + return { + rules: [ + { + description: 'Allow entities with the specified label', + name: 'HAS_LABEL', + paramsSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + label: { + description: 'Name of the label to match on', + type: 'string', + }, + }, + required: ['label'], + type: 'object', + }, + resourceType: 'catalog-entity', + }, + ], + }; + }, + } as any); + + const collector = new PluginPermissionMetadataCollector({ + deps: { + discovery: mockPluginEndpointDiscovery, + pluginIdProvider: + extendablePluginIdProviderMock as ExtendablePluginIdProvider, + logger: mockServices.logger.mock(), + config: mockServices.rootConfig(), + }, + }); + const conditionRulesMetadata = await collector.getPluginConditionRules( + mockServices.auth(), + ); + + expect(conditionRulesMetadata.length).toEqual(1); + expect(conditionRulesMetadata[0].pluginId).toEqual('catalog'); + expect(conditionRulesMetadata[0].rules).toEqual([ + { + description: 'Allow entities with the specified label', + name: 'HAS_LABEL', + paramsSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + label: { + description: 'Name of the label to match on', + type: 'string', + }, + }, + required: ['label'], + type: 'object', + }, + resourceType: 'catalog-entity', + }, + ]); + }); + }); + + describe('Test get plugin metadata by id', () => { + it('should return metadata by id', async () => { + ( + extendablePluginIdProviderMock.getPluginIds as jest.Mock + ).mockReturnValueOnce(['catalog']); + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => { + return { + permissions: [ + { + type: 'resource', + name: 'catalog.entity.read', + attributes: { action: 'read' }, + resourceType: 'catalog-entity', + }, + ], + rules: [ + { + description: 'Allow entities with the specified label', + name: 'HAS_LABEL', + paramsSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + label: { + description: 'Name of the label to match on', + type: 'string', + }, + }, + required: ['label'], + type: 'object', + }, + resourceType: 'catalog-entity', + }, + ], + }; + }, + } as any); + + const collector = new PluginPermissionMetadataCollector({ + deps: { + discovery: mockPluginEndpointDiscovery, + pluginIdProvider: + extendablePluginIdProviderMock as ExtendablePluginIdProvider, + logger: mockServices.logger.mock(), + config: mockServices.rootConfig(), + }, + }); + const metadata = await collector.getMetadataByPluginId( + 'catalog', + undefined, + ); + + expect(metadata).not.toBeUndefined(); + expect(metadata?.permissions).toEqual([ + { + name: 'catalog.entity.read', + attributes: { action: 'read' }, + type: 'resource', + resourceType: 'catalog-entity', + }, + ]); + expect(metadata?.rules).toEqual([ + { + description: 'Allow entities with the specified label', + name: 'HAS_LABEL', + paramsSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + label: { + description: 'Name of the label to match on', + type: 'string', + }, + }, + required: ['label'], + type: 'object', + }, + resourceType: 'catalog-entity', + }, + ]); + }); + + it('should return metadata by id (rbac-plugin)', async () => { + ( + extendablePluginIdProviderMock.getPluginIds as jest.Mock + ).mockReturnValue(['permission']); + + const collector = new PluginPermissionMetadataCollector({ + deps: { + discovery: mockPluginEndpointDiscovery, + pluginIdProvider: + extendablePluginIdProviderMock as ExtendablePluginIdProvider, + logger: mockServices.logger.mock(), + config: mockServices.rootConfig(), + }, + }); + const metadata = await collector.getMetadataByPluginId( + 'permission', + undefined, + ); + + expect(metadata).not.toBeUndefined(); + expect(metadata?.permissions).toEqual(policyEntityPermissions); + expect(metadata?.rules).toEqual([rbacRules]); + }); + }); +}); diff --git a/plugins/rbac-backend/src/service/plugin-endpoints.ts b/plugins/rbac-backend/src/service/plugin-endpoints.ts new file mode 100644 index 0000000000..d1edb8851e --- /dev/null +++ b/plugins/rbac-backend/src/service/plugin-endpoints.ts @@ -0,0 +1,207 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { + AuthService, + DiscoveryService, + LoggerService, +} from '@backstage/backend-plugin-api'; +import type { Config } from '@backstage/config'; +import { isError } from '@backstage/errors'; +import { + isResourcePermission, + Permission, + type MetadataResponse, + type MetadataResponseSerializedRule, +} from '@backstage/plugin-permission-common'; + +import { + policyEntityPermissions, + type PluginPermissionMetaData, + type PolicyDetails, +} from '@backstage-community/plugin-rbac-common'; +import { rbacRules } from '../permissions'; +import { ExtendablePluginIdProvider } from './extendable-id-provider'; + +type PluginMetadataResponse = { + pluginId: string; + metaDataResponse: MetadataResponse; +}; + +export type PluginMetadataResponseSerializedRule = { + pluginId: string; + rules: MetadataResponseSerializedRule[]; +}; + +const rbacPermissionMetadata: MetadataResponse = { + permissions: policyEntityPermissions, + rules: [rbacRules], +}; + +export class PluginPermissionMetadataCollector { + private readonly pluginIdProvider: ExtendablePluginIdProvider; + private readonly discovery: DiscoveryService; + private readonly logger: LoggerService; + + constructor({ + deps, + }: { + deps: { + discovery: DiscoveryService; + pluginIdProvider: ExtendablePluginIdProvider; + logger: LoggerService; + config: Config; + }; + }) { + const { discovery, logger, pluginIdProvider } = deps; + this.discovery = discovery; + this.pluginIdProvider = pluginIdProvider; + this.logger = logger; + } + + async getPluginConditionRules( + auth: AuthService, + ): Promise { + const pluginMetadata = await this.getPluginMetaData(auth); + + return pluginMetadata + .filter(metadata => metadata.metaDataResponse.rules.length > 0) + .map(metadata => { + return { + pluginId: metadata.pluginId, + rules: metadata.metaDataResponse.rules, + }; + }); + } + + async getPluginPolicies( + auth: AuthService, + ): Promise { + const pluginMetadata = await this.getPluginMetaData(auth); + + return pluginMetadata + .filter(metadata => metadata.metaDataResponse.permissions !== undefined) + .map(metadata => { + return { + pluginId: metadata.pluginId, + policies: permissionsToCasbinPolicies( + metadata.metaDataResponse.permissions!, + ), + }; + }); + } + + private async getPluginMetaData( + auth: AuthService, + ): Promise { + let pluginResponses: PluginMetadataResponse[] = []; + + const pluginIds = await this.pluginIdProvider.getPluginIds(); + for (const pluginId of pluginIds) { + try { + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: await auth.getOwnServiceCredentials(), + targetPluginId: pluginId, + }); + + const permMetaData = await this.getMetadataByPluginId(pluginId, token); + if (permMetaData) { + pluginResponses = [ + ...pluginResponses, + { + metaDataResponse: permMetaData, + pluginId, + }, + ]; + } + } catch (error) { + this.logger.error( + `Failed to retrieve permission metadata for ${pluginId}. ${error}`, + ); + } + } + + return pluginResponses; + } + + async getMetadataByPluginId( + pluginId: string, + token: string | undefined, + ): Promise { + let permMetaData: MetadataResponse | undefined; + + // Work around: This is needed for start up whenever a conditional policy for the plugin permission in the yaml file + // will make a check to the well known endpoint + // However, our plugin has not completely started and as such will throw a 503 error + // TODO: see if we are able to remove this after we migrate to the permission registry + if (pluginId === 'permission') { + return rbacPermissionMetadata; + } + + try { + const baseEndpoint = await this.discovery.getBaseUrl(pluginId); + const wellKnownURL = `${baseEndpoint}/.well-known/backstage/permissions/metadata`; + + const response = await fetch(wellKnownURL, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch metadata for ${pluginId}: ${response.status}`, + ); + } + + try { + permMetaData = await response.json(); + } catch (err) { + // workaround for https://issues.redhat.com/browse/RHIDP-1456 + return undefined; + } + } catch (err) { + if (isError(err) && err.name === 'NotFoundError') { + this.logger.warn( + `No permission metadata found for ${pluginId}. ${err}`, + ); + return undefined; + } + this.logger.error( + `Failed to retrieve permission metadata for ${pluginId}. ${err}`, + ); + } + return permMetaData; + } +} + +function permissionsToCasbinPolicies( + permissions: Permission[], +): PolicyDetails[] { + const policies: PolicyDetails[] = []; + for (const permission of permissions) { + if (isResourcePermission(permission)) { + policies.push({ + resourceType: permission.resourceType, + name: permission.name, + policy: permission.attributes.action || 'use', + }); + } else { + policies.push({ + name: permission.name, + policy: permission.attributes.action || 'use', + }); + } + } + + return policies; +} diff --git a/plugins/rbac-backend/src/service/policies-rest-api.conditions.test.ts b/plugins/rbac-backend/src/service/policies-rest-api.conditions.test.ts new file mode 100644 index 0000000000..adec90a81a --- /dev/null +++ b/plugins/rbac-backend/src/service/policies-rest-api.conditions.test.ts @@ -0,0 +1,993 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter'; +import { mockServices } from '@backstage/backend-test-utils'; +import { + AuthorizeResult, + MetadataResponse, +} from '@backstage/plugin-permission-common'; + +import express from 'express'; + +import { + PermissionAction, + PermissionInfo, + RoleConditionalPolicyDecision, +} from '@backstage-community/plugin-rbac-common'; + +import { EnforcerDelegate } from './enforcer-delegate'; +import { PluginPermissionMetadataCollector } from './plugin-endpoints'; +import { PoliciesServer } from './policies-rest-api'; +import { RBACRouterOptions } from './policy-builder'; +import { + mockAuditorService, + conditionalStorageMock, + credentials, + enforcerDelegateMock, + mockAuthService, + mockedAuthorize, + mockHttpAuth, + mockLoggerService, + pluginMetadataCollectorMock, + roleMetadataStorageMock, + mockPermissionRegistry, + permissionDependentPluginStoreMock, + extendablePluginIdProviderMock, +} from '../../__fixtures__/mock-utils'; +import request from 'supertest'; +import { RoleMetadataDao } from '../database/role-metadata'; +import { RBACFilters } from '../permissions/rules'; +import { ExtendablePluginIdProvider } from './extendable-id-provider'; + +jest.setTimeout(60000); + +jest.mock('@backstage/plugin-auth-node', () => ({ + getBearerTokenFromAuthorizationHeader: () => 'token', +})); + +const validateRoleConditionMock = jest.fn().mockImplementation(); +jest.mock('../validation/condition-validation', () => { + return { + validateRoleCondition: jest + .fn() + .mockImplementation( + (condition: RoleConditionalPolicyDecision) => { + validateRoleConditionMock(condition); + }, + ), + }; +}); + +jest.mock('../permissions/conditions', () => { + return { + conditionTransformerFunc: () => + jest.fn().mockReturnValue({ + anyOf: [{ key: 'owners', values: ['user:default/mock'] }], + }), + }; +}); + +const mockedAuthorizeConditional = jest.fn().mockImplementation(async () => [ + { + conditions: { + anyOf: [ + { + rule: 'IS_OWNER', + resourceType: 'policy-entity', + params: [{ owners: ['user:default/mock'] }], + }, + ], + }, + result: AuthorizeResult.CONDITIONAL, + }, +]); + +const mockPermissionEvaluator = { + authorize: mockedAuthorize, + authorizeConditional: mockedAuthorizeConditional, +}; + +const conditions: RoleConditionalPolicyDecision[] = [ + { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: [{ name: 'catalog.entity.read', action: 'read' }], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }, + { + id: 2, + pluginId: 'catalog', + roleEntityRef: 'role:default/guest', + resourceType: 'catalog-entity', + permissionMapping: [{ name: 'catalog.entity.read', action: 'read' }], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }, +]; + +const expectedConditions: RoleConditionalPolicyDecision[] = [ + { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: ['read'], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }, +]; + +describe('REST policies api with conditions', () => { + let app: express.Express; + + const config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + permission: { + enabled: true, + }, + }, + }); + + let server: PoliciesServer; + + beforeEach(async () => { + mockHttpAuth.credentials = jest.fn().mockImplementation(() => credentials); + enforcerDelegateMock.getFilteredGroupingPolicy = jest + .fn() + .mockImplementation( + async (_fieldIndex: number, ..._fieldValues: string[]) => { + return [['group:default/test', 'role:default/test']]; + }, + ); + + enforcerDelegateMock.getFilteredPolicy = jest + .fn() + .mockImplementation( + async (_fieldIndex: number, ...fieldValues: string[]) => { + if (fieldValues.length === 1) { + return [ + ['role:default/test', 'policy-entity', 'create', 'allow'], + ['role:default/test', 'policy-entity', 'read', 'allow'], + ]; + } + + if (fieldValues.length > 1) { + return [['role:default/test', 'policy-entity', 'read', 'allow']]; + } + + return []; + }, + ); + + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async (roleEntityRef: string): Promise => { + const owner = + roleEntityRef === 'role:default/test' ? 'user:default/mock' : ''; + return { + source: 'rest', + roleEntityRef: roleEntityRef, + modifiedBy: 'user:default/some_user', + owner, + }; + }, + ); + + roleMetadataStorageMock.filterForOwnerRoleMetadata = jest + .fn() + .mockImplementation( + async (filter?: RBACFilters): Promise => { + if (filter && 'anyOf' in filter) { + return [ + { + source: 'rest', + roleEntityRef: 'role:default/test', + modifiedBy: 'user:default/some_user', + owner: 'user:default/mock', + }, + ]; + } + + return [ + { + source: 'rest', + roleEntityRef: 'role:default/permission_admin', + modifiedBy: 'user:default/some_user', + owner: '', + }, + { + source: 'rest', + roleEntityRef: 'role:default/test', + modifiedBy: 'user:default/some_user', + owner: 'user:default/mock', + }, + ]; + }, + ); + + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[2] === 'update') { + return false; + } + return true; + }); + + conditionalStorageMock.filterConditions = jest + .fn() + .mockImplementation( + async ( + _roleEntityRef: string, + pluginId: string, + resourceType: string, + ) => { + if (resourceType === 'catalog-entity' || pluginId === 'catalog') { + return conditions; + } + + if ( + resourceType === 'scaffolder-template' || + pluginId === 'scaffolder' + ) { + return []; + } + return conditions; + }, + ); + + pluginMetadataCollectorMock.getMetadataByPluginId = jest + .fn() + .mockImplementation(() => { + const response: MetadataResponse = { + permissions: [ + { + name: 'catalog.entity.read', + attributes: { + action: 'read', + }, + type: 'resource', + resourceType: 'catalog-entity', + }, + ], + rules: [], + }; + return response; + }); + + conditionalStorageMock.getCondition = jest + .fn() + .mockImplementation(async (id: number) => { + return conditions[id - 1]; + }); + + const options: RBACRouterOptions = { + config: config, + logger: mockLoggerService, + httpAuth: mockHttpAuth, + auth: mockAuthService, + permissionsRegistry: mockPermissionRegistry, + auditor: mockAuditorService, + permissions: mockPermissionEvaluator, + }; + + server = new PoliciesServer( + options, + enforcerDelegateMock as EnforcerDelegate, + conditionalStorageMock, + pluginMetadataCollectorMock as PluginPermissionMetadataCollector, + roleMetadataStorageMock, + permissionDependentPluginStoreMock, + extendablePluginIdProviderMock as ExtendablePluginIdProvider, + undefined, + ); + + const router = await server.serve(); + app = express().use(router); + app.use( + MiddlewareFactory.create({ logger: mockLoggerService, config }).error(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /roles', () => { + it('should be returned roles in which the user is assigned ownership', async () => { + enforcerDelegateMock.getGroupingPolicy = jest + .fn() + .mockImplementation(async () => { + return [ + ['group:default/test', 'role:default/test'], + ['group:default/team_a', 'role:default/team_a'], + ]; + }); + const result = await request(app).get('/roles').send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([ + { + memberReferences: ['group:default/test'], + name: 'role:default/test', + metadata: { + isDefault: false, + source: 'rest', + modifiedBy: 'user:default/some_user', + owner: 'user:default/mock', + }, + }, + ]); + }); + }); + + describe('GET /roles/:kind/:namespace/:name', () => { + it('should return role by role reference in which the user is an owner of', async () => { + const result = await request(app).get('/roles/role/default/test').send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([ + { + memberReferences: ['group:default/test'], + name: 'role:default/test', + metadata: { + isDefault: false, + source: 'rest', + modifiedBy: 'user:default/some_user', + owner: 'user:default/mock', + }, + }, + ]); + }); + + it('should return not found error by role reference in which the user is not an owner', async () => { + enforcerDelegateMock.getFilteredGroupingPolicy = jest + .fn() + .mockImplementation( + async (_fieldIndex: number, ..._fieldValues: string[]) => { + return [['group:default/team_a', 'role:default/team_a']]; + }, + ); + + const result = await request(app) + .get('/roles/role/default/team_a') + .send(); + expect(result.statusCode).toBe(404); + expect(result.body).toEqual({ + error: { message: '', name: 'NotFoundError' }, + request: { + method: 'GET', + url: '/roles/role/default/team_a', + }, + response: { statusCode: 404 }, + }); + }); + }); + + describe('PUT /roles/:kind/:namespace/:name', () => { + it('should fail to update role - old role not found because user is not an owner', async () => { + const result = await request(app) + .put('/roles/role/default/team_a') + .send({ + oldRole: { + memberReferences: ['group:default/team_a'], + }, + newRole: { + memberReferences: ['user:default/test'], + name: 'role:default/team_a', + }, + }); + + expect(result.statusCode).toEqual(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should update description and set owner for role that the user is an owner of', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/roles/role/default/test') + .send({ + oldRole: { + memberReferences: ['user:default/guest'], + metadata: { + source: 'rest', + description: 'some admin role.', + owner: 'user:default/mock', + }, + }, + newRole: { + memberReferences: ['user:default/guest'], + name: 'role:default/test', + metadata: { + source: 'rest', + description: 'some admin role.', + owner: 'user:default/some_user', + }, + }, + }); + + expect(result.statusCode).toEqual(200); + expect(enforcerDelegateMock.updateGroupingPolicies).toHaveBeenCalledWith( + [['user:default/guest', 'role:default/test']], + [['user:default/guest', 'role:default/test']], + { + description: 'some admin role.', + modifiedBy: 'user:default/mock', + roleEntityRef: 'role:default/test', + source: 'rest', + owner: 'user:default/some_user', + }, + ); + }); + + it.each([ + ['user:default/permission_admin', 'user:default/test'], + ['user:default/Permission_Admin', 'user:default/Test'], + ])('should update role that the user owns', async (oldUser, newUser) => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[0] === newUser.toLocaleLowerCase('en-US')) { + return false; + } + return true; + }); + enforcerDelegateMock.updateGroupingPolicies = jest + .fn() + .mockImplementation(); + + const result = await request(app) + .put('/roles/role/default/test') + .send({ + oldRole: { + memberReferences: [oldUser], + metadata: { + source: 'rest', + description: 'some admin role.', + owner: 'user:default/mock', + }, + }, + newRole: { + memberReferences: [newUser], + name: 'role:default/test', + metadata: { + source: 'rest', + description: 'some admin role.', + owner: 'user:default/mock', + }, + }, + }); + + expect(result.statusCode).toEqual(200); + expect(enforcerDelegateMock.hasGroupingPolicy).toHaveBeenNthCalledWith( + 1, + 'user:default/test', + 'role:default/test', + ); + expect(enforcerDelegateMock.hasGroupingPolicy).toHaveBeenNthCalledWith( + 2, + 'user:default/permission_admin', + 'role:default/test', + ); + }); + + it('should update role where newRole has multiple roles and where the user is an owner of', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if ( + param[0] === 'user:default/test' || + param[0] === 'user:default/test2' + ) { + return false; + } + return true; + }); + enforcerDelegateMock.updateGroupingPolicies = jest + .fn() + .mockImplementation(); + + const result = await request(app) + .put('/roles/role/default/test') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + metadata: { + source: 'rest', + description: 'some admin role.', + owner: 'user:default/mock', + }, + }, + newRole: { + memberReferences: ['user:default/test', 'user:default/test2'], + name: 'role:default/test', + metadata: { + source: 'rest', + description: 'some admin role.', + owner: 'user:default/mock', + }, + }, + }); + + expect(result.statusCode).toEqual(200); + }); + + it('should update role where newRole has multiple roles with one being from oldRole and where the user is an owner of', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[0] === 'user:default/test') { + return false; + } + return true; + }); + enforcerDelegateMock.updateGroupingPolicies = jest + .fn() + .mockImplementation(); + + const result = await request(app) + .put('/roles/role/default/test') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + metadata: { + source: 'rest', + description: 'some admin role.', + owner: 'user:default/mock', + }, + }, + newRole: { + memberReferences: [ + 'user:default/permission_admin', + 'user:default/test', + ], + name: 'role:default/test', + metadata: { + source: 'rest', + description: 'some admin role.', + owner: 'user:default/mock', + }, + }, + }); + + expect(result.statusCode).toEqual(200); + }); + + it('should update role name of a role that the user is an owner of', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[0] === 'user:default/test') { + return false; + } + return true; + }); + enforcerDelegateMock.updateGroupingPolicies = jest + .fn() + .mockImplementation(); + + const result = await request(app) + .put('/roles/role/default/test') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + metadata: { + source: 'rest', + description: 'some admin role.', + owner: 'user:default/mock', + }, + }, + newRole: { + memberReferences: ['user:default/test'], + name: 'role:default/new_name', + metadata: { + source: 'rest', + description: 'some admin role.', + owner: 'user:default/mock', + }, + }, + }); + + expect(result.statusCode).toEqual(200); + }); + }); + + describe('DELETE /roles/:kind/:namespace/:name', () => { + it('should fail to delete, because user is not an owner', async () => { + const result = await request(app) + .delete( + '/roles/role/default/team_a?memberReferences=group:default/test', + ) + .send(); + + expect(result.statusCode).toEqual(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it.each(['group:default/test', 'group:default/Test'])( + 'should delete a user / group %s from a role that the user is an owner of', + async member => { + const result = await request(app) + .delete(`/roles/role/default/test?memberReferences=${member}`) + .send(); + + expect(result.statusCode).toEqual(204); + expect( + enforcerDelegateMock.getFilteredGroupingPolicy, + ).toHaveBeenCalledWith(0, 'group:default/test', 'role:default/test'); + }, + ); + + it('should delete a role that the user is an owner of', async () => { + const result = await request(app) + .delete('/roles/role/default/test') + .send(); + + expect(result.statusCode).toEqual(204); + }); + }); + + describe('GET /policies', () => { + it('should all policies that the user owns', async () => { + const result = await request(app).get('/policies').send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([ + { + entityReference: 'role:default/test', + permission: 'policy.entity.create', + policy: 'create', + effect: 'allow', + metadata: { + source: 'rest', + }, + }, + { + entityReference: 'role:default/test', + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + metadata: { + source: 'rest', + }, + }, + ]); + }); + + it('should return filtered policies that the user owns', async () => { + const result = await request(app) + .get( + '/policies?entityRef=role:default/test&permission=policy-entity&policy=read&effect=allow', + ) + .send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([ + { + entityReference: 'role:default/test', + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + metadata: { + source: 'rest', + }, + }, + ]); + }); + + it('should be return no policies because the user is not an owner', async () => { + const result = await request(app) + .get( + '/policies?entityRef=role:default/guest&permission=policy-entity&policy=read&effect=allow', + ) + .send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([]); + }); + }); + + describe('GET /policies/:kind/:namespace/:name', () => { + it('should return permission policies by user reference that the user owns', async () => { + const result = await request(app) + .get('/policies/role/default/test') + .send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([ + { + entityReference: 'role:default/test', + permission: 'policy.entity.create', + policy: 'create', + effect: 'allow', + metadata: { + source: 'rest', + }, + }, + { + entityReference: 'role:default/test', + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + metadata: { + source: 'rest', + }, + }, + ]); + }); + + it('should not return policies by user reference not found because user does not own them', async () => { + const result = await request(app) + .get('/policies/user/default/permission_admin') + .send(); + expect(result.statusCode).toBe(404); + expect(result.body).toEqual({ + error: { message: '', name: 'NotFoundError' }, + request: { + method: 'GET', + url: '/policies/user/default/permission_admin', + }, + response: { statusCode: 404 }, + }); + }); + }); + + describe('PUT /policies/:kind/:namespace/:name', () => { + it('should fail to update policy - user does not own old policy', async () => { + const result = await request(app) + .put('/policies/role/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'create', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toEqual(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should update policy that a user owns', async () => { + enforcerDelegateMock.updatePolicies = jest.fn().mockImplementation(); + + const result = await request(app) + .put('/policies/role/default/test') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'update', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toEqual(200); + }); + }); + + describe('DELETE /policies/:kind/:namespace/:name', () => { + it('should fail to delete, because policy not found because user is not an owner', async () => { + const result = await request(app) + .delete('/policies/role/default/permission_admin') + .send([ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ]); + + expect(result.statusCode).toEqual(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should delete policy', async () => { + const result = await request(app) + .delete( + '/policies/role/default/test?permission=policy-entity&policy=read&effect=allow', + ) + .send([ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ]); + + expect(result.statusCode).toEqual(204); + }); + }); + + describe('GET /roles/conditions', () => { + it('should return all condition decisions that the user is an owner of', async () => { + const result = await request(app).get('/roles/conditions').send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual(expectedConditions); + expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); + }); + }); + + describe('GET /roles/condition/:id', () => { + it('should return condition decision by id', async () => { + conditionalStorageMock.getCondition = jest + .fn() + .mockImplementation(async (id: number) => { + if (id === 1) { + return conditions[0]; + } + return undefined; + }); + + const result = await request(app).get('/roles/conditions/1').send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual(expectedConditions[0]); + expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); + }); + + it('should return 404', async () => { + const result = await request(app).get('/roles/conditions/3').send(); + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual({ + message: '', + name: 'NotFoundError', + }); + }); + + it('should return nothing when the user is not an owner of the condition', async () => { + const result = await request(app).get('/roles/conditions/2').send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([]); + }); + }); + + describe('PUT /roles/conditions', () => { + it('should return return 403 for condition that the user is not an owner of', async () => { + const conditionDecision: RoleConditionalPolicyDecision = + { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: ['read'], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }; + const result = await request(app) + .put('/roles/conditions/2') + .send(conditionDecision); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + message: '', + name: 'NotAllowedError', + }); + }); + + it('should update condition decision that the user is an owner of', async () => { + const conditionDecision: RoleConditionalPolicyDecision = + { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: ['read'], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }; + const result = await request(app) + .put('/roles/conditions/1') + .send(conditionDecision); + + expect(validateRoleConditionMock).toHaveBeenCalledWith(conditionDecision); + + expect(result.statusCode).toBe(200); + expect(conditionalStorageMock.updateCondition).toHaveBeenCalledWith(1, { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: [ + { + action: 'read', + name: 'catalog.entity.read', + }, + ], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }); + expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); + }); + }); + + describe('DELETE /roles/conditions/:id', () => { + it('should delete condition decision by id where the user is an owner', async () => { + const result = await request(app).delete('/roles/conditions/1').send(); + + expect(result.statusCode).toEqual(204); + expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); + expect(conditionalStorageMock.deleteCondition).toHaveBeenCalled(); + }); + + it('should fail to delete condition decision by id because user is not an owner', async () => { + const result = await request(app).delete('/roles/conditions/2').send(); + + expect(result.statusCode).toEqual(403); + expect(result.body.error.message).toEqual(''); + }); + }); +}); diff --git a/plugins/rbac-backend/src/service/policies-rest-api.test.ts b/plugins/rbac-backend/src/service/policies-rest-api.test.ts new file mode 100644 index 0000000000..8703091cb9 --- /dev/null +++ b/plugins/rbac-backend/src/service/policies-rest-api.test.ts @@ -0,0 +1,4239 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter'; +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; +import { InputError } from '@backstage/errors'; +import { + AuthorizeResult, + type MetadataResponse, +} from '@backstage/plugin-permission-common'; + +import express from 'express'; +import request from 'supertest'; + +import { + PermissionAction, + PermissionInfo, + policyEntityCreatePermission, + policyEntityDeletePermission, + policyEntityReadPermission, + policyEntityUpdatePermission, + Role, + RoleConditionalPolicyDecision, + Source, +} from '@backstage-community/plugin-rbac-common'; + +import { RoleMetadataDao } from '../database/role-metadata'; +import { EnforcerDelegate } from './enforcer-delegate'; +import { + PluginMetadataResponseSerializedRule, + PluginPermissionMetadataCollector, +} from './plugin-endpoints'; +import { PoliciesServer } from './policies-rest-api'; +import { RBACRouterOptions } from './policy-builder'; +import { + mockAuditorService, + conditionalStorageMock, + credentials, + enforcerDelegateMock, + mockAuthService, + mockedAuthorizeConditional, + mockHttpAuth, + mockLoggerService, + mockPermissionEvaluator, + pluginMetadataCollectorMock, + providerMock, + roleMetadataStorageMock, + mockedAuthorize, + mockPermissionRegistry, + permissionDependentPluginStoreMock, + extendablePluginIdProviderMock, +} from '../../__fixtures__/mock-utils'; +import { ExtendablePluginIdProvider } from './extendable-id-provider'; + +jest.setTimeout(60000); + +jest.mock('@backstage/plugin-auth-node', () => ({ + getBearerTokenFromAuthorizationHeader: () => 'token', +})); + +const validateRoleConditionMock = jest.fn().mockImplementation(); +jest.mock('../validation/condition-validation', () => { + return { + validateRoleCondition: jest + .fn() + .mockImplementation( + (condition: RoleConditionalPolicyDecision) => { + validateRoleConditionMock(condition); + }, + ), + }; +}); + +const conditions: RoleConditionalPolicyDecision[] = [ + { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: [{ name: 'catalog.entity.read', action: 'read' }], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }, +]; + +const expectedConditions: RoleConditionalPolicyDecision[] = [ + { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: ['read'], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }, +]; + +const modifiedBy = 'user:default/some-admin'; + +describe('REST policies API', () => { + let app: express.Express; + let config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + permission: { + enabled: true, + }, + }, + }); + + let server: PoliciesServer; + + beforeEach(async () => { + conditionalStorageMock.filterConditions = jest + .fn() + .mockImplementation( + async ( + _roleEntityRef: string, + pluginId: string, + resourceType: string, + ) => { + if (resourceType === 'catalog-entity' || pluginId === 'catalog') { + return conditions; + } + + if ( + resourceType === 'scaffolder-template' || + pluginId === 'scaffolder' + ) { + return []; + } + return conditions; + }, + ); + + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return false; + }); + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return false; + }); + enforcerDelegateMock.getFilteredPolicy = jest + .fn() + .mockImplementation( + async (_fieldIndex: number, ..._fieldValues: string[]) => { + return [ + [ + 'user:default/permission_admin', + 'policy-entity', + 'create', + 'allow', + ], + ]; + }, + ); + enforcerDelegateMock.getFilteredGroupingPolicy = jest + .fn() + .mockImplementation( + async (_fieldIndex: number, ..._fieldValues: string[]) => { + return [['user:default/permission_admin', 'role:default/rbac_admin']]; + }, + ); + enforcerDelegateMock.removeGroupingPolicies = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + enforcerDelegateMock.addGroupingPolicies = jest.fn().mockImplementation(); + + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async (roleEntityRef: string): Promise => { + return { + source: 'rest', + roleEntityRef: roleEntityRef, + modifiedBy: 'user:default/some-user', + }; + }, + ); + + roleMetadataStorageMock.filterForOwnerRoleMetadata = jest + .fn() + .mockImplementation(async (): Promise => { + return [ + { + source: 'rest', + roleEntityRef: 'role:default/permission_admin', + modifiedBy: 'user:default/some-user', + }, + { + source: 'rest', + roleEntityRef: 'role:default/guest', + modifiedBy: 'user:default/some-user', + }, + { + source: 'rest', + roleEntityRef: 'role:default/test', + modifiedBy: 'user:default/some-user', + }, + ]; + }); + + conditionalStorageMock.getCondition = jest + .fn() + .mockImplementation(async (id: number) => { + if (id === 1) { + return conditions[0]; + } + return undefined; + }); + + mockHttpAuth.credentials = jest.fn().mockImplementation(() => credentials); + + const options: RBACRouterOptions = { + config: config, + logger: mockLoggerService, + httpAuth: mockHttpAuth, + auth: mockAuthService, + permissionsRegistry: mockPermissionRegistry, + auditor: mockAuditorService, + permissions: mockPermissionEvaluator, + }; + + server = new PoliciesServer( + options, + enforcerDelegateMock as EnforcerDelegate, + conditionalStorageMock, + pluginMetadataCollectorMock as PluginPermissionMetadataCollector, + roleMetadataStorageMock, + permissionDependentPluginStoreMock, + extendablePluginIdProviderMock as ExtendablePluginIdProvider, + undefined, + ); + const router = await server.serve(); + app = express().use(router); + app.use( + MiddlewareFactory.create({ logger: mockLoggerService, config }).error(), + ); + validateRoleConditionMock.mockReset(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should build', () => { + expect(app).toBeTruthy(); + }); + + describe('GET /', () => { + it('should return a status of Authorized', async () => { + const result = await request(app).get('/').send(); + + expect(result.status).toBe(200); + expect(result.body).toEqual({ status: 'Authorized' }); + }); + + it('should return a status of Unauthorized', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app).get('/').send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityReadPermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + }); + + describe('POST /policies', () => { + afterEach(() => { + (enforcerDelegateMock.addPolicies as jest.Mock).mockReset(); + }); + + it('should return a status of Unauthorized', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app).post('/policies').send(); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: policyEntityCreatePermission, + }, + ], + { + credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should return a status of Unauthorized - non user request', async () => { + mockHttpAuth.credentials = jest + .fn() + .mockImplementationOnce(() => mockCredentials.service()); + const result = await request(app).post('/policies').send(); + + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: `Only credential principal with type 'user' permitted to modify permissions`, + }); + }); + + it('should not be created permission policy - req body is an empty', async () => { + const result = await request(app).post('/policies').send(); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `permission policy must be present`, + }); + }); + + it('should not be created permission policy - entityReference is empty', async () => { + const result = await request(app).post('/policies').send([{}]); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid policy definition. Cause: 'entityReference' must not be empty`, + }); + }); + + it('should not be created permission policy - entityReference is invalid', async () => { + const result = await request(app) + .post('/policies') + .send([{ entityReference: 'user' }]); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid policy definition. Cause: Entity reference "user" had missing or empty kind (e.g. did not start with "component:" or similar)`, + }); + }); + + it('should not be created permission policy - permission is an empty', async () => { + const result = await request(app) + .post('/policies') + .send([ + { + entityReference: 'user:default/permission_admin', + }, + ]); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid policy definition. Cause: 'permission' field must not be empty`, + }); + }); + + it('should not be created permission policy - policy is an empty', async () => { + const result = await request(app) + .post('/policies') + .send([ + { + entityReference: 'user:default/permission_admin', + permission: 'policy-entity', + }, + ]); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid policy definition. Cause: 'policy' field must not be empty`, + }); + }); + + it('should not be created permission policy - effect is an empty', async () => { + const result = await request(app) + .post('/policies') + .send([ + { + entityReference: 'user:default/permission_admin', + permission: 'policy-entity', + policy: 'read', + }, + ]); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid policy definition. Cause: 'effect' field must not be empty`, + }); + }); + + it('should be created permission policy', async () => { + const result = await request(app) + .post('/policies') + .send([ + { + entityReference: 'user:default/permission_admin', + permission: 'policy-entity', + policy: 'delete', + effect: 'deny', + }, + ]); + + expect(result.statusCode).toBe(201); + }); + + it('should fail to create permission policy, because of source mismatch', async () => { + const roleMeta: RoleMetadataDao = { + roleEntityRef: 'user:default/permission_admin', + source: 'csv-file', + modifiedBy, + }; + + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async (roleEntityRef: string): Promise => { + if (roleEntityRef === roleMeta.roleEntityRef) { + return roleMeta; + } + return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; + }, + ); + + const result = await request(app) + .post('/policies') + .send([ + { + entityReference: 'user:default/permission_admin', + permission: 'policy-entity', + policy: 'delete', + effect: 'deny', + }, + ]); + + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: `Unable to add policy user:default/permission_admin,policy-entity,delete,deny: source does not match originating role ${ + roleMeta.roleEntityRef + }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, + }); + }); + + it('should fail to add permission policy, with original source of configuration', async () => { + const roleMeta: RoleMetadataDao = { + roleEntityRef: 'user:default/permission_admin', + source: 'configuration', + modifiedBy, + }; + + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async (roleEntityRef: string): Promise => { + if (roleEntityRef === roleMeta.roleEntityRef) { + return roleMeta; + } + return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; + }, + ); + + const result = await request(app) + .post('/policies') + .send([ + { + entityReference: 'user:default/permission_admin', + permission: 'policy-entity', + policy: 'delete', + effect: 'deny', + }, + ]); + + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: `Unable to add policy user:default/permission_admin,policy-entity,delete,deny: source does not match originating role ${ + roleMeta.roleEntityRef + }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, + }); + }); + + it('should not be created permission policy, because it is has been already present', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param.at(2) === 'read') { + return Promise.resolve(true); + } + return Promise.resolve(false); + }); + + const result = await request(app) + .post('/policies') + .send([ + { + entityReference: 'user:default/permission_admin', + permission: 'policy-entity', + policy: 'read', + effect: 'deny', + }, + { + entityReference: 'user:default/permission_admin', + permission: 'policy-entity', + policy: 'delete', + effect: 'deny', + }, + ]); + + expect(result.statusCode).toBe(409); + }); + + it('should not be created permission policy caused some unexpected error', async () => { + enforcerDelegateMock.addPolicies = jest + .fn() + .mockImplementation(async (): Promise => { + throw new Error(`Failed to add policies`); + }); + + const result = await request(app) + .post('/policies') + .send([ + { + entityReference: 'user:default/permission_admin', + permission: 'policy-entity', + policy: 'delete', + effect: 'deny', + }, + ]); + + expect(result.statusCode).toBe(500); + }); + + it('should fail to create permission policy - duplication in req body', async () => { + const result = await request(app) + .post('/policies') + .send([ + { + entityReference: 'user:default/permission_admin', + permission: 'policy-entity', + policy: 'delete', + effect: 'deny', + }, + { + entityReference: 'user:default/permission_admin', + permission: 'policy-entity', + policy: 'delete', + effect: 'deny', + }, + ]); + + expect(result.statusCode).toBe(409); + expect(result.body.error).toEqual({ + name: 'ConflictError', + message: `Duplicate polices found; user:default/permission_admin, policy-entity, delete, deny is a duplicate`, + }); + }); + }); + + describe('GET /policies/:kind/:namespace/:name', () => { + it('should return a status of Unauthorized', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app) + .get('/policies/user/default/permission_admin') + .send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityReadPermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should be returned permission policies by user reference', async () => { + enforcerDelegateMock.getFilteredPolicy = jest + .fn() + .mockImplementation( + async (_fieldIndex: number, ..._fieldValues: string[]) => { + return [ + [ + 'role:default/permission_admin', + 'policy.entity.create', + 'create', + 'allow', + ], + ]; + }, + ); + const result = await request(app) + .get('/policies/role/default/permission_admin') + .send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([ + { + entityReference: 'role:default/permission_admin', + permission: 'policy.entity.create', + policy: 'create', + effect: 'allow', + metadata: { + source: 'rest', + }, + }, + ]); + }); + + // TODO: + it('should be returned permission policies with modified `policy-entity, create` permission by user reference', async () => { + const deprecatedPolicy = [ + 'role:default/permission_admin', + 'policy-entity', + 'create', + 'allow', + ]; + enforcerDelegateMock.getFilteredPolicy = jest + .fn() + .mockImplementation( + async (_fieldIndex: number, ..._fieldValues: string[]) => { + return [ + [ + 'role:default/permission_admin', + 'policy-entity', + 'create', + 'allow', + ], + ]; + }, + ); + const result = await request(app) + .get('/policies/role/default/permission_admin') + .send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([ + { + entityReference: 'role:default/permission_admin', + permission: 'policy.entity.create', + policy: 'create', + effect: 'allow', + metadata: { + source: 'rest', + }, + }, + ]); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 1, + `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${deprecatedPolicy} to use 'policy.entity.create' instead of 'policy-entity' from source rest`, + ); + }); + + it('should be returned policies by user reference not found', async () => { + enforcerDelegateMock.getFilteredPolicy = jest + .fn() + .mockImplementation( + async (_fieldIndex: number, ..._fieldValues: string[]) => { + return []; + }, + ); + + const result = await request(app) + .get('/policies/user/default/permission_admin') + .send(); + expect(result.statusCode).toBe(404); + expect(result.body).toEqual({ + error: { message: '', name: 'NotFoundError' }, + request: { + method: 'GET', + url: '/policies/user/default/permission_admin', + }, + response: { statusCode: 404 }, + }); + }); + }); + + describe('GET /policies', () => { + it('should return a status of Unauthorized', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app).get('/policies').send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityReadPermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should be returned list all policies', async () => { + enforcerDelegateMock.getFilteredPolicy = jest + .fn() + .mockImplementation( + async (_fieldIndex: number, ...fieldValues: string[]) => { + if (fieldValues[0] === 'role:default/permission_admin') { + return [ + [ + 'role:default/permission_admin', + 'policy.entity.create', + 'create', + 'allow', + ], + ]; + } + + if (fieldValues[0] === 'role:default/guest') { + return [ + [ + 'role:default/guest', + 'policy-entity', + 'read', + 'allow', + 'rest', + ], + ]; + } + + return []; + }, + ); + const result = await request(app).get('/policies').send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([ + { + entityReference: 'role:default/permission_admin', + permission: 'policy.entity.create', + policy: 'create', + effect: 'allow', + metadata: { + source: 'rest', + }, + }, + { + entityReference: 'role:default/guest', + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + metadata: { + source: 'rest', + }, + }, + ]); + }); + + // TODO: + it('should be returned list all policies with modified `policy-entity, create` permission', async () => { + const deprecatedPolicy = [ + 'role:default/guest', + 'policy-entity', + 'create', + 'allow', + ]; + enforcerDelegateMock.getFilteredPolicy = jest + .fn() + .mockImplementation( + async (_fieldIndex: number, ...fieldValues: string[]) => { + if (fieldValues[0] === 'role:default/permission_admin') { + return [ + [ + 'role:default/permission_admin', + 'policy.entity.create', + 'create', + 'allow', + ], + ]; + } + + if (fieldValues[0] === 'role:default/guest') { + return [ + [ + 'role:default/guest', + 'policy-entity', + 'read', + 'allow', + 'rest', + ], + [ + 'role:default/guest', + 'policy-entity', + 'create', + 'allow', + 'rest', + ], + ]; + } + + return []; + }, + ); + const result = await request(app).get('/policies').send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([ + { + entityReference: 'role:default/permission_admin', + permission: 'policy.entity.create', + policy: 'create', + effect: 'allow', + metadata: { + source: 'rest', + }, + }, + { + entityReference: 'role:default/guest', + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + metadata: { + source: 'rest', + }, + }, + { + entityReference: 'role:default/guest', + permission: 'policy.entity.create', + policy: 'create', + effect: 'allow', + metadata: { + source: 'rest', + }, + }, + ]); + expect(mockLoggerService.warn).toHaveBeenNthCalledWith( + 1, + `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${deprecatedPolicy} to use 'policy.entity.create' instead of 'policy-entity' from source rest`, + ); + }); + + it('should be returned list filtered policies', async () => { + enforcerDelegateMock.getFilteredPolicy = jest + .fn() + .mockImplementation( + async (_fieldIndex: number, ..._fieldValues: string[]) => { + return [ + ['role:default/guest', 'policy-entity', 'read', 'allow', 'rest'], + ]; + }, + ); + const result = await request(app) + .get( + '/policies?entityRef=role:default/guest&permission=policy-entity&policy=read&effect=allow', + ) + .send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([ + { + entityReference: 'role:default/guest', + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + metadata: { + source: 'rest', + }, + }, + ]); + }); + }); + + describe('DELETE /policies/:kind/:namespace/:name', () => { + it('should return a status of Unauthorized', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app) + .delete('/policies/user/default/permission_admin') + .send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityDeletePermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should fail to delete, request is empty', async () => { + const result = await request(app) + .delete('/policies/user/default/permission_admin') + .send(); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `permission policy must be present`, + }); + }); + + it('should fail to delete, because permission field is absent', async () => { + const result = await request(app) + .delete('/policies/user/default/permission_admin') + .send([{}]); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid policy definition. Cause: 'permission' field must not be empty`, + }); + }); + + it('should fail to delete, because policy field is absent', async () => { + const result = await request(app) + .delete('/policies/user/default/permission_admin') + .send([ + { + permission: 'policy-entity', + }, + ]); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid policy definition. Cause: 'policy' field must not be empty`, + }); + }); + + it('should fail to delete, because effect field is absent', async () => { + const result = await request(app) + .delete('/policies/user/default/permission_admin') + .send([ + { + permission: 'policy-entity', + policy: 'read', + }, + ]); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid policy definition. Cause: 'effect' field must not be empty`, + }); + }); + + it('should fail to delete, because policy not found', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return false; + }); + + const result = await request(app) + .delete('/policies/user/default/permission_admin') + .send([ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ]); + + expect(result.statusCode).toEqual(404); + expect(result.body.error).toEqual({ + name: 'NotFoundError', + message: `Policy '[user:default/permission_admin, policy-entity, read, allow]' not found`, + }); + }); + + it('should fail to delete, because unexpected error', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + enforcerDelegateMock.removePolicies = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + throw new Error('Fail to delete policy'); + }); + + const result = await request(app) + .delete('/policies/user/default/permission_admin') + .send([ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ]); + + expect(result.statusCode).toEqual(500); + expect(result.body.error).toEqual({ + name: 'Error', + message: 'Fail to delete policy', + }); + }); + + it('should fail to delete, because source mismatch', async () => { + const roleMeta: RoleMetadataDao = { + roleEntityRef: 'user:default/permission_admin', + source: 'csv-file', + modifiedBy, + }; + + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async (roleEntityRef: string): Promise => { + if (roleEntityRef === roleMeta.roleEntityRef) { + return roleMeta; + } + return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; + }, + ); + + const result = await request(app) + .delete('/policies/user/default/permission_admin') + .send([ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ]); + + const policy = [ + 'user:default/permission_admin', + 'policy-entity', + 'read', + 'allow', + ]; + + expect(result.statusCode).toEqual(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: `Unable to delete policy ${policy}: source does not match originating role ${ + roleMeta.roleEntityRef + }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, + }); + }); + + it('should fail to delete policy, with original source of configuration', async () => { + const roleMeta: RoleMetadataDao = { + roleEntityRef: 'user:default/permission_admin', + source: 'configuration', + modifiedBy, + }; + + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async (roleEntityRef: string): Promise => { + if (roleEntityRef === roleMeta.roleEntityRef) { + return roleMeta; + } + return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; + }, + ); + + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + enforcerDelegateMock.removePolicies = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + + const result = await request(app) + .delete('/policies/user/default/permission_admin') + .send([ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ]); + + const policy = [ + 'user:default/permission_admin', + 'policy-entity', + 'read', + 'allow', + ]; + + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: `Unable to delete policy ${policy}: source does not match originating role ${ + roleMeta.roleEntityRef + }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, + }); + }); + + it('should delete policy', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + enforcerDelegateMock.removePolicies = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + + const result = await request(app) + .delete( + '/policies/user/default/permission_admin?permission=policy-entity&policy=read&effect=allow', + ) + .send([ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ]); + + expect(result.statusCode).toEqual(204); + }); + }); + + describe('PUT /policies/:kind/:namespace/:name', () => { + it('should return a status of Unauthorized', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityUpdatePermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should fail to update policy - old policy is absent', async () => { + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send([{}]); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `'oldPolicy' object must be present`, + }); + }); + + it('should fail to update policy - new policy is absent', async () => { + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ oldPolicy: [{}] }); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `'newPolicy' object must be present`, + }); + }); + + it('should fail to update policy - oldPolicy permission is absent', async () => { + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ oldPolicy: [{}], newPolicy: [{}] }); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid old policy definition. Cause: 'permission' field must not be empty`, + }); + }); + + it('should fail to update policy - oldPolicy policy is absent', async () => { + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [{ permission: 'policy-entity' }], + newPolicy: [{}], + }); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid old policy definition. Cause: 'policy' field must not be empty`, + }); + }); + + it('should fail to update policy - oldPolicy effect is absent', async () => { + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [{ permission: 'policy-entity', policy: 'read' }], + newPolicy: [{}], + }); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid old policy definition. Cause: 'effect' field must not be empty`, + }); + }); + + it('should fail to update policy - newPolicy permission is absent', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [{}], + }); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid new policy definition. Cause: 'permission' field must not be empty`, + }); + }); + + it('should fail to update policy - newPolicy policy is absent', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [{ permission: 'policy-entity' }], + }); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid new policy definition. Cause: 'policy' field must not be empty`, + }); + }); + + it('should fail to update policy - newPolicy effect is absent', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [{ permission: 'policy-entity', policy: 'create' }], + }); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid new policy definition. Cause: 'effect' field must not be empty`, + }); + }); + + it('should fail to update policy - newPolicy effect has invalid value', async () => { + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'unknown', + }, + ], + newPolicy: [{ permission: 'policy-entity', policy: 'create' }], + }); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid old policy definition. Cause: 'effect' has invalid value: 'unknown'. It should be: '${AuthorizeResult.ALLOW.toLocaleLowerCase()}' or '${AuthorizeResult.DENY.toLocaleLowerCase()}'`, + }); + }); + + it('should fail to update policy - old policy not found', async () => { + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'create', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toEqual(404); + expect(result.body.error).toEqual({ + name: 'NotFoundError', + message: `Policy '[user:default/permission_admin, policy-entity, read, allow]' not found`, + }); + }); + + it('should fail to update policy - old policy not found but old and new policies match', async () => { + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toEqual(404); + expect(result.body.error).toEqual({ + name: 'NotFoundError', + message: `Policy '[user:default/permission_admin, policy-entity, read, allow]' not found`, + }); + }); + + it('should fail to update policy - newPolicy is already present', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'create', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toEqual(409); + expect(result.body.error).toEqual({ + name: 'ConflictError', + message: `Policy '[user:default/permission_admin, policy-entity, create, allow]' has been already stored`, + }); + }); + + it('should nothing to update', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toEqual(204); + }); + + it('should nothing to update - same permissions with different policy in a different order', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + { + permission: 'policy-entity', + policy: 'delete', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'delete', + effect: 'allow', + }, + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toEqual(204); + }); + + it('should nothing to update - same permissions with different permission type in a different order', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + { + permission: 'catalog-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'catalog-entity', + policy: 'read', + effect: 'allow', + }, + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toEqual(204); + }); + + it('should fail to update policy - unable to remove oldPolicy', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[2] === 'create') { + return false; + } + return true; + }); + enforcerDelegateMock.updatePolicies = jest + .fn() + .mockImplementation(async (): Promise => { + throw new Error('Fail to remove policy'); + }); + + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'create', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toEqual(500); + expect(result.body.error).toEqual({ + name: 'Error', + message: 'Fail to remove policy', + }); + }); + + it('should fail to update policy - unable to add newPolicy', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[2] === 'create') { + return false; + } + return true; + }); + enforcerDelegateMock.updatePolicies = jest + .fn() + .mockImplementation( + async (_param: string[][], _source: Source): Promise => { + throw new Error('Fail to add policy'); + }, + ); + + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'create', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toEqual(500); + expect(result.body.error).toEqual({ + name: 'Error', + message: 'Fail to add policy', + }); + }); + + it('should update policy', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[2] === 'create') { + return false; + } + return true; + }); + enforcerDelegateMock.updatePolicies = jest.fn().mockImplementation(); + + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'create', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toEqual(200); + }); + + it('should fail to update permission policy - duplication in old policy', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[2] === 'create') { + return false; + } + return true; + }); + + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'create', + effect: 'allow', + }, + { + permission: 'policy-entity', + policy: 'create', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toBe(409); + expect(result.body.error).toEqual({ + name: 'ConflictError', + message: `Duplicate polices found; user:default/permission_admin, policy-entity, read, allow is a duplicate`, + }); + }); + + it('should fail to update permission policy - duplication in new policy', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[2] === 'update') { + return false; + } + return true; + }); + + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + { + permission: 'policy-entity', + policy: 'create', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'update', + effect: 'allow', + }, + { + permission: 'policy-entity', + policy: 'update', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toBe(409); + expect(result.body.error).toEqual({ + name: 'ConflictError', + message: `Duplicate polices found; user:default/permission_admin, policy-entity, update, allow is a duplicate`, + }); + }); + + it('should fail to update permission policy - oldPolicy has an additional permission', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + { + permission: 'policy-entity', + policy: 'create', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'delete', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `'oldPolicy' object has more permission policies compared to 'newPolicy' object`, + }); + }); + + it('should fail to update permission policy, because of source mismatch', async () => { + const roleMeta: RoleMetadataDao = { + roleEntityRef: 'user:default/permission_admin', + source: 'csv-file', + modifiedBy, + }; + + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async (roleEntityRef: string): Promise => { + if (roleEntityRef === roleMeta.roleEntityRef) { + return roleMeta; + } + return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; + }, + ); + + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + { + permission: 'policy-entity', + policy: 'create', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'delete', + effect: 'allow', + }, + ], + }); + + const policy = [ + 'user:default/permission_admin', + 'policy-entity', + 'read', + 'allow', + ]; + + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: `Unable to edit policy ${policy}: source does not match originating role ${ + roleMeta.roleEntityRef + }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, + }); + }); + + it('should fail to update permission policy, with original source of configuration', async () => { + const roleMeta: RoleMetadataDao = { + roleEntityRef: 'user:default/permission_admin', + source: 'configuration', + modifiedBy, + }; + + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async (roleEntityRef: string): Promise => { + if (roleEntityRef === roleMeta.roleEntityRef) { + return roleMeta; + } + return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; + }, + ); + + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[2] === 'delete') { + return false; + } + return true; + }); + enforcerDelegateMock.updatePolicies = jest.fn().mockImplementation(); + + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'delete', + effect: 'allow', + }, + ], + }); + + const policy = [ + 'user:default/permission_admin', + 'policy-entity', + 'read', + 'allow', + ]; + + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: `Unable to edit policy ${policy}: source does not match originating role ${ + roleMeta.roleEntityRef + }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, + }); + }); + }); + + describe('GET /roles', () => { + it('should return a status of Unauthorized', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app).get('/roles').send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityReadPermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should be returned list all roles', async () => { + enforcerDelegateMock.getGroupingPolicy = jest + .fn() + .mockImplementation(async () => { + return [ + ['group:default/test', 'role:default/test'], + ['group:default/team_a', 'role:default/team_a'], + ]; + }); + const result = await request(app).get('/roles').send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([ + { + memberReferences: ['group:default/test'], + name: 'role:default/test', + metadata: { + isDefault: false, + source: 'rest', + modifiedBy: 'user:default/some-user', + }, + }, + { + memberReferences: ['group:default/team_a'], + name: 'role:default/team_a', + metadata: { + isDefault: false, + source: 'rest', + modifiedBy: 'user:default/some-user', + }, + }, + ]); + }); + }); + + describe('GET /roles/:kind/:namespace/:name', () => { + it('should return a status of Unauthorized', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app) + .get('/roles/role/default/rbac_admin') + .send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityReadPermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should return an input error when kind is wrong', async () => { + const result = await request(app) + .get('/roles/test/default/rbac_admin') + .send(); + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Unsupported kind test. Supported value should be "role"`, + }); + }); + + it('should be returned role by role reference', async () => { + const result = await request(app) + .get('/roles/role/default/rbac_admin') + .send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([ + { + memberReferences: ['user:default/permission_admin'], + name: 'role:default/rbac_admin', + metadata: { + isDefault: false, + source: 'rest', + modifiedBy: 'user:default/some-user', + }, + }, + ]); + }); + + it('should be returned not found error by role reference', async () => { + enforcerDelegateMock.getFilteredGroupingPolicy = jest + .fn() + .mockImplementation( + async (_fieldIndex: number, ..._fieldValues: string[]) => { + return []; + }, + ); + + const result = await request(app) + .get('/roles/role/default/rbac_admin') + .send(); + expect(result.statusCode).toBe(404); + expect(result.body).toEqual({ + error: { message: '', name: 'NotFoundError' }, + request: { + method: 'GET', + url: '/roles/role/default/rbac_admin', + }, + response: { statusCode: 404 }, + }); + }); + }); + + describe('POST /roles', () => { + beforeEach(() => { + mockedAuthorizeConditional.mockImplementation(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); + }); + it('should return a status of Unauthorized', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app).post('/roles').send(); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: policyEntityCreatePermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should not be created role - req body is an empty', async () => { + const result = await request(app).post('/roles').send(); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid role definition. Cause: 'name' field must not be empty`, + }); + }); + + it('should not be created role - memberReferences is missing', async () => { + const result = await request(app).post('/roles').send({ + name: 'role:default/test', + }); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid role definition. Cause: 'memberReferences' field must not be empty`, + }); + }); + + it('should not be created role - memberReferences is empty', async () => { + const result = await request(app).post('/roles').send({ + memberReferences: [], + name: 'role:default/test', + }); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid role definition. Cause: 'memberReferences' field must not be empty`, + }); + }); + + it('should not be created role - memberReferences is invalid', async () => { + const result = await request(app) + .post('/roles') + .send({ + memberReferences: ['user'], + name: 'role:default/test', + }); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid role definition. Cause: Entity reference "user" had missing or empty kind (e.g. did not start with "component:" or similar)`, + }); + }); + + it('should not be created role - name is empty', async () => { + const result = await request(app) + .post('/roles') + .send({ + memberReferences: ['user:default/permission_admin'], + }); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid role definition. Cause: 'name' field must not be empty`, + }); + }); + + it('should not create a role - name is invalid', async () => { + const result = await request(app) + .post('/roles') + .send({ + memberReferences: ['user:default/permission_admin'], + name: 'x:default/rbac_admin', + }); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid role definition. Cause: Unsupported kind x. Supported value should be "role"`, + }); + }); + + it('should be created role', async () => { + const result = await request(app) + .post('/roles') + .send({ + memberReferences: ['user:default/permission_admin'], + name: 'role:default/some_test_role', + }); + + expect(result.statusCode).toBe(201); + expect(enforcerDelegateMock.addGroupingPolicies).toHaveBeenCalledWith( + [['user:default/permission_admin', 'role:default/some_test_role']], + { + author: 'user:default/mock', + roleEntityRef: 'role:default/some_test_role', + source: 'rest', + description: '', + modifiedBy: 'user:default/mock', + owner: 'user:default/mock', + }, + ); + }); + + it.each(['user:default/permission_admin', 'user:default/Permission_Admin'])( + `should be created role with description`, + async member => { + const result = await request(app) + .post('/roles') + .send({ + memberReferences: [member], + name: 'role:default/some_test_role', + metadata: { + description: 'some test description', + }, + }); + + expect(result.statusCode).toBe(201); + expect(enforcerDelegateMock.addGroupingPolicies).toHaveBeenCalledWith( + [['user:default/permission_admin', 'role:default/some_test_role']], + { + roleEntityRef: 'role:default/some_test_role', + source: 'rest', + author: 'user:default/mock', + description: 'some test description', + modifiedBy: 'user:default/mock', + owner: 'user:default/mock', + }, + ); + }, + ); + + it('should not be created role, because it is has been already present', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + + const result = await request(app) + .post('/roles') + .send({ + memberReferences: ['user:default/permission_admin'], + name: 'role:default/rbac_admin', + }); + + expect(result.statusCode).toBe(409); + }); + + it('should not be created role caused some unexpected error', async () => { + enforcerDelegateMock.addGroupingPolicies = jest + .fn() + .mockImplementation(async (): Promise => { + throw new Error('Fail to create new policy'); + }); + + const result = await request(app) + .post('/roles') + .send({ + memberReferences: ['user:default/permission_admin'], + name: 'role:default/rbac_admin', + }); + + expect(result.statusCode).toBe(500); + expect(result.body.error).toEqual({ + name: 'Error', + message: 'Fail to create new policy', + }); + }); + + it.each(['user:default/permission_admin', 'user:default/Permission_Admin'])( + 'should fail to create role - duplicate', + async duplicate => { + const result = await request(app) + .post('/roles') + .send({ + memberReferences: ['user:default/permission_admin', duplicate], + name: 'role:default/rbac_admin', + }); + + expect(result.statusCode).toBe(409); + expect(result.body.error).toEqual({ + name: 'ConflictError', + message: `Duplicate role members found; user:default/permission_admin, role:default/rbac_admin is a duplicate`, + }); + }, + ); + + it('should fail to add role, because source mismatch', async () => { + const roleMeta: RoleMetadataDao = { + roleEntityRef: 'role:default/rbac_admin', + source: 'configuration', + modifiedBy, + }; + + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async (roleEntityRef: string): Promise => { + if (roleEntityRef === roleMeta.roleEntityRef) { + return roleMeta; + } + return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; + }, + ); + + const result = await request(app) + .post('/roles') + .send({ + memberReferences: ['user:default/permission_admin'], + name: 'role:default/rbac_admin', + }); + + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: `Unable to add role: source does not match originating role ${ + roleMeta.roleEntityRef + }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, + }); + }); + }); + + describe('PUT /roles/:kind/:namespace/:name', () => { + it('should return a status of Unauthorized', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityUpdatePermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should fail to update role - old role is absent', async () => { + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send(); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `'oldRole' object must be present`, + }); + }); + + it('should fail to update role - new role is absent', async () => { + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ oldRole: {} }); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `'newRole' object must be present`, + }); + }); + + it('should fail to update role - oldRole entity is absent', async () => { + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ oldRole: {}, newRole: {} }); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid old role object. Cause: 'memberReferences' field must not be empty`, + }); + }); + + it('should fail to update role - newRole entity is absent', async () => { + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { memberReferences: ['user:default/permission_admin'] }, + newRole: {}, + }); + + expect(result.statusCode).toEqual(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid new role object. Cause: 'name' field must not be empty`, + }); + }); + + it('should fail to update role - old role not found', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._policy: string[]): Promise => { + return false; + }); + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/test'], + name: 'role:default/rbac_admin', + }, + }); + + expect(result.statusCode).toEqual(404); + expect(result.body.error).toEqual({ + name: 'NotFoundError', + message: + 'Member reference: user:default/permission_admin was not found for role role:default/rbac_admin', + }); + }); + + it('should fail to update role - newRole is already present', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/test'], + name: 'role:default/rbac_admin', + }, + }); + + expect(result.statusCode).toEqual(409); + expect(result.body.error).toEqual({ + name: 'ConflictError', + message: '', + }); + }); + + it('should nothing to update', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/permission_admin'], + name: 'role:default/rbac_admin', + }, + }); + + expect(result.statusCode).toEqual(204); + }); + + it('should nothing to update, because role and metadata are the same', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + metadata: { + source: 'rest', + }, + }, + newRole: { + memberReferences: ['user:default/permission_admin'], + name: 'role:default/rbac_admin', + metadata: { + source: 'rest', + }, + }, + }); + + expect(result.statusCode).toEqual(204); + }); + + it('should nothing to update, because role and metadata are the same with case insensitive member', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/Permission_Admin'], + metadata: { + source: 'rest', + }, + }, + newRole: { + memberReferences: ['user:default/permission_ADMIN'], + name: 'role:default/rbac_admin', + metadata: { + source: 'rest', + }, + }, + }); + + expect(result.statusCode).toEqual(204); + }); + + it('should nothing to update, because role and metadata are the same, but old role metadata was not send', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/permission_admin'], + name: 'role:default/rbac_admin', + metadata: { + source: 'rest', + }, + }, + }); + + expect(result.statusCode).toEqual(204); + }); + + it('should update description and set author', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/permission_admin'], + name: 'role:default/rbac_admin', + metadata: { + source: 'rest', + description: 'some admin role.', + }, + }, + }); + + expect(result.statusCode).toEqual(200); + expect(enforcerDelegateMock.updateGroupingPolicies).toHaveBeenCalledWith( + [['user:default/permission_admin', 'role:default/rbac_admin']], + [['user:default/permission_admin', 'role:default/rbac_admin']], + { + description: 'some admin role.', + modifiedBy: 'user:default/mock', + roleEntityRef: 'role:default/rbac_admin', + source: 'rest', + owner: '', + }, + ); + }); + + it('should update role and role description', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[0] === 'user:default/permission_admin') { + return true; + } + return false; + }); + + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/test', 'user:default/dev'], + name: 'role:default/rbac_admin', + metadata: { + source: 'rest', + description: 'some admin role.', + }, + }, + }); + + expect(result.statusCode).toEqual(200); + + expect(enforcerDelegateMock.updateGroupingPolicies).toHaveBeenCalledWith( + [['user:default/permission_admin', 'role:default/rbac_admin']], + [ + ['user:default/test', 'role:default/rbac_admin'], + ['user:default/dev', 'role:default/rbac_admin'], + ], + { + description: 'some admin role.', + modifiedBy: 'user:default/mock', + roleEntityRef: 'role:default/rbac_admin', + source: 'rest', + owner: '', + }, + ); + }); + + it('should fail to update policy - role metadata could not be found', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[0] === 'user:default/test') { + return false; + } + return true; + }); + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation(async (): Promise => { + return undefined; + }); + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/test'], + name: 'role:default/rbac_admin', + }, + }); + + expect(result.statusCode).toEqual(404); + expect(result.body.error).toEqual({ + name: 'NotFoundError', + message: `Unable to find metadata for role:default/rbac_admin`, + }); + }); + + it('should fail to update role - unable to remove oldRole', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[0] === 'user:default/test') { + return false; + } + return true; + }); + enforcerDelegateMock.updateGroupingPolicies = jest + .fn() + .mockImplementation(async (): Promise => { + throw new Error('Unexpected error'); + }); + + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/test'], + name: 'role:default/rbac_admin', + }, + }); + + expect(result.statusCode).toEqual(500); + expect(result.body.error).toEqual({ + name: 'Error', + message: 'Unexpected error', + }); + }); + + it('should fail to update role - unable to add newRole', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[0] === 'user:default/test') { + return false; + } + return true; + }); + enforcerDelegateMock.updateGroupingPolicies = jest + .fn() + .mockImplementation( + async (_param: string[][], _source: Source): Promise => { + throw new Error('Unexpected error'); + }, + ); + + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/test'], + name: 'role:default/rbac_admin', + }, + }); + + expect(result.statusCode).toEqual(500); + expect(result.body.error).toEqual({ + name: 'Error', + message: 'Unexpected error', + }); + }); + + it.each([ + ['user:default/permission_admin', 'user:default/test'], + ['user:default/Permission_Admin', 'user:default/Test'], + ])('should update role', async (oldUser, newUser) => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[0] === newUser.toLocaleLowerCase('en-US')) { + return false; + } + return true; + }); + enforcerDelegateMock.updateGroupingPolicies = jest + .fn() + .mockImplementation(); + + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: [oldUser], + }, + newRole: { + memberReferences: [newUser], + name: 'role:default/rbac_admin', + }, + }); + + expect(result.statusCode).toEqual(200); + expect(enforcerDelegateMock.hasGroupingPolicy).toHaveBeenNthCalledWith( + 1, + 'user:default/test', + 'role:default/rbac_admin', + ); + expect(enforcerDelegateMock.hasGroupingPolicy).toHaveBeenNthCalledWith( + 2, + 'user:default/permission_admin', + 'role:default/rbac_admin', + ); + }); + + it('should update role where newRole has multiple roles', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if ( + param[0] === 'user:default/test' || + param[0] === 'user:default/test2' + ) { + return false; + } + return true; + }); + enforcerDelegateMock.updateGroupingPolicies = jest + .fn() + .mockImplementation(); + + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/test', 'user:default/test2'], + name: 'role:default/rbac_admin', + }, + }); + + expect(result.statusCode).toEqual(200); + }); + + it('should update role where newRole has multiple roles with one being from oldRole', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[0] === 'user:default/test') { + return false; + } + return true; + }); + enforcerDelegateMock.updateGroupingPolicies = jest + .fn() + .mockImplementation(); + + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: [ + 'user:default/permission_admin', + 'user:default/test', + ], + name: 'role:default/rbac_admin', + }, + }); + + expect(result.statusCode).toEqual(200); + }); + + it('should update role name', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[0] === 'user:default/test') { + return false; + } + return true; + }); + enforcerDelegateMock.updateGroupingPolicies = jest + .fn() + .mockImplementation(); + + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/test'], + name: 'role:default/test', + }, + }); + + expect(result.statusCode).toEqual(200); + }); + + it('should fail to update role - duplicate roles in oldRole', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[0] === 'user:default/test') { + return false; + } + return true; + }); + + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: [ + 'user:default/permission_admin', + 'user:default/permission_admin', + ], + }, + newRole: { + memberReferences: ['user:default/test'], + name: 'role:default/rbac_admin', + }, + }); + + expect(result.statusCode).toBe(409); + expect(result.body.error).toEqual({ + name: 'ConflictError', + message: `Duplicate role members found; user:default/permission_admin, role:default/rbac_admin is a duplicate`, + }); + }); + + it('should fail to update role - duplicate roles in newRole', async () => { + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/test', 'user:default/test'], + name: 'role:default/rbac_admin', + }, + }); + + expect(result.statusCode).toBe(409); + expect(result.body.error).toEqual({ + name: 'ConflictError', + message: `Duplicate role members found; user:default/test, role:default/rbac_admin is a duplicate`, + }); + }); + + it('should fail to update role name when role name is invalid', async () => { + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/test'], + name: 'role:default/', + }, + }); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Invalid new role object. Cause: Entity reference "role:default/" was not on the form [:][/]`, + }); + }); + + it('should fail to update - oldRole name is invalid', async () => { + const result = await request(app) + .put('/roles/x/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/test'], + name: 'role:default/', + }, + }); + + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + name: 'InputError', + message: `Unsupported kind x. Supported value should be "role"`, + }); + }); + + it('should fail to update role, because source mismatch', async () => { + const roleMeta: RoleMetadataDao = { + roleEntityRef: 'role:default/rbac_admin', + source: 'configuration', + modifiedBy, + }; + + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async (roleEntityRef: string): Promise => { + if (roleEntityRef === roleMeta.roleEntityRef) { + return roleMeta; + } + return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; + }, + ); + + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/test'], + name: 'role:default/rbac_admin', + }, + }); + + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: `Unable to edit role: source does not match originating role ${ + roleMeta.roleEntityRef + }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, + }); + }); + }); + + describe('DELETE /roles/:kind/:namespace/:name', () => { + it('should return a status of Unauthorized', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app) + .delete('/roles/role/default/rbac_admin') + .send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityDeletePermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should fail to delete, because unexpected error', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + enforcerDelegateMock.removeGroupingPolicies = jest + .fn() + .mockImplementation( + async (_param: string[][], _source: Source): Promise => { + throw new Error('Unexpected error'); + }, + ); + enforcerDelegateMock.getFilteredGroupingPolicy = jest + .fn() + .mockImplementation( + async (_index: number, ..._filter: string[]): Promise => { + return [['group:default/test', 'role/default/rbac_admin', 'rest']]; + }, + ); + + const result = await request(app) + .delete( + '/roles/role/default/rbac_admin?memberReferences=group:default/test', + ) + .send(); + + expect(result.statusCode).toEqual(500); + expect(result.body.error).toEqual({ + name: 'Error', + message: 'Unexpected error', + }); + }); + + it('should fail to delete, because not found error', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return false; + }); + enforcerDelegateMock.getFilteredGroupingPolicy = jest + .fn() + .mockImplementation( + async (_index: number, ..._filter: string[]): Promise => { + return []; + }, + ); + + const result = await request(app) + .delete( + '/roles/role/default/rbac_admin?memberReferences=group:default/test', + ) + .send(); + + expect(result.statusCode).toEqual(404); + expect(result.body.error).toEqual({ + name: 'NotFoundError', + message: `role member 'group:default/test' was not found`, + }); + }); + + it.each(['group:default/test', 'group:default/Test'])( + 'should delete a user / group %s from a role', + async member => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + if (_param[0] === 'group:default/test') { + return true; + } + return false; + }); + enforcerDelegateMock.removeGroupingPolicies = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + enforcerDelegateMock.getFilteredGroupingPolicy = jest + .fn() + .mockImplementation( + async ( + _index: number, + ..._filter: string[] + ): Promise => { + return [ + ['group:default/test', 'role/default/rbac_admin', 'rest'], + ]; + }, + ); + + const result = await request(app) + .delete(`/roles/role/default/rbac_admin?memberReferences=${member}`) + .send(); + + expect(result.statusCode).toEqual(204); + expect( + enforcerDelegateMock.getFilteredGroupingPolicy, + ).toHaveBeenCalledWith( + 0, + 'group:default/test', + 'role:default/rbac_admin', + ); + }, + ); + + it('should delete a role', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + enforcerDelegateMock.removeGroupingPolicies = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + + const result = await request(app) + .delete('/roles/role/default/rbac_admin') + .send(); + + expect(result.statusCode).toEqual(204); + }); + + it('should fail to delete role, because source mismatch', async () => { + const roleMeta: RoleMetadataDao = { + roleEntityRef: 'role:default/rbac_admin', + source: 'configuration', + modifiedBy, + }; + + roleMetadataStorageMock.findRoleMetadata = jest + .fn() + .mockImplementation( + async (roleEntityRef: string): Promise => { + if (roleEntityRef === roleMeta.roleEntityRef) { + return roleMeta; + } + return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; + }, + ); + enforcerDelegateMock.getFilteredGroupingPolicy = jest + .fn() + .mockImplementation( + async (_index: number, ..._filter: string[]): Promise => { + return [['group:default/test', 'role/default/rbac_admin', 'rest']]; + }, + ); + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + + const result = await request(app) + .delete('/roles/role/default/rbac_admin') + .send(); + + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: `Unable to delete role: source does not match originating role ${ + roleMeta.roleEntityRef + }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, + }); + }); + }); + + describe('GetFirstQuery', () => { + it('should return an empty string for undefined query value', () => { + const result = server.getFirstQuery(undefined); + expect(result).toBe(''); + }); + + it('should return the first string value from a string array', async () => { + const queryValue = ['value1', 'value2']; + const result = server.getFirstQuery(queryValue); + expect(result).toBe('value1'); + }); + + it('should throw an InputError for an array of ParsedQs', () => { + const queryValue = [{ key: 'value' }, { key: 'value2' }]; + expect(() => server.getFirstQuery(queryValue)).toThrow(InputError); + }); + + it('should return the string value when query value is a string', () => { + const queryValue = 'singleValue'; + const result = server.getFirstQuery(queryValue); + expect(result).toBe('singleValue'); + }); + + it('should throw an InputError for ParsedQs', () => { + const queryValue = { key: 'value' }; + expect(() => server.getFirstQuery(queryValue)).toThrow(InputError); + }); + }); + + describe('transformRoleArray', () => { + it('should combine two roles together that are similar', async () => { + const roles = [ + ['group:default/test', 'role:default/test'], + ['user:default/test', 'role:default/test'], + ]; + + const expectedResult: Role[] = [ + { + memberReferences: ['group:default/test', 'user:default/test'], + name: 'role:default/test', + metadata: { + author: undefined, + createdAt: undefined, + description: undefined, + isDefault: false, + lastModified: undefined, + modifiedBy: 'user:default/some-user', + owner: undefined, + source: 'rest', + }, + }, + ]; + + const transformedRoles = await server.transformRoleArray( + undefined, + ...roles, + ); + expect(transformedRoles).toStrictEqual(expectedResult); + }); + }); + + describe('transformMemberReferencesToLowercase', () => { + it('should lowercase memberReferences', () => { + const role = { + memberReferences: [ + 'user:default/Permission_Admin', + 'group:default/TEST', + ], + name: 'role:default/Rbac_Admin', + }; + server.transformMemberReferencesToLowercase(role); + expect(role).toEqual({ + memberReferences: [ + 'user:default/permission_admin', + 'group:default/test', + ], + name: 'role:default/Rbac_Admin', + }); + }); + }); + + // Define a test suite for the GET /conditions endpoint + describe('GET /roles/conditions', () => { + it('should return a status of Unauthorized', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + + // Perform the GET request to the endpoint + const result = await request(app).get('/roles/conditions').send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityReadPermission, + }, + ], + { + credentials: credentials, + }, + ); + + // Assert the response status code and error message + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should be returned list all condition decisions', async () => { + const result = await request(app).get('/roles/conditions').send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual(expectedConditions); + expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); + }); + + it('should be returned condition decision by pluginId', async () => { + const result = await request(app) + .get('/roles/conditions?pluginId=catalog') + .send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual(expectedConditions); + }); + + it('should be returned empty condition decision list by pluginId', async () => { + const result = await request(app) + .get('/roles/conditions?pluginId=scaffolder') + .send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual([]); + }); + + it('should be returned condition decision by resourceType', async () => { + const result = await request(app) + .get('/roles/conditions?resourceType=catalog-entity') + .send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual(expectedConditions); + }); + }); + + describe('DELETE /roles/conditions/:id', () => { + it('should return a status of Unauthorized', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + + const result = await request(app).delete('/roles/conditions/1').send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityDeletePermission, + }, + ], + { + credentials: credentials, + }, + ); + + // Assert the response status code and error message + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should delete condition decision by id', async () => { + const result = await request(app).delete('/roles/conditions/1').send(); + + expect(result.statusCode).toEqual(204); + expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); + expect(conditionalStorageMock.deleteCondition).toHaveBeenCalled(); + }); + + it('should fail to delete condition decision by id', async () => { + conditionalStorageMock.deleteCondition = jest.fn(() => { + throw new Error('Failed to delete condition decision by id'); + }); + + const result = await request(app).delete('/roles/conditions/1').send(); + + expect(result.statusCode).toEqual(500); + expect(result.body.error.message).toEqual( + 'Failed to delete condition decision by id', + ); + }); + + it('should fail to delete condition decision by id 404', async () => { + const result = await request(app).delete('/roles/conditions/2').send(); + + expect(result.statusCode).toEqual(404); + expect(result.body.error.message).toEqual( + 'Condition with id 2 was not found', + ); + }); + + it('should return return 400', async () => { + const result = await request(app) + .delete('/roles/conditions/non-number') + .send(); + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + message: 'Id is not a valid number.', + name: 'InputError', + }); + }); + }); + + describe('GET /roles/condition/:id', () => { + it('should return a status of Unauthorized', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + + const result = await request(app).get('/roles/conditions/1').send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityReadPermission, + }, + ], + { + credentials: credentials, + }, + ); + + // Assert the response status code and error message + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should return condition decision by id', async () => { + const result = await request(app).get('/roles/conditions/1').send(); + expect(result.statusCode).toBe(200); + expect(result.body).toEqual(expectedConditions[0]); + expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); + }); + + it('should return return 404', async () => { + const result = await request(app).get('/roles/conditions/2').send(); + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual({ + message: '', + name: 'NotFoundError', + }); + }); + + it('should return return 400', async () => { + const result = await request(app) + .get('/roles/conditions/non-number') + .send(); + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + message: 'Id is not a valid number.', + name: 'InputError', + }); + }); + }); + + describe('POST /roles/conditions', () => { + it('should return a status of Unauthorized', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + + const result = await request(app).post('/roles/conditions').send(); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: policyEntityCreatePermission, + }, + ], + { + credentials: credentials, + }, + ); + + // Assert the response status code and error message + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should be created condition', async () => { + conditionalStorageMock.createCondition = jest + .fn() + .mockImplementation(() => { + return 1; + }); + pluginMetadataCollectorMock.getMetadataByPluginId = jest + .fn() + .mockImplementation(() => { + const response: MetadataResponse = { + permissions: [ + { + name: 'catalog.entity.read', + attributes: { + action: 'read', + }, + type: 'resource', + resourceType: 'catalog-entity', + }, + ], + rules: [], + }; + return response; + }); + + const roleCondition: RoleConditionalPolicyDecision = { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: ['read'], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }; + const result = await request(app) + .post('/roles/conditions') + .send(roleCondition); + + expect(result.statusCode).toBe(201); + expect(validateRoleConditionMock).toHaveBeenCalledWith(roleCondition); + expect(result.body).toEqual({ id: 1 }); + expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); + }); + + it('should create condition with the correct permission name for different resource types but similar actions', async () => { + conditionalStorageMock.createCondition = jest + .fn() + .mockImplementation(() => { + return 1; + }); + pluginMetadataCollectorMock.getMetadataByPluginId = jest + .fn() + .mockImplementation(() => { + const response: MetadataResponse = { + permissions: [ + { + name: 'catalog.location.read', + attributes: { + action: 'read', + }, + type: 'resource', + resourceType: 'catalog-location', + }, + { + name: 'catalog.entity.read', + attributes: { + action: 'read', + }, + type: 'resource', + resourceType: 'catalog-entity', + }, + ], + rules: [], + }; + return response; + }); + + const roleCondition: RoleConditionalPolicyDecision = { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: ['read'], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }; + + const roleConditionToBeSaved: Partial< + RoleConditionalPolicyDecision + > & + Required< + Pick< + RoleConditionalPolicyDecision, + 'permissionMapping' + > + > = { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: [{ action: 'read', name: 'catalog.entity.read' }], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }; + + const result = await request(app) + .post('/roles/conditions') + .send(roleCondition); + + expect(result.statusCode).toBe(201); + expect(validateRoleConditionMock).toHaveBeenCalledWith(roleCondition); + expect(result.body).toEqual({ id: 1 }); + expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); + expect(conditionalStorageMock.createCondition).toHaveBeenCalledWith( + roleConditionToBeSaved, + ); + }); + + it('should create condition and set the action to use whenever there is no action', async () => { + conditionalStorageMock.createCondition = jest + .fn() + .mockImplementation(() => { + return 1; + }); + pluginMetadataCollectorMock.getMetadataByPluginId = jest + .fn() + .mockImplementation(() => { + const response: MetadataResponse = { + permissions: [ + { + name: 'catalog.location.use', + attributes: {}, + type: 'resource', + resourceType: 'catalog-location', + }, + { + name: 'catalog.entity.read', + attributes: { + action: 'read', + }, + type: 'resource', + resourceType: 'catalog-entity', + }, + ], + rules: [], + }; + return response; + }); + + const roleCondition: RoleConditionalPolicyDecision = { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-location', + permissionMapping: ['use'], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-location', + params: { claims: ['group:default/team-a'] }, + }, + }; + + const roleConditionToBeSaved: Partial< + RoleConditionalPolicyDecision + > & + Required< + Pick< + RoleConditionalPolicyDecision, + 'permissionMapping' + > + > = { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-location', + permissionMapping: [{ action: 'use', name: 'catalog.location.use' }], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-location', + params: { claims: ['group:default/team-a'] }, + }, + }; + + const result = await request(app) + .post('/roles/conditions') + .send(roleCondition); + + expect(result.statusCode).toBe(201); + expect(validateRoleConditionMock).toHaveBeenCalledWith(roleCondition); + expect(result.body).toEqual({ id: 1 }); + expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); + expect(conditionalStorageMock.createCondition).toHaveBeenCalledWith( + roleConditionToBeSaved, + ); + }); + }); + + describe('PUT /roles/conditions', () => { + it('should return a status of Unauthorized', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + + const result = await request(app).put('/roles/conditions/1').send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityUpdatePermission, + }, + ], + { + credentials: credentials, + }, + ); + + // Assert the response status code and error message + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should return return 400', async () => { + const result = await request(app) + .put('/roles/conditions/non-number') + .send(); + expect(result.statusCode).toBe(400); + expect(result.body.error).toEqual({ + message: 'Id is not a valid number.', + name: 'InputError', + }); + }); + + it('should update condition decision', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); + const conditionDecision: RoleConditionalPolicyDecision = + { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: ['read'], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }; + const result = await request(app) + .put('/roles/conditions/1') + .send(conditionDecision); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [ + { + permission: policyEntityUpdatePermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(validateRoleConditionMock).toHaveBeenCalledWith(conditionDecision); + + expect(result.statusCode).toBe(200); + expect(conditionalStorageMock.updateCondition).toHaveBeenCalledWith(1, { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: [ + { + action: 'read', + name: 'catalog.entity.read', + }, + ], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }); + expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); + }); + + it('should fail to update condition decision because old condition does not exist', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); + const conditionDecision: RoleConditionalPolicyDecision = + { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: ['read'], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }; + const result = await request(app) + .put('/roles/conditions/2') + .send(conditionDecision); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual({ + message: 'Condition with id 2 was not found', + name: 'NotFoundError', + }); + }); + }); + + describe('POST /refresh/:id', () => { + let appWithProvider: express.Express; + + beforeEach(async () => { + mockedAuthorizeConditional.mockImplementation(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); + + const options: RBACRouterOptions = { + config: config, + logger: mockLoggerService, + httpAuth: mockHttpAuth, + auth: mockAuthService, + permissionsRegistry: mockPermissionRegistry, + auditor: mockAuditorService, + permissions: mockPermissionEvaluator, + }; + + server = new PoliciesServer( + options, + enforcerDelegateMock as EnforcerDelegate, + conditionalStorageMock, + pluginMetadataCollectorMock as PluginPermissionMetadataCollector, + roleMetadataStorageMock, + permissionDependentPluginStoreMock, + extendablePluginIdProviderMock as ExtendablePluginIdProvider, + [providerMock], + ); + const router = await server.serve(); + appWithProvider = express().use(router); + appWithProvider.use( + MiddlewareFactory.create({ logger: mockLoggerService, config }).error(), + ); + }); + + it('should return a status of Unauthorized', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const result = await request(app).post('/refresh/test').send(); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: policyEntityCreatePermission, + }, + ], + { + credentials: credentials, + }, + ); + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: '', + }); + }); + + it('should return a 200 for successful refresh set', async () => { + const result = await request(appWithProvider) + .post('/refresh/testProvider') + .send(); + expect(result.statusCode).toBe(200); + }); + + it('should return a 404 when there are no rbac providers', async () => { + const result = await request(app).post('/refresh/test').send(); + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual({ + message: 'No RBAC providers were found', + name: 'NotFoundError', + }); + }); + + it('should return a 404 when the rbac provider does not exist', async () => { + const result = await request(appWithProvider) + .post('/refresh/test') + .send(); + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual({ + message: 'The RBAC provider test was not found', + name: 'NotFoundError', + }); + }); + }); + + describe('test rest API when permission framework disabled', () => { + beforeAll(() => { + config = mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + permission: { + enabled: false, + }, + }, + }); + }); + + it('should not delete policy, because permission framework was disabled', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + enforcerDelegateMock.removePolicies = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + + const result = await request(app) + .delete( + '/policies/user/default/permission_admin?permission=policy-entity&policy=read&effect=allow', + ) + .send([ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ]); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not create policies, because permission framework was disabled', async () => { + const result = await request(app).post('/policies').send(); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not return policies, because permission framework was disabled', async () => { + const result = await request(app) + .get('/policies/user/default/permission_admin') + .send(); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not update policy, because permission framework was disabled', async () => { + enforcerDelegateMock.hasPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[2] === 'create') { + return false; + } + return true; + }); + enforcerDelegateMock.updatePolicies = jest.fn().mockImplementation(); + + const result = await request(app) + .put('/policies/user/default/permission_admin') + .send({ + oldPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + newPolicy: [ + { + permission: 'policy-entity', + policy: 'read', + effect: 'allow', + }, + ], + }); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not return list all policies, because permission framework was disabled', async () => { + enforcerDelegateMock.getFilteredPolicy = jest + .fn() + .mockImplementation(async () => { + return [ + [ + 'role:default/permission_admin', + 'policy-entity', + 'create', + 'allow', + ], + ['role:default/guest', 'policy-entity', 'read', 'allow', 'rest'], + ]; + }); + const result = await request(app).get('/policies').send(); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not return list all roles, because permission framework was disabled', async () => { + enforcerDelegateMock.getGroupingPolicy = jest + .fn() + .mockImplementation(async () => { + return [ + ['group:default/test', 'role:default/test'], + ['group:default/team_a', 'role:default/team_a'], + ]; + }); + + const result = await request(app).get('/roles').send(); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not return role by role reference, because permission framework was disabled', async () => { + const result = await request(app) + .get('/roles/role/default/rbac_admin') + .send(); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not create role, because permission framework was disabled', async () => { + const result = await request(app) + .post('/roles') + .send({ + memberReferences: ['user:default/permission_admin'], + name: 'role:default/rbac_admin', + }); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not update role, because permission framework was disabled', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (...param: string[]): Promise => { + if (param[0] === 'user:default/permission_admin') { + return true; + } + return false; + }); + + const result = await request(app) + .put('/roles/role/default/rbac_admin') + .send({ + oldRole: { + memberReferences: ['user:default/permission_admin'], + }, + newRole: { + memberReferences: ['user:default/test', 'user:default/dev'], + name: 'role:default/rbac_admin', + metadata: { + source: 'rest', + description: 'some admin role.', + }, + }, + }); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not delete a role, because permission framework was disabled', async () => { + enforcerDelegateMock.hasGroupingPolicy = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + enforcerDelegateMock.removeGroupingPolicies = jest + .fn() + .mockImplementation(async (..._param: string[]): Promise => { + return true; + }); + + const result = await request(app) + .delete('/roles/role/default/rbac_admin') + .send(); + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not return list of all condition decisions, because permission framework was disabled', async () => { + const result = await request(app).get('/roles/conditions').send(); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not delete condition decision, because permission framework was disabled', async () => { + const result = await request(app).delete('/roles/conditions/1').send(); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not return condition decision by id, because permission framework was disabled', async () => { + const result = await request(app).get('/roles/conditions/1').send(); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not create condition, because permission framework was disabled', async () => { + conditionalStorageMock.createCondition = jest + .fn() + .mockImplementation(() => { + return 1; + }); + pluginMetadataCollectorMock.getMetadataByPluginId = jest + .fn() + .mockImplementation(() => { + const response: MetadataResponse = { + permissions: [ + { + name: 'catalog.entity.read', + attributes: { + action: 'read', + }, + type: 'resource', + resourceType: 'catalog-entity', + }, + { + name: 'catalog.location.read', + attributes: { + action: 'read', + }, + type: 'resource', + resourceType: 'catalog-location', + }, + ], + rules: [], + }; + return response; + }); + + const roleCondition: RoleConditionalPolicyDecision = { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: ['read'], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }; + + const result = await request(app) + .post('/roles/conditions') + .send(roleCondition); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not update condition decision, because permission framework was disabled', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.ALLOW }, + ]); + const conditionDecision: RoleConditionalPolicyDecision = + { + id: 1, + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + resourceType: 'catalog-entity', + permissionMapping: ['read'], + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { claims: ['group:default/team-a'] }, + }, + }; + + const result = await request(app) + .put('/roles/conditions/1') + .send(conditionDecision); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + + it('should not return list plugins condition rules, because permission framework was disabled', async () => { + const rules: PluginMetadataResponseSerializedRule[] = [ + { + pluginId: 'catalog', + rules: [ + { + description: 'Allow entities with the specified label', + name: 'HAS_LABEL', + paramsSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + label: { + description: 'Name of the label to match on', + type: 'string', + }, + }, + required: ['label'], + type: 'object', + }, + resourceType: 'catalog-entity', + }, + ], + }, + ]; + pluginMetadataCollectorMock.getPluginConditionRules = jest + .fn() + .mockImplementation(async () => { + return rules; + }); + + const result = await request(app).get('/plugins/condition-rules').send(); + + expect(result.statusCode).toBe(404); + expect(result.body.error).toEqual(undefined); + }); + }); +}); diff --git a/plugins/rbac-backend/src/service/policies-rest-api.ts b/plugins/rbac-backend/src/service/policies-rest-api.ts new file mode 100644 index 0000000000..600a05a513 --- /dev/null +++ b/plugins/rbac-backend/src/service/policies-rest-api.ts @@ -0,0 +1,1324 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { + AuthService, + BackstageCredentials, + BackstageServicePrincipal, + BackstageUserPrincipal, + HttpAuthService, + PermissionsService, +} from '@backstage/backend-plugin-api'; +import { + ConflictError, + InputError, + NotAllowedError, + NotFoundError, +} from '@backstage/errors'; +import { + AuthorizeResult, + BasicPermission, + PolicyDecision, + ResourcePermission, +} from '@backstage/plugin-permission-common'; + +import express, { Request } from 'express'; +import { isEmpty, isEqual } from 'lodash'; +import type { ParsedQs } from 'qs'; + +import { + policyEntityCreatePermission, + policyEntityDeletePermission, + policyEntityReadPermission, + policyEntityUpdatePermission, + type PermissionAction, + type Role, + type RoleBasedPolicy, + type RoleConditionalPolicyDecision, +} from '@backstage-community/plugin-rbac-common'; +import type { RBACProvider } from '@backstage-community/plugin-rbac-node'; + +import { setAuditorError, logAuditorEvent } from '../auditor/rest-interceptor'; +import { ConditionalStorage } from '../database/conditional-storage'; +import { + daoToMetadata, + RoleMetadataDao, + RoleMetadataStorage, +} from '../database/role-metadata'; +import { + buildRoleSourceMap, + deepSortedEqual, + isPermissionAction, + policyToString, + processConditionMapping, + matches, +} from '../helper'; +import { validateRoleCondition } from '../validation/condition-validation'; +import { + validateEntityReference, + validatePolicy, + validateRole, + validateSource, +} from '../validation/policies-validation'; +import { EnforcerDelegate } from './enforcer-delegate'; +import { PluginPermissionMetadataCollector } from './plugin-endpoints'; +import { RBACRouterOptions } from './policy-builder'; +import { conditionTransformerFunc, RBACFilters } from '../permissions'; +import { registerPermissionDefinitionRoutes } from './permission-definition-routes'; +import { PermissionDependentPluginStore } from '../database/extra-permission-enabled-plugins-storage'; +import { ExtendablePluginIdProvider } from './extendable-id-provider'; +import { createRouter } from './router'; + +export async function authorizeConditional( + request: Request, + permission: ResourcePermission<'policy-entity'> | BasicPermission, + deps: { + auth: AuthService; + httpAuth: HttpAuthService; + permissions: PermissionsService; + }, +): Promise<{ + decision: PolicyDecision; + credentials: BackstageCredentials< + BackstageUserPrincipal | BackstageServicePrincipal + >; +}> { + const { auth, httpAuth, permissions } = deps; + + const credentials = await httpAuth.credentials(request, { + allow: ['user', 'service'], + }); + + // allow service to service communication, but only with read permission + if ( + auth.isPrincipal(credentials, 'service') && + permission !== policyEntityReadPermission + ) { + throw new NotAllowedError( + `Only credential principal with type 'user' permitted to modify permissions`, + ); + } + + let decision: PolicyDecision; + if (permission.type === 'resource') { + decision = ( + await permissions.authorizeConditional([{ permission }], { + credentials, + }) + )[0]; + } else { + decision = ( + await permissions.authorize([{ permission }], { + credentials, + }) + )[0]; + } + + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError(); // 403 + } + + return { decision, credentials }; +} + +export class PoliciesServer { + constructor( + private readonly options: RBACRouterOptions, + private readonly enforcer: EnforcerDelegate, + private readonly conditionalStorage: ConditionalStorage, + private readonly pluginPermMetaData: PluginPermissionMetadataCollector, + private readonly roleMetadata: RoleMetadataStorage, + private readonly extraPluginsIdStorage: PermissionDependentPluginStore, + private readonly pluginIdProvider: ExtendablePluginIdProvider, + private readonly rbacProviders?: RBACProvider[], + ) {} + + async serve(): Promise { + const router = await createRouter(this.options); + + const { logger, auditor, auth, permissionsRegistry } = this.options; + + const defRoleMeta = this.roleMetadata.getCachedDefaultRoleMetadata(); + let defRole: Role | undefined; + if (defRoleMeta) { + defRole = { + name: defRoleMeta.roleEntityRef, + memberReferences: [], + metadata: daoToMetadata(defRoleMeta), + }; + } + + const isPluginEnabled = + this.options.config.getOptionalBoolean('permission.enabled'); + if (!isPluginEnabled) { + return router; + } + + const transformConditions = conditionTransformerFunc(permissionsRegistry); + + router.get('/', async (request, response) => { + await authorizeConditional( + request, + policyEntityReadPermission, + this.options, + ); + + response.send({ status: 'Authorized' }); + }); + + // Policy CRUD + + router.get( + '/policies', + logAuditorEvent(auditor), + async (request, response) => { + let conditionsFilter: RBACFilters | undefined; + const { decision } = await authorizeConditional( + request, + policyEntityReadPermission, + this.options, + ); + + if (decision.result === AuthorizeResult.CONDITIONAL) { + conditionsFilter = transformConditions(decision.conditions); + } + + const roleMetadata = + await this.roleMetadata.filterForOwnerRoleMetadata(conditionsFilter); + + let policies: string[][] = []; + if (this.isPolicyFilterEnabled(request)) { + const entityRef = this.getFirstQuery(request.query.entityRef); + const permission = this.getFirstQuery(request.query.permission); + const policy = this.getFirstQuery(request.query.policy); + const effect = this.getFirstQuery(request.query.effect); + + const matchedRoleName = roleMetadata.flatMap( + role => role.roleEntityRef, + ); + + const filter: string[] = [entityRef, permission, policy, effect]; + policies = matchedRoleName.includes(entityRef) + ? await this.enforcer.getFilteredPolicy(0, ...filter) + : []; + } else { + for (const role of roleMetadata) { + policies.push( + ...(await this.enforcer.getFilteredPolicy( + 0, + ...[role.roleEntityRef], + )), + ); + } + } + + const body = await this.transformPolicyArray(...policies); + // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` + body.map(policy => { + if ( + policy.permission === 'policy-entity' && + policy.policy === 'create' + ) { + policy.permission = 'policy.entity.create'; + logger.warn( + `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${[policy.entityReference, 'policy-entity', policy.policy, policy.effect]} to use 'policy.entity.create' instead of 'policy-entity' from source ${policy.metadata?.source}`, + ); + } + }); + + response.json(body); + }, + ); + + router.get( + '/policies/:kind/:namespace/:name', + logAuditorEvent(auditor), + async (request, response) => { + let conditionsFilter: RBACFilters | undefined; + const { decision } = await authorizeConditional( + request, + policyEntityReadPermission, + this.options, + ); + + if (decision.result === AuthorizeResult.CONDITIONAL) { + conditionsFilter = transformConditions(decision.conditions); + } + + const roleMetadata = + await this.roleMetadata.filterForOwnerRoleMetadata(conditionsFilter); + + const matchedRoleName = roleMetadata.flatMap(role => { + return role.roleEntityRef; + }); + + const entityRef = this.getEntityReference(request); + + const policy = matchedRoleName.includes(entityRef) + ? await this.enforcer.getFilteredPolicy(0, entityRef) + : []; + if (policy.length !== 0) { + const body = await this.transformPolicyArray(...policy); + // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` + body.map(bodyPolicy => { + if ( + bodyPolicy.permission === 'policy-entity' && + bodyPolicy.policy === 'create' + ) { + bodyPolicy.permission = 'policy.entity.create'; + logger.warn( + `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${[bodyPolicy.entityReference, 'policy-entity', bodyPolicy.policy, bodyPolicy.effect]} to use 'policy.entity.create' instead of 'policy-entity' from source ${bodyPolicy.metadata?.source}`, + ); + } + }); + + response.json(body); + } else { + throw new NotFoundError(); // 404 + } + }, + ); + + router.delete( + '/policies/:kind/:namespace/:name', + logAuditorEvent(auditor), + async (request, response) => { + let conditionsFilter: RBACFilters | undefined; + const { decision } = await authorizeConditional( + request, + policyEntityDeletePermission, + this.options, + ); + + if (decision.result === AuthorizeResult.CONDITIONAL) { + conditionsFilter = transformConditions(decision.conditions); + } + + const entityRef = this.getEntityReference(request); + + const policyRaw: RoleBasedPolicy[] = request.body; + if (isEmpty(policyRaw)) { + throw new InputError(`permission policy must be present`); // 400 + } + + policyRaw.forEach(element => { + element.entityReference = entityRef; + }); + + const processedPolicies = await this.processPolicies( + policyRaw, + true, + undefined, + conditionsFilter, + ); + + await this.enforcer.removePolicies(processedPolicies); + + response.locals.meta = { policies: processedPolicies }; // auditor + + response.status(204).end(); + }, + ); + + router.post( + '/policies', + logAuditorEvent(auditor), + async (request, response) => { + await authorizeConditional( + request, + policyEntityCreatePermission, + this.options, + ); + + const policyRaw: RoleBasedPolicy[] = request.body; + + if (isEmpty(policyRaw)) { + throw new InputError(`permission policy must be present`); // 400 + } + + const processedPolicies = await this.processPolicies( + policyRaw, + false, + undefined, + ); + + const entityRef = processedPolicies[0][0]; + const roleMetadata = + await this.roleMetadata.findRoleMetadata(entityRef); + if (entityRef.startsWith('role:default') && !roleMetadata) { + throw new Error(`Corresponding role ${entityRef} was not found`); + } + + await this.enforcer.addPolicies(processedPolicies); + + response.locals.meta = { policies: processedPolicies }; // auditor + + response.status(201).end(); + }, + ); + + router.put( + '/policies/:kind/:namespace/:name', + logAuditorEvent(auditor), + async (request, response) => { + let conditionsFilter: RBACFilters | undefined; + const { decision } = await authorizeConditional( + request, + policyEntityUpdatePermission, + this.options, + ); + + if (decision.result === AuthorizeResult.CONDITIONAL) { + conditionsFilter = transformConditions(decision.conditions); + } + + const entityRef = this.getEntityReference(request); + + const oldPolicyRaw: RoleBasedPolicy[] = request.body.oldPolicy; + if (isEmpty(oldPolicyRaw)) { + throw new InputError(`'oldPolicy' object must be present`); // 400 + } + const newPolicyRaw: RoleBasedPolicy[] = request.body.newPolicy; + if (isEmpty(newPolicyRaw)) { + throw new InputError(`'newPolicy' object must be present`); // 400 + } + + [...oldPolicyRaw, ...newPolicyRaw].forEach(element => { + element.entityReference = entityRef; + }); + + const processedOldPolicy = await this.processPolicies( + oldPolicyRaw, + true, + 'old policy', + conditionsFilter, + ); + + oldPolicyRaw.sort((a, b) => + a.permission === b.permission + ? this.nameSort(a.policy!, b.policy!) + : this.nameSort(a.permission!, b.permission!), + ); + + newPolicyRaw.sort((a, b) => + a.permission === b.permission + ? this.nameSort(a.policy!, b.policy!) + : this.nameSort(a.permission!, b.permission!), + ); + + if ( + isEqual(oldPolicyRaw, newPolicyRaw) && + !oldPolicyRaw.some(isEmpty) + ) { + response.status(204).end(); + } else if (oldPolicyRaw.length > newPolicyRaw.length) { + throw new InputError( + `'oldPolicy' object has more permission policies compared to 'newPolicy' object`, + ); + } + + const processedNewPolicy = await this.processPolicies( + newPolicyRaw, + false, + 'new policy', + conditionsFilter, + ); + + const roleMetadata = + await this.roleMetadata.findRoleMetadata(entityRef); + if (entityRef.startsWith('role:default') && !roleMetadata) { + throw new Error(`Corresponding role ${entityRef} was not found`); + } + + await this.enforcer.updatePolicies( + processedOldPolicy, + processedNewPolicy, + ); + + response.locals.meta = { policies: processedNewPolicy }; // auditor + + response.status(200).end(); + }, + ); + + // Role CRUD + + router.get( + '/roles', + logAuditorEvent(auditor), + async (request, response) => { + let conditionsFilter: RBACFilters | undefined; + const { decision } = await authorizeConditional( + request, + policyEntityReadPermission, + this.options, + ); + + if (decision.result === AuthorizeResult.CONDITIONAL) { + conditionsFilter = transformConditions(decision.conditions); + } + + const roles = await this.enforcer.getGroupingPolicy(); + const body = await this.transformRoleArray(conditionsFilter, ...roles); + + if (defRole) { + body.push(defRole); + } + + response.json(body); + }, + ); + + router.get( + '/roles/:kind/:namespace/:name', + logAuditorEvent(auditor), + async (request, response) => { + let conditionsFilter: RBACFilters | undefined; + const { decision } = await authorizeConditional( + request, + policyEntityReadPermission, + this.options, + ); + + if (decision.result === AuthorizeResult.CONDITIONAL) { + conditionsFilter = transformConditions(decision.conditions); + } + + const roleEntityRef = this.getEntityReference(request, true); + + let body: Role[]; + if (defRole && roleEntityRef === defRole.name) { + body = [defRole]; + } else { + const role = await this.enforcer.getFilteredGroupingPolicy( + 1, + roleEntityRef, + ); + body = await this.transformRoleArray(conditionsFilter, ...role); + } + if (body.length !== 0) { + response.json(body); + } else { + throw new NotFoundError(); // 404 + } + }, + ); + + router.post( + '/roles', + logAuditorEvent(auditor), + async (request, response) => { + const uniqueItems = new Set(); + const { credentials } = await authorizeConditional( + request, + policyEntityCreatePermission, + this.options, + ); + + const roleRaw: Role = request.body; + let err = validateRole(roleRaw); + if (err) { + throw new InputError( // 400 + `Invalid role definition. Cause: ${err.message}`, + ); + } + this.transformMemberReferencesToLowercase(roleRaw); + + const rMetadata = await this.roleMetadata.findRoleMetadata( + roleRaw.name, + ); + + err = await validateSource('rest', rMetadata); + if (err) { + throw new NotAllowedError(`Unable to add role: ${err.message}`); + } + + const roles = this.transformRoleToArray(roleRaw); + + for (const role of roles) { + if (await this.enforcer.hasGroupingPolicy(...role)) { + throw new ConflictError(); // 409 + } + const roleString = JSON.stringify(role); + + if (uniqueItems.has(roleString)) { + throw new ConflictError( + `Duplicate role members found; ${role.at(0)}, ${role.at( + 1, + )} is a duplicate`, + ); + } else { + uniqueItems.add(roleString); + } + } + + const modifiedBy = ( + credentials as BackstageCredentials + ).principal.userEntityRef; + const metadata: RoleMetadataDao = { + roleEntityRef: roleRaw.name, + source: 'rest', + description: roleRaw.metadata?.description ?? '', + author: modifiedBy, + modifiedBy, + owner: roleRaw.metadata?.owner ?? modifiedBy, + }; + + await this.enforcer.addGroupingPolicies(roles, metadata); + + response.locals.meta = { ...metadata, members: roles.map(gp => gp[0]) }; // auditor + + response.status(201).end(); + }, + ); + + router.put( + '/roles/:kind/:namespace/:name', + logAuditorEvent(auditor), + async (request, response) => { + const uniqueItems = new Set(); + let conditionsFilter: RBACFilters | undefined; + const { decision, credentials } = await authorizeConditional( + request, + policyEntityUpdatePermission, + this.options, + ); + + if (decision.result === AuthorizeResult.CONDITIONAL) { + conditionsFilter = transformConditions(decision.conditions); + } + + const roleEntityRef = this.getEntityReference(request, true); + + const oldRoleRaw: Role = request.body.oldRole; + + if (!oldRoleRaw) { + throw new InputError(`'oldRole' object must be present`); // 400 + } + const newRoleRaw: Role = request.body.newRole; + if (!newRoleRaw) { + throw new InputError(`'newRole' object must be present`); // 400 + } + + oldRoleRaw.name = roleEntityRef; + let err = validateRole(oldRoleRaw); + if (err) { + throw new InputError( // 400 + `Invalid old role object. Cause: ${err.message}`, + ); + } + err = validateRole(newRoleRaw); + if (err) { + throw new InputError( // 400 + `Invalid new role object. Cause: ${err.message}`, + ); + } + this.transformMemberReferencesToLowercase(oldRoleRaw); + this.transformMemberReferencesToLowercase(newRoleRaw); + + const oldRole = this.transformRoleToArray(oldRoleRaw); + const newRole = this.transformRoleToArray(newRoleRaw); + // todo shell we allow newRole with an empty array?... + + const modifiedBy = ( + credentials as BackstageCredentials + ).principal.userEntityRef; + const newMetadata: RoleMetadataDao = { + ...newRoleRaw.metadata, + source: newRoleRaw.metadata?.source ?? 'rest', + roleEntityRef: newRoleRaw.name, + modifiedBy, + owner: newRoleRaw.metadata?.owner ?? '', + }; + + const oldMetadata = + await this.roleMetadata.findRoleMetadata(roleEntityRef); + if (!oldMetadata) { + throw new NotFoundError( + `Unable to find metadata for ${roleEntityRef}`, + ); + } + + err = await validateSource('rest', oldMetadata); + if (err) { + throw new NotAllowedError(`Unable to edit role: ${err.message}`); + } + + if (!matches(daoToMetadata(oldMetadata), conditionsFilter)) { + throw new NotAllowedError(); // 403 + } + + if ( + isEqual(oldRole, newRole) && + deepSortedEqual(oldMetadata, newMetadata, [ + 'author', + 'modifiedBy', + 'createdAt', + 'lastModified', + 'owner', + ]) + ) { + // no content: old role and new role are equal and their metadata too + response.status(204).end(); + return; + } + + for (const role of newRole) { + const hasRole = oldRole.some(element => { + return isEqual(element, role); + }); + // if the role is already part of old role and is a grouping policy we want to skip returning a conflict error + // to allow for other roles to be checked and added + if (await this.enforcer.hasGroupingPolicy(...role)) { + if (!hasRole) { + throw new ConflictError(); // 409 + } + } + const roleString = JSON.stringify(role); + + if (uniqueItems.has(roleString)) { + throw new ConflictError( + `Duplicate role members found; ${role.at(0)}, ${role.at( + 1, + )} is a duplicate`, + ); + } else { + uniqueItems.add(roleString); + } + } + + uniqueItems.clear(); + for (const role of oldRole) { + if (!(await this.enforcer.hasGroupingPolicy(...role))) { + throw new NotFoundError( + `Member reference: ${role[0]} was not found for role ${roleEntityRef}`, + ); // 404 + } + const roleString = JSON.stringify(role); + + if (uniqueItems.has(roleString)) { + throw new ConflictError( + `Duplicate role members found; ${role.at(0)}, ${role.at( + 1, + )} is a duplicate`, + ); + } else { + uniqueItems.add(roleString); + } + } + + await this.enforcer.updateGroupingPolicies( + oldRole, + newRole, + newMetadata, + ); + + let message = `Updated ${oldMetadata.roleEntityRef}.`; + if (newMetadata.roleEntityRef !== oldMetadata.roleEntityRef) { + message = `${message}. Role entity reference renamed to ${newMetadata.roleEntityRef}`; + } + response.locals.meta = { + ...newMetadata, + members: newRole.map(gp => gp[0]), + }; // auditor + + response.status(200).end(); + }, + ); + + router.delete( + '/roles/:kind/:namespace/:name', + logAuditorEvent(auditor), + async (request, response) => { + let conditionsFilter: RBACFilters | undefined; + const { decision, credentials } = await authorizeConditional( + request, + policyEntityDeletePermission, + this.options, + ); + + if (decision.result === AuthorizeResult.CONDITIONAL) { + conditionsFilter = transformConditions(decision.conditions); + } + + const roleEntityRef = this.getEntityReference(request, true); + + const currentMetadata = + await this.roleMetadata.findRoleMetadata(roleEntityRef); + + if ( + !currentMetadata || + !matches(daoToMetadata(currentMetadata), conditionsFilter) + ) { + throw new NotAllowedError(); // 403 + } + + const err = await validateSource('rest', currentMetadata); + if (err) { + throw new NotAllowedError(`Unable to delete role: ${err.message}`); + } + + let roleMembers = []; + if (request.query.memberReferences) { + const memberReference = this.getFirstQuery( + request.query.memberReferences!, + ).toLocaleLowerCase('en-US'); + const gp = await this.enforcer.getFilteredGroupingPolicy( + 0, + memberReference, + roleEntityRef, + ); + if (gp.length > 0) { + roleMembers.push(gp[0]); + } else { + throw new NotFoundError( + `role member '${memberReference}' was not found`, + ); // 404 + } + } else { + roleMembers = await this.enforcer.getFilteredGroupingPolicy( + 1, + roleEntityRef, + ); + } + + for (const role of roleMembers) { + if (!(await this.enforcer.hasGroupingPolicy(...role))) { + throw new NotFoundError(`role member '${role[0]}' was not found`); + } + } + + const modifiedBy = ( + credentials as BackstageCredentials + ).principal.userEntityRef; + const metadata: RoleMetadataDao = { + roleEntityRef, + source: 'rest', + modifiedBy, + }; + + await this.enforcer.removeGroupingPolicies( + roleMembers, + metadata, + false, + ); + + response.locals.meta = { + ...metadata, + members: roleMembers.map(gp => gp[0]), + }; // auditor + + response.status(204).end(); + }, + ); + + router.get( + '/roles/conditions', + logAuditorEvent(auditor), + async (request, response) => { + let conditionsFilter: RBACFilters | undefined; + const { decision } = await authorizeConditional( + request, + policyEntityReadPermission, + this.options, + ); + + if (decision.result === AuthorizeResult.CONDITIONAL) { + conditionsFilter = transformConditions(decision.conditions); + } + + const roleMetadata = + await this.roleMetadata.filterForOwnerRoleMetadata(conditionsFilter); + + const matchedRoleName = roleMetadata.flatMap(role => { + return role.roleEntityRef; + }); + + const conditions = await this.conditionalStorage.filterConditions( + this.getFirstQuery(request.query.roleEntityRef), + this.getFirstQuery(request.query.pluginId), + this.getFirstQuery(request.query.resourceType), + this.getActionQueries(request.query.actions), + ); + + const body: RoleConditionalPolicyDecision[] = + conditions + .map(condition => { + return { + ...condition, + permissionMapping: condition.permissionMapping.map( + pm => pm.action, + ), + }; + }) + .filter(condition => { + return matchedRoleName.includes(condition.roleEntityRef); + }); + + response.json(body); + }, + ); + + router.post( + '/roles/conditions', + logAuditorEvent(auditor), + async (request, response) => { + await authorizeConditional( + request, + policyEntityCreatePermission, + this.options, + ); + + const roleConditionPolicy: RoleConditionalPolicyDecision = + request.body; + validateRoleCondition(roleConditionPolicy); + + const conditionToCreate = await processConditionMapping( + roleConditionPolicy, + this.pluginPermMetaData, + auth, + ); + + const id = + await this.conditionalStorage.createCondition(conditionToCreate); + + const body = { id: id }; + + response.locals.meta = { condition: roleConditionPolicy }; // auditor + + response.status(201).json(body); + }, + ); + + router.get( + '/roles/conditions/:id', + logAuditorEvent(auditor), + async (request, response) => { + let conditionsFilter: RBACFilters | undefined; + const { decision } = await authorizeConditional( + request, + policyEntityReadPermission, + this.options, + ); + + const id: number = parseInt(request.params.id, 10); + if (isNaN(id)) { + throw new InputError('Id is not a valid number.'); + } + + const condition = await this.conditionalStorage.getCondition(id); + if (!condition) { + throw new NotFoundError(); + } + + if (decision.result === AuthorizeResult.CONDITIONAL) { + conditionsFilter = transformConditions(decision.conditions); + } + + const roleMetadata = + await this.roleMetadata.filterForOwnerRoleMetadata(conditionsFilter); + + const matchedRoleName = roleMetadata.flatMap(role => { + return role.roleEntityRef; + }); + + const body: RoleConditionalPolicyDecision | [] = + matchedRoleName.includes(condition.roleEntityRef) + ? { + ...condition, + permissionMapping: condition.permissionMapping.map( + pm => pm.action, + ), + } + : []; + + response.json(body); + }, + ); + + router.delete( + '/roles/conditions/:id', + logAuditorEvent(auditor), + async (request, response) => { + let conditionsFilter: RBACFilters | undefined; + const { decision } = await authorizeConditional( + request, + policyEntityDeletePermission, + this.options, + ); + + if (decision.result === AuthorizeResult.CONDITIONAL) { + conditionsFilter = transformConditions(decision.conditions); + } + + const id: number = parseInt(request.params.id, 10); + if (isNaN(id)) { + throw new InputError('Id is not a valid number.'); + } + + const condition = await this.conditionalStorage.getCondition(id); + if (!condition) { + throw new NotFoundError(`Condition with id ${id} was not found`); + } + const conditionToDelete: RoleConditionalPolicyDecision = + { + ...condition, + permissionMapping: condition.permissionMapping.map(pm => pm.action), + }; + + const roleMetadata = await this.roleMetadata.findRoleMetadata( + conditionToDelete.roleEntityRef, + ); + + if ( + !roleMetadata || + !matches(daoToMetadata(roleMetadata), conditionsFilter) + ) { + throw new NotAllowedError(); // 403 + } + + await this.conditionalStorage.deleteCondition(id); + response.locals.meta = { condition: conditionToDelete }; // auditor + + response.status(204).end(); + }, + ); + + router.put( + '/roles/conditions/:id', + logAuditorEvent(auditor), + async (request, response) => { + let conditionsFilter: RBACFilters | undefined; + const { decision } = await authorizeConditional( + request, + policyEntityUpdatePermission, + this.options, + ); + + if (decision.result === AuthorizeResult.CONDITIONAL) { + conditionsFilter = transformConditions(decision.conditions); + } + + const id: number = parseInt(request.params.id, 10); + if (isNaN(id)) { + throw new InputError('Id is not a valid number.'); + } + + const condition = await this.conditionalStorage.getCondition(id); + + if (!condition) { + throw new NotFoundError(`Condition with id ${id} was not found`); + } + + const roleMetadata = await this.roleMetadata.findRoleMetadata( + condition.roleEntityRef, + ); + + if ( + !roleMetadata || + !matches(daoToMetadata(roleMetadata), conditionsFilter) + ) { + throw new NotAllowedError(); // 403 + } + + const roleConditionPolicy: RoleConditionalPolicyDecision = + request.body; + + validateRoleCondition(roleConditionPolicy); + + const conditionToUpdate = await processConditionMapping( + roleConditionPolicy, + this.pluginPermMetaData, + auth, + ); + + await this.conditionalStorage.updateCondition(id, conditionToUpdate); + + response.locals.meta = { condition: roleConditionPolicy }; // auditor + + response.status(200).end(); + }, + ); + + router.post( + '/refresh/:id', + logAuditorEvent(auditor), + async (request, response) => { + await authorizeConditional( + request, + policyEntityCreatePermission, + this.options, + ); + + if (!this.rbacProviders) { + throw new NotFoundError(`No RBAC providers were found`); + } + + const idProvider = this.rbacProviders.find(provider => { + const id = provider.getProviderName(); + return id === request.params.id; + }); + + if (!idProvider) { + throw new NotFoundError( + `The RBAC provider ${request.params.id} was not found`, + ); + } + + await idProvider.refresh(); + response.status(200).end(); + }, + ); + + registerPermissionDefinitionRoutes( + router, + this.pluginPermMetaData, + this.pluginIdProvider, + this.extraPluginsIdStorage, + this.options, + ); + + router.use(setAuditorError()); + + return router; + } + + getEntityReference(request: Request, role?: boolean): string { + const kind = request.params.kind; + const namespace = request.params.namespace; + const name = request.params.name; + const entityRef = `${kind}:${namespace}/${name}`; + + const err = validateEntityReference(entityRef, role); + if (err) { + throw new InputError(err.message); + } + + return entityRef; + } + + async transformPolicyArray( + ...policies: string[][] + ): Promise { + const roleToSourceMap = await buildRoleSourceMap( + policies, + this.roleMetadata, + ); + + const roleBasedPolices: RoleBasedPolicy[] = []; + for (const p of policies) { + const [entityReference, permission, policy, effect] = p; + roleBasedPolices.push({ + entityReference, + permission, + policy, + effect, + metadata: { source: roleToSourceMap.get(entityReference)! }, + }); + } + + return roleBasedPolices; + } + + async transformRoleArray( + filter?: RBACFilters, + ...roles: string[][] + ): Promise { + const combinedRoles: { [key: string]: string[] } = {}; + + roles.forEach(([value, role]) => { + if (combinedRoles.hasOwnProperty(role)) { + combinedRoles[role].push(value); + } else { + combinedRoles[role] = [value]; + } + }); + + const result: Role[] = await Promise.all( + Object.entries(combinedRoles).flatMap(async ([role, value]) => { + const metadataDao = await this.roleMetadata.findRoleMetadata(role); + const metadata = metadataDao ? daoToMetadata(metadataDao) : undefined; + return Promise.resolve({ + memberReferences: value, + name: role, + metadata, + }); + }), + ); + + const filteredResult = result.filter(role => { + return role.metadata && matches(role.metadata, filter); + }); + + return filteredResult; + } + + transformPolicyToArray(policy: RoleBasedPolicy): string[] { + return [ + policy.entityReference!, + policy.permission!, + policy.policy!, + policy.effect!, + ]; + } + + transformRoleToArray(role: Role): string[][] { + const roles: string[][] = []; + for (const entity of role.memberReferences) { + roles.push([entity, role.name]); + } + return roles; + } + + transformMemberReferencesToLowercase(role: Role) { + role.memberReferences = role.memberReferences.map(member => + member.toLocaleLowerCase('en-US'), + ); + } + + getActionQueries( + queryValue: string | ParsedQs | (string | ParsedQs)[] | undefined, + ): PermissionAction[] | undefined { + if (!queryValue) { + return undefined; + } + if (Array.isArray(queryValue)) { + const permissionNames: PermissionAction[] = []; + for (const permissionQuery of queryValue) { + if ( + typeof permissionQuery === 'string' && + isPermissionAction(permissionQuery) + ) { + permissionNames.push(permissionQuery); + } else { + throw new InputError( + `Invalid permission action query value: ${permissionQuery}. Permission name should be string.`, + ); + } + } + return permissionNames; + } + + if (typeof queryValue === 'string' && isPermissionAction(queryValue)) { + return [queryValue]; + } + throw new InputError( + `Invalid permission action query value: ${queryValue}. Permission name should be string.`, + ); + } + + getFirstQuery( + queryValue: string | ParsedQs | (string | ParsedQs)[] | undefined, + ): string { + if (!queryValue) { + return ''; + } + if (Array.isArray(queryValue)) { + if (typeof queryValue[0] === 'string') { + return queryValue[0].toString(); + } + throw new InputError(`This api doesn't support nested query`); + } + + if (typeof queryValue === 'string') { + return queryValue; + } + throw new InputError(`This api doesn't support nested query`); + } + + isPolicyFilterEnabled(request: Request): boolean { + return ( + !!request.query.entityRef || + !!request.query.permission || + !!request.query.policy || + !!request.query.effect + ); + } + + async processPolicies( + policyArray: RoleBasedPolicy[], + isOld?: boolean, + errorMessage?: string, + filter?: RBACFilters, + ): Promise { + const policies: string[][] = []; + const uniqueItems = new Set(); + for (const policy of policyArray) { + let err = validatePolicy(policy); + if (err) { + throw new InputError( + `Invalid ${errorMessage ?? 'policy'} definition. Cause: ${ + err.message + }`, + ); // 400 + } + + const metadata = await this.roleMetadata.findRoleMetadata( + policy.entityReference!, + ); + + if (!metadata || !matches(daoToMetadata(metadata), filter)) { + throw new NotAllowedError(); // 403 + } + + let action = errorMessage ? 'edit' : 'delete'; + action = isOld ? action : 'add'; + + err = await validateSource('rest', metadata); + if (err) { + throw new NotAllowedError( + `Unable to ${action} policy ${policy.entityReference},${policy.permission},${policy.policy},${policy.effect}: ${err.message}`, + ); + } + + const transformedPolicy = this.transformPolicyToArray(policy); + if (isOld && !(await this.enforcer.hasPolicy(...transformedPolicy))) { + throw new NotFoundError( + `Policy '${policyToString(transformedPolicy)}' not found`, + ); // 404 + } + + if (!isOld && (await this.enforcer.hasPolicy(...transformedPolicy))) { + throw new ConflictError( + `Policy '${policyToString( + transformedPolicy, + )}' has been already stored`, + ); // 409 + } + + // We want to ensure that there are not duplicate permission policies + const rowString = JSON.stringify(transformedPolicy); + if (uniqueItems.has(rowString)) { + throw new ConflictError( + `Duplicate polices found; ${policy.entityReference}, ${policy.permission}, ${policy.policy}, ${policy.effect} is a duplicate`, + ); + } else { + uniqueItems.add(rowString); + policies.push(transformedPolicy); + } + } + return policies; + } + + nameSort(nameA: string, nameB: string): number { + if (nameA.toLocaleUpperCase('en-US') < nameB.toLocaleUpperCase('en-US')) { + return -1; + } + if (nameA.toLocaleUpperCase('en-US') > nameB.toLocaleUpperCase('en-US')) { + return 1; + } + return 0; + } +} diff --git a/plugins/rbac-backend/src/service/policy-builder.test.ts b/plugins/rbac-backend/src/service/policy-builder.test.ts new file mode 100644 index 0000000000..065a658bbe --- /dev/null +++ b/plugins/rbac-backend/src/service/policy-builder.test.ts @@ -0,0 +1,273 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices } from '@backstage/backend-test-utils'; + +import type { Adapter, Enforcer } from 'casbin'; +import type { Router } from 'express'; +import type TypeORMAdapter from 'typeorm-adapter'; + +import type { RBACProvider } from '@backstage-community/plugin-rbac-node'; + +import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; +import { RBACPermissionPolicy } from '../policies/permission-policy'; +import { PluginPermissionMetadataCollector } from './plugin-endpoints'; +import { PoliciesServer } from './policies-rest-api'; +import { PolicyBuilder } from './policy-builder'; +import { + extendablePluginIdProviderMock, + mockPermissionRegistry, +} from '../../__fixtures__/mock-utils'; + +import { PolicyExtensionPoint } from '@backstage/plugin-permission-node/alpha'; + +const enforcerMock: Partial = { + loadPolicy: jest.fn().mockImplementation(async () => {}), + enableAutoSave: jest.fn().mockImplementation(() => {}), + setRoleManager: jest.fn().mockImplementation(() => {}), + enableAutoBuildRoleLinks: jest.fn().mockImplementation(() => {}), + buildRoleLinks: jest.fn().mockImplementation(() => {}), +}; + +jest.mock('casbin', () => { + const actualCasbin = jest.requireActual('casbin'); + return { + ...actualCasbin, + newEnforcer: jest.fn((): Promise> => { + return Promise.resolve(enforcerMock); + }), + FileAdapter: jest.fn((): Adapter => { + return {} as Adapter; + }), + }; +}); + +const dataBaseAdapterFactoryMock: Partial = { + createAdapter: jest.fn((): Promise => { + return Promise.resolve({} as TypeORMAdapter); + }), +}; + +jest.mock('../database/casbin-adapter-factory', () => { + return { + CasbinDBAdapterFactory: jest.fn((): Partial => { + return dataBaseAdapterFactoryMock; + }), + }; +}); + +const pluginMetadataCollectorMock: Partial = + { + getPluginConditionRules: jest.fn().mockImplementation(), + getPluginPolicies: jest.fn().mockImplementation(), + getMetadataByPluginId: jest.fn().mockImplementation(), + }; + +jest.mock('./plugin-endpoints', () => { + return { + PluginPermissionMetadataCollector: jest + .fn() + .mockImplementation(() => pluginMetadataCollectorMock), + }; +}); + +const mockRouter: Router = {} as Router; +const policiesServerMock: Partial = { + serve: jest.fn().mockImplementation(async () => { + return mockRouter; + }), +}; + +jest.mock('./policies-rest-api', () => { + return { + PoliciesServer: jest.fn().mockImplementation(() => policiesServerMock), + }; +}); + +jest.mock('../policies/permission-policy', () => { + return { + RBACPermissionPolicy: { + build: jest.fn((): Promise => { + return Promise.resolve({} as RBACPermissionPolicy); + }), + }, + }; +}); + +jest.mock('./extendable-id-provider', () => { + return { + ExtendablePluginIdProvider: jest + .fn() + .mockImplementation(() => extendablePluginIdProviderMock), + }; +}); + +const providerMock: RBACProvider = { + getProviderName: jest.fn().mockImplementation(), + connect: jest.fn().mockImplementation(), + refresh: jest.fn().mockImplementation(), +}; + +const policyExtensionPointMock: PolicyExtensionPoint = { + setPolicy: jest.fn().mockImplementation(), +}; + +describe('PolicyBuilder', () => { + const backendPluginIDsProviderMock = { + getPluginIds: jest.fn().mockImplementation(() => { + return []; + }), + }; + + const mockLoggerService = mockServices.logger.mock(); + + beforeEach(async () => { + jest.clearAllMocks(); + }); + + it('should build policy server', async () => { + const router = await PolicyBuilder.build( + { + config: mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + permission: { + enabled: true, + rbac: {}, + }, + }, + }), + logger: mockLoggerService, + discovery: mockServices.discovery.mock(), + permissions: mockServices.permissions.mock(), + auth: mockServices.auth.mock(), + httpAuth: mockServices.httpAuth.mock(), + auditor: mockServices.auditor.mock(), + lifecycle: mockServices.lifecycle.mock(), + permissionsRegistry: mockPermissionRegistry, + policy: policyExtensionPointMock, + }, + backendPluginIDsProviderMock, + ); + expect(CasbinDBAdapterFactory).toHaveBeenCalled(); + expect(enforcerMock.loadPolicy).toHaveBeenCalled(); + expect(enforcerMock.enableAutoSave).toHaveBeenCalled(); + expect(RBACPermissionPolicy.build).toHaveBeenCalled(); + + expect(PoliciesServer).toHaveBeenCalled(); + expect(policiesServerMock.serve).toHaveBeenCalled(); + expect(router).toBeTruthy(); + expect(router).toBe(mockRouter); + expect(mockLoggerService.info).toHaveBeenCalledWith( + 'RBAC backend plugin was enabled', + ); + expect( + extendablePluginIdProviderMock.handleConflictedPluginIds, + ).toHaveBeenCalled(); + }); + + it('should build policy server with rbac providers', async () => { + const router = await PolicyBuilder.build( + { + config: mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + permission: { + enabled: true, + rbac: {}, + }, + }, + }), + logger: mockLoggerService, + discovery: mockServices.discovery.mock(), + permissions: mockServices.permissions.mock(), + auth: mockServices.auth.mock(), + httpAuth: mockServices.httpAuth.mock(), + auditor: mockServices.auditor.mock(), + lifecycle: mockServices.lifecycle.mock(), + permissionsRegistry: mockPermissionRegistry, + policy: policyExtensionPointMock, + }, + backendPluginIDsProviderMock, + [providerMock], + ); + expect(CasbinDBAdapterFactory).toHaveBeenCalled(); + expect(enforcerMock.loadPolicy).toHaveBeenCalled(); + expect(enforcerMock.enableAutoSave).toHaveBeenCalled(); + expect(RBACPermissionPolicy.build).toHaveBeenCalled(); + expect(providerMock.connect).toHaveBeenCalled(); + + expect(PoliciesServer).toHaveBeenCalled(); + expect(policiesServerMock.serve).toHaveBeenCalled(); + expect(router).toBeTruthy(); + expect(router).toBe(mockRouter); + expect(mockLoggerService.info).toHaveBeenCalledWith( + 'RBAC backend plugin was enabled', + ); + }); + + it('should build policy server, but log warning that permission framework disabled', async () => { + const router = await PolicyBuilder.build( + { + config: mockServices.rootConfig({ + data: { + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + permission: { + enabled: false, + rbac: {}, + }, + }, + }), + logger: mockLoggerService, + discovery: mockServices.discovery.mock(), + permissions: mockServices.permissions.mock(), + auth: mockServices.auth.mock(), + httpAuth: mockServices.httpAuth.mock(), + auditor: mockServices.auditor.mock(), + lifecycle: mockServices.lifecycle.mock(), + permissionsRegistry: mockPermissionRegistry, + policy: policyExtensionPointMock, + }, + backendPluginIDsProviderMock, + ); + expect(CasbinDBAdapterFactory).toHaveBeenCalled(); + expect(enforcerMock.loadPolicy).toHaveBeenCalled(); + expect(enforcerMock.enableAutoSave).toHaveBeenCalled(); + expect(RBACPermissionPolicy.build).not.toHaveBeenCalled(); + + expect(PoliciesServer).toHaveBeenCalled(); + expect(policiesServerMock.serve).toHaveBeenCalled(); + expect(router).toBeTruthy(); + expect(router).toBe(mockRouter); + expect(mockLoggerService.warn).toHaveBeenCalledWith( + 'RBAC backend plugin was disabled by application config permission.enabled: false', + ); + }); +}); diff --git a/plugins/rbac-backend/src/service/policy-builder.ts b/plugins/rbac-backend/src/service/policy-builder.ts new file mode 100644 index 0000000000..5dd33132ca --- /dev/null +++ b/plugins/rbac-backend/src/service/policy-builder.ts @@ -0,0 +1,250 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { DatabaseManager } from '@backstage/backend-defaults/database'; +import type { + AuditorService, + AuthService, + DiscoveryService, + HttpAuthService, + LifecycleService, + LoggerService, + PermissionsRegistryService, + PermissionsService, +} from '@backstage/backend-plugin-api'; +import { CatalogClient } from '@backstage/catalog-client'; +import type { Config } from '@backstage/config'; +import type { PermissionEvaluator } from '@backstage/plugin-permission-common'; + +import { newEnforcer, newModelFromString } from 'casbin'; +import type { Router } from 'express'; + +import type { + PluginIdProvider, + RBACProvider, +} from '@backstage-community/plugin-rbac-node'; + +import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; +import { DataBaseConditionalStorage } from '../database/conditional-storage'; +import { migrate } from '../database/migration'; +import { DataBaseRoleMetadataStorage } from '../database/role-metadata'; +import { AllowAllPolicy } from '../policies/allow-all-policy'; +import { RBACPermissionPolicy } from '../policies/permission-policy'; +import { connectRBACProviders } from '../providers/connect-providers'; +import { BackstageRoleManager } from '../role-manager/role-manager'; +import { EnforcerDelegate } from './enforcer-delegate'; +import { MODEL } from './permission-model'; +import { PluginPermissionMetadataCollector } from './plugin-endpoints'; +import { PoliciesServer } from './policies-rest-api'; +import { policyEntityPermissions } from '@backstage-community/plugin-rbac-common'; +import { rules } from '../permissions'; +import { permissionMetadataResourceRef } from '../permissions/resource'; +import { PermissionDependentPluginDatabaseStore } from '../database/extra-permission-enabled-plugins-storage'; +import { ExtendablePluginIdProvider } from './extendable-id-provider'; +import { PolicyExtensionPoint } from '@backstage/plugin-permission-node/alpha'; +import { + DefaultPermissionsReader, + DefaultPermissionsSyncher, +} from '../default-permissions/default-permissions'; + +/** + * @public + */ +export type EnvOptions = { + config: Config; + logger: LoggerService; + discovery: DiscoveryService; + permissions: PermissionEvaluator; + auth: AuthService; + httpAuth: HttpAuthService; + auditor: AuditorService; + lifecycle: LifecycleService; + permissionsRegistry: PermissionsRegistryService; + policy: PolicyExtensionPoint; +}; + +/** + * @public + */ +export type RBACRouterOptions = { + config: Config; + logger: LoggerService; + auth: AuthService; + httpAuth: HttpAuthService; + permissions: PermissionsService; + permissionsRegistry: PermissionsRegistryService; + auditor: AuditorService; +}; + +/** + * @public + */ +export class PolicyBuilder { + public static async build( + env: EnvOptions, + pluginIdProvider: PluginIdProvider = { getPluginIds: () => [] }, + rbacProviders?: Array, + ): Promise { + const databaseManager = DatabaseManager.fromConfig(env.config).forPlugin( + 'permission', + { logger: env.logger, lifecycle: env.lifecycle }, + ); + + const databaseClient = await databaseManager.getClient(); + + const adapter = await new CasbinDBAdapterFactory( + env.config, + databaseClient, + ).createAdapter(); + + const enf = await newEnforcer(newModelFromString(MODEL), adapter); + await enf.loadPolicy(); + enf.enableAutoSave(true); + + const catalogClient = new CatalogClient({ discoveryApi: env.discovery }); + const catalogDBClient = await DatabaseManager.fromConfig(env.config) + .forPlugin('catalog', { logger: env.logger, lifecycle: env.lifecycle }) + .getClient(); + + const defPermReader = new DefaultPermissionsReader(env.config); + + const rm = new BackstageRoleManager( + catalogClient, + env.logger, + catalogDBClient, + databaseClient, + env.config, + env.auth, + defPermReader, + ); + enf.setRoleManager(rm); + enf.enableAutoBuildRoleLinks(false); + await enf.buildRoleLinks(); + + await migrate(databaseManager); + + const conditionStorage = new DataBaseConditionalStorage(databaseClient); + + const roleMetadataStorage = new DataBaseRoleMetadataStorage(databaseClient); + const enforcerDelegate = new EnforcerDelegate( + enf, + env.auditor, + conditionStorage, + roleMetadataStorage, + databaseClient, + ); + + const defPermSyncher = new DefaultPermissionsSyncher( + roleMetadataStorage, + enforcerDelegate, + defPermReader, + ); + await defPermSyncher.sync(); + + env.permissionsRegistry.addResourceType({ + resourceRef: permissionMetadataResourceRef, + getResources: resourceRefs => + Promise.all( + resourceRefs.map(ref => { + if ( + ref === + roleMetadataStorage.getCachedDefaultRoleMetadata()?.roleEntityRef + ) { + return roleMetadataStorage.getCachedDefaultRoleMetadata(); + } + return roleMetadataStorage.findRoleMetadata(ref); + }), + ), + permissions: policyEntityPermissions, + rules: Object.values(rules), + }); + + if (rbacProviders) { + await connectRBACProviders( + rbacProviders, + enforcerDelegate, + roleMetadataStorage, + conditionStorage, + env.logger, + env.auditor, + ); + } + + const extraPluginsIdStorage = new PermissionDependentPluginDatabaseStore( + databaseClient, + ); + const extendablePluginIdProvider = new ExtendablePluginIdProvider( + extraPluginsIdStorage, + pluginIdProvider, + env.config, + ); + await extendablePluginIdProvider.handleConflictedPluginIds(); + const pluginPermMetaData = new PluginPermissionMetadataCollector({ + deps: { + discovery: env.discovery, + pluginIdProvider: extendablePluginIdProvider, + logger: env.logger, + config: env.config, + }, + }); + + const isPluginEnabled = env.config.getOptionalBoolean('permission.enabled'); + if (isPluginEnabled) { + env.logger.info('RBAC backend plugin was enabled'); + + env.policy.setPolicy( + await RBACPermissionPolicy.build( + env.logger, + env.auditor, + env.config, + conditionStorage, + enforcerDelegate, + roleMetadataStorage, + databaseClient, + pluginPermMetaData, + env.auth, + ), + ); + } else { + env.logger.warn( + 'RBAC backend plugin was disabled by application config permission.enabled: false', + ); + + env.policy.setPolicy(new AllowAllPolicy()); + } + + const options: RBACRouterOptions = { + config: env.config, + logger: env.logger, + auth: env.auth, + httpAuth: env.httpAuth, + permissions: env.permissions, + permissionsRegistry: env.permissionsRegistry, + auditor: env.auditor, + }; + + const server = new PoliciesServer( + options, + enforcerDelegate, + conditionStorage, + pluginPermMetaData, + roleMetadataStorage, + extraPluginsIdStorage, + extendablePluginIdProvider, + rbacProviders, + ); + return server.serve(); + } +} diff --git a/plugins/rbac-backend/src/service/router.test.ts b/plugins/rbac-backend/src/service/router.test.ts new file mode 100644 index 0000000000..f3a7d25afd --- /dev/null +++ b/plugins/rbac-backend/src/service/router.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices } from '@backstage/backend-test-utils'; + +import express from 'express'; +import request from 'supertest'; + +import { createRouter } from './router'; + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + logger: mockServices.logger.mock(), + config: mockServices.rootConfig(), + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); +}); diff --git a/plugins/rbac-backend/src/service/router.ts b/plugins/rbac-backend/src/service/router.ts new file mode 100644 index 0000000000..159d698a7b --- /dev/null +++ b/plugins/rbac-backend/src/service/router.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter'; +import type { LoggerService } from '@backstage/backend-plugin-api'; +import type { Config } from '@backstage/config'; +import Router from 'express-promise-router'; + +import express from 'express'; + +/** + * @public + */ +export interface RouterOptions { + logger: LoggerService; + config: Config; +} + +/** + * @public + */ +export async function createRouter( + options: RouterOptions, +): Promise { + const { logger, config } = options; + + const router = Router(); + router.use(express.json()); + + router.get('/health', (_, response) => { + logger.info('PONG!'); + response.json({ status: 'ok' }); + }); + + const middleware = MiddlewareFactory.create({ logger, config }); + + router.use(middleware.error()); + return router; +} diff --git a/plugins/rbac-backend/src/setupTests.ts b/plugins/rbac-backend/src/setupTests.ts new file mode 100644 index 0000000000..c7ce5c0988 --- /dev/null +++ b/plugins/rbac-backend/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export {}; diff --git a/plugins/rbac-backend/src/validation/condition-validation.test.ts b/plugins/rbac-backend/src/validation/condition-validation.test.ts new file mode 100644 index 0000000000..dd809ed887 --- /dev/null +++ b/plugins/rbac-backend/src/validation/condition-validation.test.ts @@ -0,0 +1,988 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +import type { + PermissionAction, + RoleConditionalPolicyDecision, +} from '@backstage-community/plugin-rbac-common'; + +import { validateRoleCondition } from './condition-validation'; + +describe('condition-validation', () => { + describe('validation common fields', () => { + it('should fail validation role condition without pluginId', () => { + const condition: any = { + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'pluginId' must be specified in the role condition`, + ); + }); + + it('should fail validation role condition without resourceType', () => { + const condition: any = { + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'resourceType' must be specified in the role condition`, + ); + }); + + it('should fail validation role condition without permissionMapping', () => { + const condition: any = { + resourceType: 'catalog-entity', + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'permissionMapping' must be non empty array in the role condition`, + ); + }); + + it('should fail validation role condition with empty array permissionMapping', () => { + const condition: any = { + resourceType: 'catalog-entity', + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: [], + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'permissionMapping' must be non empty array in the role condition`, + ); + }); + + it('should fail validation role condition with array permissionMapping, but with wrong action value', () => { + const condition: any = { + resourceType: 'catalog-entity', + pluginId: 'catalog', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['wrong-value'], + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'permissionMapping' array contains non action value: 'wrong-value'`, + ); + }); + + it('should fail validation role condition with policy-entity resource type and create action', () => { + const condition: any = { + resourceType: 'policy-entity', + pluginId: 'permission', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['create'], + conditions: { + anyOf: [ + { + rule: 'IS_OWNER', + resourceType: 'policy-entity', + params: { key: 'owner', values: ['user:default/mock'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `Conditional policy can not be created for resource type 'policy-entity' with the permission action 'create'`, + ); + }); + + it('should fail validation role condition without role entity reference', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'roleEntityRef' must be specified in the role condition`, + ); + }); + + it('should fail validation role condition without result', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + permissionMapping: ['read'], + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'result' must be specified in the role condition`, + ); + }); + + it('should fail validation role condition without conditions', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'conditions' must be specified in the role condition`, + ); + }); + }); + + describe('validate simple condition', () => { + it('should fail validation role-condition.conditions without rule', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'rule' must be specified in the roleCondition.conditions.condition`, + ); + }); + + it('should fail validation role-condition.conditions without resourceType', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + rule: 'IS_ENTITY_OWNER', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'resourceType' must be specified in the roleCondition.conditions.condition`, + ); + }); + + it('should validate role-condition.conditions without errors', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + }; + let unexpectedErr; + try { + validateRoleCondition(condition); + } catch (err) { + unexpectedErr = err; + } + expect(unexpectedErr).toBeUndefined(); + }); + + it('should validate role-condition.conditions with permission policy action of use without errors', () => { + const condition: any = { + pluginId: 'scaffolder', + resourceType: 'scaffolder-action', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['use'], + conditions: { + rule: 'HAS_ACTION_ID', + resourceType: 'scaffolder-action', + params: { + actionId: 'quay:create-repository', + }, + }, + }; + let unexpectedErr; + try { + validateRoleCondition(condition); + } catch (err) { + unexpectedErr = err; + } + expect(unexpectedErr).toBeUndefined(); + }); + }); + + describe('validate "not" criteria', () => { + it('should fail validation role-condition.conditions.not without rule', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + permissionMapping: ['read'], + result: AuthorizeResult.CONDITIONAL, + conditions: { + not: { + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'rule' must be specified in the roleCondition.conditions.not.condition`, + ); + }); + + it('should fail validation role-condition.conditions.not without resourceType', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + not: { + rule: 'IS_ENTITY_OWNER', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'resourceType' must be specified in the roleCondition.conditions.not.condition`, + ); + }); + + it('should validate role-condition.conditions.not without errors', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + }, + }; + let unexpectedErr; + try { + validateRoleCondition(condition); + } catch (err) { + unexpectedErr = err; + } + + expect(unexpectedErr).toBeUndefined(); + }); + }); + + describe('validate anyOf criteria', () => { + it('should fail validation role-condition.conditions.anyOf with an empty array value', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + anyOf: [], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `roleCondition.conditions.anyOf criteria must be non empty array`, + ); + }); + + it('should fail validation role-condition.conditions.anyOf with non array value', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + anyOf: { + rule: 'IS_ENTITY_OWNER', + params: { + claims: ['group:default/team-a'], + }, + }, + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `roleCondition.conditions.anyOf criteria must be non empty array`, + ); + }); + + it('should fail validation role-condition.conditions.anyOf without resourceType in the first param', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'resourceType' must be specified in the roleCondition.conditions.anyOf[0].condition`, + ); + }); + + it('should fail validation role-condition.conditions.anyOf without resourceType in the second param', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'resourceType' must be specified in the roleCondition.conditions.anyOf[1].condition`, + ); + }); + + it('should fail validation role-condition.conditions.anyOf without rule in the first param', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + anyOf: [ + { + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'rule' must be specified in the roleCondition.conditions.anyOf[0].condition`, + ); + }); + + it('should fail validation role-condition.conditions.anyOf without rule in the second param', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'rule' must be specified in the roleCondition.conditions.anyOf[1].condition`, + ); + }); + + it('should validate role-condition.conditions.anyOf without errors', () => { + const condition: RoleConditionalPolicyDecision = { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + let unexpectedErr; + try { + validateRoleCondition(condition); + } catch (err) { + unexpectedErr = err; + } + expect(unexpectedErr).toBeUndefined(); + }); + }); + + describe('validate allOf criteria', () => { + it('should fail validation role-condition.conditions.allOf with an empty array value', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + allOf: [], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `roleCondition.conditions.allOf criteria must be non empty array`, + ); + }); + + it('should fail validation role-condition.conditions.allOf with non array value', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + allOf: { + rule: 'IS_ENTITY_OWNER', + params: { + claims: ['group:default/team-a'], + }, + }, + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `roleCondition.conditions.allOf criteria must be non empty array`, + ); + }); + + it('should fail validation role-condition.conditions.allOf without resourceType in the first param', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'resourceType' must be specified in the roleCondition.conditions.allOf[0].condition`, + ); + }); + + it('should fail validation role-condition.conditions.allOf without resourceType in the second param', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'resourceType' must be specified in the roleCondition.conditions.allOf[1].condition`, + ); + }); + + it('should fail validation role-condition.conditions.allOf without rule in the first param', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + allOf: [ + { + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'rule' must be specified in the roleCondition.conditions.allOf[0].condition`, + ); + }); + + it('should fail validation role-condition.conditions.allOf without rule in the second param', () => { + const condition: any = { + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `'rule' must be specified in the roleCondition.conditions.allOf[1].condition`, + ); + }); + + it('should success validation role-condition.conditions.allOf', () => { + const condition: RoleConditionalPolicyDecision = { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + let unexpectedErr; + try { + validateRoleCondition(condition); + } catch (err) { + unexpectedErr = err; + } + expect(unexpectedErr).toBeUndefined(); + }); + }); + + describe('complex conditions', () => { + it('should fail validation of role-condition.conditions in parallel with condition rule', () => { + const condition: RoleConditionalPolicyDecision = { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `RBAC plugin does not support parallel conditions alongside rules, consider reworking request to include nested condition criteria. Conditional criteria causing the error allOf, 'rule: IS_ENTITY_OWNER'.`, + ); + }); + + it('should fail validation of role-condition.conditions criteria (allOf, not) in parallel', () => { + const condition: RoleConditionalPolicyDecision = { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `RBAC plugin does not support parallel conditions, consider reworking request to include nested condition criteria. Conditional criteria causing the error allOf,not.`, + ); + }); + + it('should fail validation of role-condition.conditions criteria (allOf, anyOf) in parallel', () => { + const condition: RoleConditionalPolicyDecision = { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + allOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `RBAC plugin does not support parallel conditions, consider reworking request to include nested condition criteria. Conditional criteria causing the error allOf,anyOf.`, + ); + }); + + it('should fail validation of role-condition.conditions criteria (not, anyOf) in parallel', () => { + const condition: RoleConditionalPolicyDecision = { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + anyOf: [ + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + expect(() => validateRoleCondition(condition)).toThrow( + `RBAC plugin does not support parallel conditions, consider reworking request to include nested condition criteria. Conditional criteria causing the error anyOf,not.`, + ); + }); + + it('should validate role-condition.conditions that are nested', () => { + const condition: RoleConditionalPolicyDecision = { + id: 1, + pluginId: 'catalog', + resourceType: 'catalog-entity', + roleEntityRef: 'role:default/test', + result: AuthorizeResult.CONDITIONAL, + permissionMapping: ['read'], + conditions: { + anyOf: [ + { + not: { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + }, + { + rule: 'IS_ENTITY_OWNER', + resourceType: 'catalog-entity', + params: { + claims: ['user:default/logarifm', 'group:default/team-a'], + }, + }, + { + rule: 'IS_ENTITY_KIND', + resourceType: 'catalog-entity', + params: { kinds: ['Group'] }, + }, + ], + }, + }; + + let unexpectedErr; + try { + validateRoleCondition(condition); + } catch (err) { + unexpectedErr = err; + } + expect(unexpectedErr).toBeUndefined(); + }); + }); +}); diff --git a/plugins/rbac-backend/src/validation/condition-validation.ts b/plugins/rbac-backend/src/validation/condition-validation.ts new file mode 100644 index 0000000000..ce0b75959a --- /dev/null +++ b/plugins/rbac-backend/src/validation/condition-validation.ts @@ -0,0 +1,196 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + PermissionCondition, + PermissionCriteria, + PermissionRuleParams, +} from '@backstage/plugin-permission-common'; + +import type { + PermissionAction, + RoleConditionalPolicyDecision, +} from '@backstage-community/plugin-rbac-common'; + +import { isPermissionAction } from '../helper'; + +export function validateRoleCondition( + condition: RoleConditionalPolicyDecision, +) { + if (!condition.roleEntityRef) { + throw new Error(`'roleEntityRef' must be specified in the role condition`); + } + if (!condition.result) { + throw new Error(`'result' must be specified in the role condition`); + } + if (!condition.pluginId) { + throw new Error(`'pluginId' must be specified in the role condition`); + } + if (!condition.resourceType) { + throw new Error(`'resourceType' must be specified in the role condition`); + } + + if ( + !condition.permissionMapping || + condition.permissionMapping.length === 0 + ) { + throw new Error( + `'permissionMapping' must be non empty array in the role condition`, + ); + } + const nonActionValue = condition.permissionMapping.find( + action => !isPermissionAction(action), + ); + if (nonActionValue) { + throw new Error( + `'permissionMapping' array contains non action value: '${nonActionValue}'`, + ); + } + + if ( + condition.resourceType === 'policy-entity' && + condition.permissionMapping.includes('create') + ) { + throw new Error( + `Conditional policy can not be created for resource type 'policy-entity' with the permission action 'create'`, + ); + } + + if (!condition.conditions) { + throw new Error(`'conditions' must be specified in the role condition`); + } + if (condition.conditions) { + validatePermissionCondition( + condition.conditions, + 'roleCondition.conditions', + ); + } +} + +/** + * validatePermissionCondition validate conditional permission policies using validateCriteria and validateRule. + * @param conditionOrCriteria The Permission Criteria of the conditional permission. + * @param jsonPathLocator The location in the JSON of the current check. + * @returns undefined. + */ +function validatePermissionCondition( + conditionOrCriteria: PermissionCriteria< + PermissionCondition + >, + jsonPathLocator: string, +) { + validateCriteria(conditionOrCriteria, jsonPathLocator); + + if ('not' in conditionOrCriteria) { + validatePermissionCondition( + conditionOrCriteria.not, + `${jsonPathLocator}.not`, + ); + return; + } + + if ('allOf' in conditionOrCriteria) { + if ( + !Array.isArray(conditionOrCriteria.allOf) || + conditionOrCriteria.allOf.length === 0 + ) { + throw new Error( + `${jsonPathLocator}.allOf criteria must be non empty array`, + ); + } + for (const [index, elem] of conditionOrCriteria.allOf.entries()) { + validatePermissionCondition(elem, `${jsonPathLocator}.allOf[${index}]`); + } + return; + } + + if ('anyOf' in conditionOrCriteria) { + if ( + !Array.isArray(conditionOrCriteria.anyOf) || + conditionOrCriteria.anyOf.length === 0 + ) { + throw new Error( + `${jsonPathLocator}.anyOf criteria must be non empty array`, + ); + } + for (const [index, elem] of conditionOrCriteria.anyOf.entries()) { + validatePermissionCondition(elem, `${jsonPathLocator}.anyOf[${index}]`); + } + } +} + +/** + * validateRule ensures that there is a rule and resource type associated with each conditional permission. + * @param conditionOrCriteria The Permission Criteria of the conditional permission. + * @param jsonPathLocator The location in the JSON of the current check. + */ +function validateRule( + conditionOrCriteria: PermissionCriteria< + PermissionCondition + >, + jsonPathLocator: string, +) { + if (!('resourceType' in conditionOrCriteria)) { + throw new Error( + `'resourceType' must be specified in the ${jsonPathLocator}.condition`, + ); + } + if (!('rule' in conditionOrCriteria)) { + throw new Error( + `'rule' must be specified in the ${jsonPathLocator}.condition`, + ); + } +} + +/** + * validateCriteria ensures that there is only one of the following criteria: allOf, anyOf, and not, at any given level. + * We want to make sure that there are no parallel conditional criteria for conditional permission policies as this is + * not support by the permission framework. + * + * If more than one criteria are at a given level, we throw an error about the inability to support parallel conditions. + * If no criteria are found, we validate the rule. + * + * @param conditionOrCriteria The Permission Criteria of the conditional permission. + * @param jsonPathLocator The location in the JSON of the current check. + */ +function validateCriteria( + conditionOrCriteria: PermissionCriteria< + PermissionCondition + >, + jsonPathLocator: string, +) { + const criteriaList = ['allOf', 'anyOf', 'not']; + const found: string[] = []; + + for (const crit of criteriaList) { + if (crit in conditionOrCriteria) { + found.push(crit); + } + } + + if (found.length > 1) { + throw new Error( + `RBAC plugin does not support parallel conditions, consider reworking request to include nested condition criteria. Conditional criteria causing the error ${found}.`, + ); + } else if (found.length === 0) { + validateRule(conditionOrCriteria, jsonPathLocator); + } + + if (found.length === 1 && 'rule' in conditionOrCriteria) { + throw new Error( + `RBAC plugin does not support parallel conditions alongside rules, consider reworking request to include nested condition criteria. Conditional criteria causing the error ${found}, 'rule: ${conditionOrCriteria.rule}'.`, + ); + } +} diff --git a/plugins/rbac-backend/src/validation/plugin-validation.test.ts b/plugins/rbac-backend/src/validation/plugin-validation.test.ts new file mode 100644 index 0000000000..277b356241 --- /dev/null +++ b/plugins/rbac-backend/src/validation/plugin-validation.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { validatePermissionDependentPlugin } from './plugin-validation'; + +describe('validatePermissionDependentPlugin', () => { + it('does not throw when ids is a valid array of strings', () => { + expect(() => + validatePermissionDependentPlugin({ + ids: ['plugin-a', 'plugin-b'], + }), + ).not.toThrow(); + }); + + it('throws if ids is missing', () => { + expect(() => validatePermissionDependentPlugin({} as any)).toThrow( + `'ids' must be specified in the permission dependent plugin`, + ); + }); + + it('throws if ids is not an array', () => { + expect(() => + validatePermissionDependentPlugin({ ids: 'plugin-a' } as any), + ).toThrow(`'ids' must be an array of string plugin ID values`); + }); + + it('throws if ids contains non-string values', () => { + expect(() => + validatePermissionDependentPlugin({ ids: ['plugin-a', 123] } as any), + ).toThrow(`'ids' must be an array of string plugin ID values`); + }); +}); diff --git a/plugins/rbac-backend/src/validation/plugin-validation.ts b/plugins/rbac-backend/src/validation/plugin-validation.ts new file mode 100644 index 0000000000..16c4f76ba8 --- /dev/null +++ b/plugins/rbac-backend/src/validation/plugin-validation.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { PermissionDependentPluginList } from '@backstage-community/plugin-rbac-common'; + +export function validatePermissionDependentPlugin( + plugin: PermissionDependentPluginList, +) { + if (!plugin.ids) { + throw new Error( + `'ids' must be specified in the permission dependent plugin`, + ); + } + if ( + !Array.isArray(plugin.ids) || + !plugin.ids.every(id => typeof id === 'string') + ) { + throw new Error(`'ids' must be an array of string plugin ID values`); + } +} diff --git a/plugins/rbac-backend/src/validation/policies-validation.test.ts b/plugins/rbac-backend/src/validation/policies-validation.test.ts new file mode 100644 index 0000000000..a076453386 --- /dev/null +++ b/plugins/rbac-backend/src/validation/policies-validation.test.ts @@ -0,0 +1,410 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { + RoleBasedPolicy, + Source, +} from '@backstage-community/plugin-rbac-common'; + +import { RoleMetadataDao } from '../database/role-metadata'; +import { + validateEntityReference, + validateGroupingPolicy, + validatePolicy, + validateRole, + validateSource, +} from './policies-validation'; + +const modifiedBy = 'user:default/some-admin'; + +describe('rest data validation', () => { + describe('validate entity referenced policy', () => { + it('should return an error when entity reference is empty', () => { + const policy: RoleBasedPolicy = {}; + const err = validatePolicy(policy); + expect(err).toBeTruthy(); + expect(err?.message).toEqual(`'entityReference' must not be empty`); + }); + + it('should return an error when permission is empty', () => { + const policy: RoleBasedPolicy = { + entityReference: 'user:default/guest', + }; + const err = validatePolicy(policy); + expect(err).toBeTruthy(); + expect(err?.message).toEqual(`'permission' field must not be empty`); + }); + + it('should return an error when policy is empty', () => { + const policy: RoleBasedPolicy = { + entityReference: 'user:default/guest', + permission: 'catalog-entity', + }; + const err = validatePolicy(policy); + expect(err).toBeTruthy(); + expect(err?.message).toEqual(`'policy' field must not be empty`); + }); + + it('should return an error when policy has an invalid value', () => { + const policy: RoleBasedPolicy = { + entityReference: 'user:default/guest', + permission: 'catalog-entity', + policy: 'invalid-policy', + effect: 'allow', + }; + const err = validatePolicy(policy); + expect(err).toBeTruthy(); + expect(err?.message).toEqual( + `'policy' has invalid value: 'invalid-policy'. It should be one of: create, read, update, delete, use`, + ); + }); + + it('should return an error when effect is empty', () => { + const policy: RoleBasedPolicy = { + entityReference: 'user:default/guest', + permission: 'catalog-entity', + policy: 'read', + }; + const err = validatePolicy(policy); + expect(err).toBeTruthy(); + expect(err?.message).toEqual(`'effect' field must not be empty`); + }); + + it('should return an error when effect has an invalid value', () => { + const policy: RoleBasedPolicy = { + entityReference: 'user:default/guest', + permission: 'catalog-entity', + policy: 'read', + effect: 'invalid-effect', + }; + const err = validatePolicy(policy); + expect(err).toBeTruthy(); + expect(err?.message).toEqual( + `'effect' has invalid value: 'invalid-effect'. It should be: 'allow' or 'deny'`, + ); + }); + + it(`pass validation when all fields are valid. Effect 'allow' should be valid`, () => { + const policy: RoleBasedPolicy = { + entityReference: 'user:default/guest', + permission: 'catalog-entity', + policy: 'read', + effect: 'allow', + }; + const err = validatePolicy(policy); + expect(err).toBeUndefined(); + }); + + it(`pass validation when all fields are valid. Effect 'deny' should be valid`, () => { + const policy: RoleBasedPolicy = { + entityReference: 'user:default/guest', + permission: 'catalog-entity', + policy: 'read', + effect: 'deny', + }; + const err = validatePolicy(policy); + expect(err).toBeUndefined(); + }); + }); + + describe('validate entity reference', () => { + it('should return an error when entity reference is an empty', () => { + const err = validateEntityReference(''); + expect(err).toBeTruthy(); + expect(err?.message).toEqual(`'entityReference' must not be empty`); + }); + + it('should return an error when entity reference is not full or invalid', () => { + const invalidOrUnsupportedEntityRefs = [ + { + ref: 'admin', + expectedError: `Entity reference "admin" had missing or empty kind (e.g. did not start with "component:" or similar)`, + }, + { + ref: 'admin:default', + expectedError: `entity reference 'admin:default' does not match the required format [:][/]. Provide, please, full entity reference.`, + }, + { + ref: 'admin/guest', + expectedError: `Entity reference "admin/guest" had missing or empty kind (e.g. did not start with "component:" or similar)`, + }, + { + ref: 'admin/guest/somewhere', + expectedError: `Entity reference "admin/guest/somewhere" had missing or empty kind (e.g. did not start with "component:" or similar)`, + }, + { + ref: ':default/admin', + expectedError: `Entity reference ":default/admin" was not on the form [:][/]`, + }, + { + ref: 'user:/admin', + expectedError: `Entity reference "user:/admin" was not on the form [:][/]`, + }, + { + ref: 'user:default/', + expectedError: `Entity reference "user:default/" was not on the form [:][/]`, + }, + { + ref: 'user:/', + expectedError: `Entity reference "user:/" was not on the form [:][/]`, + }, + { + ref: ':default/', + expectedError: `Entity reference ":default/" was not on the form [:][/]`, + }, + { + ref: ':/guest', + expectedError: `Entity reference ":/guest" was not on the form [:][/]`, + }, + { + ref: ':/', + expectedError: `Entity reference ":/" was not on the form [:][/]`, + }, + { + ref: '/admin', + expectedError: `Entity reference "/admin" was not on the form [:][/]`, + }, + { + ref: 'user/', + expectedError: `Entity reference "user/" was not on the form [:][/]`, + }, + { + ref: ':default', + expectedError: `Entity reference ":default" was not on the form [:][/]`, + }, + { + ref: 'user:', + expectedError: `Entity reference "user:" was not on the form [:][/]`, + }, + { + ref: 'admin:default/test', + expectedError: `Unsupported kind admin. List supported values ["user", "group", "role"]`, + }, + ]; + for (const entityRef of invalidOrUnsupportedEntityRefs) { + const err = validateEntityReference(entityRef.ref); + expect(err).toBeTruthy(); + expect(err?.message).toEqual(entityRef.expectedError); + } + }); + + it('should return an error when entity reference name is invalid', () => { + const invalidEntityNames = [ + 'john@doe', + 'John Doe', + 'John/Doe', + 'invalid-', + 'invalid_', + '.invalid', + `too-long${'1'.repeat(60)}`, + ]; + + for (const invalidName of invalidEntityNames) { + const expectedError = `The name '${invalidName}' in the entity reference must be a string that is sequences of [a-zA-Z0-9] separated by any of [-_.], at most 63 characters in total`; + const entityRef = `user:default/${invalidName}`; + const err = validateEntityReference(entityRef); + expect(err).toBeTruthy(); + expect(err?.message).toEqual(expectedError); + } + }); + + it('should return an error when entity reference namespace is invalid', () => { + const invalidEntityNamespaces = [ + 'INVALID', + 'invalid-', + '-invalid', + 'invalid$namespace', + `too-long${'1'.repeat(60)}`, + ]; + + for (const invalidNamespace of invalidEntityNamespaces) { + const expectedError = `The namespace '${invalidNamespace}' in the entity reference must be a string that is sequences of [a-z0-9] separated by [-], at most 63 characters in total`; + const entityRef = `user:${invalidNamespace}/doe`; + const err = validateEntityReference(entityRef); + expect(err).toBeTruthy(); + expect(err?.message).toEqual(expectedError); + } + }); + + it('should pass entity reference validation', () => { + const validEntityRefs = [ + 'user:default/guest', + 'role:default/team-a', + 'role:default/team_1', + 'role:default/team.A', + 'role:custom-1/doe', + ]; + for (const entityRef of validEntityRefs) { + const err = validateEntityReference(entityRef); + expect(err).toBeFalsy(); + } + }); + }); + + describe('validateRole', () => { + it('should return an error when "memberReferences" query param is missing', () => { + const request = { name: 'role:default/user' } as any; + const err = validateRole(request); + expect(err).toBeTruthy(); + expect(err?.message).toEqual( + `'memberReferences' field must not be empty`, + ); + }); + + it('should return an error when "owner" param is in an invalid format', () => { + const request = { + memberReferences: ['user:default/guest'], + name: 'role:default/user', + metadata: { + owner: 'test:default/some_owner', + }, + } as any; + const err = validateRole(request); + expect(err).toBeTruthy(); + expect(err?.message).toEqual( + `Unsupported kind test. List supported values [\"user\", \"group\"]`, + ); + }); + + it('should pass validation when all required query params are present', () => { + const request = { + memberReferences: ['user:default/guest'], + name: 'role:default/user', + } as any; + const err = validateRole(request); + expect(err).toBeUndefined(); + }); + }); + + describe('validateSource', () => { + const roleMeta: RoleMetadataDao = { + roleEntityRef: 'role:default/catalog-reader', + source: 'rest', + modifiedBy, + }; + + it('should not return an error whenever the source that is passed matches the source of the role', async () => { + const source: Source = 'rest'; + + const err = await validateSource(source, roleMeta); + + expect(err).toBeUndefined(); + }); + + it('should not return an error whenever the source that is passed does not match a legacy source role', async () => { + const roleMetaLegacy: RoleMetadataDao = { + roleEntityRef: 'role:default/legacy-reader', + source: 'legacy', + modifiedBy, + }; + + const source: Source = 'rest'; + + const err = await validateSource(source, roleMetaLegacy); + + expect(err).toBeUndefined(); + }); + + it('should return an error whenever the source that is passed does not match the source of the role', async () => { + const source: Source = 'csv-file'; + + const err = await validateSource(source, roleMeta); + + expect(err).toBeTruthy(); + expect(err?.message).toEqual( + `source does not match originating role ${ + roleMeta.roleEntityRef + }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, + ); + }); + }); + + describe('validateGroupingPolicy', () => { + let groupPolicy = ['user:default/test', 'role:default/catalog-reader']; + let source: Source = 'rest'; + const roleMeta: RoleMetadataDao = { + roleEntityRef: 'role:default/catalog-reader', + source: 'rest', + modifiedBy, + }; + + it('should not return an error during validation', async () => { + const err = await validateGroupingPolicy(groupPolicy, roleMeta, source); + + expect(err).toBeUndefined(); + }); + + it('should return an error if the grouping policy is too long', async () => { + groupPolicy = [ + 'user:default/test', + 'role:default/catalog-reader', + 'extra', + ]; + + const err = await validateGroupingPolicy(groupPolicy, roleMeta, source); + + expect(err).toBeTruthy(); + expect(err?.message).toEqual(`Group policy should have length 2`); + }); + + it('should return an error if a member starts with role:', async () => { + groupPolicy = ['role:default/test', 'role:default/catalog-reader']; + + const err = await validateGroupingPolicy(groupPolicy, roleMeta, source); + + expect(err).toBeTruthy(); + expect(err?.message).toEqual( + `Group policy is invalid: ${groupPolicy}. rbac-backend plugin doesn't support role inheritance.`, + ); + }); + + it('should return an error for group inheritance (user to group)', async () => { + groupPolicy = ['user:default/test', 'group:default/catalog-reader']; + + const err = await validateGroupingPolicy(groupPolicy, roleMeta, source); + + expect(err).toBeTruthy(); + expect(err?.message).toEqual( + `Group policy is invalid: ${groupPolicy}. User membership information could be provided only with help of Catalog API.`, + ); + }); + + it('should return an error for group inheritance (group to group)', async () => { + groupPolicy = ['group:default/test', 'group:default/catalog-reader']; + + const err = await validateGroupingPolicy(groupPolicy, roleMeta, source); + + expect(err).toBeTruthy(); + expect(err?.message).toEqual( + `Group policy is invalid: ${groupPolicy}. Group inheritance information could be provided only with help of Catalog API.`, + ); + }); + + it('should return an error for mismatch source', async () => { + groupPolicy = ['user:default/test', 'role:default/catalog-reader']; + source = 'csv-file'; + + const err = await validateGroupingPolicy(groupPolicy, roleMeta, source); + + expect(err).toBeTruthy(); + expect(err?.name).toEqual('NotAllowedError'); + expect(err?.message).toEqual( + `Unable to validate role ${groupPolicy}. Cause: source does not match originating role ${ + roleMeta.roleEntityRef + }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, + ); + }); + }); +}); diff --git a/plugins/rbac-backend/src/validation/policies-validation.ts b/plugins/rbac-backend/src/validation/policies-validation.ts new file mode 100644 index 0000000000..0d42a06b25 --- /dev/null +++ b/plugins/rbac-backend/src/validation/policies-validation.ts @@ -0,0 +1,305 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { CompoundEntityRef, parseEntityRef } from '@backstage/catalog-model'; +import { NotAllowedError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +import { Enforcer } from 'casbin'; + +import { + isValidPermissionAction, + PermissionActionValues, + Role, + RoleBasedPolicy, + Source, +} from '@backstage-community/plugin-rbac-common'; + +import { RoleMetadataDao } from '../database/role-metadata'; + +/** + * validateSource validates the source to the role that is being modified. This includes comparing the source from the + * originating role to the source that the modification is coming from. + * We do this to ensure consistency between permissions and roles and where they are originally defined. + * This is a strict comparison where the source of all new roles (grouping policies) and permissions must match + * the source of the first role that was created. + * We are not strict for permission policies defined with an originating role source of configuration. + * @param source The source in which the modification is coming from + * @param roleMetadata The original role that was created + * @returns An error in the event that the source does not match the originating role + */ +export const validateSource = async ( + source: Source, + roleMetadata: RoleMetadataDao | undefined, +): Promise => { + if (!roleMetadata) { + return undefined; // Role does not exist yet, there is no conflict with the source + } + + if (roleMetadata.source !== source && roleMetadata.source !== 'legacy') { + return new Error( + `source does not match originating role ${ + roleMetadata.roleEntityRef + }, consider making changes to the '${roleMetadata.source.toLocaleUpperCase()}'`, + ); + } + + return undefined; +}; + +// This should be called on add and edit and delete +export function validatePolicy(policy: RoleBasedPolicy): Error | undefined { + const err = validateEntityReference(policy.entityReference); + if (err) { + return err; + } + + if (!policy.permission) { + return new Error(`'permission' field must not be empty`); + } + + if (!policy.policy) { + return new Error(`'policy' field must not be empty`); + } else if (!isValidPermissionAction(policy.policy)) { + return new Error( + `'policy' has invalid value: '${ + policy.policy + }'. It should be one of: ${PermissionActionValues.join(', ')}`, + ); + } + + if (!policy.effect) { + return new Error(`'effect' field must not be empty`); + } else if (!isValidEffectValue(policy.effect)) { + return new Error( + `'effect' has invalid value: '${ + policy.effect + }'. It should be: '${AuthorizeResult.ALLOW.toLocaleLowerCase()}' or '${AuthorizeResult.DENY.toLocaleLowerCase()}'`, + ); + } + + return undefined; +} + +export function validateRole(role: Role): Error | undefined { + if (!role.name) { + return new Error(`'name' field must not be empty`); + } + + let err = validateEntityReference(role.name, true); + if (err) { + return err; + } + + if (!role.memberReferences || role.memberReferences.length === 0) { + return new Error(`'memberReferences' field must not be empty`); + } + + for (const member of role.memberReferences) { + err = validateEntityReference(member); + if (err) { + return err; + } + } + + if (role.metadata && role.metadata.owner) { + err = validateEntityReference(role.metadata.owner, false, true); + if (err) { + return err; + } + } + + return undefined; +} + +function isValidEffectValue(effect: string): boolean { + return ( + effect === AuthorizeResult.ALLOW.toLocaleLowerCase() || + effect === AuthorizeResult.DENY.toLocaleLowerCase() + ); +} + +function isValidEntityName(name: string): boolean { + const validNamePattern = /^[a-zA-Z0-9]+([._-][a-zA-Z0-9]+)*$/; + return validNamePattern.test(name) && name.length <= 63; +} + +function isValidEntityNamespace(namespace: string): boolean { + const validNamespacePattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; + return validNamespacePattern.test(namespace) && namespace.length <= 63; +} + +// We supports only full form entity reference: [:][/] +export function validateEntityReference( + entityRef?: string, + role?: boolean, + owner?: boolean, +): Error | undefined { + if (!entityRef) { + return new Error(`'entityReference' must not be empty`); + } + + let entityRefCompound: CompoundEntityRef; + try { + entityRefCompound = parseEntityRef(entityRef); + } catch (err) { + return err as Error; + } + + const entityRefFull = `${entityRefCompound.kind}:${entityRefCompound.namespace}/${entityRefCompound.name}`; + if (entityRefFull !== entityRef) { + return new Error( + `entity reference '${entityRef}' does not match the required format [:][/]. Provide, please, full entity reference.`, + ); + } + + if (role && entityRefCompound.kind !== 'role') { + return new Error( + `Unsupported kind ${entityRefCompound.kind}. Supported value should be "role"`, + ); + } + + if ( + owner && + entityRefCompound.kind !== 'user' && + entityRefCompound.kind !== 'group' + ) { + return new Error( + `Unsupported kind ${entityRefCompound.kind}. List supported values ["user", "group"]`, + ); + } + + if ( + entityRefCompound.kind !== 'user' && + entityRefCompound.kind !== 'group' && + entityRefCompound.kind !== 'role' + ) { + return new Error( + `Unsupported kind ${entityRefCompound.kind}. List supported values ["user", "group", "role"]`, + ); + } + + if (!isValidEntityName(entityRefCompound.name)) { + return new Error( + `The name '${entityRefCompound.name}' in the entity reference must be a string that is sequences of [a-zA-Z0-9] separated by any of [-_.], at most 63 characters in total`, + ); + } + + if (!isValidEntityNamespace(entityRefCompound.namespace)) { + return new Error( + `The namespace '${entityRefCompound.namespace}' in the entity reference must be a string that is sequences of [a-z0-9] separated by [-], at most 63 characters in total`, + ); + } + + return undefined; +} + +export async function validateGroupingPolicy( + groupPolicy: string[], + metadata: RoleMetadataDao | undefined, + source: Source, +): Promise { + if (groupPolicy.length !== 2) { + return new Error(`Group policy should have length 2`); + } + + const member = groupPolicy[0]; + let err = validateEntityReference(member); + if (err) { + return new Error( + `Failed to validate group policy ${groupPolicy}. Cause: ${err.message}`, + ); + } + const parent = groupPolicy[1]; + err = validateEntityReference(parent); + if (err) { + return new Error( + `Failed to validate group policy ${groupPolicy}. Cause: ${err.message}`, + ); + } + if (member.startsWith(`role:`)) { + return new Error( + `Group policy is invalid: ${groupPolicy}. rbac-backend plugin doesn't support role inheritance.`, + ); + } + if (member.startsWith(`group:`) && parent.startsWith(`group:`)) { + return new Error( + `Group policy is invalid: ${groupPolicy}. Group inheritance information could be provided only with help of Catalog API.`, + ); + } + if (member.startsWith(`user:`) && parent.startsWith(`group:`)) { + return new Error( + `Group policy is invalid: ${groupPolicy}. User membership information could be provided only with help of Catalog API.`, + ); + } + + err = await validateSource(source, metadata); + if (metadata && err) { + return new NotAllowedError( + `Unable to validate role ${groupPolicy}. Cause: ${err.message}`, + ); + } + + return undefined; +} + +export const checkForDuplicatePolicies = async ( + fileEnf: Enforcer, + policy: string[], + policyFile: string, +): Promise => { + const duplicates = await fileEnf.getFilteredPolicy(0, ...policy); + if (duplicates.length > 1) { + return new Error( + `Duplicate policy: ${policy} found in the file ${policyFile}`, + ); + } + + const flipPolicyEffect = [ + policy[0], + policy[1], + policy[2], + policy[3] === 'deny' ? 'allow' : 'deny', + ]; + + // Check if the same policy exists but with a different effect + const dupWithDifferentEffect = await fileEnf.getFilteredPolicy( + 0, + ...flipPolicyEffect, + ); + + if (dupWithDifferentEffect.length > 0) { + return new Error( + `Duplicate policy: ${policy[0]}, ${policy[1]}, ${policy[2]} with different effect found in the file ${policyFile}`, + ); + } + + return undefined; +}; + +export const checkForDuplicateGroupPolicies = async ( + fileEnf: Enforcer, + policy: string[], + policyFile: string, +): Promise => { + const duplicates = await fileEnf.getFilteredGroupingPolicy(0, ...policy); + + if (duplicates.length > 1) { + return new Error( + `Duplicate role: ${policy} found in the file ${policyFile}`, + ); + } + return undefined; +}; diff --git a/plugins/rbac-backend/tsconfig.json b/plugins/rbac-backend/tsconfig.json new file mode 100644 index 0000000000..0f5dc138b2 --- /dev/null +++ b/plugins/rbac-backend/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src", "migrations", "config.d.ts"], + "exclude": ["node_modules", "**/*.test.ts", "__fixtures__"], + "compilerOptions": { + "outDir": "../../dist-types/plugins/rbac-backend", + "rootDir": ".", + "useUnknownInCatchVariables": false, + "skipLibCheck": true, + "noCheck": true + } +} diff --git a/plugins/rbac-backend/turbo.json b/plugins/rbac-backend/turbo.json new file mode 100644 index 0000000000..9fe704e3fc --- /dev/null +++ b/plugins/rbac-backend/turbo.json @@ -0,0 +1,8 @@ +{ + "extends": ["//"], + "tasks": { + "tsc": { + "outputs": ["../../dist-types/plugins/rbac-backend/**"] + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 0b1027fc1a..0502d5b032 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "outDir": "dist-types", "rootDir": ".", "skipLibCheck": true, - "jsx": "preserve" + "jsx": "preserve", + "useUnknownInCatchVariables": false } } diff --git a/yarn.lock b/yarn.lock index c7a47284b2..eea887ff22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1527,18 +1527,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.26.2, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": - version: 7.29.0 - resolution: "@babel/parser@npm:7.29.0" - dependencies: - "@babel/types": "npm:^7.29.0" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/333b2aa761264b91577a74bee86141ef733f9f9f6d4fc52548e4847dc35dfbf821f58c46832c637bfa761a6d9909d6a68f7d1ed59e17e4ffbb958dc510c17b62 - languageName: node - linkType: hard - -"@babel/parser@npm:^7.20.15": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.15, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.26.2, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": version: 7.29.2 resolution: "@babel/parser@npm:7.29.2" dependencies: @@ -2967,7 +2956,7 @@ __metadata: languageName: node linkType: hard -"@backstage/catalog-client@npm:1.14.0, @backstage/catalog-client@npm:^1.14.0": +"@backstage/catalog-client@npm:1.14.0": version: 1.14.0 resolution: "@backstage/catalog-client@npm:1.14.0" dependencies: @@ -2981,7 +2970,7 @@ __metadata: languageName: node linkType: hard -"@backstage/catalog-client@npm:^1.15.0": +"@backstage/catalog-client@npm:^1.14.0, @backstage/catalog-client@npm:^1.15.0": version: 1.15.0 resolution: "@backstage/catalog-client@npm:1.15.0" dependencies: @@ -2995,7 +2984,7 @@ __metadata: languageName: node linkType: hard -"@backstage/catalog-model@npm:1.7.7, @backstage/catalog-model@npm:^1.7.6, @backstage/catalog-model@npm:^1.7.7": +"@backstage/catalog-model@npm:1.7.7": version: 1.7.7 resolution: "@backstage/catalog-model@npm:1.7.7" dependencies: @@ -3007,7 +2996,7 @@ __metadata: languageName: node linkType: hard -"@backstage/catalog-model@npm:^1.8.0": +"@backstage/catalog-model@npm:^1.7.6, @backstage/catalog-model@npm:^1.7.7, @backstage/catalog-model@npm:^1.8.0": version: 1.8.0 resolution: "@backstage/catalog-model@npm:1.8.0" dependencies: @@ -3484,7 +3473,7 @@ __metadata: languageName: node linkType: hard -"@backstage/config@npm:1.3.6, @backstage/config@npm:^1.3.2, @backstage/config@npm:^1.3.6": +"@backstage/config@npm:1.3.6": version: 1.3.6 resolution: "@backstage/config@npm:1.3.6" dependencies: @@ -3495,7 +3484,7 @@ __metadata: languageName: node linkType: hard -"@backstage/config@npm:^1.3.7": +"@backstage/config@npm:^1.3.2, @backstage/config@npm:^1.3.6, @backstage/config@npm:^1.3.7": version: 1.3.7 resolution: "@backstage/config@npm:1.3.7" dependencies: @@ -3506,7 +3495,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-app-api@npm:1.19.6, @backstage/core-app-api@npm:^1.19.6": +"@backstage/core-app-api@npm:1.19.6": version: 1.19.6 resolution: "@backstage/core-app-api@npm:1.19.6" dependencies: @@ -3535,7 +3524,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-app-api@npm:^1.20.0": +"@backstage/core-app-api@npm:^1.19.6, @backstage/core-app-api@npm:^1.20.0": version: 1.20.0 resolution: "@backstage/core-app-api@npm:1.20.0" dependencies: @@ -3564,7 +3553,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-compat-api@npm:0.5.9, @backstage/core-compat-api@npm:^0.5.9": +"@backstage/core-compat-api@npm:0.5.9": version: 0.5.9 resolution: "@backstage/core-compat-api@npm:0.5.9" dependencies: @@ -3590,7 +3579,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-compat-api@npm:^0.5.10": +"@backstage/core-compat-api@npm:^0.5.10, @backstage/core-compat-api@npm:^0.5.9": version: 0.5.10 resolution: "@backstage/core-compat-api@npm:0.5.10" dependencies: @@ -3616,7 +3605,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-components@npm:0.18.8, @backstage/core-components@npm:^0.18.8": +"@backstage/core-components@npm:0.18.8": version: 0.18.8 resolution: "@backstage/core-components@npm:0.18.8" dependencies: @@ -3674,7 +3663,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-components@npm:^0.18.9": +"@backstage/core-components@npm:^0.18.8, @backstage/core-components@npm:^0.18.9": version: 0.18.9 resolution: "@backstage/core-components@npm:0.18.9" dependencies: @@ -3732,7 +3721,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-plugin-api@npm:1.12.4, @backstage/core-plugin-api@npm:^1.12.4": +"@backstage/core-plugin-api@npm:1.12.4": version: 1.12.4 resolution: "@backstage/core-plugin-api@npm:1.12.4" dependencies: @@ -3755,7 +3744,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-plugin-api@npm:^1.12.5": +"@backstage/core-plugin-api@npm:^1.12.4, @backstage/core-plugin-api@npm:^1.12.5": version: 1.12.5 resolution: "@backstage/core-plugin-api@npm:1.12.5" dependencies: @@ -3778,7 +3767,7 @@ __metadata: languageName: node linkType: hard -"@backstage/errors@npm:1.2.7, @backstage/errors@npm:^1.2.7": +"@backstage/errors@npm:1.2.7": version: 1.2.7 resolution: "@backstage/errors@npm:1.2.7" dependencies: @@ -3788,7 +3777,7 @@ __metadata: languageName: node linkType: hard -"@backstage/errors@npm:^1.3.0": +"@backstage/errors@npm:^1.2.7, @backstage/errors@npm:^1.3.0": version: 1.3.0 resolution: "@backstage/errors@npm:1.3.0" dependencies: @@ -3808,20 +3797,7 @@ __metadata: languageName: node linkType: hard -"@backstage/filter-predicates@npm:^0.1.1": - version: 0.1.1 - resolution: "@backstage/filter-predicates@npm:0.1.1" - dependencies: - "@backstage/config": "npm:^1.3.6" - "@backstage/errors": "npm:^1.2.7" - "@backstage/types": "npm:^1.2.2" - zod: "npm:^3.25.76 || ^4.0.0" - zod-validation-error: "npm:^4.0.2" - checksum: 10c0/f4bce2259af0e953ef30d292394aeea614ae42fbd825678a3abc36a97a6020a9964f40046aa3dc189f040ecf9674fb30dbcbbfd2ff3f807a513ad0353094bea5 - languageName: node - linkType: hard - -"@backstage/filter-predicates@npm:^0.1.2": +"@backstage/filter-predicates@npm:^0.1.1, @backstage/filter-predicates@npm:^0.1.2": version: 0.1.2 resolution: "@backstage/filter-predicates@npm:0.1.2" dependencies: @@ -4004,7 +3980,7 @@ __metadata: languageName: node linkType: hard -"@backstage/integration-react@npm:1.2.16, @backstage/integration-react@npm:^1.2.16": +"@backstage/integration-react@npm:1.2.16": version: 1.2.16 resolution: "@backstage/integration-react@npm:1.2.16" dependencies: @@ -4025,7 +4001,7 @@ __metadata: languageName: node linkType: hard -"@backstage/integration-react@npm:^1.2.17": +"@backstage/integration-react@npm:^1.2.16, @backstage/integration-react@npm:^1.2.17": version: 1.2.17 resolution: "@backstage/integration-react@npm:1.2.17" dependencies: @@ -4046,26 +4022,7 @@ __metadata: languageName: node linkType: hard -"@backstage/integration@npm:^2.0.0": - version: 2.0.0 - resolution: "@backstage/integration@npm:2.0.0" - dependencies: - "@azure/identity": "npm:^4.0.0" - "@azure/storage-blob": "npm:^12.5.0" - "@backstage/config": "npm:^1.3.6" - "@backstage/errors": "npm:^1.2.7" - "@octokit/auth-app": "npm:^4.0.0" - "@octokit/rest": "npm:^19.0.3" - cross-fetch: "npm:^4.0.0" - git-url-parse: "npm:^15.0.0" - lodash: "npm:^4.17.21" - luxon: "npm:^3.0.0" - p-throttle: "npm:^4.1.1" - checksum: 10c0/d7e0e45cc11277ca2b843f98d15df8150b8c264852b734279a1965ccc81ef2724871e048aa1b0c4b3fe656c041d96ab0ca8c97db2bd236582d8f4349a93cd5cd - languageName: node - linkType: hard - -"@backstage/integration@npm:^2.0.1": +"@backstage/integration@npm:^2.0.0, @backstage/integration@npm:^2.0.1": version: 2.0.1 resolution: "@backstage/integration@npm:2.0.1" dependencies: @@ -4181,26 +4138,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-app-react@npm:^0.2.1": - version: 0.2.1 - resolution: "@backstage/plugin-app-react@npm:0.2.1" - dependencies: - "@backstage/core-plugin-api": "npm:^1.12.4" - "@backstage/frontend-plugin-api": "npm:^0.15.0" - "@material-ui/core": "npm:^4.9.13" - peerDependencies: - "@types/react": ^17.0.0 || ^18.0.0 - react: ^17.0.0 || ^18.0.0 - react-dom: ^17.0.0 || ^18.0.0 - react-router-dom: ^6.30.2 - peerDependenciesMeta: - "@types/react": - optional: true - checksum: 10c0/e343bc8b67105bd1484824c66628005e8bfc8f5815960a7fd894ddb7ca3c14915c778095c549591f78cbe9a8f3acd4cfed565b5ffc569a2d7f07555ba5ab1886 - languageName: node - linkType: hard - -"@backstage/plugin-app-react@npm:^0.2.2": +"@backstage/plugin-app-react@npm:^0.2.1, @backstage/plugin-app-react@npm:^0.2.2": version: 0.2.2 resolution: "@backstage/plugin-app-react@npm:0.2.2" dependencies: @@ -4663,7 +4601,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-catalog-common@npm:1.1.8, @backstage/plugin-catalog-common@npm:^1.1.8": +"@backstage/plugin-catalog-common@npm:1.1.8": version: 1.1.8 resolution: "@backstage/plugin-catalog-common@npm:1.1.8" dependencies: @@ -4674,7 +4612,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-catalog-common@npm:^1.1.9": +"@backstage/plugin-catalog-common@npm:^1.1.8, @backstage/plugin-catalog-common@npm:^1.1.9": version: 1.1.9 resolution: "@backstage/plugin-catalog-common@npm:1.1.9" dependencies: @@ -4780,7 +4718,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-catalog-react@npm:2.1.1, @backstage/plugin-catalog-react@npm:^2.1.0": +"@backstage/plugin-catalog-react@npm:2.1.1": version: 2.1.1 resolution: "@backstage/plugin-catalog-react@npm:2.1.1" dependencies: @@ -4825,7 +4763,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-catalog-react@npm:^2.1.2": +"@backstage/plugin-catalog-react@npm:^2.1.0, @backstage/plugin-catalog-react@npm:^2.1.2": version: 2.1.4 resolution: "@backstage/plugin-catalog-react@npm:2.1.4" dependencies: @@ -5108,7 +5046,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-permission-common@npm:0.9.7, @backstage/plugin-permission-common@npm:^0.9.6, @backstage/plugin-permission-common@npm:^0.9.7": +"@backstage/plugin-permission-common@npm:0.9.7": version: 0.9.7 resolution: "@backstage/plugin-permission-common@npm:0.9.7" dependencies: @@ -5123,7 +5061,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-permission-common@npm:^0.9.8": +"@backstage/plugin-permission-common@npm:^0.9.6, @backstage/plugin-permission-common@npm:^0.9.7, @backstage/plugin-permission-common@npm:^0.9.8": version: 0.9.8 resolution: "@backstage/plugin-permission-common@npm:0.9.8" dependencies: @@ -5138,7 +5076,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-permission-node@npm:^0.10.11": +"@backstage/plugin-permission-node@npm:0.10.11, @backstage/plugin-permission-node@npm:^0.10.11": version: 0.10.11 resolution: "@backstage/plugin-permission-node@npm:0.10.11" dependencies: @@ -5510,7 +5448,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-search-common@npm:1.2.22, @backstage/plugin-search-common@npm:^1.2.22": +"@backstage/plugin-search-common@npm:1.2.22": version: 1.2.22 resolution: "@backstage/plugin-search-common@npm:1.2.22" dependencies: @@ -5520,7 +5458,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-search-common@npm:^1.2.23": +"@backstage/plugin-search-common@npm:^1.2.22, @backstage/plugin-search-common@npm:^1.2.23": version: 1.2.23 resolution: "@backstage/plugin-search-common@npm:1.2.23" dependencies: @@ -5816,7 +5754,7 @@ __metadata: languageName: node linkType: hard -"@backstage/theme@npm:0.7.2, @backstage/theme@npm:^0.7.2": +"@backstage/theme@npm:0.7.2": version: 0.7.2 resolution: "@backstage/theme@npm:0.7.2" dependencies: @@ -5836,7 +5774,7 @@ __metadata: languageName: node linkType: hard -"@backstage/theme@npm:^0.7.3": +"@backstage/theme@npm:^0.7.2, @backstage/theme@npm:^0.7.3": version: 0.7.3 resolution: "@backstage/theme@npm:0.7.3" dependencies: @@ -5956,6 +5894,15 @@ __metadata: languageName: node linkType: hard +"@casbin/expression-eval@npm:^5.3.0": + version: 5.3.0 + resolution: "@casbin/expression-eval@npm:5.3.0" + dependencies: + jsep: "npm:^0.3.0" + checksum: 10c0/1fa2fd703036b065821fbeb8d0f0c274ba50331737d19b3a77b7c9cd571f5df2580145bda1d90f2dd46863a66aae9f5256974eb168b7ccbb9facbcb796f5cb7a + languageName: node + linkType: hard + "@changesets/types@npm:^4.0.1": version: 4.1.0 resolution: "@changesets/types@npm:4.1.0" @@ -6161,6 +6108,13 @@ __metadata: languageName: node linkType: hard +"@dagrejs/graphlib@npm:^4.0.0": + version: 4.0.1 + resolution: "@dagrejs/graphlib@npm:4.0.1" + checksum: 10c0/03ab574f2eb7d87173af0b9d8bbae87c10e225778b8144a800c663afe307ff71d851c13d96d32ec91db85e325d64914cdabbab1ce76fb043e0a5538e60bb51bd + languageName: node + linkType: hard + "@date-io/core@npm:1.x, @date-io/core@npm:^1.3.13": version: 1.3.13 resolution: "@date-io/core@npm:1.3.13" @@ -7421,6 +7375,48 @@ __metadata: languageName: unknown linkType: soft +"@internal/plugin-rbac-backend@npm:*, @internal/plugin-rbac-backend@workspace:plugins/rbac-backend": + version: 0.0.0-use.local + resolution: "@internal/plugin-rbac-backend@workspace:plugins/rbac-backend" + dependencies: + "@azure/identity": "npm:^4.0.0" + "@backstage-community/plugin-rbac-common": "npm:1.26.1" + "@backstage-community/plugin-rbac-node": "npm:1.20.1" + "@backstage/backend-defaults": "npm:0.16.0" + "@backstage/backend-plugin-api": "npm:1.8.0" + "@backstage/backend-test-utils": "npm:1.11.1" + "@backstage/catalog-client": "npm:1.14.0" + "@backstage/catalog-model": "npm:1.7.7" + "@backstage/cli": "npm:0.36.0" + "@backstage/config": "npm:1.3.6" + "@backstage/core-plugin-api": "npm:1.12.4" + "@backstage/errors": "npm:^1.2.7" + "@backstage/plugin-catalog-node": "npm:2.1.0" + "@backstage/plugin-permission-common": "npm:0.9.7" + "@backstage/plugin-permission-node": "npm:0.10.11" + "@backstage/types": "npm:^1.2.2" + "@dagrejs/graphlib": "npm:^4.0.0" + "@types/express": "npm:4.17.25" + "@types/js-yaml": "npm:^4.0.9" + "@types/lodash": "npm:^4.14.151" + "@types/node": "npm:22.19.17" + "@types/supertest": "npm:7.2.0" + casbin: "npm:5.27.1" + chokidar: "npm:^3.6.0" + csv-parse: "npm:^6.0.0" + express: "npm:^4.18.2" + express-promise-router: "npm:^4.1.0" + js-yaml: "npm:^4.1.0" + knex: "npm:^3.0.0" + knex-mock-client: "npm:3.0.2" + lodash: "npm:^4.17.21" + qs: "npm:6.15.1" + supertest: "npm:7.2.2" + typeorm-adapter: "npm:^1.6.1" + zod: "npm:^4.3.6" + languageName: unknown + linkType: soft + "@internal/plugin-scalprum-backend@npm:*, @internal/plugin-scalprum-backend@workspace:plugins/scalprum-backend": version: 0.0.0-use.local resolution: "@internal/plugin-scalprum-backend@workspace:plugins/scalprum-backend" @@ -7446,16 +7442,7 @@ __metadata: languageName: unknown linkType: soft -"@internationalized/date@npm:^3.11.0, @internationalized/date@npm:^3.12.0": - version: 3.12.0 - resolution: "@internationalized/date@npm:3.12.0" - dependencies: - "@swc/helpers": "npm:^0.5.0" - checksum: 10c0/6a26495d32f010b227a1f506da02cdf8438506014b41cfb81576c707a3dfe3d0fd207f80bcf28acd9eef8248a2c2da115cf9016515d513653ea1b22a796d0246 - languageName: node - linkType: hard - -"@internationalized/date@npm:^3.12.1": +"@internationalized/date@npm:^3.12.0, @internationalized/date@npm:^3.12.1": version: 3.12.1 resolution: "@internationalized/date@npm:3.12.1" dependencies: @@ -7474,16 +7461,7 @@ __metadata: languageName: node linkType: hard -"@internationalized/number@npm:^3.6.5": - version: 3.6.5 - resolution: "@internationalized/number@npm:3.6.5" - dependencies: - "@swc/helpers": "npm:^0.5.0" - checksum: 10c0/f87d710863a8dbf057aac311193c82f3c42e862abdd99e5b71034f1022926036552620eab5dd00c23e975f28b9e41e830cb342ba0264436749d9cdc5ae031d44 - languageName: node - linkType: hard - -"@internationalized/number@npm:^3.6.6": +"@internationalized/number@npm:^3.6.5, @internationalized/number@npm:^3.6.6": version: 3.6.6 resolution: "@internationalized/number@npm:3.6.6" dependencies: @@ -7492,16 +7470,7 @@ __metadata: languageName: node linkType: hard -"@internationalized/string@npm:^3.2.7": - version: 3.2.7 - resolution: "@internationalized/string@npm:3.2.7" - dependencies: - "@swc/helpers": "npm:^0.5.0" - checksum: 10c0/8f7bea379ce047026ef20d535aa1bd7612a5e5a5108d1e514965696a46bce34e38111411943b688d00dae2c81eae7779ae18343961310696d32ebb463a19b94a - languageName: node - linkType: hard - -"@internationalized/string@npm:^3.2.8": +"@internationalized/string@npm:^3.2.7, @internationalized/string@npm:^3.2.8": version: 3.2.8 resolution: "@internationalized/string@npm:3.2.8" dependencies: @@ -10007,17 +9976,6 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/core@npm:2.5.1": - version: 2.5.1 - resolution: "@opentelemetry/core@npm:2.5.1" - dependencies: - "@opentelemetry/semantic-conventions": "npm:^1.29.0" - peerDependencies: - "@opentelemetry/api": ">=1.0.0 <1.10.0" - checksum: 10c0/cbaf36953364d1295ef2ff4587c3f99eca121c7c2dbd2553699100ccbd91017f20fb1a710ac76fad832d9762dc98ae009ce0e96ab8fb00e5b539dc401d57f217 - languageName: node - linkType: hard - "@opentelemetry/core@npm:2.7.1, @opentelemetry/core@npm:^2.0.0": version: 2.7.1 resolution: "@opentelemetry/core@npm:2.7.1" @@ -10885,7 +10843,7 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/resources@npm:2.7.1": +"@opentelemetry/resources@npm:2.7.1, @opentelemetry/resources@npm:^2.0.0": version: 2.7.1 resolution: "@opentelemetry/resources@npm:2.7.1" dependencies: @@ -10897,18 +10855,6 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/resources@npm:^2.0.0": - version: 2.5.1 - resolution: "@opentelemetry/resources@npm:2.5.1" - dependencies: - "@opentelemetry/core": "npm:2.5.1" - "@opentelemetry/semantic-conventions": "npm:^1.29.0" - peerDependencies: - "@opentelemetry/api": ">=1.3.0 <1.10.0" - checksum: 10c0/c336d5066fa7457272bcffb5a9826f090e1e07c2a70c5976942cf2bb188be685842658982a0f323ddfc1d6fbc364f123b6b0e433e230b023aefd88ec60062ba4 - languageName: node - linkType: hard - "@opentelemetry/sdk-logs@npm:0.218.0": version: 0.218.0 resolution: "@opentelemetry/sdk-logs@npm:0.218.0" @@ -10996,20 +10942,13 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/semantic-conventions@npm:^1.24.0, @opentelemetry/semantic-conventions@npm:^1.33.0, @opentelemetry/semantic-conventions@npm:^1.33.1, @opentelemetry/semantic-conventions@npm:^1.34.0, @opentelemetry/semantic-conventions@npm:^1.36.0, @opentelemetry/semantic-conventions@npm:^1.37.0": +"@opentelemetry/semantic-conventions@npm:^1.24.0, @opentelemetry/semantic-conventions@npm:^1.27.0, @opentelemetry/semantic-conventions@npm:^1.29.0, @opentelemetry/semantic-conventions@npm:^1.30.0, @opentelemetry/semantic-conventions@npm:^1.33.0, @opentelemetry/semantic-conventions@npm:^1.33.1, @opentelemetry/semantic-conventions@npm:^1.34.0, @opentelemetry/semantic-conventions@npm:^1.36.0, @opentelemetry/semantic-conventions@npm:^1.37.0": version: 1.41.1 resolution: "@opentelemetry/semantic-conventions@npm:1.41.1" checksum: 10c0/c54b1edf845766e93026d30fd95e15da9dba8d7a5b58f8c320c5d36ab542c77b37868f3e8e3d78ec162da8ee2afd24781f0a65934c9bdbc1aea86b47b12f074c languageName: node linkType: hard -"@opentelemetry/semantic-conventions@npm:^1.27.0, @opentelemetry/semantic-conventions@npm:^1.29.0, @opentelemetry/semantic-conventions@npm:^1.30.0": - version: 1.40.0 - resolution: "@opentelemetry/semantic-conventions@npm:1.40.0" - checksum: 10c0/3259de0ea11b52eb70e44c12eba21448392baf9cb74c37b62071c4a5ed7fb89b61e194f3898d40ac6bfa7293617a0e132876cb6e355472b66de0cdb13c50b529 - languageName: node - linkType: hard - "@opentelemetry/sql-common@npm:^0.41.2": version: 0.41.2 resolution: "@opentelemetry/sql-common@npm:0.41.2" @@ -11961,48 +11900,6 @@ __metadata: languageName: node linkType: hard -"@react-aria/autocomplete@npm:3.0.0-rc.5": - version: 3.0.0-rc.5 - resolution: "@react-aria/autocomplete@npm:3.0.0-rc.5" - dependencies: - "@react-aria/combobox": "npm:^3.14.2" - "@react-aria/focus": "npm:^3.21.4" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/listbox": "npm:^3.15.2" - "@react-aria/searchfield": "npm:^3.8.11" - "@react-aria/textfield": "npm:^3.18.4" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/autocomplete": "npm:3.0.0-beta.4" - "@react-stately/combobox": "npm:^3.12.2" - "@react-types/autocomplete": "npm:3.0.0-alpha.37" - "@react-types/button": "npm:^3.15.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/9ae82bc6e271dac7ae1d7612900520ab3e14215f6af1af752a6564dbf7bed90386ff4080c0ff67e840234f155e85a5e189c6e24c92275980eb24a3d880c84def - languageName: node - linkType: hard - -"@react-aria/breadcrumbs@npm:^3.5.31": - version: 3.5.31 - resolution: "@react-aria/breadcrumbs@npm:3.5.31" - dependencies: - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/link": "npm:^3.8.8" - "@react-aria/utils": "npm:^3.33.0" - "@react-types/breadcrumbs": "npm:^3.7.18" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/b28dc7b5def4a742f652cbf5237c91cd15fa5ddf6e90517ad4a55f5f7b78a8f940df0a10033be19413ad29cb31a6e1b8338a7572c9712e3814726fe7e3df0fb0 - languageName: node - linkType: hard - "@react-aria/button@npm:^3.14.3": version: 3.14.5 resolution: "@react-aria/button@npm:3.14.5" @@ -12021,220 +11918,7 @@ __metadata: languageName: node linkType: hard -"@react-aria/button@npm:^3.14.4": - version: 3.14.4 - resolution: "@react-aria/button@npm:3.14.4" - dependencies: - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/toolbar": "npm:3.0.0-beta.23" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/toggle": "npm:^3.9.4" - "@react-types/button": "npm:^3.15.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/99b640d9d50478c36c57eb0be05ad6dc86f9ac0f80501c5d73c1ae316d958ed07bfb4ec8b61fd31093fb6b62491df5422e3bff229eb86be1e43dcda7cdd41350 - languageName: node - linkType: hard - -"@react-aria/calendar@npm:^3.9.4": - version: 3.9.4 - resolution: "@react-aria/calendar@npm:3.9.4" - dependencies: - "@internationalized/date": "npm:^3.11.0" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/live-announcer": "npm:^3.4.4" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/calendar": "npm:^3.9.2" - "@react-types/button": "npm:^3.15.0" - "@react-types/calendar": "npm:^3.8.2" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/f016f4908b06d943fff28794d4ba07f70f8da906be0f24d5f9752e705eccb19d4195d25e9ac09206cdd9ab9616c430b617f9753d0383b04c082206e8fcc67ebf - languageName: node - linkType: hard - -"@react-aria/checkbox@npm:^3.16.4": - version: 3.16.4 - resolution: "@react-aria/checkbox@npm:3.16.4" - dependencies: - "@react-aria/form": "npm:^3.1.4" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/label": "npm:^3.7.24" - "@react-aria/toggle": "npm:^3.12.4" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/checkbox": "npm:^3.7.4" - "@react-stately/form": "npm:^3.2.3" - "@react-stately/toggle": "npm:^3.9.4" - "@react-types/checkbox": "npm:^3.10.3" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/8285efd0d790a0f08c97998f167e5c44af1ed87b6b9606a0a3769df1c881dcea45339b2ce48b84ec657fdc82e11710be2acaf5e7588eadff9ba3a6c4321b615e - languageName: node - linkType: hard - -"@react-aria/collections@npm:^3.0.2": - version: 3.0.2 - resolution: "@react-aria/collections@npm:3.0.2" - dependencies: - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/ssr": "npm:^3.9.10" - "@react-aria/utils": "npm:^3.33.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - use-sync-external-store: "npm:^1.6.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/2476975c72c8804b5f588cf58677ce9f05255651d14f938540049e49eee6e9411b1a791ddd6ba5571bcff19ada709089f1b78724ad7a2c291578ea9632c6841a - languageName: node - linkType: hard - -"@react-aria/color@npm:^3.1.4": - version: 3.1.4 - resolution: "@react-aria/color@npm:3.1.4" - dependencies: - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/numberfield": "npm:^3.12.4" - "@react-aria/slider": "npm:^3.8.4" - "@react-aria/spinbutton": "npm:^3.7.1" - "@react-aria/textfield": "npm:^3.18.4" - "@react-aria/utils": "npm:^3.33.0" - "@react-aria/visually-hidden": "npm:^3.8.30" - "@react-stately/color": "npm:^3.9.4" - "@react-stately/form": "npm:^3.2.3" - "@react-types/color": "npm:^3.1.3" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/7081040b2e508fd1260667bcf3c4ca2bf90e5b014dae9b32947feab0fcee3c95cdfeb3afcd0b1f8e772cf9a8cfd9669c0f5ace8c25341d086c8cff8f8a81efaa - languageName: node - linkType: hard - -"@react-aria/combobox@npm:^3.14.2": - version: 3.14.2 - resolution: "@react-aria/combobox@npm:3.14.2" - dependencies: - "@react-aria/focus": "npm:^3.21.4" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/listbox": "npm:^3.15.2" - "@react-aria/live-announcer": "npm:^3.4.4" - "@react-aria/menu": "npm:^3.20.0" - "@react-aria/overlays": "npm:^3.31.1" - "@react-aria/selection": "npm:^3.27.1" - "@react-aria/textfield": "npm:^3.18.4" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/collections": "npm:^3.12.9" - "@react-stately/combobox": "npm:^3.12.2" - "@react-stately/form": "npm:^3.2.3" - "@react-types/button": "npm:^3.15.0" - "@react-types/combobox": "npm:^3.13.11" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/83251e2be09aa6017f140071bf3763a26be95aa91966cb573da804d804b68f36762338ce08f085e31fa3266db49488cd289c19b28e19faf13c294795db54007b - languageName: node - linkType: hard - -"@react-aria/datepicker@npm:^3.16.0": - version: 3.16.0 - resolution: "@react-aria/datepicker@npm:3.16.0" - dependencies: - "@internationalized/date": "npm:^3.11.0" - "@internationalized/number": "npm:^3.6.5" - "@internationalized/string": "npm:^3.2.7" - "@react-aria/focus": "npm:^3.21.4" - "@react-aria/form": "npm:^3.1.4" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/label": "npm:^3.7.24" - "@react-aria/spinbutton": "npm:^3.7.1" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/datepicker": "npm:^3.16.0" - "@react-stately/form": "npm:^3.2.3" - "@react-types/button": "npm:^3.15.0" - "@react-types/calendar": "npm:^3.8.2" - "@react-types/datepicker": "npm:^3.13.4" - "@react-types/dialog": "npm:^3.5.23" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/e100649593ae417fe8567781969eaecd7df016da1988747b4170a92993e327a08ed0cfef4b07a6cc523bb31d9484ddde0aeaa22a0e93d48cd0a3a3f4fbd166f3 - languageName: node - linkType: hard - -"@react-aria/dialog@npm:^3.5.33": - version: 3.5.33 - resolution: "@react-aria/dialog@npm:3.5.33" - dependencies: - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/overlays": "npm:^3.31.1" - "@react-aria/utils": "npm:^3.33.0" - "@react-types/dialog": "npm:^3.5.23" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/8ccaee23239942d010c37c4e8e5a8a75163ea6ece8060e8f581148c2df99d46b4a59585c1d9f1a6a9f3ee29e91788bd43c900ad1d2f65c6da505e1b504e6ea46 - languageName: node - linkType: hard - -"@react-aria/disclosure@npm:^3.1.2": - version: 3.1.2 - resolution: "@react-aria/disclosure@npm:3.1.2" - dependencies: - "@react-aria/ssr": "npm:^3.9.10" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/disclosure": "npm:^3.0.10" - "@react-types/button": "npm:^3.15.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/dd8f01fe5b27e571a9c79edf58f815f7c86cac58e0a07de079ff100f4459d594dca150935fe142130eca6a5822ff6ccead93b10bc215e6aaeb6c697fa1e8f994 - languageName: node - linkType: hard - -"@react-aria/dnd@npm:^3.11.5": - version: 3.11.5 - resolution: "@react-aria/dnd@npm:3.11.5" - dependencies: - "@internationalized/string": "npm:^3.2.7" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/live-announcer": "npm:^3.4.4" - "@react-aria/overlays": "npm:^3.31.1" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/collections": "npm:^3.12.9" - "@react-stately/dnd": "npm:^3.7.3" - "@react-types/button": "npm:^3.15.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/f1a2b5ca3702950e54271bbfa4ea163a23cdfe6127e45421a0100037e575d516900fef9753533afdc109d4d010def29d23b955a0b4f1d2d62f93aa3bdc85b134 - languageName: node - linkType: hard - -"@react-aria/focus@npm:^3.21.4, @react-aria/focus@npm:^3.21.5": +"@react-aria/focus@npm:^3.21.5": version: 3.21.5 resolution: "@react-aria/focus@npm:3.21.5" dependencies: @@ -12250,68 +11934,7 @@ __metadata: languageName: node linkType: hard -"@react-aria/form@npm:^3.1.4": - version: 3.1.4 - resolution: "@react-aria/form@npm:3.1.4" - dependencies: - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/form": "npm:^3.2.3" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/d1fe8bf88f6cd5d1a3a065ea2c753809627f4db1395fc5acf0c3e213b060a5e823a7453bb966b4d71da9455f688150f4f5c5813fa306a34bfaed7d1e49794c79 - languageName: node - linkType: hard - -"@react-aria/grid@npm:^3.14.7": - version: 3.14.7 - resolution: "@react-aria/grid@npm:3.14.7" - dependencies: - "@react-aria/focus": "npm:^3.21.4" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/live-announcer": "npm:^3.4.4" - "@react-aria/selection": "npm:^3.27.1" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/collections": "npm:^3.12.9" - "@react-stately/grid": "npm:^3.11.8" - "@react-stately/selection": "npm:^3.20.8" - "@react-types/checkbox": "npm:^3.10.3" - "@react-types/grid": "npm:^3.3.7" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/a5668d8cd0d8b90e32a0af1b887a5e4859e6240ad895215d904a553e88607d7950c24ed55f0a75e6a189afe26f264be3e4a6094a54d5735fc3c0765067050d46 - languageName: node - linkType: hard - -"@react-aria/gridlist@npm:^3.14.3": - version: 3.14.3 - resolution: "@react-aria/gridlist@npm:3.14.3" - dependencies: - "@react-aria/focus": "npm:^3.21.4" - "@react-aria/grid": "npm:^3.14.7" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/selection": "npm:^3.27.1" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/list": "npm:^3.13.3" - "@react-stately/tree": "npm:^3.9.5" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/e5f03bd92411053801a6739225716548f1f5bacef2247a112ec913d4de42f0847ab310dd32479d366caa929fd0d6858080c19714925a0814c52dae947937e43a - languageName: node - linkType: hard - -"@react-aria/i18n@npm:^3.12.15, @react-aria/i18n@npm:^3.12.16": +"@react-aria/i18n@npm:^3.12.16": version: 3.12.16 resolution: "@react-aria/i18n@npm:3.12.16" dependencies: @@ -12330,7 +11953,7 @@ __metadata: languageName: node linkType: hard -"@react-aria/interactions@npm:^3.27.0, @react-aria/interactions@npm:^3.27.1": +"@react-aria/interactions@npm:^3.27.1": version: 3.27.1 resolution: "@react-aria/interactions@npm:3.27.1" dependencies: @@ -12346,20 +11969,6 @@ __metadata: languageName: node linkType: hard -"@react-aria/label@npm:^3.7.24": - version: 3.7.24 - resolution: "@react-aria/label@npm:3.7.24" - dependencies: - "@react-aria/utils": "npm:^3.33.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/0d34ab2479c65255f1b1af22d35fa63bda36058d85bb281f614371cbcac8276a86357456c15b2e2c3105d98a5b0685b81af5dbe64ac3b1140dda581cdc39b5ca - languageName: node - linkType: hard - "@react-aria/landmark@npm:^3.0.10": version: 3.0.10 resolution: "@react-aria/landmark@npm:3.0.10" @@ -12375,300 +11984,6 @@ __metadata: languageName: node linkType: hard -"@react-aria/landmark@npm:^3.0.9": - version: 3.0.9 - resolution: "@react-aria/landmark@npm:3.0.9" - dependencies: - "@react-aria/utils": "npm:^3.33.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - use-sync-external-store: "npm:^1.6.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/e430a5cf7517d6a674ea6ac8a76d55cede471f4991385a1acae70771b136fb21636fb28cca2e5dfe4d362a557eb9abc9200421fb2749f3cf5ed51980a06ad744 - languageName: node - linkType: hard - -"@react-aria/link@npm:^3.8.8": - version: 3.8.8 - resolution: "@react-aria/link@npm:3.8.8" - dependencies: - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/utils": "npm:^3.33.0" - "@react-types/link": "npm:^3.6.6" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/b86b2c0fc148c7ac810cc94f9228fd80aa6a00e8853ca0a0ad5c25e1c63ef26f5f712979a0fe6882e9f9f82be053f63e3a8f7b5a9380d902ca0d71cedbae50e4 - languageName: node - linkType: hard - -"@react-aria/listbox@npm:^3.15.2": - version: 3.15.2 - resolution: "@react-aria/listbox@npm:3.15.2" - dependencies: - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/label": "npm:^3.7.24" - "@react-aria/selection": "npm:^3.27.1" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/collections": "npm:^3.12.9" - "@react-stately/list": "npm:^3.13.3" - "@react-types/listbox": "npm:^3.7.5" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/3ada41c1cf0191a669ca80d31091228a5586f0342533318a5faa0f4233d8e987aa0b3b8ff4a9d3d8276eedd295f5a184976513dfeaea041f2166009296e29260 - languageName: node - linkType: hard - -"@react-aria/live-announcer@npm:^3.4.4": - version: 3.4.4 - resolution: "@react-aria/live-announcer@npm:3.4.4" - dependencies: - "@swc/helpers": "npm:^0.5.0" - checksum: 10c0/1598372e773ee8dbb2f1d2a946652384f5140ab54106416e2a182c72eaabc1b3739e624bac7aea3d95429ba16487074c782ff90db093be36dd1d4cf84f9f9a17 - languageName: node - linkType: hard - -"@react-aria/menu@npm:^3.20.0": - version: 3.20.0 - resolution: "@react-aria/menu@npm:3.20.0" - dependencies: - "@react-aria/focus": "npm:^3.21.4" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/overlays": "npm:^3.31.1" - "@react-aria/selection": "npm:^3.27.1" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/collections": "npm:^3.12.9" - "@react-stately/menu": "npm:^3.9.10" - "@react-stately/selection": "npm:^3.20.8" - "@react-stately/tree": "npm:^3.9.5" - "@react-types/button": "npm:^3.15.0" - "@react-types/menu": "npm:^3.10.6" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/8a0462bbbad8a50b7e3ecfbcfd6005942ff73c71ad061ac7406c1e80cbdb25ce60be413f385fcec323861f2e57b3a4437c169a78c3104937e3f2a6191beb3e1e - languageName: node - linkType: hard - -"@react-aria/meter@npm:^3.4.29": - version: 3.4.29 - resolution: "@react-aria/meter@npm:3.4.29" - dependencies: - "@react-aria/progress": "npm:^3.4.29" - "@react-types/meter": "npm:^3.4.14" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/4937814a69bcaef6de28619fee31c41cb8fdb97813e25317be97a5700da27f3ad54d2db6a5cebae2d576b1c125c7d5ee5e7559a645f895f420e0f1b840e04c13 - languageName: node - linkType: hard - -"@react-aria/numberfield@npm:^3.12.4": - version: 3.12.4 - resolution: "@react-aria/numberfield@npm:3.12.4" - dependencies: - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/spinbutton": "npm:^3.7.1" - "@react-aria/textfield": "npm:^3.18.4" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/form": "npm:^3.2.3" - "@react-stately/numberfield": "npm:^3.10.4" - "@react-types/button": "npm:^3.15.0" - "@react-types/numberfield": "npm:^3.8.17" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/a454ec9a9ce707706f75f0382a352d8a1c9020d176fc590d550703ebb8d3799ded954337592e36aa8fc2d54d59dfa891db75773e095a5fd4b416905a01bd693b - languageName: node - linkType: hard - -"@react-aria/overlays@npm:^3.31.1": - version: 3.31.1 - resolution: "@react-aria/overlays@npm:3.31.1" - dependencies: - "@react-aria/focus": "npm:^3.21.4" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/ssr": "npm:^3.9.10" - "@react-aria/utils": "npm:^3.33.0" - "@react-aria/visually-hidden": "npm:^3.8.30" - "@react-stately/overlays": "npm:^3.6.22" - "@react-types/button": "npm:^3.15.0" - "@react-types/overlays": "npm:^3.9.3" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/5de1c072d665547fa759446ef5c47a34d9c0fc8e404adf2d43b2ffc446940299dd28187ad04be9b5ceb1678d85c65c1634dfa5aae55d9739070c52adf4b5652c - languageName: node - linkType: hard - -"@react-aria/progress@npm:^3.4.29": - version: 3.4.29 - resolution: "@react-aria/progress@npm:3.4.29" - dependencies: - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/label": "npm:^3.7.24" - "@react-aria/utils": "npm:^3.33.0" - "@react-types/progress": "npm:^3.5.17" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/befc2f5bd31a536ace54471b07b446b6f740220d96537ead71f4cd917d68d9a0b00c6dca9ca6ffeed7f9e9a441d59e8e65fe14244f15f0c171909dfbe2948790 - languageName: node - linkType: hard - -"@react-aria/radio@npm:^3.12.4": - version: 3.12.4 - resolution: "@react-aria/radio@npm:3.12.4" - dependencies: - "@react-aria/focus": "npm:^3.21.4" - "@react-aria/form": "npm:^3.1.4" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/label": "npm:^3.7.24" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/radio": "npm:^3.11.4" - "@react-types/radio": "npm:^3.9.3" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/cee7ba4c75d9275cd9b0dd2796ee7ad56dadd56c72377db36646a72348a8b457bb9c11fada976e73df7ae59e8a9df5a15eb5fbceeb2677a048a4334ef74a1510 - languageName: node - linkType: hard - -"@react-aria/searchfield@npm:^3.8.11": - version: 3.8.11 - resolution: "@react-aria/searchfield@npm:3.8.11" - dependencies: - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/textfield": "npm:^3.18.4" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/searchfield": "npm:^3.5.18" - "@react-types/button": "npm:^3.15.0" - "@react-types/searchfield": "npm:^3.6.7" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/816eaf3e71cf70f15d75e697f5be88397569788f82f2d1b3de199d96caeeadff2542153b811781ba5e7e60b1131b5f8ded4074db66a85f25573b02235d18b9eb - languageName: node - linkType: hard - -"@react-aria/select@npm:^3.17.2": - version: 3.17.2 - resolution: "@react-aria/select@npm:3.17.2" - dependencies: - "@react-aria/form": "npm:^3.1.4" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/label": "npm:^3.7.24" - "@react-aria/listbox": "npm:^3.15.2" - "@react-aria/menu": "npm:^3.20.0" - "@react-aria/selection": "npm:^3.27.1" - "@react-aria/utils": "npm:^3.33.0" - "@react-aria/visually-hidden": "npm:^3.8.30" - "@react-stately/select": "npm:^3.9.1" - "@react-types/button": "npm:^3.15.0" - "@react-types/select": "npm:^3.12.1" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/a58f08229dd5b82faba4c51cf69e89219aa0dff716ae50ee9546ef6794d0659e3557c15015a44aa3071abf9b3dcb2ebc6943cd6e7028ad5d58cdf65053e0359f - languageName: node - linkType: hard - -"@react-aria/selection@npm:^3.27.1": - version: 3.27.1 - resolution: "@react-aria/selection@npm:3.27.1" - dependencies: - "@react-aria/focus": "npm:^3.21.4" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/selection": "npm:^3.20.8" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/0386549c0557a300288ea841bc5d9d2882e67c69dbfa3a7df1b27ce1c2dbba22987e0cfcee758dd60d3d4852ed1b02df6d7f75d961842e3d8f556fb4dc2239c5 - languageName: node - linkType: hard - -"@react-aria/separator@npm:^3.4.15": - version: 3.4.15 - resolution: "@react-aria/separator@npm:3.4.15" - dependencies: - "@react-aria/utils": "npm:^3.33.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/e9618ed089cb3398aa9bfd2fa224e3ad09f2d9eda4a24651eb2b5d2836f569a9026c2415ff9314b8024ce25ecdf50ed921006d186479a49b49f700827d45c5d5 - languageName: node - linkType: hard - -"@react-aria/slider@npm:^3.8.4": - version: 3.8.4 - resolution: "@react-aria/slider@npm:3.8.4" - dependencies: - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/label": "npm:^3.7.24" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/slider": "npm:^3.7.4" - "@react-types/shared": "npm:^3.33.0" - "@react-types/slider": "npm:^3.8.3" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/72f28c6d7661b31876c64295c2ed6927d8488f65c1f4014f9c27818154e3ef0c74f17b84dfa5a8009020ce853e208cada3c4c89b04d659cf5eec5eb5e5492e91 - languageName: node - linkType: hard - -"@react-aria/spinbutton@npm:^3.7.1": - version: 3.7.1 - resolution: "@react-aria/spinbutton@npm:3.7.1" - dependencies: - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/live-announcer": "npm:^3.4.4" - "@react-aria/utils": "npm:^3.33.0" - "@react-types/button": "npm:^3.15.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/8108b88aeb7d9cd02fe0cb2abb97c2d30cfc7ef8c5757c9280da33b46739d843acd8cc69eba3a0bc61dcd6bba09b77521c40d9957b4f062d0b85db3f00fe5b70 - languageName: node - linkType: hard - "@react-aria/ssr@npm:^3.9.10": version: 3.9.10 resolution: "@react-aria/ssr@npm:3.9.10" @@ -12680,109 +11995,7 @@ __metadata: languageName: node linkType: hard -"@react-aria/switch@npm:^3.7.10": - version: 3.7.10 - resolution: "@react-aria/switch@npm:3.7.10" - dependencies: - "@react-aria/toggle": "npm:^3.12.4" - "@react-stately/toggle": "npm:^3.9.4" - "@react-types/shared": "npm:^3.33.0" - "@react-types/switch": "npm:^3.5.16" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/1883a2b8901595611f57ddbae3e411a1a50cf334b63e529a440e213cbb75b3ea7fb0f360fcfe1839a3763de6d9337fcbf1d9568357f25f014d3c7bf8d3748159 - languageName: node - linkType: hard - -"@react-aria/table@npm:^3.17.10": - version: 3.17.10 - resolution: "@react-aria/table@npm:3.17.10" - dependencies: - "@react-aria/focus": "npm:^3.21.4" - "@react-aria/grid": "npm:^3.14.7" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/live-announcer": "npm:^3.4.4" - "@react-aria/utils": "npm:^3.33.0" - "@react-aria/visually-hidden": "npm:^3.8.30" - "@react-stately/collections": "npm:^3.12.9" - "@react-stately/flags": "npm:^3.1.2" - "@react-stately/table": "npm:^3.15.3" - "@react-types/checkbox": "npm:^3.10.3" - "@react-types/grid": "npm:^3.3.7" - "@react-types/shared": "npm:^3.33.0" - "@react-types/table": "npm:^3.13.5" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/d211082255fdc20e76ca7ce92ea614ffc93fb2ebf3dff5776cb1634517cc546ba5e40d9564e69a94b4150adf3c7c2b862084706b1cb64020a8da46c04c7f3abc - languageName: node - linkType: hard - -"@react-aria/tabs@npm:^3.11.0": - version: 3.11.0 - resolution: "@react-aria/tabs@npm:3.11.0" - dependencies: - "@react-aria/focus": "npm:^3.21.4" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/selection": "npm:^3.27.1" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/tabs": "npm:^3.8.8" - "@react-types/shared": "npm:^3.33.0" - "@react-types/tabs": "npm:^3.3.21" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/617c6a79a754cfa52862809bb46fd68e98655bf3f5b8d68440d8e013e1747665be6baf1f23e4bcf68527765e7a031ecac08b6b7634b99c36e8f11074f5e9fe9d - languageName: node - linkType: hard - -"@react-aria/tag@npm:^3.8.0": - version: 3.8.0 - resolution: "@react-aria/tag@npm:3.8.0" - dependencies: - "@react-aria/gridlist": "npm:^3.14.3" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/label": "npm:^3.7.24" - "@react-aria/selection": "npm:^3.27.1" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/list": "npm:^3.13.3" - "@react-types/button": "npm:^3.15.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/0fc389de14991b2e9cdaf0b26d8b041dc9ab9c5c9185d213d074ca126fa6166f4346a9ff2b4102e0a87e23743cb975422250a0e2c6a9b61114ad9515b45bb19d - languageName: node - linkType: hard - -"@react-aria/textfield@npm:^3.18.4": - version: 3.18.4 - resolution: "@react-aria/textfield@npm:3.18.4" - dependencies: - "@react-aria/form": "npm:^3.1.4" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/label": "npm:^3.7.24" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/form": "npm:^3.2.3" - "@react-stately/utils": "npm:^3.11.0" - "@react-types/shared": "npm:^3.33.0" - "@react-types/textfield": "npm:^3.12.7" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/5b28ce4afe987d6eeb2ddf51d9c5237fe589a6041c7ea41bbc1eac2c9ac626710e935d56cf6c041db0ff407af40f33813a11bf7295653426adb505dc60d553b8 - languageName: node - linkType: hard - -"@react-aria/toast@npm:^3.0.10, @react-aria/toast@npm:^3.0.9": +"@react-aria/toast@npm:^3.0.9": version: 3.0.11 resolution: "@react-aria/toast@npm:3.0.11" dependencies: @@ -12801,39 +12014,6 @@ __metadata: languageName: node linkType: hard -"@react-aria/toggle@npm:^3.12.4": - version: 3.12.4 - resolution: "@react-aria/toggle@npm:3.12.4" - dependencies: - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/toggle": "npm:^3.9.4" - "@react-types/checkbox": "npm:^3.10.3" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/8b35130059de34f37ba47814037a62c776db117819e7235cc3b57d67f08ae95bac303b47d236d51d92b4e69748a9df3eb3a40032b2b2849f0feff5c617626062 - languageName: node - linkType: hard - -"@react-aria/toolbar@npm:3.0.0-beta.23": - version: 3.0.0-beta.23 - resolution: "@react-aria/toolbar@npm:3.0.0-beta.23" - dependencies: - "@react-aria/focus": "npm:^3.21.4" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/utils": "npm:^3.33.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/5a1a9a24638618a509c1f7195768acda60c832d138f13d6c44023b5c87c2fec51db043276d369465f4abe6bfbdbd69ea99c6317b5bd04ee27195702f8c90a256 - languageName: node - linkType: hard - "@react-aria/toolbar@npm:3.0.0-beta.24": version: 3.0.0-beta.24 resolution: "@react-aria/toolbar@npm:3.0.0-beta.24" @@ -12850,43 +12030,7 @@ __metadata: languageName: node linkType: hard -"@react-aria/tooltip@npm:^3.9.1": - version: 3.9.1 - resolution: "@react-aria/tooltip@npm:3.9.1" - dependencies: - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/tooltip": "npm:^3.5.10" - "@react-types/shared": "npm:^3.33.0" - "@react-types/tooltip": "npm:^3.5.1" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/b6cde7d5567567e948bb62037c42d91c37873e1b40316e287b17f1f1d10a5c7a76d095a0bdae604e5ac48103fa7d0029c665a53ed501517ee30a0d720e95f05f - languageName: node - linkType: hard - -"@react-aria/tree@npm:^3.1.6": - version: 3.1.6 - resolution: "@react-aria/tree@npm:3.1.6" - dependencies: - "@react-aria/gridlist": "npm:^3.14.3" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/selection": "npm:^3.27.1" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/tree": "npm:^3.9.5" - "@react-types/button": "npm:^3.15.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/7d3678347e19895be31b17aa2cfeed07f4796123e99018d591a11928da2b475de21f13c7282569b740ad48cb572353baa926000d4c7dc389bece33f8017cd546 - languageName: node - linkType: hard - -"@react-aria/utils@npm:^3.33.0, @react-aria/utils@npm:^3.33.1": +"@react-aria/utils@npm:^3.33.1": version: 3.33.1 resolution: "@react-aria/utils@npm:3.33.1" dependencies: @@ -12903,38 +12047,6 @@ __metadata: languageName: node linkType: hard -"@react-aria/virtualizer@npm:^4.1.12": - version: 4.1.12 - resolution: "@react-aria/virtualizer@npm:4.1.12" - dependencies: - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/utils": "npm:^3.33.0" - "@react-stately/virtualizer": "npm:^4.4.5" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/f02a181f46fe6de29bba7c0330d3d6b592f7e595e43dde71a8995d81677d2ed979c987eb40f9443c76e35315c3163052cb7f7396d5f5bedddc28e91466e643c0 - languageName: node - linkType: hard - -"@react-aria/visually-hidden@npm:^3.8.30": - version: 3.8.30 - resolution: "@react-aria/visually-hidden@npm:3.8.30" - dependencies: - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/utils": "npm:^3.33.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/ff0324a222ff05ad3743d1df56feb8d1e2d95791cd360dbfbc03fad4b8e9346eb7b65a29ffda85f80665a5edc1f7705fcd047eaecb4f38573ee7e9c5d562392e - languageName: node - linkType: hard - "@react-hookz/deep-equal@npm:^1.0.4": version: 1.0.4 resolution: "@react-hookz/deep-equal@npm:1.0.4" @@ -12958,154 +12070,6 @@ __metadata: languageName: node linkType: hard -"@react-stately/autocomplete@npm:3.0.0-beta.4": - version: 3.0.0-beta.4 - resolution: "@react-stately/autocomplete@npm:3.0.0-beta.4" - dependencies: - "@react-stately/utils": "npm:^3.11.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/1dd69bc262bd761f21b2ff7c594f8ffbfc34e12bbb721d6077739a59646be9cbcdff8652874ca1402c8644ac5639275fa928172e478db10770ae951af629b03f - languageName: node - linkType: hard - -"@react-stately/calendar@npm:^3.9.2": - version: 3.9.2 - resolution: "@react-stately/calendar@npm:3.9.2" - dependencies: - "@internationalized/date": "npm:^3.11.0" - "@react-stately/utils": "npm:^3.11.0" - "@react-types/calendar": "npm:^3.8.2" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/d910836573b6088a25bef3f503b9977980c0a11feccacff08a41f8402b510255b0d26d524db63733875f25ef5e192aa6a5fbebc6e34129dbe0e4b0714ff96162 - languageName: node - linkType: hard - -"@react-stately/checkbox@npm:^3.7.4": - version: 3.7.4 - resolution: "@react-stately/checkbox@npm:3.7.4" - dependencies: - "@react-stately/form": "npm:^3.2.3" - "@react-stately/utils": "npm:^3.11.0" - "@react-types/checkbox": "npm:^3.10.3" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/2a87a0160ed50954886dce3635be62b085edd86eac3c508f34ad019411e2848fe103ae1c44a3b2e8c22eb38df80ceabc8583b7496fe4f842df681df14ef0c44a - languageName: node - linkType: hard - -"@react-stately/collections@npm:^3.12.9": - version: 3.12.9 - resolution: "@react-stately/collections@npm:3.12.9" - dependencies: - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/d7e68eaf89a2b79c73fbf7437d32f65a2d65bf59123bcb896176132652c020490d7187e52aff22169b033fefde4c728b4bb89a224e0061618c00d84ae0f373b6 - languageName: node - linkType: hard - -"@react-stately/color@npm:^3.9.4": - version: 3.9.4 - resolution: "@react-stately/color@npm:3.9.4" - dependencies: - "@internationalized/number": "npm:^3.6.5" - "@internationalized/string": "npm:^3.2.7" - "@react-stately/form": "npm:^3.2.3" - "@react-stately/numberfield": "npm:^3.10.4" - "@react-stately/slider": "npm:^3.7.4" - "@react-stately/utils": "npm:^3.11.0" - "@react-types/color": "npm:^3.1.3" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/30ee71641bcc9e8c2a03ce443b7ca694659626292110a4146f52fa8619f10e74ba0dc59ed3c35d0e33bb47c649915ca9d79280989c2e2ff3fe196ea27736cf44 - languageName: node - linkType: hard - -"@react-stately/combobox@npm:^3.12.2": - version: 3.12.2 - resolution: "@react-stately/combobox@npm:3.12.2" - dependencies: - "@react-stately/collections": "npm:^3.12.9" - "@react-stately/form": "npm:^3.2.3" - "@react-stately/list": "npm:^3.13.3" - "@react-stately/overlays": "npm:^3.6.22" - "@react-stately/utils": "npm:^3.11.0" - "@react-types/combobox": "npm:^3.13.11" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/e5fef62e7dd4502a695ae6158d3afe2047036da2b8974e34d0021e4220b6028571f6c0dbf49616f5258147cf250bd231a1be622f04c5be19004fdc0898125474 - languageName: node - linkType: hard - -"@react-stately/data@npm:^3.15.1": - version: 3.15.1 - resolution: "@react-stately/data@npm:3.15.1" - dependencies: - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/c9e131546bd2ddac696a7bba3c269108688db5af763cd17dd0a624215e23f7ea6d6b434a794416a6c572968cae4026d42f766be222c07ce5fc5aaea481d1d0e2 - languageName: node - linkType: hard - -"@react-stately/datepicker@npm:^3.16.0": - version: 3.16.0 - resolution: "@react-stately/datepicker@npm:3.16.0" - dependencies: - "@internationalized/date": "npm:^3.11.0" - "@internationalized/number": "npm:^3.6.5" - "@internationalized/string": "npm:^3.2.7" - "@react-stately/form": "npm:^3.2.3" - "@react-stately/overlays": "npm:^3.6.22" - "@react-stately/utils": "npm:^3.11.0" - "@react-types/datepicker": "npm:^3.13.4" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/37da44a664284b06730e141b033d9d2128803c97191e7f72a1e50b7f204d52f6e4ee4d310a4d2a59f0d3e838b334adb1d4b57e9008e5670d5ed4222ba4d44a00 - languageName: node - linkType: hard - -"@react-stately/disclosure@npm:^3.0.10": - version: 3.0.10 - resolution: "@react-stately/disclosure@npm:3.0.10" - dependencies: - "@react-stately/utils": "npm:^3.11.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/acfc0346f5503b8693dabd145fc2eff36f59eb140f7a0cadd956ab102453bdf1dd63503fe9611106b6b4348d85be3b29c5ab1f743945148dcd23d518035f7dbb - languageName: node - linkType: hard - -"@react-stately/dnd@npm:^3.7.3": - version: 3.7.3 - resolution: "@react-stately/dnd@npm:3.7.3" - dependencies: - "@react-stately/selection": "npm:^3.20.8" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/a6ef1cfe1ba0335837462f57fbe5dac637a861945b9f958e875dd8ad440312e10dd93df3c2740cff93106bc20d1e1145c881e4fc8ec947b135d0133b23af5b94 - languageName: node - linkType: hard - "@react-stately/flags@npm:^3.1.2": version: 3.1.2 resolution: "@react-stately/flags@npm:3.1.2" @@ -13115,214 +12079,6 @@ __metadata: languageName: node linkType: hard -"@react-stately/form@npm:^3.2.3": - version: 3.2.3 - resolution: "@react-stately/form@npm:3.2.3" - dependencies: - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/bfab5e15a9b3080a0bcf0a65d3e37aba561ec78a9aebb3ce7eeed52bec72def2b3db82ec51bf60f1f453b7a116b240350c37ae7fa9c9042cac3b5a73296daf59 - languageName: node - linkType: hard - -"@react-stately/grid@npm:^3.11.8": - version: 3.11.8 - resolution: "@react-stately/grid@npm:3.11.8" - dependencies: - "@react-stately/collections": "npm:^3.12.9" - "@react-stately/selection": "npm:^3.20.8" - "@react-types/grid": "npm:^3.3.7" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/1f7ebe345c3621abc81374cf0429f070a02d49d461a702ef2419f7f827f3de4827d742a441efe7de3f617bbcbac664bfb7bdf2e2c015275a986537f8f928f3d4 - languageName: node - linkType: hard - -"@react-stately/layout@npm:^4.5.3": - version: 4.5.3 - resolution: "@react-stately/layout@npm:4.5.3" - dependencies: - "@react-stately/collections": "npm:^3.12.9" - "@react-stately/table": "npm:^3.15.3" - "@react-stately/virtualizer": "npm:^4.4.5" - "@react-types/grid": "npm:^3.3.7" - "@react-types/shared": "npm:^3.33.0" - "@react-types/table": "npm:^3.13.5" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/828310862317b6fe612be1c0940ee7b90fc964c5e0d4168f90e3548da1dcc5675c31892a30422b077499ed9610552f1471e442a06a68370378f666acde72ae15 - languageName: node - linkType: hard - -"@react-stately/list@npm:^3.13.3": - version: 3.13.3 - resolution: "@react-stately/list@npm:3.13.3" - dependencies: - "@react-stately/collections": "npm:^3.12.9" - "@react-stately/selection": "npm:^3.20.8" - "@react-stately/utils": "npm:^3.11.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/790bc56513dcef53678509f134e9aecc04051ffdc37961b8d51f5ca7628f05d854438716d54a8ebcd26b93488628a8b0f6b7b952bf147cadacbfe2b1ac210662 - languageName: node - linkType: hard - -"@react-stately/menu@npm:^3.9.10": - version: 3.9.10 - resolution: "@react-stately/menu@npm:3.9.10" - dependencies: - "@react-stately/overlays": "npm:^3.6.22" - "@react-types/menu": "npm:^3.10.6" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/937d16c769f8fcea94060d530e1238f96a50262621ee05afdfeeb1a18771b0ae04ae5d1fadaa1dd7718f4ae9006f286eb6ebe329bf5068bad8f196af813e5ad9 - languageName: node - linkType: hard - -"@react-stately/numberfield@npm:^3.10.4": - version: 3.10.4 - resolution: "@react-stately/numberfield@npm:3.10.4" - dependencies: - "@internationalized/number": "npm:^3.6.5" - "@react-stately/form": "npm:^3.2.3" - "@react-stately/utils": "npm:^3.11.0" - "@react-types/numberfield": "npm:^3.8.17" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/7a6635a823ca7c03064c3b65e99d1122e1851f0e88795542da6a7b27198269d6aec764daffa0b97da57af1fc6bd202bf5f9cfd6ac26e8a209b7a717e1e707c0f - languageName: node - linkType: hard - -"@react-stately/overlays@npm:^3.6.22": - version: 3.6.22 - resolution: "@react-stately/overlays@npm:3.6.22" - dependencies: - "@react-stately/utils": "npm:^3.11.0" - "@react-types/overlays": "npm:^3.9.3" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/c5da7009c5cba86c852e438a334d3e8fcc17021ec8d4fd5d82135ffb83e19929f97bf12f0b038911af648e3207307e461b332952fa38abd9c82d4adee833f2c8 - languageName: node - linkType: hard - -"@react-stately/radio@npm:^3.11.4": - version: 3.11.4 - resolution: "@react-stately/radio@npm:3.11.4" - dependencies: - "@react-stately/form": "npm:^3.2.3" - "@react-stately/utils": "npm:^3.11.0" - "@react-types/radio": "npm:^3.9.3" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/ee31fd738d0c0913a4393479e8064af14439aac7e8f05f7b3042cf9ddf30e5ca138258209b3b33909b21574a6a9b051871bfcfd4b1a8311841e3f2ce26a03660 - languageName: node - linkType: hard - -"@react-stately/searchfield@npm:^3.5.18": - version: 3.5.18 - resolution: "@react-stately/searchfield@npm:3.5.18" - dependencies: - "@react-stately/utils": "npm:^3.11.0" - "@react-types/searchfield": "npm:^3.6.7" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/3fab036b4461bd6748e8e9bd17456e7908b76e1df4349f2ee34d426cf3f4ce7b6ff8266078eec3d29485beeb8d89d4426bfbc4e3613442eb489fb0f2867f70cd - languageName: node - linkType: hard - -"@react-stately/select@npm:^3.9.1": - version: 3.9.1 - resolution: "@react-stately/select@npm:3.9.1" - dependencies: - "@react-stately/form": "npm:^3.2.3" - "@react-stately/list": "npm:^3.13.3" - "@react-stately/overlays": "npm:^3.6.22" - "@react-stately/utils": "npm:^3.11.0" - "@react-types/select": "npm:^3.12.1" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/4104bb87a0a1efc377c67c675b85aa3b568b43025d0f36aed3494d7ecacc07b35574b5df2f5d2e2b39d23de3eb9391eb0d674ed520658bee96d1780df2168148 - languageName: node - linkType: hard - -"@react-stately/selection@npm:^3.20.8": - version: 3.20.8 - resolution: "@react-stately/selection@npm:3.20.8" - dependencies: - "@react-stately/collections": "npm:^3.12.9" - "@react-stately/utils": "npm:^3.11.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/8127e2adf3b96591e5ec31abcf3ca998da3f111306beec809d08675d5c563cfc6fc012b8b7570d8c5794d656e7049a8613663b0596430ddaf37dba2048ad71ac - languageName: node - linkType: hard - -"@react-stately/slider@npm:^3.7.4": - version: 3.7.4 - resolution: "@react-stately/slider@npm:3.7.4" - dependencies: - "@react-stately/utils": "npm:^3.11.0" - "@react-types/shared": "npm:^3.33.0" - "@react-types/slider": "npm:^3.8.3" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/a8fe1ed9f5144e2b66a9ce58d38d624511e5db6bbd9dec2cb9051b08caac8f17b60f614643ef82f50dd2f4aa1bb13f0e19c7700cce344db5f0d71abb57e76d75 - languageName: node - linkType: hard - -"@react-stately/table@npm:^3.15.3": - version: 3.15.3 - resolution: "@react-stately/table@npm:3.15.3" - dependencies: - "@react-stately/collections": "npm:^3.12.9" - "@react-stately/flags": "npm:^3.1.2" - "@react-stately/grid": "npm:^3.11.8" - "@react-stately/selection": "npm:^3.20.8" - "@react-stately/utils": "npm:^3.11.0" - "@react-types/grid": "npm:^3.3.7" - "@react-types/shared": "npm:^3.33.0" - "@react-types/table": "npm:^3.13.5" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/691625eea3b3fc4014a4e1f18149d717ee9907473bca80e742944740fb5ca2317b6be6a58eaabca670765cdb7be2a543a9a3b094fc1fa3c0f66f9d57b16bf80f - languageName: node - linkType: hard - -"@react-stately/tabs@npm:^3.8.8": - version: 3.8.8 - resolution: "@react-stately/tabs@npm:3.8.8" - dependencies: - "@react-stately/list": "npm:^3.13.3" - "@react-types/shared": "npm:^3.33.0" - "@react-types/tabs": "npm:^3.3.21" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/8e43e4fdbe57ac7ba4bc7be4b6757207f87301f253b5335c4736be230a1206975213c5aa3141903c9e2739e5638c76f33e22d7842e2a2e3998f65566b39341eb - languageName: node - linkType: hard - "@react-stately/toast@npm:^3.1.2, @react-stately/toast@npm:^3.1.3": version: 3.1.3 resolution: "@react-stately/toast@npm:3.1.3" @@ -13335,7 +12091,7 @@ __metadata: languageName: node linkType: hard -"@react-stately/toggle@npm:^3.9.4, @react-stately/toggle@npm:^3.9.5": +"@react-stately/toggle@npm:^3.9.5": version: 3.9.5 resolution: "@react-stately/toggle@npm:3.9.5" dependencies: @@ -13349,34 +12105,6 @@ __metadata: languageName: node linkType: hard -"@react-stately/tooltip@npm:^3.5.10": - version: 3.5.10 - resolution: "@react-stately/tooltip@npm:3.5.10" - dependencies: - "@react-stately/overlays": "npm:^3.6.22" - "@react-types/tooltip": "npm:^3.5.1" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/3d73aa49044f0e48ff0d0f30157638dac8df18347dcc7effcb6ecfa50885bdadad37550bfc4facc37211b9b06e05518cc1f451b4fb09c1c70b09549cbb85dcc3 - languageName: node - linkType: hard - -"@react-stately/tree@npm:^3.9.5": - version: 3.9.5 - resolution: "@react-stately/tree@npm:3.9.5" - dependencies: - "@react-stately/collections": "npm:^3.12.9" - "@react-stately/selection": "npm:^3.20.8" - "@react-stately/utils": "npm:^3.11.0" - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/2d9acb2d7172bacac9aefabeb01cbf47c8539bea7e57c81ff6d09a4bc501f9f297e2e63bb35d4a9abe2cfccd536400555d23f13dd4aa476af19be1d60bc50870 - languageName: node - linkType: hard - "@react-stately/utils@npm:^3.11.0": version: 3.11.0 resolution: "@react-stately/utils@npm:3.11.0" @@ -13388,45 +12116,7 @@ __metadata: languageName: node linkType: hard -"@react-stately/virtualizer@npm:^4.4.5": - version: 4.4.5 - resolution: "@react-stately/virtualizer@npm:4.4.5" - dependencies: - "@react-types/shared": "npm:^3.33.0" - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/704f236a991bfb33dc11910c47ab1fd6087e7d20fff543c78cc33a30212bcc84e3f6ebed36ed8cbff2e12361136520b7b4a016438d84135837b11142210b8ae2 - languageName: node - linkType: hard - -"@react-types/autocomplete@npm:3.0.0-alpha.37": - version: 3.0.0-alpha.37 - resolution: "@react-types/autocomplete@npm:3.0.0-alpha.37" - dependencies: - "@react-types/combobox": "npm:^3.13.11" - "@react-types/searchfield": "npm:^3.6.7" - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/e7d9a86dc3cd67fddbc9f0e1660cd359df8b3ffef2db47f76b9cb8e9ce1e9e55652e38bb9707347e90ebd4ef8500b4053a38d0738b5a77cb0248169c22647325 - languageName: node - linkType: hard - -"@react-types/breadcrumbs@npm:^3.7.18": - version: 3.7.18 - resolution: "@react-types/breadcrumbs@npm:3.7.18" - dependencies: - "@react-types/link": "npm:^3.6.6" - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/49391e83b97d1a9c362aa27142416f689e93167542b4e5a4f6b6b2b0c384a186df7f9c660f5a75a8d0653c25683bfd290f66cf3b4b073ccd2d825047ecfe53b7 - languageName: node - linkType: hard - -"@react-types/button@npm:^3.15.0, @react-types/button@npm:^3.15.1": +"@react-types/button@npm:^3.15.1": version: 3.15.1 resolution: "@react-types/button@npm:3.15.1" dependencies: @@ -13437,19 +12127,7 @@ __metadata: languageName: node linkType: hard -"@react-types/calendar@npm:^3.8.2": - version: 3.8.2 - resolution: "@react-types/calendar@npm:3.8.2" - dependencies: - "@internationalized/date": "npm:^3.11.0" - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/e7944b60c798563666cf6b35fdf347e6048ef238fe3016095f2e7283791599cc471dbbc6cb20bb83e46fc230f8ea064340ec33727569e2dcae7d8e9fc3f6c729 - languageName: node - linkType: hard - -"@react-types/checkbox@npm:^3.10.3, @react-types/checkbox@npm:^3.10.4": +"@react-types/checkbox@npm:^3.10.4": version: 3.10.4 resolution: "@react-types/checkbox@npm:3.10.4" dependencies: @@ -13460,199 +12138,7 @@ __metadata: languageName: node linkType: hard -"@react-types/color@npm:^3.1.3": - version: 3.1.3 - resolution: "@react-types/color@npm:3.1.3" - dependencies: - "@react-types/shared": "npm:^3.33.0" - "@react-types/slider": "npm:^3.8.3" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/8bc783d3adbcd18426926156248bb9decf916abd5fef9b97ad8c5ef8efbf4371c3c3277315aba2b3daab17598fd627dce5eefa87b86a668a5f91ec68596369cf - languageName: node - linkType: hard - -"@react-types/combobox@npm:^3.13.11": - version: 3.13.11 - resolution: "@react-types/combobox@npm:3.13.11" - dependencies: - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/8b704943d603fb8fcc9055cb10559677c578e9872f9e9084e3bc7f2dc9ede24ac09582b3ff8da645b77d0cabd302c994e85ce1d1e15d0b3f7d09f070c8eb00b0 - languageName: node - linkType: hard - -"@react-types/datepicker@npm:^3.13.4": - version: 3.13.4 - resolution: "@react-types/datepicker@npm:3.13.4" - dependencies: - "@internationalized/date": "npm:^3.11.0" - "@react-types/calendar": "npm:^3.8.2" - "@react-types/overlays": "npm:^3.9.3" - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/2f716c97a8a8662f311e951f94bbcee69ac902341b24a2a682535c40cbce105e93819f1995a9eefeb25a7d1610ab112cd7ac85658bb73c9f608240c18f675bbc - languageName: node - linkType: hard - -"@react-types/dialog@npm:^3.5.23": - version: 3.5.23 - resolution: "@react-types/dialog@npm:3.5.23" - dependencies: - "@react-types/overlays": "npm:^3.9.3" - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/9fbee4f73f22d4c8270e04d49021bf40077c4fda30c01add24d256cf7c5c71f9c91be03b99f651af974526e78fb14ae571b78a6855aa61637f3667e44d42baeb - languageName: node - linkType: hard - -"@react-types/form@npm:^3.7.17": - version: 3.7.17 - resolution: "@react-types/form@npm:3.7.17" - dependencies: - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/998942b98280f15cb11a147fda403b471dce45ed8ee00f56778d1aa718ecff8f17496617a8f52cd1d2844a65e5823196157a5d6669cb7e22dc84e453c9b972f0 - languageName: node - linkType: hard - -"@react-types/grid@npm:^3.3.7": - version: 3.3.7 - resolution: "@react-types/grid@npm:3.3.7" - dependencies: - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/cc153cc08bf564e12f04c51b3456bfcd3393f2ea361b6f35cf70a41100f3261dc86825842e50b3c6d84fe01944285717eeff002ed67a4dffd429c1e74ab0f41c - languageName: node - linkType: hard - -"@react-types/link@npm:^3.6.6": - version: 3.6.6 - resolution: "@react-types/link@npm:3.6.6" - dependencies: - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/aca552f96362760e9fbe6d15aa90c27383c747d33dff91b4d5ff223b4395603a6d77bee032f714aa56b790d3392640456992c09d112c7956953315f6c88526e7 - languageName: node - linkType: hard - -"@react-types/listbox@npm:^3.7.5": - version: 3.7.5 - resolution: "@react-types/listbox@npm:3.7.5" - dependencies: - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/c24b09cdde6dc0a2456c7e45db5854c8ba0f75d02a220ab27418c03be017d58d8ef26eff85b7e16ce9d19cfb7a265ccfdd3c81247e5533109201d55c81c6bf46 - languageName: node - linkType: hard - -"@react-types/menu@npm:^3.10.6": - version: 3.10.6 - resolution: "@react-types/menu@npm:3.10.6" - dependencies: - "@react-types/overlays": "npm:^3.9.3" - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/15960881debe12f37918a9aa04f26f44b2c31df3c6aa81e3208fd97b3a67050de4c4af42a18cdf69e8e281744d58a1739d5d38c105d93db5a5bf476202163911 - languageName: node - linkType: hard - -"@react-types/meter@npm:^3.4.14": - version: 3.4.14 - resolution: "@react-types/meter@npm:3.4.14" - dependencies: - "@react-types/progress": "npm:^3.5.17" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/c4de6bc227f32824029fcc882793bb12fa0ef3d55530460979fc4145dee27f1cd3bedd156870d25e2877c2e3629ab9f0bd05a71e6bad95b055bd48860c0ace45 - languageName: node - linkType: hard - -"@react-types/numberfield@npm:^3.8.17": - version: 3.8.17 - resolution: "@react-types/numberfield@npm:3.8.17" - dependencies: - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/cf6227d3a67ec7f927c70db9f4c188612e263f28190b718a9f8b68cff60937c39dfcf8422ad23555659ef25508bd7a52989c87d012c54afacf39318ead324ba1 - languageName: node - linkType: hard - -"@react-types/overlays@npm:^3.9.3": - version: 3.9.3 - resolution: "@react-types/overlays@npm:3.9.3" - dependencies: - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/e7dde940ba785d3995f87641f2b67e502f2090fa8fdef508db1f81c052f4822df608b8bc6ae91dd270ba6055cdf7d8f0e6585c8deb2d34537fb7c8eae7d3099a - languageName: node - linkType: hard - -"@react-types/progress@npm:^3.5.17": - version: 3.5.17 - resolution: "@react-types/progress@npm:3.5.17" - dependencies: - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/b334ca1ab03b75d9324daec35ab64485b97fe34672c73110f08ab1b282ef0144972d6bf0a5733a7f18a60617f7b9b9c708aff517a445c604b8f79bec06f0974a - languageName: node - linkType: hard - -"@react-types/radio@npm:^3.9.3": - version: 3.9.3 - resolution: "@react-types/radio@npm:3.9.3" - dependencies: - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/9074145535712bddeadd7e8ffa3ef51bb6606e0aa752fafd401257c7f0f8b0314bf61b30cb776b3eabda70a8a6d1d479f8a346eb529c022dda147b27a73dbe02 - languageName: node - linkType: hard - -"@react-types/searchfield@npm:^3.6.7": - version: 3.6.7 - resolution: "@react-types/searchfield@npm:3.6.7" - dependencies: - "@react-types/shared": "npm:^3.33.0" - "@react-types/textfield": "npm:^3.12.7" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/8ef81774d98054fc7efdf68f225692d453da5b21e97d6dd088c007edb0be0167ab3010b9c2c17796dde848ce51fd5c492c5989c770d46d26fe904cbefc3aaf92 - languageName: node - linkType: hard - -"@react-types/select@npm:^3.12.1": - version: 3.12.1 - resolution: "@react-types/select@npm:3.12.1" - dependencies: - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/9706fd1bbd68a113995c35339ae8d7dfaf47a370dae7ddc8244422dc72c345c9c2adbbc2b08e7755747579bc438d68a096a74afc2fb8975e1ca84947f10d9aa4 - languageName: node - linkType: hard - -"@react-types/shared@npm:^3.33.0, @react-types/shared@npm:^3.33.1": - version: 3.33.1 - resolution: "@react-types/shared@npm:3.33.1" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/072dbf92de80e3535441fbf4a9075009636221974d0b70bd1078ec1e343ae1373d69d2c02c2fba0963e50f4b134872144ffaf1573daec8477233e408f96e008c - languageName: node - linkType: hard - -"@react-types/shared@npm:^3.34.0": +"@react-types/shared@npm:^3.33.1, @react-types/shared@npm:^3.34.0": version: 3.34.0 resolution: "@react-types/shared@npm:3.34.0" peerDependencies: @@ -13661,74 +12147,6 @@ __metadata: languageName: node linkType: hard -"@react-types/slider@npm:^3.8.3": - version: 3.8.3 - resolution: "@react-types/slider@npm:3.8.3" - dependencies: - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/93656c42a9c56779363c050f13bd88abe59958c93dc43caef6ebc5193a9a6320c56108ede022e869741b430716ef632ffc2498fa25a4854124a39aa8c8cd65b3 - languageName: node - linkType: hard - -"@react-types/switch@npm:^3.5.16": - version: 3.5.16 - resolution: "@react-types/switch@npm:3.5.16" - dependencies: - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/2fe6bd9f0e053004a277bcae534d2b52cb9e92f7f3223bc3d062ae035a75d1c024797a36412c96d845d3646dd1d84fffaf77c7826e0918c215b95570a5c6ea64 - languageName: node - linkType: hard - -"@react-types/table@npm:^3.13.5": - version: 3.13.5 - resolution: "@react-types/table@npm:3.13.5" - dependencies: - "@react-types/grid": "npm:^3.3.7" - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/a19e1cf38903baa9549f37f6f2331b035ef5606ec7767313949b555ee9e21466ee8aec1384c70d92484a2aefbad6b91029f72699430d0be1ee2b3ae43abd9e87 - languageName: node - linkType: hard - -"@react-types/tabs@npm:^3.3.21": - version: 3.3.21 - resolution: "@react-types/tabs@npm:3.3.21" - dependencies: - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/e4b02a4c77d4e57d67541cfc38ed55b3ce683d84074237c720879bed5d12f7e8631d606aa552237feab622cc7edc0a4b176983bf8053547215231323b32886c0 - languageName: node - linkType: hard - -"@react-types/textfield@npm:^3.12.7": - version: 3.12.7 - resolution: "@react-types/textfield@npm:3.12.7" - dependencies: - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/6e644519a6a6eb1d00fe83a92508596079131042c48406439cd32c359c0d3a70cc309e6530a7c1b51c8f4b477cb6e4d5aa13810c242597fd470f73031c7c80de - languageName: node - linkType: hard - -"@react-types/tooltip@npm:^3.5.1": - version: 3.5.1 - resolution: "@react-types/tooltip@npm:3.5.1" - dependencies: - "@react-types/overlays": "npm:^3.9.3" - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/cf563a16bfece724ec32d5d344f1221a16256ef07c6b83d0b673ec443e6ac22218b05cb11ad351b6082286feb139a72e38ef34625941992c6b0c993966aeb31e - languageName: node - linkType: hard - "@red-hat-developer-hub/backstage-plugin-theme@npm:0.14.4": version: 0.14.4 resolution: "@red-hat-developer-hub/backstage-plugin-theme@npm:0.14.4" @@ -15109,6 +13527,13 @@ __metadata: languageName: node linkType: hard +"@sqltools/formatter@npm:^1.2.5": + version: 1.2.5 + resolution: "@sqltools/formatter@npm:1.2.5" + checksum: 10c0/4b4fa62b8cd4880784b71cc5edd4a13da04fda0a915c14282765a8ec1a900a495e69b322704413e2052d221b5646d9fb0e20e87911f9a8f438f33180eecb11a4 + languageName: node + linkType: hard + "@standard-schema/spec@npm:^1.0.0, @standard-schema/spec@npm:^1.1.0": version: 1.1.0 resolution: "@standard-schema/spec@npm:1.1.0" @@ -16986,6 +15411,13 @@ __metadata: languageName: node linkType: hard +"@types/js-yaml@npm:^4.0.9": + version: 4.0.9 + resolution: "@types/js-yaml@npm:4.0.9" + checksum: 10c0/24de857aa8d61526bbfbbaa383aa538283ad17363fcd5bb5148e2c7f604547db36646440e739d78241ed008702a8920665d1add5618687b6743858fae00da211 + languageName: node + linkType: hard + "@types/jsdom@npm:^21.1.7": version: 21.1.7 resolution: "@types/jsdom@npm:21.1.7" @@ -17037,7 +15469,7 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:^4.14.175": +"@types/lodash@npm:^4.14.151, @types/lodash@npm:^4.14.175": version: 4.17.24 resolution: "@types/lodash@npm:4.17.24" checksum: 10c0/b72f60d4daacdad1fa643edb3faba204c02a01eb1ac00a83ff73496a6d236fc55e459c06106e8ced42277dba932d087d8fc090f8de4ef590d3f91e6d6f7ce85a @@ -17173,6 +15605,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:22.19.17": + version: 22.19.17 + resolution: "@types/node@npm:22.19.17" + dependencies: + undici-types: "npm:~6.21.0" + checksum: 10c0/b66c484c0a9f6d88b1ef360b0f487717234ee1a482cb2551ff73d9f3c43a42a777daf4c8a5eee970960728f8fe1f3877d3d8c6ffabcbca74cb401a59db700fa4 + languageName: node + linkType: hard + "@types/node@npm:24.12.2": version: 24.12.2 resolution: "@types/node@npm:24.12.2" @@ -17563,6 +16004,16 @@ __metadata: languageName: node linkType: hard +"@types/supertest@npm:7.2.0": + version: 7.2.0 + resolution: "@types/supertest@npm:7.2.0" + dependencies: + "@types/methods": "npm:^1.1.4" + "@types/superagent": "npm:^8.1.0" + checksum: 10c0/78c33e968acd45207acdd965ccbd5eb7a279813ff68fab1acc438937ed017698102cc077cef8aa60ec6caefff2fa61171e902eab40607fd7ce82ead3a82b766e + languageName: node + linkType: hard + "@types/tedious@npm:^4.0.14": version: 4.0.14 resolution: "@types/tedious@npm:4.0.14" @@ -18619,6 +17070,13 @@ __metadata: languageName: node linkType: hard +"ansis@npm:^4.2.0": + version: 4.3.0 + resolution: "ansis@npm:4.3.0" + checksum: 10c0/ef9992c645ca9713f509488e02e600c87986366888c7338fbea300a86a9a82949b011687084275670f6c6a2e009aa2174c06428f6f279aaf3263811b12a25bea + languageName: node + linkType: hard + "any-promise@npm:^1.0.0": version: 1.3.0 resolution: "any-promise@npm:1.3.0" @@ -18704,6 +17162,13 @@ __metadata: languageName: unknown linkType: soft +"app-root-path@npm:^3.1.0": + version: 3.1.0 + resolution: "app-root-path@npm:3.1.0" + checksum: 10c0/4a0fd976de1bffcdb18a5e1f8050091f15d0780e0582bca99aaa9d52de71f0e08e5185355fcffc781180bfb898499e787a2f5ed79b9c448b942b31dc947acaa9 + languageName: node + linkType: hard + "app@workspace:*, app@workspace:packages/app": version: 0.0.0-use.local resolution: "app@workspace:packages/app" @@ -19167,6 +17632,13 @@ __metadata: languageName: node linkType: hard +"await-lock@npm:^2.0.1": + version: 2.2.2 + resolution: "await-lock@npm:2.2.2" + checksum: 10c0/bedf00dad44c6325a655bf3bd523ab9e1ce41023da6a8c379990c76ac1d942ac7e5301627ab84ba37917ab5247506ba429b7f6e4bf77074093f255571b9ad5ee + languageName: node + linkType: hard + "aws-ssl-profiles@npm:^1.1.2": version: 1.1.2 resolution: "aws-ssl-profiles@npm:1.1.2" @@ -19181,18 +17653,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.12.0, axios@npm:^1.12.2, axios@npm:^1.7.4": - version: 1.13.5 - resolution: "axios@npm:1.13.5" - dependencies: - follow-redirects: "npm:^1.15.11" - form-data: "npm:^4.0.5" - proxy-from-env: "npm:^1.1.0" - checksum: 10c0/abf468c34f2d145f3dc7dbc0f1be67e520630624307bda69a41bbe8d386bd672d87b4405c4ee77f9ff54b235ab02f96a9968fb00e75b13ce64706e352a3068fd - languageName: node - linkType: hard - -"axios@npm:^1.7.3": +"axios@npm:^1.12.0, axios@npm:^1.12.2, axios@npm:^1.7.3, axios@npm:^1.7.4": version: 1.13.6 resolution: "axios@npm:1.13.6" dependencies: @@ -19397,6 +17858,7 @@ __metadata: "@backstage/plugin-user-settings-backend": "npm:0.4.1" "@internal/plugin-dynamic-plugins-info-backend": "npm:*" "@internal/plugin-licensed-users-info-backend": "npm:*" + "@internal/plugin-rbac-backend": "npm:*" "@internal/plugin-scalprum-backend": "npm:*" "@opentelemetry/api": "npm:1.9.1" "@opentelemetry/auto-instrumentations-node": "npm:0.76.0" @@ -19709,9 +18171,9 @@ __metadata: languageName: node linkType: hard -"body-parser@npm:^1.15.2, body-parser@npm:~1.20.3": - version: 1.20.4 - resolution: "body-parser@npm:1.20.4" +"body-parser@npm:^1.15.2, body-parser@npm:~1.20.3, body-parser@npm:~1.20.5": + version: 1.20.5 + resolution: "body-parser@npm:1.20.5" dependencies: bytes: "npm:~3.1.2" content-type: "npm:~1.0.5" @@ -19721,11 +18183,11 @@ __metadata: http-errors: "npm:~2.0.1" iconv-lite: "npm:~0.4.24" on-finished: "npm:~2.4.1" - qs: "npm:~6.14.0" + qs: "npm:~6.15.1" raw-body: "npm:~2.5.3" type-is: "npm:~1.6.18" unpipe: "npm:~1.0.0" - checksum: 10c0/569c1e896297d1fcd8f34026c8d0ab70b90d45343c15c5d8dff5de2bad08125fc1e2f8c2f3f4c1ac6c0caaad115218202594d37dcb8d89d9b5dcae1c2b736aa9 + checksum: 10c0/ad777ca5e4711eae253c93f50fdc4608c60b76a9710d79e5e5b84581c76691e6ad21ecc9158986d9ea2b365df73e403ca33c27a8bccc1a7cfc2ccc248548118d languageName: node linkType: hard @@ -20214,6 +18676,32 @@ __metadata: languageName: node linkType: hard +"casbin@npm:5.27.1": + version: 5.27.1 + resolution: "casbin@npm:5.27.1" + dependencies: + await-lock: "npm:^2.0.1" + buffer: "npm:^6.0.3" + csv-parse: "npm:^5.3.5" + expression-eval: "npm:^5.0.0" + minimatch: "npm:^7.4.2" + checksum: 10c0/7113eb7fd9a2a2b8e36093eecc687bad30f4751dc2bb9b7931e89ed02fce13c84b4221aadff157210eb9201d0cb822b92a309cdb510a5801d9d112235c22e6c0 + languageName: node + linkType: hard + +"casbin@npm:^5.27.0": + version: 5.50.0 + resolution: "casbin@npm:5.50.0" + dependencies: + "@casbin/expression-eval": "npm:^5.3.0" + await-lock: "npm:^2.0.1" + buffer: "npm:^6.0.3" + csv-parse: "npm:^5.5.6" + minimatch: "npm:^10.2.1" + checksum: 10c0/c0c54e2b589802d25a332e0333bbad4bddb7e920efe99dcaa09ad659d329bd2455e752f3fb5526e2e98453e38532d86f7d04c07f846f2e4c5df64143acb3cc16 + languageName: node + linkType: hard + "catharsis@npm:^0.9.0": version: 0.9.0 resolution: "catharsis@npm:0.9.0" @@ -20873,7 +19361,7 @@ __metadata: languageName: node linkType: hard -"component-emitter@npm:^1.3.0": +"component-emitter@npm:^1.3.0, component-emitter@npm:^1.3.1": version: 1.3.1 resolution: "component-emitter@npm:1.3.1" checksum: 10c0/e4900b1b790b5e76b8d71b328da41482118c0f3523a516a41be598dc2785a07fd721098d9bf6e22d89b19f4fa4e1025160dc00317ea111633a3e4f75c2b86032 @@ -21082,7 +19570,7 @@ __metadata: languageName: node linkType: hard -"cookie-signature@npm:^1.2.1": +"cookie-signature@npm:^1.2.1, cookie-signature@npm:^1.2.2": version: 1.2.2 resolution: "cookie-signature@npm:1.2.2" checksum: 10c0/54e05df1a293b3ce81589b27dddc445f462f6fa6812147c033350cd3561a42bc14481674e05ed14c7bd0ce1e8bb3dc0e40851bad75415733711294ddce0b7bc6 @@ -21621,6 +20109,20 @@ __metadata: languageName: node linkType: hard +"csv-parse@npm:^5.3.5, csv-parse@npm:^5.5.6": + version: 5.6.0 + resolution: "csv-parse@npm:5.6.0" + checksum: 10c0/52f5e6c45359902e0c8e57fc2eeed41366dc6b6d283b495b538dd50c8e8510413d6f924096ea056319cbbb8ed26e111c3a3485d7985c021bcf5abaa9e92425c7 + languageName: node + linkType: hard + +"csv-parse@npm:^6.0.0": + version: 6.2.1 + resolution: "csv-parse@npm:6.2.1" + checksum: 10c0/8b6f14b244ca62476d4217aac721131ba0ada3e4ed7614e43ebc99203807564dcb054144d1de4ef22ee8b0c63b431640f75d46a0e1e0f72853a954b279ba1c61 + languageName: node + linkType: hard + "ctrlc-windows@npm:^2.1.0": version: 2.2.0 resolution: "ctrlc-windows@npm:2.2.0" @@ -21823,6 +20325,13 @@ __metadata: languageName: node linkType: hard +"dayjs@npm:^1.11.20": + version: 1.11.21 + resolution: "dayjs@npm:1.11.21" + checksum: 10c0/bd97dfdc4bfea3c66268635690313828b386faa040fbc1f829ff42a2bd748b72c9d9b3c8f9616ce9e61fcb78923f1461a462c969c54b1084458ae1b715898fb0 + languageName: node + linkType: hard + "debounce-promise@npm:^3.1.2": version: 3.1.2 resolution: "debounce-promise@npm:3.1.2" @@ -21846,7 +20355,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3": +"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.3.7, debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -21904,15 +20413,15 @@ __metadata: languageName: node linkType: hard -"dedent@npm:^1.6.0": - version: 1.7.1 - resolution: "dedent@npm:1.7.1" +"dedent@npm:^1.6.0, dedent@npm:^1.7.2": + version: 1.7.2 + resolution: "dedent@npm:1.7.2" peerDependencies: babel-plugin-macros: ^3.1.0 peerDependenciesMeta: babel-plugin-macros: optional: true - checksum: 10c0/ae29ec1c5bd5216c698c9f23acaa5b720260fd4cef3c8b5af887eb5f8c9e6fdd5fed8668767437b4efea35e2991bd798987717633411a1734807c28255769b78 + checksum: 10c0/acaff07cac355b93f17b1b17ebbb84d3cc55af6ab4b7814c3f505e061903e168bc6bf9ddce331552d64dee1525f0b4c549c9ade46aebfac6f69caaed74e90751 languageName: node linkType: hard @@ -22436,6 +20945,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:^16.6.1": + version: 16.6.1 + resolution: "dotenv@npm:16.6.1" + checksum: 10c0/15ce56608326ea0d1d9414a5c8ee6dcf0fffc79d2c16422b4ac2268e7e2d76ff5a572d37ffe747c377de12005f14b3cc22361e79fc7f1061cce81f77d2c973dc + languageName: node + linkType: hard + "drange@npm:^1.0.2": version: 1.1.1 resolution: "drange@npm:1.1.1" @@ -23904,7 +22420,7 @@ __metadata: languageName: node linkType: hard -"express@npm:4.22.1, express@npm:^4.14.0, express@npm:^4.17.3, express@npm:^4.22.0, express@npm:^4.22.1": +"express@npm:4.22.1": version: 4.22.1 resolution: "express@npm:4.22.1" dependencies: @@ -23943,6 +22459,45 @@ __metadata: languageName: node linkType: hard +"express@npm:^4.14.0, express@npm:^4.17.3, express@npm:^4.18.2, express@npm:^4.22.0, express@npm:^4.22.1": + version: 4.22.2 + resolution: "express@npm:4.22.2" + dependencies: + accepts: "npm:~1.3.8" + array-flatten: "npm:1.1.1" + body-parser: "npm:~1.20.5" + content-disposition: "npm:~0.5.4" + content-type: "npm:~1.0.4" + cookie: "npm:~0.7.1" + cookie-signature: "npm:~1.0.6" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + etag: "npm:~1.8.1" + finalhandler: "npm:~1.3.1" + fresh: "npm:~0.5.2" + http-errors: "npm:~2.0.0" + merge-descriptors: "npm:1.0.3" + methods: "npm:~1.1.2" + on-finished: "npm:~2.4.1" + parseurl: "npm:~1.3.3" + path-to-regexp: "npm:~0.1.12" + proxy-addr: "npm:~2.0.7" + qs: "npm:~6.15.1" + range-parser: "npm:~1.2.1" + safe-buffer: "npm:5.2.1" + send: "npm:~0.19.0" + serve-static: "npm:~1.16.2" + setprototypeof: "npm:1.2.0" + statuses: "npm:~2.0.1" + type-is: "npm:~1.6.18" + utils-merge: "npm:1.0.1" + vary: "npm:~1.1.2" + checksum: 10c0/d06dd4379fd217440b30f8abbe45f0e74931114c1395034f03e7d635196ecdab530d4835a1962a6aa34838d61967dc6f1f77846999bba3032373e9e714222c44 + languageName: node + linkType: hard + "express@npm:^5.2.1": version: 5.2.1 resolution: "express@npm:5.2.1" @@ -23979,6 +22534,15 @@ __metadata: languageName: node linkType: hard +"expression-eval@npm:^5.0.0": + version: 5.0.1 + resolution: "expression-eval@npm:5.0.1" + dependencies: + jsep: "npm:^0.3.0" + checksum: 10c0/74f9e1e54e50b3c924a71bcddf1550c51f15e24646f2b6cb8c45c7dd3731eb3f0e1e9a6dbf895549ddc445fe66909c373779ea6bf7f6f1e90d6bcef1590543ff + languageName: node + linkType: hard + "extend-shallow@npm:^2.0.1": version: 2.0.1 resolution: "extend-shallow@npm:2.0.1" @@ -24101,13 +22665,6 @@ __metadata: languageName: node linkType: hard -"fast-xml-builder@npm:^1.0.0": - version: 1.0.0 - resolution: "fast-xml-builder@npm:1.0.0" - checksum: 10c0/2631fda265c81e8008884d08944eeed4e284430116faa5b8b7a43a3602af367223b7bf01c933215c9ad2358b8666e45041bc038d64877156a2f88821841b3014 - languageName: node - linkType: hard - "fast-xml-builder@npm:^1.1.5": version: 1.1.5 resolution: "fast-xml-builder@npm:1.1.5" @@ -24117,7 +22674,7 @@ __metadata: languageName: node linkType: hard -"fast-xml-parser@npm:5.7.1": +"fast-xml-parser@npm:5.7.1, fast-xml-parser@npm:^5.0.7, fast-xml-parser@npm:^5.3.4": version: 5.7.1 resolution: "fast-xml-parser@npm:5.7.1" dependencies: @@ -24131,18 +22688,6 @@ __metadata: languageName: node linkType: hard -"fast-xml-parser@npm:^5.0.7, fast-xml-parser@npm:^5.3.4": - version: 5.4.1 - resolution: "fast-xml-parser@npm:5.4.1" - dependencies: - fast-xml-builder: "npm:^1.0.0" - strnum: "npm:^2.1.2" - bin: - fxparser: src/cli/cli.js - checksum: 10c0/8c696438a0c64135faf93ea6a93879208d649b7c9a3293d30d6eb750dc7f766fd083c0df5a82786b60809c3ead64fad155f28dbed25efea91017aaf9f64c91e5 - languageName: node - linkType: hard - "fastest-stable-stringify@npm:^2.0.2": version: 2.0.2 resolution: "fastest-stable-stringify@npm:2.0.2" @@ -24438,7 +22983,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.0.0": +"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.15.11": version: 1.16.0 resolution: "follow-redirects@npm:1.16.0" peerDependenciesMeta: @@ -24448,16 +22993,6 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.11": - version: 1.15.11 - resolution: "follow-redirects@npm:1.15.11" - peerDependenciesMeta: - debug: - optional: true - checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343 - languageName: node - linkType: hard - "for-each@npm:^0.3.3, for-each@npm:^0.3.5": version: 0.3.5 resolution: "for-each@npm:0.3.5" @@ -24620,6 +23155,17 @@ __metadata: languageName: node linkType: hard +"formidable@npm:^3.5.4": + version: 3.5.4 + resolution: "formidable@npm:3.5.4" + dependencies: + "@paralleldrive/cuid2": "npm:^2.2.2" + dezalgo: "npm:^1.0.4" + once: "npm:^1.4.0" + checksum: 10c0/3a311ce57617eb8f532368e91c0f2bbfb299a0f1a35090e085bd6ca772298f196fbb0b66f0d4b5549d7bf3c5e1844439338d4402b7b6d1fedbe206ad44a931f8 + languageName: node + linkType: hard + "formstream@npm:^1.5.1": version: 1.5.2 resolution: "formstream@npm:1.5.2" @@ -28097,6 +26643,13 @@ __metadata: languageName: node linkType: hard +"jsep@npm:^0.3.0": + version: 0.3.5 + resolution: "jsep@npm:0.3.5" + checksum: 10c0/fb5def7a4ba1cee41d144ebdd0d477785dc84b6bc1fed6cf5169f106de980dbe363bf99cb36a450435d7fd952d22b1d76e1609aeb5c7e7cbbbdb6d15fad03614 + languageName: node + linkType: hard + "jsep@npm:^1.2.0, jsep@npm:^1.4.0": version: 1.4.0 resolution: "jsep@npm:1.4.0" @@ -28564,6 +27117,17 @@ __metadata: languageName: node linkType: hard +"knex-mock-client@npm:3.0.2": + version: 3.0.2 + resolution: "knex-mock-client@npm:3.0.2" + dependencies: + lodash.clonedeep: "npm:^4.5.0" + peerDependencies: + knex: ">=2.0.0" + checksum: 10c0/95b0430a7d5f074afb142644c0b60f8824f065608982a6321e816b9b1fcf6edb7c532cf8930372d8f5f86c57a407ab89bb076e4419cf5d0a10fb6df550dd7020 + languageName: node + linkType: hard + "knex@npm:3, knex@npm:3.1.0, knex@npm:^3.0.0": version: 3.1.0 resolution: "knex@npm:3.1.0" @@ -30263,6 +28827,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^7.4.2": + version: 7.4.9 + resolution: "minimatch@npm:7.4.9" + dependencies: + brace-expansion: "npm:^2.0.2" + checksum: 10c0/8d5406a9697edb9b7ea02697d58cabcb3d3a9a4a02caa1cf57b9ab5ae22c78b2945600661a78f91d1545f77521f97f3cb5f8cb066e58356a121b50e4e60ccdbe + languageName: node + linkType: hard + "minimatch@npm:^9.0.4": version: 9.0.9 resolution: "minimatch@npm:9.0.9" @@ -33360,7 +31933,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.10.1, qs@npm:^6.11.0, qs@npm:^6.12.1, qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.14.1, qs@npm:^6.9.4": +"qs@npm:6.15.1": version: 6.15.1 resolution: "qs@npm:6.15.1" dependencies: @@ -33369,6 +31942,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.10.1, qs@npm:^6.11.0, qs@npm:^6.12.1, qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.14.1, qs@npm:^6.9.4, qs@npm:~6.15.1": + version: 6.15.2 + resolution: "qs@npm:6.15.2" + dependencies: + side-channel: "npm:^1.1.0" + checksum: 10c0/e6fd5f6f0aab06d480fe9ab15cebfc4ce4235303e2f91dc69a8f7f4df1e668a61c11d1cfbabacf4295cbbeb7b670ed23db45307480726259761f98e5695e93a7 + languageName: node + linkType: hard + "qs@npm:~6.14.0": version: 6.14.2 resolution: "qs@npm:6.14.2" @@ -33582,47 +32164,7 @@ __metadata: languageName: node linkType: hard -"react-aria-components@npm:^1.14.0": - version: 1.15.1 - resolution: "react-aria-components@npm:1.15.1" - dependencies: - "@internationalized/date": "npm:^3.11.0" - "@internationalized/string": "npm:^3.2.7" - "@react-aria/autocomplete": "npm:3.0.0-rc.5" - "@react-aria/collections": "npm:^3.0.2" - "@react-aria/dnd": "npm:^3.11.5" - "@react-aria/focus": "npm:^3.21.4" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/live-announcer": "npm:^3.4.4" - "@react-aria/overlays": "npm:^3.31.1" - "@react-aria/ssr": "npm:^3.9.10" - "@react-aria/textfield": "npm:^3.18.4" - "@react-aria/toolbar": "npm:3.0.0-beta.23" - "@react-aria/utils": "npm:^3.33.0" - "@react-aria/virtualizer": "npm:^4.1.12" - "@react-stately/autocomplete": "npm:3.0.0-beta.4" - "@react-stately/layout": "npm:^4.5.3" - "@react-stately/selection": "npm:^3.20.8" - "@react-stately/table": "npm:^3.15.3" - "@react-stately/utils": "npm:^3.11.0" - "@react-stately/virtualizer": "npm:^4.4.5" - "@react-types/form": "npm:^3.7.17" - "@react-types/grid": "npm:^3.3.7" - "@react-types/shared": "npm:^3.33.0" - "@react-types/table": "npm:^3.13.5" - "@swc/helpers": "npm:^0.5.0" - client-only: "npm:^0.0.1" - react-aria: "npm:^3.46.0" - react-stately: "npm:^3.44.0" - use-sync-external-store: "npm:^1.6.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/6aa61901aa67086d403d9d7b52ea8dd3ca4239bd04bbeff83e09292131e31da91bc94dee6841cb53f36e32ff614e708d22c5590e14eae6eba5712256e31658f4 - languageName: node - linkType: hard - -"react-aria-components@npm:~1.17.0": +"react-aria-components@npm:^1.14.0, react-aria-components@npm:~1.17.0": version: 1.17.0 resolution: "react-aria-components@npm:1.17.0" dependencies: @@ -33659,59 +32201,6 @@ __metadata: languageName: node linkType: hard -"react-aria@npm:^3.46.0": - version: 3.46.0 - resolution: "react-aria@npm:3.46.0" - dependencies: - "@internationalized/string": "npm:^3.2.7" - "@react-aria/breadcrumbs": "npm:^3.5.31" - "@react-aria/button": "npm:^3.14.4" - "@react-aria/calendar": "npm:^3.9.4" - "@react-aria/checkbox": "npm:^3.16.4" - "@react-aria/color": "npm:^3.1.4" - "@react-aria/combobox": "npm:^3.14.2" - "@react-aria/datepicker": "npm:^3.16.0" - "@react-aria/dialog": "npm:^3.5.33" - "@react-aria/disclosure": "npm:^3.1.2" - "@react-aria/dnd": "npm:^3.11.5" - "@react-aria/focus": "npm:^3.21.4" - "@react-aria/gridlist": "npm:^3.14.3" - "@react-aria/i18n": "npm:^3.12.15" - "@react-aria/interactions": "npm:^3.27.0" - "@react-aria/label": "npm:^3.7.24" - "@react-aria/landmark": "npm:^3.0.9" - "@react-aria/link": "npm:^3.8.8" - "@react-aria/listbox": "npm:^3.15.2" - "@react-aria/menu": "npm:^3.20.0" - "@react-aria/meter": "npm:^3.4.29" - "@react-aria/numberfield": "npm:^3.12.4" - "@react-aria/overlays": "npm:^3.31.1" - "@react-aria/progress": "npm:^3.4.29" - "@react-aria/radio": "npm:^3.12.4" - "@react-aria/searchfield": "npm:^3.8.11" - "@react-aria/select": "npm:^3.17.2" - "@react-aria/selection": "npm:^3.27.1" - "@react-aria/separator": "npm:^3.4.15" - "@react-aria/slider": "npm:^3.8.4" - "@react-aria/ssr": "npm:^3.9.10" - "@react-aria/switch": "npm:^3.7.10" - "@react-aria/table": "npm:^3.17.10" - "@react-aria/tabs": "npm:^3.11.0" - "@react-aria/tag": "npm:^3.8.0" - "@react-aria/textfield": "npm:^3.18.4" - "@react-aria/toast": "npm:^3.0.10" - "@react-aria/tooltip": "npm:^3.9.1" - "@react-aria/tree": "npm:^3.1.6" - "@react-aria/utils": "npm:^3.33.0" - "@react-aria/visually-hidden": "npm:^3.8.30" - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/1147777f6ee54f38e53ff5b8aca3d99d5f3c68c16cbabe07d8572b6f34142d3457d58a67c9ab547f0b4e2ecca3f111baf8d283a0e294115a1818af90f86daea1 - languageName: node - linkType: hard - "react-beautiful-dnd@npm:^13.0.0": version: 13.1.1 resolution: "react-beautiful-dnd@npm:13.1.1" @@ -34183,42 +32672,6 @@ __metadata: languageName: node linkType: hard -"react-stately@npm:^3.44.0": - version: 3.44.0 - resolution: "react-stately@npm:3.44.0" - dependencies: - "@react-stately/calendar": "npm:^3.9.2" - "@react-stately/checkbox": "npm:^3.7.4" - "@react-stately/collections": "npm:^3.12.9" - "@react-stately/color": "npm:^3.9.4" - "@react-stately/combobox": "npm:^3.12.2" - "@react-stately/data": "npm:^3.15.1" - "@react-stately/datepicker": "npm:^3.16.0" - "@react-stately/disclosure": "npm:^3.0.10" - "@react-stately/dnd": "npm:^3.7.3" - "@react-stately/form": "npm:^3.2.3" - "@react-stately/list": "npm:^3.13.3" - "@react-stately/menu": "npm:^3.9.10" - "@react-stately/numberfield": "npm:^3.10.4" - "@react-stately/overlays": "npm:^3.6.22" - "@react-stately/radio": "npm:^3.11.4" - "@react-stately/searchfield": "npm:^3.5.18" - "@react-stately/select": "npm:^3.9.1" - "@react-stately/selection": "npm:^3.20.8" - "@react-stately/slider": "npm:^3.7.4" - "@react-stately/table": "npm:^3.15.3" - "@react-stately/tabs": "npm:^3.8.8" - "@react-stately/toast": "npm:^3.1.3" - "@react-stately/toggle": "npm:^3.9.4" - "@react-stately/tooltip": "npm:^3.5.10" - "@react-stately/tree": "npm:^3.9.5" - "@react-types/shared": "npm:^3.33.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/0476362742d729033fc3fe5f601c587605d2374cd44adadeee77b9d476791c20f5879473f3bcbeccc5c1fcae6c7d424d7bebbdc1828a1792e0a42e667b4ded46 - languageName: node - linkType: hard - "react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3": version: 2.2.3 resolution: "react-style-singleton@npm:2.2.3" @@ -34512,6 +32965,13 @@ __metadata: languageName: node linkType: hard +"reflect-metadata@npm:^0.1.13": + version: 0.1.14 + resolution: "reflect-metadata@npm:0.1.14" + checksum: 10c0/3a6190c7f6cb224f26a012d11f9e329360c01c1945e2cbefea23976a8bacf9db6b794aeb5bf18adcb673c448a234fbc06fc41853c00a6c206b30f0777ecf019e + languageName: node + linkType: hard + "reflect-metadata@npm:^0.2.2": version: 0.2.2 resolution: "reflect-metadata@npm:0.2.2" @@ -36205,6 +34665,13 @@ __metadata: languageName: node linkType: hard +"sql-highlight@npm:^6.1.0": + version: 6.1.0 + resolution: "sql-highlight@npm:6.1.0" + checksum: 10c0/9614f4608bfde8ea7bf9b2fe9233dcc99a619c91cbc3f5cd85a6fb5ad4b2177f4ac8ca4a0191f4243ff8aea3b6f2a1229efc88635298269e0049b2ac08bde263 + languageName: node + linkType: hard + "ssh-remote-port-forward@npm:^1.0.4": version: 1.0.4 resolution: "ssh-remote-port-forward@npm:1.0.4" @@ -36710,13 +35177,6 @@ __metadata: languageName: node linkType: hard -"strnum@npm:^2.1.2": - version: 2.1.2 - resolution: "strnum@npm:2.1.2" - checksum: 10c0/4e04753b793540d79cd13b2c3e59e298440477bae2b853ab78d548138385193b37d766d95b63b7046475d68d44fb1fca692f0a3f72b03f4168af076c7b246df9 - languageName: node - linkType: hard - "strnum@npm:^2.2.3": version: 2.2.3 resolution: "strnum@npm:2.2.3" @@ -36826,6 +35286,23 @@ __metadata: languageName: node linkType: hard +"superagent@npm:^10.3.0": + version: 10.3.0 + resolution: "superagent@npm:10.3.0" + dependencies: + component-emitter: "npm:^1.3.1" + cookiejar: "npm:^2.1.4" + debug: "npm:^4.3.7" + fast-safe-stringify: "npm:^2.1.1" + form-data: "npm:^4.0.5" + formidable: "npm:^3.5.4" + methods: "npm:^1.1.2" + mime: "npm:2.6.0" + qs: "npm:^6.14.1" + checksum: 10c0/7792ec2ba3a877eb1db57ad9149bf0e108688a40af0e75084bdc4bba82604b7962248267159565be4f5ab8aa392f1285a08d2e5a8cb2c3b0a51932499caf6ee6 + languageName: node + linkType: hard + "superagent@npm:^8.1.2": version: 8.1.2 resolution: "superagent@npm:8.1.2" @@ -36854,6 +35331,17 @@ __metadata: languageName: node linkType: hard +"supertest@npm:7.2.2": + version: 7.2.2 + resolution: "supertest@npm:7.2.2" + dependencies: + cookie-signature: "npm:^1.2.2" + methods: "npm:^1.1.2" + superagent: "npm:^10.3.0" + checksum: 10c0/9de987aefbec50c5dfac79ff699bbc23c89cdbfe59ede165309fb3cf00c306117b30c5059fe6f7085c7525aab315ac27c77a1dc6056b993c7e0bb154a56c2b78 + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -38011,6 +36499,94 @@ __metadata: languageName: node linkType: hard +"typeorm-adapter@npm:^1.6.1": + version: 1.9.0 + resolution: "typeorm-adapter@npm:1.9.0" + dependencies: + casbin: "npm:^5.27.0" + reflect-metadata: "npm:^0.1.13" + typeorm: "npm:^0.3.17" + checksum: 10c0/13a8cfdad81b0b262c2b38ca83bece96e4ca6c703a40ce1594b186b08e2d8afa8614af864c24b809a4f8b14df04f213fa322303dfaa8ea11401215fa9bd33f85 + languageName: node + linkType: hard + +"typeorm@npm:^0.3.17": + version: 0.3.30 + resolution: "typeorm@npm:0.3.30" + dependencies: + "@sqltools/formatter": "npm:^1.2.5" + ansis: "npm:^4.2.0" + app-root-path: "npm:^3.1.0" + buffer: "npm:^6.0.3" + dayjs: "npm:^1.11.20" + debug: "npm:^4.4.3" + dedent: "npm:^1.7.2" + dotenv: "npm:^16.6.1" + glob: "npm:^10.5.0" + reflect-metadata: "npm:^0.2.2" + sha.js: "npm:^2.4.12" + sql-highlight: "npm:^6.1.0" + tslib: "npm:^2.8.1" + uuid: "npm:^11.1.1" + yargs: "npm:^17.7.2" + peerDependencies: + "@google-cloud/spanner": ^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + "@sap/hana-client": ^2.14.22 + better-sqlite3: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 + ioredis: ^5.0.4 + mongodb: ^5.8.0 || ^6.0.0 + mssql: ^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0 + mysql2: ^2.2.5 || ^3.0.1 + oracledb: ^6.3.0 + pg: ^8.5.1 + pg-native: ^3.0.0 + pg-query-stream: ^4.0.0 + redis: ^3.1.1 || ^4.0.0 || ^5.0.14 + sql.js: ^1.4.0 + sqlite3: ^5.0.3 + ts-node: ^10.7.0 + typeorm-aurora-data-api-driver: ^2.0.0 || ^3.0.0 + peerDependenciesMeta: + "@google-cloud/spanner": + optional: true + "@sap/hana-client": + optional: true + better-sqlite3: + optional: true + ioredis: + optional: true + mongodb: + optional: true + mssql: + optional: true + mysql2: + optional: true + oracledb: + optional: true + pg: + optional: true + pg-native: + optional: true + pg-query-stream: + optional: true + redis: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + ts-node: + optional: true + typeorm-aurora-data-api-driver: + optional: true + bin: + typeorm: cli.js + typeorm-ts-node-commonjs: cli-ts-node-commonjs.js + typeorm-ts-node-esm: cli-ts-node-esm.js + checksum: 10c0/7102d1e1d65ed69642414bfb4705ef658ecdd00eb73b557b79a2c7be5b2aadeb366d623762a4773d31088737d30f331e42378e4e66d77572dd94181b37b30c9e + languageName: node + linkType: hard + "types-ramda@npm:^0.30.1": version: 0.30.1 resolution: "types-ramda@npm:0.30.1" @@ -38203,6 +36779,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~6.21.0": + version: 6.21.0 + resolution: "undici-types@npm:6.21.0" + checksum: 10c0/c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04 + languageName: node + linkType: hard + "undici-types@npm:~7.16.0": version: 7.16.0 resolution: "undici-types@npm:7.16.0" @@ -38217,27 +36800,13 @@ __metadata: languageName: node linkType: hard -"undici@npm:7.25.0, undici@npm:^7.1.1": +"undici@npm:7.25.0, undici@npm:^7.1.1, undici@npm:^7.16.0, undici@npm:^7.2.3, undici@npm:^7.21.0, undici@npm:^7.22.0": version: 7.25.0 resolution: "undici@npm:7.25.0" checksum: 10c0/02a0b45dc14eb91bc488948750232450fe52f27a6b08086d6ac6736bb47908d600fe3a96d346f12eab24729c782e5c2f693bc8e8eca6696d4e4c09b1ed4cb4ec languageName: node linkType: hard -"undici@npm:^7.16.0": - version: 7.24.6 - resolution: "undici@npm:7.24.6" - checksum: 10c0/0f5413ccb20bafe27637a3a02cada731c53ee75f1df79029099db3af1eaaed410488489d9f430c09bd30bf0b925cb75fc30c39dff0689f656fd6fb7d75ded95f - languageName: node - linkType: hard - -"undici@npm:^7.2.3, undici@npm:^7.21.0, undici@npm:^7.22.0": - version: 7.22.0 - resolution: "undici@npm:7.22.0" - checksum: 10c0/09777c06f3f18f761f03e3a4c9c04fd9fcca8ad02ccea43602ee4adf73fcba082806f1afb637f6ea714ef6279c5323c25b16d435814c63db720f63bfc20d316b - languageName: node - linkType: hard - "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.1 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.1" @@ -38752,12 +37321,12 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^11.0.0, uuid@npm:^11.0.2": - version: 11.1.0 - resolution: "uuid@npm:11.1.0" +"uuid@npm:^11.0.0, uuid@npm:^11.0.2, uuid@npm:^11.1.1": + version: 11.1.1 + resolution: "uuid@npm:11.1.1" bin: uuid: dist/esm/bin/uuid - checksum: 10c0/34aa51b9874ae398c2b799c88a127701408cd581ee89ec3baa53509dd8728cbb25826f2a038f9465f8b7be446f0fbf11558862965b18d21c993684297628d4d3 + checksum: 10c0/9e3af58eba872ece5a5e76f4773a94fc78a0ef2c2444c38dbe6b42f41dadf76c01850fd783604f27986f6195e6286aef064d45987d401b2a33127b98ddf7c0c5 languageName: node linkType: hard @@ -39882,14 +38451,14 @@ __metadata: languageName: node linkType: hard -"zod@npm:4.3.6, zod@npm:^4.1.13": +"zod@npm:4.3.6": version: 4.3.6 resolution: "zod@npm:4.3.6" checksum: 10c0/860d25a81ab41d33aa25f8d0d07b091a04acb426e605f396227a796e9e800c44723ed96d0f53a512b57be3d1520f45bf69c0cb3b378a232a00787a2609625307 languageName: node linkType: hard -"zod@npm:^4.0.0": +"zod@npm:^4.0.0, zod@npm:^4.1.13, zod@npm:^4.3.6": version: 4.4.3 resolution: "zod@npm:4.4.3" checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3 From 78b7fa1f1f3af6f0cf6a8abdd4cd6222904b5ccf Mon Sep 17 00:00:00 2001 From: Jessica He Date: Wed, 27 May 2026 12:16:56 -0400 Subject: [PATCH 3/3] Revert "add as backend internal plugin" This reverts commit 263a466e97cc20cc5a49480198c5f9818a08035c. --- package.json | 1 - packages/backend/package.json | 1 - packages/backend/src/index.ts | 1 - packages/backend/tsconfig.json | 3 +- plugins/rbac-backend/.eslintignore | 4 - plugins/rbac-backend/.eslintrc.js | 25 - plugins/rbac-backend/.prettierignore | 14 - plugins/rbac-backend/.prettierrc.js | 34 - plugins/rbac-backend/CHANGELOG.md | 1171 ----- plugins/rbac-backend/README.md | 364 -- .../__fixtures__/auditor-test-utils.ts | 76 - .../__fixtures__/data/hierarchy/groups.ts | 893 ---- .../data/hierarchy/rbac-policy.csv | 245 - .../__fixtures__/data/hierarchy/users.ts | 357 -- .../bad-conditions-yaml.yaml | 1 - .../data/invalid-conditions/invalid-yaml.yaml | 1 - .../data/invalid-csv/deprecated-policy.csv | 1 - .../data/invalid-csv/duplicate-policy.csv | 17 - .../data/invalid-csv/error-policy.csv | 10 - .../data/valid-conditions/conditions.yaml | 28 - .../valid-conditions/empty-conditions.yaml | 0 .../extra-delimiter-conditions.yaml | 30 - .../valid-csv/basic-and-resource-policies.csv | 21 - .../data/valid-csv/policy-checks.csv | 67 - .../data/valid-csv/rbac-policy.csv | 17 - .../data/valid-csv/simple-policy.csv | 2 - .../data/valid-csv/uppercase-policy.csv | 7 - .../rbac-backend/__fixtures__/mock-utils.ts | 199 - .../rbac-backend/__fixtures__/test-utils.ts | 236 - plugins/rbac-backend/catalog-info.yaml | 28 - plugins/rbac-backend/config.d.ts | 99 - plugins/rbac-backend/docs/apis.md | 986 ---- plugins/rbac-backend/docs/audit-log.md | 252 - plugins/rbac-backend/docs/conditions.md | 388 -- plugins/rbac-backend/docs/group-hierarchy.md | 236 - .../docs/images/group-hierarchy-1.svg | 1 - .../docs/images/group-hierarchy-2.svg | 1 - .../docs/images/group-hierarchy-3.svg | 1 - .../docs/images/group-hierarchy-4.svg | 1 - plugins/rbac-backend/docs/multitenancy.md | 176 - plugins/rbac-backend/docs/permissions.md | 153 - plugins/rbac-backend/docs/providers.md | 350 -- plugins/rbac-backend/knexfile.js | 28 - plugins/rbac-backend/knip-report.md | 2 - .../migrations/20231015161232_migrations.js | 41 - .../migrations/20231212224526_migrations.js | 84 - .../migrations/20231221113214_migrations.js | 60 - .../migrations/20240201144429_migrations.js | 37 - .../migrations/20240215154456_migrations.js | 143 - .../migrations/20240308134410_migrations.js | 31 - .../migrations/20240308134941_migrations.js | 43 - .../migrations/20240404111242_migrations.js | 53 - .../migrations/20240611092136_migrations.js | 29 - .../migrations/20241108093910_migrations.js | 35 - .../migrations/20250305155143_migration.js | 73 - .../migrations/20250509110032_migrations.js | 29 - ...6100000_add_is_default_to_role_metadata.js | 43 - plugins/rbac-backend/openapi.yaml | 760 --- plugins/rbac-backend/package.json | 102 - plugins/rbac-backend/report.api.md | 76 - .../admin-permissions/admin-creation.test.ts | 211 - .../src/admin-permissions/admin-creation.ts | 212 - plugins/rbac-backend/src/auditor/auditor.ts | 111 - .../src/auditor/rest-interceptor.ts | 189 - .../alias-resolver.test.ts | 648 --- .../src/conditional-aliases/alias-resolver.ts | 123 - .../database/casbin-adapter-factory.test.ts | 612 --- .../src/database/casbin-adapter-factory.ts | 228 - .../src/database/conditional-storage.test.ts | 669 --- .../src/database/conditional-storage.ts | 298 -- ...permission-enabled-plugins-storage.test.ts | 92 - ...xtra-permission-enabled-plugins-storage.ts | 58 - .../rbac-backend/src/database/migration.ts | 34 - .../src/database/role-metadata.test.ts | 965 ---- .../src/database/role-metadata.ts | 261 - .../default-permissions.test.ts | 561 --- .../default-permissions.ts | 181 - .../file-permissions/csv-file-watcher.test.ts | 845 ---- .../src/file-permissions/csv-file-watcher.ts | 622 --- .../src/file-permissions/file-watcher.ts | 76 - .../lowercase-file-adapter.ts | 55 - .../yaml-conditional-file-watcher.test.ts | 629 --- .../yaml-conditional-file-watcher.ts | 267 -- plugins/rbac-backend/src/helper.test.ts | 827 ---- plugins/rbac-backend/src/helper.ts | 354 -- plugins/rbac-backend/src/index.ts | 23 - .../src/permissions/conditions.ts | 54 - plugins/rbac-backend/src/permissions/index.ts | 17 - .../rbac-backend/src/permissions/resource.ts | 32 - plugins/rbac-backend/src/permissions/rules.ts | 94 - plugins/rbac-backend/src/plugin.ts | 123 - .../src/policies/allow-all-policy.test.ts | 82 - .../src/policies/allow-all-policy.ts | 33 - .../permission-policy.hierarchy.test.ts | 1123 ----- .../src/policies/permission-policy.test.ts | 2499 ---------- .../src/policies/permission-policy.ts | 384 -- .../src/providers/connect-providers.test.ts | 851 ---- .../src/providers/connect-providers.ts | 438 -- .../role-manager/ancestor-search-factory.ts | 51 - .../ancestor-search-memo-pg.test.ts | 238 - .../role-manager/ancestor-search-memo-pg.ts | 84 - .../ancestor-search-memo-sqlite.test.ts | 151 - .../ancestor-search-memo-sqlite.ts | 108 - .../src/role-manager/ancestor-search-memo.ts | 83 - .../src/role-manager/member-list.test.ts | 151 - .../src/role-manager/member-list.ts | 142 - .../src/role-manager/role-manager.test.ts | 626 --- .../src/role-manager/role-manager.ts | 348 -- .../src/service/enforcer-delegate.test.ts | 1305 ----- .../src/service/enforcer-delegate.ts | 742 --- .../service/extendable-id-provider.test.ts | 138 - .../src/service/extendable-id-provider.ts | 56 - .../permission-definition-routes.test.ts | 452 -- .../service/permission-definition-routes.ts | 166 - .../src/service/permission-model.ts | 31 - .../src/service/plugin-endpoint.test.ts | 543 --- .../src/service/plugin-endpoints.ts | 207 - .../policies-rest-api.conditions.test.ts | 993 ---- .../src/service/policies-rest-api.test.ts | 4239 ----------------- .../src/service/policies-rest-api.ts | 1324 ----- .../src/service/policy-builder.test.ts | 273 -- .../src/service/policy-builder.ts | 250 - .../rbac-backend/src/service/router.test.ts | 46 - plugins/rbac-backend/src/service/router.ts | 51 - plugins/rbac-backend/src/setupTests.ts | 16 - .../validation/condition-validation.test.ts | 988 ---- .../src/validation/condition-validation.ts | 196 - .../src/validation/plugin-validation.test.ts | 44 - .../src/validation/plugin-validation.ts | 32 - .../validation/policies-validation.test.ts | 410 -- .../src/validation/policies-validation.ts | 305 -- plugins/rbac-backend/tsconfig.json | 12 - plugins/rbac-backend/turbo.json | 8 - tsconfig.json | 3 +- yarn.lock | 2375 +++++++-- 135 files changed, 1905 insertions(+), 38330 deletions(-) delete mode 100644 plugins/rbac-backend/.eslintignore delete mode 100644 plugins/rbac-backend/.eslintrc.js delete mode 100644 plugins/rbac-backend/.prettierignore delete mode 100644 plugins/rbac-backend/.prettierrc.js delete mode 100644 plugins/rbac-backend/CHANGELOG.md delete mode 100644 plugins/rbac-backend/README.md delete mode 100644 plugins/rbac-backend/__fixtures__/auditor-test-utils.ts delete mode 100644 plugins/rbac-backend/__fixtures__/data/hierarchy/groups.ts delete mode 100644 plugins/rbac-backend/__fixtures__/data/hierarchy/rbac-policy.csv delete mode 100644 plugins/rbac-backend/__fixtures__/data/hierarchy/users.ts delete mode 100644 plugins/rbac-backend/__fixtures__/data/invalid-conditions/bad-conditions-yaml.yaml delete mode 100644 plugins/rbac-backend/__fixtures__/data/invalid-conditions/invalid-yaml.yaml delete mode 100644 plugins/rbac-backend/__fixtures__/data/invalid-csv/deprecated-policy.csv delete mode 100644 plugins/rbac-backend/__fixtures__/data/invalid-csv/duplicate-policy.csv delete mode 100644 plugins/rbac-backend/__fixtures__/data/invalid-csv/error-policy.csv delete mode 100644 plugins/rbac-backend/__fixtures__/data/valid-conditions/conditions.yaml delete mode 100644 plugins/rbac-backend/__fixtures__/data/valid-conditions/empty-conditions.yaml delete mode 100644 plugins/rbac-backend/__fixtures__/data/valid-conditions/extra-delimiter-conditions.yaml delete mode 100644 plugins/rbac-backend/__fixtures__/data/valid-csv/basic-and-resource-policies.csv delete mode 100644 plugins/rbac-backend/__fixtures__/data/valid-csv/policy-checks.csv delete mode 100644 plugins/rbac-backend/__fixtures__/data/valid-csv/rbac-policy.csv delete mode 100644 plugins/rbac-backend/__fixtures__/data/valid-csv/simple-policy.csv delete mode 100644 plugins/rbac-backend/__fixtures__/data/valid-csv/uppercase-policy.csv delete mode 100644 plugins/rbac-backend/__fixtures__/mock-utils.ts delete mode 100644 plugins/rbac-backend/__fixtures__/test-utils.ts delete mode 100644 plugins/rbac-backend/catalog-info.yaml delete mode 100644 plugins/rbac-backend/config.d.ts delete mode 100644 plugins/rbac-backend/docs/apis.md delete mode 100644 plugins/rbac-backend/docs/audit-log.md delete mode 100644 plugins/rbac-backend/docs/conditions.md delete mode 100644 plugins/rbac-backend/docs/group-hierarchy.md delete mode 100644 plugins/rbac-backend/docs/images/group-hierarchy-1.svg delete mode 100644 plugins/rbac-backend/docs/images/group-hierarchy-2.svg delete mode 100644 plugins/rbac-backend/docs/images/group-hierarchy-3.svg delete mode 100644 plugins/rbac-backend/docs/images/group-hierarchy-4.svg delete mode 100644 plugins/rbac-backend/docs/multitenancy.md delete mode 100644 plugins/rbac-backend/docs/permissions.md delete mode 100644 plugins/rbac-backend/docs/providers.md delete mode 100644 plugins/rbac-backend/knexfile.js delete mode 100644 plugins/rbac-backend/knip-report.md delete mode 100644 plugins/rbac-backend/migrations/20231015161232_migrations.js delete mode 100644 plugins/rbac-backend/migrations/20231212224526_migrations.js delete mode 100644 plugins/rbac-backend/migrations/20231221113214_migrations.js delete mode 100644 plugins/rbac-backend/migrations/20240201144429_migrations.js delete mode 100644 plugins/rbac-backend/migrations/20240215154456_migrations.js delete mode 100644 plugins/rbac-backend/migrations/20240308134410_migrations.js delete mode 100644 plugins/rbac-backend/migrations/20240308134941_migrations.js delete mode 100644 plugins/rbac-backend/migrations/20240404111242_migrations.js delete mode 100644 plugins/rbac-backend/migrations/20240611092136_migrations.js delete mode 100644 plugins/rbac-backend/migrations/20241108093910_migrations.js delete mode 100644 plugins/rbac-backend/migrations/20250305155143_migration.js delete mode 100644 plugins/rbac-backend/migrations/20250509110032_migrations.js delete mode 100644 plugins/rbac-backend/migrations/20260216100000_add_is_default_to_role_metadata.js delete mode 100644 plugins/rbac-backend/openapi.yaml delete mode 100644 plugins/rbac-backend/package.json delete mode 100644 plugins/rbac-backend/report.api.md delete mode 100644 plugins/rbac-backend/src/admin-permissions/admin-creation.test.ts delete mode 100644 plugins/rbac-backend/src/admin-permissions/admin-creation.ts delete mode 100644 plugins/rbac-backend/src/auditor/auditor.ts delete mode 100644 plugins/rbac-backend/src/auditor/rest-interceptor.ts delete mode 100644 plugins/rbac-backend/src/conditional-aliases/alias-resolver.test.ts delete mode 100644 plugins/rbac-backend/src/conditional-aliases/alias-resolver.ts delete mode 100644 plugins/rbac-backend/src/database/casbin-adapter-factory.test.ts delete mode 100644 plugins/rbac-backend/src/database/casbin-adapter-factory.ts delete mode 100644 plugins/rbac-backend/src/database/conditional-storage.test.ts delete mode 100644 plugins/rbac-backend/src/database/conditional-storage.ts delete mode 100644 plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.test.ts delete mode 100644 plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.ts delete mode 100644 plugins/rbac-backend/src/database/migration.ts delete mode 100644 plugins/rbac-backend/src/database/role-metadata.test.ts delete mode 100644 plugins/rbac-backend/src/database/role-metadata.ts delete mode 100644 plugins/rbac-backend/src/default-permissions/default-permissions.test.ts delete mode 100644 plugins/rbac-backend/src/default-permissions/default-permissions.ts delete mode 100644 plugins/rbac-backend/src/file-permissions/csv-file-watcher.test.ts delete mode 100644 plugins/rbac-backend/src/file-permissions/csv-file-watcher.ts delete mode 100644 plugins/rbac-backend/src/file-permissions/file-watcher.ts delete mode 100644 plugins/rbac-backend/src/file-permissions/lowercase-file-adapter.ts delete mode 100644 plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.test.ts delete mode 100644 plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.ts delete mode 100644 plugins/rbac-backend/src/helper.test.ts delete mode 100644 plugins/rbac-backend/src/helper.ts delete mode 100644 plugins/rbac-backend/src/index.ts delete mode 100644 plugins/rbac-backend/src/permissions/conditions.ts delete mode 100644 plugins/rbac-backend/src/permissions/index.ts delete mode 100644 plugins/rbac-backend/src/permissions/resource.ts delete mode 100644 plugins/rbac-backend/src/permissions/rules.ts delete mode 100644 plugins/rbac-backend/src/plugin.ts delete mode 100644 plugins/rbac-backend/src/policies/allow-all-policy.test.ts delete mode 100644 plugins/rbac-backend/src/policies/allow-all-policy.ts delete mode 100644 plugins/rbac-backend/src/policies/permission-policy.hierarchy.test.ts delete mode 100644 plugins/rbac-backend/src/policies/permission-policy.test.ts delete mode 100644 plugins/rbac-backend/src/policies/permission-policy.ts delete mode 100644 plugins/rbac-backend/src/providers/connect-providers.test.ts delete mode 100644 plugins/rbac-backend/src/providers/connect-providers.ts delete mode 100644 plugins/rbac-backend/src/role-manager/ancestor-search-factory.ts delete mode 100644 plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.test.ts delete mode 100644 plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.ts delete mode 100644 plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.test.ts delete mode 100644 plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.ts delete mode 100644 plugins/rbac-backend/src/role-manager/ancestor-search-memo.ts delete mode 100644 plugins/rbac-backend/src/role-manager/member-list.test.ts delete mode 100644 plugins/rbac-backend/src/role-manager/member-list.ts delete mode 100644 plugins/rbac-backend/src/role-manager/role-manager.test.ts delete mode 100644 plugins/rbac-backend/src/role-manager/role-manager.ts delete mode 100644 plugins/rbac-backend/src/service/enforcer-delegate.test.ts delete mode 100644 plugins/rbac-backend/src/service/enforcer-delegate.ts delete mode 100644 plugins/rbac-backend/src/service/extendable-id-provider.test.ts delete mode 100644 plugins/rbac-backend/src/service/extendable-id-provider.ts delete mode 100644 plugins/rbac-backend/src/service/permission-definition-routes.test.ts delete mode 100644 plugins/rbac-backend/src/service/permission-definition-routes.ts delete mode 100644 plugins/rbac-backend/src/service/permission-model.ts delete mode 100644 plugins/rbac-backend/src/service/plugin-endpoint.test.ts delete mode 100644 plugins/rbac-backend/src/service/plugin-endpoints.ts delete mode 100644 plugins/rbac-backend/src/service/policies-rest-api.conditions.test.ts delete mode 100644 plugins/rbac-backend/src/service/policies-rest-api.test.ts delete mode 100644 plugins/rbac-backend/src/service/policies-rest-api.ts delete mode 100644 plugins/rbac-backend/src/service/policy-builder.test.ts delete mode 100644 plugins/rbac-backend/src/service/policy-builder.ts delete mode 100644 plugins/rbac-backend/src/service/router.test.ts delete mode 100644 plugins/rbac-backend/src/service/router.ts delete mode 100644 plugins/rbac-backend/src/setupTests.ts delete mode 100644 plugins/rbac-backend/src/validation/condition-validation.test.ts delete mode 100644 plugins/rbac-backend/src/validation/condition-validation.ts delete mode 100644 plugins/rbac-backend/src/validation/plugin-validation.test.ts delete mode 100644 plugins/rbac-backend/src/validation/plugin-validation.ts delete mode 100644 plugins/rbac-backend/src/validation/policies-validation.test.ts delete mode 100644 plugins/rbac-backend/src/validation/policies-validation.ts delete mode 100644 plugins/rbac-backend/tsconfig.json delete mode 100644 plugins/rbac-backend/turbo.json diff --git a/package.json b/package.json index 19dd3a8c6e..d0bf447023 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "zod@^3.25.76": "3.25.76", "zod@^3.25.76 || ^4.0.0": "3.25.76", "zod@^3.25 || ^4.0": "3.25.76", - "@internal/plugin-rbac-backend@workspace:plugins/rbac-backend>zod": "4.3.6", "infinispan": "0.13.0", "@protobufjs/inquire": "1.1.0" }, diff --git a/packages/backend/package.json b/packages/backend/package.json index f223ac1b19..5cc7ddac6c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -65,7 +65,6 @@ "@backstage/plugin-user-settings-backend": "0.4.1", "@internal/plugin-dynamic-plugins-info-backend": "*", "@internal/plugin-licensed-users-info-backend": "*", - "@internal/plugin-rbac-backend": "*", "@internal/plugin-scalprum-backend": "*", "@opentelemetry/api": "1.9.1", "@opentelemetry/auto-instrumentations-node": "0.76.0", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b0597721d4..c263f26e4a 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -148,7 +148,6 @@ backend.add( import('@backstage-community/plugin-scaffolder-backend-module-annotator'), ); -backend.add(import('@internal/plugin-rbac-backend')); backend.add(pluginIDProviderService); backend.add(rbacDynamicPluginsProvider); diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index e7312099e0..a8b622c7f5 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -4,7 +4,6 @@ "exclude": ["node_modules"], "compilerOptions": { "outDir": "../../dist-types/packages/backend", - "rootDir": ".", - "useUnknownInCatchVariables": false + "rootDir": "." } } diff --git a/plugins/rbac-backend/.eslintignore b/plugins/rbac-backend/.eslintignore deleted file mode 100644 index 6a77e2728b..0000000000 --- a/plugins/rbac-backend/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -dist-dynamic -dist-scalprum -!.eslintrc.js -!.prettierrc.js \ No newline at end of file diff --git a/plugins/rbac-backend/.eslintrc.js b/plugins/rbac-backend/.eslintrc.js deleted file mode 100644 index 0e688f094f..0000000000 --- a/plugins/rbac-backend/.eslintrc.js +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, { - rules: { - 'jest/expect-expect': [ - 'error', - { - assertFunctionNames: ['expect*'], - }, - ], - }, -}); diff --git a/plugins/rbac-backend/.prettierignore b/plugins/rbac-backend/.prettierignore deleted file mode 100644 index 87deff0f89..0000000000 --- a/plugins/rbac-backend/.prettierignore +++ /dev/null @@ -1,14 +0,0 @@ -dist -dist-types -coverage -.vscode -CHANGELOG.md -generated -templates -*.hbs -renovate.json -dist-dynamic -dist-scalprum -playwright-report -report.api.md -knip-report.md diff --git a/plugins/rbac-backend/.prettierrc.js b/plugins/rbac-backend/.prettierrc.js deleted file mode 100644 index c45fe04c2b..0000000000 --- a/plugins/rbac-backend/.prettierrc.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */ -module.exports = { - ...require('@backstage/cli/config/prettier'), - plugins: ['@ianvs/prettier-plugin-sort-imports'], - importOrder: [ - '^react(.*)$', - '', - '^@backstage/(.*)$', - '', - '', - '', - '^@backstage-community/(.*)$', - '', - '', - '', - '^[.]', - ], -}; diff --git a/plugins/rbac-backend/CHANGELOG.md b/plugins/rbac-backend/CHANGELOG.md deleted file mode 100644 index 84cfdf5044..0000000000 --- a/plugins/rbac-backend/CHANGELOG.md +++ /dev/null @@ -1,1171 +0,0 @@ -# @backstage-community/plugin-rbac-backend - -## 7.12.4 - -### Patch Changes - -- 170f85d: Migrate to Jest 30 and fix backend test assertion compatibility -- Updated dependencies [170f85d] - - @backstage-community/plugin-rbac-common@1.26.1 - - @backstage-community/plugin-rbac-node@1.20.1 - -## 7.12.3 - -### Patch Changes - -- fb2a770: Made postgres username and password optional in casbin adapter factory to support passwordless authentication - -## 7.12.2 - -### Patch Changes - -- 39272f8: Updated dependency `csv-parse` to `^6.0.0`. -- 70e6333: Updated dependency `@dagrejs/graphlib` to `^4.0.0`. -- a559dfb: Updated dependency `@types/node` to `22.19.17`. -- 8846adf: Updated dependency `qs` to `6.15.1`. - -## 7.12.1 - -### Patch Changes - -- 40e44bb: Updated dependency `qs` to `6.14.2`. - -## 7.12.0 - -### Minor Changes - -- 8993474: Backstage version bump to v1.49.2 - -### Patch Changes - -- Updated dependencies [8993474] - - @backstage-community/plugin-rbac-common@1.26.0 - - @backstage-community/plugin-rbac-node@1.20.0 - -## 7.11.0 - -### Minor Changes - -- 50e194d: Add support for a default role and permissions for authenticated users in RBAC backend - - - Introduced a new `defaultRole` and `basicPermissions` configuration options to assign a default role to all authenticated users. - - ```diff - permission: - rbac: - + defaultPermissions: - + defaultRole: role:default/my-default-role - + basicPermissions: - + - permission: catalog.entity.read - + action: read - ``` - - - Updated the RBAC permission policy to include the default role in user roles if not already present. - -### Patch Changes - -- Updated dependencies [50e194d] - - @backstage-community/plugin-rbac-common@1.25.0 - - @backstage-community/plugin-rbac-node@1.19.1 - -## 7.10.0 - -### Minor Changes - -- 133eae6: Add support for loading conditional permissions from a remote provider (fix #6412) - -### Patch Changes - -- Updated dependencies [133eae6] - - @backstage-community/plugin-rbac-node@1.19.0 - -## 7.9.1 - -### Patch Changes - -- d737494: Backstage version bump to v1.48.5 -- Updated dependencies [d737494] - - @backstage-community/plugin-rbac-common@1.24.1 - - @backstage-community/plugin-rbac-node@1.18.1 - -## 7.9.0 - -### Minor Changes - -- da170a1: Add support for group reference in superUsers list, using direct membership only - -### Patch Changes - -- 8a6b81c: Updated dependency `@types/supertest` to `^7.0.0`. - -## 7.8.0 - -### Minor Changes - -- 843bbe2: Backstage version bump to v1.48.4 - -### Patch Changes - -- Updated dependencies [843bbe2] - - @backstage-community/plugin-rbac-common@1.24.0 - - @backstage-community/plugin-rbac-node@1.18.0 - -## 7.7.2 - -### Patch Changes - -- 8c7bddb: Added NFS support -- af998b7: Updated dependency `supertest` to `7.2.2`. - -## 7.7.1 - -### Patch Changes - -- b133c9d: Updated dependency `@types/supertest` to `^6.0.0`. -- 497d5c6: Updated dependency `@types/node` to `22.19.11`. -- 9c7ae87: Fix - stop error on upgrade v1.47.x - allow all plugins in the arry to show - -## 7.7.0 - -### Minor Changes - -- e6dbf70: Backstage version bump to v1.47.2 - -### Patch Changes - -- e6dbf70: updated the permissionFactory to use the `FetchUrlReader.fromConfig` -- a184943: Updated dependency `@types/node` to `22.19.7`. -- Updated dependencies [e6dbf70] - - @backstage-community/plugin-rbac-common@1.23.0 - - @backstage-community/plugin-rbac-node@1.17.0 - -## 7.6.2 - -### Patch Changes - -- 9a07184: Backport: Remove usage of breaking imports from @backstage/backend-defaults - - This backports the fix from commit 9c7ae87 to avoid compatibility issue when @backstage/backend-defaults resolves to 0.13.2, which introduced breaking changes to address a CVE. By removing the problematic import, this plugin remains compatible with both 0.13.1 and 0.13.2 and does not use the code containing the CVE. - -## 7.6.1 - -### Patch Changes - -- 6d3ed24: Updated dependency `supertest` to `^7.0.0`. -- 9714391: Updated dependency `qs` to `6.14.1`. -- efdad9e: Updated dependency `@types/node` to `22.19.3`. - -## 7.6.0 - -### Minor Changes - -- e2d17e1: Backstage version bump to v1.45.1 - -### Patch Changes - -- 636525d: Updated dependency `@types/express` to `4.17.25`. -- Updated dependencies [e2d17e1] - - @backstage-community/plugin-rbac-common@1.22.0 - - @backstage-community/plugin-rbac-node@1.16.0 - -## 7.5.1 - -### Patch Changes - -- 0743ffa: Backport: Remove usage of breaking imports from @backstage/backend-defaults - - This backports the fix from commit 9c7ae87 to avoid compatibility issues when @backstage backend-defaults resolves to 0.13.2, which introduced breaking changes to address a CVE. By removing the problematic import, this plugin remains compatible with both 0.13.1 and 0.13.2 and does not use the code containing the CVE. - -## 7.5.0 - -### Minor Changes - -- 2d1f63f: Backstage version bump to v1.44.2 - -### Patch Changes - -- Updated dependencies [2d1f63f] - - @backstage-community/plugin-rbac-common@1.21.0 - - @backstage-community/plugin-rbac-node@1.15.0 - -## 7.4.3 - -### Patch Changes - -- 05801c1: Backport: Remove usage of breaking imports from @backstage/backend-defaults - - This backports the fix from commit 9c7ae87 to avoid compatibility issues when @backstage backend-defaults resolves to 0.13.2, which introduced breaking changes to address a CVE. By removing the problematic import, this plugin remains compatible with both 0.13.1 and 0.13.2 and does not use the code containing the CVE. - -## 7.4.2 - -### Patch Changes - -- de412d4: Fix issue with extra delimiter in conditional yaml. -- 93ce408: Compare parent reference in sqlite memo using entity ref - -## 7.4.1 - -### Patch Changes - -- db1ab9d: Updated dependency `knex-mock-client` to `3.0.2`. - -## 7.4.0 - -### Minor Changes - -- 232a84d: Backstage version bump to v1.42.5 - -### Patch Changes - -- Updated dependencies [232a84d] - - @backstage-community/plugin-rbac-common@1.20.0 - - @backstage-community/plugin-rbac-node@1.14.0 - -## 7.3.0 - -### Minor Changes - -- 5260b5c: support config to set permission vs conditional policy evaluation order - -## 7.2.0 - -### Minor Changes - -- 2f4d9ff: Backstage version bump to v1.41.1 - -### Patch Changes - -- e843699: Added missing configSchema into package.json -- Updated dependencies [2f4d9ff] - - @backstage-community/plugin-rbac-common@1.19.0 - - @backstage-community/plugin-rbac-node@1.13.0 - -## 7.1.0 - -### Minor Changes - -- 8db28a0: Updated readme example on conditional policy yaml to be well formed (removed quotes) - -### Patch Changes - -- 4c49556: Updated dependency `@types/express` to `4.17.23`. - -## 7.0.0 - -### Major Changes - -- 2e732e8: **BREAKING**: Removal of the deprecated createRouter from @backstage/plugin-permission-backend. This results in a new requirement of having the permission plugin installed alongside the RBAC backend plugin. - - Recent changes to the @backstage/plugin-permission-backend resulted in the deprecating and removal of `createRouter` which was primarily used as a way to start both the permission backend plugin and the RBAC backend plugin at the same time. This removal now results in the requirement of having the permission backend plugin installed separately to ensure that the RBAC backend plugin works accordingly. - - Changes required to `packages/backend/src/index.ts` - - ```diff - // permission plugin - + backend.add(import('@backstage/plugin-permission-backend')); - backend.add(import('@backstage-community/plugin-rbac-backend')); - ``` - -### Minor Changes - -- 4b58a1d: Backstage version bump to v1.39.0 - -### Patch Changes - -- 6a59fcf: remove support and lifecycle keywords in package.json -- Updated dependencies [6a59fcf] -- Updated dependencies [4b58a1d] - - @backstage-community/plugin-rbac-common@1.18.0 - - @backstage-community/plugin-rbac-node@1.12.0 - -## 6.3.0 - -### Minor Changes - -- a42945e: Introduce API to store additional plugin ID list -- 3e3f346: Migrate rbac-backend to use permission registry service. - -### Patch Changes - -- 098b200: Updated dependency `@types/express` to `4.17.22`. -- e958f2f: Updated dependency `@types/node` to `22.15.29`. -- Updated dependencies [a42945e] - - @backstage-community/plugin-rbac-common@1.17.0 - -## 6.2.6 - -### Patch Changes - -- fcc57ec: Updated dependency `@types/node` to `22.14.1`. - -## 6.2.5 - -### Patch Changes - -- 658c51c: chore: Remove usage of @spotify/prettier-config -- Updated dependencies [658c51c] - - @backstage-community/plugin-rbac-common@1.16.1 - - @backstage-community/plugin-rbac-node@1.11.1 - -## 6.2.4 - -### Patch Changes - -- 298b1d4: Avoid unnecessary query to check 'relations' table in the role manager - -## 6.2.3 - -### Patch Changes - -- 9436665: Reduce rbac-backend requests to credentials API. - -## 6.2.2 - -### Patch Changes - -- c92a50c: Fixed a bug where updating a role name via the `PUT
` endpoint did not propagate changes to metadata, permissions and conditions, leaving them mapped to the old role name. - -## 6.2.1 - -### Patch Changes - -- 10b9919: Avoid filter's args duplication. - -## 6.2.0 - -### Minor Changes - -- e8755f6: Backstage version bump to v1.38.1 - -### Patch Changes - -- Updated dependencies [e8755f6] - - @backstage-community/plugin-rbac-common@1.16.0 - - @backstage-community/plugin-rbac-node@1.11.0 - -## 6.1.1 - -### Patch Changes - -- 10a0d31: Fixes an issue where the correct permission name was not selected while processing new conditional policies to be added. This scenario happens whenever a plugin exports multiple permissions that have different resource types but similar actions. What would end up happening is the first matched action would be the one selected during processing even though it was not the correct permission and used for the conditional policy. This problem has been fixed and now the correct permission name and action are selected. - -## 6.1.0 - -### Minor Changes - -- d278b4c: Adds the ability to assign ownership to roles that can then be used to conditionally filter roles, permission policies, and conditional policies. The conditional filter can now be accomplished through the use of the new RBAC conditional rule `IS_OWNER`. - - `IS_OWNER` can be used to grant limited access to the RBAC plugins where in admins might want leads to control their own team's access. - - Removed the resource type from the `policy.entity.create` permission to prevent conditional rules being applied to the permission. At the moment, the plugins will still continue to work as expected. However, it is strongly recommended updating all permission policies that utilize the resource type `policy-entity` with the action `create` (ex. `role:default/some_role, policy-entity, create, allow` to `role:default/some_role, policy.entity.create, create, allow`) to prevent any future degradation in service. A migration has been supplied to automatically update all permission policies that have not originated from the CSV file. The CSV file was skipped as a duplication event could happen during reloads / restarts. This means that the CSV file will need to be updated manually to ensure that all references to the old permission policy, resource type `policy-entity` with an action of `create`, have been updated to the named permission `policy.entity.create` with an action of `create`. - -### Patch Changes - -- Updated dependencies [d278b4c] - - @backstage-community/plugin-rbac-common@1.15.0 - -## 6.0.1 - -### Patch Changes - -- f84ad73: chore: remove homepage field from package.json -- Updated dependencies [f84ad73] - - @backstage-community/plugin-rbac-common@1.14.1 - - @backstage-community/plugin-rbac-node@1.10.1 - -## 6.0.0 - -### Major Changes - -- 9cccb0d: **BREAKING**: Migration to the core Auditor service. The Auditor format has been updated. Audit fields and event names (ids) have been updated to conform with the new Auditor service conventions. Filtering queries based on the old format may no longer work. - -## 5.6.1 - -### Patch Changes - -- b2a5daa: Updated dependency `qs` to `6.14.0`. - -## 5.6.0 - -### Minor Changes - -- 0253db6: Backstage version bump to v1.36.1 - -### Patch Changes - -- Updated dependencies [0253db6] - - @backstage-community/plugin-rbac-common@1.14.0 - - @backstage-community/plugin-rbac-node@1.10.0 - -## 5.5.3 - -### Patch Changes - -- 973a5ef: remove prettier from devDevpendencies -- Updated dependencies [973a5ef] - - @backstage-community/plugin-rbac-node@1.9.1 - -## 5.5.2 - -### Patch Changes - -- 9aa839a: Fixes two issues that were impact the performance, the first was that we were individually adding and removing roles and the second was we were removing all policies and roles regardless of whether they should actually be removed. - -## 5.5.1 - -### Patch Changes - -- fcfaf89: Fixed an issue where aliases would not be applied across all conditional policy rules. - -## 5.5.0 - -### Minor Changes - -- 36e2c6c: Reduces the number of times that we build the group hierarchy graphs during evaluation. Originally, during time of evaluation, we would build a graph to of all of the groups that a user was directly or indirectly a member of. Now, we only build the graph once and pass along all of the roles that the user is directly or indirectly attached to. - -## 5.4.0 - -### Minor Changes - -- 5d5c02a: Backstage version bump to v1.35.0 - -### Patch Changes - -- Updated dependencies [5d5c02a] - - @backstage-community/plugin-rbac-common@1.13.0 - - @backstage-community/plugin-rbac-node@1.9.0 - -## 5.3.1 - -### Patch Changes - -- 1d5dd17: Evaluate the permissions for a superuser earlier in the process to avoid the unintended consequence of having conditional permissions policies applied to a superuser. - -## 5.3.0 - -### Minor Changes - -- 53daff0: Roles and permissions were not correctly applied for users and groups with names containing uppercase letters. To address this issue, we now convert user and group references in all user inputs to lowercase. This change migrates `v0` column in `casbin_rule` table in `backstage_plugin_permission` database. Conditions containing claims with uppercase letters are not resolved yet. - -## 5.2.10 - -### Patch Changes - -- ba4b3e9: Use loadPolicy to keep the enforcer in sync for edit operations. It should keep the RBAC plugin in sync when the Backstage instance is scaled to multiple deployment replicas. Reuse the maximum database pool size value from the application configuration in the RBAC Casbin adapter. - -## 5.2.9 - -### Patch Changes - -- 5b19b0d: Update documentation information about `pluginsWithPermission` setting. In order for the RBAC UI to display available permissions provided by installed plugins, this setting needs to be configured. - -## 5.2.8 - -### Patch Changes - -- 0f5c451: Updated dependency `prettier` to `3.4.2`. -- 18f9d9d: Updated dependency `@types/node` to `18.19.68`. -- Updated dependencies [0f5c451] - - @backstage-community/plugin-rbac-node@1.8.4 - -## 5.2.7 - -### Patch Changes - -- 7843798: Updated dependency `qs` to `6.13.1`. -- 4b3653a: Clean up api report warnings and remove unnecessary files -- Updated dependencies [4b3653a] - - @backstage-community/plugin-rbac-common@1.12.3 - - @backstage-community/plugin-rbac-node@1.8.3 - -## 5.2.6 - -### Patch Changes - -- 4084738: Ensures that the permissions and roles are properly synced during request handling. This is important in high availability scenarios as we need to ensure data is up to date during scaling. - -## 5.2.5 - -### Patch Changes - -- a6e850f: Updated dependency `msw` to `1.3.5`. - -## 5.2.4 - -### Patch Changes - -- dd0e2b4: chore: use workspace dependencies -- b7c2fa1: Updated supported-versions to ^1.28.4. -- Updated dependencies [b7c2fa1] - - @backstage-community/plugin-rbac-common@1.12.2 - - @backstage-community/plugin-rbac-node@1.8.2 - -## 5.2.3 - -### Patch Changes - -- 2249d08: bump rbac plugins to include latest changes in janus - -## 5.2.2 - -### Patch Changes - -- 019f010: Migrated from [janus-idp/backstage-plugins](https://github.com/janus-idp/backstage-plugins). -- Updated dependencies [019f010] - - @backstage-community/plugin-rbac-common@1.12.1 - - @backstage-community/plugin-rbac-node@1.8.1 - -## 5.2.1 - -### Patch Changes - -- 0646434: Fix broken plugin startup: don't attempt to store permission policies that are already stored. - -## 5.2.0 - -### Minor Changes - -- 8244f28: chore(deps): update to backstage 1.32 - -### Patch Changes - -- Updated dependencies [8244f28] - - @janus-idp/backstage-plugin-audit-log-node@1.7.0 - - @backstage-community/plugin-rbac-common@1.12.0 - - @backstage-community/plugin-rbac-node@1.8.0 - -## 5.1.2 - -### Patch Changes - -- 7342e9b: chore: remove @janus-idp/cli dep and relink local packages - - This update removes `@janus-idp/cli` from all plugins, as it’s no longer necessary. Additionally, packages are now correctly linked with a specified version. - -## 5.1.1 - -### Patch Changes - -- e6ef910: Refactors the rbac backend plugin to prevent the creation of permission policies and roles whenever the plugin and permission framework is disabled - -## 5.1.0 - -### Minor Changes - -- d9551ae: feat(deps): update to backstage 1.31 - -### Patch Changes - -- d9551ae: Refactors the rbac backend plugin to move the admin role and admin permission creation to a separate file -- d9551ae: Change local package references to a `*` -- d9551ae: upgrade to yarn v3 -- Updated dependencies [d9551ae] -- Updated dependencies [d9551ae] -- Updated dependencies [d9551ae] - - @backstage-community/plugin-rbac-common@1.11.0 - - @janus-idp/backstage-plugin-audit-log-node@1.6.0 - - @backstage-community/plugin-rbac-node@1.7.0 - -* **@janus-idp/backstage-plugin-audit-log-node:** upgraded to 1.5.1 - -### Dependencies - -- **@janus-idp/backstage-plugin-audit-log-node:** upgraded to 1.5.0 -- **@backstage-community/plugin-rbac-common:** upgraded to 1.10.0 -- **@backstage-community/plugin-rbac-node:** upgraded to 1.6.0 - -### Dependencies - -- **@janus-idp/backstage-plugin-audit-log-node:** upgraded to 1.4.1 - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.9.0 -- **@backstage-community/plugin-rbac-node:** upgraded to 1.5.0 - -## @backstage-community/plugin-rbac-backend [4.7.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.7.2...@backstage-community/plugin-rbac-backend@4.7.3) (2024-08-06) - -### Bug Fixes - -- **rbac:** implement conditional aliases ([#1847](https://github.com/janus-idp/backstage-plugins/issues/1847)) ([dbc9a0b](https://github.com/janus-idp/backstage-plugins/commit/dbc9a0bc92f19a4382e406f83b4889905dc6e33d)) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.8.2 - -## @backstage-community/plugin-rbac-backend [4.7.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.7.1...@backstage-community/plugin-rbac-backend@4.7.2) (2024-08-05) - -### Bug Fixes - -- **rbac:** add additional validation for permission policies ([#1908](https://github.com/janus-idp/backstage-plugins/issues/1908)) ([592498f](https://github.com/janus-idp/backstage-plugins/commit/592498f34a3b605162d3c242184aa6877b0360e8)), closes [#1939](https://github.com/janus-idp/backstage-plugins/issues/1939) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.8.1 - -## @backstage-community/plugin-rbac-backend [4.7.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.7.0...@backstage-community/plugin-rbac-backend@4.7.1) (2024-08-02) - -### Bug Fixes - -- **rbac:** log when plugin has no permissions ([#1917](https://github.com/janus-idp/backstage-plugins/issues/1917)) ([cc8752b](https://github.com/janus-idp/backstage-plugins/commit/cc8752b159364fdab62e7bbdaa51ca811288197b)) - -## @backstage-community/plugin-rbac-backend [4.7.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.6.1...@backstage-community/plugin-rbac-backend@4.7.0) (2024-07-30) - -### Features - -- **argocd:** add permission support for argocd ([#1855](https://github.com/janus-idp/backstage-plugins/issues/1855)) ([3b78237](https://github.com/janus-idp/backstage-plugins/commit/3b782377683605ea4d584c43bea14be2f435003d)) - -## @backstage-community/plugin-rbac-backend [4.6.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.6.0...@backstage-community/plugin-rbac-backend@4.6.1) (2024-07-29) - -### Bug Fixes - -- **rbac:** fix uncommited knex transaction in the addGroupingPolicies ([#1968](https://github.com/janus-idp/backstage-plugins/issues/1968)) ([24d5eef](https://github.com/janus-idp/backstage-plugins/commit/24d5eeffbce685bbe05f8895fe3a69ee26a4eb8a)) - -## @backstage-community/plugin-rbac-backend [4.6.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.5.0...@backstage-community/plugin-rbac-backend@4.6.0) (2024-07-26) - -### Features - -- **tekton:** add permissions support for tekton plugin ([#1854](https://github.com/janus-idp/backstage-plugins/issues/1854)) ([f744896](https://github.com/janus-idp/backstage-plugins/commit/f7448963c252574e0309a091563c19e1ed9a58fd)) - -## @backstage-community/plugin-rbac-backend [4.5.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.4.3...@backstage-community/plugin-rbac-backend@4.5.0) (2024-07-26) - -### Features - -- **deps:** update to backstage 1.29 ([#1900](https://github.com/janus-idp/backstage-plugins/issues/1900)) ([f53677f](https://github.com/janus-idp/backstage-plugins/commit/f53677fb02d6df43a9de98c43a9f101a6db76802)) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.8.0 -- **@backstage-community/plugin-rbac-node:** upgraded to 1.4.0 - -## @backstage-community/plugin-rbac-backend [4.4.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.4.2...@backstage-community/plugin-rbac-backend@4.4.3) (2024-07-25) - -### Documentation - -- **rbac:** add curl request examples ([#1913](https://github.com/janus-idp/backstage-plugins/issues/1913)) ([e496eb7](https://github.com/janus-idp/backstage-plugins/commit/e496eb73349987d43caba86a29e4c98c86179250)) - -## @backstage-community/plugin-rbac-backend [4.4.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.4.1...@backstage-community/plugin-rbac-backend@4.4.2) (2024-07-24) - -### Bug Fixes - -- **deps:** rollback unreleased plugins ([#1951](https://github.com/janus-idp/backstage-plugins/issues/1951)) ([8b77969](https://github.com/janus-idp/backstage-plugins/commit/8b779694f02f8125587296305276b84cdfeeaebe)) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.7.2 -- **@backstage-community/plugin-rbac-node:** upgraded to 1.3.1 - -## @backstage-community/plugin-rbac-backend [4.4.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.4.0...@backstage-community/plugin-rbac-backend@4.4.1) (2024-07-24) - -### Bug Fixes - -- **rbac:** don't start transaction if there no group policies ([#1923](https://github.com/janus-idp/backstage-plugins/issues/1923)) ([dffa964](https://github.com/janus-idp/backstage-plugins/commit/dffa9643b500a19dc70c66cedf9016508cdb5947)) - -## @backstage-community/plugin-rbac-backend [4.4.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.3.4...@backstage-community/plugin-rbac-backend@4.4.0) (2024-07-24) - -### Features - -- **deps:** update to backstage 1.28 ([#1891](https://github.com/janus-idp/backstage-plugins/issues/1891)) ([1ba1108](https://github.com/janus-idp/backstage-plugins/commit/1ba11088e0de60e90d138944267b83600dc446e5)) - -### Bug Fixes - -- **deps:** fix rbac dependencies ([#1918](https://github.com/janus-idp/backstage-plugins/issues/1918)) ([fcc4e1d](https://github.com/janus-idp/backstage-plugins/commit/fcc4e1dde55bc0fb2dd284d256330c7f9f928036)) -- **deps:** move backend-test-utils to devDependencies ([#1944](https://github.com/janus-idp/backstage-plugins/issues/1944)) ([9052a3f](https://github.com/janus-idp/backstage-plugins/commit/9052a3f41cae1cd57fb8f52033ea2c6f752f64fe)) - -### Documentation - -- added OpenAPI spec for rbac-backend ([#1830](https://github.com/janus-idp/backstage-plugins/issues/1830)) ([4eb2035](https://github.com/janus-idp/backstage-plugins/commit/4eb20351bf9713355cb79905a2e49aeec9ad6ec9)) -- **rbac:** fix condition rules api url ([#1914](https://github.com/janus-idp/backstage-plugins/issues/1914)) ([e6fa0ae](https://github.com/janus-idp/backstage-plugins/commit/e6fa0ae7265ea56b50fffbf1466540a61d714ed8)) - -## @backstage-community/plugin-rbac-backend [4.3.4](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.3.3...@backstage-community/plugin-rbac-backend@4.3.4) (2024-07-17) - -### Bug Fixes - -- **rbac:** simplify db logic ([#1842](https://github.com/janus-idp/backstage-plugins/issues/1842)) ([cbe263b](https://github.com/janus-idp/backstage-plugins/commit/cbe263b2901c0d57105667caf2d3ab7c0583468a)) - -## @backstage-community/plugin-rbac-backend [4.3.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.3.2...@backstage-community/plugin-rbac-backend@4.3.3) (2024-07-16) - -### Bug Fixes - -- **rbac:** catch errors whenever a plugin token is not generated ([#1866](https://github.com/janus-idp/backstage-plugins/issues/1866)) ([c9abf44](https://github.com/janus-idp/backstage-plugins/commit/c9abf441591347753fe94fe2590b8059804baeb7)) - -## @backstage-community/plugin-rbac-backend [4.3.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.3.1...@backstage-community/plugin-rbac-backend@4.3.2) (2024-07-05) - -### Bug Fixes - -- **rbac:** casbinDBAdapterFactory supporting postgres schema configuration ([#1841](https://github.com/janus-idp/backstage-plugins/issues/1841)) ([c0e63f9](https://github.com/janus-idp/backstage-plugins/commit/c0e63f9541edc121c77d6569d6fe6958ce937c0b)) -- **rbac:** correct plugin ID matching to permission policy ([#1795](https://github.com/janus-idp/backstage-plugins/issues/1795)) ([6dc4b1c](https://github.com/janus-idp/backstage-plugins/commit/6dc4b1c23d22252f394eecd8b795ac15507ecc50)) -- **rbac:** update rbac common to fix compilation ([#1858](https://github.com/janus-idp/backstage-plugins/issues/1858)) ([48f142b](https://github.com/janus-idp/backstage-plugins/commit/48f142b447f0d1677ba3f16b2a3c8972b22d0588)) - -## @backstage-community/plugin-rbac-backend [4.3.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.3.0...@backstage-community/plugin-rbac-backend@4.3.1) (2024-06-19) - -## @backstage-community/plugin-rbac-backend [4.3.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.2.0...@backstage-community/plugin-rbac-backend@4.3.0) (2024-06-13) - -### Features - -- **deps:** update to backstage 1.27 ([#1683](https://github.com/janus-idp/backstage-plugins/issues/1683)) ([a14869c](https://github.com/janus-idp/backstage-plugins/commit/a14869c3f4177049cb8d6552b36c3ffd17e7997d)) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.6.0 -- **@backstage-community/plugin-rbac-node:** upgraded to 1.2.0 -- **@janus-idp/backstage-plugin-audit-log-node:** upgraded to 1.2.0 - -## @backstage-community/plugin-rbac-backend [4.2.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.1.0...@backstage-community/plugin-rbac-backend@4.2.0) (2024-06-05) - -### Features - -- **rbac:** add type checks with generics for audit log ([#1789](https://github.com/janus-idp/backstage-plugins/issues/1789)) ([ac69838](https://github.com/janus-idp/backstage-plugins/commit/ac698382f64fe91e0f9f9232dd3eecd9cc9247be)) - -### Dependencies - -- **@janus-idp/backstage-plugin-audit-log-node:** upgraded to 1.1.0 - -## @backstage-community/plugin-rbac-backend [4.1.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.0.2...@backstage-community/plugin-rbac-backend@4.1.0) (2024-06-04) - -### Features - -- **rbac:** add audit log for RBAC backend ([#1726](https://github.com/janus-idp/backstage-plugins/issues/1726)) ([e50464b](https://github.com/janus-idp/backstage-plugins/commit/e50464bcb38e9897ddfe208fdeef699e4bfeda3a)) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.5.0 -- **@backstage-community/plugin-rbac-node:** upgraded to 1.1.2 -- **@janus-idp/backstage-plugin-audit-log-node:** upgraded to 1.0.3 - -## @backstage-community/plugin-rbac-backend [4.0.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.0.1...@backstage-community/plugin-rbac-backend@4.0.2) (2024-06-04) - -### Bug Fixes - -- **rbac:** fix handling condition action conflicts ([#1781](https://github.com/janus-idp/backstage-plugins/issues/1781)) ([966b2b2](https://github.com/janus-idp/backstage-plugins/commit/966b2b200e0ade0ce600901a7853a4a94751df22)) - -## @backstage-community/plugin-rbac-backend [4.0.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@4.0.0...@backstage-community/plugin-rbac-backend@4.0.1) (2024-06-03) - -### Bug Fixes - -- **rbac:** add support for scaling ([#1757](https://github.com/janus-idp/backstage-plugins/issues/1757)) ([caddc83](https://github.com/janus-idp/backstage-plugins/commit/caddc832e0df5199a455539d3538635448691c2d)) - -## @backstage-community/plugin-rbac-backend [4.0.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@3.3.0...@backstage-community/plugin-rbac-backend@4.0.0) (2024-05-31) - -### ⚠ BREAKING CHANGES - -- **rbac:** This will lead to more strict validation on the source of permission policies and roles based on the where the first role is defined. - -Improves the validation of the different sources of permission policies and roles. Aims to make policy definition more consistent. - -Now checks if a permission policy or role with new member matches the originating role's source and prevents any action if the sources do not match. Exception includes the event of adding -new permission policies to the RBAC Admin role defined by the configuration file. Sources include 'REST, 'CSV', 'Configuration', and 'legacy'. - -Before updating, ensure that you have attempted to migrate all permission policies and roles to a single source. This can be done by checking source information through the REST API and -by querying the database. Make updates through one of the available avenues: REST API, CSV file, and the database. - -To view the originating source for a particular role, query the role-metadata table or use the GET roles endpoint. - -- feat(rbac): remove the ability to add permission policies to configuration role - -- feat(rbac): remove no longer needed check for source in EnforcerDelegate - -- feat(rbac): update yarn lock - -- feat(rbac): address review comments - -### Features - -- **rbac:** improve validation from source ([#1643](https://github.com/janus-idp/backstage-plugins/issues/1643)) ([5f983cb](https://github.com/janus-idp/backstage-plugins/commit/5f983cbc0184e0a8e74f7e89cdff71d5ed5cd2fa)) - -## @backstage-community/plugin-rbac-backend [3.3.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@3.2.0...@backstage-community/plugin-rbac-backend@3.3.0) (2024-05-29) - -### Features - -- **rbac:** improve conditional policy validation ([#1673](https://github.com/janus-idp/backstage-plugins/issues/1673)) ([15dac91](https://github.com/janus-idp/backstage-plugins/commit/15dac91b673c63a4e7ac41f95296651df2ef8053)) - -## @backstage-community/plugin-rbac-backend [3.2.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@3.1.1...@backstage-community/plugin-rbac-backend@3.2.0) (2024-05-21) - -### Features - -- **topology:** add permissions to topology plugin ([#1665](https://github.com/janus-idp/backstage-plugins/issues/1665)) ([9d8f244](https://github.com/janus-idp/backstage-plugins/commit/9d8f244ae136cdf1980a5abf416180bce3f235ea)) - -## @backstage-community/plugin-rbac-backend [3.1.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@3.1.0...@backstage-community/plugin-rbac-backend@3.1.1) (2024-05-16) - -## @backstage-community/plugin-rbac-backend [3.1.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@3.0.0...@backstage-community/plugin-rbac-backend@3.1.0) (2024-05-14) - -### Features - -- **rbac:** implement a file watcher for csv reloads ([#1587](https://github.com/janus-idp/backstage-plugins/issues/1587)) ([62fcafc](https://github.com/janus-idp/backstage-plugins/commit/62fcafcdb3ab3cb308b16b8fab0a14916b921b82)) - -### Bug Fixes - -- **rbac:** fix sonar cloud issues for rbac-backend plugin ([#1619](https://github.com/janus-idp/backstage-plugins/issues/1619)) ([bf93354](https://github.com/janus-idp/backstage-plugins/commit/bf9335404232f8ec66253f56387d3432d8839406)) - -## @backstage-community/plugin-rbac-backend [3.0.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.8.2...@backstage-community/plugin-rbac-backend@3.0.0) (2024-05-10) - -### ⚠ BREAKING CHANGES - -- **rbac:** remove token manager for auth service (#1632) - -### Bug Fixes - -- **rbac:** remove token manager for auth service ([#1632](https://github.com/janus-idp/backstage-plugins/issues/1632)) ([2f19655](https://github.com/janus-idp/backstage-plugins/commit/2f196556cffc61c83239721b1cd51d6a2c64eee7)) - -## @backstage-community/plugin-rbac-backend [2.8.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.8.1...@backstage-community/plugin-rbac-backend@2.8.2) (2024-05-09) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.4.2 -- **@backstage-community/plugin-rbac-node:** upgraded to 1.1.1 - -## @backstage-community/plugin-rbac-backend [2.8.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.8.0...@backstage-community/plugin-rbac-backend@2.8.1) (2024-05-07) - -### Bug Fixes - -- **rbac:** implement ability to disable rbac-backend plugin ([#1501](https://github.com/janus-idp/backstage-plugins/issues/1501)) ([6367965](https://github.com/janus-idp/backstage-plugins/commit/6367965c550286dc8423b0942341ecee178dc6c1)) - -## @backstage-community/plugin-rbac-backend [2.8.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.7.1...@backstage-community/plugin-rbac-backend@2.8.0) (2024-05-07) - -### Features - -- **rbac:** add support for the new backend services ([#1607](https://github.com/janus-idp/backstage-plugins/issues/1607)) ([2892709](https://github.com/janus-idp/backstage-plugins/commit/2892709860987c6f4b36d821afa2e612b220d030)) - -## @backstage-community/plugin-rbac-backend [2.7.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.7.0...@backstage-community/plugin-rbac-backend@2.7.1) (2024-05-06) - -### Bug Fixes - -- **ocm:** update ocm frontend plugin readme ([#1611](https://github.com/janus-idp/backstage-plugins/issues/1611)) ([9960cc0](https://github.com/janus-idp/backstage-plugins/commit/9960cc0c2d611cdd1ee10a82ed02b7be9becefcf)) - -## @backstage-community/plugin-rbac-backend [2.7.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.6.4...@backstage-community/plugin-rbac-backend@2.7.0) (2024-04-25) - -### Features - -- **rbac:** add the optional maxDepth feature ([#1486](https://github.com/janus-idp/backstage-plugins/issues/1486)) ([ea87f34](https://github.com/janus-idp/backstage-plugins/commit/ea87f3412eb374123ea623332de0648d4c7bda5c)) -- **rbac:** lazy load temporary enforcer ([#1513](https://github.com/janus-idp/backstage-plugins/issues/1513)) ([b5f1552](https://github.com/janus-idp/backstage-plugins/commit/b5f1552f069068af43a4ca2756a5a38187f6d453)) - -## @backstage-community/plugin-rbac-backend [2.6.4](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.6.3...@backstage-community/plugin-rbac-backend@2.6.4) (2024-04-17) - -### Bug Fixes - -- **rbac:** reduce the number of permissions returned, add isResourced flag ([#1474](https://github.com/janus-idp/backstage-plugins/issues/1474)) ([e5dda95](https://github.com/janus-idp/backstage-plugins/commit/e5dda95bfc87d1d5d404726cbbe05c8bfdb73845)) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.4.1 - -## @backstage-community/plugin-rbac-backend [2.6.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.6.2...@backstage-community/plugin-rbac-backend@2.6.3) (2024-04-15) - -### Dependencies - -- **@backstage-community/plugin-rbac-node:** upgraded to 1.1.0 - -## @backstage-community/plugin-rbac-backend [2.6.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.6.1...@backstage-community/plugin-rbac-backend@2.6.2) (2024-04-09) - -### Dependencies - -- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.6 - -## @backstage-community/plugin-rbac-backend [2.6.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.6.0...@backstage-community/plugin-rbac-backend@2.6.1) (2024-04-08) - -### Dependencies - -- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.5 - -## @backstage-community/plugin-rbac-backend [2.6.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.5.1...@backstage-community/plugin-rbac-backend@2.6.0) (2024-04-05) - -### Features - -- **rbac:** save role modification information to the metadata ([#1280](https://github.com/janus-idp/backstage-plugins/issues/1280)) ([0454509](https://github.com/janus-idp/backstage-plugins/commit/0454509e41db2ae332d1b2bf8f72d34241483efd)) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.4.0 -- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.5 - -## @backstage-community/plugin-rbac-backend [2.5.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.5.0...@backstage-community/plugin-rbac-backend@2.5.1) (2024-04-04) - -### Bug Fixes - -- **rbac:** rework condition policies to bound them to RBAC roles ([#1330](https://github.com/janus-idp/backstage-plugins/issues/1330)) ([55c00b2](https://github.com/janus-idp/backstage-plugins/commit/55c00b21b27b449cb0e5100c7b64a6ae742536ac)) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.3.2 - -## @backstage-community/plugin-rbac-backend [2.5.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.4.1...@backstage-community/plugin-rbac-backend@2.5.0) (2024-03-29) - -### Features - -- **rbac:** load filtered policies before enforcing ([#1387](https://github.com/janus-idp/backstage-plugins/issues/1387)) ([66980ba](https://github.com/janus-idp/backstage-plugins/commit/66980baebd4d8b5b398646bcab1750c0edec715e)) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.3.1 -- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.4 - -## @backstage-community/plugin-rbac-backend [2.4.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.4.0...@backstage-community/plugin-rbac-backend@2.4.1) (2024-03-19) - -### Bug Fixes - -- **rbac:** pass token to readUrl for well-known permission endpoint ([#1342](https://github.com/janus-idp/backstage-plugins/issues/1342)) ([36b7c77](https://github.com/janus-idp/backstage-plugins/commit/36b7c7739753bd1cc55d10aa68d41ed7e15162e6)) - -## @backstage-community/plugin-rbac-backend [2.4.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.3.5...@backstage-community/plugin-rbac-backend@2.4.0) (2024-03-14) - -### Features - -- **rbac:** query the catalog database when building graph ([#1298](https://github.com/janus-idp/backstage-plugins/issues/1298)) ([c2c9e22](https://github.com/janus-idp/backstage-plugins/commit/c2c9e22e90a594e2a44d1683a05d3111c4baa97b)) - -### Bug Fixes - -- **rbac:** remove admin metadata, when all admins removed from config ([#1314](https://github.com/janus-idp/backstage-plugins/issues/1314)) ([cc6555e](https://github.com/janus-idp/backstage-plugins/commit/cc6555ea22a191c9f9f554b1909b67e517deee71)) - -## @backstage-community/plugin-rbac-backend [2.3.5](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.3.4...@backstage-community/plugin-rbac-backend@2.3.5) (2024-03-07) - -### Bug Fixes - -- **rbac:** check source before throwing duplicate warning ([#1278](https://github.com/janus-idp/backstage-plugins/issues/1278)) ([a100eef](https://github.com/janus-idp/backstage-plugins/commit/a100eef67983ba73d929864f0b64991de69718d0)) - -## @backstage-community/plugin-rbac-backend [2.3.4](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.3.3...@backstage-community/plugin-rbac-backend@2.3.4) (2024-03-04) - -### Dependencies - -- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.3 - -## @backstage-community/plugin-rbac-backend [2.3.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.3.2...@backstage-community/plugin-rbac-backend@2.3.3) (2024-02-29) - -### Documentation - -- **rbac:** update to the rbac documentation ([#1268](https://github.com/janus-idp/backstage-plugins/issues/1268)) ([5c7253b](https://github.com/janus-idp/backstage-plugins/commit/5c7253b7d0646433c55f185092648f0816aee88e)) - -## @backstage-community/plugin-rbac-backend [2.3.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.3.1...@backstage-community/plugin-rbac-backend@2.3.2) (2024-02-28) - -### Bug Fixes - -- **rbac:** improve error handling in retrieving permission metadata. ([#1285](https://github.com/janus-idp/backstage-plugins/issues/1285)) ([77f5f0e](https://github.com/janus-idp/backstage-plugins/commit/77f5f0efaadf1873b68876f11ca633646ce882b9)) - -## @backstage-community/plugin-rbac-backend [2.3.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.3.0...@backstage-community/plugin-rbac-backend@2.3.1) (2024-02-27) - -### Dependencies - -- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.2 - -## @backstage-community/plugin-rbac-backend [2.3.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.2.4...@backstage-community/plugin-rbac-backend@2.3.0) (2024-02-21) - -### Features - -- **rbac:** backend part - store role description to the database ([#1178](https://github.com/janus-idp/backstage-plugins/issues/1178)) ([ec8b1c2](https://github.com/janus-idp/backstage-plugins/commit/ec8b1c27cce5c36997f84a068dc4cc5cc542f428)) - -### Bug Fixes - -- **rbac:** reduce the catalog calls when build graph ([#1203](https://github.com/janus-idp/backstage-plugins/issues/1203)) ([e63aac2](https://github.com/janus-idp/backstage-plugins/commit/e63aac2a8e7513974a5aabb3ce25c838d6b34dde)) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.3.0 -- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.1 - -## @backstage-community/plugin-rbac-backend [2.2.4](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.2.3...@backstage-community/plugin-rbac-backend@2.2.4) (2024-02-20) - -### Bug Fixes - -- **rbac:** drop database disabled mode ([#1214](https://github.com/janus-idp/backstage-plugins/issues/1214)) ([b18d80d](https://github.com/janus-idp/backstage-plugins/commit/b18d80dd14e6b7f4f9c90d72ec418609ff1f6a67)) - -## @backstage-community/plugin-rbac-backend [2.2.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.2.2...@backstage-community/plugin-rbac-backend@2.2.3) (2024-02-14) - -### Bug Fixes - -- **rbac:** allow for super users to have allow all access ([#1208](https://github.com/janus-idp/backstage-plugins/issues/1208)) ([c02a4b0](https://github.com/janus-idp/backstage-plugins/commit/c02a4b029a800b1bcf1f2e2722185faae1e5837e)) - -## @backstage-community/plugin-rbac-backend [2.2.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.2.1...@backstage-community/plugin-rbac-backend@2.2.2) (2024-02-13) - -### Bug Fixes - -- **rbac:** display resource typed permissions by name too ([#1197](https://github.com/janus-idp/backstage-plugins/issues/1197)) ([bc4e8e7](https://github.com/janus-idp/backstage-plugins/commit/bc4e8e783b1acd8088a45ffed4d902fd9515c2e8)) - -## @backstage-community/plugin-rbac-backend [2.2.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.2.0...@backstage-community/plugin-rbac-backend@2.2.1) (2024-02-12) - -### Bug Fixes - -- **rbac:** csv updates no longer require server restarts ([#1171](https://github.com/janus-idp/backstage-plugins/issues/1171)) ([ed6fe65](https://github.com/janus-idp/backstage-plugins/commit/ed6fe65d99a2c2facf832a84d29dabc8d339e328)) - -## @backstage-community/plugin-rbac-backend [2.2.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.1.3...@backstage-community/plugin-rbac-backend@2.2.0) (2024-02-08) - -### Features - -- add support for the new backend system to the `rbac-backend` plugin ([#1179](https://github.com/janus-idp/backstage-plugins/issues/1179)) ([d625cb2](https://github.com/janus-idp/backstage-plugins/commit/d625cb2470513862027e048c70944275043ce70a)) - -### Dependencies - -- **@backstage-community/plugin-rbac-node:** upgraded to 1.0.0 - -## @backstage-community/plugin-rbac-backend [2.1.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.1.2...@backstage-community/plugin-rbac-backend@2.1.3) (2024-02-02) - -### Bug Fixes - -- **rbac:** set up higher jest timeout for rbac db tests ([#1163](https://github.com/janus-idp/backstage-plugins/issues/1163)) ([b8541f3](https://github.com/janus-idp/backstage-plugins/commit/b8541f3ac149446238dc07432116fafc23a48a82)) -- **rbac:** split policies and roles by source ([#1042](https://github.com/janus-idp/backstage-plugins/issues/1042)) ([03a678d](https://github.com/janus-idp/backstage-plugins/commit/03a678d96deeb1d42448e94ac95d735e61393a40)), closes [#1103](https://github.com/janus-idp/backstage-plugins/issues/1103) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.2.1 - -## @backstage-community/plugin-rbac-backend [2.1.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.1.1...@backstage-community/plugin-rbac-backend@2.1.2) (2024-01-30) - -### Bug Fixes - -- **rbac:** enable create button for default role:default/rbac_admin ([#1137](https://github.com/janus-idp/backstage-plugins/issues/1137)) ([9926463](https://github.com/janus-idp/backstage-plugins/commit/9926463c8c46871b823796adf77bbd52eb8e6758)) - -## @backstage-community/plugin-rbac-backend [2.1.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.1.0...@backstage-community/plugin-rbac-backend@2.1.1) (2024-01-23) - -### Bug Fixes - -- **rbac:** fix work resource permission specified by name ([#940](https://github.com/janus-idp/backstage-plugins/issues/940)) ([3601eb8](https://github.com/janus-idp/backstage-plugins/commit/3601eb8d0c19e0aad27031ab61f1afa0edc78945)) - -## @backstage-community/plugin-rbac-backend [2.1.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@2.0.0...@backstage-community/plugin-rbac-backend@2.1.0) (2024-01-17) - -### Features - -- **Notifications:** new notifications FE plugin, API and backend ([#933](https://github.com/janus-idp/backstage-plugins/issues/933)) ([4d4cb78](https://github.com/janus-idp/backstage-plugins/commit/4d4cb781ca9fc331a2c621583e9203f9e4585ee7)) -- **rbac:** add doc about RBAC backend conditions API ([#1027](https://github.com/janus-idp/backstage-plugins/issues/1027)) ([fc9ad53](https://github.com/janus-idp/backstage-plugins/commit/fc9ad5348d768423cbce0df7e2a4239c9a24a11e)) - -### Bug Fixes - -- **rbac:** fix role validation ([#1020](https://github.com/janus-idp/backstage-plugins/issues/1020)) ([49c7975](https://github.com/janus-idp/backstage-plugins/commit/49c7975f74a1791e205fe3a322f1efe6504212ed)) - -## @backstage-community/plugin-rbac-backend [2.0.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.7.1...@backstage-community/plugin-rbac-backend@2.0.0) (2023-12-14) - -### ⚠ BREAKING CHANGES - -- **rbac:** add support for multiple policies CRUD (#984) - -### Features - -- **rbac:** add support for multiple policies CRUD ([#984](https://github.com/janus-idp/backstage-plugins/issues/984)) ([518c767](https://github.com/janus-idp/backstage-plugins/commit/518c7674aa037669fe9c2fc6f8dc9be5f0c8fa84)) - -## @backstage-community/plugin-rbac-backend [1.7.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.7.0...@backstage-community/plugin-rbac-backend@1.7.1) (2023-12-08) - -### Documentation - -- **rbac:** add documentation for api and known permissions ([#1000](https://github.com/janus-idp/backstage-plugins/issues/1000)) ([8f8133f](https://github.com/janus-idp/backstage-plugins/commit/8f8133f12d2a74dc6503f7545942f11c40b52092)) - -## @backstage-community/plugin-rbac-backend [1.7.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.6.6...@backstage-community/plugin-rbac-backend@1.7.0) (2023-12-07) - -### Features - -- **rbac:** list roles with no permission policies ([#998](https://github.com/janus-idp/backstage-plugins/issues/998)) ([217b7b0](https://github.com/janus-idp/backstage-plugins/commit/217b7b0db3414788c8e77247f378a51cf0eeda0d)) - -## @backstage-community/plugin-rbac-backend [1.6.6](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.6.5...@backstage-community/plugin-rbac-backend@1.6.6) (2023-12-05) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.2.0 - -## @backstage-community/plugin-rbac-backend [1.6.5](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.6.4...@backstage-community/plugin-rbac-backend@1.6.5) (2023-12-04) - -### Documentation - -- **rbac:** additional docs for backend configuration ([#982](https://github.com/janus-idp/backstage-plugins/issues/982)) ([17b95a0](https://github.com/janus-idp/backstage-plugins/commit/17b95a0c51e97ee5a9160dc7bec7559c075eca88)) - -## @backstage-community/plugin-rbac-backend [1.6.4](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.6.3...@backstage-community/plugin-rbac-backend@1.6.4) (2023-11-20) - -### Bug Fixes - -- **aap+3scale+ocm:** don't log sensitive data from errors ([#945](https://github.com/janus-idp/backstage-plugins/issues/945)) ([7a5e7b8](https://github.com/janus-idp/backstage-plugins/commit/7a5e7b8a57c9841003d9b16e1a65fb62e101fbf1)) - -## @backstage-community/plugin-rbac-backend [1.6.3](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.6.2...@backstage-community/plugin-rbac-backend@1.6.3) (2023-11-13) - -### Bug Fixes - -- **rbac:** use the same Knex version with Backstage ([#929](https://github.com/janus-idp/backstage-plugins/issues/929)) ([6923ce0](https://github.com/janus-idp/backstage-plugins/commit/6923ce07d787ea6edd911ab348704ba6b9f95ada)) - -## @backstage-community/plugin-rbac-backend [1.6.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.6.1...@backstage-community/plugin-rbac-backend@1.6.2) (2023-11-10) - -### Bug Fixes - -- **rbac:** handle postgres ssl connection for rbac backend plugin ([#923](https://github.com/janus-idp/backstage-plugins/issues/923)) ([deb2026](https://github.com/janus-idp/backstage-plugins/commit/deb202642f456cda446a99f55a475eeaddc59e7c)) - -## @backstage-community/plugin-rbac-backend [1.6.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.6.0...@backstage-community/plugin-rbac-backend@1.6.1) (2023-11-01) - -### Bug Fixes - -- **rbac:** add migration folder to rbac-backend package ([#897](https://github.com/janus-idp/backstage-plugins/issues/897)) ([694a9d6](https://github.com/janus-idp/backstage-plugins/commit/694a9d65bd986eb8e7fde3d66e012963033741af)) - -## @backstage-community/plugin-rbac-backend [1.6.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.5.1...@backstage-community/plugin-rbac-backend@1.6.0) (2023-10-31) - -### Features - -- **rbac:** implement REST method to list all plugin permission policies ([#808](https://github.com/janus-idp/backstage-plugins/issues/808)) ([0a17e67](https://github.com/janus-idp/backstage-plugins/commit/0a17e67cbb72416176e978fc3ed8868855375a8b)) - -## @backstage-community/plugin-rbac-backend [1.5.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.5.0...@backstage-community/plugin-rbac-backend@1.5.1) (2023-10-30) - -### Bug Fixes - -- **rbac:** fix service to service requests for RBAC CRUD ([#886](https://github.com/janus-idp/backstage-plugins/issues/886)) ([0b72d73](https://github.com/janus-idp/backstage-plugins/commit/0b72d7373dddc3f4d8c5076ca3800745bf619d85)) - -## @backstage-community/plugin-rbac-backend [1.5.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.4.0...@backstage-community/plugin-rbac-backend@1.5.0) (2023-10-30) - -### Features - -- **rbac:** implement conditional policies feature. ([#833](https://github.com/janus-idp/backstage-plugins/issues/833)) ([3c0675b](https://github.com/janus-idp/backstage-plugins/commit/3c0675ba6ebf91274848981fa1e6eab9e4a1e659)) - -## @backstage-community/plugin-rbac-backend [1.4.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.3.0...@backstage-community/plugin-rbac-backend@1.4.0) (2023-10-30) - -### Features - -- **rbac:** add role support for policies-csv-file ([#894](https://github.com/janus-idp/backstage-plugins/issues/894)) ([7ad4902](https://github.com/janus-idp/backstage-plugins/commit/7ad4902be12a9900149a73427a6c52cbb65659f3)) - -## @backstage-community/plugin-rbac-backend [1.3.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.2.1...@backstage-community/plugin-rbac-backend@1.3.0) (2023-10-27) - -### Features - -- **rbac:** implement the concept of roles in rbac ([#867](https://github.com/janus-idp/backstage-plugins/issues/867)) ([4d878a2](https://github.com/janus-idp/backstage-plugins/commit/4d878a29babd86bd7896d69e6b2b63392b6e6cc8)) - -### Bug Fixes - -- **rbac:** add models folder and config.d.ts to package ([#891](https://github.com/janus-idp/backstage-plugins/issues/891)) ([406c147](https://github.com/janus-idp/backstage-plugins/commit/406c14703110018c702834482d32fdd4f8a36cef)) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.1.0 - -## @backstage-community/plugin-rbac-backend [1.2.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.2.0...@backstage-community/plugin-rbac-backend@1.2.1) (2023-10-24) - -### Bug Fixes - -- **rbac:** use token manager for catalog requests ([#866](https://github.com/janus-idp/backstage-plugins/issues/866)) ([8ad3480](https://github.com/janus-idp/backstage-plugins/commit/8ad348029cec4eabf605c7065e76a5305be3cac8)) - -## @backstage-community/plugin-rbac-backend [1.2.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.1.1...@backstage-community/plugin-rbac-backend@1.2.0) (2023-10-23) - -### Features - -- **cli:** add frontend dynamic plugins base build config ([#747](https://github.com/janus-idp/backstage-plugins/issues/747)) ([91e06da](https://github.com/janus-idp/backstage-plugins/commit/91e06da8ab108c17fd2a6531f25e01c7a7350276)), closes [#831](https://github.com/janus-idp/backstage-plugins/issues/831) - -## @backstage-community/plugin-rbac-backend [1.1.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.1.0...@backstage-community/plugin-rbac-backend@1.1.1) (2023-10-19) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.0.1 - -## @backstage-community/plugin-rbac-backend [1.1.0](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.0.2...@backstage-community/plugin-rbac-backend@1.1.0) (2023-10-06) - -### Features - -- **rbac:** implement RBAC group support ([#803](https://github.com/janus-idp/backstage-plugins/issues/803)) ([4c72f5c](https://github.com/janus-idp/backstage-plugins/commit/4c72f5c23324ea2f7538b406d60730ea224ae758)) - -## @backstage-community/plugin-rbac-backend [1.0.2](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.0.1...@backstage-community/plugin-rbac-backend@1.0.2) (2023-10-04) - -### Bug Fixes - -- **rbac:** add models folder to package ([#823](https://github.com/janus-idp/backstage-plugins/issues/823)) ([e2bc66e](https://github.com/janus-idp/backstage-plugins/commit/e2bc66edac61a16ec92f75fb48c8ad459f24a23a)) - -## @backstage-community/plugin-rbac-backend [1.0.1](https://github.com/janus-idp/backstage-plugins/compare/@backstage-community/plugin-rbac-backend@1.0.0...@backstage-community/plugin-rbac-backend@1.0.1) (2023-10-03) - -### Documentation - -- **rbac:** initial documentation for RBAC ([#814](https://github.com/janus-idp/backstage-plugins/issues/814)) ([d5cd566](https://github.com/janus-idp/backstage-plugins/commit/d5cd5666c43be5ca2790b1c548f56350ef50c96c)) - -## @backstage-community/plugin-rbac-backend 1.0.0 (2023-09-29) - -### Bug Fixes - -- **rbac:** remove private package ([#809](https://github.com/janus-idp/backstage-plugins/issues/809)) ([cf59d6d](https://github.com/janus-idp/backstage-plugins/commit/cf59d6d1c5a65363a7ccdd7490d3148d665e7d46)) - -### Dependencies - -- **@backstage-community/plugin-rbac-common:** upgraded to 1.0.0 diff --git a/plugins/rbac-backend/README.md b/plugins/rbac-backend/README.md deleted file mode 100644 index 4e421ab598..0000000000 --- a/plugins/rbac-backend/README.md +++ /dev/null @@ -1,364 +0,0 @@ -# RBAC backend plugin for Backstage - -This plugin seamlessly integrates with the [Backstage permission framework](https://backstage.io/docs/permissions/overview/) to empower you with robust role-based access control capabilities within your Backstage environment. - -The Backstage permission framework is a core component of the Backstage project, designed to provide meticulous control over resource and action access. Our RBAC plugin harnesses the power of this framework, allowing you to tailor access permissions without the need for coding. Instead, you can effortlessly manage your access policies through User interface embedded within Backstage or via the configuration files. - -With the RBAC plugin, you'll have the means to efficiently administer permissions within your Backstage instance by assigning them to users and groups. - -## Prerequisites - -Before you dive into utilizing the RBAC plugin for Backstage, there are a few essential prerequisites to ensure a seamless experience. Please review the following requirements to make sure your environment is properly set up - -### Setup Permission Framework - -**NOTE**: This section is only relevant if you are still on the old backend system. - -To effectively utilize the RBAC plugin, you must have the Backstage permission framework in place. If you're using the Red Hat Developer Hub, some of these steps may have already been completed for you. However, for other Backstage application instances, please verify that the following prerequisites are satisfied: - -You need to [set up the permission framework in Backstage](https://backstage.io/docs/permissions/getting-started/).Since this plugin provides a dynamic policy that replaces the traditional one, there's no need to create a policy manually. Please note that one of the requirements for permission framework is enabling the [service-to-service authentication](https://backstage.io/docs/auth/service-to-service-auth/#setup). Ensure that you complete these authentication setup steps as well. - -### Identity resolver - -The permission framework, and consequently, this RBAC plugin, rely on the concept of group membership. To ensure smooth operation, please follow the [Sign-in identities and resolvers](https://backstage.io/docs/auth/identity-resolver/) documentation. It's crucial that when populating groups, you include any groups that you plan to assign permissions to. - -## Installation - -To integrate the RBAC plugin into your Backstage instance, follow these steps. - -### Installing the plugin - -Add the RBAC plugin packages as dependencies by running the following command. - -```SHELL -yarn workspace backend add @backstage-community/plugin-rbac-backend -``` - -**NOTE**: If you are using Red Hat Developer Hub, backend plugin is pre-installed and you do not need this step. - -### Configuring the Backend - -#### New Backend System - -The RBAC plugin supports the integration with the new backend system. - -Add the RBAC plugin to the `packages/backend/src/index.ts` file and remove the Allow All Permission policy module. - -```diff -// permission plugin -backend.add(import('@backstage/plugin-permission-backend')); -- backend.add( -- import('@backstage/plugin-permission-backend-module-allow-all-policy'), -- ); -+ backend.add(import('@backstage-community/plugin-rbac-backend')); -``` - -### Configure policy admins - -The RBAC plugin empowers you to manage permission policies for users and groups with a designated group of individuals known as policy administrators. These administrators are granted access to the RBAC plugin's REST API and user interface as well as the ability to read from the catalog. - -You can specify the policy administrators in your application configuration as follows: - -```YAML -permission: - enabled: true - rbac: - admin: - users: - - name: user:default/alice - - name: group:default/admins -``` - -The RBAC plugin also enables you to grant users the title of 'super user,' which provides them with unrestricted access throughout the Backstage instance. - -You can specify the super users in your application configuration as follows: - -```YAML -permission: - enabled: true - rbac: - admin: - superUsers: - - name: user:default/alice - - name: user:default/mike - - name: group:default/admins -``` - -> **Note:** **Transient memberships are not supported for `superUsers`.** Meaning, when a group is specified as a super user, only direct group memberships are taken into account. Users who belong to a sub-group of a configured super user group will not be granted super user access. - -For more information on the available API endpoints accessible to the policy administrators, refer to the [API documentation](./docs/apis.md). - -### Configure default role - -You can optionally assign a default role to all authenticated users by using `defaultPermissions.defaultRole`. -This ensures that every authenticated user receives the specified role in addition to any other roles they may have. -You can also define baseline permissions for that role using `defaultPermissions.basicPermissions`. -This is especially useful when using [Sign-In without Users in the Catalog](https://backstage.io/docs/auth/identity-resolver/#sign-in-without-users-in-the-catalog). - -```YAML -permission: - rbac: - defaultPermissions: - defaultRole: role:default/my-default-role - basicPermissions: - - permission: catalog.entity.read - action: read - - permission: catalog-entity - action: read - - permission: catalog.entity.create - action: create -``` - -If configured, the RBAC backend will automatically include the default role in each authenticated user's roles and evaluate the configured `basicPermissions` for that role. -When `defaultPermissions.defaultRole` is set, `defaultPermissions.basicPermissions` must contain at least one permission entry. - -### Configure plugins with permission - -In order for the RBAC UI to display the available permissions provided by installed plugins, you must supply the corresponding list of plugin IDs. There are two ways to achieve this: - -- Application configuration(`app-config.yaml`) -- REST API - -#### Configure plugins with Application configuration - -You can specify the plugins with permissions in your application configuration as follows: - -```YAML -permission: - enabled: true - rbac: - pluginsWithPermission: - - catalog - - scaffolder - - permission - admin: - users: - - name: user:default/alice - - name: group:default/admins -``` - -#### Configure plugins with REST API - -You can specify the plugins with permissions using the corresponding [REST API](./docs/apis.md#plugin-ids-that-support-the-backstage-permission-framework). - -Curl Examples: - -Get the object containing the list of plugin IDs: - -``` -curl -X GET "http://localhost:7007/api/permission/plugins/id" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $token" -v -``` - -Add more plugin IDs: - -``` -curl -X POST "http://localhost:7007/api/permission/plugins/id" \ - -d '{ "ids": [ "permission", "scaffolder" ] }' \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $token" -v -``` - -Remove plugin IDs: - -``` -curl -X DELETE "http://localhost:7007/api/permission/plugins/id" \ - -d '{ "ids": [ "permission", "scaffolder" ] }' \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $token" -v -``` - -Notice: The REST API does not allow deletion of plugin IDs that were provided via application configuration, in order to prevent an inconsistent state after a deployment restart. These ID values can only be removed through the configuration file. - -For more information on the available permissions, refer to the [RBAC permissions documentation](./docs/permissions.md). - -### Configuring policies via file - -The RBAC plugin also allows you to import policies from an external file. These policies are defined in the [Casbin rules format](https://casbin.org/docs/category/the-basics), known for its simplicity and clarity. For a quick start, please refer to the format details in the provided link. - -Here's an example of an external permission policies configuration file named `rbac-policy.csv`: - -```CSV -p, role:default/team_a, catalog-entity, read, deny -p, role:default/team_b, catalog.entity.create, create, deny - -g, user:default/bob, role:default/team_a - -g, group:default/team_b, role:default/team_b -``` - ---- - -**NOTE**: When you add a role in the permission policies configuration file, ensure that the role is associated with at least one permission policy with the `allow` effect. - ---- - -You can specify the path to this configuration file in your application configuration: - -```YAML -permission: - enabled: true - rbac: - policies-csv-file: /some/path/rbac-policy.csv -``` - -Also, there is an additional configuration value that allows for the reloading of the CSV file without the need to restart. - -```YAML -permission: - enabled: true - rbac: - policies-csv-file: /some/path/rbac-policy.csv - policyFileReload: true -``` - -For more information on the available permissions, refer to the [RBAC permissions documentation](./docs/permissions.md). - -We also have a fairly strict validation for permission policies and roles based on the originating role's source information, refer to the [api documentation](./docs/apis.md). - -### Configuring conditional policies via file - -The RBAC plugin allows you to import conditional policies from an external file. User can defined conditional policies for roles created with the help of the policies-csv-file. Conditional policies should be defined as object sequences in the YAML format. - -You can specify the path to this configuration file in your application configuration: - -```YAML -permission: - enabled: true - rbac: - conditionalPoliciesFile: /some/path/conditional-policies.yaml - policies-csv-file: /some/path/rbac-policy.csv -``` - -Also, there is an additional configuration value that allows for the reloading of the file without the need to restart. - -```YAML -permission: - enabled: true - rbac: - conditionalPoliciesFile: /some/path/conditional-policies.yaml - policies-csv-file: /some/path/rbac-policy.csv - policyFileReload: true -``` - -This feature supports nested conditional policies. - -Example of the conditional policies file: - -```yaml ---- -result: CONDITIONAL -roleEntityRef: role:default/test -pluginId: catalog -resourceType: catalog-entity -permissionMapping: - - read - - update -conditions: - rule: IS_ENTITY_OWNER - resourceType: catalog-entity - params: - claims: - - group:default/team-a - - group:default/team-b ---- -result: CONDITIONAL -roleEntityRef: role:default/test -pluginId: catalog -resourceType: catalog-entity -permissionMapping: - - delete -conditions: - rule: IS_ENTITY_OWNER - resourceType: catalog-entity - params: - claims: - - group:default/team-a -``` - -Information about condition policies format you can find in the doc: [Conditional policies documentation](./docs/conditions.md). There is only one difference: yaml format compare to json. But yaml and json are back convertiable. - -### Configuring Database Storage for policies - -The RBAC plugin offers the option to store policies in a database. It supports two database storage options: - -- sqlite3: Suitable for development environments. -- postgres: Recommended for production environments. - -Ensure that you have already configured the database backend for your Backstage instance, as the RBAC plugin utilizes the same database configuration. - -#### Azure PostgreSQL Passwordless Authentication - -For Azure Database for PostgreSQL, the RBAC plugin supports passwordless authentication using Microsoft Entra ID (formerly Azure Active Directory). This provides enhanced security by using Azure-managed identities or service principals instead of passwords. - -To enable Azure passwordless authentication, configure your database connection with `type: azure`: - -```yaml -backend: - database: - client: pg - connection: - type: azure - host: ${POSTGRES_HOST} - user: ${POSTGRES_USER} - ssl: - rejectUnauthorized: false - tokenCredential: - # Option 1: Use system-assigned managed identity (no config needed) - # Option 2: Use user-assigned managed identity - clientId: ${AZURE_CLIENT_ID} - # Option 3: Use service principal (requires all three) - clientId: ${AZURE_CLIENT_ID} - tenantId: ${AZURE_TENANT_ID} - clientSecret: ${AZURE_CLIENT_SECRET} -``` - -**Authentication methods:** - -1. **System-assigned managed identity**: Omit all `tokenCredential` properties -2. **User-assigned managed identity**: Provide only `clientId` -3. **Service principal**: Provide `clientId`, `tenantId`, and `clientSecret` - -**Token renewal:** - -The RBAC plugin automatically handles Azure AD token renewal by leveraging the pg driver's support for password functions. Fresh tokens are fetched on each new database connection, ensuring uninterrupted operation even as tokens expire (typically after 60 minutes). The connection pool is configured with a 50-minute idle timeout to force connection recycling before token expiry. - -**Important notes:** - -- Ensure your Azure PostgreSQL server has Microsoft Entra authentication enabled and the appropriate database roles configured. -- The username should be the Entra ID principal name (e.g., `myuser@myserver` for managed identities). -- The Azure credentials must be available to the application environment (managed identity, environment variables, or Azure CLI). - -### Optional maximum depth - -The RBAC plugin also includes an option max depth feature for organizations with potentially complex group hierarchy, this configuration value will ensure that the RBAC plugin will stop at a certain depth when building user graphs. - -```YAML -permission: - enabled: true - rbac: - maxDepth: 1 -``` - -The maxDepth must be greater than 0 to ensure that the graphs are built correctly. Also the graph will be built with a hierarchy of 1 + maxDepth. - -More information about group hierarchy can be found in the doc: [Group hierarchy](./docs/group-hierarchy.md). - -### Optional RBAC provider module support - -We also include the ability to create and load in RBAC backend plugin modules that can be used to make connections to third part access management tools. For more information, consult the [RBAC Providers documentation](./docs/providers.md). - -### Optional configuration to control policy decision precedence - -Controls the evaluation order between permission policies (basic) and conditional policies for resource permissions. - -- Default: `conditional` (conditional policies take precedence when present) -- Set to `basic` to evaluate basic permission policy first - -```YAML -permission: - enabled: true - rbac: - policyDecisionPrecedence: basic # or conditional -``` diff --git a/plugins/rbac-backend/__fixtures__/auditor-test-utils.ts b/plugins/rbac-backend/__fixtures__/auditor-test-utils.ts deleted file mode 100644 index 7ff8b0e0ca..0000000000 --- a/plugins/rbac-backend/__fixtures__/auditor-test-utils.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { AuditorServiceCreateEventOptions } from '@backstage/backend-plugin-api'; - -import { mockAuditorService, createEventMock } from './mock-utils'; -import { type JsonObject } from '@backstage/types'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import { EvaluationEvents } from '../src/auditor/auditor'; - -export function expectAuditorLog( - events: { - event: AuditorServiceCreateEventOptions; - success?: { meta?: JsonObject }; - fail?: { meta?: JsonObject; error: Error }; - }[], -) { - const auditEvents = mockAuditorService.createEvent.mock.calls; - const succeededEvents = createEventMock.success.mock.calls; - const failedEvents = createEventMock.fail.mock.calls; - - expect(auditEvents.length).toBe(events.length); - for (let i = 0; i < events.length; i++) { - const expectedEvent = { ...events[i].event, severityLevel: 'medium' }; - expect(auditEvents[i][0]).toEqual(expectedEvent); // verifies also eventId - if (events[i].success) { - expect(succeededEvents[i][0]).toEqual(events[i].success); - } - if (events[i].fail) { - expect(failedEvents[i][0]).toEqual(events[i].fail); - } - } -} - -export function expectAuditorLogForPermission( - user: string | undefined, - permissionName: string, - resourceType: string | undefined, - action: string, - result: AuthorizeResult, -) { - const expectedUser = user ?? 'user without entity'; - const meta = { - action, - permissionName, - resourceType, - userEntityRef: expectedUser, - }; - expectAuditorLog([ - { - event: { eventId: EvaluationEvents.PERMISSION_EVALUATION, meta }, - success: { - meta: { result }, - }, - }, - ]); -} - -export function clearAuditorMock() { - mockAuditorService.createEvent.mockClear(); - createEventMock.fail.mockClear(); - createEventMock.success.mockClear(); -} diff --git a/plugins/rbac-backend/__fixtures__/data/hierarchy/groups.ts b/plugins/rbac-backend/__fixtures__/data/hierarchy/groups.ts deleted file mode 100644 index 513256aec1..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/hierarchy/groups.ts +++ /dev/null @@ -1,893 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export const GROUPS_FOR_TESTS = [ - { - name: 'thor_group_0', - namespace: null, - title: 'Thor Group 0', - children: [], - parent: null, - hasMember: ['user:default/thor'], - }, - { - name: 'wasp_group_0', - namespace: null, - title: 'Wasp Group 0', - children: [], - parent: null, - hasMember: ['user:default/wasp'], - }, - { - name: 'captain_america_group_0', - namespace: null, - title: 'Captain America Group 0', - children: [], - parent: 'captain_america_group_1', - hasMember: ['user:default/captain_america'], - }, - { - name: 'captain_america_group_1', - namespace: null, - title: 'Captain America Group 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'hawkeye_group_0', - namespace: null, - title: 'Hawkeye Group 0', - children: [], - parent: 'hawkeye_group_1', - hasMember: ['user:default/hawkeye'], - }, - { - name: 'hawkeye_group_1', - namespace: null, - title: 'Hawkeye Group 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'quicksilver_group_0', - namespace: null, - title: 'Quicksilver Group 0', - children: [], - parent: 'quicksilver_group_1', - hasMember: ['user:default/quicksilver'], - }, - { - name: 'quicksilver_group_1', - namespace: null, - title: 'Quicksilver Group 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'scarlet_witch_group_0', - namespace: null, - title: 'Scarlet Witch Group 0', - children: [], - parent: 'scarlet_witch_group_1', - hasMember: ['user:default/scarlet_witch'], - }, - { - name: 'scarlet_witch_group_1', - namespace: null, - title: 'Scarlet Witch Group 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'swordsman_group_0', - namespace: null, - title: 'Swordsman Group 0', - children: [], - parent: null, - hasMember: ['user:default/swordsman'], - }, - { - name: 'hercules_group_0', - namespace: null, - title: 'Hercules Group 0', - children: [], - parent: null, - hasMember: ['user:default/hercules'], - }, - { - name: 'black_panther_group_0', - namespace: null, - title: 'Black Panther Group 0', - children: [], - parent: null, - hasMember: ['user:default/black_panther'], - }, - { - name: 'vision_group_0', - namespace: null, - title: 'Vision Group 0', - children: [], - parent: null, - hasMember: ['user:default/vision'], - }, - { - name: 'black_knight_group_a', - namespace: null, - title: 'Black Knight Group A', - children: [], - parent: null, - hasMember: ['user:default/black_knight'], - }, - { - name: 'black_knight_group_b', - namespace: null, - title: 'Black Knight Group B', - children: [], - parent: null, - hasMember: ['user:default/black_knight'], - }, - { - name: 'black_widow_group_a', - namespace: null, - title: 'Black Widow Group A', - children: [], - parent: null, - hasMember: ['user:default/black_widow'], - }, - { - name: 'black_widow_group_b', - namespace: null, - title: 'Black Widow Group B', - children: [], - parent: null, - hasMember: ['user:default/black_widow'], - }, - { - name: 'mantis_group_a', - namespace: null, - title: 'Mantis Group A', - children: [], - parent: null, - hasMember: ['user:default/mantis'], - }, - { - name: 'mantis_group_b', - namespace: null, - title: 'Mantis Group B', - children: [], - parent: null, - hasMember: ['user:default/mantis'], - }, - { - name: 'beast_group_a', - namespace: null, - title: 'Beast Group A', - children: [], - parent: null, - hasMember: ['user:default/beast'], - }, - { - name: 'beast_group_b', - namespace: null, - title: 'Beast Group B', - children: [], - parent: null, - hasMember: ['user:default/beast'], - }, - { - name: 'moondragon_group_a_0', - namespace: null, - title: 'Moondragon Group A 0', - children: [], - parent: 'moondragon_group_a_1', - hasMember: ['user:default/moondragon'], - }, - { - name: 'moondragon_group_a_1', - namespace: null, - title: 'Moondragon Group A 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'moondragon_group_b_0', - namespace: null, - title: 'Moondragon Group B 0', - children: [], - parent: 'moondragon_group_b_1', - hasMember: ['user:default/moondragon'], - }, - { - name: 'moondragon_group_b_1', - namespace: null, - title: 'Moondragon Group B 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'hellcat_group_a_0', - namespace: null, - title: 'Hellcat Group A 0', - children: [], - parent: 'hellcat_group_a_1', - hasMember: ['user:default/hellcat'], - }, - { - name: 'hellcat_group_a_1', - namespace: null, - title: 'Hellcat Group A 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'hellcat_group_b_0', - namespace: null, - title: 'Hellcat Group B 0', - children: [], - parent: 'hellcat_group_b_1', - hasMember: ['user:default/hellcat'], - }, - { - name: 'hellcat_group_b_1', - namespace: null, - title: 'Hellcat Group B 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'captain_marvel_group_a_0', - namespace: null, - title: 'Captain Marvel Group A 0', - children: [], - parent: 'captain_marvel_group_a_1', - hasMember: ['user:default/captain_marvel'], - }, - { - name: 'captain_marvel_group_a_1', - namespace: null, - title: 'Captain Marvel Group A 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'captain_marvel_group_b_0', - namespace: null, - title: 'Captain Marvel Group B 0', - children: [], - parent: 'captain_marvel_group_b_1', - hasMember: ['user:default/captain_marvel'], - }, - { - name: 'captain_marvel_group_b_1', - namespace: null, - title: 'Captain Marvel Group B 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'falcon_group_a_0', - namespace: null, - title: 'Falcon Group A 0', - children: [], - parent: 'falcon_group_a_1', - hasMember: ['user:default/falcon'], - }, - { - name: 'falcon_group_a_1', - namespace: null, - title: 'Falcon Group A 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'falcon_group_b_0', - namespace: null, - title: 'Falcon Group B 0', - children: [], - parent: 'falcon_group_b_1', - hasMember: ['user:default/falcon'], - }, - { - name: 'falcon_group_b_1', - namespace: null, - title: 'Falcon Group B 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'wonder_man_group_0', - namespace: null, - title: 'Wonder Man Group 0', - children: [], - parent: 'wonder_man_group_1', - hasMember: ['user:default/wonder_man'], - }, - { - name: 'wonder_man_group_1', - namespace: null, - title: 'Wonder Man Group 1', - children: [], - parent: 'wonder_man_group_0', - hasMember: [], - }, - { - name: 'tigra_group_0', - namespace: null, - title: 'Tigra Group 0', - children: [], - parent: 'tigra_group_1', - hasMember: ['user:default/tigra'], - }, - { - name: 'tigra_group_1', - namespace: null, - title: 'Tigra Group 1', - children: [], - parent: 'tigra_group_0', - hasMember: [], - }, - { - name: 'she_hulk_group_0', - namespace: null, - title: 'She-Hulk Group 0', - children: [], - parent: 'she_hulk_group_1', - hasMember: ['user:default/she_hulk'], - }, - { - name: 'she_hulk_group_1', - namespace: null, - title: 'She-Hulk Group 1', - children: [], - parent: 'she_hulk_group_0', - hasMember: [], - }, - { - name: 'starfox_group_0', - namespace: null, - title: 'Starfox Group 0', - children: [], - parent: 'starfox_group_1', - hasMember: ['user:default/starfox'], - }, - { - name: 'starfox_group_1', - namespace: null, - title: 'Starfox Group 1', - children: [], - parent: 'starfox_group_0', - hasMember: [], - }, - { - name: 'mockingbird_group_0', - namespace: null, - title: 'Mockingbird Group 0', - children: [], - parent: 'mockingbird_group_1', - hasMember: ['user:default/mockingbird'], - }, - { - name: 'mockingbird_group_1', - namespace: null, - title: 'Mockingbird Group 1', - children: [], - parent: 'mockingbird_group_0', - hasMember: [], - }, - { - name: 'war_machine_group_0', - namespace: null, - title: 'War Machine Group 0', - children: [], - parent: 'war_machine_group_1', - hasMember: ['user:default/war_machine'], - }, - { - name: 'war_machine_group_1', - namespace: null, - title: 'War Machine Group 1', - children: [], - parent: 'war_machine_group_0', - hasMember: [], - }, - { - name: 'namor_group_a_0', - namespace: null, - title: 'Namor Group A 0', - children: [], - parent: 'namor_group_a_1', - hasMember: ['user:default/namor'], - }, - { - name: 'namor_group_a_1', - namespace: null, - title: 'Namor Group A 1', - children: [], - parent: 'namor_group_a_0', - hasMember: [], - }, - { - name: 'namor_group_b_0', - namespace: null, - title: 'Namor Group B 0', - children: [], - parent: 'namor_group_b_1', - hasMember: ['user:default/namor'], - }, - { - name: 'namor_group_b_1', - namespace: null, - title: 'Namor Group B 1', - children: [], - parent: 'namor_group_b_0', - hasMember: [], - }, - { - name: 'thing_group_a_0', - namespace: null, - title: 'Thing Group A 0', - children: [], - parent: 'thing_group_a_1', - hasMember: ['user:default/thing'], - }, - { - name: 'thing_group_a_1', - namespace: null, - title: 'Thing Group A 1', - children: [], - parent: 'thing_group_a_0', - hasMember: [], - }, - { - name: 'thing_group_b_0', - namespace: null, - title: 'Thing Group B 0', - children: [], - parent: 'thing_b_1', - hasMember: ['user:default/thing'], - }, - { - name: 'thing_group_b_1', - namespace: null, - title: 'Thing Group B 1', - children: [], - parent: 'thing_b_0', - hasMember: [], - }, - { - name: 'doctor_druid_group_a_0', - namespace: null, - title: 'Doctor Druid Group A 0', - children: [], - parent: 'doctor_druid_group_a_1', - hasMember: ['user:default/doctor_druid'], - }, - { - name: 'doctor_druid_group_a_1', - namespace: null, - title: 'Doctor Druid Group A 1', - children: [], - parent: 'doctor_druid_group_a_0', - hasMember: [], - }, - { - name: 'doctor_druid_group_b_0', - namespace: null, - title: 'Doctor Druid Group B 0', - children: [], - parent: 'doctor_druid_group_b_1', - hasMember: ['user:default/doctor_druid'], - }, - { - name: 'doctor_druid_group_b_1', - namespace: null, - title: 'Doctor Druid Group B 1', - children: [], - parent: 'doctor_druid_group_b_0', - hasMember: [], - }, - { - name: 'firebird_group_a_0', - namespace: null, - title: 'Firebird Group A 0', - children: [], - parent: 'firebird_group_a_1', - hasMember: ['user:default/firebird'], - }, - { - name: 'firebird_group_a_1', - namespace: null, - title: 'Firebird Group A 1', - children: [], - parent: 'firebird_group_a_0', - hasMember: [], - }, - { - name: 'firebird_group_b_0', - namespace: null, - title: 'Firebird Group B 0', - children: [], - parent: 'firebird_group_b_1', - hasMember: ['user:default/firebird'], - }, - { - name: 'firebird_group_b_1', - namespace: null, - title: 'Firebird Group B 1', - children: [], - parent: 'firebird_group_b_0', - hasMember: [], - }, - { - name: 'valkyrie_group_a_0', - namespace: null, - title: 'Valkyrie Group A 0', - children: [], - parent: 'valkyrie_group_a_1', - hasMember: ['user:default/valkyrie'], - }, - { - name: 'valkyrie_group_a_1', - namespace: null, - title: 'Valkyrie Group A 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'valkyrie_group_b_0', - namespace: null, - title: 'Valkyrie Group B 0', - children: [], - parent: 'valkyrie_group_b_1', - hasMember: ['user:default/valkyrie'], - }, - { - name: 'valkyrie_group_b_1', - namespace: null, - title: 'Valkyrie Group B 1', - children: [], - parent: 'valkyrie_group_b_0', - hasMember: [], - }, - { - name: 'nova_group_a_0', - namespace: null, - title: 'Nova Group A 0', - children: [], - parent: 'nova_group_a_1', - hasMember: ['user:default/nova'], - }, - { - name: 'nova_group_a_1', - namespace: null, - title: 'Nova Group A 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'nova_group_b_0', - namespace: null, - title: 'Nova Group B 0', - children: [], - parent: 'nova_group_b_1', - hasMember: ['user:default/nova'], - }, - { - name: 'nova_group_b_1', - namespace: null, - title: 'Nova Group B 1', - children: [], - parent: 'nova_group_b_0', - hasMember: [], - }, - { - name: 'storm_group_a_0', - namespace: null, - title: 'Storm Group A 0', - children: [], - parent: 'storm_group_a_1', - hasMember: ['user:default/storm'], - }, - { - name: 'storm_group_a_1', - namespace: null, - title: 'Storm Group A 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'storm_group_b_0', - namespace: null, - title: 'Storm Group B 0', - children: [], - parent: 'storm_group_b_1', - hasMember: ['user:default/storm'], - }, - { - name: 'storm_group_b_1', - namespace: null, - title: 'Storm Group B 1', - children: [], - parent: 'storm_group_b_0', - hasMember: [], - }, - { - name: 'daredevil_group_a_0', - namespace: null, - title: 'Daredevil Group A 0', - children: [], - parent: 'daredevil_group_a_1', - hasMember: ['user:default/daredevil'], - }, - { - name: 'daredevil_group_a_1', - namespace: null, - title: 'Daredevil Group A 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'daredevil_group_b_0', - namespace: null, - title: 'Daredevil Group B 0', - children: [], - parent: 'daredevil_group_b_1', - hasMember: ['user:default/daredevil'], - }, - { - name: 'daredevil_group_b_1', - namespace: null, - title: 'Daredevil Group B 1', - children: [], - parent: 'daredevil_group_b_0', - hasMember: [], - }, - { - name: 'spiderman_group_0', - namespace: null, - title: 'Spiderman Group 0', - children: [], - parent: 'spiderman_group_1', - hasMember: ['user:default/spiderman'], - }, - { - name: 'spiderman_group_1', - namespace: null, - title: 'Spiderman Group 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'moon_knight_group_0', - namespace: null, - title: 'Moon Knight Group 0', - children: [], - parent: 'moon_knight_group_1', - hasMember: ['user:default/moon_knight'], - }, - { - name: 'moon_knight_group_1', - namespace: null, - title: 'Moon Knight Group 1', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'cable_group_0', - namespace: null, - title: 'Cable Group 0', - children: [], - parent: null, - hasMember: ['user:default/cable'], - }, - { - name: 'ghost_rider_group_0', - namespace: null, - title: 'Ghost Rider Group 0', - children: [], - parent: null, - hasMember: ['user:default/ghost_rider'], - }, - { - name: 'admin', - namespace: null, - title: 'Admin', - children: [], - parent: null, - hasMember: ['user:default/admin_one'], - }, - { - name: 'team-a', - namespace: null, - title: 'Team A', - children: [], - parent: 'root-group', - hasMember: ['user:default/tor', 'user:default/adam'], - }, - { - name: 'team-b', - namespace: null, - title: 'Team B', - children: [], - parent: 'team-a', - hasMember: ['user:default/mike'], - }, - { - name: 'team-C', - namespace: null, - title: 'Team C', - children: [], - parent: 'team-a', - hasMember: ['user:default/tor', 'user:default/tom'], - }, - { - name: 'team-d', - namespace: null, - title: 'Team D', - children: [], - parent: 'team-a', - hasMember: ['user:default/george', 'user:default/john'], - }, - { - name: 'team-e', - namespace: null, - title: 'Team E', - children: [], - parent: 'team-f', - hasMember: [], - }, - { - name: 'team-f', - namespace: null, - title: 'Team F', - children: [], - parent: 'team-e', - hasMember: ['user:default/john'], - }, - { - name: 'team-g', - namespace: null, - title: 'Team G', - children: [], - parent: 'team-f', - hasMember: ['user:default/bill'], - }, - { - name: 'team-z', - namespace: null, - title: 'Team Z', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'team-y', - namespace: null, - title: 'Team Y', - children: [], - parent: 'team-z', - hasMember: ['user:default/mike'], - }, - { - name: 'team-x', - namespace: null, - title: 'Team X', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'root-group', - namespace: null, - title: 'Root Group', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'data_admin', - namespace: null, - title: 'Data Admin', - children: [], - parent: null, - hasMember: [ - 'user:default/alice', - 'user:default/akira', - 'user:default/antey', - ], - }, - { - name: 'data_read_admin', - namespace: null, - title: 'Data Read Admin', - children: [], - parent: 'data_parent_admin', - hasMember: ['user:default/mike', 'user:default/tom'], - }, - { - name: 'data_parent_admin', - namespace: null, - title: 'Data Parent Admin', - children: [], - parent: null, - hasMember: [], - }, - { - name: 'test-group', - namespace: null, - title: 'Test Group', - children: [], - parent: null, - hasMember: ['user:default/mike'], - }, - { - name: 'qa', - namespace: null, - title: 'QA Group', - children: [], - parent: null, - hasMember: ['user:default/mike'], - }, - { - name: 'team-hr', - namespace: null, - title: 'HR Group', - children: [], - parent: 'team-management', - hasMember: ['user:default/sally'], - }, - { - name: 'team-management', - namespace: null, - title: 'Management Group', - children: [], - parent: 'group:hq/team-management', - hasMember: [], - }, - { - name: 'team-management', - namespace: 'hq', - title: 'Management Group', - children: [], - parent: 'team-administration', - hasMember: [], - }, - { - name: 'team-administration', - namespace: 'hq', - title: 'Administration Group', - children: [], - parent: 'group:default/root-group', - hasMember: [], - }, -]; diff --git a/plugins/rbac-backend/__fixtures__/data/hierarchy/rbac-policy.csv b/plugins/rbac-backend/__fixtures__/data/hierarchy/rbac-policy.csv deleted file mode 100644 index 9e74a2676f..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/hierarchy/rbac-policy.csv +++ /dev/null @@ -1,245 +0,0 @@ -# Test one: user:default/ant_man, expect allow -g, user:default/ant_man, role:default/ant_man -p, role:default/ant_man, catalog-entity, read, allow - -# Test two: user:default/hulk, expect deny -g, user:default/hulk, role:default/hulk -p, role:default/hulk, catalog-entity, read, deny - -# Test three: user:default/thor, expect allow -g, group:default/thor_group_0, role:default/thor_group_0 -p, role:default/thor_group_0, catalog-entity, read, allow - -# Test four: user:default/wasp, expect deny -g, group:default/wasp_group_0, role:default/wasp_group_0 -p, role:default/wasp_group_0, catalog-entity, read, deny - -# Test five: user:default/moon_knight, expect allow -g, group:default/moon_knight_group_1, role:default/moon_knight_group_1 -p, role:default/moon_knight_group_1, catalog-entity, read, allow - -# Test six: user:default/spiderman, expect deny -g, group:default/spiderman_group_1, role:default/spiderman_group_1 -p, role:default/spiderman_group_1, catalog-entity, read, deny - -# Test seven: user:default/captain_america, expect allow -g, group:default/captain_america_group_0, role:default/captain_america_group_0 -p, role:default/captain_america_group_0, catalog-entity, read, allow - -g, group:default/captain_america_group_1, role:default/captain_america_group_1 -p, role:default/captain_america_group_1, catalog-entity, read, allow - -# Test eight: user:default/hawkeye, expect deny -g, group:default/hawkeye_group_0, role:default/hawkeye_group_0 -p, role:default/hawkeye_group_0, catalog-entity, read, deny - -g, group:default/hawkeye_group_1, role:default/hawkeye_group_1 -p, role:default/hawkeye_group_1, catalog-entity, read, deny - -# Test nine: user:default/quicksilver, expect deny -g, group:default/quicksilver_group_0, role:default/quicksilver_group_0 -p, role:default/quicksilver_group_0, catalog-entity, read, deny - -g, group:default/quicksilver_group_1, role:default/quicksilver_group_1 -p, role:default/quicksilver_group_1, catalog-entity, read, allow - -# Test ten: user:default/scarlet_witch, expect deny -g, group:default/scarlet_witch_group_0, role:default/scarlet_witch_group_0 -p, role:default/scarlet_witch_group_0, catalog-entity, read, allow - -g, group:default/scarlet_witch_group_1, role:default/scarlet_witch_group_1 -p, role:default/scarlet_witch_group_1, catalog-entity, read, deny - -# Test eleven: user:default/swordsman, expect allow -g, user:default/swordsman, role:default/swordsman -p, role:default/swordsman, catalog-entity, read, allow - -g, group:default/swordsman_group_0, role:default/swordsman_group_0 -p, role:default/swordsman_group_0, catalog-entity, read, allow - -# Test twelve: user:default/hercules, expect deny -g, user:default/hercules, role:default/hercules -p, role:default/hercules, catalog-entity, read, deny - -g, group:default/hercules_group_0, role:default/hercules_group_0 -p, role:default/hercules_group_0, catalog-entity, read, deny - -# Test thriteen: user:default/black_panther, expect deny -g, user:default/black_panther, role:default/black_panther -p, role:default/black_panther, catalog-entity, read, deny - -g, group:default/black_panther_group_0, role:default/black_panther_group_0 -p, role:default/black_panther_group_0, catalog-entity, read, allow - -# Test fourteen: user:default/vision, expect deny -g, user:default/vision, role:default/vision -p, role:default/vision, catalog-entity, read, allow - -g, group:default/vision_group_0, role:default/vision_group_0 -p, role:default/vision_group_0, catalog-entity, read, deny - -# Test fifteen: user:default/black_knight, expect allow -g, group:default/black_knight_group_a, role:default/black_knight_group_a -p, role:default/black_knight_group_a, catalog-entity, read, allow - -g, group:default/black_knight_group_b, role:default/black_knight_group_b -p, role:default/black_knight_group_b, catalog-entity, read, allow - -# Test sixteen: user:default/black_widow, expect deny -g, group:default/black_widow_group_a, role:default/black_widow_group_a -p, role:default/black_widow_group_a, catalog-entity, read, deny - -g, group:default/black_widow_group_b, role:default/black_widow_group_b -p, role:default/black_widow_group_b, catalog-entity, read, deny - -# Test seventeen: user:default/mantis, expect deny -g, group:default/mantis_group_a, role:default/mantis_group_a -p, role:default/mantis_group_a, catalog-entity, read, deny - -g, group:default/mantis_group_b, role:default/mantis_group_b -p, role:default/mantis_group_b, catalog-entity, read, allow - -# Test eighteen: user:default/beast, expect deny -g, group:default/beast_group_a, role:default/beast_group_a -p, role:default/beast_group_a, catalog-entity, read, allow - -g, group:default/beast_group_b, role:default/beast_group_b -p, role:default/beast_group_b, catalog-entity, read, deny - -# Test nineteen: user:default/moondragon, expect allow -g, group:default/moondragon_group_a_1, role:default/moondragon_group_a_1 -p, role:default/moondragon_group_a_1, catalog-entity, read, allow - -g, group:default/moondragon_group_b_1, role:default/moondragon_group_b_1 -p, role:default/moondragon_group_b_1, catalog-entity, read, allow - -# Test twenty: user:default/hellcat, expect deny -g, group:default/hellcat_group_a_1, role:default/hellcat_group_a_1 -p, role:default/hellcat_group_a_1, catalog-entity, read, deny - -g, group:default/hellcat_group_b_1, role:default/hellcat_group_b_1 -p, role:default/hellcat_group_b_1, catalog-entity, read, deny - -# Test twenty one: user:default/captain_marvel, expect deny -g, group:default/captain_marvel_group_a_1, role:default/captain_marvel_group_a_1 -p, role:default/captain_marvel_group_a_1, catalog-entity, read, deny - -g, group:default/captain_marvel_group_b_1, role:default/captain_marvel_group_b_1 -p, role:default/captain_marvel_group_b_1, catalog-entity, read, allow - -# Test twenty two: user:default/falcon, expect deny -g, group:default/falcon_group_a_1, role:default/falcon_group_a_1 -p, role:default/falcon_group_a_1, catalog-entity, read, allow - -g, group:default/falcon_group_b_1, role:default/falcon_group_b_1 -p, role:default/falcon_group_b_1, catalog-entity, read, deny - -# Test twenty three: user:default/wonder_man, expect deny -g, group:default/wonder_man_group_1, role:default/wonder_man_group_1 -p, role:default/wonder_man_group_1, catalog-entity, read, allow - -# Test twenty four: user:default/tigra, expect deny -g, group:default/tigra_group_1, role:default/tigra_group_1 -p, role:default/tigra_group_1, catalog-entity, read, deny - -# Test twenty five: user:default/she_hulk, expect deny -g, group:default/she_hulk_group_0, role:default/she_hulk_group_0 -p, role:default/she_hulk_group_0, catalog-entity, read, allow - -g, group:default/she_hulk_group_1, role:default/she_hulk_group_1 -p, role:default/she_hulk_group_1, catalog-entity, read, allow - -# Test twenty six: user:default/starfox, expect deny -g, group:default/starfox_group_0, role:default/starfox_group_0 -p, role:default/starfox_group_0, catalog-entity, read, deny - -g, group:default/starfox_group_1, role:default/starfox_group_1 -p, role:default/starfox_group_1, catalog-entity, read, deny - -# Test twenty seven: user:default/mockingbird, expect deny -g, group:default/mockingbird_group_0, role:default/mockingbird_group_0 -p, role:default/mockingbird_group_0, catalog-entity, read, deny - -g, group:default/mockingbird_group_1, role:default/mockingbird_group_1 -p, role:default/mockingbird_group_1, catalog-entity, read, allow - -# Test twenty eight: user:default/war_machine, expect deny -g, group:default/war_machine_group_0, role:default/war_machine_group_0 -p, role:default/war_machine_group_0, catalog-entity, read, allow - -g, group:default/war_machine_group_1, role:default/war_machine_group_1 -p, role:default/war_machine_group_1, catalog-entity, read, deny - -# Test twenty nine: user:default/namor, expect deny -g, group:default/namor_group_a_1, role:default/namor_group_a_1 -p, role:default/namor_group_a_1, catalog-entity, read, allow - -g, group:default/namor_group_b_1, role:default/namor_group_b_1 -p, role:default/namor_group_b_1, catalog-entity, read, allow - -# Test thirty: user:default/thing, expect deny -g, group:default/thing_group_a_1, role:default/thing_group_a_1 -p, role:default/thing_group_a_1, catalog-entity, read, deny - -g, group:default/thing_group_b_1, role:default/thing_group_b_1 -p, role:default/thing_group_b_1, catalog-entity, read, deny - -# Test thirty one: user:default/doctor_druid, expect deny -g, group:default/doctor_druid_group_a_1, role:default/doctor_druid_group_a_1 -p, role:default/doctor_druid_group_a_1, catalog-entity, read, deny - -g, group:default/doctor_druid_group_b_1, role:default/doctor_druid_group_b_1 -p, role:default/doctor_druid_group_b_1, catalog-entity, read, allow - -# Test thirty two: user:default/firebird, expect deny -g, group:default/firebird_group_a_1, role:default/firebird_group_a_1 -p, role:default/firebird_group_a_1, catalog-entity, read, allow - -g, group:default/firebird_group_b_1, role:default/firebird_group_b_1 -p, role:default/firebird_group_b_1, catalog-entity, read, deny - -# Test thirty three: user:default/valkyrie, expect deny -g, group:default/valkyrie_group_a_1, role:default/valkyrie_group_a_1 -p, role:default/valkyrie_group_a_1, catalog-entity, read, allow - -g, group:default/valkyrie_group_b_1, role:default/valkyrie_group_b_1 -p, role:default/valkyrie_group_b_1, catalog-entity, read, allow - -# Test thirty four: user:default/nova, expect deny -g, group:default/nova_group_a_1, role:default/nova_group_a_1 -p, role:default/nova_group_a_1, catalog-entity, read, deny - -g, group:default/nova_group_b_1, role:default/nova_group_b_1 -p, role:default/nova_group_b_1, catalog-entity, read, deny - -# Test thirty five: user:default/storm, expect deny -g, group:default/storm_group_a_1, role:default/storm_group_a_1 -p, role:default/storm_group_a_1, catalog-entity, read, deny - -g, group:default/storm_group_b_1, role:default/storm_group_b_1 -p, role:default/storm_group_b_1, catalog-entity, read, allow - -# Test thirty six: user:default/daredevil, expect deny -g, group:default/daredevil_group_a_1, role:default/daredevil_group_a_1 -p, role:default/daredevil_group_a_1, catalog-entity, read, allow - -g, group:default/daredevil_group_b_1, role:default/daredevil_group_b_1 -p, role:default/daredevil_group_b_1, catalog-entity, read, deny - -# Test thirty seven: user:default/psylocke, expect allow -p, user:default/psylocke, catalog-entity, read, allow - -# Test thirty eight: user:default/penance, expect deny -p, user:default/penance, catalog-entity, read, deny - -# Test thirty nine: user:default/cable, expect allow -p, group:default/cable_group_0, catalog-entity, read, allow - -# Test fourty: user:default/ghost_rider, expect deny -p, group:default/ghost_rider_group_0, catalog-entity, read, deny - -# Test fourty One: user:default/super_user: expect allow -# Set user:default/super_user to `rbac.admin.superUsers` - -# Test fourty Two: user:default/admin: expect allow -# Set user:default/admin to `rbac.admin.users` \ No newline at end of file diff --git a/plugins/rbac-backend/__fixtures__/data/hierarchy/users.ts b/plugins/rbac-backend/__fixtures__/data/hierarchy/users.ts deleted file mode 100644 index 47a196c858..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/hierarchy/users.ts +++ /dev/null @@ -1,357 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export const USERS_FOR_TEST = [ - { - name: 'ant_man', - memberOf: [], - displayName: 'Ant Man', - email: 'ant_man@example.com', - }, - { - name: 'hulk', - memberOf: [], - displayName: 'Hulk', - email: 'hulk@example.com', - }, - { - name: 'thor', - memberOf: ['group:default/thor_group_0'], - displayName: 'Thor', - email: 'thor@example.com', - }, - { - name: 'wasp', - memberOf: ['group:default/wasp_group_0'], - displayName: 'Wasp', - email: 'wasp@example.com', - }, - { - name: 'captain_america', - memberOf: ['group:default/captain_america_group_0'], - displayName: 'Captain America', - email: 'captain_america@example.com', - }, - { - name: 'hawkeye', - memberOf: ['group:default/hawkeye_group_0'], - displayName: 'Hawkeye', - email: 'hawkeye@example.com', - }, - { - name: 'quicksilver', - memberOf: ['group:default/quicksilver_group_0'], - displayName: 'Quicksilver', - email: 'quicksilver@example.com', - }, - { - name: 'scarlet_witch', - memberOf: ['group:default/scarlet_witch_group_0'], - displayName: 'Scarlet Witch', - email: 'scarlet_witch@example.com', - }, - { - name: 'swordsman', - memberOf: ['group:default/swordsman_group_0'], - displayName: 'Swordsman', - email: 'swordsman@example.com', - }, - { - name: 'hercules', - memberOf: ['group:default/hercules_group_0'], - displayName: 'Hercules', - email: 'hercules@example.com', - }, - { - name: 'black_panther', - memberOf: ['group:default/black_panther_group_0'], - displayName: 'Black Panther', - email: 'black_panther@example.com', - }, - { - name: 'vision', - memberOf: ['group:default/vision_group_0'], - displayName: 'Vision', - email: 'vision@example.com', - }, - { - name: 'black_knight', - memberOf: [ - 'group:default/black_knight_group_a', - 'group:default/black_knight_group_b', - ], - displayName: 'Black Knight', - email: 'black_knight@example.com', - }, - { - name: 'black_widow', - memberOf: [ - 'group:default/black_widow_group_a', - 'group:default/black_widow_group_b', - ], - displayName: 'Black Widow', - email: 'black_widow@example.com', - }, - { - name: 'mantis', - memberOf: ['group:default/mantis_group_a', 'group:default/mantis_group_b'], - displayName: 'Mantis', - email: 'mantis@example.com', - }, - { - name: 'beast', - memberOf: ['group:default/beast_group_a', 'group:default/beast_group_b'], - displayName: 'Beast', - email: 'beast@example.com', - }, - { - name: 'moondragon', - memberOf: [ - 'group:default/moondragon_group_a_0', - 'group:defaultmoondragon_group_b_0', - ], - displayName: 'Moondragon', - email: 'moondragon@example.com', - }, - { - name: 'hellcat', - memberOf: [ - 'group:default/hellcat_group_a_0', - 'group:default/hellcat_group_b_0', - ], - displayName: 'Hellcat', - email: 'hellcat@example.com', - }, - { - name: 'captain_marvel', - memberOf: [ - 'group:default/captain_marvel_group_a_0', - 'group:default/captain_marvel_group_b_0', - ], - displayName: 'Captain Marvel', - email: 'captain_marvel@example.com', - }, - { - name: 'falcon', - memberOf: [ - 'group:default/falcon_group_a_0', - 'group:default/falcon_group_b_0', - ], - displayName: 'Falcon', - email: 'falcon@example.com', - }, - { - name: 'wonder_man', - memberOf: ['group:default/wonder_man_group_0'], - displayName: 'Wonder Man', - email: 'wonder_man@example.com', - }, - { - name: 'tigra', - memberOf: ['group:default/tigra_group_0'], - displayName: 'Tigra', - email: 'tigra@example.com', - }, - { - name: 'she_hulk', - memberOf: ['group:default/she_hulk_group_0'], - displayName: 'She-Hulk', - email: 'she_hulk@example.com', - }, - { - name: 'starfox', - memberOf: ['group:default/starfox_group_0'], - displayName: 'Starfox', - email: 'starfox@example.com', - }, - { - name: 'mockingbird', - memberOf: ['group:default/mockingbird_group_0'], - displayName: 'Mockingbird', - email: 'mockingbird@example.com', - }, - { - name: 'war_machine', - memberOf: ['group:default/war_machine_group_0'], - displayName: 'War Machine', - email: 'war_machine@example.com', - }, - { - name: 'namor', - memberOf: [ - 'group:default/namor_group_a_0', - 'group:default/namor_group_b_0', - ], - displayName: 'Namor', - email: 'namor@example.com', - }, - { - name: 'thing', - memberOf: [ - 'group:default/thing_group_a_0', - 'group:default/thing_group_b_0', - ], - displayName: 'Thing', - email: 'thing@example.com', - }, - { - name: 'doctor_druid', - memberOf: [ - 'group:default/doctor_druid_group_a_0', - 'group:default/doctor_druid_group_b_0', - ], - displayName: 'Doctor Druid', - email: 'doctor_druid@example.com', - }, - { - name: 'firebird', - memberOf: [ - 'group:default/firebird_group_a_0', - 'group:default/firebird_group_b_0', - ], - displayName: 'Firebird', - email: 'firebird@example.com', - }, - { - name: 'moon_knight', - memberOf: ['group:default/moon_knight_group_0'], - displayName: 'Moon Knight', - email: 'moon_knight@example.com', - }, - { - name: 'spiderman', - memberOf: ['group:default/spiderman_group_0'], - displayName: 'Spiderman', - email: 'spiderman@example.com', - }, - { - name: 'valkyrie', - memberOf: [ - 'group:default/valkyrie_group_a_0', - 'group:default/valkyrie_group_b_0', - ], - displayName: 'Valkyrie', - email: 'valkyrie@example.com', - }, - { - name: 'nova', - memberOf: ['group:default/nova_group_a_0', 'group:default/nova_group_b_0'], - displayName: 'Nova', - email: 'nova@example.com', - }, - { - name: 'storm', - memberOf: [ - 'group:default/storm_group_a_0', - 'group:default/storm_group_b_0', - ], - displayName: 'Storm', - email: 'storm@example.com', - }, - { - name: 'daredevil', - memberOf: [ - 'group:default/daredevil_group_a_0', - 'group:default/daredevil_group_b_0', - ], - displayName: 'Daredevil', - email: 'daredevil@example.com', - }, - { - name: 'psylocke', - memberOf: [], - displayName: 'Psylocke', - email: 'psylocke@example.com', - }, - { - name: 'penance', - memberOf: [], - displayName: 'Penance', - email: 'penance@example.com', - }, - { - name: 'cable', - memberOf: ['group:default/cable_group_0'], - displayName: 'Cable', - email: 'cable@example.com', - }, - { - name: 'ghost_rider', - memberOf: ['group:default/ghost_rider_group_0'], - displayName: 'Ghost Rider', - email: 'ghost_rider@example.com', - }, - { - name: 'admin', - memberOf: [], - displayName: 'Admin', - email: 'admin@example.com', - }, - { - name: 'admin_one', - memberOf: ['group:default/admin'], - displayName: 'Admin One', - email: 'admin_one@example.com', - }, - { - name: 'super_user', - memberOf: [], - displayName: 'Super User', - email: 'super_user@example.com', - }, - { - name: 'tor', - memberOf: ['group:default/team-a', 'group:default/team-C'], - displayName: 'Tor', - email: 'tor@example.com', - }, - { - name: 'mike', - memberOf: ['group:default/team-b', 'group:default/team-y'], - displayName: 'Mike', - email: 'mike@example.com', - }, - { - name: 'tom', - memberOf: ['group:default/team-c'], - displayName: 'Tom', - email: 'tom@example.com', - }, - { - name: 'bill', - memberOf: ['group:default/team-g'], - displayName: 'Bill', - email: 'bill@example.com', - }, - { - name: 'john', - memberOf: ['group:default/team-d', 'group:defaul/team-f'], - displayName: 'John', - email: 'john@example.com', - }, - { - name: 'bob', - memberOf: [], - displayName: 'Bob', - email: 'bob@example.com', - }, - { - name: 'sally', - memberOf: ['group:default/hr'], - displayName: 'Sally', - email: 'sally@example.com', - }, -]; diff --git a/plugins/rbac-backend/__fixtures__/data/invalid-conditions/bad-conditions-yaml.yaml b/plugins/rbac-backend/__fixtures__/data/invalid-conditions/bad-conditions-yaml.yaml deleted file mode 100644 index 862169502d..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/invalid-conditions/bad-conditions-yaml.yaml +++ /dev/null @@ -1 +0,0 @@ -some bad yaml.... diff --git a/plugins/rbac-backend/__fixtures__/data/invalid-conditions/invalid-yaml.yaml b/plugins/rbac-backend/__fixtures__/data/invalid-conditions/invalid-yaml.yaml deleted file mode 100644 index 1575881403..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/invalid-conditions/invalid-yaml.yaml +++ /dev/null @@ -1 +0,0 @@ -result: diff --git a/plugins/rbac-backend/__fixtures__/data/invalid-csv/deprecated-policy.csv b/plugins/rbac-backend/__fixtures__/data/invalid-csv/deprecated-policy.csv deleted file mode 100644 index ceb3791502..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/invalid-csv/deprecated-policy.csv +++ /dev/null @@ -1 +0,0 @@ -p, role:default/some_role, policy-entity, create, allow \ No newline at end of file diff --git a/plugins/rbac-backend/__fixtures__/data/invalid-csv/duplicate-policy.csv b/plugins/rbac-backend/__fixtures__/data/invalid-csv/duplicate-policy.csv deleted file mode 100644 index 23e7ef1077..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/invalid-csv/duplicate-policy.csv +++ /dev/null @@ -1,17 +0,0 @@ -g, user:default/guest, role:default/catalog-deleter -g, user:default/guest, role:default/catalog-deleter - -g, user:default/guest, role:default/catalog-updater - -g, group:default/READER-GROUP, role:default/CATALOG-USER -g, group:default/READER-GROUP, role:default/CATALOG-USER - -p, role:default/catalog-writer, catalog.entity.create, use, allow -p, role:default/catalog-writer, catalog.entity.create, use, allow - -p, role:default/catalog-writer, catalog-entity, delete, allow - -p, role:default/duplication-effect, catalog-entity, update, allow -p, role:default/duplication-effect, catalog-entity, update, deny - -p, role:default/CATALOG-USER, catalog-entity, read, allow diff --git a/plugins/rbac-backend/__fixtures__/data/invalid-csv/error-policy.csv b/plugins/rbac-backend/__fixtures__/data/invalid-csv/error-policy.csv deleted file mode 100644 index 963ad4cceb..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/invalid-csv/error-policy.csv +++ /dev/null @@ -1,10 +0,0 @@ -g, user:default/, role:default/catalog-deleter -g, user:default/test, role:default/ -p, role:default/, catalog.entity.create, use, allow -p, role:default/test, catalog.entity.create, delete, temp - -p, role:default/rest, catalog-entity, update, allow -g, user:default/guest, role:default/rest - -p, role:default/config, catalog-entity, update, allow -g, user:default/guest, role:default/config diff --git a/plugins/rbac-backend/__fixtures__/data/valid-conditions/conditions.yaml b/plugins/rbac-backend/__fixtures__/data/valid-conditions/conditions.yaml deleted file mode 100644 index 0af4cab20b..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/valid-conditions/conditions.yaml +++ /dev/null @@ -1,28 +0,0 @@ ---- -result: CONDITIONAL -roleEntityRef: 'role:default/test' -pluginId: catalog -resourceType: catalog-entity -permissionMapping: - - update -conditions: - rule: IS_ENTITY_OWNER - resourceType: catalog-entity - params: - claims: - - 'group:default/team-a' ---- -result: CONDITIONAL -roleEntityRef: 'role:default/test' -pluginId: catalog -resourceType: catalog-entity -permissionMapping: - - read - - delete -conditions: - rule: IS_ENTITY_OWNER - resourceType: catalog-entity - params: - claims: - - 'group:default/team-a' - - 'group:default/team-b' diff --git a/plugins/rbac-backend/__fixtures__/data/valid-conditions/empty-conditions.yaml b/plugins/rbac-backend/__fixtures__/data/valid-conditions/empty-conditions.yaml deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/plugins/rbac-backend/__fixtures__/data/valid-conditions/extra-delimiter-conditions.yaml b/plugins/rbac-backend/__fixtures__/data/valid-conditions/extra-delimiter-conditions.yaml deleted file mode 100644 index be93fc5132..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/valid-conditions/extra-delimiter-conditions.yaml +++ /dev/null @@ -1,30 +0,0 @@ ---- -result: CONDITIONAL -roleEntityRef: 'role:default/test-2' -pluginId: catalog -resourceType: catalog-entity -permissionMapping: - - update -conditions: - rule: IS_ENTITY_OWNER - resourceType: catalog-entity - params: - claims: - - 'group:default/team-a' ---- -result: CONDITIONAL -roleEntityRef: 'role:default/test-3' -pluginId: catalog -resourceType: catalog-entity -permissionMapping: - - read - - delete -conditions: - rule: IS_ENTITY_OWNER - resourceType: catalog-entity - params: - claims: - - 'group:default/team-a' - - 'group:default/team-b' ---- - diff --git a/plugins/rbac-backend/__fixtures__/data/valid-csv/basic-and-resource-policies.csv b/plugins/rbac-backend/__fixtures__/data/valid-csv/basic-and-resource-policies.csv deleted file mode 100644 index 7615d8aa63..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/valid-csv/basic-and-resource-policies.csv +++ /dev/null @@ -1,21 +0,0 @@ -# ========== basic type permission policies ========== # -# case 1 -p, user:default/known_user, test.resource.deny, use, deny -# case 2 is about user without listed permissions -# case 3 -p, user:default/duplicated, test.resource, use, allow -p, user:default/duplicated, test.resource, use, deny -# case 4 -p, user:default/known_user, test.resource, use, allow -# case 5 -unknown user - -# ========== resource type permission policies ========== # -# case 1 -p, user:default/known_user, test-resource-deny, update, deny -# case 2 is about user without listed permissions -# case 3 -p, user:default/duplicated, test-resource, update, allow -p, user:default/duplicated, test-resource, update, deny -# case 4 -p, user:default/known_user, test-resource, update, allow diff --git a/plugins/rbac-backend/__fixtures__/data/valid-csv/policy-checks.csv b/plugins/rbac-backend/__fixtures__/data/valid-csv/policy-checks.csv deleted file mode 100644 index 96aa84f201..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/valid-csv/policy-checks.csv +++ /dev/null @@ -1,67 +0,0 @@ -# basic type permission policies -### Let's deny 'use' action for 'test.resource' for group:default/data_admin -p, group:default/data_admin, test.resource, use, deny - -# case1: -# g, user:default/alice, group:default/data_admin -p, user:default/alice, test.resource, use, allow - -# case2: -# g, user:default/akira, group:default/data_admin - -# case3: -# g, user:default/antey, group:default/data_admin -p, user:default/antey, test.resource, use, deny - -### Let's allow 'use' action for 'test.resource' for group:default/data_read_admin -p, group:default/data_read_admin, test.resource, use, allow - -# case4: -# g, user:default/julia, group:default/data_read_admin -p, user:default/julia, test.resource, use, allow - -# case5: -# g, user:default/mike, group:default/data_read_admin - -# case6: -# g, user:default/tom, group:default/data_read_admin -p, user:default/tom, test.resource, use, deny - - -# resource type permission policies -### Let's deny 'read' action for 'test.resource' permission for group:default/data_admin -p, group:default/data_admin, test-resource, read, deny - -# case1: -# g, user:default/alice, group:default/data_admin -p, user:default/alice, test-resource, read, allow - -# case2: -# g, user:default/akira, group:default/data_admin - -# case3: -# g, user:default/antey, group:default/data_admin -p, user:default/antey, test-resource, read, deny - -### Let's allow 'read' action for 'test-resource' permission for group:default/data_read_admin -p, group:default/data_read_admin, test-resource, read, allow - -# case4: -# g, user:default/julia, group:default/data_read_admin -p, user:default/julia, test-resource, read, allow - -# case5: -# g, user:default/mike, group:default/data_read_admin - -# case6: -# g, user:default/tom, group:default/data_read_admin -p, user:default/tom, test-resource, read, deny - - -# group inheritance: -# g, group:default/data-read-admin, group:default/data_parent_admin -# and we know case5: -# g, user:default/mike, data-read-admin - -p, group:default/data_parent_admin, test.resource.2, use, allow -p, group:default/data_parent_admin, test-resource, create, allow diff --git a/plugins/rbac-backend/__fixtures__/data/valid-csv/rbac-policy.csv b/plugins/rbac-backend/__fixtures__/data/valid-csv/rbac-policy.csv deleted file mode 100644 index 619a199543..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/valid-csv/rbac-policy.csv +++ /dev/null @@ -1,17 +0,0 @@ -g, user:default/guest, role:default/catalog-writer -g, user:default/guest, role:default/legacy -g, user:default/guest, role:default/catalog-reader -g, user:default/guest, role:default/catalog-deleter - -p, role:default/catalog-writer, catalog-entity, update, allow -p, role:default/legacy, catalog-entity, update, allow -p, role:default/catalog-writer, catalog-entity, read, allow -p, role:default/catalog-writer, catalog.entity.create, use, allow -p, role:default/catalog-deleter, catalog-entity, delete, deny -p, role:default/CATALOG-USER, catalog-entity, read, allow - -p, role:default/known_role, test.resource.deny, use, allow - -g, user:default/known_user, role:default/known_role -g, user:default/TOM, role:default/CATALOG-USER -g, group:default/READER-GROUP, role:default/CATALOG-USER diff --git a/plugins/rbac-backend/__fixtures__/data/valid-csv/simple-policy.csv b/plugins/rbac-backend/__fixtures__/data/valid-csv/simple-policy.csv deleted file mode 100644 index 15c68d545a..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/valid-csv/simple-policy.csv +++ /dev/null @@ -1,2 +0,0 @@ -g, user:default/guest, role:default/catalog-writer -p, role:default/catalog-writer, catalog-entity, update, allow diff --git a/plugins/rbac-backend/__fixtures__/data/valid-csv/uppercase-policy.csv b/plugins/rbac-backend/__fixtures__/data/valid-csv/uppercase-policy.csv deleted file mode 100644 index c69f67019e..0000000000 --- a/plugins/rbac-backend/__fixtures__/data/valid-csv/uppercase-policy.csv +++ /dev/null @@ -1,7 +0,0 @@ -p, role:default/CATALOG-USER, catalog-entity, read, allow -p, role:default/known_role, test.resource.deny, use, allow - -g, user:default/known_user, role:default/known_role -g, user:default/TOM, role:default/CATALOG-USER -g, group:default/READER-GROUP, role:default/CATALOG-USER -g, group:default/READER-GROUP, role:default/known_role diff --git a/plugins/rbac-backend/__fixtures__/mock-utils.ts b/plugins/rbac-backend/__fixtures__/mock-utils.ts deleted file mode 100644 index 2de553dc50..0000000000 --- a/plugins/rbac-backend/__fixtures__/mock-utils.ts +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - mockCredentials, - mockServices, - ServiceMock, -} from '@backstage/backend-test-utils'; -import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; -import { AuditorService } from '@backstage/backend-plugin-api'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; - -import type { Enforcer } from 'casbin'; -import * as Knex from 'knex'; -import { MockClient } from 'knex-mock-client'; -import { resolve } from 'path'; -import type TypeORMAdapter from 'typeorm-adapter'; - -import type { RBACProvider } from '@backstage-community/plugin-rbac-node'; - -import { CasbinDBAdapterFactory } from '../src/database/casbin-adapter-factory'; -import { ConditionalStorage } from '../src/database/conditional-storage'; -import { RoleMetadataStorage } from '../src/database/role-metadata'; -import { - EnforcerDelegate, - RoleEventEmitter, - RoleEvents, -} from '../src/service/enforcer-delegate'; -import { PluginPermissionMetadataCollector } from '../src/service/plugin-endpoints'; -import { PermissionDependentPluginStore } from '../src/database/extra-permission-enabled-plugins-storage'; -import { ExtendablePluginIdProvider } from '../src/service/extendable-id-provider'; -import { convertGroupsToEntity, convertUsersToEntity } from './test-utils'; - -export const conditionalStorageMock: ConditionalStorage = { - filterConditions: jest.fn().mockImplementation(() => []), - createCondition: jest.fn().mockImplementation(), - checkConflictedConditions: jest.fn().mockImplementation(), - getCondition: jest.fn().mockImplementation(), - deleteCondition: jest.fn().mockImplementation(), - updateCondition: jest.fn().mockImplementation(), -}; - -export const roleMetadataStorageMock: RoleMetadataStorage = { - filterRoleMetadata: jest.fn().mockImplementation(() => []), - filterForOwnerRoleMetadata: jest.fn().mockImplementation(), - findRoleMetadata: jest.fn().mockImplementation(), - createRoleMetadata: jest.fn().mockImplementation(), - updateRoleMetadata: jest.fn().mockImplementation(), - removeRoleMetadata: jest.fn().mockImplementation(), - getCachedDefaultRoleMetadata: jest.fn().mockImplementation(() => undefined), - getDefaultRole: jest.fn().mockResolvedValue(undefined), - syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), -}; - -export const pluginMetadataCollectorMock: Partial = - { - getPluginConditionRules: jest.fn().mockImplementation(), - getPluginPolicies: jest.fn().mockImplementation(), - getMetadataByPluginId: jest.fn().mockImplementation(), - }; - -export const permissionDependentPluginStoreMock: PermissionDependentPluginStore = - { - getPlugins: jest - .fn() - .mockImplementation(async () => [ - { pluginId: 'jenkins' }, - { pluginId: 'sonarqube' }, - ]), - addPlugins: jest.fn().mockImplementation(), - deletePlugins: jest.fn().mockImplementation(), - }; - -export const pluginIdProviderMock = { - getPluginIds: jest.fn().mockImplementation(() => []), -}; - -export const extendablePluginIdProviderMock: Partial = - { - isConfiguredPluginId: jest.fn().mockImplementation(), - getPluginIds: jest.fn().mockImplementation(async () => ['catalog']), - handleConflictedPluginIds: jest.fn().mockImplementation(), - }; - -export const roleEventEmitterMock: RoleEventEmitter = { - on: jest.fn().mockImplementation(), -}; - -export const enforcerMock: Partial = { - loadPolicy: jest.fn().mockImplementation(async () => {}), - enableAutoSave: jest.fn().mockImplementation(() => {}), - setRoleManager: jest.fn().mockImplementation(() => {}), - enableAutoBuildRoleLinks: jest.fn().mockImplementation(() => {}), - buildRoleLinks: jest.fn().mockImplementation(() => {}), -}; - -export const enforcerDelegateMock: Partial = { - hasPolicy: jest.fn().mockImplementation(), - hasGroupingPolicy: jest.fn().mockImplementation(), - getPolicy: jest.fn().mockImplementation(), - getGroupingPolicy: jest.fn().mockImplementation(), - getFilteredPolicy: jest.fn().mockImplementation(), - getFilteredGroupingPolicy: jest.fn().mockImplementation(), - addPolicy: jest.fn().mockImplementation(), - addPolicies: jest.fn().mockImplementation(), - addGroupingPolicies: jest.fn().mockImplementation(), - removePolicy: jest.fn().mockImplementation(), - removePolicies: jest.fn().mockImplementation(), - removeGroupingPolicy: jest.fn().mockImplementation(), - removeGroupingPolicies: jest.fn().mockImplementation(), - updatePolicies: jest.fn().mockImplementation(), - updateGroupingPolicies: jest.fn().mockImplementation(), -}; - -export const dataBaseAdapterFactoryMock: Partial = { - createAdapter: jest.fn((): Promise => { - return Promise.resolve({} as TypeORMAdapter); - }), -}; - -export const providerMock: RBACProvider = { - getProviderName: jest.fn().mockImplementation(() => `testProvider`), - connect: jest.fn().mockImplementation(), - refresh: jest.fn().mockImplementation(), -}; - -export const mockClientKnex = Knex.knex({ client: MockClient }); - -export const mockHttpAuth = mockServices.httpAuth(); -export const mockAuthService = mockServices.auth(); - -export const createEventMock = { - success: jest.fn(), - fail: jest.fn(), -}; -export const mockAuditorService: ServiceMock = - mockServices.auditor.mock({ - createEvent: jest.fn(async _ => { - return createEventMock; - }), - }); - -export const credentials = mockCredentials.user(); -export const mockLoggerService = mockServices.logger.mock(); -export const mockPermissionRegistry = mockServices.permissionsRegistry.mock({ - getPermissionRuleset: jest.fn(resourceRef => { - return { - getRules: () => [ - { - resourceRef, - rules: [], - }, - ], - getRuleByName: jest.fn(), - }; - }), -}); - -export const mockedAuthorize = jest.fn().mockImplementation(async () => [ - { - result: AuthorizeResult.ALLOW, - }, -]); - -export const mockedAuthorizeConditional = jest - .fn() - .mockImplementation(async () => [ - { - result: AuthorizeResult.ALLOW, - }, - ]); - -export const mockPermissionEvaluator = { - authorize: mockedAuthorize, - authorizeConditional: mockedAuthorizeConditional, -}; - -export const testUsers = convertUsersToEntity(); -export const testGroups = convertGroupsToEntity(); -export const catalogMock = catalogServiceMock({ - entities: [...testGroups, ...testUsers], -}); - -export const csvPermFile = resolve( - __dirname, - './../__fixtures__/data/valid-csv/rbac-policy.csv', -); diff --git a/plugins/rbac-backend/__fixtures__/test-utils.ts b/plugins/rbac-backend/__fixtures__/test-utils.ts deleted file mode 100644 index 6d37e8cff8..0000000000 --- a/plugins/rbac-backend/__fixtures__/test-utils.ts +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { mockServices } from '@backstage/backend-test-utils'; -import { Config } from '@backstage/config'; - -import { - Adapter, - Enforcer, - Model, - newEnforcer, - newModelFromString, -} from 'casbin'; -import * as Knex from 'knex'; -import { MockClient } from 'knex-mock-client'; - -import { CasbinDBAdapterFactory } from '../src/database/casbin-adapter-factory'; -import { RoleMetadataStorage } from '../src/database/role-metadata'; -import { RBACPermissionPolicy } from '../src/policies/permission-policy'; -import { BackstageRoleManager } from '../src/role-manager/role-manager'; -import { DefaultPermissionsReader } from '../src/default-permissions/default-permissions'; -import { EnforcerDelegate } from '../src/service/enforcer-delegate'; -import { MODEL } from '../src/service/permission-model'; -import { PluginPermissionMetadataCollector } from '../src/service/plugin-endpoints'; -import { - mockAuditorService, - conditionalStorageMock, - csvPermFile, - mockAuthService, - mockClientKnex, - pluginMetadataCollectorMock, - roleMetadataStorageMock, -} from './mock-utils'; -import { clearAuditorMock } from './auditor-test-utils'; -import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; -import { USERS_FOR_TEST } from './data/hierarchy/users'; -import { - Entity, - GroupEntityV1alpha1, - UserEntityV1alpha1, -} from '@backstage/catalog-model'; -import { GROUPS_FOR_TESTS } from './data/hierarchy/groups'; - -export function newConfig( - permFile?: string, - users?: Array<{ name: string }>, - superUsers?: Array<{ name: string }>, -): Config { - const testUsers = [ - { - name: 'user:default/guest', - }, - { - name: 'group:default/guests', - }, - ]; - - return mockServices.rootConfig({ - data: { - permission: { - rbac: { - 'policies-csv-file': permFile || csvPermFile, - policyFileReload: true, - admin: { - users: users || testUsers, - superUsers: superUsers, - }, - }, - }, - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - }, - }); -} - -export async function newAdapter(config: Config): Promise { - return await new CasbinDBAdapterFactory( - config, - mockClientKnex, - ).createAdapter(); -} - -export async function createEnforcer( - theModel: Model, - adapter: Adapter, - logger: LoggerService, - config: Config, -): Promise { - const catalogDBClient = Knex.knex({ client: MockClient }); - const rbacDBClient = Knex.knex({ client: MockClient }); - const enf = await newEnforcer(theModel, adapter); - - const rm = new BackstageRoleManager( - catalogServiceMock.mock(), - logger, - catalogDBClient, - rbacDBClient, - config, - mockAuthService, - new DefaultPermissionsReader(config), - ); - enf.setRoleManager(rm); - enf.enableAutoBuildRoleLinks(false); - await enf.buildRoleLinks(); - - return enf; -} - -export async function newEnforcerDelegate( - adapter: Adapter, - config: Config, - storedPolicies?: string[][], - storedGroupingPolicies?: string[][], -): Promise { - const theModel = newModelFromString(MODEL); - const logger = mockServices.logger.mock(); - - const enf = await createEnforcer(theModel, adapter, logger, config); - - if (storedPolicies) { - await enf.addPolicies(storedPolicies); - } - - if (storedGroupingPolicies) { - await enf.addGroupingPolicies(storedGroupingPolicies); - } - - return new EnforcerDelegate( - enf, - mockAuditorService, - conditionalStorageMock, - roleMetadataStorageMock, - mockClientKnex, - ); -} - -export async function newPermissionPolicy( - config: Config, - enfDelegate: EnforcerDelegate, - roleMock?: RoleMetadataStorage, -): Promise { - const logger = mockServices.logger.mock(); - const permissionPolicy = await RBACPermissionPolicy.build( - logger, - mockAuditorService, - config, - conditionalStorageMock, - enfDelegate, - roleMock || roleMetadataStorageMock, - mockClientKnex, - pluginMetadataCollectorMock as PluginPermissionMetadataCollector, - mockAuthService, - ); - clearAuditorMock(); - return permissionPolicy; -} - -export function convertGroupsToEntity( - groups?: { - name: string; - namespace?: string | null; - title: string; - children: never[]; - parent: string | null; - hasMember: string[]; - }[], -): Entity[] { - const groupsForTests = groups ?? GROUPS_FOR_TESTS; - const groupsMocked = groupsForTests.map(group => { - const entityMock: GroupEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', - metadata: { - name: group.name, - namespace: group.namespace ?? 'default', - title: group.title, - }, - spec: { - children: group.children, - parent: group.parent!, - type: 'team', - }, - relations: [ - ...group.hasMember.map(member => ({ - type: 'hasMember', - targetRef: member, - })), - ], - }; - return entityMock; - }); - return groupsMocked; -} - -export function convertUsersToEntity(): Entity[] { - const usersMocked = USERS_FOR_TEST.map(user => { - const entityMock: UserEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', - metadata: { - name: user.name, - namespace: 'default', - }, - spec: { - memberOf: user.memberOf, - profile: { - displayName: user.displayName, - email: user.email, - }, - }, - relations: user.memberOf.map(member => ({ - type: 'memberOf', - targetRef: member, - })), - }; - return entityMock; - }); - return usersMocked; -} diff --git a/plugins/rbac-backend/catalog-info.yaml b/plugins/rbac-backend/catalog-info.yaml deleted file mode 100644 index 1b58a835cd..0000000000 --- a/plugins/rbac-backend/catalog-info.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# https://backstage.io/docs/features/software-catalog/descriptor-format#kind-component -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: backstage-community-rbac-backend - title: '@backstage-community/backstage-plugin-rbac-backend' - description: RBAC backend plugin for Backstage - annotations: - backstage.io/source-location: url:https://github.com/backstage/community-plugins/tree/main/workspaces/rbac/plugins/rbac-backend - backstage.io/view-url: https://github.com/backstage/community-plugins/blob/main/workspaces/rbac/plugins/rbac-backend/catalog-info.yaml - backstage.io/edit-url: https://github.com/backstage/community-plugins/edit/main/workspaces/rbac/plugins/rbac-backend/catalog-info.yaml - github.com/project-slug: backstage-community/backstage-plugins - github.com/team-slug: backstage/maintainers-plugins - sonarqube.org/project-key: backstage-community_plugins - tags: - - security - - rbac - links: - - url: https://github.com/backstage/community-plugins/tree/main/workspaces/rbac/plugins/rbac-backend - title: GitHub Source - icon: source - type: source -spec: - type: backstage-backend-plugin - lifecycle: production - owner: backstage-team - system: backstage - subcomponentOf: backstage-community-rbac diff --git a/plugins/rbac-backend/config.d.ts b/plugins/rbac-backend/config.d.ts deleted file mode 100644 index ba53eda566..0000000000 --- a/plugins/rbac-backend/config.d.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export interface Config { - permission: { - rbac: { - 'policies-csv-file'?: string; - /** - * The path to the yaml file containing the conditional policies - * @visibility frontend - */ - conditionalPoliciesFile?: string; - /** - * Allow for reloading of the CSV and conditional policies files. - * @visibility frontend - */ - policyFileReload?: boolean; - /** - * Optional configuration for admins - * @visibility frontend - */ - admin?: { - /** - * The list of users and / or groups with admin access - * @visibility frontend - */ - users?: Array<{ - /** - * @visibility frontend - */ - name: string; - }>; - /** - * The list of super users that will have allow all access, should be a list of only users - * @visibility frontend - */ - superUsers?: Array<{ - /** - * @visibility frontend - */ - name: string; - }>; - }; - /** - * An optional list of plugin IDs. - * The RBAC plugin will handle access control for plugins included in this list. - */ - pluginsWithPermission?: string[]; - /** - * An optional value that limits the depth when building the hierarchy group graph - * @visibility frontend - */ - maxDepth?: number; - /** - * An optional value that controls evaluation order between basic permission policy and conditional policy for permissions. - * - Default: "conditional" - * - "basic": prefer permission policy first - * - "conditional": prefer conditional policies first - * @visibility frontend - */ - policyDecisionPrecedence?: 'basic' | 'conditional'; - /** - * Configuration for assigning a default role with permissions - * to all authenticated users. - */ - defaultPermissions?: { - /** - * The default role to assign to all authenticated users. - */ - defaultRole: string; - /** - * The list of baseline basic permissions assigned to the default role. - */ - basicPermissions: Array<{ - /** - * Permission name or resource type, for example `catalog.entity.read` or `catalog-entity`. - */ - permission: string; - /** - * Action for the permission. Defaults to `use` when omitted. - */ - action: 'create' | 'read' | 'update' | 'delete' | 'use'; - }>; - }; - }; - }; -} diff --git a/plugins/rbac-backend/docs/apis.md b/plugins/rbac-backend/docs/apis.md deleted file mode 100644 index 322bee82ac..0000000000 --- a/plugins/rbac-backend/docs/apis.md +++ /dev/null @@ -1,986 +0,0 @@ -# APIs - -## Requirements - -To access the APIs for the RBAC Backend plugin, a user will need to have admin access. Refer to the [README](../README.md#configure-policy-admins) on how to set up admin access. - -Each endpoint also requires an Authorization header with the Bearer token that was generated by Backstage. If not using the RBAC Frontend plugin, then to access this token, traverse to your deployed instance and inspect the web page. Here are two places and example network calls that will have the Bearer token - -- From the Homepage, the network call `query?term=` - -- From the Catalog, any network call with `entity-facets` - -## Source - -Each permission policy and role that is created through the RBAC Backend plugin will have a source associated with it. When adding new permission policies and roles, we evaluate the ability to modify them based on the location of the first role that was defined. This means that permissions policies that are associated with a role that was created in the CSV file will also need to be defined in the CSV file. - -This strictness helps to keep the integrity and consistency of the data. A permission policy defined in by the REST API with a role in the CSV file can lead to issues in the event that the role was removed from the CSV file. The permission policy would be hanging without a role and would not show up in the RBAC Frontend. - -We have four options for source locations: CSV file, Configuration file, REST API, and legacy. The CSV file and REST API are fairly straightforward and involve modifying the role and permissions policies from that particular source only. Configuration file involves the `role:default/rbac_admin` role where the role can only be modified from the `app-config.yaml`. You are unable to add permission policies to the `role:default/rbac_admin` role. This is because we consider this as a default role for starting out. It is encouraged to either use the `superUsers` configuration feature or to craft your own admin role with the permission policies that are required of your admins. - -Finally, the legacy source is a source that may appear if your permission policies and roles were defined prior to RBAC Backend plugin `2.1.3`. It is recommended to update these legacy sourced roles and permission polices to a source of REST API or CSV file. This can be done by redefining these permission policies and roles using one of the available options. Remember that future permission policies and members of the role will be based on the first originating role with the new source. Be sure to add the role first through one of the described methods, then proceed to add additional members and permission policies to the roles. - -## Role - -### GET role - -GET - -Lists all roles. - -Returns: - -```json -[ - { - "memberReferences": ["user:default/adam"], - "name": "role:default/rbac_admin", - "metadata": { - "source": "configuration", - "description": null - } - }, - { - "memberReferences": [ - "group:default/backstage-community-authors", - "user:default/matt" - ], - "name": "role:default/test", - "metadata": { - "source": "csv-file", - "description": null - } - } -] -``` - ---- - -GET -ex. - -List the single role and the members associated with that role. - -Request Parameters: - -| Parameter name | Description | Type | -| -------------- | --------------------- | ------ | -| kind | role | String | -| namespace | Namespace of the role | String | -| name | name of the role | String | - -Returns: - -```json -[ - { - "memberReferences": [ - "group:default/backstage-community-authors", - "user:default/matt" - ], - "name": "role:default/test", - "metadata": { - "source": "csv-file", - "description": null - } - } -] -``` - ---- - -### POST role - -POST - -Creates a new role. - -Request Parameters: - -| Parameter name | Description | Type | -| -------------------- | ---------------------------------------------------------------- | ------ | -| memberReferences | users / groups to be added to the role `:/` | Array | -| name | name of the role | String | -| metadata.description | description of the role | String | - -body: - -```json -{ - "memberReferences": ["group:default/test"], - "name": "role:default/test_admin", - "metadata": { - "description": "This is a test admin role" - } -} -``` - -Returns a status code of 201 upon success. - ---- - -### PUT role - -PUT -ex. - -Updates a specified role. - -Request Parameters: - -| Parameter name | Description | Type | -| -------------- | --------------------- | ------ | -| kind | role | String | -| namespace | Namespace of the role | String | -| name | name of the role | String | - -Request Parameters for oldRole and newRole: - -| Parameter name | Description | Type | -| -------------------- | ---------------------------------------------------------------- | ------ | -| memberReferences | users / groups to be added to the role `:/` | Array | -| name | name of the role | String | -| metadata.description | description of the role | String | - -body: - -```json -{ - "oldRole": { - "memberReferences": ["group:default/test"], - "name": "role:default/test_admin", - "metadata": { - "description": "This is a test admin role" - } - }, - "newRole": { - "memberReferences": ["group:default/test", "user:default/test2"], - "name": "role:default/test_admin", - "metadata": { - "description": "This is a test admin role with a group and user" - } - } -} -``` - -Returns a status code of 200 upon success. - ---- - -### DELETE role - -DELETE -ex. - -Deletes a single user / group from a role. - -Request Parameters: - -| Parameter name | Description | Type | -| ---------------- | ---------------------------------------------------------------- | ------ | -| kind | role | String | -| namespace | Namespace of the role | String | -| name | name of the role | String | -| memberReferences | users / groups to be added to the role `:/` | String | -| name | name of the role | String | - -before: - -```json -{ - "memberReferences": ["group:default/test, user:default/test2"], - "name": "role:default/test_admin", - "metadata": { - "description": "This is a test admin role with a group and user" - } -} -``` - -after: - -```json -{ - "memberReferences": ["group:default/test"], - "name": "role:default/test_admin", - "metadata": { - "description": "This is a test admin role with a group and user" - } -} -``` - -Returns a status code of 204 upon success. - ---- - -DELETE -ex. - -Deletes a single role and all users associated with that role. - -Request Parameters: - -| Parameter name | Description | Type | -| -------------- | --------------------- | ------ | -| kind | role | String | -| namespace | Namespace of the role | String | -| name | name of the role | String | - -Returns a status code of 204 upon success. - ---- - -## Permission - -### GET permission - -GET - -Lists all permission polices. - -Returns: - -```json -[ - { - "entityReference": "role:default/test", - "permission": "catalog-entity", - "policy": "read", - "effect": "allow", - "metadata": { - "source": "csv-file" - } - }, - { - "entityReference": "role:default/test", - "permission": "catalog.entity.create", - "policy": "create", - "effect": "allow", - "metadata": { - "source": "csv-file" - } - }, - ... -] -``` - ---- - -GET -ex. - -List permission policies related to the specified entity reference `:/`. - -Request parameters: - -| Parameter name | Description | Type | -| -------------- | ----------------------- | ------ | -| kind | Kind of the entity | String | -| namespace | Namespace of the entity | String | -| name | Username of the entity | String | - -Returns: - -```json -[ - { - "entityReference": "role:default/test", - "permission": "catalog-entity", - "policy": "read", - "effect": "allow", - "metadata": { - "source": "csv-file" - } - }, - { - "entityReference": "role:default/test", - "permission": "catalog.entity.create", - "policy": "create", - "effect": "allow", - "metadata": { - "source": "csv-file" - } - } -] -``` - ---- - -### POST permission - -POST - -Creates one or more permission policies for a specified entity. - -Request parameters: - -| Parameter name | Description | Type | -| --------------- | ----------------------------------------------------------------------------- | ------ | -| entityReference | Entity `:/` | String | -| permission | Permission from a specific plugin, Resource type or name | String | -| policy | Policy action for the permission, `create`, `read`, `update`, `delete`, `use` | String | -| effect | `allow` or `deny` | String | - -body: - -```json -[ - { - "entityReference": "role:default/test", - "permission": "catalog-entity", - "policy": "delete", - "effect": "allow" - } -] -``` - -Returns a status code of 201 upon success. - ---- - -### PUT permission - -PUT -ex. - -Updates one or more permission policies for a specified entity. - -Request parameters: - -| Parameter name | Description | Type | -| -------------- | ----------------------- | ------ | -| kind | Kind of the entity | String | -| namespace | Namespace of the entity | String | -| name | Username of the entity | String | - -Request parameters for oldPolicy and newPolicy objects: - -| Parameter name | Description | Type | -| -------------- | ----------------------------------------------------------------------------- | ------ | -| permission | Permission from a specific plugin, Resource type or name | String | -| policy | Policy action for the permission, `create`, `read`, `update`, `delete`, `use` | String | -| effect | `allow` or `deny` | String | - -body: - -```json -{ - "oldPolicy": [ - { - "permission": "catalog-entity", - "policy": "read", - "effect": "allow" - }, - { - "permission": "catalog.entity.create", - "policy": "create", - "effect": "allow" - } - ], - "newPolicy": [ - { - "permission": "catalog-entity", - "policy": "read", - "effect": "deny" - }, - { - "permission": "policy-entity", - "policy": "create", - "effect": "allow" - } - ] -} -``` - -Returns a status code of 200 upon success. - ---- - -### Delete permission - -DELETE -ex. - -Deletes a permission policy of a specified entity. - -Returns a status code of 204 upon success. - ---- - -DELETE -ex. - -Deletes a group of permission policies of a specified entity. - -Request Parameters: - -| Parameter name | Description | Type | -| -------------- | ----------------------- | ------ | -| kind | Kind of the entity | String | -| namespace | Namespace of the entity | String | -| name | Username of the entity | String | - -body: - -```json -[ - { - "entityReference": "role:default/test", - "permission": "catalog-entity", - "policy": "delete", - "effect": "allow" - }, - { - "entityReference": "role:default/test", - "permission": "catalog-entity", - "policy": "update", - "effect": "allow" - } -] -``` - -Returns a status code of 204 upon success. - ---- - -## Plugin - -### GET plugin permission policies - -GET - -Lists all plugin permission policies from plugins installed in your Backstage instance. - -Returns: - -```json -[ - { - "pluginId": "catalog", - "policies": [ - { - "name": "catalog.entity.read", - "policy": "read", - "resourceType": "catalog-entity" - }, - { - "name": "catalog.entity.create", - "policy": "create" - }, - { - "name": "catalog.entity.delete", - "policy": "delete", - "resourceType": "catalog-entity" - }, - { - "name": "catalog.entity.refresh", - "policy": "update", - "resourceType": "catalog-entity" - }, - { - "name": "catalog.location.read", - "policy": "read" - }, - { - "name": "catalog.location.create", - "policy": "create" - }, - { - "name": "catalog.location.delete", - "policy": "delete" - } - ] - }, - ... -] -``` - ---- - -## Conditions - -Conditional permission policies are fairly complex. For more information on how to structure your conditional policies, consult our documentation on [conditions](./conditions.md). - -### GET conditional rules - -GET - -Provides conditional rule parameter schemas. - -```json -[ - { - "pluginId": "catalog", - "rules": [ - { - "name": "HAS_ANNOTATION", - "description": "Allow entities with the specified annotation", - "resourceType": "catalog-entity", - "paramsSchema": { - "type": "object", - "properties": { - "annotation": { - "type": "string", - "description": "Name of the annotation to match on" - }, - "value": { - "type": "string", - "description": "Value of the annotation to match on" - } - }, - "required": [ - "annotation" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "HAS_LABEL", - "description": "Allow entities with the specified label", - "resourceType": "catalog-entity", - "paramsSchema": { - "type": "object", - "properties": { - "label": { - "type": "string", - "description": "Name of the label to match on" - } - }, - "required": [ - "label" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "HAS_METADATA", - "description": "Allow entities with the specified metadata subfield", - "resourceType": "catalog-entity", - "paramsSchema": { - "type": "object", - "properties": { - "key": { - "type": "string", - "description": "Property within the entities metadata to match on" - }, - "value": { - "type": "string", - "description": "Value of the given property to match on" - } - }, - "required": [ - "key" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "HAS_SPEC", - "description": "Allow entities with the specified spec subfield", - "resourceType": "catalog-entity", - "paramsSchema": { - "type": "object", - "properties": { - "key": { - "type": "string", - "description": "Property within the entities spec to match on" - }, - "value": { - "type": "string", - "description": "Value of the given property to match on" - } - }, - "required": [ - "key" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "IS_ENTITY_KIND", - "description": "Allow entities matching a specified kind", - "resourceType": "catalog-entity", - "paramsSchema": { - "type": "object", - "properties": { - "kinds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of kinds to match at least one of" - } - }, - "required": [ - "kinds" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "IS_ENTITY_OWNER", - "description": "Allow entities owned by a specified claim", - "resourceType": "catalog-entity", - "paramsSchema": { - "type": "object", - "properties": { - "claims": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of claims to match at least one on within ownedBy" - } - }, - "required": [ - "claims" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - } - ] - } - ... -] -``` - ---- - -### POST condition - -POST - -Creates a new condition. - -Request Parameters: condition object in json format described above. - -body: - -```json -{ - "result": "CONDITIONAL", - "roleEntityRef": "role:default/test", - "pluginId": "catalog", - "resourceType": "catalog-entity", - "permissionMapping": ["read"], - "conditions": { - "rule": "IS_ENTITY_OWNER", - "resourceType": "catalog-entity", - "params": { - "claims": ["group:default/team-a"] - } - } -} -``` - -Returns a status code of 201 and json with id upon success: - -```json -{ - "id": 1 -} -``` - ---- - -### PUT condition - -PUT - -Update conditions by id. - -Request Parameters: condition object in json format described above. - -body: - -```json -{ - "result": "CONDITIONAL", - "roleEntityRef": "role:default/test", - "pluginId": "catalog", - "resourceType": "catalog-entity", - "permissionMapping": ["read"], - "conditions": { - "anyOf": [ - { - "rule": "IS_ENTITY_OWNER", - "resourceType": "catalog-entity", - "params": { - "claims": ["group:default/team-a"] - } - }, - { - "rule": "IS_ENTITY_KIND", - "resourceType": "catalog-entity", - "params": { - "kinds": ["Group"] - } - } - ] - } -} -``` - -Returns a status code of 200 upon success. - ---- - -### Get condition by id - -GET - -Returns condition by id: - -```json -{ - "id": 1, - "result": "CONDITIONAL", - "roleEntityRef": "role:default/test", - "pluginId": "catalog", - "resourceType": "catalog-entity", - "permissionMapping": ["read"], - "conditions": { - "anyOf": [ - { - "rule": "IS_ENTITY_OWNER", - "resourceType": "catalog-entity", - "params": { - "claims": ["group:default/team-a"] - } - }, - { - "rule": "IS_ENTITY_KIND", - "resourceType": "catalog-entity", - "params": { - "kinds": ["Group"] - } - } - ] - } -} -``` - -Returns a status code of 200 upon success. - ---- - -### GET conditions - -GET - -Returns lists all conditions: - -```json -[ - { - "id": 1, - "result": "CONDITIONAL", - "roleEntityRef": "role:default/test", - "pluginId": "catalog", - "resourceType": "catalog-entity", - "permissionMapping": ["read"], - "conditions": { - "anyOf": [ - { - "rule": "IS_ENTITY_OWNER", - "resourceType": "catalog-entity", - "params": { - "claims": ["group:default/team-a"] - } - }, - { - "rule": "IS_ENTITY_KIND", - "resourceType": "catalog-entity", - "params": { - "kinds": ["Group"] - } - } - ] - } - } -] -``` - -Returns a status code of 200 upon success. - ---- - -### DELETE condition by id - -DELETE - -Deletes condition by id. - -Returns a status code of 204 upon success. - ---- - -## Refresh permission policies provider API - -The API to update permissions allows triggering the Provider to refresh the permissions list. - -### POST RBAC permission policies - -POST - -Refreshes RBAC permission policies by provider id. - -Request Parameters: provider 'id' in the url path. - -Returns a status code of 200 upon success. - ---- - -## Plugin IDs that support the Backstage permission framework - -API to manage the list of permission IDs that support the Backstage permission framework at runtime without a server restart. This API is important to control rendering the plugin list in the UI. - -List plugins IDs stored in the object: - -| Parameter name | Description | Type | -| -------------- | ---------------- | ------------ | -| ids | list plugins IDs | String Array | - -### GET object with list plugin IDs - -GET - -Returns object with list plugin IDs: - -```json -{ - "ids": ["catalog", "permission"] -} -``` - -Returns a status code of 200 upon success. - ---- - -### POST object with list plugin IDs - -POST - -Add more plugins IDs defined in the request object. - -Request Parameters: object in json format described above. - -body: - -```json -{ - "ids": ["scaffolder"] -} -``` - -Returns a status code of 200 and json with actual object stored in the server: - -```json -{ - "ids": ["catalog", "permission", "scaffolder"] -} -``` - ---- - -### DELETE plugin IDs - -DELETE - -Delete plugins IDs defined in the request object. - -Request Parameters: object in json format described above. - -body: - -```json -{ - "ids": ["scaffolder"] -} -``` - -Returns a status code of 200 and json with actual object stored in the server: - -```json -{ - "ids": ["catalog", "permission"] -} -``` - -## HTTP status codes - -| Code | Descriptions | -| ---- | ----------------------------------------------- | -| 200 | Request was successful | -| 201 | New resource was successfully created | -| 204 | No additional content to send in response | -| 400 | Input Error | -| 401 | Lacks valid authentication | -| 403 | Refusal to authorize | -| 404 | Could not find resource | -| 409 | Conflict with current state and target resource | - ---- - -## Curl Request Examples - -Create role `role:default/test` for `group:default/example`: - -```bash -curl -X POST "http://localhost:7007/api/permission/roles" \ - -d '{ - "memberReferences": [ - "group:default/example" - ], - "name": "role:default/test", - "metadata": { - "description": "This is a test role" - } - }' \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $token" \ - -v -``` - -Create permission policy for `role:default/test`: - -```bash -curl -X POST "http://localhost:7007/api/permission/policies" \ - -d '[{ - "entityReference": "role:default/test", - "permission": "catalog-entity", - "policy": "read", - "effect": "allow" - }]' \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $token" \ - -v -``` - -Create conditional permission policy for `role:default/test`: - -```bash -curl -X POST "http://localhost:7007/api/permission/roles/conditions" \ - -d '{ - "result": "CONDITIONAL", - "roleEntityRef": "role:default/test", - "pluginId": "catalog", - "resourceType": "catalog-entity", - "permissionMapping": ["read"], - "conditions": { - "rule": "IS_ENTITY_OWNER", - "resourceType": "catalog-entity", - "params": { - "claims": ["group:default/backstage-community-authors"] - } - } - }' \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $token" \ - -v -``` diff --git a/plugins/rbac-backend/docs/audit-log.md b/plugins/rbac-backend/docs/audit-log.md deleted file mode 100644 index 04e9f800f7..0000000000 --- a/plugins/rbac-backend/docs/audit-log.md +++ /dev/null @@ -1,252 +0,0 @@ -# Audit logging - -The RBAC backend plugin supports audit logging with the help of the Auditor Service from [`@backstage/backend-plugin-api`](https://www.npmjs.com/package/@backstage/backend-plugin-api) package. Audit logging helps to track the latest changes and events from the RBAC plugin: - -- RBAC role changes; -- RBAC permissions changes; -- RBAC conditions changes; -- Changes causing modification of application configuration; -- Changes causing modification of the permission policy file; -- GET requests for RBAC permission information; -- User authorization results to RBAC resources. - -The RBAC backend plugin logging doesn't provide information about the actual state of the permissions. The actual state of RBAC permissions can be found in the RBAC UI. Audit logging provides information about what operations were performed, by whom, when, and on which resources. Each operation to audit is recorded as an event with an `eventId` that represents the logical group of the action, such as `role-write`. The event contains information about event id, RBAC permission changes, the actor who made these changes, time, severityLevel, some part of the request if applicable, response if applicable, and so on. You can use this information like a history of the RBAC operations. - -Notice: RBAC permissions and conditions are bound to RBAC roles. However, the RBAC backend plugin logs information about permissions and conditions with the help of separated log messages. That's because for now, the RBAC plugin has a separated API for RBAC roles, RBAC permissions, and RBAC conditions. - -## Audit log actor - -The audit log actor can be a real REST API user or the RBAC plugin itself. When the actor is a REST API user, then the RBAC plugin logs the user's IP, browser agent, and hostname. The RBAC plugin can also be the actor of the events. In this case, the actor has an actorId: "plugin:permission". In this case, the plugin typically applies changes from the configuration or permission policy file. Application configuration and permission policy files usually mount to the application deployment with the help of config maps. Unfortunately, the RBAC plugin cannot track who originally made modifications to these resources. But you can enable Kubernetes API audit log: https://kubernetes.io/docs/tasks/debug/debug-cluster/audit. Then you can match RBAC plugin audit log events to the events from Kubernetes logs by time. - -## Audit log format - -The RBAC plugin prints information to the backend log. The format of these messages is defined in the `@backstage/backend-plugin-api` library. Each audit log line contains the key "isAuditEvent". - -Example logged RBAC events: - -a) RBAC role created with corresponding basic permissions and conditional permission: - -```json -[backend]: 2025-03-25T17:24:17.438Z permission info permission.role-write isAuditEvent=true eventId="role-write" severityLevel="medium" actor={"actorId":"user:default/dzemanov","ip":"::1","hostname":"localhost","us -erAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"} request={"url":"/api/permission/roles","method":"POST"} meta={"actionType":"create", "source":"rest"} status= -"initiated" -[backend]: 2025-03-25T17:24:17.458Z permission info permission.role-write isAuditEvent=true eventId="role-write" severityLevel="medium" actor={"actorId":"user:default/dzemanov","ip":"::1","hostname":"localhost","us -erAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"} request={"url":"/api/permission/roles","method":"POST"} meta={"actionType":"create", "source":"rest","respons -e":{"status":201}, "roleEntityRef":"role:default/test","description":"some test role","author":"user:default/dzemanov","modifiedBy":"user:default/dzemanov","createdAt":"Tue, 25 Mar 2025 17:24:17 GMT","lastModified":"T -ue, 25 Mar 2025 17:24:17 GMT","members":["user:default/dzemanov"]} status="succeeded" - -[backend]: 2025-03-25T17:24:17.461Z permission info permission.policy-write isAuditEvent=true eventId="policy-write" severityLevel="medium" actor={"actorId":"user:default/dzemanov","ip":"::1","hostname":"localhost" -,"userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"} request={"url":"/api/permission/policies","method":"POST"} meta={"actionType":"create", "source":"rest"} -status="initiated" -[backend]: 2025-03-25T17:24:17.473Z permission info permission.policy-write isAuditEvent=true eventId="policy-write" severityLevel="medium" actor={"actorId":"user:default/dzemanov","ip":"::1","hostname":"localhost" -,"userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"} request={"url":"/api/permission/policies","method":"POST"} meta={"actionType":"create", "source":"rest"," -response":{"status":201},"policies":[["role:default/test","catalog.entity.read","read","allow"],["role:default/test","catalog.entity.create","create","allow"],["role:default/test","catalog.entity.refresh","update","a -llow"],["role:default/test","scaffolder.task.create","create","allow"],["role:default/test","scaffolder.task.read","read","allow"]]} status="succeeded" - -[backend]: 2025-03-25T17:24:17.476Z permission info permission.condition-write isAuditEvent=true eventId="condition-write" severityLevel="medium" actor={"actorId":"user:default/dzemanov","ip":"::1","hostname":"loca -lhost","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"} request={"url":"/api/permission/roles/conditions","method":"POST"} meta={"actionType":"create", "so -urce":"rest"} status="initiated" -[backend]: 2025-03-25T17:24:17.488Z permission info permission.condition-write isAuditEvent=true eventId="condition-write" severityLevel="medium" actor={"actorId":"user:default/dzemanov","ip":"::1","hostname":"loca -lhost","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"} request={"url":"/api/permission/roles/conditions","method":"POST"} meta={"actionType":"create", "so -urce":"rest","response":{"status":201},"condition":{"result":"CONDITIONAL","roleEntityRef":"role:default/test","pluginId":"catalog","resourceType":"catalog-entity","permissionMapping":["delete"],"conditions":{"rule": -"IS_ENTITY_OWNER","resourceType":"catalog-entity","params":{"claims":["group:default/team-a"]}}}} status="succeeded" -``` - -b) Check access user to application resource: - -```json -[backend]: 2025-03-25T17:24:29.154Z permission info permission.permission-evaluation isAuditEvent=true eventId="permission-evaluation" severityLevel="medium" actor={"actorId":"plugin:permission"} request=undefined meta={"userEntityRef":"user:default/dzemanov","permissionName":"scaffolder.task.create","action":"create"} status="initiated" -[backend]: 2025-03-25T17:24:29.171Z permission info permission.permission-evaluation isAuditEvent=true eventId="permission-evaluation" severityLevel="medium" actor={"actorId":"plugin:permission"} request=undefined meta={"userEntityRef":"user:default/dzemanov","permissionName":"scaffolder.task.create","action":"create","result":"ALLOW"} status="succeeded" - -[backend]: 2025-03-25T17:24:17.509Z permission info permission.permission-evaluation isAuditEvent=true eventId="permission-evaluation" severityLevel="medium" actor={"actorId":"plugin:permission"} request=undefined me -ta={"userEntityRef":"user:default/dzemanov","permissionName":"policy.entity.delete","action":"delete","resourceType":"policy-entity"} status="initiated" -[backend]: 2025-03-25T17:24:17.522Z permission info permission.permission-evaluation isAuditEvent=true eventId="permission-evaluation" severityLevel="medium" actor={"actorId":"plugin:permission"} request=undefined me -ta={"userEntityRef":"user:default/dzemanov","permissionName":"policy.entity.delete","action":"delete","resourceType":"policy-entity","result":"ALLOW"} status="succeeded" -``` - -Most audit log lines contain a metadata object. The RBAC plugin includes information about RBAC roles, permissions, conditions, and authorization results in this metadata. - -Notice: You need to properly configure the logger to see nested JSON objects in the audit log lines. - -## RBAC audit events - -The RBAC backend emits audit events for various operations. Events are grouped logically by `eventId`. Audit event begins in the `initiated` state. The event then transitions to either `succeeded` state or `failed` state. All events can contain `meta` field with additional information. Event that is `succeeded` or `failed` can contain additional data in its `meta` field, in addition to event `meta`. -Failed events contain `error` information. - -### Role Events - -- **`role-write`**: Modifies roles. - - **Role Event meta for `role-write`:** - - source: string (source emitting the event, `rest`, `csv-file`, `configuration`, `externalProviderPluginId`) - - actionType: string (further specifies type of modify action, `create`, `update`, `delete`, `create_or_update`) - - **Role Event fail/success meta for `role-write`:** - - source: string (source emitting the event, `rest`, `csv-file`, `configuration`, `externalProviderPluginId`) - - actionType: string (further specifies type of modify action, `create`, `update`, `delete`, `create_or_update`) - - roleEntityRef: string - - description?: string - - members: string[] - - Filter on `actionType`. - - **`create`**: Creates roles. (POST `/roles`, extension point `applyRoles`, `rbac_admin` role from `configuration`) - - **`update`**: Updates roles. (PUT `/roles`) - - **`delete`**: Deletes roles. (DELETE `/roles`, extension point `applyRoles`) - - **`create_or_update`**: Bulk creates or updates roles. (loading roles from `csv file`) - - **Role Event fail/success meta for `create_or_update`:** - - addedPolicies: string[][] - - updatedPolicies: string[][] - - failedPolicies: string[][] - -- **`role-read`**: Reads roles. (GET `/roles`) - - **Role Event meta for `role-read`:** - - source: string (source emitting the event, `rest`) - - queryType: string (specifies type of query, `all`, `by-role`) - - **Role Event fail/success meta for `role-read`:** - - source: string (source emitting the event, `rest`) - - queryType: string (specifies type of query, `all`, `by-role`) - - Filter on `queryType`. - - **`all`**: Read all roles. (GET `/roles`) - - **`by-role`**: Read concrete role. (GET `/roles/:kind/:namespace/:name`) - - **Role Event meta for `by-role`:** - - entityRef: string (role entity reference) - -### Permission Events - -- **`policy-write`**: Modifies permissions. - - **Permission Event meta for `policy-write`:** - - source: string (source emitting the event, `rest`, `csv-file`, `configuration`, `externalProviderPluginId`) - - actionType: string (further specifies type of modify action, `create`, `update`, `delete`) - - **Permission Event fail/success meta for `policy-write`:** - - source: string (source emitting the event, `rest`, `csv-file`, `configuration`, `externalProviderPluginId`) - - actionType: string (further specifies type of modify action, `create`, `update`, `delete`) - - policies: string[][] (modified permissions) - - Filter on `actionType`. - - **`create`**: Creates permissions. (POST `/policies`, extension point `applyPermissions`) - - **`update`**: Updates permissions. (PUT `/policies`) - - **`delete`**: Deletes permissions. (DELETE `/policies`, extension point `applyPermissions`) - -- **`policy-read`**: Reads permissions. (GET `/policies`) - - **Policy Event meta for `policy-read`:** - - source: string (source emitting the event, `rest`) - - queryType: string (specifies type of query, `all`, `by-role`, `by-query`) - - **Policy Event fail/success meta for `policy-read`:** - - source: string (source emitting the event, `rest`) - - queryType: string (specifies type of query, `all`, `by-role`, `by-query`) - - Filter on `queryType`. - - **`all`**: Read all policies. (GET `/policies`) - - **`by-role`**: Read all policies associated with a role. (GET `/policies/:kind/:namespace/:name`) - - **`by-query`**: Read all policies that match query filter criteria. (GET `/policies`) - - **Policy Event meta for `by-role`:** - - entityRef: string (role entity reference) - - **Policy Event meta for `by-query`:** - - query: string - -### Condition Events - -- **`condition-write`**: Modifies conditions. - - **Condition Event meta for `condition-write`:** - - source?: string (source emitting the event, `rest` or not included for conditions from `yaml-conditional-file`) - - actionType: string (further specifies type of modify action, `create`, `update`, `delete`) - - **Condition Event fail/success meta for `condition-write`:** - - source?: string (source emitting the event, `rest` or not included for conditions from `yaml-conditional-file`) - - actionType: string (further specifies type of modify action, `create`, `update`, `delete`) - - condition: RoleConditionalPolicyDecision<"create" | "read" | "update" | "delete" | "use"> - - Filter on `actionType`. - - **`create`**: Creates conditions. (POST `/roles/conditions`, extension point `applyPermissions`) - - **`update`**: Updates conditions. (PUT `/roles/conditions`) - - **`delete`**: Deletes conditions. (DELETE `/roles/conditions`, extension point `applyPermissions`) - -- **`condition-read`**: Reads conditions. (GET `/roles/conditions`) - - **Condition Event meta for `condition-read`:** - - source: string (source emitting the event, `rest`) - - queryType: string (specifies type of query, `all`, `by-id`, `by-query`) - - **Condition Event fail/success meta for `condition-read`:** - - source: string (source emitting the event, `rest`) - - queryType: string (specifies type of query, `all`, `by-id`, `by-query`) - - Filter on `queryType`. - - **`all`**: Read all conditions. (GET `/roles/conditions`) - - **`by-id`**: Read condition with id. (GET `/roles/conditions/:id`) - - **`by-query`**: Read all conditions that match query filter criteria. (GET `/policies`) - - **Condition Event meta for `by-id`:** - - id: string (condition id) - - **Condition Event meta for `by-query`:** - - query: string - -### Conditional File Events - -- **`conditional-policies-file-not-found`**: Conditional policies file was not found. - -- **`conditional-policies-file-change`**: Conditional policies file changed. - -### Permission Evaluation Events - -- **`permission-evaluation`**: Evaluation of permissions. - - **Permission Evaluation Event meta for `permission-evaluation`:** - - userEntityRef: string - - permissionName: string - - action: PermissionAction - - resourceType?: string - - decision?: PolicyDecision - - **Permission Evaluation Success/Fail meta for `permission-evaluation`:** - - userEntityRef: string - - permissionName: string - - action: PermissionAction - - resourceType?: string - - decision?: PolicyDecision - - result: AuthorizeResult - -### Plugins Events - -- **`plugin-policies-read`**: List available plugin permission policies. (GET `/plugins/policies`) - -- **`condition-rules-read`**: List conditional rule parameter schema. (GET `/plugins/condition-rules`) - -**Plugins Event meta:** - -- source: string (source emitting the event, `rest`) - -**Plugins Event fail/success meta:** - -- source: string (source emitting the event, `rest`) - -### Plugin IDs events - --**`plugin-ids-read`**: Lists the plugins that support the Backstage permission framework. - --**`plugin-ids-write`**: Updates the list of plugins that support the Backstage permission framework. - -**Plugins IDs Event meta:** - -- source: string (source emitting the event, `rest`) - -**Plugins IDs Event fail/success meta:** - -- source: string (source emitting the event, `rest`) - -for `plugin-ids-write` will be included also: - -- ids: string[] diff --git a/plugins/rbac-backend/docs/conditions.md b/plugins/rbac-backend/docs/conditions.md deleted file mode 100644 index 985e6b5551..0000000000 --- a/plugins/rbac-backend/docs/conditions.md +++ /dev/null @@ -1,388 +0,0 @@ -# Conditional Permission Policies - -The Backstage permission framework provides conditions, and the RBAC backend plugin supports this feature. Conditions work like content filters for Backstage resources (provided by plugins). The RBAC backend API stores conditions assigned to the role in the database. When a user requests access to the frontend resources, the RBAC backend API searches for corresponding conditions and delegates the condition for this resource to the corresponding plugin by its plugin ID. If a user was assigned to multiple roles, and each of these roles contains its own condition, the RBAC backend merges conditions using the anyOf criteria. - -The corresponding plugin analyzes conditional parameters and makes a decision about which part of the content the user should see. Consequently, the user can view not all resource content but only some allowed parts. The RBAC backend plugin supports conditions bounded to the RBAC role. - -A Backstage condition can be a simple condition with a rule and parameters. But also a Backstage condition could consists of a parameter or an array of parameters joined by criteria. The list of supported conditional criteria includes: - -- allOf -- anyOf -- not - -The plugin defines the supported condition parameters. API users can retrieve the conditional object schema from the RBAC API endpoint to determine how to build a condition JSON object and utilize it through the RBAC backend plugin API. - -The structure of the condition JSON object is as follows: - -| Json field | Description | Type | -| ----------------- | --------------------------------------------------------------------- | ------------ | -| result | Always has the value "CONDITIONAL" | String | -| roleEntityRef | String entity reference to the RBAC role ('role:default/dev') | String | -| pluginId | Corresponding plugin ID (e.g., "catalog") | String | -| permissionMapping | Array permission actions (['read', 'update', 'delete']) | String array | -| resourceType | Resource type provided by the plugin (e.g., "catalog-entity") | String | -| conditions | Condition JSON with parameters or array parameters joined by criteria | JSON | - -To get the available conditional rules that can be used to create conditional permission policies, use the GET API request `api/permission/plugins/condition-rules` as seen below. - -GET - -Provides condition parameters schemas. - -```json -[ - { - "pluginId": "catalog", - "rules": [ - { - "name": "HAS_ANNOTATION", - "description": "Allow entities with the specified annotation", - "resourceType": "catalog-entity", - "paramsSchema": { - "type": "object", - "properties": { - "annotation": { - "type": "string", - "description": "Name of the annotation to match on" - }, - "value": { - "type": "string", - "description": "Value of the annotation to match on" - } - }, - "required": [ - "annotation" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "HAS_LABEL", - "description": "Allow entities with the specified label", - "resourceType": "catalog-entity", - "paramsSchema": { - "type": "object", - "properties": { - "label": { - "type": "string", - "description": "Name of the label to match on" - } - }, - "required": [ - "label" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "HAS_METADATA", - "description": "Allow entities with the specified metadata subfield", - "resourceType": "catalog-entity", - "paramsSchema": { - "type": "object", - "properties": { - "key": { - "type": "string", - "description": "Property within the entities metadata to match on" - }, - "value": { - "type": "string", - "description": "Value of the given property to match on" - } - }, - "required": [ - "key" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "HAS_SPEC", - "description": "Allow entities with the specified spec subfield", - "resourceType": "catalog-entity", - "paramsSchema": { - "type": "object", - "properties": { - "key": { - "type": "string", - "description": "Property within the entities spec to match on" - }, - "value": { - "type": "string", - "description": "Value of the given property to match on" - } - }, - "required": [ - "key" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "IS_ENTITY_KIND", - "description": "Allow entities matching a specified kind", - "resourceType": "catalog-entity", - "paramsSchema": { - "type": "object", - "properties": { - "kinds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of kinds to match at least one of" - } - }, - "required": [ - "kinds" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - }, - { - "name": "IS_ENTITY_OWNER", - "description": "Allow entities owned by a specified claim", - "resourceType": "catalog-entity", - "paramsSchema": { - "type": "object", - "properties": { - "claims": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of claims to match at least one on within ownedBy" - } - }, - "required": [ - "claims" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - } - ] - } - ... -] -``` - -From this condition schema, the RBAC backend API user can determine how to build a condition JSON object. - -For example, consider a condition without criteria: displaying catalogs only if the user is a member of the owner group. The Catalog plugin schema "IS_ENTITY_OWNER" can be utilized to achieve this goal. To construct the condition JSON object based on this schema, the following information should be used: - -- rule: the parameter name is "IS_ENTITY_OWNER" in this case -- resourceType: "catalog-entity" -- criteria: in this example, criteria are not used since we need to use only one conditional parameter -- params: from the schema, it is evident that it should be an object named "claims" with a string array. This string array constitutes a list of user or group string entity references. - -Based on the above schema condition is: - -```json -{ - "rule": "IS_ENTITY_OWNER", - "resourceType": "catalog-entity", - "params": { - "claims": ["group:default/team-a"] - } -} -``` - -To utilize this condition to the RBAC REST api you need to wrap it with more info - -```json -{ - "result": "CONDITIONAL", - "roleEntityRef": "role:default/test", - "pluginId": "catalog", - "resourceType": "catalog-entity", - "permissionMapping": ["read"], - "conditions": { - "rule": "IS_ENTITY_OWNER", - "resourceType": "catalog-entity", - "params": { - "claims": ["group:default/team-a"] - } - } -} -``` - -**Example condition with criteria**: display catalogs only if user is a member of owner group "OR" display list of all catalog user groups. - -We can reuse previous condition parameter to display catalogs only for owner. Also we can use one more condition "IS_ENTITY_KIND" to display catalog groups for any user: - -- rule - the parameter name is "IS_ENTITY_KIND" in this case. -- resource type: "catalog-entity". -- criteria - "anyOf". -- params - from the schema, it is evident that it should be an object named "kinds" with string array. This string array is a list of catalog kinds. It should be array with single element "Group" in our case. - -Based on the above schema: - -```json -{ - "anyOf": [ - { - "rule": "IS_ENTITY_OWNER", - "resourceType": "catalog-entity", - "params": { - "claims": ["group:default/team-a"] - } - }, - { - "rule": "IS_ENTITY_KIND", - "resourceType": "catalog-entity", - "params": { - "kinds": ["Group"] - } - } - ] -} -``` - -To utilize this condition to the RBAC REST api you need to wrap it with more info: - -```json -{ - "result": "CONDITIONAL", - "roleEntityRef": "role:default/test", - "pluginId": "catalog", - "resourceType": "catalog-entity", - "permissionMapping": ["read"], - "conditions": { - "anyOf": [ - { - "rule": "IS_ENTITY_OWNER", - "resourceType": "catalog-entity", - "params": { - "claims": ["group:default/team-a"] - } - }, - { - "rule": "IS_ENTITY_KIND", - "resourceType": "catalog-entity", - "params": { - "kinds": ["Group"] - } - } - ] - } -} -``` - -## Conditional Policy Aliases - -The RBAC-backend plugin allows for the use of aliases in the conditional policy rule parameters. These aliases are dynamically replaced with corresponding values during the policy evaluation process. Each alias is prefixed with a `$` sign to denote its special function. - -### Supported Aliases - -1. **`$currentUser`**: - - **Description**: This alias is replaced with the user entity reference for the user currently requesting access to the resource. - - **Example**: If the user "Tom" from the "default" namespace is requesting access, `$currentUser` will be replaced with `user:default/tom`. - -2. **`$ownerRefs`**: - - **Description**: This alias is replaced with ownership references, typically in the form of an array. The array usually contains the user entity reference and the user's parent group entity reference. - - **Example**: For a user "Tom" who belongs to "team-a", `$ownerRefs` will be replaced with `['user:default/tom', 'group:default/team-a']`. - -### Example of a Conditional Policy Object with Alias - -This condition should allow members of the `role:default/developer` to delete only their own catalogs and no others: - -```json -{ - "result": "CONDITIONAL", - "roleEntityRef": "role:default/developer", - "pluginId": "catalog", - "resourceType": "catalog-entity", - "permissionMapping": ["delete"], - "conditions": { - "rule": "IS_ENTITY_OWNER", - "resourceType": "catalog-entity", - "params": { - "claims": ["$currentUser"] - } - } -} -``` - -## Examples of Conditional Policies - -Below are a few examples that can be used on some of the Janus IDP plugins. These can help in determining how based to define conditional policies - -### Keycloak plugin - -```json -{ - "result": "CONDITIONAL", - "roleEntityRef": "role:default/developer", - "pluginId": "catalog", - "resourceType": "catalog-entity", - "permissionMapping": ["update", "delete"], - "conditions": { - "not": { - "rule": "HAS_ANNOTATION", - "resourceType": "catalog-entity", - "params": { "annotation": "keycloak.org/realm", "value": "" } - } - } -} -``` - -This example will prevent users in the role `role:default/developer` from updating or deleting users that ingested into the catalog from the Keycloak plugin. - -Notice the use of the annotation `keycloak.org/realm` requires the value of `` - -### Quay Actions - -```json -{ - "result": "CONDITIONAL", - "roleEntityRef": "role:default/developer", - "pluginId": "scaffolder", - "resourceType": "scaffolder-action", - "permissionMapping": ["use"], - "conditions": { - "not": { - "rule": "HAS_ACTION_ID", - "resourceType": "scaffolder-action", - "params": { "actionId": "quay:create-repository" } - } - } -} -``` - -This example will prevent users from using the Quay scaffolder action if they are a part of the role `role:default/developer`. - -Notice, we use the `permissionMapping` field with `use`. This is because the `scaffolder-action` resource type permission does not have a permission policy. More information can be found in our documentation on [permissions](./permissions.md). - -**NOTE**: We do not support the ability to run conditions in parallel during creation. An example can be found below, notice that `anyOf` and `not` are on the same level. Consider making separate condition requests, or nest your conditions based on the available criteria. - -```json -{ - "anyOf": [ - { - "rule": "IS_ENTITY_OWNER", - "resourceType": "catalog-entity", - "params": { - "claims": ["group:default/team-a"] - } - }, - { - "rule": "IS_ENTITY_KIND", - "resourceType": "catalog-entity", - "params": { - "kinds": ["Group"] - } - } - ], - "not": { - "rule": "IS_ENTITY_KIND", - "resourceType": "catalog-entity", - "params": { "kinds": ["Api"] } - } -} -``` diff --git a/plugins/rbac-backend/docs/group-hierarchy.md b/plugins/rbac-backend/docs/group-hierarchy.md deleted file mode 100644 index 13f4a3bb28..0000000000 --- a/plugins/rbac-backend/docs/group-hierarchy.md +++ /dev/null @@ -1,236 +0,0 @@ -# Group Hierarchy - -RBAC access control is configured by defining roles and their associated permission policies, which -are then assigned to users or groups. Leveraging group hierarchy can greatly simplify RBAC management, -making it more scalable and flexible. - -## Group-Based Role Assignment - -Role can be assigned to a specific group. If a user is a member of that group, or a member of any of -its child groups, the role (and its associated permissions) will automatically be applied to that user. - -Examples: - -- Sam will inherit `role:default/test` from `team-group` via `subteam-group`. - - ![Group hierarchy diagram with sam as a member of subteam-group that is child of team-group](./images/group-hierarchy-1.svg) - - ```yaml - # catalog-entity.yaml - apiVersion: backstage.io/v1alpha1 - kind: Group - metadata: - name: team-group - spec: - type: team - children: [subteam-group] - --- - apiVersion: backstage.io/v1alpha1 - kind: Group - metadata: - name: subteam-group - spec: - type: team - children: [] - parent: team-group - --- - apiVersion: backstage.io/v1alpha1 - kind: User - metadata: - name: sam - spec: - memberOf: - - subteam-group - ``` - - ```CSV - g, group:default/team-group, role:default/test - p, role:default/test, catalog-entity, read, allow - ``` - -- Sam will have `role:default/test` via `team-group`. - - ![Group hierarchy diagram with sam as a member of team-group](./images/group-hierarchy-2.svg) - - ```yaml - # catalog-entity.yaml - apiVersion: backstage.io/v1alpha1 - kind: User - metadata: - name: sam - --- - apiVersion: backstage.io/v1alpha1 - kind: Group - metadata: - name: team-group - spec: - type: team - children: [] - members: - - sam - ``` - - ```CSV - g, group:default/team-group, role:default/test - p, role:default/test, catalog-entity, read, allow - ``` - -- Sam will inherit `role:default/role-a` from `group-a` and `role:default/role-c` from `group-c`. - - ![Group hierarchy diagram with sam as a member of group-b and group-c, group-a is parent of group-b](./images/group-hierarchy-3.svg) - - ```yaml - # catalog-entity.yaml - apiVersion: backstage.io/v1alpha1 - kind: Group - metadata: - name: group-a - spec: - type: team - children: [group-b] - --- - apiVersion: backstage.io/v1alpha1 - kind: Group - metadata: - name: group-b - spec: - type: team - children: [] - --- - apiVersion: backstage.io/v1alpha1 - kind: Group - metadata: - name: group-c - spec: - type: team - children: [] - --- - apiVersion: backstage.io/v1alpha1 - kind: User - metadata: - name: sam - spec: - memberOf: - - group-b - - group-c - ``` - - ```CSV - g, group:default/group-a, role:default/role-a - g, group:default/group-c, role:default/role-c - p, role:default/role-a, catalog-entity, read, allow - p, role:default/role-c, catalog-entity, delete, allow - ``` - -## Managing Group Hierarchy Depth - -While group hierarchy provides powerful inheritance features, it can have performance implications. -Organizations with potentially complex group hierarchy can specify `maxDepth` configuration value, -that will ensure that the RBAC plugin will stop at a certain depth when building user graphs. - -```YAML -permission: - enabled: true - rbac: - maxDepth: 1 -``` - -The `maxDepth` must be greater than or equal to 0 to ensure that the graphs are built correctly. Also the graph -will be built with a hierarchy of 1 + maxDepth. - -A value of 0 for maxDepth disables the group inheritance feature. - -## Non-Existent Groups in the Hierarchy - -For group hierarchy to function, groups don't need to be present in the catalog as long as the group -has an existing parent group or is a member of existing group or an existing user is a member of -that group. -(Note that this does not work with in-memory database.) - -Examples: - -- Sam will inherit `role:default/test`, although `team-group` isn't explicitly defined. - - ![Group hierarchy diagram with sam as a member of team-group](./images/group-hierarchy-2.svg) - - ```yaml - # catalog-entity.yaml - apiVersion: backstage.io/v1alpha1 - kind: User - metadata: - name: sam - spec: - memberOf: - - team-group - ``` - - ```CSV - g, group:default/team-group, role:default/test - p, role:default/test, catalog-entity, read, allow - ``` - -- Sam will inherit `role:default/test` via `subteam-group` that is a child of `team-group`, although `subteam-group` isn't explicitly defined. - - ![Group hierarchy diagram with sam as a member of subteam-group that is child of team-group](./images/group-hierarchy-1.svg) - - ```yaml - # catalog-entity.yaml - apiVersion: backstage.io/v1alpha1 - kind: User - metadata: - name: sam - spec: - memberOf: - - subteam-group - --- - apiVersion: backstage.io/v1alpha1 - kind: Group - metadata: - name: team-group - spec: - type: team - children: [subteam-group] - ``` - - ```CSV - g, group:default/team-group, role:default/test - p, role:default/test, catalog-entity, read, allow - ``` - -- Sam will inherit `role:default/test` via `group-d` <- `group-c` <- `group-b` <- `group-a`, - although `group-d` and `group-b` aren't explicitly defined. - - ![Group hierarchy diagram with sam as a member of group-a with parent group-b with parent group-c with parent group-d](./images/group-hierarchy-4.svg) - - ```yaml - # catalog-entity.yaml - apiVersion: backstage.io/v1alpha1 - kind: User - metadata: - name: sam - spec: - memberOf: - - group-d - --- - apiVersion: backstage.io/v1alpha1 - kind: Group - metadata: - name: group-c - spec: - type: team - children: [group-d] - parent: group-b - --- - apiVersion: backstage.io/v1alpha1 - kind: Group - metadata: - name: group-a - spec: - type: team - children: [group-b] - ``` - - ```CSV - g, group:default/group-a, role:default/test - p, role:default/test, catalog-entity, read, allow - ``` diff --git a/plugins/rbac-backend/docs/images/group-hierarchy-1.svg b/plugins/rbac-backend/docs/images/group-hierarchy-1.svg deleted file mode 100644 index 573bb26c68..0000000000 --- a/plugins/rbac-backend/docs/images/group-hierarchy-1.svg +++ /dev/null @@ -1 +0,0 @@ -

role:default/test

group:default/team-group

group:default/subteam-group

user:default/sam

\ No newline at end of file diff --git a/plugins/rbac-backend/docs/images/group-hierarchy-2.svg b/plugins/rbac-backend/docs/images/group-hierarchy-2.svg deleted file mode 100644 index 86156c7fcd..0000000000 --- a/plugins/rbac-backend/docs/images/group-hierarchy-2.svg +++ /dev/null @@ -1 +0,0 @@ -

role:default/test

group:default/team-group

user:default/sam

\ No newline at end of file diff --git a/plugins/rbac-backend/docs/images/group-hierarchy-3.svg b/plugins/rbac-backend/docs/images/group-hierarchy-3.svg deleted file mode 100644 index 62f819f0d0..0000000000 --- a/plugins/rbac-backend/docs/images/group-hierarchy-3.svg +++ /dev/null @@ -1 +0,0 @@ -

role:default/role-a

role:default/role-c

group:default/group-a

group:default/group-b

user:default/sam

group:default/group-c

\ No newline at end of file diff --git a/plugins/rbac-backend/docs/images/group-hierarchy-4.svg b/plugins/rbac-backend/docs/images/group-hierarchy-4.svg deleted file mode 100644 index 3fd7d85ee8..0000000000 --- a/plugins/rbac-backend/docs/images/group-hierarchy-4.svg +++ /dev/null @@ -1 +0,0 @@ -

role:default/test

group:default/group-a

group:default/group-b

group:default/group-c

group:default/group-d

user:default/sam

diff --git a/plugins/rbac-backend/docs/multitenancy.md b/plugins/rbac-backend/docs/multitenancy.md deleted file mode 100644 index f398ea8096..0000000000 --- a/plugins/rbac-backend/docs/multitenancy.md +++ /dev/null @@ -1,176 +0,0 @@ -# Multitenancy - -The RBAC backend plugin has support for multitenancy through the use of its own conditional rule `IS_OWNER`. This rule will allow users the ability to perform actions against roles and permissions in which they are an owner. An example where this conditional rule could be helpful is where admins would like to grant team leads the ability to manage their own roles and permissions for their team. - -## Conditional rule - -```yaml - { - "pluginId": "permission", - "rules": [ - { - "name": "IS_OWNER", - "description": "Should allow access to RBAC roles and Permissions through ownership", - "resourceType": "policy-entity", - "paramsSchema": { - "type": "object", - "properties": { - "owners": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of entity refs to match against" - } - }, - "required": [ - "owners" - ], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - } - } - ] - }, -``` - -## Example - -### Admin - -The following can be used as an example on how to set up multitenancy from the admin's point of view. In this example, we are going to create a role, assign a user, and assign the `catalog.entity.read` permission as well as a conditional policy for permission policies and roles. - -1. Create a new role for the team lead: - - ```bash - curl -X POST 'http://localhost:7007/api/permission/roles' \ - --header "Authorization: Bearer $ADMIN_TOKEN" \ - --header "Content-Type: application/json" \ - --data '{ - "memberReferences": ["user:default/team_lead"], - "name": "role:default/team_lead", - "metadata": { - "description": "This is an example team lead role" - } - }' - ``` - -2. Create a permission policy to grant the team lead read access to the catalog and create access to the RBAC backend plugin: - - ```bash - curl -X POST 'http://localhost:7007/api/permission/policies' \ - --header "Authorization: Bearer $ADMIN_TOKEN" \ - --header "Content-Type: application/json" \ - --data '[ - { - "entityReference": "role:default/team_lead", - "permission": "policy-entity", - "policy": "create", - "effect": "allow" - }, - { - "entityReference": "role:default/team_lead", - "permission": "catalog-entity", - "policy": "read", - "effect": "allow" - } - ]' - ``` - -3. Create a conditional policy to grant the team lead access to the RBAC backend plugin: - - ```bash - curl -X POST 'http://localhost:7007/api/permission/roles/conditions' \ - --header "Authorization: Bearer $ADMIN_TOKEN" \ - --header "Content-Type: application/json" \ - --data '{ - "result": "CONDITIONAL", - "pluginId": "permission", - "resourceType": "policy-entity", - "conditions": { - "rule": "IS_OWNER", - "resourceType": "policy-entity", - "params": { - "owners": [ - "user:default/team_lead" - ] - } - }, - "roleEntityRef": "role:default/team_lead", - "permissionMapping": [ - "read", - "update", - "delete" - ] - }' - ``` - -### Team Lead - -The following is an example from the team lead's point of view after they have been granted conditional access to the RBAC backend plugin. In this example: - -- We will check that we are unable to see any roles prior to performing any actions. -- Create a role, assign a user, and assign the `catalog.entity.read` permission. -- And finally check that we are able to read the new role and permission policy after creation. - -1. Query the roles to see that we are unable to see any other roles: - - ```bash - curl -X GET 'http://localhost:7007/api/permission/roles' \ - --header "Authorization: Bearer $TEAM_LEAD_TOKEN" - ``` - -2. Query the permission policies to see that we are unable to see any other policies: - - ```bash - curl -X GET 'http://localhost:7007/api/permission/policies' \ - --header "Authorization: Bearer $TEAM_LEAD_TOKEN" - ``` - -3. Create a new role for your team, ensuring you set yourself as the owner: - - **NOTE**: Ownership is automatically assigned to the user during creation but can be updated at anytime. - - ```bash - curl -X POST 'http://localhost:7007/api/permission/roles' \ - --header "Authorization: Bearer $TEAM_LEAD_TOKEN" \ - --header "Content-Type: application/json" \ - --data '{ - "memberReferences": ["user:default/team_member"], - "name": "role:default/team_a", - "metadata": { - "description": "This is an example team_a role", - "owner": "user:default/team_lead" - } - }' - ``` - -4. Create a permission policy for your new role: - - ```bash - curl -X POST 'http://localhost:7007/api/permission/policies' \ - --header "Authorization: Bearer $ADMIN_TOKEN" \ - --header "Content-Type: application/json" \ - --data '[ - { - "entityReference": "role:default/team_a", - "permission": "catalog-entity", - "policy": "read", - "effect": "allow" - } - ]' - ``` - -5. Re-query the roles to see our new created role: - - ```bash - curl -X GET 'http://localhost:7007/api/permission/roles' \ - --header "Authorization: Bearer $TEAM_LEAD_TOKEN" - ``` - -6. Re-query the permission policies to see our new created policy: - - ```bash - curl -X GET 'http://localhost:7007/api/permission/policies' \ - --header "Authorization: Bearer $TEAM_LEAD_TOKEN" - ``` diff --git a/plugins/rbac-backend/docs/permissions.md b/plugins/rbac-backend/docs/permissions.md deleted file mode 100644 index c15dc72515..0000000000 --- a/plugins/rbac-backend/docs/permissions.md +++ /dev/null @@ -1,153 +0,0 @@ -# Example permissions within Showcase / RHDH - -Note: The requirements section primarily pertains to the frontend and may not be strictly necessary for the backend. - -When defining a permission for the RBAC Backend plugin to consume, follow these guidelines: - -- Permission policies defined using the name of the permission will have higher priority over permission policies that are defined using the resource type. - - Example: - - ```CSV - p, role:default/myrole, catalog-entity, read, allow - p, role:default/myrole, catalog.entity.read, read, deny - g, user:default/myuser, role:default/myrole - ``` - - Where 'myuser' will have a deny for reading catalog entities, because the permission name takes priority over the permission resource type. - -- If the permission does not have a policy associated with it, use the keyword `use` in its place. - - Example: `p, role:default/test, kubernetes.proxy, use, allow` - -## Resource Type vs Basic Named Permissions - -There are two types of permissions within Backstage that can be defined using the RBAC Backend plugin. These are resource permissions and basic named permissions. The difference between the two is whether or not a permission has a resource type. Resource type permissions can be defined either using their associated resource type or their name. Basic named permissions must use their name. - -Basic name permissions are simple permissions that handle most use cases for plugins. These permissions on require a name and an attribute during creation. While the name and attribute for the basic named permission are required, the actions under the attributes are optional. These actions are what we consider policies within the RBAC Backend plugin. - -- Example of the `catalog.location.read` permission and how it would be defined using the RBAC Backend plugin: - - ```ts - export const catalogLocationReadPermission = createPermission({ - name: 'catalog.location.read', - attributes: { - action: 'read', - }, - }); - ``` - - ```CSV - p, role:default/myrole, catalog.location.read, read, allow - g, user:default/myuser, role:default/myrole - ``` - -Resource type permissions on the other hand are basic named permissions with a resource type. These permissions are typically associated with conditional permission rules based on that particular resource type. We can define these permissions using either their name or resource type. - -- Example of the `catalog.entity.read` permission and two ways that we can define its permissions using the RBAC Backend plugin: - - ```ts - export const RESOURCE_TYPE_CATALOG_ENTITY = 'catalog-entity'; - - export const catalogEntityReadPermission = createPermission({ - name: 'catalog.entity.read', - attributes: { - action: 'read', - }, - resourceType: RESOURCE_TYPE_CATALOG_ENTITY, - }); - ``` - - ```CSV - p, role:default/myrole, catalog.entity.read, read, allow - g, user:default/myuser, role:default/myrole - - p, role:default/another-role, catalog-entity, read, allow - g, user:default/another-user, role:default/another-role - ``` - -## Catalog - -| Name | Resource Type | Policy | Description | Requirements | -| ----------------------- | -------------- | ------ | ------------------------------------------------------- | ----------------------- | -| catalog.entity.read | catalog-entity | read | Allows the user to read from the catalog | X | -| catalog.entity.create | | create | Allows the user to create catalog entities | catalog.location.create | -| catalog.entity.refresh | catalog-entity | update | Allows the user to refresh one or more catalog entities | catalog.entity.read | -| catalog.entity.delete | catalog-entity | delete | Allows the user to delete one or more catalog entities | catalog.entity.read | -| catalog.location.read | | read | Allows the user to read one or more catalog locations | catalog.entity.read | -| catalog.location.create | | create | Allows the user to create one or more catalog locations | catalog.entity.create | -| catalog.location.delete | | delete | Allows the user to delete one or more catalog locations | catalog.entity.delete | - -## Jenkins - -| Name | Resource Type | Policy | Description | Requirements | -| --------------- | -------------- | ------ | ---------------------------------------------------------- | ------------------- | -| jenkins.execute | catalog-entity | update | Allows the user to execute an action in the Jenkins plugin | catalog.entity.read | - -## Kubernetes - -| Name | Resource Type | Policy | Description | Requirements | -| ------------------------- | ------------- | ------ | ----------------------------------------------------------------------------------------------------------- | ------------------- | -| kubernetes.clusters.read | | read | Allows the user to read Kubernetes clusters information under `/clusters` | catalog.entity.read | -| kubernetes.resources.read | | read | Allows the user to read Kubernetes resources information under `/services/:serviceId` and `/resources` | catalog.entity.read | -| kubernetes.proxy | | | Allows the user to access the proxy endpoint (ability to read pod logs and events within Showcase and RHDH) | catalog.entity.read | - -## RBAC - -| Name | Resource Type | Policy | Description | Requirements | -| -------------------- | ------------- | ------ | ----------------------------------------------------- | ------------ | -| policy.entity.read | policy-entity | read | Allows the user to read permission policies / roles | X | -| policy.entity.create | policy-entity | create | Allows the user to create permission policies / roles | X | -| policy.entity.update | policy-entity | update | Allow the user to update permission policies / roles | X | -| policy.entity.delete | policy-entity | delete | Allow the user to delete permission policies / roles | X | - -## Scaffolder - -| Name | Resource Type | Policy | Description | Requirements | -| ---------------------------------- | ------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| scaffolder.action.execute | scaffolder-action | | Allows the execution of an action from a template | scaffolder.template.parameter.read, scaffolder.template.step.read | -| scaffolder.template.parameter.read | scaffolder-template | read | Allows the user to read parameters of a template | scaffolder.template.step.read | -| scaffolder.template.step.read | scaffolder-template | read | Allows the user to read steps of a template | scaffolder.template.paramater.read | -| scaffolder.task.create | | create | This permission is used to authorize actions that involve the creation of tasks in the scaffolder | scaffolder.template.parameter.read, scaffolder.template.step.read | -| scaffolder.task.read | | read | This permission is used to authorize actions that involve reading one or more tasks in the scaffolder and reading logs of tasks | scaffolder.template.parameter.read, scaffolder.template.step.read | -| scaffolder.task.cancel | | use | This permission is used to authorize actions that involve the cancellation of tasks in the scaffolder | scaffolder.template.parameter.read, scaffolder.template.step.read | -| scaffolder.template.management | | use | Allows a user or role to access frontend template management features, including editing, previewing, and trying templates, forms, and custom fields. | | - -## OCM - -| Name | Resource Type | Policy | Description | Requirements | -| ---------------- | ------------- | ------ | ----------------------------------------------------------------- | ------------ | -| ocm.entity.read | | read | Allows the user to read from the ocm plugin | X | -| ocm.cluster.read | | read | Allows the user to read the cluster information in the ocm plugin | X | - -## Tekton - -| Name | Resource Type | Policy | Description | Requirements | -| ------------------------- | ------------- | ------ | ------------------------------------------------------------------------------------------------------------------ | ------------------- | -| kubernetes.clusters.read | | read | Allows the user to read Kubernetes clusters information under `/clusters` | catalog.entity.read | -| kubernetes.resources.read | | read | Allows the user to read Kubernetes resources information under `/services/:serviceId` and `/resources` | catalog.entity.read | -| kubernetes.proxy | | | Allows the user to access the proxy endpoint (ability to read tekton pod logs and events within Showcase and RHDH) | catalog.entity.read | - -## Topology - -| Name | Resource Type | Policy | Description | Requirements | -| ------------------------- | ------------- | ------ | ----------------------------------------------------------------------------------------------------------- | ------------------- | -| kubernetes.clusters.read | | read | Allows the user to read Kubernetes clusters information under `/clusters` | catalog.entity.read | -| kubernetes.resources.read | | read | Allows the user to read Kubernetes resources information under `/services/:serviceId` and `/resources` | catalog.entity.read | -| kubernetes.proxy | | | Allows the user to access the proxy endpoint (ability to read pod logs and events within Showcase and RHDH) | catalog.entity.read | - -## Argocd - -| Name | Resource Type | Policy | Description | Requirements | -| ---------------- | ------------- | ------ | ----------------------------------------- | ------------------- | -| argocd.view.read | | read | Allows the user to view the argocd plugin | catalog.entity.read | - -## Quay - -| Name | Resource Type | Policy | Description | Requirements | -| -------------- | ------------- | ------ | --------------------------------------- | ------------------- | -| quay.view.read | | read | Allows the user to view the quay plugin | catalog.entity.read | - -## Bulk Import - -| Name | Resource Type | Policy | Description | Requirements | -| ----------- | ------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------ | -| bulk.import | bulk-import | | Allows the user to access the bulk import endpoints (listing all repositories and organizations accessible by all GitHub integrations, as well as managing the import requests, ...) | X | diff --git a/plugins/rbac-backend/docs/providers.md b/plugins/rbac-backend/docs/providers.md deleted file mode 100644 index a3f6c6b0bd..0000000000 --- a/plugins/rbac-backend/docs/providers.md +++ /dev/null @@ -1,350 +0,0 @@ -# RBAC Providers - -The RBAC plugins also has the ability to apply roles and permissions from third party access management tools through the use of the RBAC extension points. These extension points allow you to create a backend plugin module that connects your third part access management tool to the RBAC backend plugin. In this documentation, we will discuss how to create a simple RBAC backend module that will be used to apply roles and permissions. - -## Getting started - -Our first step is to create an RBAC backend module using the following command: - -```bash -yarn new -``` - -This will start an interactive setup to create a new plugin. The following are what will need to be selected to create the new plugin module: - -```bash -? What do you want to create? backend-module - A new backend module -? Enter the ID of the plugin [required] permission -? Enter the ID of the module [required] test -? Enter an owner to add to CODEOWNERS [optional] -``` - -This will then create a simple backend plugin module that is ready to updated based on your needs. - -## Creating the Test Provider - -Add the dependencies `@backstage-community/plugin-rbac-node` and `@backstage/config` to your newly created backend module using `yarn --cwd plugins/rbac-backend-module-test add @backstage-community/plugin-rbac-node @backstage/config`. - -Add the test provider to the newly created plugin module `/plugins/rbac-backend-module-test/TestProvider.ts` and populate it with the following: - -```ts -import { LoggerService } from '@backstage/backend-plugin-api'; - -import { - RBACProvider, - RBACProviderConnection, -} from '@backstage-community/plugin-rbac-node'; - -export class TestProvider implements RBACProvider { - private readonly logger: LoggerService; - private connection?: RBACProviderConnection; - - private constructor(logger: LoggerService) { - this.logger = logger.child({ - target: this.getProviderName(), - }); - } - - // The name of the provider, used to distinguish between multiple providers - getProviderName(): string { - return `testProvider`; - } - - // Used to connect the RBACProvider to the RBAC backend plugin - async connect(connection: RBACProviderConnection): Promise {} - - // Used to manually refresh the RBACProvider using an endpoint available in the RBAC backend plugin - async refresh(): Promise {} -} -``` - -Now, we will include a `run` method that will add a new role and permissions (one simple and one conditional) to the RBAC backend plugin through the use of the extension points. - -```ts -export class TestProvider implements RBACProvider { - // Addition code above - private async run(): Promise { - if (!this.connection) { - throw new Error('Not initialized'); - } - - const roles: string[][] = [ - ['user:default/tony', 'role:default/test-provider-role'], - ]; - const permissions: string[][] = [ - ['role:default/test-provider-role', 'catalog-entity', 'read', 'allow'], - ]; - - const conditionalPermissions: RoleConditionalPolicyDecision[] = - [ - { - id: 0, // The id is ignored, so it can be any number - result: 'CONDITIONAL', - roleEntityRef: 'role:default/test-provider-role', - pluginId: 'catalog', - resourceType: 'catalog-entity', - permissionMapping: [ - { name: 'catalog.entity.delete', action: 'delete' }, - ], - conditions: { - rule: 'HAS_LABEL', - resourceType: 'catalog-entity', - params: { - label: 'role', - value: 'deletable', - }, - }, - }, - ]; - - await this.connection.applyRoles(roles); - await this.connection.applyPermissions(permissions); - await this.connection.applyConditionalPermissions(conditionalPermissions); - } -} -``` - -Next, we will provider a scheduler option so that we can ensure our provider will be periodically synced. But first we want to include an option to read this schedule from the `app-config`. - -```ts -import { - LoggerService, - readSchedulerServiceTaskScheduleDefinitionFromConfig, - SchedulerServiceTaskScheduleDefinition, -} from '@backstage/backend-plugin-api'; -import { Config } from '@backstage/config'; - -// Additional imports above - -export class TestProvider implements RBACProvider { - private readonly logger: LoggerService; - private connection?: RBACProviderConnection; - - private constructor( - logger: LoggerService, - schedulerServiceTaskRunner: SchedulerServiceTaskRunner, - ) { - this.logger = logger.child({ - target: this.getProviderName(), - }); - } - - static fromConfig( - config: Config, - options: { - logger: LoggerService; - schedule?: SchedulerServiceTaskRunner; - scheduler?: SchedulerService; - }, - ): TestProvider { - const providerSchedule = readProviderConfig(config); - let schedulerServiceTaskRunner; - - if (options.scheduler && providerSchedule) { - schedulerServiceTaskRunner = - options.scheduler.createScheduledTaskRunner(providerSchedule); - } else if (options.schedule) { - schedulerServiceTaskRunner = options.schedule; - } else { - throw new Error('Neither schedule nor scheduler is provided.'); - } - - return new TestProvider(options.logger, schedulerServiceTaskRunner); - } - // Additional code below -} - -function readProviderConfig( - config: Config, -): SchedulerServiceTaskScheduleDefinition | undefined { - const rbacConfig = config.getOptionalConfig('permission.rbac.providers.test'); - if (!rbacConfig) { - return undefined; - } - - const schedule = rbacConfig.has('schedule') - ? readSchedulerServiceTaskScheduleDefinitionFromConfig( - rbacConfig.getConfig('schedule'), - ) - : undefined; - - return schedule; -} -``` - -We can then began to create our schedule function that will ensure we sync based on the schedule that is provider. - -```ts -// Additional imports above -export class TestProvider implements RBACProvider { - private readonly logger: LoggerService; - private connection?: RBACProviderConnection; - private readonly scheduleFn: () => Promise; - - private constructor( - logger: LoggerService, - schedulerServiceTaskRunner: SchedulerServiceTaskRunner, - ) { - this.logger = logger.child({ - target: this.getProviderName(), - }); - - this.scheduleFn = this.createScheduleFN(schedulerServiceTaskRunner); - } - - // Additional code - - // Creates our schedule function that will periodically call our run method - private createScheduleFN( - schedulerServiceTaskRunner: SchedulerServiceTaskRunner, - ): () => Promise { - return async () => { - const taskId = `${this.getProviderName()}:run`; - return schedulerServiceTaskRunner.run({ - id: taskId, - fn: async () => { - try { - await this.run(); - } catch (error: any) { - this.logger.error(`Error occurred, here is the error ${error}`); - } - }, - }); - }; - } -} -``` - -After setting up the scheduler, we can supply the option to manually refresh the module. - -```ts -// Additional imports above - -export class TestProvider implements RBACProvider { - // Addition code - - // Used to manually refresh the RBACProvider using an endpoint available in the RBAC backend plugin - async refresh(): Promise { - try { - await this.run(); - } catch (error: any) { - this.logger.error(`Error occurred, here is the error ${error}`); - } - } -} -``` - -Finally, we just need to supply the logic for the connection. - -```ts -// Additional imports above - -export class TestProvider implements RBACProvider { - // Addition code - - // Used to connect the RBACProvider to the RBAC backend plugin - async connect(connection: RBACProviderConnection): Promise { - this.connection = connection; - this.scheduleFn(); - } -} -``` - -## Updating the Module - -Our final step is to update the dependencies that will be supplied and add the provider in `module.ts`. - -```ts -import { - coreServices, - createBackendModule, -} from '@backstage/backend-plugin-api'; - -import { rbacProviderExtensionPoint } from '@backstage-community/plugin-rbac-node'; - -import { TestProvider } from './TestProvider'; - -/** - * The test backend module for the rbac plugin. - * - * @alpha - */ -export const rbacModuleTest = createBackendModule({ - pluginId: 'permission', - moduleId: 'test', - register(reg) { - reg.registerInit({ - deps: { - logger: coreServices.logger, - rbac: rbacProviderExtensionPoint, - scheduler: coreServices.scheduler, - config: coreServices.rootConfig, - }, - async init({ logger, rbac, scheduler, config }) { - rbac.addRBACProvider( - TestProvider.fromConfig(config, { - logger, - scheduler: scheduler, - schedule: scheduler.createScheduledTaskRunner({ - frequency: { minutes: 30 }, - timeout: { minutes: 3 }, - }), - }), - ); - }, - }); - }, -}); -``` - -## Testing your newly created backend module - -Install the provider and add it to `packages/backend/src/index.ts`. - -```bash -yarn --cwd packages/app add @backstage-community/plugin-rbac-backend-module-test -``` - -```ts -backend.add( - import('@backstage-community/plugin-rbac-backend-module-test/alpha'), -); -``` - -Configure the test provider in the `app-config`. - -```yaml -permission: - rbac: - providers: - test: - schedule: - frequency: { minutes: 1 } - timeout: { minutes: 1 } - initialDelay: { seconds: 1 } -``` - -This will set the provider schedule to apply the roles and permissions every minute. - -Finally, to test the manual refresh capability update the config to adjust the frequency of the schedule. - -```yaml -permission: - rbac: - providers: - test: - schedule: - frequency: { minutes: 10 } - timeout: { minutes: 1 } - initialDelay: { seconds: 1 } -``` - -10 Minutes should give you enough time to manually trigger refresh. - -Call the refresh endpoint. - -```bash -curl -X POST "http://localhost:7007/api/permission/refresh/testProvider" -H "Authorization: Bearer $token" -v -``` - -Should return a 200. diff --git a/plugins/rbac-backend/knexfile.js b/plugins/rbac-backend/knexfile.js deleted file mode 100644 index c0245f5723..0000000000 --- a/plugins/rbac-backend/knexfile.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// To create new migration file use: "yarn knex migrate:make migrations", -// open generated new migration file and edit it to complete code. -// To run new migration use: "yarn knex migrate:make some_file_name" - -module.exports = { - client: 'better-sqlite3', - connection: ':memory:', - useNullAsDefault: true, - migrations: { - directory: './migrations', - }, -}; diff --git a/plugins/rbac-backend/knip-report.md b/plugins/rbac-backend/knip-report.md deleted file mode 100644 index 2661c35327..0000000000 --- a/plugins/rbac-backend/knip-report.md +++ /dev/null @@ -1,2 +0,0 @@ -# Knip report - diff --git a/plugins/rbac-backend/migrations/20231015161232_migrations.js b/plugins/rbac-backend/migrations/20231015161232_migrations.js deleted file mode 100644 index e084a54824..0000000000 --- a/plugins/rbac-backend/migrations/20231015161232_migrations.js +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -exports.up = async function up(knex) { - await knex.schema.createTable('policy-conditions', table => { - table.increments('id').primary(); - table.string('result'); - table.string('pluginId'); - table.string('resourceType'); - // Conditions is potentially long json. - // In the future maybe we can use `json` or `jsonb` type instead of `text`: - // table.json('conditions') or table.jsonb('conditions'). - // But let's start with text type. - // Data type "text" can be unlimited by size for Postgres. - // Also postgres has a lot of build in features for this data type. - table.text('conditionsJson'); - }); -}; - -/** - * down - reverts(undo) migration. - * - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = async function down(knex) { - await knex.schema.dropTable('policy-conditions'); -}; diff --git a/plugins/rbac-backend/migrations/20231212224526_migrations.js b/plugins/rbac-backend/migrations/20231212224526_migrations.js deleted file mode 100644 index 18daaa19d3..0000000000 --- a/plugins/rbac-backend/migrations/20231212224526_migrations.js +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -exports.up = async function up(knex) { - const casbinDoesExist = await knex.schema.hasTable('casbin_rule'); - const policyMetadataDoesExist = await knex.schema.hasTable('policy-metadata'); - let policies = []; - let groupPolicies = []; - - if (casbinDoesExist) { - policies = await knex - .select('*') - .from('casbin_rule') - .where('ptype', 'p') - .then(listPolicies => { - const allPolicies = []; - for (const policy of listPolicies) { - const { v0, v1, v2, v3 } = policy; - allPolicies.push(`[${v0}, ${v1}, ${v2}, ${v3}]`); - } - return allPolicies; - }); - groupPolicies = await knex - .select('*') - .from('casbin_rule') - .where('ptype', 'g') - .then(listGroupPolicies => { - const allGroupPolicies = []; - for (const groupPolicy of listGroupPolicies) { - const { v0, v1 } = groupPolicy; - allGroupPolicies.push(`[${v0}, ${v1}]`); - } - return allGroupPolicies; - }); - } - - if (!policyMetadataDoesExist) { - await knex.schema - .createTable('policy-metadata', table => { - table.increments('id').primary(); - table.string('policy').primary(); - table.string('source'); - }) - .then(async () => { - const metadata = []; - for (const policy of policies) { - metadata.push({ source: 'legacy', policy: policy }); - } - if (metadata.length > 0) { - await knex.table('policy-metadata').insert(metadata); - } - }) - .then(async () => { - const metadata = []; - for (const groupPolicy of groupPolicies) { - metadata.push({ source: 'legacy', policy: groupPolicy }); - } - if (metadata.length > 0) { - await knex.table('policy-metadata').insert(metadata); - } - }); - } -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = async function down(knex) { - await knex.schema.dropTable('policy-metadata'); -}; diff --git a/plugins/rbac-backend/migrations/20231221113214_migrations.js b/plugins/rbac-backend/migrations/20231221113214_migrations.js deleted file mode 100644 index 6fbbc621da..0000000000 --- a/plugins/rbac-backend/migrations/20231221113214_migrations.js +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -exports.up = async function up(knex) { - const casbinDoesExist = await knex.schema.hasTable('casbin_rule'); - const roleMetadataDoesExist = await knex.schema.hasTable('role-metadata'); - const groupPolicies = new Set(); - - if (casbinDoesExist) { - await knex - .select('*') - .from('casbin_rule') - .where('ptype', 'g') - .then(listGroupPolicies => { - for (const groupPolicy of listGroupPolicies) { - const { v1 } = groupPolicy; - groupPolicies.add(v1); - } - }); - } - - if (!roleMetadataDoesExist) { - await knex.schema - .createTable('role-metadata', table => { - table.increments('id').primary(); - table.string('roleEntityRef').primary(); - table.string('source'); - }) - .then(async () => { - const metadata = []; - for (const groupPolicy of groupPolicies) { - metadata.push({ source: 'legacy', roleEntityRef: groupPolicy }); - } - if (metadata.length > 0) { - await knex.table('role-metadata').insert(metadata); - } - }); - } -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = async function down(knex) { - await knex.schema.dropTable('role-metadata'); -}; diff --git a/plugins/rbac-backend/migrations/20240201144429_migrations.js b/plugins/rbac-backend/migrations/20240201144429_migrations.js deleted file mode 100644 index 143a4a6364..0000000000 --- a/plugins/rbac-backend/migrations/20240201144429_migrations.js +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -exports.up = async function up(knex) { - const isRoleMetaDataExist = await knex.schema.hasTable('role-metadata'); - if (isRoleMetaDataExist) { - await knex.schema.alterTable('role-metadata', table => { - table.string('description'); - }); - } -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = async function down(knex) { - const isRoleMetaDataExist = await knex.schema.hasTable('role-metadata'); - if (isRoleMetaDataExist) { - await knex.schema.alterTable('role-metadata', table => { - table.dropColumn('description'); - }); - } -}; diff --git a/plugins/rbac-backend/migrations/20240215154456_migrations.js b/plugins/rbac-backend/migrations/20240215154456_migrations.js deleted file mode 100644 index 9df4f5e3d2..0000000000 --- a/plugins/rbac-backend/migrations/20240215154456_migrations.js +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -exports.up = async function up(knex) { - const casbinDoesExist = await knex.schema.hasTable('casbin_rule'); - const policyMetadataExist = await knex.schema.hasTable('policy-metadata'); - const roleMetadataExist = await knex.schema.hasTable('role-metadata'); - - if (casbinDoesExist && policyMetadataExist) { - const policyMetadataColumns = await knex('policy-metadata').select( - 'id', - 'policy', - ); - - const policiesToCheck = policyMetadataColumns.map(metadataColumn => { - const policy = metadataColumn.policy - .replace(/\[/g, '') - .replace(/\]/g, '') - .split(',') - .map(str => str.trim()); - return { policy, id: metadataColumn.id }; - }); - - const existingPolicies = await knex('casbin_rule') - .whereIn( - 'v0', - policiesToCheck.map(policyToCheck => policyToCheck.policy[0]), - ) - .whereIn( - 'v1', - policiesToCheck.map(policyToCheck => policyToCheck.policy[1]), - ) - .andWhere(query => { - query - .where(innerQuery => { - innerQuery.whereNotNull('v2').whereIn( - 'v2', - policiesToCheck - .filter(policy => policy.policy.length === 4) - .map(policy => policy.policy[2]), - ); - }) - .orWhereNull('v2'); - }) - .andWhere(query => { - query - .where(innerQuery => { - innerQuery.whereNotNull('v3').whereIn( - 'v3', - policiesToCheck - .filter(policy => policy.policy.length === 4) - .map(policy => policy.policy[3]), - ); - }) - .orWhereNull('v3'); - }) - .select('v0', 'v1', 'v2', 'v3'); - - const existingPoliciesSet = new Set( - existingPolicies.map(policy => - policy.v2 - ? `${policy.v0},${policy.v1},${policy.v2},${policy.v3}` - : `${policy.v0},${policy.v1}`, - ), - ); - - const policiesToDelete = policiesToCheck.filter( - policyToCheck => !existingPoliciesSet.has(policyToCheck.policy.join(',')), - ); - - if (policiesToDelete.length > 0) { - await knex('policy-metadata') - .whereIn( - 'id', - policiesToDelete.map(policyToDel => policyToDel.id), - ) - .del(); - console.log( - `Deleted inconsistent policy metadata ${JSON.stringify( - policiesToDelete, - )} from 'policy-metadata' table.`, - ); - } - } - - if (casbinDoesExist && roleMetadataExist) { - const roleMetadataColumns = await knex('role-metadata').select( - 'id', - 'roleEntityRef', - ); - const roleMetadata = roleMetadataColumns.map(rm => { - return { roleEntityRef: rm.roleEntityRef, id: rm.id }; - }); - const existingPoliciesForRoles = await knex('casbin_rule') - .orWhereIn( - 'v1', - roleMetadata.map(rm => rm.roleEntityRef), - ) - .select('v1'); - - const existingRoles = new Set( - existingPoliciesForRoles.map(policy => policy.v1), - ); - const rolesMetadataToDelete = roleMetadata.filter( - rm => !existingRoles.has(rm.roleEntityRef), - ); - - if (rolesMetadataToDelete.length > 0) { - await knex('role-metadata') - .whereIn( - 'id', - rolesMetadataToDelete.map(rm => rm.id), - ) - .del(); - console.log( - `Deleted inconsistent role metadata ${JSON.stringify( - rolesMetadataToDelete, - )} from 'role-metadata' table.`, - ); - } - } -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = function down(_knex) { - // do nothing -}; diff --git a/plugins/rbac-backend/migrations/20240308134410_migrations.js b/plugins/rbac-backend/migrations/20240308134410_migrations.js deleted file mode 100644 index f5b5ca08ab..0000000000 --- a/plugins/rbac-backend/migrations/20240308134410_migrations.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -exports.up = async function up(knex) { - const policyConditionsExist = await knex.schema.hasTable('policy-conditions'); - - if (policyConditionsExist) { - // We drop policy condition table, because we decided to rework this feature - // and bound policy condition to the role - await knex.schema.dropTable('policy-conditions'); - } -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = async function down(_knex) {}; diff --git a/plugins/rbac-backend/migrations/20240308134941_migrations.js b/plugins/rbac-backend/migrations/20240308134941_migrations.js deleted file mode 100644 index 0516e48691..0000000000 --- a/plugins/rbac-backend/migrations/20240308134941_migrations.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -exports.up = async function up(knex) { - await knex.schema.createTable('role-condition-policies', table => { - table.increments('id').primary(); - table.string('roleEntityRef'); - table.string('result'); - table.string('pluginId'); - table.string('resourceType'); - table.string('permissions'); - // Conditions is potentially long json. - // In the future maybe we can use `json` or `jsonb` type instead of `text`: - // table.json('conditions') or table.jsonb('conditions'). - // But let's start with text type. - // Data type "text" can be unlimited by size for Postgres. - // Also postgres has a lot of build in features for this data type. - table.text('conditionsJson'); - }); -}; - -/** - * down - reverts(undo) migration. - * - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = async function down(knex) { - await knex.schema.dropTable('policy-conditions'); -}; diff --git a/plugins/rbac-backend/migrations/20240404111242_migrations.js b/plugins/rbac-backend/migrations/20240404111242_migrations.js deleted file mode 100644 index 5fda96eccd..0000000000 --- a/plugins/rbac-backend/migrations/20240404111242_migrations.js +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -exports.up = async function up(knex) { - const isRoleMetaDataExist = await knex.schema.hasTable('role-metadata'); - if (isRoleMetaDataExist) { - await knex.schema.alterTable('role-metadata', table => { - table.string('author'); - table.string('modifiedBy'); - table.dateTime('createdAt'); - table.dateTime('lastModified'); - }); - - await knex('role-metadata') - .update({ - description: - 'The default permission policy for the admin role allows for the creation, deletion, updating, and reading of roles and permission policies.', - author: 'application configuration', - modifiedBy: 'application configuration', - lastModified: new Date().toUTCString(), - }) - .where('roleEntityRef', 'role:default/rbac_admin'); - } -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = async function down(knex) { - const isRoleMetaDataExist = await knex.schema.hasTable('role-metadata'); - if (isRoleMetaDataExist) { - await knex.schema.alterTable('role-metadata', table => { - table.dropColumn('author'); - table.dropColumn('modifiedBy'); - table.dropColumn('createdAt'); - table.dropColumn('lastModified'); - }); - } -}; diff --git a/plugins/rbac-backend/migrations/20240611092136_migrations.js b/plugins/rbac-backend/migrations/20240611092136_migrations.js deleted file mode 100644 index 65ad48666c..0000000000 --- a/plugins/rbac-backend/migrations/20240611092136_migrations.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -exports.up = async function up(knex) { - const policyMetadataExist = await knex.schema.hasTable('policy-metadata'); - - if (policyMetadataExist) { - await knex.schema.dropTable('policy-metadata'); - } -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = function down(_knex) {}; diff --git a/plugins/rbac-backend/migrations/20241108093910_migrations.js b/plugins/rbac-backend/migrations/20241108093910_migrations.js deleted file mode 100644 index a84da53e25..0000000000 --- a/plugins/rbac-backend/migrations/20241108093910_migrations.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -exports.up = async function up(knex) { - const casbinExists = await knex.schema.hasTable('casbin_rule'); - if (casbinExists) { - await knex('casbin_rule') - .whereNotNull('v0') - .where(function groups() { - this.where('v0', 'like', 'user:%').orWhere('v0', 'like', 'group:%'); - }) - .update({ - v0: knex.raw('LOWER(??)', ['v0']), - }); - } -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = async function down(_knex) {}; diff --git a/plugins/rbac-backend/migrations/20250305155143_migration.js b/plugins/rbac-backend/migrations/20250305155143_migration.js deleted file mode 100644 index 4cad332234..0000000000 --- a/plugins/rbac-backend/migrations/20250305155143_migration.js +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -exports.up = async function up(knex) { - const roleMetaDataExist = await knex.schema.hasTable('role-metadata'); - const casbinExists = await knex.schema.hasTable('casbin_rule'); - if (roleMetaDataExist) { - // Add the owner field to the role-metadata field - await knex.schema.alterTable('role-metadata', table => { - table.string('owner'); - }); - } - - if (casbinExists && roleMetaDataExist) { - // Get the policies for resource type policy-entity and action create - const policyEntityCreateRoles = await knex - .from('casbin_rule') - .where('v1', 'policy-entity') - .where('v2', 'create') - .pluck('v0'); - - // Ensure that we are only updating the rest api and configuration policies only - const rolesFromConfigAndRest = await knex - .from('role-metadata') - .whereNot('source', 'csv-file') - .whereIn('roleEntityRef', policyEntityCreateRoles) - .pluck('roleEntityRef'); - - // Update the polices from the config and rest from resource type policy-entity and action create - // to policy.entity.create and action create - await knex - .from('casbin_rule') - .whereIn('v0', rolesFromConfigAndRest) - .where('v2', 'create') - .update({ v1: 'policy.entity.create' }); - - const rolesFromCSV = await knex - .from('role-metadata') - .where('source', 'csv-file') - .whereIn('roleEntityRef', policyEntityCreateRoles) - .pluck('roleEntityRef'); - - console.log( - `The following roles: ${rolesFromCSV} have the permission policy 'policy-entity, create' and will need to be updated within the CSV file to 'policy.entity.create, create'`, - ); - } -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = async function down(knex) { - const isRoleMetaDataExist = await knex.schema.hasTable('role-metadata'); - if (isRoleMetaDataExist) { - await knex.schema.alterTable('role-metadata', table => { - table.dropColumn('owner'); - }); - } -}; diff --git a/plugins/rbac-backend/migrations/20250509110032_migrations.js b/plugins/rbac-backend/migrations/20250509110032_migrations.js deleted file mode 100644 index d7f42f1a8c..0000000000 --- a/plugins/rbac-backend/migrations/20250509110032_migrations.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -exports.up = async function up(knex) { - await knex.schema.createTable('extra_permission_enabled_plugins', table => { - table.string('pluginId').primary(); - }); -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = async function down(knex) { - await knex.schema.dropTable('extra_permission_enabled_plugins'); -}; diff --git a/plugins/rbac-backend/migrations/20260216100000_add_is_default_to_role_metadata.js b/plugins/rbac-backend/migrations/20260216100000_add_is_default_to_role_metadata.js deleted file mode 100644 index 51ed564a2a..0000000000 --- a/plugins/rbac-backend/migrations/20260216100000_add_is_default_to_role_metadata.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -exports.up = async function up(knex) { - const roleMetadataExist = await knex.schema.hasTable('role-metadata'); - if (roleMetadataExist) { - const hasColumn = await knex.schema.hasColumn('role-metadata', 'isDefault'); - if (!hasColumn) { - await knex.schema.alterTable('role-metadata', table => { - table.boolean('isDefault').defaultTo(false); - }); - } - } -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = async function down(knex) { - const roleMetadataExist = await knex.schema.hasTable('role-metadata'); - if (roleMetadataExist) { - const hasColumn = await knex.schema.hasColumn('role-metadata', 'isDefault'); - if (hasColumn) { - await knex.schema.alterTable('role-metadata', table => { - table.dropColumn('isDefault'); - }); - } - } -}; diff --git a/plugins/rbac-backend/openapi.yaml b/plugins/rbac-backend/openapi.yaml deleted file mode 100644 index 459c808ff9..0000000000 --- a/plugins/rbac-backend/openapi.yaml +++ /dev/null @@ -1,760 +0,0 @@ -openapi: 3.0.0 -info: - title: RBAC Backend API - description: >- - Harnesses the power of the Backstage permission framework to empower you - with robust role-based access control capabilities within your Backstage - environment. - version: latest -servers: - - url: 'http://localhost:7007' -components: - schemas: - RoleResponse: - type: array - items: - type: object - properties: - memberReferences: - type: array - description: Users / groups to be added to the role :/. - items: - type: string - name: - type: string - description: The name of the role. - metadata: - type: object - description: Metadata about the role. - properties: - author: - type: string - description: The author of the role. - createdAt: - type: string - description: The date and time the role was created. - lastModified: - type: string - description: The date and time the role was last modified. - modifiedBy: - type: string - description: The user who last modified the role. - source: - type: string - description: The source from which the role was defined. - description: - type: string - description: A description of the role.``` - Role: - type: object - properties: - memberReferences: - type: array - description: Users / groups to be added to the role :/. - items: - type: string - name: - type: string - description: The name of the role. - metadata: - type: object - description: Metadata about the role. - properties: - description: - type: string - description: A description of the role. - Condition: - type: object - oneOf: - - properties: - anyOf: - type: array - items: - $ref: '#/components/schemas/Condition' - required: [anyOf] - - properties: - allOf: - type: array - items: - $ref: '#/components/schemas/Condition' - required: [allOf] - - properties: - not: - $ref: '#/components/schemas/Condition' - required: [not] - - properties: - rule: - type: string - resourceType: - type: string - params: - type: object - required: [rule, resourceType, params] - PropertyObject: - type: object - properties: - type: - type: string - description: - type: string - required: [type, description] - PropertyArray: - type: object - properties: - type: - type: string - description: - type: string - items: - type: object - properties: - type: - type: string - required: [type, description, items] - - PermissionPolicy: - type: object - properties: - entityReference: - type: string - description: Entity :/. - permission: - type: string - description: Permission from a specific plugin, Resource type or name - policy: - type: string - description: 'Policy action for the permission: create, read, update, delete, use' - effect: - type: string - description: allow or deny - - PermissionResponse: - type: object - properties: - entityReference: - type: string - description: Entity :/. - permission: - type: string - description: Permission from a specific plugin, Resource type or name - policy: - type: string - description: 'Policy action for the permission: create, read, update, delete, use' - effect: - type: string - description: allow or deny - metadata: - type: object - description: Metadata about the role. - properties: - source: - type: string - description: The source from which the permission policy was defined. - PluginIds: - type: object - properties: - ids: - type: array - description: List of plugin ids, which support Backstage permission framework. - items: - type: string - - parameters: - nameParam: - name: name - in: path - description: Name of the role. - required: true - schema: - type: string - namespaceParam: - name: namespace - in: path - description: Namespace of the role. - required: true - schema: - type: string - kindParam: - name: kind - in: path - description: role - required: true - schema: - type: string - memberReferencesParam: - name: memberReferences - in: query - description: users / groups to be deleted from the role :/ - required: false - schema: - type: array - description: Users / groups to be added to the role :/. - items: - type: string -paths: - /api/permission/roles: - get: - description: Lists all roles - responses: - '200': - description: Request was successful. - content: - application/json: - schema: - type: object - '$ref': '#/components/schemas/RoleResponse' - '403': - description: Refusal to authorize - post: - description: Creates a new role. - requestBody: - required: true - content: - application/json: - schema: - '$ref': '#/components/schemas/Role' - responses: - '201': - description: New resource was successfully created. - '400': - description: Invalid role definition. - '403': - description: Refusal to authorize - '409': - description: Conflict with current state and target resource. - /api/permission/roles/{kind}/{namespace}/{name}: - get: - description: List the single role and the members associated with that role. - parameters: - - $ref: '#/components/parameters/nameParam' - - $ref: '#/components/parameters/namespaceParam' - - $ref: '#/components/parameters/kindParam' - responses: - '200': - description: Request was successful. - content: - application/json: - schema: - '$ref': '#/components/schemas/RoleResponse' - '403': - description: Refusal to authorize - '404': - description: Could not find resource - put: - description: Updates a specified role. - parameters: - - $ref: '#/components/parameters/nameParam' - - $ref: '#/components/parameters/namespaceParam' - - $ref: '#/components/parameters/kindParam' - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - oldRole: - '$ref': '#/components/schemas/Role' - newRole: - '$ref': '#/components/schemas/Role' - responses: - '200': - description: Request was successful. - '400': - description: Input Error - '403': - description: Refusal to authorize - '404': - description: Could not find resource - '409': - description: Conflict with current state and target resource. - delete: - description: >- - Deletes a single role and all users associated with that role if no - memberReferences is specified. Otherwise deletes the single user/group - specified in the memberReferences parameter. - parameters: - - $ref: '#/components/parameters/nameParam' - - $ref: '#/components/parameters/namespaceParam' - - $ref: '#/components/parameters/kindParam' - - $ref: '#/components/parameters/memberReferencesParam' - responses: - '204': - description: ok - '403': - description: Refusal to authorize - '404': - description: Could not find resource. - /api/permission/policies: - get: - description: Lists all permission polices. - parameters: - - name: entityReference - in: query - description: Entity :/. - required: false - schema: - type: string - - name: permission - in: query - description: Permission from a specific plugin, Resource type or name - required: false - schema: - type: string - - name: policy - in: query - description: 'Policy action for the permission: create, read, update, delete, use' - required: false - schema: - type: string - - name: effect - in: query - description: allow or deny - required: false - schema: - type: string - responses: - '200': - description: Request was successful. - content: - application/json: - schema: - type: array - items: - '$ref': '#/components/schemas/PermissionResponse' - '403': - description: Refusal to authorize - post: - description: Creates one or more permission policies for a specified entity. - requestBody: - required: true - content: - application/json: - schema: - type: array - items: - '$ref': '#/components/schemas/PermissionPolicy' - responses: - '201': - description: New resource was successfully created. - '400': - description: Input Error - '403': - description: Refusal to authorize - /api/permission/policies/{kind}/{namespace}/{name}: - get: - description: List permission policies related to the specified entity reference - parameters: - - $ref: '#/components/parameters/nameParam' - - $ref: '#/components/parameters/namespaceParam' - - $ref: '#/components/parameters/kindParam' - responses: - '200': - description: Request was successful. - content: - application/json: - schema: - type: array - items: - '$ref': '#/components/schemas/PermissionResponse' - '403': - description: Refusal to authorize - '404': - description: Could not find resource - put: - description: Updates one or more permission policies for a specified entity. - parameters: - - $ref: '#/components/parameters/nameParam' - - $ref: '#/components/parameters/namespaceParam' - - $ref: '#/components/parameters/kindParam' - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - oldPolicy: - type: array - items: - type: object - properties: - permission: - type: string - description: >- - Permission from a specific plugin, Resource type or - name - policy: - type: string - description: >- - Policy action for the permission: create, read, - update, delete, use - effect: - type: string - description: allow or deny - newPolicy: - type: array - items: - type: object - properties: - permission: - type: string - description: >- - Permission from a specific plugin, Resource type or - name - policy: - type: string - description: >- - Policy action for the permission: create, read, - update, delete, use - effect: - type: string - description: allow or deny - responses: - '200': - description: Request was successful. - '400': - description: Input Error - '403': - description: Refusal to authorize - delete: - description: >- - Deletes a permission policy or a group of permission policies of a - specified entity. - parameters: - - $ref: '#/components/parameters/nameParam' - - $ref: '#/components/parameters/namespaceParam' - - $ref: '#/components/parameters/kindParam' - requestBody: - required: false - content: - application/json: - schema: - type: array - items: - '$ref': '#/components/schemas/PermissionPolicy' - responses: - '204': - description: ok - '400': - description: Input Error - '403': - description: Refusal to authorize - /api/permission/plugins/policies: - get: - description: >- - Lists all plugin permission policies from plugins installed in your - Backstage instance. - responses: - '200': - description: Request was successful - content: - application/json: - schema: - type: array - items: - type: object - properties: - pluginId: - type: string - policies: - type: array - items: - type: object - properties: - name: - type: string - description: Permission from a specific plugin. - resourceType: - type: string - description: Resource type. - policy: - type: string - description: >- - Policy action for the permission: create, read, - update, delete, use. - required: [name, policy] - '403': - description: Refusal to authorize - /api/permission/plugins/condition-rules: - get: - description: Provides conditional rule parameter schemas. - responses: - '200': - description: Request was successful - content: - application/json: - schema: - type: array - items: - type: object - properties: - pluginId: - type: string - rules: - type: array - items: - type: object - properties: - name: - type: string - description: - type: string - resourceType: - type: string - paramsSchema: - type: object - properties: - $schema: - type: string - additionalProperties: - type: boolean - required: - type: string - type: - type: string - oneOf: - - properties: - properties: - type: object - additionalProperties: - $ref: '#/components/schemas/PropertyArray' - - properties: - properties: - type: object - additionalProperties: - $ref: '#/components/schemas/PropertyObject' - '403': - description: Refusal to authorize - /api/permission/plugins/id: - get: - description: Returns plugin IDs that support the Backstage permission framework. - responses: - '200': - description: Request was successful - content: - application/json: - schema: - '$ref': '#/components/schemas/PluginIds' - '403': - description: Refusal to authorize - - post: - description: Add additional plugin IDs. - requestBody: - required: true - content: - application/json: - schema: - '$ref': '#/components/schemas/PluginIds' - responses: - '200': - description: Plugin IDs were successfully added. Returns updated list. - content: - application/json: - schema: - '$ref': '#/components/schemas/PluginIds' - '409': - description: Conflict with current state and target resource. - '403': - description: Refusal to authorize - - delete: - description: Delete some additional plugin IDs. - requestBody: - required: true - content: - application/json: - schema: - '$ref': '#/components/schemas/PluginIds' - responses: - '200': - description: Plugin IDs were successfully removed. Returns updated list. - content: - application/json: - schema: - '$ref': '#/components/schemas/PluginIds' - '404': - description: Could not find resource - '403': - description: Refusal to authorize - /api/permission/roles/conditions: - get: - description: Lists all conditions - responses: - '200': - description: Request was successful - content: - application/json: - schema: - type: array - items: - type: object - required: - [ - result, - roleEntityRef, - pluginId, - resourceType, - permissionMapping, - conditions, - ] - properties: - id: - type: integer - result: - type: string - roleEntityRef: - type: string - pluginId: - type: string - resourceType: - type: string - permissionMapping: - type: array - items: - type: string - conditions: - $ref: '#/components/schemas/Condition' - '403': - description: Refusal to authorize - post: - description: Creates a new condition. - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - result: - type: string - roleEntityRef: - type: string - pluginId: - type: string - resourceType: - type: string - permissionMapping: - type: array - items: - type: string - conditions: - $ref: '#/components/schemas/Condition' - responses: - '201': - description: New resource was successfully created. - content: - application/json: - schema: - type: object - properties: - id: - type: integer - '403': - description: Refusal to authorize - /api/permission/roles/conditions/{id}: - get: - description: Returns condition by id. - responses: - '200': - description: Request was successful - content: - application/json: - schema: - type: object - properties: - id: - type: integer - result: - type: string - roleEntityRef: - type: string - pluginId: - type: string - resourceType: - type: string - permissionMapping: - type: array - items: - type: string - conditions: - $ref: '#/components/schemas/Condition' - '400': - description: Input Error - '403': - description: Refusal to authorize - '404': - description: Could not find resource - put: - description: Update conditions by id. - parameters: - - name: id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - result: - type: string - roleEntityRef: - type: string - pluginId: - type: string - resourceType: - type: string - permissionMapping: - type: array - items: - type: string - conditions: - $ref: '#/components/schemas/Condition' - responses: - '200': - description: Request was successful - '400': - description: Id is not a valid number. - '403': - description: Refusal to authorize - '404': - description: Id is not a valid number. - delete: - description: Deletes condition by id. - parameters: - - name: id - in: path - required: true - schema: - type: integer - responses: - '204': - description: ok - '400': - description: Id is not a valid number. - '403': - description: Refusal to authorize - '404': - description: Could not find resource. - /api/permission/refresh/{id}: - post: - description: >- - Refreshes RBAC permission policies by provider id. - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - '200': - description: Request was successful - '403': - description: Refusal to authorize - '404': - description: Could not find resource. diff --git a/plugins/rbac-backend/package.json b/plugins/rbac-backend/package.json deleted file mode 100644 index a691444e32..0000000000 --- a/plugins/rbac-backend/package.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "name": "@internal/plugin-rbac-backend", - "version": "0.1.0", - "main": "src/index.ts", - "types": "src/index.ts", - "license": "Apache-2.0", - "private": true, - "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" - }, - "backstage": { - "role": "backend-plugin", - "pluginId": "rbac", - "pluginPackages": [ - "@internal/plugin-rbac-backend" - ] - }, - "scripts": { - "start": "backstage-cli package start", - "build": "backstage-cli package build", - "tsc": "tsc", - "prettier:check": "prettier --ignore-unknown --check .", - "prettier:fix": "prettier --ignore-unknown --write .", - "lint:check": "backstage-cli package lint", - "lint:fix": "backstage-cli package lint --fix", - "test": "backstage-cli package test --passWithNoTests --coverage", - "clean": "backstage-cli package clean", - "prepack": "backstage-cli package prepack", - "postpack": "backstage-cli package postpack" - }, - "dependencies": { - "@azure/identity": "^4.0.0", - "@backstage-community/plugin-rbac-common": "1.26.1", - "@backstage-community/plugin-rbac-node": "1.20.1", - "@backstage/backend-defaults": "0.16.0", - "@backstage/backend-plugin-api": "1.8.0", - "@backstage/catalog-client": "1.14.0", - "@backstage/catalog-model": "1.7.7", - "@backstage/config": "1.3.6", - "@backstage/errors": "^1.2.7", - "@backstage/plugin-permission-common": "0.9.7", - "@backstage/plugin-permission-node": "0.10.11", - "@dagrejs/graphlib": "^4.0.0", - "casbin": "5.27.1", - "chokidar": "^3.6.0", - "csv-parse": "^6.0.0", - "express": "^4.18.2", - "express-promise-router": "^4.1.0", - "js-yaml": "^4.1.0", - "knex": "^3.0.0", - "lodash": "^4.17.21", - "typeorm-adapter": "^1.6.1", - "zod": "^4.3.6" - }, - "devDependencies": { - "@backstage/backend-test-utils": "1.11.1", - "@backstage/cli": "0.36.0", - "@backstage/core-plugin-api": "1.12.4", - "@backstage/plugin-catalog-node": "2.1.0", - "@backstage/types": "^1.2.2", - "@types/express": "4.17.25", - "@types/js-yaml": "^4.0.9", - "@types/lodash": "^4.14.151", - "@types/node": "22.19.17", - "@types/supertest": "7.2.0", - "knex-mock-client": "3.0.2", - "qs": "6.15.1", - "supertest": "7.2.2" - }, - "files": [ - "dist", - "config.d.ts", - "migrations" - ], - "configSchema": "config.d.ts", - "repository": { - "type": "git", - "url": "https://github.com/backstage/community-plugins", - "directory": "workspaces/rbac/plugins/rbac-backend" - }, - "keywords": [ - "backstage", - "plugin" - ], - "bugs": "https://github.com/backstage/community-plugins/issues", - "maintainers": [ - "@PatAKnight" - ], - "author": "Red Hat", - "prettier": "@backstage/cli/config/prettier", - "lint-staged": { - "*.{js,jsx,ts,tsx,mjs,cjs}": [ - "eslint --fix", - "prettier --write" - ], - "*.{json,md}": [ - "prettier --write" - ] - } -} diff --git a/plugins/rbac-backend/report.api.md b/plugins/rbac-backend/report.api.md deleted file mode 100644 index 5003438513..0000000000 --- a/plugins/rbac-backend/report.api.md +++ /dev/null @@ -1,76 +0,0 @@ -## API Report File for "@backstage-community/plugin-rbac-backend" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts -import type { AuditorService } from '@backstage/backend-plugin-api'; -import type { AuthService } from '@backstage/backend-plugin-api'; -import { BackendFeature } from '@backstage/backend-plugin-api'; -import type { Config } from '@backstage/config'; -import type { DiscoveryService } from '@backstage/backend-plugin-api'; -import express from 'express'; -import type { HttpAuthService } from '@backstage/backend-plugin-api'; -import type { LifecycleService } from '@backstage/backend-plugin-api'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { PermissionEvaluator } from '@backstage/plugin-permission-common'; -import type { PermissionsRegistryService } from '@backstage/backend-plugin-api'; -import type { PermissionsService } from '@backstage/backend-plugin-api'; -import { PluginIdProvider } from '@backstage-community/plugin-rbac-node'; -import { PolicyExtensionPoint } from '@backstage/plugin-permission-node/alpha'; -import type { RBACProvider } from '@backstage-community/plugin-rbac-node'; -import type { Router } from 'express'; - -// @public (undocumented) -export function createRouter(options: RouterOptions): Promise; - -// @public (undocumented) -export type EnvOptions = { - config: Config; - logger: LoggerService; - discovery: DiscoveryService; - permissions: PermissionEvaluator; - auth: AuthService; - httpAuth: HttpAuthService; - auditor: AuditorService; - lifecycle: LifecycleService; - permissionsRegistry: PermissionsRegistryService; - policy: PolicyExtensionPoint; -}; - -export { PluginIdProvider }; - -// @public (undocumented) -export class PolicyBuilder { - // (undocumented) - static build( - env: EnvOptions, - pluginIdProvider?: PluginIdProvider, - rbacProviders?: Array, - ): Promise; -} - -// @public -const rbacPlugin: BackendFeature; -export default rbacPlugin; - -// @public (undocumented) -export type RBACRouterOptions = { - config: Config; - logger: LoggerService; - auth: AuthService; - httpAuth: HttpAuthService; - permissions: PermissionsService; - permissionsRegistry: PermissionsRegistryService; - auditor: AuditorService; -}; - -// @public (undocumented) -export interface RouterOptions { - // (undocumented) - config: Config; - // (undocumented) - logger: LoggerService; -} - -// (No @packageDocumentation comment for this package) -``` diff --git a/plugins/rbac-backend/src/admin-permissions/admin-creation.test.ts b/plugins/rbac-backend/src/admin-permissions/admin-creation.test.ts deleted file mode 100644 index ad531f28dd..0000000000 --- a/plugins/rbac-backend/src/admin-permissions/admin-creation.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { mockServices } from '@backstage/backend-test-utils'; -import { Config } from '@backstage/config'; - -import * as Knex from 'knex'; - -import type { RoleMetadata } from '@backstage-community/plugin-rbac-common'; - -import { - mockAuditorService, - csvPermFile, - mockClientKnex, - roleMetadataStorageMock, -} from '../../__fixtures__/mock-utils'; -import { - newAdapter, - newConfig, - newEnforcerDelegate, - newPermissionPolicy, -} from '../../__fixtures__/test-utils'; -import { RoleMetadataDao } from '../database/role-metadata'; -import { EnforcerDelegate } from '../service/enforcer-delegate'; -import { - ADMIN_ROLE_NAME, - setAdminPermissions, - useAdminsFromConfig, -} from './admin-creation'; - -const modifiedBy = 'user:default/some-admin'; -const adminRole = 'role:default/rbac_admin'; -const groupPolicy = [['user:default/test_admin', 'role:default/rbac_admin']]; -const permissions = [ - ['role:default/rbac_admin', 'policy-entity', 'read', 'allow'], - ['role:default/rbac_admin', 'policy.entity.create', 'create', 'allow'], - ['role:default/rbac_admin', 'policy-entity', 'delete', 'allow'], - ['role:default/rbac_admin', 'policy-entity', 'update', 'allow'], - ['role:default/rbac_admin', 'catalog-entity', 'read', 'allow'], -]; -const oldGroupPolicy = ['user:default/old_admin', 'role:default/rbac_admin']; - -describe('Admin Creation', () => { - describe('Admin role and permission creation to a user', () => { - let enfDelegate: EnforcerDelegate; - let config: Config; - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async ( - _roleEntityRef: string, - _trx: Knex.Knex.Transaction, - ): Promise => { - return { - roleEntityRef: 'role:default/catalog-writer', - source: 'legacy', - modifiedBy, - }; - }, - ); - - const admins = new Array<{ name: string }>(); - admins.push({ name: 'user:default/test_admin' }); - const superUser = new Array<{ name: string }>(); - superUser.push({ name: 'user:default/super_user' }); - - beforeEach(async () => { - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async ( - _roleEntityRef: string, - _trx: Knex.Knex.Transaction, - ): Promise => { - return { - roleEntityRef: 'role:default/catalog-writer', - source: 'legacy', - modifiedBy, - }; - }, - ); - - config = newConfig(csvPermFile, admins, superUser); - const adapter = await newAdapter(config); - - enfDelegate = await newEnforcerDelegate(adapter, config); - - await enfDelegate.addGroupingPolicy(oldGroupPolicy, { - source: 'configuration', - roleEntityRef: ADMIN_ROLE_NAME, - modifiedBy: `user:default/tom`, - }); - - const adminUsers = config.getOptionalConfigArray( - 'permission.rbac.admin.users', - ); - await useAdminsFromConfig( - adminUsers || [], - enfDelegate, - mockAuditorService, - roleMetadataStorageMock, - mockClientKnex, - ); - await setAdminPermissions(enfDelegate, mockAuditorService); - }); - - it('should assign an admin to the admin role and permissions', async () => { - const enfRole = await enfDelegate.getFilteredGroupingPolicy(1, adminRole); - const enfPermission = await enfDelegate.getFilteredPolicy(0, adminRole); - expect(enfRole).toEqual(groupPolicy); - expect(enfPermission).toEqual(permissions); - }); - - it(`should not assign an admin to the permissions if permissions are already assigned`, async () => { - await expect(async () => { - await setAdminPermissions(enfDelegate, mockAuditorService); - }).not.toThrow(); - }); - - it(`should assign an admin to the new permission`, async () => { - const newDefaultPermission = [ - adminRole, - 'something-new', - 'create', - 'allow', - ]; - await enfDelegate.addPolicy(newDefaultPermission); - await setAdminPermissions(enfDelegate, mockAuditorService); - const enfPermission = await enfDelegate.getFilteredPolicy( - 0, - ...newDefaultPermission, - ); - expect(enfPermission.length).toEqual(1); - }); - - it('should fail to build the admin permissions, problem with creating role metadata', async () => { - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation(async (): Promise => { - return undefined; - }); - - roleMetadataStorageMock.createRoleMetadata = jest - .fn() - .mockImplementation(async (): Promise => { - throw new Error(`Failed to create`); - }); - - config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - 'policies-csv-file': csvPermFile, - policyFileReload: true, - }, - }, - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - }, - }); - - await expect( - newPermissionPolicy(config, enfDelegate, roleMetadataStorageMock), - ).rejects.toThrow('Failed to create'); - }); - - it('should build and update a legacy admin permission', async () => { - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementationOnce( - async ( - _roleEntityRef: string, - _trx: Knex.Knex.Transaction, - ): Promise => { - return { source: 'legacy' }; - }, - ); - - const enfRole = await enfDelegate.getFilteredGroupingPolicy(1, adminRole); - const enfPermission = await enfDelegate.getFilteredPolicy(0, adminRole); - - expect(enfRole).toEqual(groupPolicy); - expect(enfPermission).toEqual(permissions); - expect(roleMetadataStorageMock.updateRoleMetadata).toHaveBeenCalled(); - }); - - it('should remove users that are no longer in the config file', async () => { - const enfRole = await enfDelegate.getFilteredGroupingPolicy(1, adminRole); - const enfPermission = await enfDelegate.getFilteredPolicy(0, adminRole); - expect(enfRole).toEqual(groupPolicy); - expect(enfRole).not.toContain(oldGroupPolicy); - expect(enfPermission).toEqual(permissions); - }); - }); -}); diff --git a/plugins/rbac-backend/src/admin-permissions/admin-creation.ts b/plugins/rbac-backend/src/admin-permissions/admin-creation.ts deleted file mode 100644 index 5a77535758..0000000000 --- a/plugins/rbac-backend/src/admin-permissions/admin-creation.ts +++ /dev/null @@ -1,212 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { Config } from '@backstage/config'; - -import { Knex } from 'knex'; - -import { ActionType, PermissionEvents, RoleEvents } from '../auditor/auditor'; - -import { - RoleMetadataDao, - RoleMetadataStorage, -} from '../database/role-metadata'; -import { removeTheDifference } from '../helper'; -import { EnforcerDelegate } from '../service/enforcer-delegate'; -import { validateEntityReference } from '../validation/policies-validation'; -import { AuditorService } from '@backstage/backend-plugin-api'; - -export const ADMIN_ROLE_NAME = 'role:default/rbac_admin'; -export const ADMIN_ROLE_AUTHOR = 'application configuration'; -const DEF_ADMIN_ROLE_DESCRIPTION = - 'The default permission policy for the admin role allows for the creation, deletion, updating, and reading of roles and permission policies.'; - -const getAdminRoleMetadata = (): RoleMetadataDao => { - const currentDate: Date = new Date(); - return { - source: 'configuration', - roleEntityRef: ADMIN_ROLE_NAME, - description: DEF_ADMIN_ROLE_DESCRIPTION, - author: ADMIN_ROLE_AUTHOR, - modifiedBy: ADMIN_ROLE_AUTHOR, - lastModified: currentDate.toUTCString(), - createdAt: currentDate.toUTCString(), - }; -}; - -export const useAdminsFromConfig = async ( - admins: Config[], - enf: EnforcerDelegate, - auditor: AuditorService, - roleMetadataStorage: RoleMetadataStorage, - knex: Knex, -) => { - const addedGroupPolicies = new Map(); - const newGroupPolicies = new Map(); - - for (const admin of admins) { - const entityRef = admin.getString('name').toLocaleLowerCase('en-US'); - validateEntityReference(entityRef); - - addedGroupPolicies.set(entityRef, ADMIN_ROLE_NAME); - - if (!(await enf.hasGroupingPolicy(...[entityRef, ADMIN_ROLE_NAME]))) { - newGroupPolicies.set(entityRef, ADMIN_ROLE_NAME); - } - } - - const adminRoleMeta = - await roleMetadataStorage.findRoleMetadata(ADMIN_ROLE_NAME); - const addedRoleMembers = Array.from(newGroupPolicies.entries()); - const meta = { - ...getAdminRoleMetadata(), - members: addedRoleMembers.map(gp => gp[0]), - }; - const auditorEvent = await auditor.createEvent({ - eventId: RoleEvents.ROLE_WRITE, - severityLevel: 'medium', - meta: { - actionType: adminRoleMeta ? ActionType.UPDATE : ActionType.CREATE, - source: meta.source, - }, - }); - - const trx = await knex.transaction(); - try { - if (!adminRoleMeta) { - // even if there are no user, we still create default role metadata for admins - await roleMetadataStorage.createRoleMetadata(getAdminRoleMetadata(), trx); - } else if (adminRoleMeta.source === 'legacy') { - await roleMetadataStorage.updateRoleMetadata( - getAdminRoleMetadata(), - ADMIN_ROLE_NAME, - trx, - ); - } - - await enf.addGroupingPolicies( - addedRoleMembers, - getAdminRoleMetadata(), - undefined, - trx, - ); - - await trx.commit(); - await auditorEvent.success({ - meta, - }); - } catch (error) { - await trx.rollback(error); - await auditorEvent.fail({ - error, - meta, - }); - throw error; - } - - const configGroupPolicies = await enf.getFilteredGroupingPolicy( - 1, - ADMIN_ROLE_NAME, - ); - - await removeTheDifference( - configGroupPolicies.map(gp => gp[0]), - Array.from(addedGroupPolicies.keys()), - 'configuration', - ADMIN_ROLE_NAME, - enf, - auditor, - ADMIN_ROLE_AUTHOR, - ); -}; - -const addAdminPermissions = async ( - policies: string[][], - enf: EnforcerDelegate, - auditor: AuditorService, -) => { - const policiesToAdd: string[][] = []; - for (const policy of policies) { - if (!(await enf.hasPolicy(...policy))) { - policiesToAdd.push(policy); - } - } - - const auditorEvent = await auditor.createEvent({ - eventId: PermissionEvents.POLICY_WRITE, - severityLevel: 'medium', - meta: { actionType: ActionType.CREATE, source: 'configuration' }, - }); - - try { - await enf.addPolicies(policiesToAdd); - await auditorEvent.success({ - meta: { policies: policiesToAdd }, - }); - } catch (error) { - await auditorEvent.fail({ - error, - meta: { policies: policiesToAdd }, - }); - } -}; - -const removeOldCreateAdminPermissions = async ( - enf: EnforcerDelegate, - auditor: AuditorService, -) => { - const policyEntityCreate = [ - 'role:default/rbac_admin', - 'policy-entity', - 'create', - 'allow', - ]; - if (await enf.hasPolicy(...policyEntityCreate)) { - const auditorEvent = await auditor.createEvent({ - eventId: PermissionEvents.POLICY_WRITE, - severityLevel: 'medium', - meta: { actionType: ActionType.DELETE, source: 'configuration' }, - }); - - try { - await enf.removePolicy(policyEntityCreate); - await auditorEvent.success({ - meta: { policy: policyEntityCreate }, - }); - } catch (error) { - await auditorEvent.fail({ - error, - meta: { policy: policyEntityCreate }, - }); - } - } -}; - -export const setAdminPermissions = async ( - enf: EnforcerDelegate, - auditor: AuditorService, -) => { - // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` - await removeOldCreateAdminPermissions(enf, auditor); - const adminPermissions = [ - [ADMIN_ROLE_NAME, 'policy-entity', 'read', 'allow'], - [ADMIN_ROLE_NAME, 'policy.entity.create', 'create', 'allow'], - [ADMIN_ROLE_NAME, 'policy-entity', 'delete', 'allow'], - [ADMIN_ROLE_NAME, 'policy-entity', 'update', 'allow'], - // Needed for the RBAC frontend plugin. - [ADMIN_ROLE_NAME, 'catalog-entity', 'read', 'allow'], - ]; - await addAdminPermissions(adminPermissions, enf, auditor); -}; diff --git a/plugins/rbac-backend/src/auditor/auditor.ts b/plugins/rbac-backend/src/auditor/auditor.ts deleted file mode 100644 index d6ab047e76..0000000000 --- a/plugins/rbac-backend/src/auditor/auditor.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - PolicyDecision, - ResourcePermission, -} from '@backstage/plugin-permission-common'; -import type { PolicyQuery } from '@backstage/plugin-permission-node'; - -import { - PermissionAction, - toPermissionAction, -} from '@backstage-community/plugin-rbac-common'; -import { - AuditorService, - AuditorServiceEvent, -} from '@backstage/backend-plugin-api'; - -export const ActionType = { - CREATE: 'create', - CREATE_OR_UPDATE: 'create_or_update', - UPDATE: 'update', - DELETE: 'delete', -}; - -export const RoleEvents = { - ROLE_WRITE: 'role-write', - ROLE_READ: 'role-read', -} as const; - -export const PermissionEvents = { - POLICY_WRITE: 'policy-write', - POLICY_READ: 'policy-read', -} as const; - -export const EvaluationEvents = { - PERMISSION_EVALUATION: 'permission-evaluation', -} as const; - -export const ListPluginPoliciesEvents = { - PLUGIN_POLICIES_READ: 'plugin-policies-read', -}; - -export const ListConditionEvents = { - CONDITION_RULES_READ: 'condition-rules-read', -}; - -export const ListPluginIDsEvents = { - PLUGIN_IDS_READ: 'plugin-ids-read', - PLUGIN_IDS_WRITE: 'plugin-ids-write', -}; - -export type EvaluationAuditInfo = { - userEntityRef: string; - permissionName: string; - action: PermissionAction; - resourceType?: string; - decision?: PolicyDecision; -}; - -export const PoliciesData = { - PERMISSIONS_READ: 'permissions-read', -}; - -export const ConditionEvents = { - CONDITION_WRITE: 'condition-write', - CONDITION_READ: 'condition-read', - CONDITIONAL_POLICIES_FILE_NOT_FOUND: 'conditional-policies-file-not-found', - CONDITIONAL_POLICIES_FILE_CHANGE: 'conditional-policies-file-change', -}; - -export async function createPermissionEvaluationAuditorEvent( - auditor: AuditorService, - userEntityRef: string, - request: PolicyQuery, - policyDecision?: PolicyDecision, -): Promise { - const auditInfo: EvaluationAuditInfo = { - userEntityRef, - permissionName: request.permission.name, - action: toPermissionAction(request.permission.attributes), - }; - - const resourceType = (request.permission as ResourcePermission).resourceType; - if (resourceType) { - auditInfo.resourceType = resourceType; - } - if (policyDecision) { - auditInfo.decision = policyDecision; - } - - return await auditor.createEvent({ - eventId: EvaluationEvents.PERMISSION_EVALUATION, - severityLevel: 'medium', - meta: { - ...auditInfo, - }, - }); -} diff --git a/plugins/rbac-backend/src/auditor/rest-interceptor.ts b/plugins/rbac-backend/src/auditor/rest-interceptor.ts deleted file mode 100644 index a2fde3cb6a..0000000000 --- a/plugins/rbac-backend/src/auditor/rest-interceptor.ts +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - type RequestHandler, - type NextFunction, - type Request, - type Response, - type ErrorRequestHandler, -} from 'express'; - -import { - ActionType, - ConditionEvents, - ListConditionEvents, - ListPluginIDsEvents, - ListPluginPoliciesEvents, - PermissionEvents, - RoleEvents, -} from './auditor'; -import { - AuditorService, - AuditorServiceEvent, -} from '@backstage/backend-plugin-api'; -import type { JsonObject } from '@backstage/types'; - -// Mapping paths and methods to corresponding events and messages -const eventMap: { - [key: string]: { [key: string]: string }; -} = { - '/policies': { - POST: PermissionEvents.POLICY_WRITE, - PUT: PermissionEvents.POLICY_WRITE, - DELETE: PermissionEvents.POLICY_WRITE, - GET: PermissionEvents.POLICY_READ, - }, - '/roles/conditions': { - POST: ConditionEvents.CONDITION_WRITE, - PUT: ConditionEvents.CONDITION_WRITE, - DELETE: ConditionEvents.CONDITION_WRITE, - GET: ConditionEvents.CONDITION_READ, - }, - '/roles': { - POST: RoleEvents.ROLE_WRITE, - PUT: RoleEvents.ROLE_WRITE, - DELETE: RoleEvents.ROLE_WRITE, - GET: RoleEvents.ROLE_READ, - }, - '/plugins/policies': { - GET: ListPluginPoliciesEvents.PLUGIN_POLICIES_READ, - }, - '/plugins/condition-rules': { - GET: ListConditionEvents.CONDITION_RULES_READ, - }, - '/plugins/id': { - GET: ListPluginIDsEvents.PLUGIN_IDS_READ, - POST: ListPluginIDsEvents.PLUGIN_IDS_WRITE, - DELETE: ListPluginIDsEvents.PLUGIN_IDS_WRITE, - }, -}; - -const eventToActionMap: { - [key: string]: string; -} = { - POST: ActionType.CREATE, - PUT: ActionType.UPDATE, - DELETE: ActionType.DELETE, -}; - -function getRequestAuditorMeta(req: Request, eventId: string): JsonObject { - const meta = { - ...(req.method in eventToActionMap - ? { actionType: eventToActionMap[req.method] } - : {}), - source: 'rest', - }; - - if (req.method !== 'GET') { - return meta; - } - - let extraMeta = {}; - const hasQuery = Object.keys(req.query).length > 0; - const hasParams = Object.keys(req.params).length > 0; - switch (eventId) { - case PermissionEvents.POLICY_READ: - if (hasParams) { - extraMeta = { - queryType: 'by-role', - entityRef: `${req.params.kind}:${req.params.namespace}/${req.params.name}`, - }; - break; - } - extraMeta = { - queryType: hasQuery ? 'by-query' : 'all', - ...(hasQuery ? { query: req.query } : {}), - }; - break; - case RoleEvents.ROLE_READ: - extraMeta = { - queryType: hasParams ? 'by-role' : 'all', - ...(hasParams - ? { - entityRef: `${req.params.kind}:${req.params.namespace}/${req.params.name}`, - } - : {}), - }; - break; - case ConditionEvents.CONDITION_READ: - if (hasParams) { - extraMeta = { - queryType: 'by-id', - id: req.params.id, - }; - break; - } - extraMeta = { - queryType: hasQuery ? 'by-query' : 'all', - ...(hasQuery ? { query: req.query } : {}), - }; - break; - default: - break; - } - return { ...meta, ...extraMeta }; -} - -export function logAuditorEvent(auditor: AuditorService): RequestHandler { - return async (req: Request, resp: Response, next: NextFunction) => { - let auditorEvent: AuditorServiceEvent | undefined; - const matchedPath = Object.keys(eventMap).find(path => - req.path.startsWith(path), - ); - if (matchedPath) { - const methodEvent = eventMap[matchedPath][req.method]; - if (methodEvent) { - const meta = getRequestAuditorMeta(req, methodEvent); - auditorEvent = await auditor.createEvent({ - eventId: methodEvent, - severityLevel: 'medium', - request: req, - meta, - }); - } - } - - resp.on('finish', async () => { - const meta = { - response: { status: resp.statusCode }, - ...(resp.locals.meta ?? {}), - }; - if (resp.statusCode < 400) { - await auditorEvent?.success({ meta }); - } else { - const error = resp.locals.error ?? new Error(resp.statusMessage); - await auditorEvent?.fail({ - error, - meta, - }); - } - }); - - next(); - }; -} - -export function setAuditorError(): ErrorRequestHandler { - return async ( - err: Error, - _req: Request, - resp: Response, - next: NextFunction, - ) => { - resp.locals.error = err; - next(err); - }; -} diff --git a/plugins/rbac-backend/src/conditional-aliases/alias-resolver.test.ts b/plugins/rbac-backend/src/conditional-aliases/alias-resolver.test.ts deleted file mode 100644 index cfbfb3c8f2..0000000000 --- a/plugins/rbac-backend/src/conditional-aliases/alias-resolver.test.ts +++ /dev/null @@ -1,648 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - PermissionCondition, - PermissionCriteria, - PermissionRuleParams, -} from '@backstage/plugin-permission-common'; - -import { replaceAliases } from './alias-resolver'; - -describe('replaceAliases', () => { - describe('should replace "currentUser" aliases', () => { - it('should replace aliases in the string value', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - rule: 'TEST', - resourceType: 'test-entity', - params: { - test: '$currentUser', - }, - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - rule: 'TEST', - resourceType: 'test-entity', - params: { - test: 'user:default/tim', - }, - }); - }); - }); - - it('should replace aliases in the string array', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$currentUser'], - }, - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim'], - }, - }); - }); - - it('should replace aliases with criteria not', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - not: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$currentUser'], - }, - }, - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - not: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim'], - }, - }, - }); - }); - - it('should replace aliases with criteria anyOf', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$currentUser'], - }, - }, - ], - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim'], - }, - }, - ], - }); - }); - - it('should replace aliases with criteria anyOf and few values', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$currentUser'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - ], - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - ], - }); - }); - - it('should replace aliases with criteria allOf', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$currentUser'], - }, - }, - ], - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim'], - }, - }, - ], - }); - }); - - it('should replace aliases with criteria allOf and few values', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$currentUser'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - ], - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - ], - }); - }); - - it('should replace aliases with nested criteria', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - allOf: [ - { - not: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$currentUser'], - }, - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - ], - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - allOf: [ - { - not: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim'], - }, - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - ], - }); - }); - - describe('should replace "ownerRefs" aliases', () => { - it('should replace aliases without criteria', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$ownerRefs'], - }, - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim', 'group:default/team-a'], - }, - }); - }); - - it('should replace aliases with criteria not', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - not: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$ownerRefs'], - }, - }, - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - not: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim', 'group:default/team-a'], - }, - }, - }); - }); - - it('should replace aliases with criteria anyOf', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$ownerRefs'], - }, - }, - ], - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim', 'group:default/team-a'], - }, - }, - ], - }); - }); - - it('should replace aliases with criteria anyOf and few values', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$ownerRefs'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - ], - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - ], - }); - }); - - it('should replace aliases with criteria anyOf and few values in a different order', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - anyOf: [ - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$ownerRefs'], - }, - }, - ], - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - anyOf: [ - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim', 'group:default/team-a'], - }, - }, - ], - }); - }); - - it('should replace aliases with criteria anyOf and few values for other rules', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - anyOf: [ - { - rule: 'HAS_ANNOTATION', - resourceType: 'catalog-entity', - params: { value: '$currentUser', annotation: 'template/creator' }, - }, - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$ownerRefs'], - }, - }, - ], - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - anyOf: [ - { - rule: 'HAS_ANNOTATION', - resourceType: 'catalog-entity', - params: { - value: 'user:default/tim', - annotation: 'template/creator', - }, - }, - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim', 'group:default/team-a'], - }, - }, - ], - }); - }); - - it('should replace aliases with criteria allOf', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$ownerRefs'], - }, - }, - ], - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim', 'group:default/team-a'], - }, - }, - ], - }); - }); - - it('should replace aliases with criteria allOf and few values', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$ownerRefs'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - ], - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - ], - }); - }); - - it('should replace aliases with nested criteria', () => { - const conditionParam: PermissionCriteria< - PermissionCondition - > = { - allOf: [ - { - not: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$ownerRefs'], - }, - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - ], - }; - - replaceAliases(conditionParam, { - userEntityRef: 'user:default/tim', - ownershipEntityRefs: ['user:default/tim', 'group:default/team-a'], - }); - - expect(conditionParam).toEqual({ - allOf: [ - { - not: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/tim', 'group:default/team-a'], - }, - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - ], - }); - }); - }); -}); diff --git a/plugins/rbac-backend/src/conditional-aliases/alias-resolver.ts b/plugins/rbac-backend/src/conditional-aliases/alias-resolver.ts deleted file mode 100644 index 9700057c45..0000000000 --- a/plugins/rbac-backend/src/conditional-aliases/alias-resolver.ts +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { BackstageUserInfo } from '@backstage/backend-plugin-api'; -import type { - PermissionCondition, - PermissionCriteria, - PermissionRuleParam, - PermissionRuleParams, -} from '@backstage/plugin-permission-common'; -import type { JsonPrimitive } from '@backstage/types'; - -import { - CONDITION_ALIAS_SIGN, - ConditionalAliases, -} from '@backstage-community/plugin-rbac-common'; - -interface Predicate { - (item: T): boolean; -} - -function isOwnerRefsAlias(value: PermissionRuleParam): boolean { - const alias = `${CONDITION_ALIAS_SIGN}${ConditionalAliases.OWNER_REFS}`; - return value === alias; -} - -function isCurrentUserAlias(value: PermissionRuleParam): boolean { - const alias = `${CONDITION_ALIAS_SIGN}${ConditionalAliases.CURRENT_USER}`; - return value === alias; -} - -function replaceAliasWithValue< - K extends string, - V extends JsonPrimitive | JsonPrimitive[], ->( - params: Record, - key: K, - predicate: Predicate, - newValue: V, -): Record { - if (!params) { - return params; - } - - if (Array.isArray(params[key])) { - const oldValues = params[key] as JsonPrimitive[]; - const nonAliasValues: JsonPrimitive[] = []; - for (const oldValue of oldValues) { - const isAliasMatched = predicate(oldValue); - if (isAliasMatched) { - const newValues = Array.isArray(newValue) ? newValue : [newValue]; - nonAliasValues.push(...newValues); - } else { - nonAliasValues.push(oldValue); - } - } - return { ...params, [key]: nonAliasValues }; - } - - const oldValue = params[key] as JsonPrimitive; - const isAliasMatched = predicate(oldValue); - if (isAliasMatched && !Array.isArray(newValue)) { - return { ...params, [key]: newValue }; - } - - return params; -} - -export function replaceAliases( - conditions: PermissionCriteria< - PermissionCondition - >, - userInfo: BackstageUserInfo, -) { - if ('not' in conditions) { - replaceAliases(conditions.not, userInfo); - return; - } - if ('allOf' in conditions) { - for (const condition of conditions.allOf) { - replaceAliases(condition, userInfo); - } - return; - } - if ('anyOf' in conditions) { - for (const condition of conditions.anyOf) { - replaceAliases(condition, userInfo); - } - return; - } - - if (conditions.params) { - for (const key of Object.keys(conditions.params)) { - let modifiedParams: PermissionRuleParams = replaceAliasWithValue( - conditions.params, - key, - isCurrentUserAlias, - userInfo.userEntityRef, - ); - - modifiedParams = replaceAliasWithValue( - modifiedParams, - key, - isOwnerRefsAlias, - userInfo.ownershipEntityRefs, - ); - - conditions.params = modifiedParams; - } - } -} diff --git a/plugins/rbac-backend/src/database/casbin-adapter-factory.test.ts b/plugins/rbac-backend/src/database/casbin-adapter-factory.test.ts deleted file mode 100644 index 27f90e101c..0000000000 --- a/plugins/rbac-backend/src/database/casbin-adapter-factory.test.ts +++ /dev/null @@ -1,612 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { mockServices } from '@backstage/backend-test-utils'; - -import knex, { Knex } from 'knex'; -import TypeORMAdapter from 'typeorm-adapter'; - -import { CasbinDBAdapterFactory } from './casbin-adapter-factory'; - -jest.mock('typeorm-adapter', () => { - return { - newAdapter: jest.fn((): Promise => { - return Promise.resolve({} as TypeORMAdapter); - }), - }; -}); - -// Mock Azure Identity -const mockGetToken = jest.fn(); - -jest.mock('@azure/identity', () => { - const mockDefaultAzureCredential = jest.fn(); - const mockManagedIdentityCredential = jest.fn(); - const mockClientSecretCredential = jest.fn(); - - return { - DefaultAzureCredential: mockDefaultAzureCredential, - ManagedIdentityCredential: mockManagedIdentityCredential, - ClientSecretCredential: mockClientSecretCredential, - }; -}); - -describe('CasbinAdapterFactory', () => { - let newAdapterMock: jest.Mock>; - let db: Knex; - - beforeEach(() => { - newAdapterMock = TypeORMAdapter.newAdapter as jest.Mock< - Promise - >; - jest.clearAllMocks(); - }); - it('test building an adapter using a better-sqlite3 configuration.', async () => { - db = knex.knex({ - client: 'better-sqlite3', - connection: ':memory', - }); - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - }, - }); - const adapterFactory = new CasbinDBAdapterFactory(config, db); - const adapter = adapterFactory.createAdapter(); - expect(adapter).not.toBeNull(); - expect(newAdapterMock).toHaveBeenCalled(); - }); - - describe('build adapter with postgres configuration', () => { - beforeEach(() => { - db = knex.knex({ - client: 'pg', - connection: { - database: 'test-database', - }, - }); - process.env.TEST = 'test'; - }); - - it('test building an adapter using a PostgreSQL configuration.', async () => { - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'pg', - connection: { - host: 'localhost', - port: '5432', - schema: 'public', - user: 'postgresUser', - password: process.env.TEST, - }, - }, - }, - }, - }); - const factory = new CasbinDBAdapterFactory(config, db); - const adapter = await factory.createAdapter(); - expect(adapter).not.toBeNull(); - expect(newAdapterMock).toHaveBeenCalledWith({ - type: 'postgres', - host: 'localhost', - port: 5432, - schema: 'public', - username: 'postgresUser', - password: process.env.TEST, - database: 'test-database', - ssl: undefined, - }); - }); - - it('test building an adapter using a PostgreSQL configuration with enabled ssl.', async () => { - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'pg', - connection: { - host: 'localhost', - port: '5432', - schema: 'public', - user: 'postgresUser', - password: process.env.TEST, - ssl: true, - }, - }, - }, - }, - }); - const factory = new CasbinDBAdapterFactory(config, db); - const adapter = await factory.createAdapter(); - expect(adapter).not.toBeNull(); - expect(newAdapterMock).toHaveBeenCalledWith({ - type: 'postgres', - host: 'localhost', - port: 5432, - schema: 'public', - username: 'postgresUser', - password: process.env.TEST, - database: 'test-database', - ssl: true, - }); - }); - - it('test building an adapter using a PostgreSQL configuration without explicit credentials.', async () => { - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'pg', - connection: { - host: 'localhost', - port: '5432', - schema: 'public', - }, - }, - }, - }, - }); - const factory = new CasbinDBAdapterFactory(config, db); - const adapter = await factory.createAdapter(); - expect(adapter).not.toBeNull(); - expect(newAdapterMock).toHaveBeenCalledWith({ - type: 'postgres', - host: 'localhost', - port: 5432, - schema: 'public', - username: undefined, - password: undefined, - database: 'test-database', - ssl: undefined, - }); - }); - - it('test building an adapter using a PostgreSQL configuration with intentionally disabled ssl.', async () => { - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'pg', - connection: { - host: 'localhost', - port: '5432', - schema: 'public', - user: 'postgresUser', - password: process.env.TEST, - ssl: false, - }, - }, - }, - }, - }); - const factory = new CasbinDBAdapterFactory(config, db); - const adapter = await factory.createAdapter(); - expect(adapter).not.toBeNull(); - expect(newAdapterMock).toHaveBeenCalledWith({ - type: 'postgres', - host: 'localhost', - port: 5432, - schema: 'public', - username: 'postgresUser', - password: process.env.TEST, - database: 'test-database', - ssl: false, - }); - }); - - it('test building an adapter using a PostgreSQL configuration with intentionally ssl and ca cert.', async () => { - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'pg', - connection: { - host: 'localhost', - port: '5432', - schema: 'public', - user: 'postgresUser', - password: process.env.TEST, - ssl: { - ca: 'abc', - }, - }, - }, - }, - }, - }); - const factory = new CasbinDBAdapterFactory(config, db); - const adapter = await factory.createAdapter(); - expect(adapter).not.toBeNull(); - expect(newAdapterMock).toHaveBeenCalledWith({ - type: 'postgres', - host: 'localhost', - port: 5432, - schema: 'public', - username: 'postgresUser', - password: process.env.TEST, - database: 'test-database', - ssl: { - ca: 'abc', - }, - }); - }); - - it('test building an adapter using a PostgreSQL configuration with intentionally ssl and TLS options.', async () => { - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'pg', - connection: { - host: 'localhost', - port: '5432', - user: 'postgresUser', - password: process.env.TEST, - ssl: { - ca: 'abc', - rejectUnauthorized: false, - }, - }, - }, - }, - }, - }); - const factory = new CasbinDBAdapterFactory(config, db); - const adapter = await factory.createAdapter(); - expect(adapter).not.toBeNull(); - expect(newAdapterMock).toHaveBeenCalledWith({ - type: 'postgres', - host: 'localhost', - port: 5432, - schema: 'public', - username: 'postgresUser', - password: process.env.TEST, - database: 'test-database', - ssl: { - ca: 'abc', - rejectUnauthorized: false, - }, - }); - }); - - it('test building an adapter using a PostgreSQL configuration with intentionally ssl without CA.', async () => { - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'pg', - connection: { - host: 'localhost', - port: '5432', - user: 'postgresUser', - password: process.env.TEST, - ssl: { - rejectUnauthorized: false, - }, - }, - }, - }, - }, - }); - const factory = new CasbinDBAdapterFactory(config, db); - const adapter = await factory.createAdapter(); - expect(adapter).not.toBeNull(); - expect(newAdapterMock).toHaveBeenCalledWith({ - type: 'postgres', - host: 'localhost', - port: 5432, - schema: 'public', - username: 'postgresUser', - password: process.env.TEST, - database: 'test-database', - ssl: { - rejectUnauthorized: false, - }, - }); - }); - }); - - describe('build adapter with Azure PostgreSQL passwordless authentication', () => { - let mockDefaultAzureCredential: jest.Mock; - let mockManagedIdentityCredential: jest.Mock; - let mockClientSecretCredential: jest.Mock; - - beforeEach(() => { - db = knex.knex({ - client: 'pg', - connection: { - database: 'test-database', - }, - }); - jest.clearAllMocks(); - - // Get the mocked Azure Identity constructors - const azureIdentity = require('@azure/identity'); - mockDefaultAzureCredential = - azureIdentity.DefaultAzureCredential as jest.Mock; - mockManagedIdentityCredential = - azureIdentity.ManagedIdentityCredential as jest.Mock; - mockClientSecretCredential = - azureIdentity.ClientSecretCredential as jest.Mock; - - // Setup mock credential with getToken - mockGetToken.mockResolvedValue({ - token: 'mock-azure-token-1234567890', - expiresOnTimestamp: Date.now() + 3600000, // 1 hour from now - }); - - mockDefaultAzureCredential.mockImplementation(() => ({ - getToken: mockGetToken, - })); - mockManagedIdentityCredential.mockImplementation(() => ({ - getToken: mockGetToken, - })); - mockClientSecretCredential.mockImplementation(() => ({ - getToken: mockGetToken, - })); - }); - - it('should use DefaultAzureCredential when no credentials are provided (system-assigned managed identity)', async () => { - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'pg', - connection: { - type: 'azure', - host: 'myserver.postgres.database.azure.com', - port: '5432', - user: 'myuser@myserver', - ssl: { - rejectUnauthorized: false, - }, - }, - }, - }, - }, - }); - - const factory = new CasbinDBAdapterFactory(config, db); - await factory.createAdapter(); - - // Verify DefaultAzureCredential was instantiated - expect(mockDefaultAzureCredential).toHaveBeenCalled(); - expect(mockManagedIdentityCredential).not.toHaveBeenCalled(); - expect(mockClientSecretCredential).not.toHaveBeenCalled(); - - // Verify TypeORMAdapter.newAdapter was called - expect(newAdapterMock).toHaveBeenCalled(); - const adapterConfig = newAdapterMock.mock.calls[0][0]; - - // Verify password is a function - expect(typeof adapterConfig.password).toBe('function'); - - // Call the password function to verify it works - const passwordFn = adapterConfig.password; - const tokenResult = await passwordFn(); - - // Verify getToken was called with correct scope when password function is invoked - expect(mockGetToken).toHaveBeenCalledWith( - 'https://ossrdbms-aad.database.windows.net/.default', - ); - expect(tokenResult).toBe('mock-azure-token-1234567890'); - - // Verify other config - expect(adapterConfig.type).toBe('postgres'); - expect(adapterConfig.host).toBe('myserver.postgres.database.azure.com'); - expect(adapterConfig.port).toBe(5432); - expect(adapterConfig.username).toBe('myuser@myserver'); - expect(adapterConfig.database).toBe('test-database'); - expect(adapterConfig.ssl).toEqual({ rejectUnauthorized: false }); - expect(adapterConfig.extra).toEqual({ - idleTimeoutMillis: 50 * 60 * 1000, - }); - }); - - it('should use ManagedIdentityCredential with clientId (user-assigned managed identity)', async () => { - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'pg', - connection: { - type: 'azure', - host: 'myserver.postgres.database.azure.com', - port: '5432', - user: 'myuser@myserver', - tokenCredential: { - clientId: 'my-client-id', - }, - }, - }, - }, - }, - }); - - const factory = new CasbinDBAdapterFactory(config, db); - await factory.createAdapter(); - - // Verify ManagedIdentityCredential was instantiated with clientId - expect(mockManagedIdentityCredential).toHaveBeenCalledWith( - 'my-client-id', - ); - expect(mockDefaultAzureCredential).not.toHaveBeenCalled(); - expect(mockClientSecretCredential).not.toHaveBeenCalled(); - - // Call the password function to verify getToken is invoked - const adapterConfig = newAdapterMock.mock.calls[0][0]; - const passwordFn = adapterConfig.password; - await passwordFn(); - - // Verify getToken was called when password function is invoked - expect(mockGetToken).toHaveBeenCalledWith( - 'https://ossrdbms-aad.database.windows.net/.default', - ); - }); - - it('should use ClientSecretCredential with clientId, tenantId, and clientSecret (service principal)', async () => { - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'pg', - connection: { - type: 'azure', - host: 'myserver.postgres.database.azure.com', - port: '5432', - user: 'myuser@myserver', - tokenCredential: { - clientId: 'my-client-id', - tenantId: 'my-tenant-id', - clientSecret: 'my-client-secret', - }, - }, - }, - }, - }, - }); - - const factory = new CasbinDBAdapterFactory(config, db); - await factory.createAdapter(); - - // Verify ClientSecretCredential was instantiated with all three parameters - expect(mockClientSecretCredential).toHaveBeenCalledWith( - 'my-tenant-id', - 'my-client-id', - 'my-client-secret', - ); - expect(mockDefaultAzureCredential).not.toHaveBeenCalled(); - expect(mockManagedIdentityCredential).not.toHaveBeenCalled(); - - // Call the password function to verify getToken is invoked - const adapterConfig = newAdapterMock.mock.calls[0][0]; - const passwordFn = adapterConfig.password; - await passwordFn(); - - // Verify getToken was called when password function is invoked - expect(mockGetToken).toHaveBeenCalledWith( - 'https://ossrdbms-aad.database.windows.net/.default', - ); - }); - - it('should call password function and return token when invoked', async () => { - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'pg', - connection: { - type: 'azure', - host: 'myserver.postgres.database.azure.com', - port: '5432', - user: 'myuser@myserver', - }, - }, - }, - }, - }); - - const factory = new CasbinDBAdapterFactory(config, db); - await factory.createAdapter(); - - // Get the password function that was passed to TypeORMAdapter - const adapterConfig = newAdapterMock.mock.calls[0][0]; - const passwordFn = adapterConfig.password; - - // Clear previous calls - mockGetToken.mockClear(); - - // Call the password function - const result = await passwordFn(); - - // Verify it returns the token - expect(result).toBe('mock-azure-token-1234567890'); - - // Verify getToken was called when we invoked the password function - expect(mockGetToken).toHaveBeenCalledTimes(1); - expect(mockGetToken).toHaveBeenCalledWith( - 'https://ossrdbms-aad.database.windows.net/.default', - ); - - // Call it again to verify it fetches a fresh token each time - mockGetToken.mockResolvedValue({ - token: 'new-token-different', - expiresOnTimestamp: Date.now() + 3600000, - }); - const result2 = await passwordFn(); - expect(result2).toBe('new-token-different'); - expect(mockGetToken).toHaveBeenCalledTimes(2); - }); - - it('should throw error when Azure token acquisition fails', async () => { - mockGetToken.mockResolvedValue(null); - - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'pg', - connection: { - type: 'azure', - host: 'myserver.postgres.database.azure.com', - port: '5432', - user: 'myuser@myserver', - }, - }, - }, - }, - }); - - const factory = new CasbinDBAdapterFactory(config, db); - await factory.createAdapter(); - - // Get the password function - const adapterConfig = newAdapterMock.mock.calls[0][0]; - const passwordFn = adapterConfig.password; - - // The error should be thrown when the password function is called - await expect(passwordFn()).rejects.toThrow( - 'Failed to acquire Azure access token for database authentication', - ); - }); - }); - - it('ensure that building an adapter with an unknown configuration fails.', async () => { - const client = 'unknown-db'; - const expectedError = new Error(`Unsupported database client ${client}`); - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client, - }, - }, - }, - }); - const adapterFactory = new CasbinDBAdapterFactory(config, db); - - await expect(adapterFactory.createAdapter()).rejects.toStrictEqual( - expectedError, - ); - expect(newAdapterMock).not.toHaveBeenCalled(); - }); -}); diff --git a/plugins/rbac-backend/src/database/casbin-adapter-factory.ts b/plugins/rbac-backend/src/database/casbin-adapter-factory.ts deleted file mode 100644 index 0ad0a95f39..0000000000 --- a/plugins/rbac-backend/src/database/casbin-adapter-factory.ts +++ /dev/null @@ -1,228 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { Config } from '@backstage/config'; -import type { ConfigApi } from '@backstage/core-plugin-api'; - -import { Knex } from 'knex'; -import TypeORMAdapter from 'typeorm-adapter'; - -import { resolve } from 'path'; -import type { ConnectionOptions, TlsOptions } from 'tls'; - -import '@backstage/backend-defaults/database'; - -const DEFAULT_SQLITE3_STORAGE_FILE_NAME = 'rbac.sqlite'; - -/* Note: the following type definition is intentionally duplicated in - * config.d.ts so the clientSecret property can be annotated with - * "@visibility secret" there. - */ -export type AzureTokenCredentialConfig = { - /** - * The client ID of a user-assigned managed identity. - * If not provided, the system-assigned managed identity is used. - */ - clientId?: string; - /** - * The client secret for service principal authentication. - */ - clientSecret?: string; - /** - * The Azure Active Directory tenant ID. - */ - tenantId?: string; -}; - -export class CasbinDBAdapterFactory { - public constructor( - private readonly config: ConfigApi, - private readonly databaseClient: Knex, - ) {} - - public async createAdapter(): Promise { - const databaseConfig = this.config.getOptionalConfig('backend.database'); - const client = databaseConfig?.getOptionalString('client'); - - let adapter; - if (client === 'pg') { - const dbName = - await this.databaseClient.client.config.connection.database; - const schema = - (await this.databaseClient.client.searchPath?.[0]) ?? 'public'; - - const connectionType = - databaseConfig?.getOptionalString('connection.type'); - - if (connectionType === 'azure') { - adapter = await this.createAzureAdapter( - databaseConfig!, - dbName, - schema, - ); - } else { - const ssl = this.handlePostgresSSL(databaseConfig!); - - adapter = await TypeORMAdapter.newAdapter({ - type: 'postgres', - host: databaseConfig?.getString('connection.host'), - port: databaseConfig?.getNumber('connection.port'), - username: databaseConfig?.getOptionalString('connection.user'), - password: databaseConfig?.getOptionalString('connection.password'), - ssl, - database: dbName, - schema: schema, - poolSize: databaseConfig?.getOptionalNumber('knexConfig.pool.max'), - }); - } - } - - if (client === 'better-sqlite3') { - let storage; - if (typeof databaseConfig?.get('connection')?.valueOf() === 'string') { - storage = databaseConfig?.getString('connection'); - } else if (databaseConfig?.has('connection.directory')) { - const storageDir = databaseConfig?.getString('connection.directory'); - storage = resolve(storageDir, DEFAULT_SQLITE3_STORAGE_FILE_NAME); - } - - adapter = await TypeORMAdapter.newAdapter({ - type: 'better-sqlite3', - // Storage type or path to the storage. - database: storage || ':memory:', - }); - } - - if (!adapter) { - throw new Error(`Unsupported database client ${client}`); - } - - return adapter; - } - - private async createAzureAdapter( - dbConfig: Config, - dbName: string, - schema: string, - ): Promise { - // eslint-disable-next-line @backstage/no-forbidden-package-imports - const { - DefaultAzureCredential, - ManagedIdentityCredential, - ClientSecretCredential, - } = require('@azure/identity'); - - const tokenConfig = dbConfig.getOptionalConfig( - 'connection.tokenCredential', - ); - - const clientId = tokenConfig?.getOptionalString('clientId'); - const tenantId = tokenConfig?.getOptionalString('tenantId'); - const clientSecret = tokenConfig?.getOptionalString('clientSecret'); - let credential; - - /** - * Determine which TokenCredential to use based on provided config - * 1. If clientId, tenantId and clientSecret are provided, use ClientSecretCredential - * 2. If only clientId is provided, use ManagedIdentityCredential with user-assigned identity - * 3. Otherwise, use DefaultAzureCredential (which may use system-assigned identity among other methods) - */ - if (clientId && tenantId && clientSecret) { - credential = new ClientSecretCredential(tenantId, clientId, clientSecret); - } else if (clientId) { - credential = new ManagedIdentityCredential(clientId); - } else { - credential = new DefaultAzureCredential(); - } - - const ssl = this.handlePostgresSSL(dbConfig); - - // Create a password function that fetches fresh Azure AD tokens - // The pg driver supports async password functions, enabling automatic token renewal - const passwordFn = async () => { - const token = await credential.getToken( - 'https://ossrdbms-aad.database.windows.net/.default', - ); - - if (!token) { - throw new Error( - 'Failed to acquire Azure access token for database authentication', - ); - } - - return token.token; - }; - - // Create adapter with Azure AD token function for automatic renewal - // The pg driver will call passwordFn on each new connection, ensuring fresh tokens - return TypeORMAdapter.newAdapter({ - type: 'postgres', - host: dbConfig.getString('connection.host'), - port: dbConfig.getNumber('connection.port'), - username: dbConfig.getString('connection.user'), - password: passwordFn as any, // TypeORM types don't include function, but pg driver supports it - ssl, - database: dbName, - schema: schema, - poolSize: dbConfig.getOptionalNumber('knexConfig.pool.max'), - extra: { - // Set max connection lifetime to 50 minutes (tokens expire after ~60 minutes) - // This ensures connections are recycled before tokens expire - idleTimeoutMillis: 50 * 60 * 1000, - }, - }); - } - - private handlePostgresSSL( - dbConfig: Config, - ): boolean | TlsOptions | undefined { - const connection = dbConfig.getOptional( - 'connection', - ); - if (!connection) { - return undefined; - } - - if (typeof connection === 'string' || connection instanceof String) { - throw new Error( - `rbac backend plugin doesn't support postgres connection in a string format yet`, - ); - } - - const ssl: boolean | ConnectionOptions | undefined = connection.ssl; - - if (ssl === undefined) { - return undefined; - } - - if (typeof ssl === 'boolean') { - return ssl; - } - - if (typeof ssl === 'object') { - const { ca, rejectUnauthorized } = ssl as ConnectionOptions; - const tlsOpts = { ca, rejectUnauthorized }; - - // SSL object was defined with some options that we don't support yet. - if (Object.values(tlsOpts).every(el => el === undefined)) { - return true; - } - - return tlsOpts; - } - - return undefined; - } -} diff --git a/plugins/rbac-backend/src/database/conditional-storage.test.ts b/plugins/rbac-backend/src/database/conditional-storage.test.ts deleted file mode 100644 index de3725a82e..0000000000 --- a/plugins/rbac-backend/src/database/conditional-storage.test.ts +++ /dev/null @@ -1,669 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - mockServices, - TestDatabaseId, - TestDatabases, -} from '@backstage/backend-test-utils'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; - -import * as Knex from 'knex'; -import { createTracker, MockClient } from 'knex-mock-client'; - -import type { - PermissionInfo, - RoleConditionalPolicyDecision, -} from '@backstage-community/plugin-rbac-common'; - -import { - CONDITIONAL_TABLE, - ConditionalPolicyDecisionDAO, - DataBaseConditionalStorage, -} from './conditional-storage'; -import { migrate } from './migration'; - -jest.setTimeout(60000); - -describe('DataBaseConditionalStorage', () => { - const databases = TestDatabases.create({ - ids: ['POSTGRES_13', 'SQLITE_3'], - }); - - const conditionDao1: ConditionalPolicyDecisionDAO = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - permissions: '[{"action":"read","name":"catalog.entity.read"}]', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - conditionsJson: - `{` + - `"rule":"IS_ENTITY_OWNER",` + - `"resourceType":"catalog-entity",` + - `"params":{"claims":["group:default/test-group"]}` + - `}`, - }; - const conditionDao2: ConditionalPolicyDecisionDAO = { - pluginId: 'test', - resourceType: 'test-entity', - permissions: '[{"action": "delete", "name": "catalog.entity.delete"}]', - roleEntityRef: 'role:default/test-2', - result: AuthorizeResult.CONDITIONAL, - conditionsJson: - `{` + - `"rule": "IS_ENTITY_OWNER",` + - `"resourceType": "test-entity",` + - `"params": {"claims": ["group:default/test-group"]}` + - `}`, - }; - const condition1: RoleConditionalPolicyDecision = { - id: 1, - pluginId: 'catalog', - resourceType: 'catalog-entity', - permissionMapping: [{ action: 'read', name: 'catalog.entity.read' }], - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/test-group'], - }, - }, - }; - const condition2: RoleConditionalPolicyDecision = { - id: 2, - pluginId: 'test', - resourceType: 'test-entity', - permissionMapping: [{ action: 'delete', name: 'catalog.entity.delete' }], - roleEntityRef: 'role:default/test-2', - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'test-entity', - params: { - claims: ['group:default/test-group'], - }, - }, - }; - - async function createDatabase(databaseId: TestDatabaseId) { - const knex = await databases.init(databaseId); - const mockDatabaseService = mockServices.database.mock({ - getClient: async () => knex, - migrations: { skip: false }, - }); - - await migrate(mockDatabaseService); - return { - knex, - db: new DataBaseConditionalStorage(knex), - }; - } - - describe('filterConditions', () => { - it.each(databases.eachSupportedId())( - 'should return all conditions', - async databaseId => { - const { knex, db } = await createDatabase(databaseId); - await knex(CONDITIONAL_TABLE).insert( - conditionDao1, - ); - await knex(CONDITIONAL_TABLE).insert( - conditionDao2, - ); - - const conditions = await db.filterConditions(); - expect(conditions.length).toEqual(2); - - expect(conditions[0]).toEqual(condition1); - expect(conditions[1]).toEqual(condition2); - }, - ); - - it.each(databases.eachSupportedId())( - 'should return condition by roleEntityRef', - async databaseId => { - const { knex, db } = await createDatabase(databaseId); - await knex(CONDITIONAL_TABLE).insert( - conditionDao1, - ); - await knex(CONDITIONAL_TABLE).insert( - conditionDao2, - ); - - const conditions = await db.filterConditions(`role:default/test`); - expect(conditions.length).toEqual(1); - - expect(conditions[0]).toEqual(condition1); - }, - ); - - it.each(databases.eachSupportedId())( - 'should return condition by pluginId', - async databaseId => { - const { knex, db } = await createDatabase(databaseId); - await knex(CONDITIONAL_TABLE).insert( - conditionDao1, - ); - await knex(CONDITIONAL_TABLE).insert( - conditionDao2, - ); - - const conditions = await db.filterConditions(undefined, 'catalog'); - expect(conditions.length).toEqual(1); - - expect(conditions[0]).toEqual(condition1); - }, - ); - - it.each(databases.eachSupportedId())( - 'should return condition by pluginId', - async databaseId => { - const { knex, db } = await createDatabase(databaseId); - await knex(CONDITIONAL_TABLE).insert( - conditionDao1, - ); - await knex(CONDITIONAL_TABLE).insert( - conditionDao2, - ); - - const conditions = await db.filterConditions( - undefined, - undefined, - 'catalog-entity', - ); - expect(conditions.length).toEqual(1); - - expect(conditions[0]).toEqual(condition1); - }, - ); - - it.each(databases.eachSupportedId())( - 'should return condition by action', - async databaseId => { - const { knex, db } = await createDatabase(databaseId); - await knex(CONDITIONAL_TABLE).insert( - conditionDao1, - ); - await knex(CONDITIONAL_TABLE).insert( - conditionDao2, - ); - - const conditions = await db.filterConditions( - undefined, - undefined, - undefined, - ['read'], - ); - expect(conditions.length).toEqual(1); - - expect(conditions[0]).toEqual(condition1); - }, - ); - - it.each(databases.eachSupportedId())( - 'should return condition by permission name', - async databaseId => { - const { knex, db } = await createDatabase(databaseId); - await knex(CONDITIONAL_TABLE).insert( - conditionDao1, - ); - await knex(CONDITIONAL_TABLE).insert( - conditionDao2, - ); - - const conditions = await db.filterConditions( - undefined, - undefined, - undefined, - undefined, - ['catalog.entity.read'], - ); - expect(conditions.length).toEqual(1); - - expect(conditions[0]).toEqual(condition1); - }, - ); - - it.each(databases.eachSupportedId())( - 'should return condition by all arguments', - async databaseId => { - const { knex, db } = await createDatabase(databaseId); - await knex(CONDITIONAL_TABLE).insert( - conditionDao1, - ); - await knex(CONDITIONAL_TABLE).insert( - conditionDao2, - ); - - const conditions = await db.filterConditions( - 'role:default/test', - 'catalog', - 'catalog-entity', - ['read'], - ['catalog.entity.read'], - ); - expect(conditions.length).toEqual(1); - - expect(conditions[0]).toEqual(condition1); - }, - ); - }); - - describe('createCondition', () => { - it.each(databases.eachSupportedId())( - 'should successfully create new conditional policy', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - - const id = await db.createCondition(condition1); - - const condition = await knex( - CONDITIONAL_TABLE, - ).where('id', id); - expect(condition.length).toEqual(1); - expect(condition[0]).toEqual({ - id: 1, - ...conditionDao1, - }); - }, - ); - - it.each(databases.eachSupportedId())( - 'should throw conflict error', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - - await knex(CONDITIONAL_TABLE).insert( - conditionDao1, - ); - - await expect(async () => { - await db.createCondition(condition1); - }).rejects.toThrow( - `Found condition with conflicted permission action '["read"]'. Role could have multiple conditions for the same resource type 'catalog-entity', but with different permission action sets.`, - ); - }, - ); - - it('should throw failed to create metadata error, because inserted result is undefined', async () => { - const knex = Knex.knex({ client: MockClient }); - const tracker = createTracker(knex); - tracker.on.select(CONDITIONAL_TABLE).response(undefined); - tracker.on.insert(CONDITIONAL_TABLE).response(undefined); - - const db = new DataBaseConditionalStorage(knex); - - await expect(async () => { - await db.createCondition(condition1); - }).rejects.toThrow(`Failed to create the condition.`); - }); - }); - - describe('checkConflictedConditions', () => { - it.each(databases.eachSupportedId())( - 'should check conflicted condition', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - await knex(CONDITIONAL_TABLE).insert( - conditionDao1, - ); - - await expect(async () => { - await db.checkConflictedConditions( - 'role:default/test', - 'catalog-entity', - 'catalog', - ['read'], - ); - }).rejects.toThrow( - `Found condition with conflicted permission action '["read"]'. Role could have multiple conditions for the same resource type 'catalog-entity', but with different permission action sets.`, - ); - }, - ); - - it.each(databases.eachSupportedId())( - 'should fail check, when there is condition with one conflicted action "read"', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - const conditionDaoWithFewActions = { - ...conditionDao1, - permissions: - '[{"action":"read","name":"catalog.entity.read"}, {"action":"delete","name":"catalog.entity.delete"}]', - }; - await knex(CONDITIONAL_TABLE).insert( - conditionDaoWithFewActions, - ); - - await expect(async () => { - await db.checkConflictedConditions( - 'role:default/test', - 'catalog-entity', - 'catalog', - ['read'], - ); - }).rejects.toThrow( - `Found condition with conflicted permission action '["read"]'. Role could have multiple conditions for the same resource type 'catalog-entity', but with different permission action sets.`, - ); - }, - ); - - it.each(databases.eachSupportedId())( - 'should fail check, when there is one condition with two conflicted actions "read" and "update"', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - const conditionDaoWithFewActions = { - ...conditionDao1, - permissions: - '[{"action":"read","name":"catalog.entity.read"}, {"action":"delete","name":"catalog.entity.delete"}, {"action":"update","name":"catalog.entity.update"}]', - }; - await knex(CONDITIONAL_TABLE).insert( - conditionDaoWithFewActions, - ); - - await expect(async () => { - await db.checkConflictedConditions( - 'role:default/test', - 'catalog-entity', - 'catalog', - ['read', 'update'], - ); - }).rejects.toThrow( - `Found condition with conflicted permission action '["read","update"]'. Role could have multiple conditions for the same resource type 'catalog-entity', but with different permission action sets.`, - ); - }, - ); - - it.each(databases.eachSupportedId())( - 'should fail check, when there is condition with three conflicted actions "read", "update", "delete"', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - const conditionDaoWithFewActions = { - ...conditionDao1, - permissions: - '[{"action":"read","name":"catalog.entity.read"}, {"action":"delete","name":"catalog.entity.delete"}, {"action":"update","name":"catalog.entity.update"}]', - }; - await knex(CONDITIONAL_TABLE).insert( - conditionDaoWithFewActions, - ); - - await expect(async () => { - await db.checkConflictedConditions( - 'role:default/test', - 'catalog-entity', - 'catalog', - ['read', 'update', 'delete'], - ); - }).rejects.toThrow( - `Found condition with conflicted permission action '["read","update","delete"]'. Role could have multiple conditions for the same resource type 'catalog-entity', but with different permission action sets.`, - ); - }, - ); - - it.each(databases.eachSupportedId())( - 'should pass check, when there is one non conflicted condition', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - const filterConditionsSpy = jest.spyOn(db, 'filterConditions'); - - const conditionDaoWithFewActions = { - ...conditionDao1, - permissions: - '[{"action":"read","name":"catalog.entity.read"}, {"action":"update","name":"catalog.entity.update"}]', - }; - await knex(CONDITIONAL_TABLE).insert( - conditionDaoWithFewActions, - ); - - await db.checkConflictedConditions( - 'role:default/test', - 'catalog-entity', - 'catalog', - ['delete'], - ); - - expect(filterConditionsSpy).toHaveBeenCalledTimes(1); - const result = await filterConditionsSpy.mock.results[0].value; - expect(result).toEqual([ - { - ...condition1, - permissionMapping: [ - { name: 'catalog.entity.read', action: 'read' }, - { name: 'catalog.entity.update', action: 'update' }, - ], - }, - ]); - }, - ); - - it.each(databases.eachSupportedId())( - 'should pass check, when there are no conditions', - async databasesId => { - const { db } = await createDatabase(databasesId); - const filterConditionsSpy = jest.spyOn(db, 'filterConditions'); - - await db.checkConflictedConditions( - 'role:default/test', - 'catalog-entity', - 'catalog', - ['read'], - ); - - expect(filterConditionsSpy).toHaveBeenCalledTimes(1); - const result = await filterConditionsSpy.mock.results[0].value; - expect(result).toEqual([]); - }, - ); - }); - - describe('getCondition', () => { - it.each(databases.eachSupportedId())( - 'should return condition by id', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - await knex(CONDITIONAL_TABLE).insert( - conditionDao1, - ); - - const condition = await db.getCondition(1); - - expect(condition).toEqual(condition1); - }, - ); - - it.each(databases.eachSupportedId())( - 'should not find condition', - async databasesId => { - const { db } = await createDatabase(databasesId); - - const condition = await db.getCondition(1); - - expect(condition).toBeUndefined(); - }, - ); - }); - - describe('deleteCondition', () => { - it.each(databases.eachSupportedId())( - 'should delete condition by id', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - await knex(CONDITIONAL_TABLE).insert( - conditionDao1, - ); - - await db.deleteCondition(1); - - const conditions = await knex - .table(CONDITIONAL_TABLE) - .select(); - expect(conditions.length).toEqual(0); - }, - ); - - it.each(databases.eachSupportedId())( - 'should not find condition', - async databasesId => { - const { db } = await createDatabase(databasesId); - - await expect(async () => { - await db.deleteCondition(1); - }).rejects.toThrow('Condition with id 1 was not found'); - }, - ); - }); - - describe('updateCondition', () => { - it.each(databases.eachSupportedId())( - 'should update condition with added new action', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - await knex(CONDITIONAL_TABLE).insert( - conditionDao1, - ); - - const updateCondition: RoleConditionalPolicyDecision = { - ...condition1, - permissionMapping: [ - { name: 'catalog.entity.read', action: 'read' }, - { name: 'catalog.entity.delete', action: 'delete' }, - ], - }; - await db.updateCondition(1, updateCondition); - - const condition = await knex - .table(CONDITIONAL_TABLE) - .select() - .where('id', 1); - expect(condition).toEqual([ - { - ...conditionDao1, - permissions: - '[{"name":"catalog.entity.read","action":"read"},{"name":"catalog.entity.delete","action":"delete"}]', - id: 1, - }, - ]); - }, - ); - - it.each(databases.eachSupportedId())( - 'should update condition with removed one action', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - await knex(CONDITIONAL_TABLE).insert({ - ...conditionDao1, - permissions: - '[{"action":"read","name":"catalog.entity.read"}, {"action":"delete","name":"catalog.entity.delete"}]', - }); - - const updateCondition: RoleConditionalPolicyDecision = { - ...condition1, - permissionMapping: [{ name: 'catalog.entity.read', action: 'read' }], - }; - await db.updateCondition(1, updateCondition); - - const condition = await knex - .table(CONDITIONAL_TABLE) - .select() - .where('id', 1); - expect(condition).toEqual([ - { - ...conditionDao1, - permissions: '[{"name":"catalog.entity.read","action":"read"}]', - id: 1, - }, - ]); - }, - ); - - it.each(databases.eachSupportedId())( - 'should fail to update condition, because condition not found', - async databasesId => { - const { db } = await createDatabase(databasesId); - - const updateCondition: RoleConditionalPolicyDecision = { - ...condition1, - permissionMapping: [ - { name: 'catalog.entity.name', action: 'read' }, - { name: 'catalog.entity.delete', action: 'delete' }, - ], - }; - await expect(async () => { - await db.updateCondition(1, updateCondition); - }).rejects.toThrow('Condition with id 1 was not found'); - }, - ); - - it.each(databases.eachSupportedId())( - 'should fail to update condition, because found condition with conflict', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - - await knex(CONDITIONAL_TABLE).insert( - conditionDao1, - ); - await knex(CONDITIONAL_TABLE).insert({ - ...conditionDao1, - permissions: - '[{"name": "catalog.entity.delete", "action": "delete"}]', - }); - - const updateCondition: RoleConditionalPolicyDecision = { - ...condition1, - permissionMapping: [ - { name: 'catalog.entity.read', action: 'read' }, - { name: 'catalog.entity.delete', action: 'delete' }, - ], - }; - await expect(async () => { - await db.updateCondition(1, updateCondition); - }).rejects.toThrow( - `Found condition with conflicted permission action '["delete"]'. Role could have multiple conditions for the same resource type 'catalog-entity', but with different permission action sets.`, - ); - }, - ); - - it.each(databases.eachSupportedId())( - 'should fail to update condition, because found condition with two conflicted actions', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - - await knex(CONDITIONAL_TABLE).insert( - conditionDao1, - ); - await knex(CONDITIONAL_TABLE).insert({ - ...conditionDao1, - permissions: - '[{"name": "catalog.entity.delete", "action": "delete"}, {"name": "catalog.entity.read", "action": "read"}]', - }); - - const updateCondition: RoleConditionalPolicyDecision = { - ...condition1, - permissionMapping: [ - { name: 'catalog.entity.read', action: 'read' }, - { name: 'catalog.entity.delete', action: 'delete' }, - ], - }; - await expect(async () => { - await db.updateCondition(1, updateCondition); - }).rejects.toThrow( - `Found condition with conflicted permission action '["read","delete"]'. Role could have multiple ` + - `conditions for the same resource type 'catalog-entity', but with different permission action sets.`, - ); - }, - ); - }); -}); diff --git a/plugins/rbac-backend/src/database/conditional-storage.ts b/plugins/rbac-backend/src/database/conditional-storage.ts deleted file mode 100644 index 1d09c152b7..0000000000 --- a/plugins/rbac-backend/src/database/conditional-storage.ts +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { ConflictError, InputError, NotFoundError } from '@backstage/errors'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; - -import { Knex } from 'knex'; - -import type { - PermissionAction, - PermissionInfo, - RoleConditionalPolicyDecision, -} from '@backstage-community/plugin-rbac-common'; - -export const CONDITIONAL_TABLE = 'role-condition-policies'; - -export interface ConditionalPolicyDecisionDAO { - result: AuthorizeResult.CONDITIONAL; - id?: number; - roleEntityRef: string; - permissions: string; - pluginId: string; - resourceType: string; - conditionsJson: string; -} - -export interface ConditionalStorage { - filterConditions( - roleEntityRef?: string | string[], - pluginId?: string, - resourceType?: string, - actions?: PermissionAction[], - permissionNames?: string[], - trx?: Knex.Transaction | Knex, - ): Promise[]>; - createCondition( - conditionalDecision: RoleConditionalPolicyDecision, - ): Promise; - checkConflictedConditions( - roleEntityRef: string, - resourceType: string, - pluginId: string, - queryPermissionNames: string[], - idToExclude?: number, - ): Promise; - getCondition( - id: number, - trx?: Knex.Transaction | Knex, - ): Promise | undefined>; - deleteCondition(id: number): Promise; - updateCondition( - id: number, - conditionalDecision: RoleConditionalPolicyDecision, - trx?: Knex.Transaction, - ): Promise; -} - -export class DataBaseConditionalStorage implements ConditionalStorage { - public constructor(private readonly knex: Knex) {} - - async filterConditions( - roleEntityRef?: string | string[], - pluginId?: string, - resourceType?: string, - actions?: PermissionAction[], - permissionNames?: string[], - trx?: Knex.Transaction | Knex, - ): Promise[]> { - const db = trx ?? this.knex; - const daoRaws = await db.table(CONDITIONAL_TABLE).where(builder => { - if (pluginId) { - builder.where('pluginId', pluginId); - } - if (resourceType) { - builder.where('resourceType', resourceType); - } - if (roleEntityRef) { - if (Array.isArray(roleEntityRef)) { - builder.whereIn('roleEntityRef', roleEntityRef); - } else { - builder.where('roleEntityRef', roleEntityRef); - } - } - }); - - let conditions: RoleConditionalPolicyDecision[] = []; - if (daoRaws) { - conditions = daoRaws.map(dao => this.daoToConditionalDecision(dao)); - } - - if (permissionNames && permissionNames.length > 0) { - conditions = conditions.filter(condition => { - return permissionNames.every(permissionName => - condition.permissionMapping - .map(permInfo => permInfo.name) - .includes(permissionName), - ); - }); - } - - if (actions && actions.length > 0) { - conditions = conditions.filter(condition => { - return actions.every(action => - condition.permissionMapping - .map(permInfo => permInfo.action) - .includes(action), - ); - }); - } - - return conditions; - } - - async createCondition( - conditionalDecision: RoleConditionalPolicyDecision, - ): Promise { - await this.checkConflictedConditions( - conditionalDecision.roleEntityRef, - conditionalDecision.resourceType, - conditionalDecision.pluginId, - conditionalDecision.permissionMapping.map(permInfo => permInfo.action), - ); - - const conditionRaw = this.toDAO(conditionalDecision); - const result = await this.knex - .table(CONDITIONAL_TABLE) - .insert(conditionRaw) - .returning('id'); - if (result && result?.length > 0) { - return result[0].id; - } - - throw new Error(`Failed to create the condition.`); - } - - async checkConflictedConditions( - roleEntityRef: string, - resourceType: string, - pluginId: string, - queryConditionActions: PermissionAction[], - idToExclude?: number, - trx?: Knex.Transaction | Knex, - ): Promise { - const db = trx ?? this.knex; - let conditionsForTheSameResource = await this.filterConditions( - roleEntityRef, - pluginId, - resourceType, - undefined, - undefined, - db, - ); - conditionsForTheSameResource = conditionsForTheSameResource.filter( - c => c.id !== idToExclude, - ); - - if (conditionsForTheSameResource) { - const conflictedCondition = conditionsForTheSameResource.find( - condition => { - const conditionActions = condition.permissionMapping.map( - permInfo => permInfo.action, - ); - return queryConditionActions.some(action => - conditionActions.includes(action), - ); - }, - ); - - if (conflictedCondition) { - const conflictedActions = queryConditionActions.filter(action => - conflictedCondition.permissionMapping.some(p => p.action === action), - ); - throw new ConflictError( - `Found condition with conflicted permission action '${JSON.stringify( - conflictedActions, - )}'. Role could have multiple ` + - `conditions for the same resource type '${conflictedCondition.resourceType}', but with different permission action sets.`, - ); - } - } - } - - async getCondition( - id: number, - trx?: Knex.Transaction | Knex, - ): Promise | undefined> { - const db = trx ?? this.knex; - const daoRaw = await db.table(CONDITIONAL_TABLE).where('id', id).first(); - - if (daoRaw) { - return this.daoToConditionalDecision(daoRaw); - } - return undefined; - } - - async deleteCondition(id: number): Promise { - const condition = await this.getCondition(id); - if (!condition) { - throw new NotFoundError(`Condition with id ${id} was not found`); - } - await this.knex?.table(CONDITIONAL_TABLE).delete().whereIn('id', [id]); - } - - async updateCondition( - id: number, - conditionalDecision: RoleConditionalPolicyDecision, - trx?: Knex.Transaction, - ): Promise { - const db = trx ?? this.knex; - const condition = await this.getCondition(id, db); - if (!condition) { - throw new NotFoundError(`Condition with id ${id} was not found`); - } - - await this.checkConflictedConditions( - conditionalDecision.roleEntityRef, - conditionalDecision.resourceType, - conditionalDecision.pluginId, - conditionalDecision.permissionMapping.map(perm => perm.action), - id, - db, - ); - - const conditionRaw = this.toDAO(conditionalDecision); - conditionRaw.id = id; - const result = await db - .table(CONDITIONAL_TABLE) - .where('id', conditionRaw.id) - .update(conditionRaw) - .returning('id'); - - if (!result || result.length === 0) { - throw new Error(`Failed to update the condition with id: ${id}.`); - } - } - - private toDAO( - conditionalDecision: RoleConditionalPolicyDecision, - ): ConditionalPolicyDecisionDAO { - const { - result, - pluginId, - resourceType, - conditions, - roleEntityRef, - permissionMapping, - } = conditionalDecision; - const conditionsJson = JSON.stringify(conditions); - return { - result, - pluginId, - resourceType, - conditionsJson, - roleEntityRef, - permissions: JSON.stringify(permissionMapping), - }; - } - - private daoToConditionalDecision( - dao: ConditionalPolicyDecisionDAO, - ): RoleConditionalPolicyDecision { - if (!dao.id) { - throw new InputError(`Missed id in the dao object: ${dao}`); - } - const { - id, - result, - pluginId, - resourceType, - conditionsJson, - roleEntityRef, - permissions, - } = dao; - - const conditions = JSON.parse(conditionsJson); - return { - id, - result, - pluginId, - resourceType, - conditions, - roleEntityRef, - permissionMapping: JSON.parse(permissions), - }; - } -} diff --git a/plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.test.ts b/plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.test.ts deleted file mode 100644 index 461085adea..0000000000 --- a/plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - mockServices, - TestDatabaseId, - TestDatabases, -} from '@backstage/backend-test-utils'; -import { migrate } from './migration'; -import { - PermissionDependentPluginDatabaseStore, - PermissionDependentPluginDTO, - PLUGINS_TABLE, -} from './extra-permission-enabled-plugins-storage'; - -describe('PermissionDependentPluginDatabaseStore', () => { - const databases = TestDatabases.create({ - ids: ['POSTGRES_13', 'SQLITE_3'], - }); - - async function createDatabase(databaseId: TestDatabaseId) { - const knex = await databases.init(databaseId); - const mockDatabaseService = mockServices.database.mock({ - getClient: async () => knex, - migrations: { skip: false }, - }); - - await migrate(mockDatabaseService); - return { - knex, - db: new PermissionDependentPluginDatabaseStore(knex), - }; - } - - it.each(databases.eachSupportedId())( - 'should return list plugins', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - const expectedPlugin = { pluginId: 'catalog' }; - await knex(PLUGINS_TABLE).insert( - expectedPlugin, - ); - - const plugins = await db.getPlugins(); - - expect(plugins).toEqual([expectedPlugin]); - }, - ); - - it.each(databases.eachSupportedId())( - 'should delete plugin', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - const expectedPlugin = { pluginId: 'catalog' }; - await knex(PLUGINS_TABLE).insert( - expectedPlugin, - ); - - await db.deletePlugins(['catalog']); - const plugins = await db.getPlugins(); - - expect(plugins).toEqual([]); - }, - ); - - it.each(databases.eachSupportedId())( - 'should add plugin', - async databasesId => { - const { db } = await createDatabase(databasesId); - const expectedPlugin = { pluginId: 'catalog' }; - - await db.addPlugins([expectedPlugin]); - - const plugins = await db.getPlugins(); - - expect(plugins).toEqual([expectedPlugin]); - }, - ); -}); diff --git a/plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.ts b/plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.ts deleted file mode 100644 index 5db4d55601..0000000000 --- a/plugins/rbac-backend/src/database/extra-permission-enabled-plugins-storage.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Knex } from 'knex'; - -export const PLUGINS_TABLE = 'extra_permission_enabled_plugins'; - -export interface PermissionDependentPluginDTO { - pluginId: string; -} - -/** - * This interface defines the methods for managing the extra permission-enabled plugins in the database. - */ -export interface PermissionDependentPluginStore { - // Fetches the extra plugin list from database. - // This list contains information about extra plugins that supports Backstage permissions framework. - getPlugins(): Promise; - - // Adds the plugins to the database. - addPlugins(plugins: PermissionDependentPluginDTO[]): Promise; - - // Removes plugins from the database by pluginIds. - deletePlugins(pluginIds: string[]): Promise; -} - -export class PermissionDependentPluginDatabaseStore implements PermissionDependentPluginStore { - public constructor(private readonly knex: Knex) {} - - async getPlugins(): Promise { - return await this.knex - .table(PLUGINS_TABLE) - .select('pluginId'); - } - - async addPlugins(plugins: PermissionDependentPluginDTO[]): Promise { - await this.knex.table(PLUGINS_TABLE).insert(plugins); - } - - async deletePlugins(pluginIds: string[]): Promise { - await this.knex - .table(PLUGINS_TABLE) - .whereIn('pluginId', pluginIds) - .delete(); - } -} diff --git a/plugins/rbac-backend/src/database/migration.ts b/plugins/rbac-backend/src/database/migration.ts deleted file mode 100644 index ebdd04ccbe..0000000000 --- a/plugins/rbac-backend/src/database/migration.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - DatabaseService, - resolvePackagePath, -} from '@backstage/backend-plugin-api'; - -const migrationsDir = resolvePackagePath( - '@internal/plugin-rbac-backend', // Package name - 'migrations', // Migrations directory -); - -export async function migrate(databaseManager: DatabaseService) { - const knex = await databaseManager.getClient(); - - if (!databaseManager.migrations?.skip) { - await knex.migrate.latest({ - directory: migrationsDir, - }); - } -} diff --git a/plugins/rbac-backend/src/database/role-metadata.test.ts b/plugins/rbac-backend/src/database/role-metadata.test.ts deleted file mode 100644 index 2ddb690cda..0000000000 --- a/plugins/rbac-backend/src/database/role-metadata.test.ts +++ /dev/null @@ -1,965 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - mockServices, - TestDatabaseId, - TestDatabases, -} from '@backstage/backend-test-utils'; - -import * as Knex from 'knex'; -import { createTracker, MockClient } from 'knex-mock-client'; - -import { migrate } from './migration'; -import { - DataBaseRoleMetadataStorage, - ROLE_METADATA_TABLE, - RoleMetadataDao, -} from './role-metadata'; -import { RBACFilter } from '../permissions'; - -jest.setTimeout(60000); - -describe('role-metadata-db-table', () => { - const databases = TestDatabases.create({ - ids: ['POSTGRES_13', 'SQLITE_3'], - }); - const modifiedBy = 'user:default/some-user'; - - async function createDatabase(databaseId: TestDatabaseId) { - const knex = await databases.init(databaseId); - const mockDatabaseService = mockServices.database.mock({ - getClient: async () => knex, - migrations: { skip: false }, - }); - - await migrate(mockDatabaseService); - const config = mockServices.rootConfig(); - return { - knex, - config, - db: new DataBaseRoleMetadataStorage(knex), - }; - } - - async function createDatabaseWithDefaultRole(databaseId: TestDatabaseId) { - const knex = await databases.init(databaseId); - const mockDatabaseService = mockServices.database.mock({ - getClient: async () => knex, - migrations: { skip: false }, - }); - - await migrate(mockDatabaseService); - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: { - defaultRole: 'role:default/default-role', - basicPermissions: [ - { permission: 'catalog.entity.read', policy: 'read' }, - ], - }, - }, - }, - }, - }); - return { - knex, - config, - db: new DataBaseRoleMetadataStorage(knex), - }; - } - - /** Normalize DAO isDefault (0/1 from SQLite) to boolean for assertion. */ - function withBooleanIsDefault( - rows: RoleMetadataDao[], - ): (RoleMetadataDao & { isDefault?: boolean })[] { - return rows.map(r => ({ ...r, isDefault: Boolean(r.isDefault) })); - } - - describe('syncDefaultRoleMetadata', () => { - it.each(databases.eachSupportedId())( - 'should remove default role from DB when not in config', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/default-role', - source: 'configuration', - modifiedBy, - isDefault: true, - }); - await db.syncDefaultRoleMetadata(); - const found = await db.findRoleMetadata('role:default/default-role'); - expect(found).toBeUndefined(); - expect(db.getCachedDefaultRoleMetadata()).toBeUndefined(); - }, - ); - - it.each(databases.eachSupportedId())( - 'should insert default role in DB when in config', - async databasesId => { - const { db } = await createDatabaseWithDefaultRole(databasesId); - await db.syncDefaultRoleMetadata('role:default/default-role'); - const found = await db.findRoleMetadata('role:default/default-role'); - expect(found).toBeDefined(); - expect(found!.roleEntityRef).toBe('role:default/default-role'); - expect(found!.source).toBe('configuration'); - expect(found!.isDefault).toBeTruthy(); - expect(db.getCachedDefaultRoleMetadata()?.roleEntityRef).toBe( - 'role:default/default-role', - ); - }, - ); - - it.each(databases.eachSupportedId())( - 'should delete old default role from DB when config has a new default role', - async databasesId => { - const { knex } = await createDatabase(databasesId); - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/old-default', - source: 'configuration', - modifiedBy, - isDefault: true, - }); - const db = new DataBaseRoleMetadataStorage(knex); - await db.syncDefaultRoleMetadata('role:default/new-default'); - - const oldFound = await db.findRoleMetadata('role:default/old-default'); - expect(oldFound).toBeUndefined(); - - const newFound = await db.findRoleMetadata('role:default/new-default'); - expect(newFound).toBeDefined(); - expect(newFound!.roleEntityRef).toBe('role:default/new-default'); - expect(newFound!.isDefault).toBeTruthy(); - expect(db.getCachedDefaultRoleMetadata()?.roleEntityRef).toBe( - 'role:default/new-default', - ); - }, - ); - - it.each(databases.eachSupportedId())( - 'should throw error when role exists with incompatible source', - async databasesId => { - const { knex } = await createDatabase(databasesId); - - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/csv-role', - source: 'csv-file', - modifiedBy, - isDefault: false, - }); - const db = new DataBaseRoleMetadataStorage(knex); - - // Try to sync this role as default (which requires 'configuration' source) - await expect( - db.syncDefaultRoleMetadata('role:default/csv-role'), - ).rejects.toThrow( - "Role 'role:default/csv-role' has incompatible source. Expected 'configuration' source value", - ); - }, - ); - }); - - describe('findRoleMetadata', () => { - it.each(databases.eachSupportedId())( - 'should return undefined', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - const trx = await knex.transaction(); - try { - const roleMetadata = await db.findRoleMetadata( - 'role:default/some-super-important-role', - trx, - ); - await trx.commit(); - expect(roleMetadata).toBeUndefined(); - } catch (err) { - await trx.rollback(); - throw err; - } - }, - ); - - it.each(databases.eachSupportedId())( - 'should return found metadata', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/some-super-important-role', - source: 'rest', - modifiedBy, - }); - - const trx = await knex.transaction(); - try { - const roleMetadata = await db.findRoleMetadata( - 'role:default/some-super-important-role', - trx, - ); - await trx.commit(); - expect( - withBooleanIsDefault(roleMetadata ? [roleMetadata] : [])[0], - ).toEqual({ - author: null, - createdAt: null, - description: null, - id: 1, - isDefault: false, - lastModified: null, - modifiedBy, - owner: null, - roleEntityRef: 'role:default/some-super-important-role', - source: 'rest', - }); - } catch (err) { - await trx.rollback(); - throw err; - } - }, - ); - }); - - describe('filterRoleMetadata', () => { - it.each(databases.eachSupportedId())( - 'should return undefined', - async databasesId => { - const { db } = await createDatabase(databasesId); - try { - const roleMetadata = await db.filterRoleMetadata('rest'); - expect(roleMetadata).toEqual([]); - } catch (err) { - throw err; - } - }, - ); - - it.each(databases.eachSupportedId())( - 'should return found metadata', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/some-super-important-role', - source: 'rest', - modifiedBy, - }); - - try { - const roleMetadata = await db.filterRoleMetadata('rest'); - expect(withBooleanIsDefault(roleMetadata)).toEqual([ - { - author: null, - createdAt: null, - description: null, - id: 1, - isDefault: false, - lastModified: null, - modifiedBy, - owner: null, - roleEntityRef: 'role:default/some-super-important-role', - source: 'rest', - }, - ]); - } catch (err) { - throw err; - } - }, - ); - }); - - describe('filterForOwnerRoleMetadata', () => { - it.each(databases.eachSupportedId())( - 'should return undefined', - async databasesId => { - const rbacFilter: RBACFilter = { - key: 'owner', - values: ['user:default/some_user'], - }; - const { db } = await createDatabase(databasesId); - try { - const roleMetadata = await db.filterForOwnerRoleMetadata({ - anyOf: [rbacFilter], - }); - expect(roleMetadata).toEqual([]); - } catch (err) { - throw err; - } - }, - ); - - it.each(databases.eachSupportedId())( - 'should return found metadata for specified filter', - async databasesId => { - const rbacFilter: RBACFilter = { - key: 'owner', - values: ['user:default/some_user'], - }; - const { knex, db } = await createDatabase(databasesId); - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/some-super-important-role', - source: 'rest', - modifiedBy, - owner: 'user:default/some_user', - }); - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/role:default/some-important-role', - source: 'rest', - modifiedBy, - owner: 'user:default/some_other_user', - }); - - try { - const roleMetadata = await db.filterForOwnerRoleMetadata({ - anyOf: [rbacFilter], - }); - expect(withBooleanIsDefault(roleMetadata)).toEqual([ - { - author: null, - createdAt: null, - description: null, - id: 1, - isDefault: false, - lastModified: null, - modifiedBy, - owner: 'user:default/some_user', - roleEntityRef: 'role:default/some-super-important-role', - source: 'rest', - }, - ]); - } catch (err) { - throw err; - } - }, - ); - - it.each(databases.eachSupportedId())( - 'should return found metadata for no filter', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/some-super-important-role', - source: 'rest', - modifiedBy, - owner: 'user:default/some_user', - }); - - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/some-important-role', - source: 'rest', - modifiedBy, - owner: 'user:default/some_other_user', - }); - - try { - const roleMetadata = await db.filterForOwnerRoleMetadata(); - expect(withBooleanIsDefault(roleMetadata)).toEqual([ - { - author: null, - createdAt: null, - description: null, - id: 1, - isDefault: false, - lastModified: null, - modifiedBy, - owner: 'user:default/some_user', - roleEntityRef: 'role:default/some-super-important-role', - source: 'rest', - }, - { - author: null, - createdAt: null, - description: null, - id: 2, - isDefault: false, - lastModified: null, - modifiedBy, - owner: 'user:default/some_other_user', - roleEntityRef: 'role:default/some-important-role', - source: 'rest', - }, - ]); - } catch (err) { - throw err; - } - }, - ); - - it.each(databases.eachSupportedId())( - 'should include cached default role in filtered results', - async databasesId => { - const rbacFilter: RBACFilter = { - key: 'owner', - values: ['user:default/some_user'], - }; - const { knex, db } = await createDatabase(databasesId); - - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/regular-role', - source: 'rest', - modifiedBy, - owner: 'user:default/some_user', - }); - - await db.syncDefaultRoleMetadata('role:default/default-role'); - - try { - const roleMetadata = await db.filterForOwnerRoleMetadata({ - anyOf: [rbacFilter], - }); - - // Should return regular role + cached default role - expect(roleMetadata.length).toBe(2); - expect(roleMetadata.map(r => r.roleEntityRef).sort()).toEqual([ - 'role:default/default-role', - 'role:default/regular-role', - ]); - - const defaultRole = roleMetadata.find( - r => r.roleEntityRef === 'role:default/default-role', - ); - expect(defaultRole?.isDefault).toBeTruthy(); - expect(defaultRole?.source).toBe('configuration'); - } catch (err) { - throw err; - } - }, - ); - }); - - describe('createRoleMetadata', () => { - it.each(databases.eachSupportedId())( - 'should successfully create new role metadata', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - - const trx = await knex.transaction(); - let id; - try { - id = await db.createRoleMetadata( - { - source: 'configuration', - roleEntityRef: 'role:default/some-super-important-role', - modifiedBy, - }, - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(); - throw err; - } - - const metadata = await knex(ROLE_METADATA_TABLE).where( - 'id', - id, - ); - expect(metadata.length).toEqual(1); - expect(metadata[0]).toMatchObject({ - author: null, - createdAt: null, - roleEntityRef: 'role:default/some-super-important-role', - description: null, - id: 1, - lastModified: null, - modifiedBy, - owner: null, - source: 'configuration', - }); - }, - ); - - it.each(databases.eachSupportedId())( - 'should throw conflict error', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/some-super-important-role', - source: 'configuration', - }); - - const trx = await knex.transaction(); - await expect(async () => { - try { - await db.createRoleMetadata( - { - source: 'configuration', - roleEntityRef: 'role:default/some-super-important-role', - modifiedBy, - }, - - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(); - throw err; - } - }).rejects.toThrow( - `A metadata for role role:default/some-super-important-role has already been stored`, - ); - }, - ); - - it.each(databases.eachSupportedId())( - 'should throw ConflictError when creating role with default role name', - async databasesId => { - const { knex, db } = await createDatabaseWithDefaultRole(databasesId); - await db.syncDefaultRoleMetadata('role:default/default-role'); - - const trx = await knex.transaction(); - await expect(async () => { - try { - await db.createRoleMetadata( - { - source: 'configuration', - roleEntityRef: 'role:default/default-role', - modifiedBy, - }, - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(); - throw err; - } - }).rejects.toThrow( - `A metadata for role role:default/default-role has already been stored`, - ); - }, - ); - - it('should throw failed to create metadata error, because inserted result is an empty array.', async () => { - const knex = Knex.knex({ client: MockClient }); - const tracker = createTracker(knex); - tracker.on.select(ROLE_METADATA_TABLE).response(undefined); - tracker.on.insert(ROLE_METADATA_TABLE).response([]); - - const db = new DataBaseRoleMetadataStorage(knex); - const trx = await knex.transaction(); - - await expect( - db.createRoleMetadata( - { - source: 'configuration', - roleEntityRef: 'role:default/some-super-important-role', - modifiedBy, - }, - trx, - ), - ).rejects.toThrow( - `Failed to create the role metadata: '{"source":"configuration","roleEntityRef":"role:default/some-super-important-role","modifiedBy":"user:default/some-user"}'.`, - ); - }); - - it('should throw failed to create metadata error, because inserted result is undefined.', async () => { - const knex = Knex.knex({ client: MockClient }); - const tracker = createTracker(knex); - tracker.on.select(ROLE_METADATA_TABLE).response(undefined); - tracker.on.insert(ROLE_METADATA_TABLE).response(undefined); - - const db = new DataBaseRoleMetadataStorage(knex); - - await expect(async () => { - const trx = await knex.transaction(); - try { - await db.createRoleMetadata( - { - source: 'configuration', - roleEntityRef: 'role:default/some-super-important-role', - modifiedBy, - }, - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(err); - throw err; - } - }).rejects.toThrow( - `Failed to create the role metadata: '{"source":"configuration","roleEntityRef":"role:default/some-super-important-role","modifiedBy":"user:default/some-user"}'.`, - ); - }); - - it('should throw an error on insert metadata operation', async () => { - const knex = Knex.knex({ client: MockClient }); - const tracker = createTracker(knex); - tracker.on.select(ROLE_METADATA_TABLE).response(undefined); - tracker.on - .insert(ROLE_METADATA_TABLE) - .simulateError('connection refused error'); - - const db = new DataBaseRoleMetadataStorage(knex); - - await expect(async () => { - const trx = await knex.transaction(); - try { - await db.createRoleMetadata( - { - source: 'configuration', - roleEntityRef: 'role:default/some-super-important-role', - modifiedBy, - }, - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(err); - throw err; - } - }).rejects.toThrow('connection refused error'); - }); - }); - - describe('updateRoleMetadata', () => { - it.each(databases.eachSupportedId())( - 'should successfully update role metadata from legacy source to new value', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/some-super-important-role', - source: 'legacy', - }); - - const trx = await knex.transaction(); - try { - await db.updateRoleMetadata( - { - roleEntityRef: 'role:default/some-super-important-role', - source: 'rest', - modifiedBy, - }, - 'role:default/some-super-important-role', - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(); - throw err; - } - - const metadata = await knex(ROLE_METADATA_TABLE).where( - 'id', - 1, - ); - expect(metadata.length).toEqual(1); - expect(metadata[0]).toMatchObject({ - author: null, - createdAt: null, - description: null, - source: 'rest', - roleEntityRef: 'role:default/some-super-important-role', - id: 1, - lastModified: null, - modifiedBy, - owner: null, - }); - }, - ); - - it.each(databases.eachSupportedId())( - 'should fail to update role metadata source to new value, because source is not legacy', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/some-super-important-role', - source: 'rest', - }); - - await expect(async () => { - const trx = await knex.transaction(); - try { - await db.updateRoleMetadata( - { - roleEntityRef: 'role:default/some-super-important-role', - source: 'configuration', - modifiedBy, - }, - 'role:default/some-super-important-role', - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(); - throw err; - } - }).rejects.toThrow(`The RoleMetadata.source field is 'read-only'`); - }, - ); - - it.each(databases.eachSupportedId())( - 'should successfully update role metadata with the new name', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/some-super-important-role', - source: 'configuration', - }); - - const trx = await knex.transaction(); - try { - await db.updateRoleMetadata( - { - roleEntityRef: 'role:default/important-role', - source: 'configuration', - modifiedBy, - }, - 'role:default/some-super-important-role', - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(); - throw err; - } - - const metadata = await knex(ROLE_METADATA_TABLE).where( - 'id', - 1, - ); - expect(metadata.length).toEqual(1); - expect(metadata[0]).toMatchObject({ - author: null, - createdAt: null, - description: null, - source: 'configuration', - roleEntityRef: 'role:default/important-role', - id: 1, - lastModified: null, - modifiedBy, - owner: null, - }); - }, - ); - - it.each(databases.eachSupportedId())( - 'should fail to update role metadata, because role metadata was not found', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - - await expect(async () => { - const trx = await knex.transaction(); - try { - await db.updateRoleMetadata( - { - roleEntityRef: 'role:default/important-role', - source: 'configuration', - modifiedBy, - }, - 'role:default/some-super-important-role', - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(); - throw err; - } - }).rejects.toThrow( - `A metadata for role 'role:default/some-super-important-role' was not found`, - ); - }, - ); - - it('should throw failed to update metadata error, because update result is an empty array.', async () => { - const knex = Knex.knex({ client: MockClient }); - const tracker = createTracker(knex); - tracker.on.select(ROLE_METADATA_TABLE).response({ - roleEntityRef: 'role:default/some-super-important-role', - source: 'configuration', - id: 1, - }); - tracker.on.update(ROLE_METADATA_TABLE).response([]); - - const db = new DataBaseRoleMetadataStorage(knex); - - await expect(async () => { - const trx = await knex.transaction(); - try { - await db.updateRoleMetadata( - { - roleEntityRef: 'role:default/important-role', - source: 'configuration', - modifiedBy, - }, - 'role:default/some-super-important-role', - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(err); - throw err; - } - }).rejects.toThrow( - `Failed to update the role metadata '{"roleEntityRef":"role:default/some-super-important-role","source":"configuration","id":1}' with new value: '{"roleEntityRef":"role:default/important-role","source":"configuration","modifiedBy":"user:default/some-user"}'.`, - ); - }); - - it('should throw failed to update metadata error, because update result is undefined.', async () => { - const knex = Knex.knex({ client: MockClient }); - const tracker = createTracker(knex); - tracker.on.select(ROLE_METADATA_TABLE).response({ - roleEntityRef: 'role:default/some-super-important-role', - source: 'configuration', - id: 1, - }); - tracker.on.update(ROLE_METADATA_TABLE).response(undefined); - - const db = new DataBaseRoleMetadataStorage(knex); - - await expect(async () => { - const trx = await knex.transaction(); - try { - await db.updateRoleMetadata( - { - roleEntityRef: 'role:default/important-role', - source: 'configuration', - modifiedBy, - }, - 'role:default/some-super-important-role', - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(err); - throw err; - } - }).rejects.toThrow( - `Failed to update the role metadata '{"roleEntityRef":"role:default/some-super-important-role","source":"configuration","id":1}' with new value: '{"roleEntityRef":"role:default/important-role","source":"configuration","modifiedBy":"user:default/some-user"}'.`, - ); - }); - - it('should throw on insert metadata operation', async () => { - const knex = Knex.knex({ client: MockClient }); - const tracker = createTracker(knex); - tracker.on.select(ROLE_METADATA_TABLE).response({ - roleEntityRef: 'role:default/some-super-important-role', - source: 'configuration', - id: 1, - }); - tracker.on - .update(ROLE_METADATA_TABLE) - .simulateError('connection refused error'); - - const db = new DataBaseRoleMetadataStorage(knex); - - await expect(async () => { - const trx = await knex.transaction(); - try { - await db.updateRoleMetadata( - { - roleEntityRef: 'role:default/important-role', - source: 'configuration', - modifiedBy, - }, - 'role:default/some-super-important-role', - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(err); - throw err; - } - }).rejects.toThrow('connection refused error'); - }); - }); - - describe('removeRoleMetadata', () => { - it.each(databases.eachSupportedId())( - 'should successfully delete role metadata', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - - await knex(ROLE_METADATA_TABLE).insert({ - roleEntityRef: 'role:default/some-super-important-role', - source: 'legacy', - }); - - const trx = await knex.transaction(); - try { - await db.removeRoleMetadata( - 'role:default/some-super-important-role', - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(); - throw err; - } - - const metadata = await knex(ROLE_METADATA_TABLE).where( - 'id', - 1, - ); - expect(metadata.length).toEqual(0); - }, - ); - - it.each(databases.eachSupportedId())( - 'should fail to delete role metadata, because nothing to delete', - async databasesId => { - const { knex, db } = await createDatabase(databasesId); - - const trx = await knex.transaction(); - - await expect(async () => { - try { - await db.removeRoleMetadata( - 'role:default/some-super-important-role', - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(); - throw err; - } - }).rejects.toThrow( - `A metadata for role 'role:default/some-super-important-role' was not found`, - ); - }, - ); - - it('should throw an error on delete metadata operation', async () => { - const knex = Knex.knex({ client: MockClient }); - const tracker = createTracker(knex); - tracker.on.select(ROLE_METADATA_TABLE).response({ - roleEntityRef: 'role:default/some-super-important-role', - source: 'configuration', - id: 1, - }); - tracker.on - .delete(ROLE_METADATA_TABLE) - .simulateError('connection refused error'); - - const db = new DataBaseRoleMetadataStorage(knex); - - await expect(async () => { - const trx = await knex.transaction(); - try { - await db.removeRoleMetadata( - 'role:default/some-super-important-role', - trx, - ); - await trx.commit(); - } catch (err) { - await trx.rollback(err); - throw err; - } - }).rejects.toThrow('connection refused error'); - }); - }); -}); diff --git a/plugins/rbac-backend/src/database/role-metadata.ts b/plugins/rbac-backend/src/database/role-metadata.ts deleted file mode 100644 index c14a26bc8e..0000000000 --- a/plugins/rbac-backend/src/database/role-metadata.ts +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { ConflictError, InputError, NotFoundError } from '@backstage/errors'; - -import { Knex } from 'knex'; - -import type { - RoleMetadata, - Source, -} from '@backstage-community/plugin-rbac-common'; - -import { deepSortedEqual } from '../helper'; -import { RBACFilters } from '../permissions'; -import { matches } from '../helper'; -import { buildDefaultRoleMetadata } from '../default-permissions/default-permissions'; -import { validateSource } from '../validation/policies-validation'; - -export const ROLE_METADATA_TABLE = 'role-metadata'; - -export interface RoleMetadataDao { - id?: number; - roleEntityRef: string; - source: Source; - modifiedBy: string; - description?: string; - author?: string; - lastModified?: string; - createdAt?: string; - owner?: string; - /** Postgres has a real boolean type; SQLite stores and may return 0/1. Optional when creating. */ - isDefault?: boolean | 0 | 1; -} - -export interface RoleMetadataStorage { - filterRoleMetadata(source?: Source): Promise; - filterForOwnerRoleMetadata(filter?: RBACFilters): Promise; - findRoleMetadata( - roleEntityRef: string, - trx?: Knex.Transaction, - ): Promise; - createRoleMetadata( - roleMetadata: RoleMetadataDao, - trx: Knex.Transaction, - ): Promise; - updateRoleMetadata( - roleMetadata: RoleMetadataDao, - oldRoleEntityRef: string, - externalTrx?: Knex.Transaction, - ): Promise; - removeRoleMetadata( - roleEntityRef: string, - trx?: Knex.Transaction, - ): Promise; - getCachedDefaultRoleMetadata(): RoleMetadataDao | undefined; - /** Returns the default role from the database (isDefault = true), if any. */ - getDefaultRole(trx?: Knex.Transaction): Promise; - syncDefaultRoleMetadata(actualDefRoleRef?: string): Promise; -} - -export class DataBaseRoleMetadataStorage implements RoleMetadataStorage { - private cachedDefaultRoleMeta: RoleMetadataDao | undefined; - constructor(private readonly knex: Knex) {} - - async filterRoleMetadata(source?: Source): Promise { - return await this.knex.table(ROLE_METADATA_TABLE).where(builder => { - if (source) { - builder.where('source', source); - } - }); - } - - async syncDefaultRoleMetadata(actualDefRoleRef?: string): Promise { - if (!actualDefRoleRef) { - await this.knex(ROLE_METADATA_TABLE).where('isDefault', true).delete(); - this.cachedDefaultRoleMeta = undefined; - return; - } - await this.knex.transaction(async trx => { - const currentDefaultRole = await this.getDefaultRole(trx); - if ( - currentDefaultRole && - currentDefaultRole.roleEntityRef !== actualDefRoleRef - ) { - await trx(ROLE_METADATA_TABLE).where('isDefault', true).delete(); - } - const existing = await this.findRoleMetadata(actualDefRoleRef, trx); - if (!existing) { - const newDefaultRole = buildDefaultRoleMetadata(actualDefRoleRef); - await this.createRoleMetadata(newDefaultRole, trx); - } else { - const err = await validateSource('configuration', existing); - if (err) { - throw new Error( - `Role '${actualDefRoleRef}' has incompatible source. Expected 'configuration' source value. Cause: ${err.message}`, - ); - } - } - }); - const row = await this.findRoleMetadata(actualDefRoleRef); - this.cachedDefaultRoleMeta = row; - } - - getCachedDefaultRoleMetadata(): RoleMetadataDao | undefined { - return this.cachedDefaultRoleMeta; - } - - async getDefaultRole( - trx?: Knex.Transaction, - ): Promise { - const db = trx || this.knex; - return await db(ROLE_METADATA_TABLE) - .where('isDefault', true) - .first(); - } - - async filterForOwnerRoleMetadata( - filter?: RBACFilters, - ): Promise { - const roleMetadata = - await this.knex.table(ROLE_METADATA_TABLE); - - if (filter) { - const ownerRoles = roleMetadata.filter(role => { - return matches(role as RoleMetadata, filter); - }); - if (this.cachedDefaultRoleMeta) { - ownerRoles.push(this.cachedDefaultRoleMeta); - } - return ownerRoles; - } - - return roleMetadata; - } - - async findRoleMetadata( - roleEntityRef: string, - trx?: Knex.Transaction, - ): Promise { - const db = trx || this.knex; - return await db - .table(ROLE_METADATA_TABLE) - .where('roleEntityRef', roleEntityRef) - // roleEntityRef should be unique. - .first(); - } - - async createRoleMetadata( - metadata: RoleMetadataDao, - trx: Knex.Transaction, - ): Promise { - if (await this.findRoleMetadata(metadata.roleEntityRef, trx)) { - throw new ConflictError( - `A metadata for role ${metadata.roleEntityRef} has already been stored`, - ); - } - - const result = await trx(ROLE_METADATA_TABLE) - .insert(metadata) - .returning<[{ id: number }]>('id'); - if (result && result?.length > 0) { - return result[0].id; - } - - throw new Error( - `Failed to create the role metadata: '${JSON.stringify(metadata)}'.`, - ); - } - - async updateRoleMetadata( - newRoleMetadata: RoleMetadataDao, - oldRoleEntityRef: string, - externalTrx?: Knex.Transaction, - ): Promise { - const trx = externalTrx ?? (await this.knex.transaction()); - const currentMetadataDao = await this.findRoleMetadata( - oldRoleEntityRef, - trx, - ); - - if (!currentMetadataDao) { - throw new NotFoundError( - `A metadata for role '${oldRoleEntityRef}' was not found`, - ); - } - - if ( - currentMetadataDao.source !== 'legacy' && - currentMetadataDao.source !== newRoleMetadata.source - ) { - throw new InputError(`The RoleMetadata.source field is 'read-only'.`); - } - - if (deepSortedEqual(currentMetadataDao, newRoleMetadata)) { - return; - } - - const result = await trx(ROLE_METADATA_TABLE) - .where('id', currentMetadataDao.id) - .update(newRoleMetadata) - .returning('id'); - - if (!externalTrx) { - await trx.commit(); - } - - if (!result || result.length === 0) { - throw new Error( - `Failed to update the role metadata '${JSON.stringify( - currentMetadataDao, - )}' with new value: '${JSON.stringify(newRoleMetadata)}'.`, - ); - } - } - - async removeRoleMetadata( - roleEntityRef: string, - externalTrx?: Knex.Transaction, - ): Promise { - const trx = externalTrx ?? (await this.knex.transaction()); - const metadataDao = await this.findRoleMetadata(roleEntityRef, trx); - if (!metadataDao) { - throw new NotFoundError( - `A metadata for role '${roleEntityRef}' was not found`, - ); - } - - await trx(ROLE_METADATA_TABLE) - .delete() - .whereIn('id', [metadataDao.id!]); - - if (!externalTrx) { - await trx.commit(); - } - } -} - -export function daoToMetadata(dao: RoleMetadataDao): RoleMetadata { - return { - source: dao.source, - description: dao.description, - owner: dao.owner, - author: dao.author, - modifiedBy: dao.modifiedBy, - createdAt: dao.createdAt, - lastModified: dao.lastModified, - isDefault: dao.isDefault === true || dao.isDefault === 1 ? true : false, - }; -} diff --git a/plugins/rbac-backend/src/default-permissions/default-permissions.test.ts b/plugins/rbac-backend/src/default-permissions/default-permissions.test.ts deleted file mode 100644 index bf8cb9711a..0000000000 --- a/plugins/rbac-backend/src/default-permissions/default-permissions.test.ts +++ /dev/null @@ -1,561 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { mockServices } from '@backstage/backend-test-utils'; - -import { EnforcerDelegate } from '../service/enforcer-delegate'; -import { - DefaultPermissionsReader, - DefaultPermissionsSyncher, - buildDefaultRoleMetadata, -} from './default-permissions'; - -const mockValidateEntityReference = jest.fn(); -jest.mock('../validation/policies-validation', () => { - const actual = jest.requireActual('../validation/policies-validation'); - return { - ...actual, - validateEntityReference: (...args: any[]) => - mockValidateEntityReference(...args), - }; -}); - -describe('DefaultPermissionsReader', () => { - beforeEach(() => { - mockValidateEntityReference.mockReturnValue(undefined); - }); - - afterEach(() => { - mockValidateEntityReference.mockClear(); - }); - - describe('readRole', () => { - it('returns undefined when no defaultPermissions config', () => { - const config = mockServices.rootConfig({ data: {} }); - const reader = new DefaultPermissionsReader(config); - expect(reader.readRole()).toBeUndefined(); - }); - - it('throws when defaultRole is not set but defaultPermissions section exists', () => { - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: {}, - }, - }, - }, - }); - const reader = new DefaultPermissionsReader(config); - expect(() => reader.readRole()).toThrow( - 'Default role is mandatory for defaultPermissions configuration. Please set a valid default role in the configuration.', - ); - }); - - it('returns role when defaultRole is set', () => { - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: { - defaultRole: 'role:default/catalog-reader', - }, - }, - }, - }, - }); - const reader = new DefaultPermissionsReader(config); - expect(reader.readRole()).toBe('role:default/catalog-reader'); - }); - - it('throws when defaultRole is empty or missing in defaultPermissions section', () => { - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: { - defaultRole: '', - }, - }, - }, - }, - }); - const reader = new DefaultPermissionsReader(config); - // Either config layer or readRole throws when defaultRole is empty/missing - expect(() => reader.readRole()).toThrow(); - }); - - it('should validate role using validateEntityReference with role=true', () => { - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: { - defaultRole: 'role:default/catalog-reader', - }, - }, - }, - }, - }); - const reader = new DefaultPermissionsReader(config); - reader.readRole(); - - expect(mockValidateEntityReference).toHaveBeenCalledWith( - 'role:default/catalog-reader', - true, - ); - }); - - it('throws when validateEntityReference returns an error', () => { - const mockError = new Error('Invalid role format'); - mockValidateEntityReference.mockReturnValue(mockError); - - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: { - defaultRole: 'invalid-role', - }, - }, - }, - }, - }); - const reader = new DefaultPermissionsReader(config); - expect(() => reader.readRole()).toThrow( - "Invalid default role 'invalid-role': Invalid role format", - ); - }); - }); - - describe('readPolicies', () => { - it('returns empty array when no defaultPermissions config', () => { - const config = mockServices.rootConfig({ data: {} }); - const reader = new DefaultPermissionsReader(config); - expect(reader.readPolicies()).toEqual([]); - }); - - it('throws when defaultRole is set but basicPermissions is missing', () => { - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: { - defaultRole: 'role:default/catalog-reader', - }, - }, - }, - }, - }); - const reader = new DefaultPermissionsReader(config); - expect(() => reader.readPolicies()).toThrow( - "The default role 'role:default/catalog-reader' requires at least one entry in permission.rbac.defaultPermissions.basicPermissions.", - ); - }); - - it('throws when defaultRole is set but basicPermissions is empty array', () => { - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: { - defaultRole: 'role:default/catalog-reader', - basicPermissions: [], - }, - }, - }, - }, - }); - const reader = new DefaultPermissionsReader(config); - expect(() => reader.readPolicies()).toThrow( - "The default role 'role:default/catalog-reader' requires at least one entry in permission.rbac.defaultPermissions.basicPermissions.", - ); - }); - - it('returns policies with default action and effect', () => { - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: { - defaultRole: 'role:default/catalog-reader', - basicPermissions: [ - { - permission: 'catalog.entity.read', - }, - ], - }, - }, - }, - }, - }); - const reader = new DefaultPermissionsReader(config); - expect(reader.readPolicies()).toEqual([ - { - entityReference: 'role:default/catalog-reader', - permission: 'catalog.entity.read', - policy: 'use', - effect: 'allow', - }, - ]); - }); - - it('returns policies with custom action (effect is always allow)', () => { - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: { - defaultRole: 'role:default/guest', - basicPermissions: [ - { - permission: 'catalog.entity.read', - action: 'read', - }, - { - permission: 'catalog.entity.delete', - action: 'delete', - }, - ], - }, - }, - }, - }, - }); - const reader = new DefaultPermissionsReader(config); - expect(reader.readPolicies()).toEqual([ - { - entityReference: 'role:default/guest', - permission: 'catalog.entity.read', - policy: 'read', - effect: 'allow', - }, - { - entityReference: 'role:default/guest', - permission: 'catalog.entity.delete', - policy: 'delete', - effect: 'allow', - }, - ]); - }); - - it('throws when action is invalid', () => { - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: { - defaultRole: 'role:default/guest', - basicPermissions: [ - { - permission: 'catalog.entity.read', - action: 'invalid-action', - }, - ], - }, - }, - }, - }, - }); - const reader = new DefaultPermissionsReader(config); - expect(() => reader.readPolicies()).toThrow( - "Invalid action 'invalid-action' for permission 'catalog.entity.read'.", - ); - }); - }); -}); - -describe('DefaultPermissionsSyncher', () => { - function createRoleMetadataStorageMock() { - return { - getDefaultRole: jest.fn().mockResolvedValue(undefined), - syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), - removeRoleMetadata: jest.fn().mockResolvedValue(undefined), - filterRoleMetadata: jest.fn(), - filterForOwnerRoleMetadata: jest.fn(), - findRoleMetadata: jest.fn(), - createRoleMetadata: jest.fn(), - updateRoleMetadata: jest.fn(), - getCachedDefaultRoleMetadata: jest.fn(), - }; - } - - function createEnforcerMock() { - return { - getFilteredPolicy: jest.fn().mockResolvedValue([]), - removePolicies: jest.fn().mockResolvedValue(undefined), - addPolicies: jest.fn().mockResolvedValue(undefined), - }; - } - - it('returns early when no roleEntityRef and no previous default role', async () => { - const config = mockServices.rootConfig({ data: {} }); - const reader = new DefaultPermissionsReader(config); - const storage = createRoleMetadataStorageMock(); - const enforcer = createEnforcerMock(); - - const syncher = new DefaultPermissionsSyncher( - storage, - enforcer as unknown as EnforcerDelegate, - reader, - ); - await syncher.sync(); - - expect(storage.getDefaultRole).toHaveBeenCalled(); - expect(storage.syncDefaultRoleMetadata).not.toHaveBeenCalled(); - expect(storage.removeRoleMetadata).not.toHaveBeenCalled(); - expect(enforcer.getFilteredPolicy).not.toHaveBeenCalled(); - }); - - it('removes previous default role when no roleEntityRef but prevDefRole exists', async () => { - const config = mockServices.rootConfig({ data: {} }); - const reader = new DefaultPermissionsReader(config); - const storage = createRoleMetadataStorageMock(); - storage.getDefaultRole.mockResolvedValue({ - roleEntityRef: 'role:default/old-default', - source: 'configuration', - modifiedBy: 'config', - }); - const enforcer = createEnforcerMock(); - enforcer.getFilteredPolicy.mockResolvedValue([ - ['role:default/old-default', 'catalog.entity.read', 'read', 'allow'], - ]); - - const syncher = new DefaultPermissionsSyncher( - storage, - enforcer as unknown as EnforcerDelegate, - reader, - ); - await syncher.sync(); - - expect(enforcer.removePolicies).toHaveBeenCalledWith([ - ['role:default/old-default', 'catalog.entity.read', 'read', 'allow'], - ]); - expect(storage.removeRoleMetadata).toHaveBeenCalledWith( - 'role:default/old-default', - ); - expect(storage.syncDefaultRoleMetadata).not.toHaveBeenCalled(); - }); - - it('syncs metadata and policies when roleEntityRef is set and no prevDefRole', async () => { - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: { - defaultRole: 'role:default/catalog-reader', - basicPermissions: [ - { permission: 'catalog.entity.read', action: 'read' }, - ], - }, - }, - }, - }, - }); - const reader = new DefaultPermissionsReader(config); - const storage = createRoleMetadataStorageMock(); - const enforcer = createEnforcerMock(); - - const syncher = new DefaultPermissionsSyncher( - storage, - enforcer as unknown as EnforcerDelegate, - reader, - ); - await syncher.sync(); - - expect(storage.syncDefaultRoleMetadata).toHaveBeenCalledWith( - 'role:default/catalog-reader', - ); - expect(enforcer.getFilteredPolicy).toHaveBeenCalled(); - expect(enforcer.addPolicies).toHaveBeenCalled(); - }); - - it('throws when prevDefRole has incompatible source', async () => { - const config = mockServices.rootConfig({ data: {} }); - const reader = new DefaultPermissionsReader(config); - const storage = createRoleMetadataStorageMock(); - storage.getDefaultRole.mockResolvedValue({ - roleEntityRef: 'role:default/csv-role', - source: 'csv-file', - modifiedBy: 'file', - }); - const enforcer = createEnforcerMock(); - - const syncher = new DefaultPermissionsSyncher( - storage, - enforcer as unknown as EnforcerDelegate, - reader, - ); - await expect(syncher.sync()).rejects.toThrow( - 'Detected previous default role with incompatible source:', - ); - }); - - describe('sync - role name changes', () => { - it('should clean up old role permissions when role name changes', async () => { - const storage = createRoleMetadataStorageMock(); - storage.getDefaultRole = jest.fn().mockResolvedValue({ - roleEntityRef: 'role:default/old-role', - source: 'configuration', - isDefault: true, - }); - - const enforcer = createEnforcerMock(); - enforcer.getFilteredPolicy = jest.fn().mockResolvedValue([ - ['role:default/old-role', 'catalog-entity', 'read', 'allow'], - ['role:default/old-role', 'catalog.entity.create', 'use', 'allow'], - ]); - - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: { - defaultRole: 'role:default/new-role', - basicPermissions: [ - { permission: 'catalog-entity', action: 'read' }, - ], - }, - }, - }, - }, - }); - - const reader = new DefaultPermissionsReader(config); - const syncher = new DefaultPermissionsSyncher( - storage as any, - enforcer as any, - reader, - ); - - await syncher.sync(); - - // Should remove old role's permissions - expect(enforcer.getFilteredPolicy).toHaveBeenCalledWith( - 0, - 'role:default/old-role', - ); - expect(enforcer.removePolicies).toHaveBeenCalledWith([ - ['role:default/old-role', 'catalog-entity', 'read', 'allow'], - ['role:default/old-role', 'catalog.entity.create', 'use', 'allow'], - ]); - - // Should sync new role metadata and policies - expect(storage.syncDefaultRoleMetadata).toHaveBeenCalledWith( - 'role:default/new-role', - ); - }); - - it('should not clean up when role name is unchanged', async () => { - const storage = createRoleMetadataStorageMock(); - storage.getDefaultRole = jest.fn().mockResolvedValue({ - roleEntityRef: 'role:default/same-role', - source: 'configuration', - isDefault: true, - }); - const enforcer = createEnforcerMock(); - enforcer.getFilteredPolicy = jest.fn().mockResolvedValue([]); - - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: { - defaultRole: 'role:default/same-role', - basicPermissions: [ - { permission: 'catalog-entity', action: 'read' }, - ], - }, - }, - }, - }, - }); - - const reader = new DefaultPermissionsReader(config); - const syncher = new DefaultPermissionsSyncher( - storage as any, - enforcer as any, - reader, - ); - - await syncher.sync(); - - // Should NOT call removePolicies for cleanup since role name hasn't changed - expect(enforcer.removePolicies).not.toHaveBeenCalled(); - }); - - it('should handle cleanup when old role has no permissions', async () => { - const storage = createRoleMetadataStorageMock(); - storage.getDefaultRole = jest.fn().mockResolvedValue({ - roleEntityRef: 'role:default/old-role', - source: 'configuration', - isDefault: true, - }); - - const enforcer = createEnforcerMock(); - enforcer.getFilteredPolicy = jest.fn().mockResolvedValue([]); - - const config = mockServices.rootConfig({ - data: { - permission: { - rbac: { - defaultPermissions: { - defaultRole: 'role:default/new-role', - basicPermissions: [ - { permission: 'catalog-entity', action: 'read' }, - ], - }, - }, - }, - }, - }); - - const reader = new DefaultPermissionsReader(config); - const syncher = new DefaultPermissionsSyncher( - storage as any, - enforcer as any, - reader, - ); - - await syncher.sync(); - - expect(enforcer.getFilteredPolicy).toHaveBeenCalledWith( - 0, - 'role:default/old-role', - ); - expect(enforcer.removePolicies).not.toHaveBeenCalled(); - }); - }); -}); - -describe('buildDefaultRoleMetadata', () => { - it('returns RoleMetadataDao with all required fields', () => { - const roleRef = 'role:default/catalog-reader'; - const meta = buildDefaultRoleMetadata(roleRef); - - expect(meta.roleEntityRef).toBe(roleRef); - expect(meta.author).toBe('application configuration'); - expect(meta.source).toBe('configuration'); - expect(meta.isDefault).toBe(true); - expect(meta.description).toBe( - 'Role with default permissions for all users and groups.', - ); - expect(meta.modifiedBy).toBe('application configuration'); - expect(meta.lastModified).toBeDefined(); - expect(meta.createdAt).toBeDefined(); - expect(typeof meta.lastModified).toBe('string'); - expect(typeof meta.createdAt).toBe('string'); - }); -}); diff --git a/plugins/rbac-backend/src/default-permissions/default-permissions.ts b/plugins/rbac-backend/src/default-permissions/default-permissions.ts deleted file mode 100644 index 832a4cf466..0000000000 --- a/plugins/rbac-backend/src/default-permissions/default-permissions.ts +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Config } from '@backstage/config'; - -import { - RoleBasedPolicy, - isValidPermissionAction, -} from '@backstage-community/plugin-rbac-common'; - -import type { - RoleMetadataDao, - RoleMetadataStorage, -} from '../database/role-metadata'; -import type { EnforcerDelegate } from '../service/enforcer-delegate'; -import { syncRolePolicies } from '../helper'; -import { ADMIN_ROLE_AUTHOR } from '../admin-permissions/admin-creation'; -import { - validateSource, - validateEntityReference, -} from '../validation/policies-validation'; - -const DEFAULT_ROLE_DESCRIPTION = - 'Role with default permissions for all users and groups.'; - -const DEFAULT_PERMISSIONS_CONF = 'permission.rbac.defaultPermissions'; - -export class DefaultPermissionsReader { - constructor(private readonly config: Config) {} - - readRole(): string | undefined { - const defPermissionsConfig = this.config.getOptionalConfig( - DEFAULT_PERMISSIONS_CONF, - ); - let role: string | undefined; - - if (defPermissionsConfig) { - role = defPermissionsConfig.getOptionalString('defaultRole'); - - if (!role) { - throw new Error( - 'Default role is mandatory for defaultPermissions configuration. Please set a valid default role in the configuration.', - ); - } - - const validationError = validateEntityReference(role, true); - if (validationError) { - throw new Error( - `Invalid default role '${role}': ${validationError.message}`, - ); - } - } - - return role; - } - - readPolicies(): RoleBasedPolicy[] { - const defPermissionsConfig = this.config.getOptionalConfig( - DEFAULT_PERMISSIONS_CONF, - ); - const role = this.readRole(); - - let policies: RoleBasedPolicy[] = []; - if (defPermissionsConfig) { - const basicPermissions = - defPermissionsConfig.getOptionalConfigArray('basicPermissions'); - if (!basicPermissions || basicPermissions.length === 0) { - throw new Error( - `The default role '${role}' requires at least one entry in permission.rbac.defaultPermissions.basicPermissions.`, - ); - } - - policies = basicPermissions.map(permission => { - const permissionName = permission.getString('permission'); - const action = permission.getOptionalString('action'); - - if (action && !isValidPermissionAction(action)) { - throw new Error( - `Invalid action '${action}' for permission '${permissionName}'.`, - ); - } - - return { - entityReference: role, - permission: permissionName, - policy: action || 'use', - effect: 'allow', - }; - }); - } - - return policies; - } -} - -export class DefaultPermissionsSyncher { - constructor( - private readonly roleMetadataStorage: RoleMetadataStorage, - private readonly enforcer: EnforcerDelegate, - private readonly defaultPermissionsReader: DefaultPermissionsReader, - ) {} - - public async sync() { - const policies = this.defaultPermissionsReader.readPolicies(); - const roleEntityRef = this.defaultPermissionsReader.readRole(); - - const prevDefRole = await this.roleMetadataStorage.getDefaultRole(); - - const err = await validateSource('configuration', prevDefRole); - if (err) { - throw new Error( - `Detected previous default role with incompatible source: ${err.message}`, - ); - } - - if (!roleEntityRef) { - if (prevDefRole) { - const pls = await this.enforcer.getFilteredPolicy( - 0, - prevDefRole.roleEntityRef, - ); - if (pls.length > 0) { - await this.enforcer.removePolicies(pls); - } - await this.roleMetadataStorage.removeRoleMetadata( - prevDefRole.roleEntityRef, - ); - } - - return; - } - - // Clean up orphaned permissions if role name changed - if (prevDefRole && prevDefRole.roleEntityRef !== roleEntityRef) { - const oldPolicies = await this.enforcer.getFilteredPolicy( - 0, - prevDefRole.roleEntityRef, - ); - if (oldPolicies.length > 0) { - await this.enforcer.removePolicies(oldPolicies); - } - } - - const casbinPolicies: string[][] = policies.map(p => [ - p.entityReference!, - p.permission!, - p.policy!, - p.effect!, - ]); - - await this.roleMetadataStorage.syncDefaultRoleMetadata(roleEntityRef); - await syncRolePolicies(this.enforcer, roleEntityRef, casbinPolicies); - } -} - -export function buildDefaultRoleMetadata( - defaultRoleRef: string, -): RoleMetadataDao { - return { - roleEntityRef: defaultRoleRef, - author: ADMIN_ROLE_AUTHOR, - source: 'configuration', - isDefault: true, - description: DEFAULT_ROLE_DESCRIPTION, - modifiedBy: ADMIN_ROLE_AUTHOR, - lastModified: new Date().toUTCString(), - createdAt: new Date().toUTCString(), - }; -} diff --git a/plugins/rbac-backend/src/file-permissions/csv-file-watcher.test.ts b/plugins/rbac-backend/src/file-permissions/csv-file-watcher.test.ts deleted file mode 100644 index e3a0780135..0000000000 --- a/plugins/rbac-backend/src/file-permissions/csv-file-watcher.test.ts +++ /dev/null @@ -1,845 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { mockServices } from '@backstage/backend-test-utils'; -import type { Config } from '@backstage/config'; - -import { - Adapter, - Enforcer, - Model, - newEnforcer, - newModelFromString, -} from 'casbin'; -import * as Knex from 'knex'; -import { MockClient } from 'knex-mock-client'; - -import type { Source } from '@backstage-community/plugin-rbac-common'; - -import { resolve } from 'path'; - -import { ADMIN_ROLE_AUTHOR } from '../admin-permissions/admin-creation'; -import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; -import { - RoleMetadataDao, - RoleMetadataStorage, -} from '../database/role-metadata'; -import { BackstageRoleManager } from '../role-manager/role-manager'; -import { DefaultPermissionsReader } from '../default-permissions/default-permissions'; -import { EnforcerDelegate } from '../service/enforcer-delegate'; -import { MODEL } from '../service/permission-model'; -import { CSVFileWatcher } from './csv-file-watcher'; -import { mockAuditorService } from '../../__fixtures__/mock-utils'; -import { conditionalStorageMock } from '../../__fixtures__/mock-utils'; -import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; - -const legacyPermission = [ - 'role:default/legacy', - 'catalog-entity', - 'update', - 'allow', -]; - -const legacyRole = ['user:default/guest', 'role:default/legacy']; - -const restPermission = [ - 'role:default/rest', - 'catalog-entity', - 'update', - 'allow', -]; - -const restRole = ['user:default/guest', 'role:default/rest']; - -const configPermission = [ - 'role:default/config', - 'catalog-entity', - 'update', - 'allow', -]; - -const configRole = ['user:default/guest', 'role:default/config']; - -const mockLoggerService = mockServices.logger.mock(); - -const modifiedBy = 'user:default/some-admin'; - -const legacyRoleMetadata: RoleMetadataDao = { - roleEntityRef: legacyPermission[0], - source: 'legacy', - modifiedBy, -}; - -const roleMetadataStorageMock: RoleMetadataStorage = { - filterRoleMetadata: jest.fn().mockImplementation(() => []), - filterForOwnerRoleMetadata: jest.fn().mockImplementation(), - findRoleMetadata: jest - .fn() - .mockImplementation( - async ( - roleEntityRef: string, - _trx: Knex.Knex.Transaction, - ): Promise => { - if (roleEntityRef === legacyPermission[0]) { - return legacyRoleMetadata; - } else if (roleEntityRef === restPermission[0]) { - return { - roleEntityRef: restPermission[0], - source: 'rest', - modifiedBy, - }; - } - if (roleEntityRef === configPermission[0]) { - return { - roleEntityRef: configPermission[0], - source: 'configuration', - modifiedBy, - }; - } - return { roleEntityRef: '', source: 'csv-file', modifiedBy }; - }, - ), - createRoleMetadata: jest.fn().mockImplementation(), - updateRoleMetadata: jest.fn().mockImplementation(), - removeRoleMetadata: jest.fn().mockImplementation(), - getCachedDefaultRoleMetadata: jest.fn().mockImplementation(() => undefined), - getDefaultRole: jest.fn().mockResolvedValue(undefined), - syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), -}; - -const mockClientKnex = Knex.knex({ client: MockClient }); - -const mockAuthService = mockServices.auth(); - -const currentPermissionPolicies = [ - ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], - ['role:default/legacy', 'catalog-entity', 'update', 'allow'], - ['role:default/catalog-writer', 'catalog-entity', 'read', 'allow'], - ['role:default/catalog-writer', 'catalog.entity.create', 'use', 'allow'], - ['role:default/catalog-deleter', 'catalog-entity', 'delete', 'deny'], - ['role:default/CATALOG-USER', 'catalog-entity', 'read', 'allow'], - ['role:default/known_role', 'test.resource.deny', 'use', 'allow'], -]; - -const currentRoles = [ - ['user:default/guest', 'role:default/catalog-writer'], - ['user:default/guest', 'role:default/legacy'], - ['user:default/guest', 'role:default/catalog-reader'], - ['user:default/guest', 'role:default/catalog-deleter'], - ['user:default/known_user', 'role:default/known_role'], - ['user:default/tom', 'role:default/CATALOG-USER'], - ['group:default/reader-group', 'role:default/CATALOG-USER'], -]; - -describe('CSVFileWatcher', () => { - let enforcerDelegate: EnforcerDelegate; - let csvFileName: string; - - beforeEach(async () => { - csvFileName = resolve( - __dirname, - '../../__fixtures__/data/valid-csv/rbac-policy.csv', - ); - - const config = newConfig(); - - const adapter = await new CasbinDBAdapterFactory( - config, - mockClientKnex, - ).createAdapter(); - - const stringModel = newModelFromString(MODEL); - const enf = await createEnforcer(stringModel, adapter, mockLoggerService); - - const knex = Knex.knex({ client: MockClient }); - - enforcerDelegate = new EnforcerDelegate( - enf, - mockAuditorService, - conditionalStorageMock, - roleMetadataStorageMock, - knex, - ); - - (roleMetadataStorageMock.updateRoleMetadata as jest.Mock).mockClear(); - }); - - afterEach(() => { - (mockLoggerService.warn as jest.Mock).mockReset(); - (roleMetadataStorageMock.removeRoleMetadata as jest.Mock).mockReset(); - }); - - function createCSVFileWatcher(fileName?: string): CSVFileWatcher { - return new CSVFileWatcher( - fileName, - false, - mockLoggerService, - enforcerDelegate, - roleMetadataStorageMock, - mockAuditorService, - ); - } - - describe('parse', () => { - test('should parse users and groups in lowercase', async () => { - csvFileName = resolve( - __dirname, - '../../__fixtures__/data/valid-csv/uppercase-policy.csv', - ); - - const csvFileWatcher = createCSVFileWatcher(csvFileName); - const content = await csvFileWatcher.parse(); - const expected = [ - ['p', 'role:default/CATALOG-USER', 'catalog-entity', 'read', 'allow'], - ['p', 'role:default/known_role', 'test.resource.deny', 'use', 'allow'], - ['g', 'user:default/known_user', 'role:default/known_role'], - ['g', 'user:default/tom', 'role:default/CATALOG-USER'], - ['g', 'group:default/reader-group', 'role:default/CATALOG-USER'], - ['g', 'group:default/reader-group', 'role:default/known_role'], - ]; - expect(content).toStrictEqual(expected); - }); - }); - - describe('initialize', () => { - it('should be able to add permission policies during initialization', async () => { - const csvFileWatcher = createCSVFileWatcher(csvFileName); - await csvFileWatcher.initialize(); - - const enfPolicies = await enforcerDelegate.getPolicy(); - - expect(enfPolicies).toStrictEqual(currentPermissionPolicies); - }); - - it('should be able to add roles during initialization', async () => { - const csvFileWatcher = createCSVFileWatcher(csvFileName); - await csvFileWatcher.initialize(); - - const enfRoles = await enforcerDelegate.getGroupingPolicy(); - - expect(enfRoles).toStrictEqual(currentRoles); - }); - - it('should be able to update legacy role metadata during initialization', async () => { - const permissionPolicies = [ - ['role:default/legacy', 'catalog-entity', 'update', 'allow'], - ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], - ['role:default/catalog-writer', 'catalog-entity', 'read', 'allow'], - [ - 'role:default/catalog-writer', - 'catalog.entity.create', - 'use', - 'allow', - ], - ['role:default/catalog-deleter', 'catalog-entity', 'delete', 'deny'], - ['role:default/CATALOG-USER', 'catalog-entity', 'read', 'allow'], - ['role:default/known_role', 'test.resource.deny', 'use', 'allow'], - ]; - - await enforcerDelegate.addPolicy(legacyPermission); - await enforcerDelegate.addGroupingPolicies( - [['user:default/guest', 'role:default/legacy']], - legacyRoleMetadata!, - ); - roleMetadataStorageMock.filterRoleMetadata = jest - .fn() - .mockImplementation((source: Source) => { - if (source === 'legacy') { - return [legacyRoleMetadata]; - } - return []; - }); - (roleMetadataStorageMock.updateRoleMetadata as jest.Mock).mockReset(); - - const csvFileWatcher = createCSVFileWatcher(csvFileName); - await csvFileWatcher.initialize(); - - const enfPolicies = await enforcerDelegate.getPolicy(); - - const legacyMetadatas = ( - roleMetadataStorageMock.updateRoleMetadata as jest.Mock - ).mock.calls - .map(call => call[0]) - .filter(metadata => metadata.roleEntityRef === 'role:default/legacy'); - expect(legacyMetadatas.length).toEqual(1); - // legacy source should be updated from legacy to csv-file - expect(legacyMetadatas[0].source).toEqual('csv-file'); - expect(enfPolicies).toStrictEqual(permissionPolicies); - }); - - it('should be able to update legacy roles during initialization', async () => { - const roles = [ - ['user:default/guest', 'role:default/legacy'], - ['user:default/guest', 'role:default/catalog-writer'], - ['user:default/guest', 'role:default/catalog-reader'], - ['user:default/guest', 'role:default/catalog-deleter'], - ['user:default/known_user', 'role:default/known_role'], - ['user:default/tom', 'role:default/CATALOG-USER'], - ['group:default/reader-group', 'role:default/CATALOG-USER'], - ]; - - await enforcerDelegate.addGroupingPolicy(legacyRole, legacyRoleMetadata); - roleMetadataStorageMock.filterRoleMetadata = jest - .fn() - .mockImplementation((source: Source) => { - if (source === 'legacy') { - return [legacyRoleMetadata]; - } - return []; - }); - (roleMetadataStorageMock.updateRoleMetadata as jest.Mock).mockReset(); - - const csvFileWatcher = createCSVFileWatcher(csvFileName); - await csvFileWatcher.initialize(); - - const enfPolicies = await enforcerDelegate.getGroupingPolicy(); - - const legacyMetadatas = ( - roleMetadataStorageMock.updateRoleMetadata as jest.Mock - ).mock.calls - .map(call => call[0]) - .filter(metadata => metadata.roleEntityRef === 'role:default/legacy'); - expect(legacyMetadatas.length).toEqual(1); - // legacy source should be updated from legacy to csv-file - expect(legacyMetadatas[0].source).toEqual('csv-file'); - - expect(enfPolicies).toStrictEqual(roles); - }); - - // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` - it('should be able to add `policy-entity, create` permissions but log a warning roles during creation', async () => { - csvFileName = resolve( - __dirname, - '../../__fixtures__/data/invalid-csv/deprecated-policy.csv', - ); - const csvFileWatcher = createCSVFileWatcher(csvFileName); - - const deprecatedPolicy = [ - 'role:default/some_role', - 'policy-entity', - 'create', - 'allow', - ]; - - await csvFileWatcher.initialize(); - - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 1, - `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${deprecatedPolicy} to use 'policy.entity.create' instead of 'policy-entity' from source csv-file`, - ); - - const enfPolicies = await enforcerDelegate.getPolicy(); - - expect(enfPolicies).toStrictEqual([deprecatedPolicy]); - }); - - // Failing tests - it('should fail to add duplicate policies', async () => { - csvFileName = resolve( - __dirname, - '../../__fixtures__/data/invalid-csv/duplicate-policy.csv', - ); - const csvFileWatcher = createCSVFileWatcher(csvFileName); - - const duplicatePolicy = [ - 'role:default/catalog-writer', - 'catalog.entity.create', - 'use', - 'allow', - ]; - const duplicateRole = [ - 'user:default/guest', - 'role:default/catalog-deleter', - ]; - const duplicateGroupRole = [ - 'group:default/reader-group', // changed to lowercase - 'role:default/CATALOG-USER', - ]; - - const duplicatePolicyWithDifferentEffect = [ - 'role:default/duplication-effect', - 'catalog-entity', - 'update', - ]; - - await csvFileWatcher.initialize(); - - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 1, - `Duplicate policy: ${duplicatePolicy} found in the file ${csvFileName}`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 2, - `Duplicate policy: ${duplicatePolicy} found in the file ${csvFileName}`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 3, - `Duplicate policy: ${duplicatePolicyWithDifferentEffect[0]}, ${duplicatePolicyWithDifferentEffect[1]}, ${duplicatePolicyWithDifferentEffect[2]} with different effect found in the file ${csvFileName}`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 4, - `Duplicate policy: ${duplicatePolicyWithDifferentEffect[0]}, ${duplicatePolicyWithDifferentEffect[1]}, ${duplicatePolicyWithDifferentEffect[2]} with different effect found in the file ${csvFileName}`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 5, - `Duplicate role: ${duplicateRole} found in the file ${csvFileName}`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 6, - `Duplicate role: ${duplicateRole} found in the file ${csvFileName}`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 7, - `Duplicate role: ${duplicateGroupRole} found in the file ${csvFileName}`, - ); - }); - - it('should fail to add policies with errors', async () => { - csvFileName = resolve( - __dirname, - '../../__fixtures__/data/invalid-csv/error-policy.csv', - ); - const csvFileWatcher = createCSVFileWatcher(csvFileName); - - const entityRoleError = ['user:default/', 'role:default/catalog-deleter']; - const roleError = ['user:default/test', 'role:default/']; - - const roleErrorPolicy = [ - 'role:default/', - 'catalog.entity.create', - 'use', - 'allow', - ]; - const allowErrorPolicy = [ - 'role:default/test', - 'catalog.entity.create', - 'delete', - 'temp', - ]; - - await csvFileWatcher.initialize(); - - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 1, - `Failed to validate policy from file ${csvFileName}. Cause: Entity reference "${roleErrorPolicy[0]}" was not on the form [:][/]`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 2, - `Failed to validate policy from file ${csvFileName}. Cause: 'effect' has invalid value: '${allowErrorPolicy[3]}'. It should be: 'allow' or 'deny'`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 3, - `Unable to add policy ${restPermission} from file ${csvFileName}. Cause: source does not match originating role ${restPermission[0]}, consider making changes to the 'REST'`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 4, - `Unable to add policy ${configPermission} from file ${csvFileName}. Cause: source does not match originating role ${configPermission[0]}, consider making changes to the 'CONFIGURATION'`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 5, - `Failed to validate group policy ${entityRoleError}. Cause: Entity reference "${entityRoleError[0]}" was not on the form [:][/], error originates from file ${csvFileName}`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 6, - `Failed to validate group policy ${roleError}. Cause: Entity reference "${roleError[1]}" was not on the form [:][/], error originates from file ${csvFileName}`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 7, - `Unable to validate role ${restRole}. Cause: source does not match originating role ${restRole[1]}, consider making changes to the 'REST', error originates from file ${csvFileName}`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 8, - `Unable to validate role ${configRole}. Cause: source does not match originating role ${configRole[1]}, consider making changes to the 'CONFIGURATION', error originates from file ${csvFileName}`, - ); - }); - }); - - describe('onChange', () => { - let csvFileWatcher: CSVFileWatcher; - - beforeEach(async () => { - csvFileName = resolve( - __dirname, - '../../__fixtures__/data/valid-csv/simple-policy.csv', - ); - csvFileWatcher = createCSVFileWatcher(csvFileName); - await csvFileWatcher.initialize(); - }); - - afterEach(() => { - (csvFileWatcher.parse as jest.Mock).mockReset(); - }); - - it('should add new permission policies on change', async () => { - const addContents = [ - ['g', 'user:default/guest', 'role:default/catalog-writer'], - [ - 'p', - 'role:default/catalog-writer', - 'catalog-entity', - 'update', - 'allow', - ], - [ - 'p', - 'role:default/catalog-writer', - 'catalog-entity', - 'delete', - 'allow', - ], - ]; - - const policies = [ - ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], - ['role:default/catalog-writer', 'catalog-entity', 'delete', 'allow'], - ]; - - csvFileWatcher.parse = jest.fn().mockImplementation(() => { - return addContents; - }); - - await csvFileWatcher.onChange(); - - const enfPolicies = await enforcerDelegate.getPolicy(); - - expect(enfPolicies).toStrictEqual(policies); - }); - - // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` - it('should be able to add `policy-entity, create` permissions but log a warning roles on change', async () => { - const addContents = [ - ['p', 'role:default/some_role', 'policy-entity', 'create', 'allow'], - ]; - - csvFileWatcher.parse = jest.fn().mockImplementation(() => { - return addContents; - }); - - await csvFileWatcher.onChange(); - - const deprecatedPolicy = [ - 'role:default/some_role', - 'policy-entity', - 'create', - 'allow', - ]; - - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 1, - `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${deprecatedPolicy} to use 'policy.entity.create' instead of 'policy-entity' from source csv-file`, - ); - - const enfPolicies = await enforcerDelegate.getPolicy(); - - expect(enfPolicies).toStrictEqual([deprecatedPolicy]); - }); - - it('should add new roles on change', async () => { - const addContents = [ - ['g', 'user:default/guest', 'role:default/catalog-writer'], - [ - 'p', - 'role:default/catalog-writer', - 'catalog-entity', - 'update', - 'allow', - ], - ['g', 'user:default/test', 'role:default/catalog-writer'], - ]; - - const roles = [ - ['user:default/guest', 'role:default/catalog-writer'], - ['user:default/test', 'role:default/catalog-writer'], - ]; - - csvFileWatcher.parse = jest.fn().mockImplementation(() => { - return addContents; - }); - - await csvFileWatcher.onChange(); - - const enfRoles = await enforcerDelegate.getGroupingPolicy(); - - expect(enfRoles).toStrictEqual(roles); - }); - - it('should fail to add new permission policies on change if there is a mismatch in source', async () => { - const addContents = [ - ['g', 'user:default/guest', 'role:default/catalog-writer'], - [ - 'p', - 'role:default/catalog-writer', - 'catalog-entity', - 'update', - 'allow', - ], - ['p', 'role:default/config', 'catalog-entity', 'update', 'allow'], - ['p', 'role:default/rest', 'catalog-entity', 'update', 'allow'], - ]; - - const policies = [ - ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], - ['role:default/config', 'catalog-entity', 'update', 'allow'], - ['role:default/rest', 'catalog-entity', 'update', 'allow'], - ]; - - await enforcerDelegate.addPolicy(configPermission); - await enforcerDelegate.addPolicy(restPermission); - - csvFileWatcher.parse = jest.fn().mockImplementation(() => { - return addContents; - }); - - await csvFileWatcher.onChange(); - - const enfPolicies = await enforcerDelegate.getPolicy(); - - expect(enfPolicies).toStrictEqual(policies); - - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 1, - `Unable to add policy ${configPermission} from file ${csvFileName}. Cause: source does not match originating role ${configPermission[0]}, consider making changes to the 'CONFIGURATION'`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 2, - `Unable to add policy ${restPermission} from file ${csvFileName}. Cause: source does not match originating role ${restPermission[0]}, consider making changes to the 'REST'`, - ); - }); - - it('should fail to add new roles on change if there is a mismatch in source', async () => { - const addContents = [ - ['g', 'user:default/guest', 'role:default/catalog-writer'], - ['g', 'user:default/guest', 'role:default/rest'], - ['g', 'user:default/guest', 'role:default/config'], - ]; - - const roles = [ - ['user:default/guest', 'role:default/catalog-writer'], - ['user:default/guest', 'role:default/config'], - ['user:default/guest', 'role:default/rest'], - ]; - - await enforcerDelegate.addGroupingPolicy(configRole, { - roleEntityRef: configRole[1], - source: 'configuration', - modifiedBy: ADMIN_ROLE_AUTHOR, - }); - await enforcerDelegate.addGroupingPolicy(restRole, { - roleEntityRef: restRole[1], - source: 'rest', - modifiedBy, - }); - - csvFileWatcher.parse = jest.fn().mockImplementation(() => { - return addContents; - }); - - await csvFileWatcher.onChange(); - - const enfRoles = await enforcerDelegate.getGroupingPolicy(); - - expect(enfRoles).toStrictEqual(roles); - - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 1, - `Unable to validate role ${restRole}. Cause: source does not match originating role ${restRole[1]}, consider making changes to the 'REST', error originates from file ${csvFileName}`, - ); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 2, - `Unable to validate role ${configRole}. Cause: source does not match originating role ${configRole[1]}, consider making changes to the 'CONFIGURATION', error originates from file ${csvFileName}`, - ); - }); - - it('should remove old permission policies on change', async () => { - const addContents = [ - ['g', 'user:default/guest', 'role:default/catalog-writer'], - ]; - - csvFileWatcher.parse = jest.fn().mockImplementation(() => { - return addContents; - }); - - await csvFileWatcher.onChange(); - - const enfPolicies = await enforcerDelegate.getPolicy(); - - expect(enfPolicies).toStrictEqual([]); - }); - - it('should remove old roles on change', async () => { - const addContents = [ - [ - 'p', - 'role:default/catalog-writer', - 'catalog-entity', - 'update', - 'allow', - ], - ]; - - csvFileWatcher.parse = jest.fn().mockImplementation(() => { - return addContents; - }); - - await csvFileWatcher.onChange(); - - const enfRoles = await enforcerDelegate.getGroupingPolicy(); - - expect(enfRoles).toStrictEqual([]); - }); - - it('should do nothing if there is no change', async () => { - const addContents = [ - ['g', 'user:default/guest', 'role:default/catalog-writer'], - [ - 'p', - 'role:default/catalog-writer', - 'catalog-entity', - 'update', - 'allow', - ], - ]; - - csvFileWatcher.parse = jest.fn().mockImplementation(() => { - return addContents; - }); - - await csvFileWatcher.onChange(); - - const enfRoles = await enforcerDelegate.getGroupingPolicy(); - const enfPolicies = await enforcerDelegate.getPolicy(); - - expect(enfRoles).toStrictEqual([ - ['user:default/guest', 'role:default/catalog-writer'], - ]); - expect(enfPolicies).toStrictEqual([ - ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], - ]); - }); - }); - - describe('cleanUpRolesAndPolicies', () => { - let csvFileWatcher: CSVFileWatcher; - - const roleMetadata: RoleMetadataDao = { - roleEntityRef: 'role:default/dev', - source: 'csv-file', - modifiedBy, - }; - - beforeEach(async () => { - csvFileWatcher = createCSVFileWatcher(); - await csvFileWatcher.initialize(); - }); - - it('should remove all roles and policies', async () => { - const permissionPolicies = [ - ['role:default/dev', 'catalog-entity', 'update', 'allow'], - ['role:default/dev', 'catalog-entity', 'allow', 'allow'], - ]; - - await enforcerDelegate.addPolicies(permissionPolicies); - await enforcerDelegate.addGroupingPolicies( - [['user:default/guest', 'role:default/dev']], - roleMetadata, - ); - roleMetadataStorageMock.filterRoleMetadata = jest - .fn() - .mockImplementation((source: Source) => { - if (source === 'csv-file') { - return [roleMetadata]; - } - return []; - }); - - await csvFileWatcher.cleanUpRolesAndPolicies(); - const enfPolicies = await enforcerDelegate.getPolicy(); - - expect(enfPolicies).toStrictEqual([]); - - const enfRoles = await enforcerDelegate.getGroupingPolicy(); - expect(enfRoles).toStrictEqual([]); - - expect( - roleMetadataStorageMock.removeRoleMetadata, - ).toHaveBeenNthCalledWith( - 1, - roleMetadata.roleEntityRef, - expect.anything(), - ); - }); - }); -}); - -async function createEnforcer( - theModel: Model, - adapter: Adapter, - logger: LoggerService, -): Promise { - const catalogDBClient = Knex.knex({ client: MockClient }); - const rbacDBClient = Knex.knex({ client: MockClient }); - const enf = await newEnforcer(theModel, adapter); - - const config = newConfig(); - - const rm = new BackstageRoleManager( - catalogServiceMock.mock(), - logger, - catalogDBClient, - rbacDBClient, - config, - mockAuthService, - new DefaultPermissionsReader(config), - ); - enf.setRoleManager(rm); - enf.enableAutoBuildRoleLinks(false); - await enf.buildRoleLinks(); - - return enf; -} - -function newConfig( - users?: Array<{ name: string }>, - superUsers?: Array<{ name: string }>, -): Config { - const testUsers = [ - { - name: 'user:default/guest', - }, - { - name: 'group:default/guests', - }, - ]; - - return mockServices.rootConfig({ - data: { - permission: { - rbac: { - admin: { - users: users || testUsers, - superUsers: superUsers, - }, - }, - }, - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - }, - }); -} diff --git a/plugins/rbac-backend/src/file-permissions/csv-file-watcher.ts b/plugins/rbac-backend/src/file-permissions/csv-file-watcher.ts deleted file mode 100644 index 3bbda9d743..0000000000 --- a/plugins/rbac-backend/src/file-permissions/csv-file-watcher.ts +++ /dev/null @@ -1,622 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - AuditorService, - LoggerService, -} from '@backstage/backend-plugin-api'; - -import { Enforcer, newEnforcer, newModelFromString } from 'casbin'; -import { parse } from 'csv-parse/sync'; -import { difference } from 'lodash'; - -import { ActionType, PermissionEvents, RoleEvents } from '../auditor/auditor'; -import { - RoleMetadataDao, - RoleMetadataStorage, -} from '../database/role-metadata'; -import { - mergeRoleMetadata, - metadataStringToPolicy, - policyToString, - transformArrayToPolicy, - transformPolicyGroupToLowercase, -} from '../helper'; -import { EnforcerDelegate } from '../service/enforcer-delegate'; -import { MODEL } from '../service/permission-model'; -import { - checkForDuplicateGroupPolicies, - checkForDuplicatePolicies, - validateGroupingPolicy, - validatePolicy, - validateSource, -} from '../validation/policies-validation'; -import { AbstractFileWatcher } from './file-watcher'; -import { LowercaseFileAdapter } from './lowercase-file-adapter'; - -export const CSV_PERMISSION_POLICY_FILE_AUTHOR = 'csv permission policy file'; - -type CSVFilePolicies = { - addedPolicies: string[][]; - removedPolicies: string[][]; - addedGroupPolicies: Map; - removedGroupPolicies: Map; -}; - -export class CSVFileWatcher extends AbstractFileWatcher { - private currentContent: string[][]; - private csvFilePolicies: CSVFilePolicies; - - constructor( - filePath: string | undefined, - allowReload: boolean, - logger: LoggerService, - private readonly enforcer: EnforcerDelegate, - private readonly roleMetadataStorage: RoleMetadataStorage, - private readonly auditor: AuditorService, - ) { - super(filePath, allowReload, logger); - this.currentContent = []; - this.csvFilePolicies = { - addedPolicies: [], - removedPolicies: [], - addedGroupPolicies: new Map(), - removedGroupPolicies: new Map(), - }; - } - - /** - * parse is used to parse the current contents of the CSV file. - * @returns The CSV file parsed into a string[][]. - */ - parse(): string[][] { - const content = this.getCurrentContents(); - const data = parse(content, { - skip_empty_lines: true, - relax_column_count: true, - trim: true, - }); - - for (const policy of data) { - transformPolicyGroupToLowercase(policy); - } - - return data; - } - - /** - * initialize will initialize the CSV file by loading all of the permission policies and roles into - * the enforcer. - * First, we will remove all roles and permission policies if they do not exist in the temporary file enforcer. - * Next, we will add all roles and permission polices if they are new to the CSV file - * Finally, we will set the file to be watched if allow reload is set - * @param csvFileName The name of the csvFile - * @param allowReload Whether or not we will allow reloads of the CSV file - */ - async initialize(): Promise { - if (!this.filePath) { - return; - } - let content: string[][] = []; - // If the file is set load the file contents - content = this.parse(); - - const tempEnforcer = await newEnforcer( - newModelFromString(MODEL), - new LowercaseFileAdapter(this.filePath), - ); - - // Check for any old policies that will need to be removed - await this.filterPoliciesAndRoles( - this.enforcer, - tempEnforcer, - this.csvFilePolicies.removedPolicies, - this.csvFilePolicies.removedGroupPolicies, - true, - ); - - await this.filterPoliciesAndRoles( - tempEnforcer, - this.enforcer, - this.csvFilePolicies.addedPolicies, - this.csvFilePolicies.addedGroupPolicies, - ); - - await this.migrateLegacyMetadata(tempEnforcer); - - // We pass current here because this is during initialization and it has not changed yet - await this.updatePolicies(content); - - if (this.allowReload) { - this.watchFile(); - } - } - - // Check for policies that might need to be updated - // This will involve update "legacy" source in the role metadata if it exist in both the - // temp enforcer (csv file) and a role metadata storage. - // We will update role metadata with the new source "csv-file" - private async migrateLegacyMetadata(tempEnforcer: Enforcer) { - let legacyRolesMetadata = - await this.roleMetadataStorage.filterRoleMetadata('legacy'); - const legacyRoles = legacyRolesMetadata.map(meta => meta.roleEntityRef); - if (legacyRoles.length > 0) { - const legacyGroupPolicies = await tempEnforcer.getFilteredGroupingPolicy( - 1, - ...legacyRoles, - ); - const legacyPolicies = await tempEnforcer.getFilteredPolicy( - 0, - ...legacyRoles, - ); - const legacyRolesFromFile = new Set([ - ...legacyGroupPolicies.map(gp => gp[1]), - ...legacyPolicies.map(p => p[0]), - ]); - legacyRolesMetadata = legacyRolesMetadata.filter(meta => - legacyRolesFromFile.has(meta.roleEntityRef), - ); - for (const legacyRoleMeta of legacyRolesMetadata) { - const nonLegacyRole = mergeRoleMetadata(legacyRoleMeta, { - modifiedBy: CSV_PERMISSION_POLICY_FILE_AUTHOR, - source: 'csv-file', - roleEntityRef: legacyRoleMeta.roleEntityRef, - }); - await this.roleMetadataStorage.updateRoleMetadata( - nonLegacyRole, - legacyRoleMeta.roleEntityRef, - ); - } - } - } - - /** - * onChange is called whenever there is a change to the CSV file. - * It will parse the current and new contents of the CSV file and process the roles and permission policies present. - * Afterwards, it will find the difference between the current and new contents of the CSV file - * and sort them into added / removed, permission policies / roles. - * It will finally call updatePolicies with the new content. - */ - async onChange(): Promise { - const newContent = this.parse(); - - const tempEnforcer = await newEnforcer( - newModelFromString(MODEL), - new LowercaseFileAdapter(this.filePath!), - ); - - const currentFlatContent = this.currentContent.flatMap(data => { - return policyToString(data); - }); - const newFlatContent = newContent.flatMap(data => { - return policyToString(data); - }); - - await this.findFileContentDiff( - currentFlatContent, - newFlatContent, - tempEnforcer, - ); - - await this.updatePolicies(newContent); - } - - /** - * updatePolicies is used to update all of the permission policies and roles within a CSV file. - * It will check the number of added and removed permissions policies and roles and call the appropriate - * methods for these. - * It will also update the current contents of the CSV file to the most recent - * @param newContent The new content present in the CSV file - */ - private async updatePolicies(newContent: string[][]): Promise { - this.currentContent = newContent; - - if (this.csvFilePolicies.addedPolicies.length > 0) - await this.addPermissionPolicies(); - if (this.csvFilePolicies.removedPolicies.length > 0) - await this.removePermissionPolicies(); - if (this.csvFilePolicies.addedGroupPolicies.size > 0) await this.addRoles(); - if (this.csvFilePolicies.removedGroupPolicies.size > 0) - await this.removeRoles(); - } - - /** - * addPermissionPolicies will add the new permission policies that are present in the CSV file. - */ - private async addPermissionPolicies(): Promise { - const auditorEvent = await this.auditor.createEvent({ - eventId: PermissionEvents.POLICY_WRITE, - severityLevel: 'medium', - meta: { actionType: ActionType.CREATE, source: 'csv-file' }, - }); - - try { - await this.enforcer.addPolicies(this.csvFilePolicies.addedPolicies); - await auditorEvent.success({ - meta: { policies: this.csvFilePolicies.addedPolicies }, - }); - } catch (e) { - await auditorEvent.fail({ - meta: { policies: this.csvFilePolicies.addedPolicies }, - error: e, - }); - } - - this.csvFilePolicies.addedPolicies = []; - } - - /** - * removePermissionPolicies will remove the permission policies that are no longer present in the CSV file. - */ - private async removePermissionPolicies(): Promise { - const auditorEvent = await this.auditor.createEvent({ - eventId: PermissionEvents.POLICY_WRITE, - severityLevel: 'medium', - meta: { actionType: ActionType.DELETE, source: 'csv-file' }, - }); - - try { - await this.enforcer.removePolicies(this.csvFilePolicies.removedPolicies); - await auditorEvent.success({ - meta: { policies: this.csvFilePolicies.removedPolicies }, - }); - } catch (e) { - await auditorEvent.fail({ - meta: { policies: this.csvFilePolicies.removedPolicies }, - error: e, - }); - } - - this.csvFilePolicies.removedPolicies = []; - } - - /** - * addRoles will add the new roles that are present in the CSV file. - */ - private async addRoles(): Promise { - const changedPolicies: { - addedPolicies: string[][]; - updatedPolicies: string[][]; - failedPolicies: { error: string; policies: string[][] }[]; - } = { - addedPolicies: [], - updatedPolicies: [], - failedPolicies: [], - }; - - const auditorEvent = await this.auditor.createEvent({ - eventId: RoleEvents.ROLE_WRITE, - severityLevel: 'medium', - meta: { actionType: ActionType.CREATE_OR_UPDATE, source: 'csv-file' }, - }); - - for (const [key, value] of this.csvFilePolicies.addedGroupPolicies) { - const groupPolicies = value.map(member => { - return [member, key]; - }); - - const roleMetadata: RoleMetadataDao = { - source: 'csv-file', - roleEntityRef: key, - author: CSV_PERMISSION_POLICY_FILE_AUTHOR, - modifiedBy: CSV_PERMISSION_POLICY_FILE_AUTHOR, - }; - - try { - const currentMetadata = await this.roleMetadataStorage.findRoleMetadata( - roleMetadata.roleEntityRef, - ); - - await this.enforcer.addGroupingPolicies(groupPolicies, roleMetadata); - - if (currentMetadata) { - changedPolicies.updatedPolicies.push(...groupPolicies); - } else { - changedPolicies.addedPolicies.push(...groupPolicies); - } - } catch (e) { - changedPolicies.failedPolicies.push({ - error: e, - policies: groupPolicies, - }); - } - } - - if (changedPolicies.failedPolicies.length > 0) { - await auditorEvent.fail({ - error: new Error( - `Failed to add or update group policies after modification ${this.filePath}.`, - ), - meta: { ...changedPolicies }, - }); - } else { - await auditorEvent.success({ - meta: { - addedPolicies: changedPolicies.addedPolicies, - updatedPolicies: changedPolicies.updatedPolicies, - }, - }); - } - - this.csvFilePolicies.addedGroupPolicies = new Map(); - } - - /** - * removeRoles will remove the roles that are no longer present in the CSV file. - * If the role exists with multiple groups and or users, we will update it role information. - * Otherwise, we will remove the role completely. - */ - private async removeRoles(): Promise { - for (const [key, value] of this.csvFilePolicies.removedGroupPolicies) { - // This requires knowledge of whether or not it is an update - const oldGroupingPolicies = await this.enforcer.getFilteredGroupingPolicy( - 1, - key, - ); - const groupPolicies = value.map(member => { - return [member, key]; - }); - - const roleMetadata: RoleMetadataDao = { - source: 'csv-file', - roleEntityRef: key, - author: CSV_PERMISSION_POLICY_FILE_AUTHOR, - modifiedBy: CSV_PERMISSION_POLICY_FILE_AUTHOR, - }; - const isUpdate = - oldGroupingPolicies.length > 1 && - oldGroupingPolicies.length !== groupPolicies.length; - const actionType = isUpdate ? ActionType.UPDATE : ActionType.DELETE; - - const meta = { - ...roleMetadata, - members: value, - }; - const auditorEvent = await this.auditor.createEvent({ - eventId: RoleEvents.ROLE_WRITE, - severityLevel: 'medium', - meta: { actionType, source: meta.source }, - }); - - try { - await this.enforcer.removeGroupingPolicies( - groupPolicies, - roleMetadata, - isUpdate, - ); - await auditorEvent.success({ meta }); - } catch (e) { - await auditorEvent.fail({ - meta, - error: e, - }); - } - } - - this.csvFilePolicies.removedGroupPolicies = new Map(); - } - - async cleanUpRolesAndPolicies(): Promise { - const roleMetadatas = - await this.roleMetadataStorage.filterRoleMetadata('csv-file'); - const fileRoles = roleMetadatas.map(meta => meta.roleEntityRef); - - if (fileRoles.length > 0) { - for (const fileRole of fileRoles) { - const filteredPolicies = await this.enforcer.getFilteredGroupingPolicy( - 1, - fileRole, - ); - for (const groupPolicy of filteredPolicies) { - this.addGroupPolicyToMap( - this.csvFilePolicies.removedGroupPolicies, - groupPolicy[1], - groupPolicy[0], - ); - } - this.csvFilePolicies.removedPolicies.push( - ...(await this.enforcer.getFilteredPolicy(0, fileRole)), - ); - } - } - await this.removePermissionPolicies(); - await this.removeRoles(); - } - - async filterPoliciesAndRoles( - enforcerOne: Enforcer | EnforcerDelegate, - enforcerTwo: Enforcer | EnforcerDelegate, - policies: string[][], - groupPolicies: Map, - remove?: boolean, - ) { - // Check for any policies that need to be edited by comparing policies from - // one enforcer to the other - const policiesToEdit = await enforcerOne.getPolicy(); - const groupPoliciesToEdit = await enforcerOne.getGroupingPolicy(); - - for (const policy of policiesToEdit) { - if ( - !(await enforcerTwo.hasPolicy(...policy)) && - (await this.validateAddedPolicy( - policy, - enforcerOne as Enforcer, - remove, - )) - ) { - policies.push(policy); - } - - // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` - if (policy[1] === 'policy-entity' && policy[2] === 'create' && !remove) { - this.logger.warn( - `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${policy} to use 'policy.entity.create' instead of 'policy-entity' from source csv-file`, - ); - } - } - - for (const groupPolicy of groupPoliciesToEdit) { - if ( - !(await enforcerTwo.hasGroupingPolicy(...groupPolicy)) && - (await this.validateAddedGroupPolicy( - groupPolicy, - enforcerOne as Enforcer, - remove, - )) - ) { - this.addGroupPolicyToMap(groupPolicies, groupPolicy[1], groupPolicy[0]); - } - } - } - - async validateAddedPolicy( - policy: string[], - tempEnforcer: Enforcer, - remove?: boolean, - ): Promise { - const transformedPolicy = transformArrayToPolicy(policy); - const metadata = await this.roleMetadataStorage.findRoleMetadata(policy[0]); - - if (remove) { - return metadata?.source === 'csv-file'; - } - - let err = validatePolicy(transformedPolicy); - if (err) { - this.logger.warn( - `Failed to validate policy from file ${this.filePath}. Cause: ${err.message}`, - ); - return false; - } - - err = await validateSource('csv-file', metadata); - if (err) { - this.logger.warn( - `Unable to add policy ${policy} from file ${this.filePath}. Cause: ${err.message}`, - ); - return false; - } - - err = await checkForDuplicatePolicies(tempEnforcer, policy, this.filePath!); - if (err) { - this.logger.warn(err.message); - return false; - } - - return true; - } - - async validateAddedGroupPolicy( - groupPolicy: string[], - tempEnforcer: Enforcer, - remove?: boolean, - ): Promise { - const metadata = await this.roleMetadataStorage.findRoleMetadata( - groupPolicy[1], - ); - - if (remove) { - return metadata?.source === 'csv-file'; - } - - let err = await validateGroupingPolicy(groupPolicy, metadata, 'csv-file'); - if (err) { - this.logger.warn( - `${err.message}, error originates from file ${this.filePath}`, - ); - return false; - } - - err = await checkForDuplicateGroupPolicies( - tempEnforcer, - groupPolicy, - this.filePath!, - ); - if (err) { - this.logger.warn(err.message); - return false; - } - - return true; - } - - async findFileContentDiff( - currentFlatContent: string[], - newFlatContent: string[], - tempEnforcer: Enforcer, - ) { - const diffRemoved = difference(currentFlatContent, newFlatContent); // policy was removed - const diffAdded = difference(newFlatContent, currentFlatContent); // policy was added - - await this.migrateLegacyMetadata(tempEnforcer); - - if (diffRemoved.length === 0 && diffAdded.length === 0) { - return; - } - - diffRemoved.forEach(policy => { - const convertedPolicy = metadataStringToPolicy(policy); - if (convertedPolicy[0] === 'p') { - convertedPolicy.splice(0, 1); - this.csvFilePolicies.removedPolicies.push(convertedPolicy); - } else if (convertedPolicy[0] === 'g') { - convertedPolicy.splice(0, 1); - this.addGroupPolicyToMap( - this.csvFilePolicies.removedGroupPolicies, - convertedPolicy[1], - convertedPolicy[0], - ); - } - }); - - for (const policy of diffAdded) { - const convertedPolicy = metadataStringToPolicy(policy); - if (convertedPolicy[0] === 'p') { - convertedPolicy.splice(0, 1); - if (await this.validateAddedPolicy(convertedPolicy, tempEnforcer)) - this.csvFilePolicies.addedPolicies.push(convertedPolicy); - - // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` - if ( - convertedPolicy[1] === 'policy-entity' && - convertedPolicy[2] === 'create' - ) { - this.logger.warn( - `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${convertedPolicy} to use 'policy.entity.create' instead of 'policy-entity' from source csv-file`, - ); - } - } else if (convertedPolicy[0] === 'g') { - convertedPolicy.splice(0, 1); - if (await this.validateAddedGroupPolicy(convertedPolicy, tempEnforcer)) - this.addGroupPolicyToMap( - this.csvFilePolicies.addedGroupPolicies, - convertedPolicy[1], - convertedPolicy[0], - ); - } - } - } - - addGroupPolicyToMap( - groupPolicyMap: Map, - key: string, - value: string, - ) { - if (!groupPolicyMap.has(key)) { - groupPolicyMap.set(key, []); - } - groupPolicyMap.get(key)?.push(value); - } -} diff --git a/plugins/rbac-backend/src/file-permissions/file-watcher.ts b/plugins/rbac-backend/src/file-permissions/file-watcher.ts deleted file mode 100644 index 06debeb1ed..0000000000 --- a/plugins/rbac-backend/src/file-permissions/file-watcher.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; - -import chokidar from 'chokidar'; - -import fs from 'fs'; - -/** - * Represents a file watcher that can be used to monitor changes in a file. - */ -export abstract class AbstractFileWatcher { - constructor( - protected readonly filePath: string | undefined, - protected readonly allowReload: boolean, - protected readonly logger: LoggerService, - ) {} - - /** - * Initializes the file watcher and starts watching the specified file. - */ - abstract initialize(): Promise; - - /** - * watchFile initializes the file watcher and sets it to begin watching for changes. - */ - watchFile(): void { - if (!this.filePath) { - throw new Error('File path is not specified'); - } - const watcher = chokidar.watch(this.filePath); - watcher.on('change', async path => { - this.logger.info(`file ${path} has changed`); - await this.onChange(); - }); - watcher.on('error', error => { - this.logger.error(`error watching file ${this.filePath}: ${error}`); - }); - } - - /** - * Handles the change event when the watched file is modified. - * @returns A promise that resolves when the change event is handled. - */ - abstract onChange(): Promise; - - /** - * getCurrentContents reads the current contents of the CSV file. - * @returns The current contents of the file. - */ - getCurrentContents(): string { - if (!this.filePath) { - throw new Error('File path is not specified'); - } - return fs.readFileSync(this.filePath, 'utf-8'); - } - - /** - * parse is used to parse the current contents of the file. - * @returns The file parsed into a type . - */ - abstract parse(): T; -} diff --git a/plugins/rbac-backend/src/file-permissions/lowercase-file-adapter.ts b/plugins/rbac-backend/src/file-permissions/lowercase-file-adapter.ts deleted file mode 100644 index c4c003c9e9..0000000000 --- a/plugins/rbac-backend/src/file-permissions/lowercase-file-adapter.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { FileAdapter, Helper, Model, mustGetDefaultFileSystem } from 'casbin'; - -export class LowercaseFileAdapter extends FileAdapter { - public async loadPolicy(model: Model): Promise { - if (!this.filePath) { - return; - } - await this.loadLowercasePolicyFile(model, Helper.loadPolicyLine); - } - - private transformLineToLowercaseGroupsUsers(line: string): string { - if (line.trim().startsWith('g')) { - const policyArray = line.split(','); - if (policyArray.length >= 1 && policyArray[0].trim().startsWith('g')) { - policyArray[1] = policyArray[1].toLocaleLowerCase('en-US'); - } - return policyArray.join(','); - } - return line; - } - - private async loadLowercasePolicyFile( - model: Model, - handler: (line: string, model: Model) => void, - ): Promise { - // Reference: https://github.com/casbin/node-casbin/blob/master/src/persist/fileAdapter.ts#L34-#L43 - const bodyBuf = await ( - this.fs ? this.fs : mustGetDefaultFileSystem() - ).readFileSync(this.filePath); - const lines = bodyBuf.toString().split('\n'); - - lines.forEach((line: string) => { - if (!line) { - return; - } - const lowercasedLine = this.transformLineToLowercaseGroupsUsers(line); - handler(lowercasedLine, model); - }); - } -} diff --git a/plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.test.ts b/plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.test.ts deleted file mode 100644 index b9b7036c2f..0000000000 --- a/plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.test.ts +++ /dev/null @@ -1,629 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { mockServices } from '@backstage/backend-test-utils'; -import { - AuthorizeResult, - type MetadataResponse, -} from '@backstage/plugin-permission-common'; - -import { resolve } from 'path'; - -import { ActionType, ConditionEvents } from '../auditor/auditor'; -import { DataBaseConditionalStorage } from '../database/conditional-storage'; -import { - RoleMetadataDao, - RoleMetadataStorage, -} from '../database/role-metadata'; -import { RoleEventEmitter, RoleEvents } from '../service/enforcer-delegate'; -import { PluginPermissionMetadataCollector } from '../service/plugin-endpoints'; -import { YamlConditinalPoliciesFileWatcher } from './yaml-conditional-file-watcher'; // Adjust the import path as necessary -import { mockAuditorService } from '../../__fixtures__/mock-utils'; -import { expectAuditorLog } from '../../__fixtures__/auditor-test-utils'; -import { - PermissionInfo, - RoleConditionalPolicyDecision, -} from '@backstage-community/plugin-rbac-common'; -import { JsonObject } from '@backstage/types'; -import { NotFoundError } from '@backstage/errors'; - -const mockLoggerService = mockServices.logger.mock(); - -let loggerWarnSpy: jest.SpyInstance; - -const conditionalStorageMock: Partial = { - filterConditions: jest.fn().mockImplementation(), - createCondition: jest.fn().mockImplementation(), - checkConflictedConditions: jest.fn().mockImplementation(), - getCondition: jest.fn().mockImplementation(), - deleteCondition: jest.fn().mockImplementation(), - updateCondition: jest.fn().mockImplementation(), -}; - -const mockAuthService = mockServices.auth(); - -const testPluginMetadataResp: MetadataResponse = { - permissions: [ - { - type: 'resource', - name: 'catalog.entity.read', - attributes: { - action: 'read', - }, - resourceType: 'catalog-entity', - }, - { - type: 'basic', - name: 'catalog.entity.create', - attributes: { - action: 'create', - }, - }, - { - type: 'resource', - name: 'catalog.entity.delete', - attributes: { - action: 'delete', - }, - resourceType: 'catalog-entity', - }, - { - type: 'resource', - name: 'catalog.entity.refresh', - attributes: { - action: 'update', - }, - resourceType: 'catalog-entity', - }, - ], - rules: [ - { - name: 'IS_ENTITY_OWNER', - description: 'Allow entities owned by a specified claim', - resourceType: 'catalog-entity', - paramsSchema: { - type: 'object', - properties: { - claims: { - type: 'array', - items: { - type: 'string', - }, - description: - 'List of claims to match at least one on within ownedBy', - }, - }, - required: ['claims'], - additionalProperties: false, - $schema: 'http://json-schema.org/draft-07/schema#', - }, - }, - ], -}; - -const conditionToStore1: Partial< - RoleConditionalPolicyDecision -> & - Required< - Pick, 'permissionMapping'> - > = { - result: AuthorizeResult.CONDITIONAL, - roleEntityRef: 'role:default/test', - pluginId: 'catalog', - resourceType: 'catalog-entity', - permissionMapping: [{ name: 'catalog.entity.refresh', action: 'update' }], - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/team-a'], - }, - }, -}; - -const conditionToStore2: Partial< - RoleConditionalPolicyDecision -> & - Required< - Pick, 'permissionMapping'> - > = { - result: AuthorizeResult.CONDITIONAL, - roleEntityRef: 'role:default/test', - pluginId: 'catalog', - resourceType: 'catalog-entity', - permissionMapping: [ - { name: 'catalog.entity.read', action: 'read' }, - { name: 'catalog.entity.delete', action: 'delete' }, - ], - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/team-a', 'group:default/team-b'], - }, - }, -}; - -const conditionToRemove: Partial< - RoleConditionalPolicyDecision -> & - Required< - Pick, 'permissionMapping'> - > = { - id: 2, - result: AuthorizeResult.CONDITIONAL, - roleEntityRef: 'role:default/dev', - pluginId: 'catalog', - resourceType: 'catalog-entity', - permissionMapping: [{ name: 'catalog.entity.read', action: 'read' }], - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/team-dev'], - }, - }, -}; - -const pluginMetadataCollectorMock: Partial = - { - getPluginConditionRules: jest.fn().mockImplementation(), - getPluginPolicies: jest.fn().mockImplementation(), - getMetadataByPluginId: jest - .fn() - .mockImplementation(async () => testPluginMetadataResp), - }; - -const roleMetadataStorageMock: RoleMetadataStorage = { - filterRoleMetadata: jest.fn().mockImplementation(() => []), - filterForOwnerRoleMetadata: jest.fn().mockImplementation(), - findRoleMetadata: jest.fn().mockImplementation(), - createRoleMetadata: jest.fn().mockImplementation(), - updateRoleMetadata: jest.fn().mockImplementation(), - removeRoleMetadata: jest.fn().mockImplementation(), - getCachedDefaultRoleMetadata: jest.fn().mockImplementation(() => undefined), - getDefaultRole: jest.fn().mockResolvedValue(undefined), - syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), -}; - -const roleEventEmitterMock: RoleEventEmitter = { - on: jest.fn().mockImplementation(), -}; - -describe('YamlConditionalFileWatcher', () => { - let csvFileName: string; - - const csvFileRoles: RoleMetadataDao[] = [ - { - roleEntityRef: 'role:default/test', - source: 'csv-file', - author: 'user:default/tom', - modifiedBy: 'user:default/tom', - createdAt: '2021-09-01T00:00:00Z', - }, - ]; - - beforeEach(() => { - csvFileName = resolve( - __dirname, - '../../__fixtures__/data/valid-conditions/conditions.yaml', - ); - - loggerWarnSpy = jest.spyOn(mockLoggerService, 'warn'); - - conditionalStorageMock.createCondition = jest.fn().mockImplementation(); - conditionalStorageMock.deleteCondition = jest.fn().mockImplementation(); - jest.clearAllMocks(); - }); - - function createWatcher(filePath?: string): YamlConditinalPoliciesFileWatcher { - return new YamlConditinalPoliciesFileWatcher( - filePath, - false, - mockLoggerService, - conditionalStorageMock as DataBaseConditionalStorage, - mockAuditorService, - mockAuthService, - pluginMetadataCollectorMock as PluginPermissionMetadataCollector, - roleMetadataStorageMock, - roleEventEmitterMock, - ); - } - - test('handles errors for invalid file paths', async () => { - const invalidFilePath = 'invalid-file-path.yaml'; - const watcher = createWatcher(invalidFilePath); - await watcher.initialize(); - - expectAuditorLog([ - { - event: { eventId: ConditionEvents.CONDITIONAL_POLICIES_FILE_NOT_FOUND }, - fail: { error: new Error(`File '${invalidFilePath}' was not found`) }, - }, - ]); - }); - - test('handles error on parse invalid yaml file', async () => { - const invalidFilePath = resolve( - __dirname, - '../../__fixtures__/data/invalid-conditions/invalid-yaml.yaml', - ); - const watcher = createWatcher(invalidFilePath); - await watcher.initialize(); - - expectAuditorLog([ - { - event: { eventId: ConditionEvents.CONDITIONAL_POLICIES_FILE_CHANGE }, - fail: { - error: new Error( - `'roleEntityRef' must be specified in the role condition`, - ), - }, - }, - ]); - }); - - test('should handle error on create condition', async () => { - conditionalStorageMock.filterConditions = jest - .fn() - .mockImplementation(() => []); - roleMetadataStorageMock.filterRoleMetadata = jest - .fn() - .mockImplementation(() => csvFileRoles); - conditionalStorageMock.createCondition = jest - .fn() - .mockImplementationOnce(() => { - throw new Error('unknown error message 1'); - }) - .mockImplementationOnce(() => { - throw new Error('unknown error message 2'); - }); - - const watcher = createWatcher(csvFileName); - await watcher.initialize(); - - expect(conditionalStorageMock.createCondition).toHaveBeenCalled(); - expectAuditorLog([ - { - event: { - eventId: ConditionEvents.CONDITION_WRITE, - meta: { actionType: ActionType.CREATE }, - }, - fail: { - error: new Error('unknown error message 1'), - ...mappedConditionMeta(conditionToStore1), - }, - }, - { - event: { - eventId: ConditionEvents.CONDITION_WRITE, - meta: { actionType: ActionType.CREATE }, - }, - fail: { - error: new Error('unknown error message 2'), - ...mappedConditionMeta(conditionToStore2), - }, - }, - ]); - }); - - test('should add conditional policies from the file on initialization', async () => { - conditionalStorageMock.filterConditions = jest - .fn() - .mockImplementation(() => []); - roleMetadataStorageMock.filterRoleMetadata = jest - .fn() - .mockImplementation(() => csvFileRoles); - - const watcher = createWatcher(csvFileName); - await watcher.initialize(); - - expect(conditionalStorageMock.createCondition).toHaveBeenCalledWith( - conditionToStore1, - ); - expect(conditionalStorageMock.createCondition).toHaveBeenCalledWith( - conditionToStore2, - ); - expectAuditorLog([ - { - event: { - eventId: ConditionEvents.CONDITION_WRITE, - meta: { actionType: ActionType.CREATE }, - }, - success: { ...mappedConditionMeta(conditionToStore1) }, - }, - { - event: { - eventId: ConditionEvents.CONDITION_WRITE, - meta: { actionType: ActionType.CREATE }, - }, - success: { ...mappedConditionMeta(conditionToStore2) }, - }, - ]); - }); - - test('should not fail on initialization, when conditional policies contains empty array', async () => { - conditionalStorageMock.filterConditions = jest - .fn() - .mockImplementation(() => []); - roleMetadataStorageMock.filterRoleMetadata = jest - .fn() - .mockImplementation(() => csvFileRoles); - - csvFileName = resolve( - __dirname, - '../../__fixtures__/data/valid-conditions/empty-conditions.yaml', - ); - const watcher = createWatcher(csvFileName); - await watcher.initialize(); - expectAuditorLog([]); - }); - - test('should not fail on initialization, when conditional policies file contains extra delimiter', async () => { - conditionalStorageMock.filterConditions = jest - .fn() - .mockImplementation(() => []); - roleMetadataStorageMock.filterRoleMetadata = jest - .fn() - .mockImplementation(() => [ - { - roleEntityRef: 'role:default/test-2', - source: 'csv-file', - author: 'user:default/tom', - modifiedBy: 'user:default/tom', - createdAt: '2021-09-01T00:00:00Z', - }, - { - roleEntityRef: 'role:default/test-3', - source: 'csv-file', - author: 'user:default/tom', - modifiedBy: 'user:default/tom', - createdAt: '2021-09-01T00:00:00Z', - }, - ]); - - csvFileName = resolve( - __dirname, - '../../__fixtures__/data/valid-conditions/extra-delimiter-conditions.yaml', - ); - const watcher = createWatcher(csvFileName); - await watcher.initialize(); - const expectedCondition1 = { - result: AuthorizeResult.CONDITIONAL, - roleEntityRef: 'role:default/test-2', - pluginId: 'catalog', - resourceType: 'catalog-entity', - permissionMapping: [{ name: 'catalog.entity.refresh', action: 'update' }], - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/team-a'], - }, - }, - }; - const expectedCondition2 = { - result: AuthorizeResult.CONDITIONAL, - roleEntityRef: 'role:default/test-3', - pluginId: 'catalog', - resourceType: 'catalog-entity', - permissionMapping: [ - { name: 'catalog.entity.read', action: 'read' }, - { name: 'catalog.entity.delete', action: 'delete' }, - ], - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/team-a', 'group:default/team-b'], - }, - }, - }; - - expect(conditionalStorageMock.createCondition).toHaveBeenCalledWith( - expectedCondition1, - ); - expect(conditionalStorageMock.createCondition).toHaveBeenCalledWith( - expectedCondition2, - ); - expectAuditorLog([ - { - event: { - eventId: ConditionEvents.CONDITION_WRITE, - meta: { actionType: ActionType.CREATE }, - }, - success: { ...mappedConditionMeta(expectedCondition1 as any) }, - }, - { - event: { - eventId: ConditionEvents.CONDITION_WRITE, - meta: { actionType: ActionType.CREATE }, - }, - success: { ...mappedConditionMeta(expectedCondition2 as any) }, - }, - ]); - }); - - test(`should not apply conditions if corresponding role is present, but with non 'csv-file' source`, async () => { - conditionalStorageMock.filterConditions = jest - .fn() - .mockImplementation(() => []); - roleMetadataStorageMock.filterRoleMetadata = jest - .fn() - .mockImplementation(() => [ - { - ...csvFileRoles[0], - source: 'rest', - }, - ]); - - const watcher = createWatcher(csvFileName); - await watcher.initialize(); - - expect(conditionalStorageMock.createCondition).not.toHaveBeenCalled(); - - expectAuditorLog([]); - expect(loggerWarnSpy).toHaveBeenNthCalledWith( - 1, - `skip to add condition for role 'role:default/test'. Role is not from csv-file`, - ); - expect(loggerWarnSpy).toHaveBeenNthCalledWith( - 2, - `skip to add condition for role 'role:default/test'. Role is not from csv-file`, - ); - }); - - test('should not apply conditions if corresponding role is absent', async () => { - conditionalStorageMock.filterConditions = jest - .fn() - .mockImplementation(() => []); - roleMetadataStorageMock.filterRoleMetadata = jest - .fn() - .mockImplementation(() => []); - - const watcher = createWatcher(csvFileName); - await watcher.initialize(); - - expect(conditionalStorageMock.createCondition).not.toHaveBeenCalled(); - - expectAuditorLog([]); - expect(loggerWarnSpy).toHaveBeenNthCalledWith( - 1, - `skip to add condition for role 'role:default/test'. The role either does not exist or was not created from a CSV file.`, - ); - expect(loggerWarnSpy).toHaveBeenNthCalledWith( - 2, - `skip to add condition for role 'role:default/test'. The role either does not exist or was not created from a CSV file.`, - ); - }); - - test('should remove conditions, which is not included to yaml any more', async () => { - conditionalStorageMock.filterConditions = jest - .fn() - .mockImplementation(() => [conditionToRemove]); - roleMetadataStorageMock.filterRoleMetadata = jest - .fn() - .mockImplementation(() => []); - - const watcher = createWatcher(csvFileName); - await watcher.initialize(); - - expect(conditionalStorageMock.createCondition).not.toHaveBeenCalled(); - - expectAuditorLog([ - { - event: { - eventId: ConditionEvents.CONDITION_WRITE, - meta: { actionType: ActionType.DELETE }, - }, - success: { ...mappedConditionMeta(conditionToRemove) }, - }, - ]); - expect(conditionalStorageMock.deleteCondition).toHaveBeenCalledWith(2); - }); - - test('should handle error on delete condition', async () => { - conditionalStorageMock.filterConditions = jest - .fn() - .mockImplementation(() => [conditionToRemove]); - roleMetadataStorageMock.filterRoleMetadata = jest - .fn() - .mockImplementation(() => []); - conditionalStorageMock.deleteCondition = jest - .fn() - .mockImplementation(() => { - throw new NotFoundError('Condition was not found'); - }); - - const watcher = createWatcher(csvFileName); - await watcher.initialize(); - - expect(conditionalStorageMock.createCondition).not.toHaveBeenCalled(); - - expect(conditionalStorageMock.deleteCondition).toHaveBeenCalled(); - expectAuditorLog([ - { - event: { - eventId: ConditionEvents.CONDITION_WRITE, - meta: { actionType: ActionType.DELETE }, - }, - fail: { - error: new NotFoundError('Condition was not found'), - ...mappedConditionMeta(conditionToRemove), - }, - }, - ]); - }); - - test('should clean up conditions if conditional file was not specified', async () => { - conditionalStorageMock.filterConditions = jest - .fn() - .mockImplementation(() => [conditionToRemove]); - roleMetadataStorageMock.filterRoleMetadata = jest - .fn() - .mockImplementation(() => csvFileRoles); - - const watcher = createWatcher(); - await watcher.initialize(); - await watcher.cleanUpConditionalPolicies(); - - expect(conditionalStorageMock.createCondition).not.toHaveBeenCalled(); - expectAuditorLog([ - { - event: { - eventId: ConditionEvents.CONDITION_WRITE, - meta: { actionType: ActionType.DELETE }, - }, - success: { ...mappedConditionMeta(conditionToRemove) }, - }, - ]); - expect(conditionalStorageMock.deleteCondition).toHaveBeenNthCalledWith( - 1, - 2, - ); - }); - - test('should not clean up conditions if list conditions is empty', async () => { - conditionalStorageMock.filterConditions = jest - .fn() - .mockImplementation(() => []); - roleMetadataStorageMock.filterRoleMetadata = jest - .fn() - .mockImplementation(() => csvFileRoles); - - const watcher = createWatcher(); - await watcher.initialize(); - await watcher.cleanUpConditionalPolicies(); - - expect(conditionalStorageMock.createCondition).not.toHaveBeenCalled(); - expectAuditorLog([]); - expect(conditionalStorageMock.deleteCondition).not.toHaveBeenCalled(); - }); -}); - -function mappedConditionMeta( - condition: Required< - Pick, 'permissionMapping'> - >, -): JsonObject { - return { - meta: { - condition: { - ...condition, - permissionMapping: condition.permissionMapping.map(pm => pm.action), - }, - }, - }; -} diff --git a/plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.ts b/plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.ts deleted file mode 100644 index 6f81dc31d3..0000000000 --- a/plugins/rbac-backend/src/file-permissions/yaml-conditional-file-watcher.ts +++ /dev/null @@ -1,267 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - AuditorService, - AuthService, - LoggerService, -} from '@backstage/backend-plugin-api'; - -import yaml from 'js-yaml'; -import { omit } from 'lodash'; - -import type { - PermissionAction, - RoleConditionalPolicyDecision, -} from '@backstage-community/plugin-rbac-common'; - -import fs from 'fs'; - -import { ActionType, ConditionEvents } from '../auditor/auditor'; -import { ConditionalStorage } from '../database/conditional-storage'; -import { RoleMetadataStorage } from '../database/role-metadata'; -import { deepSortEqual, processConditionMapping } from '../helper'; -import { RoleEventEmitter, RoleEvents } from '../service/enforcer-delegate'; -import { PluginPermissionMetadataCollector } from '../service/plugin-endpoints'; -import { validateRoleCondition } from '../validation/condition-validation'; -import { AbstractFileWatcher } from './file-watcher'; - -type ConditionalPoliciesDiff = { - addedConditions: RoleConditionalPolicyDecision[]; - removedConditions: RoleConditionalPolicyDecision[]; -}; - -export class YamlConditinalPoliciesFileWatcher extends AbstractFileWatcher< - RoleConditionalPolicyDecision[] -> { - private conditionsDiff: ConditionalPoliciesDiff; - - constructor( - filePath: string | undefined, - allowReload: boolean, - logger: LoggerService, - private readonly conditionalStorage: ConditionalStorage, - private readonly auditor: AuditorService, - private readonly auth: AuthService, - private readonly pluginMetadataCollector: PluginPermissionMetadataCollector, - private readonly roleMetadataStorage: RoleMetadataStorage, - private readonly roleEventEmitter: RoleEventEmitter, - ) { - super(filePath, allowReload, logger); - - this.conditionsDiff = { - addedConditions: [], - removedConditions: [], - }; - } - - async initialize(): Promise { - if (!this.filePath) { - return; - } - const fileExists = fs.existsSync(this.filePath); - if (!fileExists) { - const auditorEvent = await this.auditor.createEvent({ - eventId: ConditionEvents.CONDITIONAL_POLICIES_FILE_NOT_FOUND, - severityLevel: 'medium', - }); - await auditorEvent.fail({ - error: new Error(`File '${this.filePath}' was not found`), - }); - return; - } - - this.roleEventEmitter.on('roleAdded', this.onChange.bind(this)); - await this.onChange(); - - if (this.allowReload) { - this.watchFile(); - } - } - - async onChange(): Promise { - try { - const newConds = this.parse(); - - const addedConds: RoleConditionalPolicyDecision[] = []; - const removedConds: RoleConditionalPolicyDecision[] = - []; - - const csvFileRoles = - await this.roleMetadataStorage.filterRoleMetadata('csv-file'); - const existedFileConds = ( - await this.conditionalStorage.filterConditions( - csvFileRoles.map(role => role.roleEntityRef), - ) - ).map(condition => { - return { - ...condition, - permissionMapping: condition.permissionMapping.map(pm => pm.action), - }; - }); - - // Find added conditions - for (const condition of newConds) { - const roleMetadata = csvFileRoles.find( - role => condition.roleEntityRef === role.roleEntityRef, - ); - if (!roleMetadata) { - this.logger.warn( - `skip to add condition for role '${condition.roleEntityRef}'. The role either does not exist or was not created from a CSV file.`, - ); - continue; - } - if (roleMetadata.source !== 'csv-file') { - this.logger.warn( - `skip to add condition for role '${condition.roleEntityRef}'. Role is not from csv-file`, - ); - continue; - } - - const existingCondition = existedFileConds.find(c => - deepSortEqual(omit(c, ['id']), omit(condition, ['id'])), - ); - - if (!existingCondition) { - addedConds.push(condition); - } - } - - // Find removed conditions - for (const condition of existedFileConds) { - if ( - !newConds.find(c => - deepSortEqual(omit(c, ['id']), omit(condition, ['id'])), - ) - ) { - removedConds.push(condition); - } - } - - this.conditionsDiff = { - addedConditions: addedConds, - removedConditions: removedConds, - }; - - await this.handleFileChanges(); - } catch (error) { - const auditorEvent = await this.auditor.createEvent({ - eventId: ConditionEvents.CONDITIONAL_POLICIES_FILE_CHANGE, - severityLevel: 'medium', - }); - await auditorEvent.fail({ - error, - }); - } - } - - /** - * Reads the current contents of the file and parses it. - * @returns parsed data. - */ - parse(): RoleConditionalPolicyDecision[] { - const fileContents = this.getCurrentContents(); - const data = yaml - .loadAll(fileContents) - .filter( - doc => doc !== null, - ) as RoleConditionalPolicyDecision[]; - - for (const condition of data) { - validateRoleCondition(condition); - } - - return data; - } - - private async handleFileChanges(): Promise { - await this.removeConditions(); - await this.addConditions(); - } - - private async addConditions(): Promise { - for (const condition of this.conditionsDiff.addedConditions) { - const auditorEvent = await this.auditor.createEvent({ - eventId: ConditionEvents.CONDITION_WRITE, - severityLevel: 'medium', - meta: { actionType: ActionType.CREATE }, - }); - - try { - const conditionToCreate = await processConditionMapping( - condition, - this.pluginMetadataCollector, - this.auth, - ); - - await this.conditionalStorage.createCondition(conditionToCreate); - await auditorEvent.success({ - meta: { condition }, - }); - } catch (error) { - await auditorEvent.fail({ error, meta: { condition } }); - } - } - - this.conditionsDiff.addedConditions = []; - } - - private async removeConditions(): Promise { - for (const condition of this.conditionsDiff.removedConditions) { - const auditorEvent = await this.auditor.createEvent({ - eventId: ConditionEvents.CONDITION_WRITE, - severityLevel: 'medium', - meta: { actionType: ActionType.DELETE }, - }); - - try { - const conditionToDelete = ( - await this.conditionalStorage.filterConditions( - condition.roleEntityRef, - condition.pluginId, - condition.resourceType, - condition.permissionMapping, - ) - )[0]; - await this.conditionalStorage.deleteCondition(conditionToDelete.id!); - await auditorEvent.success({ meta: { condition } }); - } catch (error) { - await auditorEvent.fail({ - error, - meta: { condition }, - }); - } - } - - this.conditionsDiff.removedConditions = []; - } - - async cleanUpConditionalPolicies(): Promise { - const csvFileRoles = - await this.roleMetadataStorage.filterRoleMetadata('csv-file'); - const existedFileConds = ( - await this.conditionalStorage.filterConditions( - csvFileRoles.map(role => role.roleEntityRef), - ) - ).map(condition => { - return { - ...condition, - permissionMapping: condition.permissionMapping.map(pm => pm.action), - }; - }); - this.conditionsDiff.removedConditions = existedFileConds; - await this.removeConditions(); - } -} diff --git a/plugins/rbac-backend/src/helper.test.ts b/plugins/rbac-backend/src/helper.test.ts deleted file mode 100644 index 4b4e003611..0000000000 --- a/plugins/rbac-backend/src/helper.test.ts +++ /dev/null @@ -1,827 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { RoleMetadata } from '@backstage-community/plugin-rbac-common'; -import { clearAuditorMock } from '../__fixtures__/auditor-test-utils'; -import { mockAuditorService } from '../__fixtures__/mock-utils'; -import { ADMIN_ROLE_AUTHOR } from './admin-permissions/admin-creation'; -import { RoleMetadataDao } from './database/role-metadata'; -import { - deepSortedEqual, - isPermissionAction, - matches, - mergeRoleMetadata, - metadataStringToPolicy, - policiesToString, - policyToString, - removeTheDifference, - syncRolePolicies, - transformArrayToPolicy, - transformPolicyGroupToLowercase, - transformRolesGroupToLowercase, - typedPoliciesToString, - typedPolicyToString, -} from './helper'; -import { RBACFilters } from './permissions'; -// Import the function to test -import { EnforcerDelegate } from './service/enforcer-delegate'; - -const modifiedBy = 'user:default/some-user'; - -describe('helper.ts', () => { - describe('policyToString', () => { - it('should convert permission policy to string', () => { - const policy = [ - 'user:default/some-user', - 'catalog-entity', - 'read', - 'allow', - ]; - const expectedString = - '[user:default/some-user, catalog-entity, read, allow]'; - expect(policyToString(policy)).toEqual(expectedString); - }); - }); - - describe('typedPolicyToString', () => { - it('should convert permission policy to string', () => { - const policy = [ - 'user:default/some-user', - 'catalog-entity', - 'read', - 'allow', - ]; - const type = 'p'; - const expectedString = - 'p, user:default/some-user, catalog-entity, read, allow'; - expect(typedPolicyToString(policy, type)).toEqual(expectedString); - }); - }); - - describe('policiesToString', () => { - it('should convert one permission policy to string', () => { - const policies = [ - ['user:default/some-user', 'catalog-entity', 'read', 'allow'], - ]; - const expectedString = - '[[user:default/some-user, catalog-entity, read, allow]]'; - expect(policiesToString(policies)).toEqual(expectedString); - }); - - it('should convert empty permission policy array to string', () => { - const policies = [[]]; - const expectedString = '[[]]'; - expect(policiesToString(policies)).toEqual(expectedString); - }); - }); - - describe('typedPoliciesToString', () => { - it('should convert one permission policy to string', () => { - const policies = [ - ['user:default/some-user', 'catalog-entity', 'read', 'allow'], - ]; - const type = 'p'; - const expectedString = `\n p, user:default/some-user, catalog-entity, read, allow\n `; - - expect(typedPoliciesToString(policies, type)).toEqual(expectedString); - }); - - it('should convert empty permission policy array to string', () => { - const policies = [[]]; - const expectedString = `\n \n `; - const type = 'p'; - expect(typedPoliciesToString(policies, type)).toEqual(expectedString); - }); - }); - - describe('metadataStringToPolicy', () => { - it('parses a permission policy string', () => { - const policy = '[user:default/some-user, catalog-entity, read, allow]'; - const expectedPolicy = [ - 'user:default/some-user', - 'catalog-entity', - 'read', - 'allow', - ]; - expect(metadataStringToPolicy(policy)).toEqual(expectedPolicy); - }); - - it('parses a grouping policy', () => { - const policy = '[user:default/some-user, role:default/dev]'; - const expectedPolicy = ['user:default/some-user', 'role:default/dev']; - expect(metadataStringToPolicy(policy)).toEqual(expectedPolicy); - }); - }); - - describe('syncRolePolicies', () => { - it('should add new policies when they are not in the enforcer', async () => { - const mockEnforcer = { - getFilteredPolicy: jest - .fn() - .mockResolvedValue([ - ['role:default/test', 'catalog-entity', 'read', 'allow'], - ]), - addPolicies: jest.fn().mockResolvedValue(true), - removePolicies: jest.fn().mockResolvedValue(true), - } as unknown as EnforcerDelegate; - - const desiredPolicies = [ - ['role:default/test', 'catalog-entity', 'read', 'allow'], - ['role:default/test', 'catalog-entity', 'update', 'allow'], - ]; - - await syncRolePolicies( - mockEnforcer, - 'role:default/test', - desiredPolicies, - ); - - expect(mockEnforcer.getFilteredPolicy).toHaveBeenCalledWith( - 0, - 'role:default/test', - ); - expect(mockEnforcer.addPolicies).toHaveBeenCalledWith([ - ['role:default/test', 'catalog-entity', 'update', 'allow'], - ]); - expect(mockEnforcer.removePolicies).not.toHaveBeenCalled(); - }); - - it('should remove old policies when they are not in desired', async () => { - const mockEnforcer = { - getFilteredPolicy: jest.fn().mockResolvedValue([ - ['role:default/test', 'catalog-entity', 'read', 'allow'], - ['role:default/test', 'catalog-entity', 'delete', 'allow'], - ]), - addPolicies: jest.fn().mockResolvedValue(true), - removePolicies: jest.fn().mockResolvedValue(true), - } as unknown as EnforcerDelegate; - - const desiredPolicies = [ - ['role:default/test', 'catalog-entity', 'read', 'allow'], - ]; - - await syncRolePolicies( - mockEnforcer, - 'role:default/test', - desiredPolicies, - ); - - expect(mockEnforcer.getFilteredPolicy).toHaveBeenCalledWith( - 0, - 'role:default/test', - ); - expect(mockEnforcer.removePolicies).toHaveBeenCalledWith([ - ['role:default/test', 'catalog-entity', 'delete', 'allow'], - ]); - expect(mockEnforcer.addPolicies).not.toHaveBeenCalled(); - }); - - it('should add and remove policies to sync to desired state', async () => { - const mockEnforcer = { - getFilteredPolicy: jest.fn().mockResolvedValue([ - ['role:default/test', 'catalog-entity', 'read', 'allow'], - ['role:default/test', 'catalog-entity', 'delete', 'allow'], - ]), - addPolicies: jest.fn().mockResolvedValue(true), - removePolicies: jest.fn().mockResolvedValue(true), - } as unknown as EnforcerDelegate; - - const desiredPolicies = [ - ['role:default/test', 'catalog-entity', 'read', 'allow'], - ['role:default/test', 'catalog-entity', 'update', 'allow'], - ]; - - await syncRolePolicies( - mockEnforcer, - 'role:default/test', - desiredPolicies, - ); - - expect(mockEnforcer.getFilteredPolicy).toHaveBeenCalledWith( - 0, - 'role:default/test', - ); - expect(mockEnforcer.addPolicies).toHaveBeenCalledWith([ - ['role:default/test', 'catalog-entity', 'update', 'allow'], - ]); - expect(mockEnforcer.removePolicies).toHaveBeenCalledWith([ - ['role:default/test', 'catalog-entity', 'delete', 'allow'], - ]); - }); - - it('should do nothing when current and desired policies match', async () => { - const mockEnforcer = { - getFilteredPolicy: jest.fn().mockResolvedValue([ - ['role:default/test', 'catalog-entity', 'read', 'allow'], - ['role:default/test', 'catalog-entity', 'update', 'allow'], - ]), - addPolicies: jest.fn().mockResolvedValue(true), - removePolicies: jest.fn().mockResolvedValue(true), - } as unknown as EnforcerDelegate; - - const desiredPolicies = [ - ['role:default/test', 'catalog-entity', 'read', 'allow'], - ['role:default/test', 'catalog-entity', 'update', 'allow'], - ]; - - await syncRolePolicies( - mockEnforcer, - 'role:default/test', - desiredPolicies, - ); - - expect(mockEnforcer.getFilteredPolicy).toHaveBeenCalledWith( - 0, - 'role:default/test', - ); - expect(mockEnforcer.addPolicies).not.toHaveBeenCalled(); - expect(mockEnforcer.removePolicies).not.toHaveBeenCalled(); - }); - - it('should handle empty current policies', async () => { - const mockEnforcer = { - getFilteredPolicy: jest.fn().mockResolvedValue([]), - addPolicies: jest.fn().mockResolvedValue(true), - removePolicies: jest.fn().mockResolvedValue(true), - } as unknown as EnforcerDelegate; - - const desiredPolicies = [ - ['role:default/test', 'catalog-entity', 'read', 'allow'], - ]; - - await syncRolePolicies( - mockEnforcer, - 'role:default/test', - desiredPolicies, - ); - - expect(mockEnforcer.addPolicies).toHaveBeenCalledWith(desiredPolicies); - expect(mockEnforcer.removePolicies).not.toHaveBeenCalled(); - }); - - it('should handle empty desired policies', async () => { - const mockEnforcer = { - getFilteredPolicy: jest - .fn() - .mockResolvedValue([ - ['role:default/test', 'catalog-entity', 'read', 'allow'], - ]), - addPolicies: jest.fn().mockResolvedValue(true), - removePolicies: jest.fn().mockResolvedValue(true), - } as unknown as EnforcerDelegate; - - const desiredPolicies: string[][] = []; - - await syncRolePolicies( - mockEnforcer, - 'role:default/test', - desiredPolicies, - ); - - expect(mockEnforcer.removePolicies).toHaveBeenCalledWith([ - ['role:default/test', 'catalog-entity', 'read', 'allow'], - ]); - expect(mockEnforcer.addPolicies).not.toHaveBeenCalled(); - }); - }); - - describe('transformPolicyGroupToLowercase', () => { - it.each([ - [ - ['g', 'user:default/TOM', 'role:default/CATALOG-USER'], - ['g', 'user:default/tom', 'role:default/CATALOG-USER'], - ], - [ - ['g', 'group:default/Developers', 'role:default/CATALOG-USER'], - ['g', 'group:default/developers', 'role:default/CATALOG-USER'], - ], - ])('should convert group in %s to lowercase', (input, expected) => { - transformPolicyGroupToLowercase(input); - expect(input).toEqual(expected); - }); - - it('should not transform policy to lowercase', () => { - const policyArray = [ - 'p', - 'role:default/CATALOG-USER', - 'catalog-entity', - 'read', - 'allow', - ]; - const expected = [...policyArray]; - transformPolicyGroupToLowercase(policyArray); - expect(policyArray).toEqual(expected); - }); - - it('should handle invalid input', () => { - const policyArray = ['g']; - transformPolicyGroupToLowercase(policyArray); - expect(policyArray).toEqual(['g']); - }); - }); - - describe('transformRolesGroupToLowercase', () => { - it('should convert users and groups in roles to lowercase', () => { - const roles = [ - ['user:default/test', 'role:default/test-provider'], - ['group:default/Developers', 'role:default/Reader'], - ]; - const expectedRoles = [ - ['user:default/test', 'role:default/test-provider'], - ['group:default/developers', 'role:default/Reader'], - ]; - expect(transformRolesGroupToLowercase(roles)).toEqual(expectedRoles); - }); - - it.each([[[['user:default/test']]], [[[]]]])( - 'should handle invalid input %d', - input => { - const result = transformRolesGroupToLowercase(input); - expect(result).toEqual(input); - }, - ); - }); - - describe('removeTheDifference', () => { - const mockEnforcerDelegate: Partial = { - removeGroupingPolicies: jest.fn().mockImplementation(), - getFilteredGroupingPolicy: jest.fn().mockReturnValue([]), - }; - - beforeEach(() => { - (mockEnforcerDelegate.removeGroupingPolicies as jest.Mock).mockClear(); - clearAuditorMock(); - }); - - it('removes the difference between originalGroup and addedGroup', async () => { - const originalGroup = [ - 'user:default/some-user', - 'user:default/dev', - 'user:default/admin', - ]; - const addedGroup = ['user:default/some-user', 'user:default/dev']; - const source = 'rest'; - const roleName = 'role:default/admin'; - - await removeTheDifference( - originalGroup, - addedGroup, - source, - roleName, - mockEnforcerDelegate as EnforcerDelegate, - mockAuditorService, - ADMIN_ROLE_AUTHOR, - ); - - expect(mockEnforcerDelegate.removeGroupingPolicies).toHaveBeenCalledWith( - [['user:default/admin', roleName]], - { - modifiedBy: ADMIN_ROLE_AUTHOR, - roleEntityRef: 'role:default/admin', - source: 'rest', - }, - false, - ); - }); - - it('does nothing when originalGroup and addedGroup are the same', async () => { - const originalGroup = ['user:default/some-user', 'user:default/dev']; - const addedGroup = ['user:default/some-user', 'user:default/dev']; - const source = 'rest'; - const roleName = 'role:default/admin'; - - await removeTheDifference( - originalGroup, - addedGroup, - source, - roleName, - mockEnforcerDelegate as EnforcerDelegate, - mockAuditorService, - ADMIN_ROLE_AUTHOR, - ); - - expect( - mockEnforcerDelegate.removeGroupingPolicies, - ).not.toHaveBeenCalled(); - }); - - it('does nothing when originalGroup is empty', async () => { - const originalGroup: string[] = []; - const addedGroup = ['user:default/some-user', 'role:default/dev']; - const source = 'rest'; - const roleName = 'admin'; - - await removeTheDifference( - originalGroup, - addedGroup, - source, - roleName, - mockEnforcerDelegate as EnforcerDelegate, - mockAuditorService, - ADMIN_ROLE_AUTHOR, - ); - - expect( - mockEnforcerDelegate.removeGroupingPolicies, - ).not.toHaveBeenCalled(); - }); - }); - - describe('transformArrayToPolicy', () => { - it('transforms array to RoleBasedPolicy object', () => { - const policyArray = [ - 'role:default/dev', - 'catalog-entity', - 'read', - 'allow', - ]; - const expectedPolicy = { - entityReference: 'role:default/dev', - permission: 'catalog-entity', - policy: 'read', - effect: 'allow', - }; - - const result = transformArrayToPolicy(policyArray); - - expect(result).toEqual(expectedPolicy); - }); - }); - - describe('deepSortedEqual', () => { - it('should return true for identical objects with nested properties in different order', () => { - const obj1: RoleMetadataDao = { - description: 'qa team', - id: 1, - roleEntityRef: 'role:default/qa', - source: 'rest', - modifiedBy, - }; - const obj2: RoleMetadataDao = { - roleEntityRef: 'role:default/qa', - description: 'qa team', - id: 1, - source: 'rest', - modifiedBy, - }; - expect(deepSortedEqual(obj1, obj2)).toBe(true); - }); - - it('should return true for identical objects with different ordering of top-level properties', () => { - const obj1: RoleMetadataDao = { - description: 'qa team', - id: 1, - roleEntityRef: 'role:default/qa', - source: 'rest', - modifiedBy, - }; - const obj2: RoleMetadataDao = { - id: 1, - description: 'qa team', - source: 'rest', - roleEntityRef: 'role:default/qa', - modifiedBy, - }; - expect(deepSortedEqual(obj1, obj2)).toBe(true); - }); - - it('should return true for identical objects with different ordering of top-level properties with exclude read only fields', () => { - const obj1: RoleMetadataDao = { - description: 'qa team', - id: 1, - roleEntityRef: 'role:default/qa', - source: 'rest', - // read only properties - author: 'role:default/some-role', - modifiedBy: 'role:default/some-role', - createdAt: '2024-02-26 12:25:31+00', - lastModified: '2024-02-26 12:25:31+00', - }; - const obj2: RoleMetadataDao = { - id: 1, - description: 'qa team', - source: 'rest', - roleEntityRef: 'role:default/qa', - modifiedBy, - }; - expect( - deepSortedEqual(obj1, obj2, [ - 'author', - 'modifiedBy', - 'createdAt', - 'lastModified', - ]), - ).toBe(true); - }); - - it('should return false for objects with different values', () => { - const obj1: RoleMetadataDao = { - description: 'qa', - id: 1, - roleEntityRef: 'role:default/qa', - source: 'rest', - modifiedBy, - }; - const obj2: RoleMetadataDao = { - description: 'great qa', - id: 1, - roleEntityRef: 'role:default/qa', - source: 'rest', - modifiedBy, - }; - expect(deepSortedEqual(obj1, obj2)).toBe(false); - }); - - it('should return false for objects with different source', () => { - const obj1: RoleMetadataDao = { - description: 'qa teams', - id: 1, - roleEntityRef: 'role:default/qa', - source: 'rest', - modifiedBy, - }; - const obj2: RoleMetadataDao = { - description: 'qa teams', - id: 1, - roleEntityRef: 'role:default/qa', - source: 'configuration', - modifiedBy, - }; - expect(deepSortedEqual(obj1, obj2)).toBe(false); - }); - - it('should return false for objects with different id', () => { - const obj1: RoleMetadataDao = { - description: 'qa teams', - id: 1, - roleEntityRef: 'role:default/qa', - source: 'rest', - modifiedBy, - }; - const obj2: RoleMetadataDao = { - description: 'qa teams', - id: 2, - roleEntityRef: 'role:default/qa', - source: 'rest', - modifiedBy, - }; - expect(deepSortedEqual(obj1, obj2)).toBe(false); - }); - - it('should return false for objects with different role entity reference', () => { - const obj1: RoleMetadataDao = { - description: 'qa teams', - id: 1, - roleEntityRef: 'role:default/qa', - source: 'rest', - modifiedBy, - }; - const obj2: RoleMetadataDao = { - description: 'qa teams', - id: 1, - roleEntityRef: 'role:default/dev', - source: 'rest', - modifiedBy, - }; - expect(deepSortedEqual(obj1, obj2)).toBe(false); - }); - }); - - describe('isPermissionAction', () => { - it('should return true', () => { - let result = isPermissionAction('create'); - expect(result).toBeTruthy(); - - result = isPermissionAction('read'); - expect(result).toBeTruthy(); - - result = isPermissionAction('update'); - expect(result).toBeTruthy(); - - result = isPermissionAction('delete'); - expect(result).toBeTruthy(); - - result = isPermissionAction('use'); - expect(result).toBeTruthy(); - }); - - it('should return false', () => { - const result = isPermissionAction('unknown'); - expect(result).toBeFalsy(); - }); - }); - - describe('matches', () => { - const anyOfFilter: RBACFilters = { - anyOf: [ - { - key: 'owner', - values: ['user:default/some_user'], - }, - ], - }; - - const allOfFilter: RBACFilters = { - allOf: [ - { - key: 'owner', - values: ['user:default/some_user'], - }, - ], - }; - - const notFilter: RBACFilters = { - not: { - key: 'owner', - values: ['user:default/some_user'], - }, - }; - - const matchedRole: RoleMetadata = { - owner: 'user:default/some_user', - }; - - const noMatchedRole: RoleMetadata = { - owner: 'user:default/some_other_user', - }; - - it('should return true when a filter is not supplied', () => { - expect(matches()).toBeTruthy(); - }); - - it('should return false whenever a filter is supplied but a role is not', () => { - expect(matches(undefined, anyOfFilter)).toBeFalsy(); - }); - - it('should return true with anyOf filter where role owner matches filter owner', () => { - expect(matches(matchedRole, anyOfFilter)).toBeTruthy(); - }); - - it('shoule return false with anyOf filter where role owner does not match filter owner', () => { - expect(matches(noMatchedRole, anyOfFilter)).toBeFalsy(); - }); - - it('should return true with allOf filter where role owner matches filter owner', () => { - expect(matches(matchedRole, allOfFilter)).toBeTruthy(); - }); - - it('shoule return false with allOf filter where role owner does not match filter owner', () => { - expect(matches(noMatchedRole, allOfFilter)).toBeFalsy(); - }); - - it('should return false with not filter where role owner matches filter owner', () => { - expect(matches(matchedRole, notFilter)).toBeFalsy(); - }); - - it('shoule return true with not filter where role owner does not match filter owner', () => { - expect(matches(noMatchedRole, notFilter)).toBeTruthy(); - }); - }); -}); - -describe('mergeRoleMetadata', () => { - it('should merge new metadata into current metadata', () => { - const currentMetadata: RoleMetadataDao = { - lastModified: '2021-01-01T00:00:00Z', - modifiedBy: 'user:default/user1', - description: 'Initial role description', - roleEntityRef: 'user:default/tim', - source: 'legacy', - }; - - const newMetadata: RoleMetadataDao = { - lastModified: '2022-01-01T00:00:00Z', - modifiedBy: 'user:default/user2', - description: 'Updated role description', - roleEntityRef: 'user:default/dev-team', - source: 'rest', - }; - - const expectedMergedMetadata: RoleMetadataDao = { - ...currentMetadata, - ...newMetadata, - }; - - const result = mergeRoleMetadata(currentMetadata, newMetadata); - - expect(result).toEqual(expectedMergedMetadata); - }); - - it('should use current metadata description if new metadata description is undefined', () => { - const currentMetadata: RoleMetadataDao = { - lastModified: '2021-01-01T00:00:00Z', - modifiedBy: 'user:default/user1', - description: 'Initial role description', - roleEntityRef: 'user:default/tim', - source: 'legacy', - }; - - const newMetadata: RoleMetadataDao = { - lastModified: '2022-01-01T00:00:00Z', - modifiedBy: 'user:default/user2', - roleEntityRef: 'user:default/dev-team', - source: 'csv-file', - }; - - const expectedMergedMetadata: RoleMetadataDao = { - ...currentMetadata, - ...newMetadata, - description: currentMetadata.description, - }; - - const result = mergeRoleMetadata(currentMetadata, newMetadata); - - expect(result).toEqual(expectedMergedMetadata); - }); - - it('should use current date if new metadata lastModified is undefined', () => { - const currentMetadata: RoleMetadataDao = { - lastModified: '2021-01-01T00:00:00Z', - modifiedBy: 'user:default/user1', - description: 'Initial role description', - roleEntityRef: 'user:default/tim', - source: 'legacy', - }; - - const newMetadata: RoleMetadataDao = { - modifiedBy: 'user:default/user2', - description: 'Updated role description', - roleEntityRef: 'user:default/dev-team', - source: 'configuration', - }; - - const result = mergeRoleMetadata(currentMetadata, newMetadata); - const resultDate = new Date(result.lastModified!); - expect(resultDate).toBeInstanceOf(Date); - expect(result.modifiedBy).toEqual(newMetadata.modifiedBy); - expect(result.description).toEqual(newMetadata.description); - expect(result.roleEntityRef).toEqual(newMetadata.roleEntityRef); - expect(result.source).toEqual(newMetadata.source); - }); - - it('should not modify original metadata objects', () => { - const currentMetadata: RoleMetadataDao = { - lastModified: '2021-01-01T00:00:00Z', - modifiedBy: 'user:default/user1', - description: 'Initial role description', - roleEntityRef: 'user:default/tim', - source: 'legacy', - }; - - const newMetadata: RoleMetadataDao = { - lastModified: '2022-01-01T00:00:00Z', - modifiedBy: 'user:default/user2', - description: 'Updated role description', - roleEntityRef: 'user:default/dev-team', - source: 'configuration', - }; - - const currentMetadataClone = { ...currentMetadata }; - const newMetadataClone = { ...newMetadata }; - - mergeRoleMetadata(currentMetadata, newMetadata); - - expect(currentMetadata).toEqual(currentMetadataClone); - expect(newMetadata).toEqual(newMetadataClone); - }); - - it('should use current date if new metadata createdAt is undefined', () => { - const currentMetadata: RoleMetadataDao = { - createdAt: '2021-01-01T00:00:00Z', - lastModified: '2021-01-01T00:00:00Z', - modifiedBy: 'user:default/user1', - description: 'Initial role description', - roleEntityRef: 'user:default/tim', - source: 'legacy', - }; - - const newMetadata: RoleMetadataDao = { - lastModified: '2022-01-01T00:00:00Z', - modifiedBy: 'user:default/user2', - description: 'Updated role description', - roleEntityRef: 'user:default/dev-team', - source: 'configuration', - }; - - const result = mergeRoleMetadata(currentMetadata, newMetadata); - const resultDate = new Date(result.createdAt!); - expect(resultDate).toBeInstanceOf(Date); - expect(result.lastModified).toEqual(newMetadata.lastModified); - expect(result.modifiedBy).toEqual(newMetadata.modifiedBy); - expect(result.description).toEqual(newMetadata.description); - expect(result.roleEntityRef).toEqual(newMetadata.roleEntityRef); - expect(result.source).toEqual(newMetadata.source); - }); -}); diff --git a/plugins/rbac-backend/src/helper.ts b/plugins/rbac-backend/src/helper.ts deleted file mode 100644 index f951534aae..0000000000 --- a/plugins/rbac-backend/src/helper.ts +++ /dev/null @@ -1,354 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { AuditorService, AuthService } from '@backstage/backend-plugin-api'; -import type { MetadataResponse } from '@backstage/plugin-permission-common'; - -import { - difference, - fromPairs, - isArray, - isEqual, - isPlainObject, - omitBy, - sortBy, - toPairs, - ValueKeyIteratee, -} from 'lodash'; - -import { - PermissionAction, - PermissionInfo, - RoleBasedPolicy, - RoleConditionalPolicyDecision, - Source, -} from '@backstage-community/plugin-rbac-common'; - -import { ActionType, RoleEvents } from './auditor/auditor'; -import { RoleMetadataDao, RoleMetadataStorage } from './database/role-metadata'; -import { EnforcerDelegate } from './service/enforcer-delegate'; -import { PluginPermissionMetadataCollector } from './service/plugin-endpoints'; -import { RoleMetadata } from '@backstage-community/plugin-rbac-common'; -import { RBACFilters } from './permissions'; - -export function policyToString(policy: string[]): string { - return `[${policy.join(', ')}]`; -} - -export function typedPolicyToString(policy: string[], type: string): string { - return `${type}, ${policy.join(', ')}`; -} - -export function policiesToString(policies: string[][]): string { - const policiesString = policies - .map(policy => policyToString(policy)) - .join(','); - return `[${policiesString}]`; -} - -export function typedPoliciesToString( - policies: string[][], - type: string, -): string { - const policiesString = policies - .map(policy => { - return policy.length !== 0 ? typedPolicyToString(policy, type) : ''; - }) - .join('\n'); - return ` - ${policiesString} - `; -} - -export function metadataStringToPolicy(policy: string): string[] { - return policy.replace('[', '').replace(']', '').split(', '); -} - -/** - * Compares two policy arrays (e.g. [entityRef, permission, policy, effect]) for equality. - */ -function policyArraysEqual(a: string[], b: string[]): boolean { - return a.length === b.length && a.every((v, i) => v === b[i]); -} - -/** - * Syncs permission policies for a role to match a desired set. - * - Adds policies that are in desired but not in the enforcer (addPolicies skips existing via hasPolicy). - * - Removes policies that are in the enforcer but not in desired. - * - * @param enforcerDelegate - Enforcer to read from and write to - * @param roleEntityRef - Role to sync (used to load current policies via getFilteredPolicy(0, roleEntityRef)) - * @param desiredPolicies - Desired policies in casbin format string[][] - */ -export async function syncRolePolicies( - enforcerDelegate: EnforcerDelegate, - roleEntityRef: string, - desiredPolicies: string[][], -): Promise { - const current = await enforcerDelegate.getFilteredPolicy(0, roleEntityRef); - - const toAdd = desiredPolicies.filter( - d => !current.some(c => policyArraysEqual(c, d)), - ); - const toRemove = current.filter( - c => !desiredPolicies.some(d => policyArraysEqual(c, d)), - ); - - if (toAdd.length > 0) { - await enforcerDelegate.addPolicies(toAdd); - } - if (toRemove.length > 0) { - await enforcerDelegate.removePolicies(toRemove); - } -} - -export async function removeTheDifference( - originalGroup: string[], - addedGroup: string[], - source: Source, - roleEntityRef: string, - enf: EnforcerDelegate, - auditor: AuditorService, - modifiedBy: string, -): Promise { - originalGroup.sort((a, b) => a.localeCompare(b)); - addedGroup.sort((a, b) => a.localeCompare(b)); - const missing = difference(originalGroup, addedGroup); - - const groupPolicies: string[][] = []; - for (const missingRole of missing) { - groupPolicies.push([missingRole, roleEntityRef]); - } - - if (groupPolicies.length === 0) { - return; - } - - const roleMetadata = { source, modifiedBy, roleEntityRef }; - const existingMembers = await enf.getFilteredGroupingPolicy(1, roleEntityRef); - const actionType = - existingMembers.length === missing.length - ? ActionType.DELETE - : ActionType.UPDATE; - const auditorMeta = { - ...roleMetadata, - members: groupPolicies.map(gp => gp[0]), - }; - const auditorEvent = await auditor.createEvent({ - eventId: RoleEvents.ROLE_WRITE, - severityLevel: 'medium', - meta: { actionType, source: auditorMeta.source }, - }); - - try { - await enf.removeGroupingPolicies(groupPolicies, roleMetadata, false); - await auditorEvent.success({ meta: auditorMeta }); - } catch (error) { - await auditorEvent.fail({ - error, - meta: auditorMeta, - }); - throw error; - } -} - -export function transformArrayToPolicy(policyArray: string[]): RoleBasedPolicy { - const [entityReference, permission, policy, effect] = policyArray; - return { entityReference, permission, policy, effect }; -} - -export function transformPolicyGroupToLowercase(policyArray: string[]) { - if ( - policyArray.length > 1 && - policyArray[0].startsWith('g') && - (policyArray[1].startsWith('user') || policyArray[1].startsWith('group')) - ) { - policyArray[1] = policyArray[1].toLocaleLowerCase('en-US'); - } -} - -export function transformRolesGroupToLowercase(roles: string[][]) { - return roles.map(role => - role.length >= 1 - ? [role[0].toLocaleLowerCase('en-US'), ...role.slice(1)] - : role, - ); -} - -export function deepSortedEqual( - obj1: Record, - obj2: Record, - excludeFields?: string[], -): boolean { - let copyObj1; - let copyObj2; - if (excludeFields) { - const excludeFieldsPredicate: ValueKeyIteratee = (_value, key) => { - for (const field of excludeFields) { - if (key === field) { - return true; - } - } - return false; - }; - copyObj1 = omitBy(obj1, excludeFieldsPredicate); - copyObj2 = omitBy(obj2, excludeFieldsPredicate); - } - - const sortedObj1 = sortBy(toPairs(copyObj1 || obj1), ([key]) => key); - const sortedObj2 = sortBy(toPairs(copyObj2 || obj2), ([key]) => key); - - return isEqual(sortedObj1, sortedObj2); -} - -export function isPermissionAction(action: string): action is PermissionAction { - return ['create', 'read', 'update', 'delete', 'use'].includes( - action as PermissionAction, - ); -} - -export async function buildRoleSourceMap( - policies: string[][], - roleMetadata: RoleMetadataStorage, -): Promise> { - return await policies.reduce( - async ( - acc: Promise>, - policy: string[], - ): Promise> => { - const roleEntityRef = policy[0]; - const acummulator = await acc; - if (!acummulator.has(roleEntityRef)) { - const metadata = await roleMetadata.findRoleMetadata(roleEntityRef); - acummulator.set(roleEntityRef, metadata?.source); - } - return acummulator; - }, - Promise.resolve(new Map()), - ); -} - -export function mergeRoleMetadata( - currentMetadata: RoleMetadataDao, - newMetadata: RoleMetadataDao, -): RoleMetadataDao { - const mergedMetaData: RoleMetadataDao = { ...currentMetadata }; - mergedMetaData.lastModified = - newMetadata.lastModified ?? new Date().toUTCString(); - mergedMetaData.modifiedBy = newMetadata.modifiedBy; - mergedMetaData.description = - newMetadata.description ?? currentMetadata.description; - mergedMetaData.roleEntityRef = newMetadata.roleEntityRef; - mergedMetaData.source = newMetadata.source; - mergedMetaData.owner = newMetadata.owner ?? currentMetadata.owner; - return mergedMetaData; -} - -export async function processConditionMapping( - roleConditionPolicy: RoleConditionalPolicyDecision, - pluginPermMetaData: PluginPermissionMetadataCollector, - auth: AuthService, -): Promise> { - const { token } = await auth.getPluginRequestToken({ - onBehalfOf: await auth.getOwnServiceCredentials(), - targetPluginId: roleConditionPolicy.pluginId, - }); - - const rule: MetadataResponse | undefined = - await pluginPermMetaData.getMetadataByPluginId( - roleConditionPolicy.pluginId, - token, - ); - if (!rule?.permissions) { - throw new Error( - `Unable to get permission list for plugin ${roleConditionPolicy.pluginId}`, - ); - } - - const permInfo: PermissionInfo[] = []; - for (const action of roleConditionPolicy.permissionMapping) { - const perm = rule.permissions.find(permission => { - if (permission.type === 'resource') { - const isCorrectResourceType = - permission.resourceType === roleConditionPolicy.resourceType; - const isCorrectAction = action === permission.attributes.action; - const undefinedAction = - action === 'use' && permission.attributes.action === undefined; - - return isCorrectResourceType && (isCorrectAction || undefinedAction); - } - return false; - }); - - if (!perm) { - throw new Error( - `Unable to find permission to get permission name for resource type '${ - roleConditionPolicy.resourceType - }' and action ${JSON.stringify(action)}`, - ); - } - permInfo.push({ name: perm.name, action }); - } - - return { - ...roleConditionPolicy, - permissionMapping: permInfo, - }; -} - -export function deepSort(value: any): any { - if (isArray(value)) { - return sortBy(value.map(deepSort)); - } else if (isPlainObject(value)) { - return fromPairs( - sortBy( - toPairs(value).map(([k, v]: [string, any]) => [k, deepSort(v)]), - 0, - ), - ); - } - return value; -} - -export function deepSortEqual(obj1: any, obj2: any): boolean { - return isEqual(deepSort(obj1), deepSort(obj2)); -} - -export const matches = ( - role?: RoleMetadata, - filters?: RBACFilters, -): boolean => { - if (!filters) { - return true; - } - - if (!role) { - return false; - } - - if ('allOf' in filters) { - return filters.allOf.every(filter => matches(role, filter)); - } - - if ('anyOf' in filters) { - return filters.anyOf.some(filter => matches(role, filter)); - } - - if ('not' in filters) { - return !matches(role, filters.not); - } - - return filters.values.includes(role.owner); -}; diff --git a/plugins/rbac-backend/src/index.ts b/plugins/rbac-backend/src/index.ts deleted file mode 100644 index de60add2ae..0000000000 --- a/plugins/rbac-backend/src/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export * from './service/router'; -export * from './service/policy-builder'; - -// To provide backward compatibility with client code implemented -// before PluginIdProvider was moved to @backstage-community/plugin-rbac-node. -export type { PluginIdProvider } from '@backstage-community/plugin-rbac-node'; - -export { rbacPlugin as default } from './plugin'; diff --git a/plugins/rbac-backend/src/permissions/conditions.ts b/plugins/rbac-backend/src/permissions/conditions.ts deleted file mode 100644 index 2a55f8f232..0000000000 --- a/plugins/rbac-backend/src/permissions/conditions.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { RESOURCE_TYPE_POLICY_ENTITY } from '@backstage-community/plugin-rbac-common'; -import type { - ConditionalPolicyDecision, - PermissionCondition, - PermissionCriteria, - ResourcePermission, -} from '@backstage/plugin-permission-common'; -import { - ConditionTransformer, - createConditionExports, - createConditionTransformer, -} from '@backstage/plugin-permission-node'; -import { PermissionsRegistryService } from '@backstage/backend-plugin-api'; - -import { permissionMetadataResourceRef } from './resource'; -import { rules, RBACFilter } from './rules'; - -const { conditions, createConditionalDecision } = createConditionExports({ - resourceRef: permissionMetadataResourceRef, - rules, -}); - -export const rbacConditions = conditions; - -export const createRBACConditionalDecision: ( - permission: ResourcePermission, - conditions: PermissionCriteria< - PermissionCondition - >, -) => ConditionalPolicyDecision = createConditionalDecision; - -export const conditionTransformerFunc: ( - permissionRegistry: PermissionsRegistryService, -) => ConditionTransformer = ( - permissionRegistry: PermissionsRegistryService, -) => - createConditionTransformer( - permissionRegistry.getPermissionRuleset(permissionMetadataResourceRef), - ); diff --git a/plugins/rbac-backend/src/permissions/index.ts b/plugins/rbac-backend/src/permissions/index.ts deleted file mode 100644 index d519cc542c..0000000000 --- a/plugins/rbac-backend/src/permissions/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export * from './conditions'; -export * from './rules'; diff --git a/plugins/rbac-backend/src/permissions/resource.ts b/plugins/rbac-backend/src/permissions/resource.ts deleted file mode 100644 index 8cdd9392e7..0000000000 --- a/plugins/rbac-backend/src/permissions/resource.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { RESOURCE_TYPE_POLICY_ENTITY } from '@backstage-community/plugin-rbac-common'; -import { createPermissionResourceRef } from '@backstage/plugin-permission-node'; -import { RBACFilter } from './rules'; -import { RoleMetadataDao } from '../database/role-metadata'; - -/** - * Reference to the RBAC permission metadata resource. - * This is used to create RBAC permissions and conditions. - * - */ -export const permissionMetadataResourceRef = createPermissionResourceRef< - RoleMetadataDao, - RBACFilter ->().with({ - pluginId: 'permission', - resourceType: RESOURCE_TYPE_POLICY_ENTITY, -}); diff --git a/plugins/rbac-backend/src/permissions/rules.ts b/plugins/rbac-backend/src/permissions/rules.ts deleted file mode 100644 index 531b1db750..0000000000 --- a/plugins/rbac-backend/src/permissions/rules.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { RESOURCE_TYPE_POLICY_ENTITY } from '@backstage-community/plugin-rbac-common'; -import { PermissionRule } from '@backstage/plugin-permission-node'; -import { z as zod3 } from 'zod/v3'; -import { z as zod4 } from 'zod/v4'; - -import { RoleMetadataDao } from '../database/role-metadata'; - -/** - * The RBACFilter is a simple filter without any conditional criteria. - * - */ -export type RBACFilter = { - key: string; - values: any[]; -}; - -/** - * The RBACFilters type is a recursive type that can be used to create complex filter structures. - * It can be used to create filters that are a combination of other filters, or a negation of a filter. - * - */ -export type RBACFilters = - | { anyOf: RBACFilters[] } - | { allOf: RBACFilters[] } - | { not: RBACFilters } - | RBACFilter; - -type IsOwnerParams = { - owners: string[]; -}; - -const isOwnerParamsSchema: zod3.ZodType = zod3.object({ - owners: zod3 - .string() - .array() - .describe('List of entity refs to match against'), -}); - -const isOwner = { - name: 'IS_OWNER', - description: - 'Should allow access to RBAC roles and Permissions through ownership', - resourceType: RESOURCE_TYPE_POLICY_ENTITY, - paramsSchema: isOwnerParamsSchema, - apply: (roleMeta: RoleMetadataDao, { owners }: IsOwnerParams) => { - if (roleMeta.isDefault) { - return true; - } - if (!roleMeta.owner) { - return false; - } - return owners.includes(roleMeta.owner); - }, - toQuery: ({ owners }: IsOwnerParams) => ({ - key: 'owners', - values: owners, - }), -} as unknown as PermissionRule< - RoleMetadataDao, - RBACFilter, - typeof RESOURCE_TYPE_POLICY_ENTITY, - IsOwnerParams ->; - -export const rbacRules = { - name: isOwner.name, - description: isOwner.description, - resourceType: isOwner.resourceType, - paramsSchema: zod4 - .object({ - owners: zod4 - .string() - .array() - .describe('List of entity refs to match against'), - }) - .toJSONSchema(), -}; - -export const rules = { isOwner }; diff --git a/plugins/rbac-backend/src/plugin.ts b/plugins/rbac-backend/src/plugin.ts deleted file mode 100644 index 3e9026fe86..0000000000 --- a/plugins/rbac-backend/src/plugin.ts +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - coreServices, - createBackendModule, -} from '@backstage/backend-plugin-api'; - -import { PolicyBuilder } from './service/policy-builder'; -import { - PluginIdProvider, - PluginIdProviderExtensionPoint, - pluginIdProviderExtensionPoint, - RBACProvider, - rbacProviderExtensionPoint, -} from '@backstage-community/plugin-rbac-node'; - -import { policyExtensionPoint } from '@backstage/plugin-permission-node/alpha'; - -/** - * @public - * RBAC plugin - * - */ -export const rbacPlugin = createBackendModule({ - pluginId: 'permission', - moduleId: 'rbac', - register(env) { - const pluginIdProviderExtensionPointImpl = - new (class PluginIdProviderImpl implements PluginIdProviderExtensionPoint { - pluginIdProviders: PluginIdProvider[] = []; - - addPluginIdProvider(pluginIdProvider: PluginIdProvider): void { - this.pluginIdProviders.push(pluginIdProvider); - } - })(); - - env.registerExtensionPoint( - pluginIdProviderExtensionPoint, - pluginIdProviderExtensionPointImpl, - ); - - const rbacProviders = new Array(); - - env.registerExtensionPoint(rbacProviderExtensionPoint, { - addRBACProvider( - ...providers: Array> - ): void { - rbacProviders.push(...providers.flat()); - }, - }); - - env.registerInit({ - deps: { - http: coreServices.httpRouter, - config: coreServices.rootConfig, - logger: coreServices.logger, - discovery: coreServices.discovery, - permissions: coreServices.permissions, - auth: coreServices.auth, - httpAuth: coreServices.httpAuth, - auditor: coreServices.auditor, - userInfo: coreServices.userInfo, - lifecycle: coreServices.lifecycle, - permissionsRegistry: coreServices.permissionsRegistry, - policy: policyExtensionPoint, - }, - async init({ - http, - config, - logger, - discovery, - permissions, - auth, - httpAuth, - auditor, - lifecycle, - permissionsRegistry: permissionsRegistry, - policy, - }) { - http.use( - await PolicyBuilder.build( - { - config, - logger, - discovery, - permissions, - auth, - httpAuth, - auditor, - lifecycle, - permissionsRegistry: permissionsRegistry, - policy, - }, - { - getPluginIds: () => - Array.from( - new Set( - pluginIdProviderExtensionPointImpl.pluginIdProviders.flatMap( - p => p.getPluginIds(), - ), - ), - ), - }, - rbacProviders, - ), - ); - }, - }); - }, -}); diff --git a/plugins/rbac-backend/src/policies/allow-all-policy.test.ts b/plugins/rbac-backend/src/policies/allow-all-policy.test.ts deleted file mode 100644 index fad05fe2f8..0000000000 --- a/plugins/rbac-backend/src/policies/allow-all-policy.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - AuthorizeResult, - createPermission, -} from '@backstage/plugin-permission-common'; -import { - PermissionPolicy, - PolicyQuery, - PolicyQueryUser, -} from '@backstage/plugin-permission-node'; - -import { AllowAllPolicy } from './allow-all-policy'; - -describe('Allow All Policy', () => { - describe('Allow all policy should allow all', () => { - let policy: PermissionPolicy; - beforeEach(() => { - policy = new AllowAllPolicy(); - }); - - it('should be able to create an allow all permission policy', () => { - expect(policy).not.toBeNull(); - }); - - it('should allow all when handle is called', async () => { - const result = await policy.handle( - newPolicyQueryWithBasicPermission('catalog.entity.create'), - newPolicyQueryUser('user:default/guest'), - ); - - expect(result).toStrictEqual({ result: AuthorizeResult.ALLOW }); - }); - }); -}); - -function newPolicyQueryWithBasicPermission(name: string): PolicyQuery { - const mockPermission = createPermission({ - name: name, - attributes: {}, - }); - return { permission: mockPermission }; -} - -function newPolicyQueryUser( - user?: string, - ownershipEntityRefs?: string[], -): PolicyQueryUser | undefined { - if (user) { - return { - identity: { - ownershipEntityRefs: ownershipEntityRefs ?? [], - type: 'user', - userEntityRef: user, - }, - credentials: { - $$type: '@backstage/BackstageCredentials', - principal: true, - expiresAt: new Date('2021-01-01T00:00:00Z'), - }, - info: { - userEntityRef: user, - ownershipEntityRefs: ownershipEntityRefs ?? [], - }, - token: 'token', - }; - } - return undefined; -} diff --git a/plugins/rbac-backend/src/policies/allow-all-policy.ts b/plugins/rbac-backend/src/policies/allow-all-policy.ts deleted file mode 100644 index 99c962d2be..0000000000 --- a/plugins/rbac-backend/src/policies/allow-all-policy.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - AuthorizeResult, - PolicyDecision, -} from '@backstage/plugin-permission-common'; -import { - PermissionPolicy, - PolicyQuery, - PolicyQueryUser, -} from '@backstage/plugin-permission-node'; - -export class AllowAllPolicy implements PermissionPolicy { - async handle( - _request: PolicyQuery, - _user?: PolicyQueryUser, - ): Promise { - return { result: AuthorizeResult.ALLOW }; - } -} diff --git a/plugins/rbac-backend/src/policies/permission-policy.hierarchy.test.ts b/plugins/rbac-backend/src/policies/permission-policy.hierarchy.test.ts deleted file mode 100644 index 10d6d2568a..0000000000 --- a/plugins/rbac-backend/src/policies/permission-policy.hierarchy.test.ts +++ /dev/null @@ -1,1123 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { mockServices } from '@backstage/backend-test-utils'; -import { Config } from '@backstage/config'; -import { - AuthorizeResult, - createPermission, -} from '@backstage/plugin-permission-common'; -import type { - PolicyQuery, - PolicyQueryUser, -} from '@backstage/plugin-permission-node'; - -import { - Adapter, - Enforcer, - Model, - newEnforcer, - newModelFromString, -} from 'casbin'; -import * as Knex from 'knex'; -import { MockClient } from 'knex-mock-client'; - -import { resolve } from 'path'; - -import { - mockAuditorService, - conditionalStorageMock, - csvPermFile, - mockAuthService, - mockClientKnex, - pluginMetadataCollectorMock, - roleMetadataStorageMock, - catalogMock, -} from '../../__fixtures__/mock-utils'; -import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; -import { RoleMetadataStorage } from '../database/role-metadata'; -import { BackstageRoleManager } from '../role-manager/role-manager'; -import { DefaultPermissionsReader } from '../default-permissions/default-permissions'; -import { EnforcerDelegate } from '../service/enforcer-delegate'; -import { MODEL } from '../service/permission-model'; -import { PluginPermissionMetadataCollector } from '../service/plugin-endpoints'; -import { RBACPermissionPolicy } from './permission-policy'; -import { - clearAuditorMock, - expectAuditorLogForPermission, -} from '../../__fixtures__/auditor-test-utils'; - -type PermissionAction = 'create' | 'read' | 'update' | 'delete'; - -/** - * Group, user, role, and permission information can be found under `__fixtures__/data/hierarchy/` - * More information can be found at `examples/manual-tests/rbac` at the root of the workspace - * Included is a txt file with charts for the hierarchy levels for visualization - */ -describe('Policy checks for users and groups', () => { - let policy: RBACPermissionPolicy; - - beforeEach(async () => { - const policyChecksCSV = resolve( - __dirname, - '../../__fixtures__/data/hierarchy/rbac-policy.csv', - ); - const config = newConfig(policyChecksCSV); - const adapter = await newAdapter(config); - - const enfDelegate = await newEnforcerDelegate(adapter, config); - - policy = await newPermissionPolicy(config, enfDelegate); - }); - - // Simple user to role tests - it('case 1, user directly assigned to allow role', async () => { - const userEntity = 'user:default/ant_man'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('case 2, user directly assigned to deny role', async () => { - const userEntity = 'user:default/hulk'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - // Simple group to role tests - it('case 3, group assigned to allow role', async () => { - const userEntity = 'user:default/thor'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('case 4, group assigned to deny role', async () => { - const userEntity = 'user:default/wasp'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - // Group hierarchy tests with a two level hierarchy - it('case 5, group hierarchy test where furthest group is assigned to allow role', async () => { - const userEntity = 'user:default/moon_knight'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('case 6, group hierarchy test where furthest group is assigned to deny role', async () => { - const userEntity = 'user:default/spiderman'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 7, group hierarchy test where the closest group is assigned allow role, furthest group is assigned allow role', async () => { - const userEntity = 'user:default/captain_america'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('case 8, group hierarchy test where the closest group is assigned deny role, furthest group is assigned deny role', async () => { - const userEntity = 'user:default/hawkeye'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 9, group hierarchy test where the closest group is assigned deny role, furthest group is assigned allow role', async () => { - const userEntity = 'user:default/quicksilver'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 10, group hierarchy test where the closest group is assigned allow role, furthest group is assigned deny role', async () => { - const userEntity = 'user:default/scarlet_witch'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - // Branching tests - it('case 11, branching test where user is directly assigned to allow role and group is directly assigned to allow role', async () => { - const userEntity = 'user:default/swordsman'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('case 12, branching test where user is directly assigned to deny role and group is directly assigned to deny role', async () => { - const userEntity = 'user:default/hercules'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 13, branching test where user is directly assigned to deny role and group is directly assigned to allow role', async () => { - const userEntity = 'user:default/black_panther'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 14, branching test where user is directly assigned to allow role and group is directly assigned to deny role', async () => { - const userEntity = 'user:default/vision'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - // Branching tests with group role assignment - it('case 15, branching test where top group assigned to allow role and right group is assigned to allow role', async () => { - const userEntity = 'user:default/black_knight'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('case 16, branching test where top group assigned to deny role and right group is assigned to deny role', async () => { - const userEntity = 'user:default/black_widow'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 17, branching test where top group assigned to deny role and right group is assigned to allow role', async () => { - const userEntity = 'user:default/mantis'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 18, branching test where top group assigned to allow role and right group is assigned to deny role', async () => { - const userEntity = 'user:default/beast'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - // Branching tests with two level group role assignment - it('case 19, branching test where fruthest top group assigned to allow role and furthest right group is assigned to allow role', async () => { - const userEntity = 'user:default/moondragon'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('case 20, branching test where fruthest top group assigned to deny role and furthest right group is assigned to deny role', async () => { - const userEntity = 'user:default/hellcat'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 21, branching test where fruthest top group assigned to deny role and furthest right group is assigned to allow role', async () => { - const userEntity = 'user:default/captain_marvel'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 22, branching test where fruthest top group assigned to allow role and furthest right group is assigned to deny role', async () => { - const userEntity = 'user:default/falcon'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - // Group hierarchy with cyclic behavior - // TODO: get the logger for all cyclic behavior tests - it('case 23, cyclic behavior between two groups with one group assigned to allow role', async () => { - const userEntity = 'user:default/wonder_man'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 24, cyclic behavior between two groups with one group assigned to deny role', async () => { - const userEntity = 'user:default/tigra'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - // Branching tests with cyclic behavior - it('case 25, branching test where closest group is assigned to allow role and cyclic behavior between two groups with one group assigned to allow role', async () => { - const userEntity = 'user:default/she_hulk'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 26, branching test where closest group is assigned to deny role and cyclic behavior between two groups with one group assigned to deny role', async () => { - const userEntity = 'user:default/starfox'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 27, branching test where closest group is assigned to deny role and cyclic behavior between two groups with one group assigned to allow role', async () => { - const userEntity = 'user:default/mockingbird'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 28, branching test where closest group is assigned to allow role and cyclic behavior between two groups with one group assigned to deny role', async () => { - const userEntity = 'user:default/war_machine'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - // Branching tests with two level group hierarchy and both branches have cyclic behavior - it('case 29, branching test where top group is assigned to allow role and right group is assigned allow role, both branches have cyclic behavior', async () => { - const userEntity = 'user:default/namor'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 30, branching test where top group is assigned to deny role and right group is assigned deny role, both branches have cyclic behavior', async () => { - const userEntity = 'user:default/thing'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 31, branching test where top group is assigned to deny role and right group is assigned allow role, both branches have cyclic behavior', async () => { - const userEntity = 'user:default/doctor_druid'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 32, branching test where top group is assigned to allow role and right group is assigned deny role, both branches have cyclic behavior', async () => { - const userEntity = 'user:default/firebird'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - // Branching tests with two level group hierarchy and cyclic behavior - it('case 33, branching test where top group is assigned to allow role and right group is assigned allow role, right branch has cyclic behavior', async () => { - const userEntity = 'user:default/valkyrie'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 34, branching test where top group is assigned to deny role and right group is assigned deny role, right branch has cyclic behavior', async () => { - const userEntity = 'user:default/nova'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 35, branching test where top group is assigned to deny role and right group is assigned allow role, right branch has cyclic behavior', async () => { - const userEntity = 'user:default/storm'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('case 36, branching test where top group is assigned to allow role and right group is assigned deny role, right branch has cyclic behavior', async () => { - const userEntity = 'user:default/daredevil'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - // Simple user to role tests - it('case 37, user directly assigned to allow permission', async () => { - const userEntity = 'user:default/psylocke'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('case 38, user directly assigned to deny permission', async () => { - const userEntity = 'user:default/penance'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - // Simple group to role tests - it('case 39, group assigned to allow permission', async () => { - const userEntity = 'user:default/cable'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('case 40, group assigned to deny permission', async () => { - const userEntity = 'user:default/ghost_rider'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - // Admin test - it('case 37, user directly assigned to admin role through config', async () => { - const userEntity = 'user:default/admin'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - // Admin test - it('case 37, group directly assigned to admin role through config', async () => { - const userEntity = 'user:default/admin_one'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - // Super user test - it('case 37, super user assigned to superUsers through config', async () => { - const userEntity = 'user:default/super_user'; - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser(userEntity), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - userEntity, - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); -}); - -function newPolicyQueryWithResourcePermission( - name: string, - resource: string, - action: PermissionAction, -): PolicyQuery { - const mockPermission = createPermission({ - name: name, - attributes: {}, - resourceType: resource, - }); - if (action) { - mockPermission.attributes.action = action; - } - return { permission: mockPermission }; -} - -function newPolicyQueryUser( - user?: string, - ownershipEntityRefs?: string[], -): PolicyQueryUser | undefined { - if (user) { - return { - identity: { - ownershipEntityRefs: ownershipEntityRefs ?? [], - type: 'user', - userEntityRef: user, - }, - credentials: { - $$type: '@backstage/BackstageCredentials', - principal: true, - expiresAt: new Date('2021-01-01T00:00:00Z'), - }, - info: { - userEntityRef: user, - ownershipEntityRefs: ownershipEntityRefs ?? [], - }, - token: 'token', - }; - } - return undefined; -} - -function newConfig(permFile?: string): Config { - const adminUsers = [ - { - name: 'user:default/admin', - }, - { - name: 'group:default/admin', - }, - ]; - - const superUser = [ - { - name: 'user:default/super_user', - }, - ]; - - return mockServices.rootConfig({ - data: { - permission: { - rbac: { - 'policies-csv-file': permFile || csvPermFile, - policyFileReload: false, - admin: { - users: adminUsers, - superUsers: superUser, - }, - }, - }, - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - }, - }); -} - -async function newAdapter(config: Config): Promise { - return await new CasbinDBAdapterFactory( - config, - mockClientKnex, - ).createAdapter(); -} - -async function createEnforcer( - theModel: Model, - adapter: Adapter, - logger: LoggerService, - config: Config, -): Promise { - const catalogDBClient = Knex.knex({ client: MockClient }); - const rbacDBClient = Knex.knex({ client: MockClient }); - const enf = await newEnforcer(theModel, adapter); - - const rm = new BackstageRoleManager( - catalogMock, - logger, - catalogDBClient, - rbacDBClient, - config, - mockAuthService, - new DefaultPermissionsReader(config), - ); - enf.setRoleManager(rm); - enf.enableAutoBuildRoleLinks(false); - await enf.buildRoleLinks(); - - return enf; -} - -async function newEnforcerDelegate( - adapter: Adapter, - config: Config, - storedPolicies?: string[][], - storedGroupingPolicies?: string[][], -): Promise { - const theModel = newModelFromString(MODEL); - const logger = mockServices.logger.mock(); - - const enf = await createEnforcer(theModel, adapter, logger, config); - - if (storedPolicies) { - await enf.addPolicies(storedPolicies); - } - - if (storedGroupingPolicies) { - await enf.addGroupingPolicies(storedGroupingPolicies); - } - - return new EnforcerDelegate( - enf, - mockAuditorService, - conditionalStorageMock, - roleMetadataStorageMock, - mockClientKnex, - ); -} - -async function newPermissionPolicy( - config: Config, - enfDelegate: EnforcerDelegate, - roleMock?: RoleMetadataStorage, -): Promise { - const logger = mockServices.logger.mock(); - const permissionPolicy = await RBACPermissionPolicy.build( - logger, - mockAuditorService, - config, - conditionalStorageMock, - enfDelegate, - roleMock || roleMetadataStorageMock, - mockClientKnex, - pluginMetadataCollectorMock as PluginPermissionMetadataCollector, - mockAuthService, - ); - clearAuditorMock(); - return permissionPolicy; -} diff --git a/plugins/rbac-backend/src/policies/permission-policy.test.ts b/plugins/rbac-backend/src/policies/permission-policy.test.ts deleted file mode 100644 index d25bf29932..0000000000 --- a/plugins/rbac-backend/src/policies/permission-policy.test.ts +++ /dev/null @@ -1,2499 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { mockServices } from '@backstage/backend-test-utils'; -import { Config } from '@backstage/config'; -import { - AuthorizeResult, - createPermission, -} from '@backstage/plugin-permission-common'; -import type { - PolicyQuery, - PolicyQueryUser, -} from '@backstage/plugin-permission-node'; - -import { - Adapter, - Enforcer, - Model, - newEnforcer, - newModelFromString, -} from 'casbin'; -import * as Knex from 'knex'; -import { MockClient } from 'knex-mock-client'; - -import type { - RoleBasedPolicy, - RoleMetadata, -} from '@backstage-community/plugin-rbac-common'; - -import { resolve } from 'path'; - -import { ADMIN_ROLE_NAME } from '../admin-permissions/admin-creation'; -import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; -import { ConditionalStorage } from '../database/conditional-storage'; -import { - RoleMetadataDao, - RoleMetadataStorage, -} from '../database/role-metadata'; -import { BackstageRoleManager } from '../role-manager/role-manager'; -import { DefaultPermissionsReader } from '../default-permissions/default-permissions'; -import { EnforcerDelegate } from '../service/enforcer-delegate'; -import { MODEL } from '../service/permission-model'; -import { PluginPermissionMetadataCollector } from '../service/plugin-endpoints'; -import { RBACPermissionPolicy } from './permission-policy'; -import { buildDefaultRoleMetadata } from '../default-permissions/default-permissions'; -import { catalogMock, mockAuditorService } from '../../__fixtures__/mock-utils'; -import { - clearAuditorMock, - expectAuditorLogForPermission, -} from '../../__fixtures__/auditor-test-utils'; - -type PermissionAction = 'create' | 'read' | 'update' | 'delete'; - -const conditionalStorageMock: ConditionalStorage = { - filterConditions: jest.fn().mockImplementation(() => []), - createCondition: jest.fn().mockImplementation(), - checkConflictedConditions: jest.fn().mockImplementation(), - getCondition: jest.fn().mockImplementation(), - deleteCondition: jest.fn().mockImplementation(), - updateCondition: jest.fn().mockImplementation(), -}; - -const roleMetadataStorageMock: RoleMetadataStorage = { - filterRoleMetadata: jest.fn().mockImplementation(() => []), - findRoleMetadata: jest - .fn() - .mockImplementation( - async ( - _roleEntityRef: string, - _trx: Knex.Knex.Transaction, - ): Promise => { - return { source: 'csv-file' }; - }, - ), - filterForOwnerRoleMetadata: jest.fn().mockImplementation(), - createRoleMetadata: jest.fn().mockImplementation(), - updateRoleMetadata: jest.fn().mockImplementation(), - removeRoleMetadata: jest.fn().mockImplementation(), - getCachedDefaultRoleMetadata: jest.fn().mockImplementation(() => undefined), - getDefaultRole: jest.fn().mockResolvedValue(undefined), - syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), -}; - -const csvPermFile = resolve( - __dirname, - '../../__fixtures__/data/valid-csv/rbac-policy.csv', -); - -const mockClientKnex = Knex.knex({ client: MockClient }); - -const mockAuthService = mockServices.auth(); - -const pluginMetadataCollectorMock: Partial = - { - getPluginConditionRules: jest.fn().mockImplementation(), - getPluginPolicies: jest.fn().mockImplementation(), - getMetadataByPluginId: jest.fn().mockImplementation(), - }; - -const modifiedBy = 'user:default/some-admin'; - -describe('RBACPermissionPolicy Tests', () => { - beforeEach(() => { - roleMetadataStorageMock.updateRoleMetadata = jest.fn().mockImplementation(); - jest.clearAllMocks(); - }); - - it('should build', async () => { - const config = newConfig(); - const adapter = await newAdapter(config); - const enfDelegate = await newEnforcerDelegate(adapter, config); - - const policy = await newPermissionPolicy(config, enfDelegate); - - expect(policy).not.toBeNull(); - }); - - it('should fail to build when creating admin role', async () => { - roleMetadataStorageMock.updateRoleMetadata = jest - .fn() - .mockImplementation(async (): Promise => { - throw new Error(`Failed to create`); - }); - - const config = newConfig(); - const adapter = await newAdapter(config); - const enfDelegate = await newEnforcerDelegate(adapter, config); - await enfDelegate.addPolicy([ - 'user:default/known_user', - 'test-resource', - 'update', - 'allow', - ]); - - await expect(newPermissionPolicy(config, enfDelegate)).rejects.toThrow( - 'Failed to create', - ); - }); - - describe('Policy checks from csv file', () => { - let enfDelegate: EnforcerDelegate; - let policy: RBACPermissionPolicy; - - beforeEach(async () => { - const config = newConfig(); - const adapter = await newAdapter(config); - enfDelegate = await newEnforcerDelegate(adapter, config); - policy = await newPermissionPolicy(config, enfDelegate); - }); - - // case1 - it('should allow read access to resource permission for user from csv file', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/guest'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/guest', - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - // case2 - it('should allow create access to resource permission for user from csv file', async () => { - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('catalog.entity.create'), - newPolicyQueryUser('user:default/guest'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/guest', - 'catalog.entity.create', - undefined, - 'use', - AuthorizeResult.ALLOW, - ); - }); - - // case3 - it('should allow deny access to resource permission for user:default/known_user', async () => { - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('test.resource.deny'), - newPolicyQueryUser('user:default/known_user'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/known_user', - 'test.resource.deny', - undefined, - 'use', - AuthorizeResult.ALLOW, - ); - }); - - // case1 with role - it('should allow update access to resource permission for user from csv file', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'update', - ), - newPolicyQueryUser('user:default/guest'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/guest', - 'catalog.entity.read', - 'catalog-entity', - 'update', - AuthorizeResult.ALLOW, - ); - }); - }); - - describe('Policy checks for clean up old policies for csv file', () => { - let config: Config; - let adapter: Adapter; - let enforcerDelegate: EnforcerDelegate; - let rbacPolicy: RBACPermissionPolicy; - const allEnfRoles = [ - 'role:default/some-role', - 'role:default/rbac_admin', - 'role:default/catalog-writer', - 'role:default/legacy', - 'role:default/catalog-reader', - 'role:default/catalog-deleter', - 'role:default/known_role', - 'role:default/CATALOG-USER', - ]; - - const allEnfGroupPolicies = [ - ['user:default/tester', 'role:default/some-role'], - ['user:default/guest', 'role:default/rbac_admin'], - ['group:default/guests', 'role:default/rbac_admin'], - ['user:default/guest', 'role:default/catalog-writer'], - ['user:default/guest', 'role:default/legacy'], - ['user:default/guest', 'role:default/catalog-reader'], - ['user:default/guest', 'role:default/catalog-deleter'], - ['user:default/known_user', 'role:default/known_role'], - ['user:default/tom', 'role:default/CATALOG-USER'], - ['group:default/reader-group', 'role:default/CATALOG-USER'], - ]; - - const allEnfPolicies = [ - // stored policy - ['role:default/some-role', 'test.some.resource', 'use', 'allow'], - // policies from csv file - ['role:default/catalog-writer', 'catalog-entity', 'update', 'allow'], - ['role:default/legacy', 'catalog-entity', 'update', 'allow'], - ['role:default/catalog-writer', 'catalog-entity', 'read', 'allow'], - ['role:default/catalog-writer', 'catalog.entity.create', 'use', 'allow'], - ['role:default/catalog-deleter', 'catalog-entity', 'delete', 'deny'], - ['role:default/CATALOG-USER', 'catalog-entity', 'read', 'allow'], - ['role:default/known_role', 'test.resource.deny', 'use', 'allow'], - ]; - - beforeEach(async () => { - (roleMetadataStorageMock.removeRoleMetadata as jest.Mock).mockReset(); - - config = newConfig(); - adapter = await newAdapter(config); - }); - - it('should cleanup old group policies and metadata after re-attach policy file', async () => { - roleMetadataStorageMock.filterRoleMetadata = jest - .fn() - .mockImplementation(() => { - const roleMetadataDao: RoleMetadataDao = { - roleEntityRef: 'role:default/old-role', - source: 'csv-file', - modifiedBy: 'user:default/tom', - }; - return [roleMetadataDao]; - }); - - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async ( - roleEntityRef: string, - _trx: Knex.Knex.Transaction, - ): Promise => { - if (roleEntityRef.includes('rbac_admin')) { - return { source: 'configuration' }; - } - if (roleEntityRef.includes('some-role')) { - return { source: 'rest' }; - } - return { source: 'csv-file' }; - }, - ); - - const storedGroupPolicies = [ - // should be removed - ['user:default/user-old-1', 'role:default/old-role'], - ['group:default/team-a-old-1', 'role:default/old-role'], - - // should not be removed: - ['user:default/tester', 'role:default/some-role'], - ]; - const storedPolicies = [ - // should not be removed - ['role:default/some-role', 'test.some.resource', 'use', 'allow'], - ]; - - enforcerDelegate = await newEnforcerDelegate( - adapter, - config, - storedPolicies, - storedGroupPolicies, - ); - - await newPermissionPolicy(config, enforcerDelegate); - - expect(await enforcerDelegate.getGroupingPolicy()).toEqual( - allEnfGroupPolicies, - ); - - expect(await enforcerDelegate.getAllRoles()).toEqual(allEnfRoles); - - const nonAdminPolicies = (await enforcerDelegate.getPolicy()).filter( - (policy: string[]) => policy[0] !== 'role:default/rbac_admin', - ); - - expect(nonAdminPolicies).toEqual(allEnfPolicies); - - // role metadata should be removed - expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( - 'role:default/old-role', - expect.anything(), - ); - }); - - it('should cleanup old policies and metadata after re-attach policy file', async () => { - const storedGroupPolicies = [ - // should not be removed: - ['user:default/tester', 'role:default/some-role'], - ]; - const storedPolicies = [ - // should be removed - ['role:default/old-role', 'test.some.resource', 'use', 'allow'], - - // should not be removed - ['role:default/some-role', 'test.some.resource', 'use', 'allow'], - ]; - - enforcerDelegate = await newEnforcerDelegate( - adapter, - config, - storedPolicies, - storedGroupPolicies, - ); - - rbacPolicy = await newPermissionPolicy(config, enforcerDelegate); - - expect(await enforcerDelegate.getAllRoles()).toEqual(allEnfRoles); - - expect(await enforcerDelegate.getGroupingPolicy()).toEqual( - allEnfGroupPolicies, - ); - - const nonAdminPolicies = (await enforcerDelegate.getPolicy()).filter( - (p: string[]) => { - return p[0] !== 'role:default/rbac_admin'; - }, - ); - expect(nonAdminPolicies).toEqual(allEnfPolicies); - - // role metadata should not be removed - expect( - roleMetadataStorageMock.removeRoleMetadata, - ).not.toHaveBeenCalledWith('role:default/old-role', expect.anything()); - - const decision = await rbacPolicy.handle( - newPolicyQueryWithBasicPermission('test.some.resource'), - newPolicyQueryUser('user:default/user-old-1'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/user-old-1', - 'test.some.resource', - undefined, - 'use', - AuthorizeResult.DENY, - ); - }); - - it('should cleanup old policies and group policies and metadata after re-attach policy file', async () => { - const storedGroupPolicies = [ - // should be removed - ['user:default/user-old-1', 'role:default/old-role'], - ['user:default/user-old-2', 'role:default/old-role'], - ['group:default/team-a-old-1', 'role:default/old-role'], - ['group:default/team-a-old-2', 'role:default/old-role'], - - // should not be removed: - ['user:default/tester', 'role:default/some-role'], - ]; - const storedPolicies = [ - // should be removed - ['role:default/old-role', 'test.some.resource', 'use', 'allow'], - - // should not be removed - ['role:default/some-role', 'test.some.resource', 'use', 'allow'], - ]; - - enforcerDelegate = await newEnforcerDelegate( - adapter, - config, - storedPolicies, - storedGroupPolicies, - ); - - await newPermissionPolicy(config, enforcerDelegate); - - expect(await enforcerDelegate.getAllRoles()).toEqual(allEnfRoles); - - expect(await enforcerDelegate.getGroupingPolicy()).toEqual( - allEnfGroupPolicies, - ); - - const nonAdminPolicies = (await enforcerDelegate.getPolicy()).filter( - (policy: string[]) => { - return policy[0] !== 'role:default/rbac_admin'; - }, - ); - expect(nonAdminPolicies).toEqual(allEnfPolicies); - - // role metadata should be removed - expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( - 'role:default/old-role', - expect.anything(), - ); - }); - - it('should cleanup old group policies and metadata after detach policy file', async () => { - const storedGroupPolicies = [ - // should be removed - ['user:default/user-old-1', 'role:default/old-role'], - ['group:default/team-a-old-1', 'role:default/old-role'], - - // should not be removed: - ['user:default/tester', 'role:default/some-role'], - ]; - const storedPolicies = [ - // should not be removed - ['role:default/some-role', 'test.some.resource', 'use', 'allow'], - ]; - - enforcerDelegate = await newEnforcerDelegate( - adapter, - config, - storedPolicies, - storedGroupPolicies, - ); - - await newPermissionPolicy(config, enforcerDelegate); - - expect(await enforcerDelegate.getAllRoles()).toEqual(allEnfRoles); - - expect(await enforcerDelegate.getGroupingPolicy()).toEqual( - allEnfGroupPolicies, - ); - - const nonAdminPolicies = (await enforcerDelegate.getPolicy()).filter( - (policy: string[]) => { - return policy[0] !== 'role:default/rbac_admin'; - }, - ); - expect(nonAdminPolicies).toEqual(allEnfPolicies); - - // role metadata should be removed - expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( - 'role:default/old-role', - expect.anything(), - ); - }); - - it('should cleanup old policies after detach policy file', async () => { - const storedGroupPolicies = [ - // should not be removed: - ['user:default/tester', 'role:default/some-role'], - ]; - const storedPolicies = [ - // should be removed - ['role:default/old-role', 'test.some.resource', 'use', 'allow'], - - // should not be removed - ['role:default/some-role', 'test.some.resource', 'use', 'allow'], - ]; - - enforcerDelegate = await newEnforcerDelegate( - adapter, - config, - storedPolicies, - storedGroupPolicies, - ); - - await newPermissionPolicy(config, enforcerDelegate); - - expect(await enforcerDelegate.getAllRoles()).toEqual(allEnfRoles); - - expect(await enforcerDelegate.getGroupingPolicy()).toEqual( - allEnfGroupPolicies, - ); - - const nonAdminPolicies = (await enforcerDelegate.getPolicy()).filter( - (policy: string[]) => { - return policy[0] !== 'role:default/rbac_admin'; - }, - ); - expect(nonAdminPolicies).toEqual(allEnfPolicies); - }); - - it('should cleanup old policies and group policies and metadata after detach policy file', async () => { - const storedGroupPolicies = [ - // should be removed - ['user:default/user-old-1', 'role:default/old-role'], - ['user:default/user-old-2', 'role:default/old-role'], - ['group:default/team-a-old-1', 'role:default/old-role'], - ['group:default/team-a-old-2', 'role:default/old-role'], - - // should not be removed: - ['user:default/tester', 'role:default/some-role'], - ]; - const storedPolicies = [ - // should be removed - ['role:default/old-role', 'test.some.resource', 'use', 'allow'], - - // should not be removed - ['role:default/some-role', 'test.some.resource', 'use', 'allow'], - ]; - - enforcerDelegate = await newEnforcerDelegate( - adapter, - config, - storedPolicies, - storedGroupPolicies, - ); - - await newPermissionPolicy(config, enforcerDelegate); - - expect(await enforcerDelegate.getAllRoles()).toEqual(allEnfRoles); - - expect(await enforcerDelegate.getGroupingPolicy()).toEqual( - allEnfGroupPolicies, - ); - - const nonAdminPolicies = (await enforcerDelegate.getPolicy()).filter( - (policy: string[]) => { - return policy[0] !== 'role:default/rbac_admin'; - }, - ); - expect(nonAdminPolicies).toEqual(allEnfPolicies); - - // role metadata should be removed - expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( - 'role:default/old-role', - expect.anything(), - ); - }); - }); - - describe('Policy checks for users', () => { - let policy: RBACPermissionPolicy; - let enfDelegate: EnforcerDelegate; - - const roleMetadataStorageTest: RoleMetadataStorage = { - filterRoleMetadata: jest.fn().mockImplementation(() => []), - findRoleMetadata: jest - .fn() - .mockImplementation( - async ( - roleEntityRef: string, - _trx: Knex.Knex.Transaction, - ): Promise => { - if (roleEntityRef.includes('rbac_admin')) { - return { source: 'configuration' }; - } - return { source: 'csv-file' }; - }, - ), - filterForOwnerRoleMetadata: jest.fn().mockImplementation(), - createRoleMetadata: jest.fn().mockImplementation(), - updateRoleMetadata: jest.fn().mockImplementation(), - removeRoleMetadata: jest.fn().mockImplementation(), - getCachedDefaultRoleMetadata: jest - .fn() - .mockImplementation(() => undefined), - getDefaultRole: jest.fn().mockResolvedValue(undefined), - syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), - }; - - beforeEach(async () => { - const basicAndResourcePermissions = resolve( - __dirname, - '../../__fixtures__/data/valid-csv/basic-and-resource-policies.csv', - ); - const config = newConfig(basicAndResourcePermissions); - const adapter = await newAdapter(config); - enfDelegate = await newEnforcerDelegate(adapter, config); - - policy = await newPermissionPolicy( - config, - enfDelegate, - roleMetadataStorageTest, - ); - }); - // +-------+------+------------------------------------+ - // | allow | deny | result | | - // +-------+------+--------------------------------+---| - // | N | Y | deny | 1 | - // | N | N | deny (user not listed) | 2 | - // | Y | Y | deny (user:default/duplicated) | 3 | - // | Y | N | allow | 4 | - - // Tests for Resource basic type permission - - // case1 - it('should deny access to basic permission for listed user with deny action', async () => { - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('test.resource.deny'), - newPolicyQueryUser('user:default/known_user'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/known_user', - 'test.resource.deny', - undefined, - 'use', - AuthorizeResult.DENY, - ); - }); - // case2 - it('should deny access to basic permission for unlisted user', async () => { - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('test.resource'), - newPolicyQueryUser('unuser:default/known_user'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'unuser:default/known_user', - 'test.resource', - undefined, - 'use', - AuthorizeResult.DENY, - ); - }); - // case3 - it('should deny access to basic permission for listed user deny and allow', async () => { - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('test.resource'), - newPolicyQueryUser('user:default/duplicated'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/duplicated', - 'test.resource', - undefined, - 'use', - AuthorizeResult.DENY, - ); - }); - // case4 - it('should allow access to basic permission for user listed on policy', async () => { - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('test.resource'), - newPolicyQueryUser('user:default/known_user'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/known_user', - 'test.resource', - undefined, - 'use', - AuthorizeResult.ALLOW, - ); - }); - // case5 - it('should deny access to undefined user', async () => { - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('test.resource'), - newPolicyQueryUser(), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - undefined, - 'test.resource', - undefined, - 'use', - AuthorizeResult.DENY, - ); - }); - - // Tests for Resource Permission type - - // case1 - it('should deny access to resource permission for user listed on policy', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'test.resource.deny', - 'test-resource-deny', - 'update', - ), - newPolicyQueryUser('user:default/known_user'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/known_user', - 'test.resource.deny', - 'test-resource-deny', - 'update', - AuthorizeResult.DENY, - ); - }); - // case 2 - it('should deny access to resource permission for user unlisted on policy', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'test.resource.update', - 'test-resource', - 'update', - ), - newPolicyQueryUser('unuser:default/known_user'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'unuser:default/known_user', - 'test.resource.update', - 'test-resource', - 'update', - AuthorizeResult.DENY, - ); - }); - // case 3 - it('should deny access to resource permission for user listed deny and allow', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'test.resource.update', - 'test-resource', - 'update', - ), - newPolicyQueryUser('user:default/duplicated'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/duplicated', - 'test.resource.update', - 'test-resource', - 'update', - AuthorizeResult.DENY, - ); - }); - // case 4 - it('should allow access to resource permission for user listed on policy', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'test.resource.update', - 'test-resource', - 'update', - ), - newPolicyQueryUser('user:default/known_user'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/known_user', - 'test.resource.update', - 'test-resource', - 'update', - AuthorizeResult.ALLOW, - ); - }); - // case 5 - // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` - it('should allow access to basic permission policy.entity.create even though it is defined as `policy-entity, create` for user listed on policy', async () => { - await enfDelegate.addPolicy([ - 'user:default/known_user', - 'policy-entity', - 'create', - 'allow', - ]); - - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('policy.entity.create', 'create'), - newPolicyQueryUser('user:default/known_user'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/known_user', - 'policy.entity.create', - undefined, - 'create', - AuthorizeResult.ALLOW, - ); - }); - // case 6 - // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` - it('should allow access to basic permission policy.entity.create even though it is defined as `policy-entity, create` for role', async () => { - await enfDelegate.addGroupingPolicy( - ['user:default/known_user', 'role:default/known_user'], - { - source: 'csv-file', - roleEntityRef: 'role:default/known_user', - modifiedBy, - }, - ); - await enfDelegate.addPolicy([ - 'role:default/known_user', - 'policy-entity', - 'create', - 'allow', - ]); - - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('policy.entity.create', 'create'), - newPolicyQueryUser('user:default/known_user'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/known_user', - 'policy.entity.create', - undefined, - 'create', - AuthorizeResult.ALLOW, - ); - }); - - // Tests for actions on resource permissions - it('should deny access to resource permission for unlisted action for user listed on policy', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'test.resource.update', - 'test-resource', - 'delete', - ), - newPolicyQueryUser('user:default/known_user'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - }); - - // Tests for admin added through app config - it('should allow access to permission resources for admin added through app config', async () => { - const adminPerm: { - name: string; - resource: string; - action: PermissionAction; - }[] = [ - { - name: 'policy.entity.read', - resource: 'policy-entity', - action: 'read', - }, - { - name: 'policy.entity.create', - resource: 'policy-entity', - action: 'create', - }, - { - name: 'policy.entity.update', - resource: 'policy-entity', - action: 'update', - }, - { - name: 'policy.entity.delete', - resource: 'policy-entity', - action: 'delete', - }, - { - name: 'catalog.entity.read', - resource: 'catalog-entity', - action: 'read', - }, - ]; - for (const perm of adminPerm) { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - perm.name, - perm.resource, - perm.action, - ), - newPolicyQueryUser('user:default/guest'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/guest', - perm.name, - perm.resource, - perm.action, - AuthorizeResult.ALLOW, - ); - clearAuditorMock(); - } - }); - }); - - describe('Policy checks from config file', () => { - let policy: RBACPermissionPolicy; - let enfDelegate: EnforcerDelegate; - const roleMetadataStorageTest: RoleMetadataStorage = { - filterRoleMetadata: jest.fn().mockImplementation(() => []), - findRoleMetadata: jest - .fn() - .mockImplementation( - async ( - _roleEntityRef: string, - _trx: Knex.Knex.Transaction, - ): Promise => { - return { - roleEntityRef: 'role:default/catalog-writer', - source: 'legacy', - modifiedBy, - }; - }, - ), - filterForOwnerRoleMetadata: jest.fn().mockImplementation(), - createRoleMetadata: jest.fn().mockImplementation(), - updateRoleMetadata: jest.fn().mockImplementation(), - removeRoleMetadata: jest.fn().mockImplementation(), - getCachedDefaultRoleMetadata: jest - .fn() - .mockImplementation(() => undefined), - getDefaultRole: jest.fn().mockResolvedValue(undefined), - syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), - }; - - const adminRole = 'role:default/rbac_admin'; - const groupPolicy = [ - ['user:default/test_admin', 'role:default/rbac_admin'], - ]; - const permissions = [ - ['role:default/rbac_admin', 'policy-entity', 'read', 'allow'], - ['role:default/rbac_admin', 'policy.entity.create', 'create', 'allow'], - ['role:default/rbac_admin', 'policy-entity', 'delete', 'allow'], - ['role:default/rbac_admin', 'policy-entity', 'update', 'allow'], - ['role:default/rbac_admin', 'catalog-entity', 'read', 'allow'], - ]; - const oldGroupPolicy = [ - 'user:default/old_admin', - 'role:default/rbac_admin', - ]; - const admins = new Array<{ name: string }>(); - admins.push({ name: 'user:default/test_admin' }); - const superUser = new Array<{ name: string }>(); - superUser.push({ name: 'user:default/super_user' }); - - beforeEach(async () => { - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async ( - _roleEntityRef: string, - _trx: Knex.Knex.Transaction, - ): Promise => { - return { - roleEntityRef: 'role:default/catalog-writer', - source: 'legacy', - modifiedBy, - }; - }, - ); - - const config = newConfig(csvPermFile, admins, superUser); - const adapter = await newAdapter(config); - - enfDelegate = await newEnforcerDelegate(adapter, config); - - await enfDelegate.addGroupingPolicy(oldGroupPolicy, { - source: 'configuration', - roleEntityRef: ADMIN_ROLE_NAME, - modifiedBy: `user:default/tom`, - }); - - policy = await newPermissionPolicy( - config, - enfDelegate, - roleMetadataStorageTest, - ); - }); - - it('should allow read access to resource permission for user from config file', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'policy.entity.read', - 'policy-entity', - 'read', - ), - newPolicyQueryUser('user:default/test_admin'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/test_admin', - 'policy.entity.read', - 'policy-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('should allow read access to resource permission for super user from config file', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'policy.entity.read', - 'policy-entity', - 'read', - ), - newPolicyQueryUser('user:default/super_user'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/super_user', - 'policy.entity.read', - 'policy-entity', - 'read', - AuthorizeResult.ALLOW, - ); - clearAuditorMock(); - const decision2 = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.delete', - 'catalog-entity', - 'delete', - ), - newPolicyQueryUser('user:default/super_user'), - ); - expect(decision2.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/super_user', - 'catalog.entity.delete', - 'catalog-entity', - 'delete', - AuthorizeResult.ALLOW, - ); - }); - - it('should allow access to a user who is a member of a group configured as super user', async () => { - const superUsersConfig = new Array<{ name: string }>(); - superUsersConfig.push({ name: 'group:default/super_users_group' }); - - const config = newConfig(csvPermFile, admins, superUsersConfig); - const adapter = await newAdapter(config); - const enfDelegateForTest = await newEnforcerDelegate(adapter, config); - const policyForTest = await newPermissionPolicy( - config, - enfDelegateForTest, - roleMetadataStorageTest, - ); - - const decision = await policyForTest.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.delete', - 'catalog-entity', - 'delete', - ), - newPolicyQueryUser('user:default/some_user', [ - 'group:default/super_users_group', - ]), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/some_user', - 'catalog.entity.delete', - 'catalog-entity', - 'delete', - AuthorizeResult.ALLOW, - ); - }); - - it('should deny access to a user who is not a member of a group configured as super user', async () => { - const superUsersConfig = new Array<{ name: string }>(); - superUsersConfig.push({ name: 'group:default/super_users_group' }); - - const config = newConfig(csvPermFile, admins, superUsersConfig); - const adapter = await newAdapter(config); - const enfDelegateForTest = await newEnforcerDelegate(adapter, config); - const policyForTest = await newPermissionPolicy( - config, - enfDelegateForTest, - roleMetadataStorageTest, - ); - - const decision = await policyForTest.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.delete', - 'catalog-entity', - 'delete', - ), - newPolicyQueryUser('user:default/some_user', [ - 'group:default/other_group', - ]), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/some_user', - 'catalog.entity.delete', - 'catalog-entity', - 'delete', - AuthorizeResult.DENY, - ); - }); - - it('should remove users that are no longer in the config file', async () => { - const enfRole = await enfDelegate.getFilteredGroupingPolicy(1, adminRole); - const enfPermission = await enfDelegate.getFilteredPolicy(0, adminRole); - expect(enfRole).toEqual(groupPolicy); - expect(enfRole).not.toContain(oldGroupPolicy); - expect(enfPermission).toEqual(permissions); - }); - }); -}); - -// Notice: There is corner case, when "resourced" permission policy can be defined not by resource type, but by name. -describe('Policy checks for resourced permissions defined by name', () => { - const roleMetadataStorageTest: RoleMetadataStorage = { - filterRoleMetadata: jest.fn().mockImplementation(() => []), - findRoleMetadata: jest - .fn() - .mockImplementation( - async ( - _roleEntityRef: string, - _trx: Knex.Knex.Transaction, - ): Promise => { - return { - roleEntityRef: 'role:default/catalog-writer', - source: 'legacy', - modifiedBy, - }; - }, - ), - filterForOwnerRoleMetadata: jest.fn().mockImplementation(), - createRoleMetadata: jest.fn().mockImplementation(), - updateRoleMetadata: jest.fn().mockImplementation(), - removeRoleMetadata: jest.fn().mockImplementation(), - getCachedDefaultRoleMetadata: jest.fn().mockImplementation(() => undefined), - getDefaultRole: jest.fn().mockResolvedValue(undefined), - syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), - }; - let enfDelegate: EnforcerDelegate; - let policy: RBACPermissionPolicy; - - beforeEach(async () => { - const config = newConfig(); - const adapter = await newAdapter(config); - enfDelegate = await newEnforcerDelegate(adapter, config); - policy = await newPermissionPolicy( - config, - enfDelegate, - roleMetadataStorageTest, - ); - }); - - it('should allow access to resourced permission assigned by name', async () => { - await enfDelegate.addGroupingPolicy( - ['user:default/tor', 'role:default/catalog_reader'], - { - source: 'csv-file', - roleEntityRef: 'role:default/catalog_reader', - modifiedBy, - }, - ); - await enfDelegate.addPolicy([ - 'role:default/catalog_reader', - 'catalog.entity.read', - 'read', - 'allow', - ]); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/tor'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - }); - - it('should allow access to resourced permission assigned by name, because it has higher priority then permission for the same resource assigned by resource type', async () => { - await enfDelegate.addGroupingPolicy( - ['user:default/tor', 'role:default/catalog_reader'], - { - source: 'csv-file', - roleEntityRef: 'role:default/catalog_reader', - modifiedBy, - }, - ); - await enfDelegate.addPolicies([ - ['role:default/catalog_reader', 'catalog.entity.read', 'read', 'allow'], - ['role:default/catalog_reader', 'catalog-entity', 'read', 'deny'], - ]); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/tor'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - }); - - it('should deny access to resourced permission assigned by name, because it has higher priority then permission for the same resource assigned by resource type', async () => { - await enfDelegate.addGroupingPolicy( - ['user:default/tor', 'role:default/catalog_reader'], - { - source: 'csv-file', - roleEntityRef: 'role:default/catalog_reader', - modifiedBy, - }, - ); - - await enfDelegate.addPolicies([ - ['role:default/catalog_reader', 'catalog.entity.read', 'read', 'deny'], - ['role:default/catalog_reader', 'catalog-entity', 'read', 'allow'], - ]); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/tor'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/tor', - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - - it('should allow access to resourced permission assigned by name, but user inherits policy from his group', async () => { - await enfDelegate.addGroupingPolicy( - ['group:default/team-a', 'role:default/catalog_user'], - { - source: 'csv-file', - roleEntityRef: 'role:default/catalog_user', - modifiedBy, - }, - ); - - await enfDelegate.addPolicies([ - ['role:default/catalog_user', 'catalog.entity.read', 'read', 'allow'], - ]); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/tor'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/tor', - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('should allow access to resourced permission assigned by name, but user inherits policy from uppercase group', async () => { - const name = 'team-C'; - await enfDelegate.addGroupingPolicy( - [ - `group:default/${name.toLocaleLowerCase('en-US')}`, - 'role:default/catalog_user', - ], - { - source: 'csv-file', - roleEntityRef: 'role:default/catalog_user', - modifiedBy, - }, - ); - - await enfDelegate.addPolicies([ - ['role:default/catalog_user', 'catalog.entity.read', 'read', 'allow'], - ]); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/tor'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/tor', - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('should allow access to resourced permission assigned by name, but user inherits policy from few groups', async () => { - await enfDelegate.addGroupingPolicy( - ['group:default/team-a', 'role:default/catalog_user'], - { - source: 'csv-file', - roleEntityRef: 'role:default/catalog_user', - modifiedBy, - }, - ); - await enfDelegate.addGroupingPolicy( - ['group:default/team-a', 'group:default/team-c'], - { - source: 'csv-file', - roleEntityRef: 'role:default/catalog_user', - modifiedBy, - }, - ); - - await enfDelegate.addPolicies([ - ['role:default/catalog_user', 'catalog.entity.read', 'read', 'allow'], - ]); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/tor'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/tor', - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); -}); - -describe('Policy checks for users and groups', () => { - let policy: RBACPermissionPolicy; - - beforeEach(async () => { - const policyChecksCSV = resolve( - __dirname, - '../../__fixtures__/data/valid-csv/policy-checks.csv', - ); - const config = newConfig(policyChecksCSV); - const adapter = await newAdapter(config); - - const enfDelegate = await newEnforcerDelegate(adapter, config); - - policy = await newPermissionPolicy(config, enfDelegate); - }); - - // User inherits permissions from groups and their parent groups. - // This behavior can be configured with `policy_effect` in the model. - // Also it can be customized using casbin function. - // Test suite table: - // +-------+---------+----------+-------+ - // | Group | User | result | case# | - // +-------+---------+----------+-------+ - // | deny | allow | deny | 1 | + - // | deny | - | deny | 2 | + - // | deny | deny | deny | 3 | + - // +-------+---------+----------+-------+ - // | allow | allow | allow | 4 | + - // | allow | - | allow | 5 | + - // | allow | deny | deny | 6 | - // +-------+---------+----------+-------+ - - // Basic type permissions - - // case1 - it('should deny access to basic permission for user Alice with "allow" "use" action, when her group "deny" this action', async () => { - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('test.resource'), - newPolicyQueryUser('user:default/alice'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/alice', - 'test.resource', - undefined, - 'use', - AuthorizeResult.DENY, - ); - }); - - // case2 - it('should deny access to basic permission for user Akira without("-") "use" action definition, when his group "deny" this action', async () => { - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('test.resource'), - newPolicyQueryUser('user:default/akira'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/akira', - 'test.resource', - undefined, - 'use', - AuthorizeResult.DENY, - ); - }); - - // case3 - it('should deny access to basic permission for user Antey with "deny" "use" action definition, when his group "deny" this action', async () => { - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('test.resource'), - newPolicyQueryUser('user:default/antey'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/antey', - 'test.resource', - undefined, - 'use', - AuthorizeResult.DENY, - ); - }); - - // case4 - it('should allow access to basic permission for user Julia with "allow" "use" action, when her group "allow" this action', async () => { - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('test.resource'), - newPolicyQueryUser('user:default/julia'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/julia', - 'test.resource', - undefined, - 'use', - AuthorizeResult.ALLOW, - ); - }); - - // case5 - it('should allow access to basic permission for user Mike without("-") "use" action definition, when his group "allow" this action', async () => { - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('test.resource'), - newPolicyQueryUser('user:default/mike'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/mike', - 'test.resource', - undefined, - 'use', - AuthorizeResult.ALLOW, - ); - }); - - // case6 - it('should deny access to basic permission for user Tom with "deny" "use" action definition, when his group "allow" this action', async () => { - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('test.resource'), - newPolicyQueryUser('user:default/tom'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/tom', - 'test.resource', - undefined, - 'use', - AuthorizeResult.DENY, - ); - }); - - // inheritance case - it('should allow access to basic permission to test.resource.2 for user Mike with "-" "use" action definition, when parent group of his group "allow" this action', async () => { - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('test.resource.2'), - newPolicyQueryUser('user:default/mike'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/mike', - 'test.resource.2', - undefined, - 'use', - AuthorizeResult.ALLOW, - ); - }); - - // Resource type permissions - - // case1 - it('should deny access to basic permission for user Alice with "allow" "read" action, when her group "deny" this action', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'test.resource.read', - 'test-resource', - 'read', - ), - newPolicyQueryUser('user:default/alice'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/alice', - 'test.resource.read', - 'test-resource', - 'read', - AuthorizeResult.DENY, - ); - }); - - // case2 - it('should deny access to basic permission for user Akira without("-") "read" action definition, when his group "deny" this action', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'test.resource.read', - 'test-resource', - 'read', - ), - newPolicyQueryUser('user:default/akira'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/akira', - 'test.resource.read', - 'test-resource', - 'read', - AuthorizeResult.DENY, - ); - }); - - // case3 - it('should deny access to basic permission for user Antey with "deny" "read" action definition, when his group "deny" this action', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'test.resource.read', - 'test-resource', - 'read', - ), - newPolicyQueryUser('user:default/antey'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/antey', - 'test.resource.read', - 'test-resource', - 'read', - AuthorizeResult.DENY, - ); - }); - - // case4 - it('should allow access to basic permission for user Julia with "allow" "read" action, when her group "allow" this action', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'test.resource.read', - 'test-resource', - 'read', - ), - newPolicyQueryUser('user:default/julia'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/julia', - 'test.resource.read', - 'test-resource', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - // case5 - it('should allow access to basic permission for user Mike without("-") "read" action definition, when his group "allow" this action', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'test.resource.read', - 'test-resource', - 'read', - ), - newPolicyQueryUser('user:default/mike'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/mike', - 'test.resource.read', - 'test-resource', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - // case6 - it('should deny access to basic permission for user Tom with "deny" "read" action definition, when his group "allow" this action', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'test.resource.read', - 'test-resource', - 'read', - ), - newPolicyQueryUser('user:default/tom'), - ); - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/tom', - 'test.resource.read', - 'test-resource', - 'read', - AuthorizeResult.DENY, - ); - }); - - // inheritance case - it('should allow access to resource permission to test-resource for user Mike with "-" "write" action definition, when parent group of his group "allow" this action', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'test.resource.create', - 'test-resource', - 'create', - ), - newPolicyQueryUser('user:default/mike'), - ); - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/mike', - 'test.resource.create', - 'test-resource', - 'create', - AuthorizeResult.ALLOW, - ); - }); -}); - -describe('Policy checks for conditional policies', () => { - let policy: RBACPermissionPolicy; - - beforeEach(async () => { - const config = newConfig(undefined, []); - const adapter = await newAdapter(config); - const theModel = newModelFromString(MODEL); - const logger = mockServices.logger.mock(); - const enf = await createEnforcer(theModel, adapter, logger, config); - const policies = [['role:default/test', 'catalog-entity', 'read', 'allow']]; - const groupPolicies = [ - ['group:default/test-group', 'role:default/test'], - ['group:default/qa', 'role:default/qa'], - ]; - await enf.addPolicies(policies); - await enf.addGroupingPolicies(groupPolicies); - - const enfDelegate = new EnforcerDelegate( - enf, - mockAuditorService, - conditionalStorageMock, - roleMetadataStorageMock, - mockClientKnex, - ); - - policy = await RBACPermissionPolicy.build( - logger, - mockAuditorService, - config, - conditionalStorageMock, - enfDelegate, - roleMetadataStorageMock, - mockClientKnex, - pluginMetadataCollectorMock as PluginPermissionMetadataCollector, - mockAuthService, - ); - }); - - it('should execute condition policy', async () => { - (conditionalStorageMock.filterConditions as jest.Mock).mockReturnValueOnce([ - { - id: 1, - pluginId: 'catalog', - resourceType: 'catalog-entity', - actions: ['read'], - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/test-group'], - }, - }, - }, - ]); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/mike'), - ); - expect(decision).toStrictEqual({ - pluginId: 'catalog', - resourceType: 'catalog-entity', - result: AuthorizeResult.CONDITIONAL, - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/test-group'], - }, - }, - ], - }, - }); - }); - - it('should execute condition policy with current user alias', async () => { - (conditionalStorageMock.filterConditions as jest.Mock).mockReturnValueOnce([ - { - id: 1, - pluginId: 'catalog', - resourceType: 'catalog-entity', - actions: ['read'], - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['$currentUser'], - }, - }, - }, - ]); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/mike', [ - 'user:default/mike', - 'group:default/team-a', - ]), - ); - expect(decision).toStrictEqual({ - pluginId: 'catalog', - resourceType: 'catalog-entity', - result: AuthorizeResult.CONDITIONAL, - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/mike'], - }, - }, - ], - }, - }); - }); - - it('should merge condition policies for user assigned to few roles', async () => { - (conditionalStorageMock.filterConditions as jest.Mock) - .mockReturnValueOnce([ - { - id: 1, - pluginId: 'catalog', - resourceType: 'catalog-entity', - actions: ['read'], - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/test-group'], - }, - }, - }, - ]) - .mockReturnValueOnce([ - { - id: 2, - pluginId: 'catalog', - resourceType: 'catalog-entity', - actions: ['read'], - roleEntityRef: 'role:default/qa', - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - }, - ]); - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/mike'), - ); - expect(decision).toStrictEqual({ - pluginId: 'catalog', - resourceType: 'catalog-entity', - result: AuthorizeResult.CONDITIONAL, - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/test-group'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group', 'User'] }, - }, - ], - }, - }); - }); - - it('should deny condition policy caused collision', async () => { - (conditionalStorageMock.filterConditions as jest.Mock).mockReturnValueOnce([ - { - id: 1, - pluginId: 'catalog', - resourceType: 'catalog-entity', - actions: ['read'], - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/test-group'], - }, - }, - }, - { - id: 2, - pluginId: 'catalog-fork', - resourceType: 'catalog-entity', - actions: ['read'], - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/test-group'], - }, - }, - }, - ]); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/mike'), - ); - expect(decision).toStrictEqual({ - result: AuthorizeResult.DENY, - }); - }); -}); - -describe('Policy checks with preferPermissionPolicy config', () => { - const allowReadAndCreatePolicies = [ - // allow read for all resources - ['role:default/all_resource_reader', 'catalog-entity', 'read', 'allow'], - ['role:default/all_resource_reader', 'catalog-entity', 'create', 'allow'], - ]; - - const allowCreateButDenyReadPolicies = [ - // deny read for all resources - ['role:default/all_resource_reader', 'catalog-entity', 'read', 'deny'], - ['role:default/all_resource_reader', 'catalog-entity', 'create', 'allow'], - ]; - - const allowOnlyCreateAndNoneReadPolicies = [ - ['role:default/all_resource_reader', 'catalog-entity', 'create', 'allow'], - ]; - - const groupPolicies = [ - ['user:default/mike', 'role:default/all_resource_reader'], - ['user:default/mike', 'role:default/owned_resource_reader'], - ]; - - const conditionalPolicy = [ - { - id: 1, - pluginId: 'catalog', - resourceType: 'catalog-entity', - actions: ['read'], - roleEntityRef: 'role:default/owned_resource_reader', - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/mike'], - }, - }, - }, - ]; - - it('should allow "catalog read operation" when preferPermissionPolicy is true (permission policy first) and read policy has "allow" value', async () => { - const config = newConfig(undefined, undefined, undefined, 'basic'); - const adapter = await newAdapter(config); - const enfDelegate = await newEnforcerDelegate( - adapter, - config, - allowReadAndCreatePolicies, - groupPolicies, - ); - const policy = await newPermissionPolicy(config, enfDelegate); - - // Mock conditionalStorage to return a conditional ALLOW for owned-reader - ( - conditionalStorageMock.filterConditions as jest.Mock - ).mockResolvedValueOnce(conditionalPolicy); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/mike', ['user:default/mike']), // user is owner - ); - expect(decision).toStrictEqual({ result: AuthorizeResult.ALLOW }); - }); - - it('should deny "catalog read operation" when preferPermissionPolicy is true (permission policy first) and read policy has "deny" value', async () => { - const config = newConfig(undefined, undefined, undefined, 'basic'); - const adapter = await newAdapter(config); - const enfDelegate = await newEnforcerDelegate( - adapter, - config, - allowCreateButDenyReadPolicies, - groupPolicies, - ); - const policy = await newPermissionPolicy(config, enfDelegate); - - // Mock conditionalStorage to return a conditional ALLOW for owned-reader - ( - conditionalStorageMock.filterConditions as jest.Mock - ).mockResolvedValueOnce(conditionalPolicy); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/mike', ['user:default/mike']), // user is owner - ); - expect(decision).toStrictEqual({ result: AuthorizeResult.DENY }); - }); - - it('should return conditional result for "catalog read operation" when preferPermissionPolicy is true (permission policy first) and there is no read policy value', async () => { - const config = newConfig(undefined, undefined, undefined, 'basic'); - const adapter = await newAdapter(config); - const enfDelegate = await newEnforcerDelegate( - adapter, - config, - allowOnlyCreateAndNoneReadPolicies, - groupPolicies, - ); - const policy = await newPermissionPolicy(config, enfDelegate); - - // Mock conditionalStorage to return a conditional ALLOW for owned-reader - ( - conditionalStorageMock.filterConditions as jest.Mock - ).mockResolvedValueOnce(conditionalPolicy); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/mike', ['user:default/mike']), // user is owner - ); - expect(decision).toStrictEqual({ - pluginId: 'catalog', - resourceType: 'catalog-entity', - result: AuthorizeResult.CONDITIONAL, - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/mike'], - }, - }, - ], - }, - }); - }); - - it('should NOT allow read when preferPermissionPolicy is false by default (conditional policy first)', async () => { - const config = newConfig(); - const adapter = await newAdapter(config); - const enfDelegate = await newEnforcerDelegate( - adapter, - config, - allowReadAndCreatePolicies, - groupPolicies, - ); - const policy = await newPermissionPolicy(config, enfDelegate); - - // Mock conditionalStorage to return a conditional ALLOW for owned-reader - ( - conditionalStorageMock.filterConditions as jest.Mock - ).mockResolvedValueOnce(conditionalPolicy); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/mike', ['user:default/mike']), - ); - expect(decision).toStrictEqual({ - pluginId: 'catalog', - resourceType: 'catalog-entity', - result: AuthorizeResult.CONDITIONAL, - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/mike'], - }, - }, - ], - }, - }); - }); -}); - -function newPolicyQueryWithBasicPermission( - name: string, - action?: 'create' | 'read' | 'update' | 'delete', -): PolicyQuery { - const mockPermission = createPermission({ - name: name, - attributes: { action }, - }); - return { permission: mockPermission }; -} - -function newPolicyQueryWithResourcePermission( - name: string, - resource: string, - action: PermissionAction, -): PolicyQuery { - const mockPermission = createPermission({ - name: name, - attributes: {}, - resourceType: resource, - }); - if (action) { - mockPermission.attributes.action = action; - } - return { permission: mockPermission }; -} - -function newPolicyQueryUser( - user?: string, - ownershipEntityRefs?: string[], -): PolicyQueryUser | undefined { - if (user) { - return { - identity: { - ownershipEntityRefs: ownershipEntityRefs ?? [], - type: 'user', - userEntityRef: user, - }, - credentials: { - $$type: '@backstage/BackstageCredentials', - principal: true, - expiresAt: new Date('2021-01-01T00:00:00Z'), - }, - info: { - userEntityRef: user, - ownershipEntityRefs: ownershipEntityRefs ?? [], - }, - token: 'token', - }; - } - return undefined; -} - -function newConfig( - permFile?: string, - users?: Array<{ name: string }>, - superUsers?: Array<{ name: string }>, - policyDecisionPrecedence?: 'basic' | 'conditional', -): Config { - const testUsers = [ - { - name: 'user:default/guest', - }, - { - name: 'group:default/guests', - }, - ]; - - return mockServices.rootConfig({ - data: { - permission: { - rbac: { - 'policies-csv-file': permFile || csvPermFile, - policyFileReload: false, - admin: { - users: users || testUsers, - superUsers: superUsers, - }, - policyDecisionPrecedence: policyDecisionPrecedence ?? 'conditional', - }, - }, - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - }, - }); -} - -function newConfigWithDefaultRole( - defaultRole?: string, - permFile?: string, - users?: Array<{ name: string }>, - superUsers?: Array<{ name: string }>, -): Config { - const testUsers = [ - { - name: 'user:default/guest', - }, - { - name: 'group:default/guests', - }, - ]; - - const rbacConfig: any = { - 'policies-csv-file': permFile || csvPermFile, - policyFileReload: false, - admin: { - users: users || testUsers, - superUsers: superUsers, - }, - }; - - if (defaultRole !== undefined) { - rbacConfig.defaultPermissions = { - defaultRole, - basicPermissions: [ - { permission: 'catalog.entity.read', action: 'read' }, - { permission: 'catalog-entity', action: 'read' }, - { permission: 'catalog.entity.create', action: 'create' }, - ], - }; - } - - return mockServices.rootConfig({ - data: { - permission: { - rbac: rbacConfig, - }, - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - }, - }); -} - -async function newAdapter(config: Config): Promise { - return await new CasbinDBAdapterFactory( - config, - mockClientKnex, - ).createAdapter(); -} - -async function createEnforcer( - theModel: Model, - adapter: Adapter, - logger: LoggerService, - config: Config, -): Promise { - const catalogDBClient = Knex.knex({ client: MockClient }); - const rbacDBClient = Knex.knex({ client: MockClient }); - const enf = await newEnforcer(theModel, adapter); - - const rm = new BackstageRoleManager( - catalogMock, - logger, - catalogDBClient, - rbacDBClient, - config, - mockAuthService, - new DefaultPermissionsReader(config), - ); - enf.setRoleManager(rm); - enf.enableAutoBuildRoleLinks(false); - await enf.buildRoleLinks(); - - return enf; -} - -async function newEnforcerDelegate( - adapter: Adapter, - config: Config, - storedPolicies?: string[][], - storedGroupingPolicies?: string[][], -): Promise { - const theModel = newModelFromString(MODEL); - const logger = mockServices.logger.mock(); - - const enf = await createEnforcer(theModel, adapter, logger, config); - - if (storedPolicies) { - await enf.addPolicies(storedPolicies); - } - - if (storedGroupingPolicies) { - await enf.addGroupingPolicies(storedGroupingPolicies); - } - - return new EnforcerDelegate( - enf, - mockAuditorService, - conditionalStorageMock, - roleMetadataStorageMock, - mockClientKnex, - ); -} - -async function newPermissionPolicy( - config: Config, - enfDelegate: EnforcerDelegate, - roleMock?: RoleMetadataStorage, - defaultPolicies: RoleBasedPolicy[] = [], -): Promise { - const defaultRoleRef = defaultPolicies[0]?.entityReference; - if (defaultPolicies.length > 0) { - const casbinPolicies = defaultPolicies.map(p => [ - p.entityReference!, - p.permission!, - p.policy!, - p.effect!, - ]); - await enfDelegate.addPolicies(casbinPolicies); - const storage = roleMock || roleMetadataStorageMock; - if (defaultRoleRef) { - (storage.getCachedDefaultRoleMetadata as jest.Mock).mockReturnValue( - buildDefaultRoleMetadata(defaultRoleRef), - ); - } - } else { - const storage = roleMock || roleMetadataStorageMock; - (storage.getCachedDefaultRoleMetadata as jest.Mock).mockReturnValue( - undefined, - ); - } - - const logger = mockServices.logger.mock(); - const permissionPolicy = await RBACPermissionPolicy.build( - logger, - mockAuditorService, - config, - conditionalStorageMock, - enfDelegate, - roleMock || roleMetadataStorageMock, - mockClientKnex, - pluginMetadataCollectorMock as PluginPermissionMetadataCollector, - mockAuthService, - ); - clearAuditorMock(); - return permissionPolicy; -} - -describe('Default Role Tests', () => { - let enfDelegate: EnforcerDelegate; - let policy: RBACPermissionPolicy; - - const defaultRolePolicies: RoleBasedPolicy[] = [ - { - entityReference: 'role:default/viewer', - permission: 'catalog.entity.read', - policy: 'read', - effect: 'allow', - }, - { - entityReference: 'role:default/viewer', - permission: 'catalog-entity', - policy: 'read', - effect: 'allow', - }, - { - entityReference: 'role:default/viewer', - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ]; - - describe('when defaultRole is configured', () => { - beforeEach(async () => { - const config = newConfigWithDefaultRole('role:default/viewer'); - const adapter = await newAdapter(config); - enfDelegate = await newEnforcerDelegate(adapter, config); - - policy = await newPermissionPolicy( - config, - enfDelegate, - undefined, - defaultRolePolicies, - ); - }); - - it('should add default role to user roles when user has no explicit roles', async () => { - // Create a user with no explicit roles assigned - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/noroles'), - ); - - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/noroles', - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('should add default role to user roles when user has existing roles but not the default one', async () => { - // Add user to another role first - await enfDelegate.addGroupingPolicy( - ['user:default/hasrole', 'role:default/custom'], - { - source: 'rest', - roleEntityRef: 'role:default/custom', - modifiedBy: 'test', - }, - ); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/hasrole'), - ); - - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/hasrole', - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('should not duplicate default role when user already has it assigned explicitly', async () => { - // Add user to the default role explicitly - await enfDelegate.addGroupingPolicy( - ['user:default/alreadyhas', 'role:default/viewer'], - { - source: 'rest', - roleEntityRef: 'role:default/viewer', - modifiedBy: 'test', - }, - ); - - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/alreadyhas'), - ); - - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/alreadyhas', - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.ALLOW, - ); - }); - - it('should work with basic permissions when default role is applied', async () => { - // Add a basic permission for the default role - await enfDelegate.addPolicy([ - 'role:default/viewer', - 'catalog.entity.create', - 'use', - 'allow', - ]); - - const decision = await policy.handle( - newPolicyQueryWithBasicPermission('catalog.entity.create'), - newPolicyQueryUser('user:default/basictest'), - ); - - expect(decision.result).toBe(AuthorizeResult.ALLOW); - expectAuditorLogForPermission( - 'user:default/basictest', - 'catalog.entity.create', - undefined, - 'use', - AuthorizeResult.ALLOW, - ); - }); - }); - - describe('when defaultRole is not configured', () => { - beforeEach(async () => { - const config = newConfig(); // No default role - const adapter = await newAdapter(config); - enfDelegate = await newEnforcerDelegate(adapter, config); - policy = await newPermissionPolicy(config, enfDelegate); - }); - - it('should not add any default role when none is configured', async () => { - const decision = await policy.handle( - newPolicyQueryWithResourcePermission( - 'catalog.entity.read', - 'catalog-entity', - 'read', - ), - newPolicyQueryUser('user:default/nodefault'), - ); - - // Should deny since no permissions are granted and no default role - expect(decision.result).toBe(AuthorizeResult.DENY); - expectAuditorLogForPermission( - 'user:default/nodefault', - 'catalog.entity.read', - 'catalog-entity', - 'read', - AuthorizeResult.DENY, - ); - }); - }); -}); diff --git a/plugins/rbac-backend/src/policies/permission-policy.ts b/plugins/rbac-backend/src/policies/permission-policy.ts deleted file mode 100644 index c03b391336..0000000000 --- a/plugins/rbac-backend/src/policies/permission-policy.ts +++ /dev/null @@ -1,384 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - AuditorService, - AuditorServiceEvent, - AuthService, - BackstageUserInfo, - LoggerService, -} from '@backstage/backend-plugin-api'; -import type { ConfigApi } from '@backstage/core-plugin-api'; -import { - AuthorizeResult, - ConditionalPolicyDecision, - isResourcePermission, - PermissionCondition, - PermissionCriteria, - PermissionRuleParams, - PolicyDecision, - ResourcePermission, -} from '@backstage/plugin-permission-common'; -import type { - PermissionPolicy, - PolicyQuery, - PolicyQueryUser, -} from '@backstage/plugin-permission-node'; - -import type { Knex } from 'knex'; - -import { - NonEmptyArray, - toPermissionAction, -} from '@backstage-community/plugin-rbac-common'; - -import { - setAdminPermissions, - useAdminsFromConfig, -} from '../admin-permissions/admin-creation'; -import { createPermissionEvaluationAuditorEvent } from '../auditor/auditor'; -import { replaceAliases } from '../conditional-aliases/alias-resolver'; -import { ConditionalStorage } from '../database/conditional-storage'; -import { RoleMetadataStorage } from '../database/role-metadata'; -import { CSVFileWatcher } from '../file-permissions/csv-file-watcher'; -import { YamlConditinalPoliciesFileWatcher } from '../file-permissions/yaml-conditional-file-watcher'; -import { EnforcerDelegate } from '../service/enforcer-delegate'; -import { PluginPermissionMetadataCollector } from '../service/plugin-endpoints'; - -export class RBACPermissionPolicy implements PermissionPolicy { - private readonly superUserList?: string[]; - private readonly preferPermissionPolicy: boolean; - - public static async build( - logger: LoggerService, - auditor: AuditorService, - configApi: ConfigApi, - conditionalStorage: ConditionalStorage, - enforcerDelegate: EnforcerDelegate, - roleMetadataStorage: RoleMetadataStorage, - knex: Knex, - pluginMetadataCollector: PluginPermissionMetadataCollector, - auth: AuthService, - ): Promise { - const superUserList: string[] = []; - const adminUsers = configApi.getOptionalConfigArray( - 'permission.rbac.admin.users', - ); - - const superUsers = configApi.getOptionalConfigArray( - 'permission.rbac.admin.superUsers', - ); - - const policiesFile = configApi.getOptionalString( - 'permission.rbac.policies-csv-file', - ); - - const allowReload = - configApi.getOptionalBoolean('permission.rbac.policyFileReload') || false; - - const conditionalPoliciesFile = configApi.getOptionalString( - 'permission.rbac.conditionalPoliciesFile', - ); - - const preferPermissionPolicy = - (configApi.getOptionalString( - 'permission.rbac.policyDecisionPrecedence', - ) ?? 'conditional') === 'basic'; - - if (superUsers && superUsers.length > 0) { - for (const user of superUsers) { - const userName = user.getString('name'); - superUserList.push(userName); - } - } - - await useAdminsFromConfig( - adminUsers || [], - enforcerDelegate, - auditor, - roleMetadataStorage, - knex, - ); - await setAdminPermissions(enforcerDelegate, auditor); - - if ( - (!adminUsers || adminUsers.length === 0) && - (!superUsers || superUsers.length === 0) - ) { - logger.warn( - 'There are no admins or super admins configured for the RBAC-backend plugin.', - ); - } - - const csvFile = new CSVFileWatcher( - policiesFile, - allowReload, - logger, - enforcerDelegate, - roleMetadataStorage, - auditor, - ); - await csvFile.initialize(); - - const conditionalFile = new YamlConditinalPoliciesFileWatcher( - conditionalPoliciesFile, - allowReload, - logger, - conditionalStorage, - auditor, - auth, - pluginMetadataCollector, - roleMetadataStorage, - enforcerDelegate, - ); - await conditionalFile.initialize(); - - if (!conditionalPoliciesFile) { - // clean up conditional policies corresponding to roles from csv file - logger.info('conditional policies file feature was disabled'); - await conditionalFile.cleanUpConditionalPolicies(); - } - if (!policiesFile) { - // remove roles and policies from csv file - logger.info('csv policies file feature was disabled'); - await csvFile.cleanUpRolesAndPolicies(); - } - - return new RBACPermissionPolicy( - enforcerDelegate, - auditor, - conditionalStorage, - preferPermissionPolicy, - superUserList, - ); - } - - private constructor( - private readonly enforcer: EnforcerDelegate, - private readonly auditor: AuditorService, - private readonly conditionStorage: ConditionalStorage, - preferPermissionPolicy: boolean, - superUserList?: string[], - ) { - this.superUserList = superUserList; - this.preferPermissionPolicy = preferPermissionPolicy; - } - - async handle( - request: PolicyQuery, - user?: PolicyQueryUser, - ): Promise { - const userEntityRef = user?.info.userEntityRef ?? `user without entity`; - - const auditorEvent = await createPermissionEvaluationAuditorEvent( - this.auditor, - userEntityRef, - request, - ); - - try { - let status = false; - const action = toPermissionAction(request.permission.attributes); - - if (!user) { - await auditorEvent.success({ - meta: { result: AuthorizeResult.DENY }, - }); - return { result: AuthorizeResult.DENY }; - } - - if ( - this.superUserList!.includes(userEntityRef) || - user.info.ownershipEntityRefs.some(ref => - this.superUserList!.includes(ref), - ) - ) { - await auditorEvent.success({ - meta: { result: AuthorizeResult.ALLOW }, - }); - return { result: AuthorizeResult.ALLOW }; - } - - const permissionName = request.permission.name; - const roles = await this.enforcer.getRolesForUser(userEntityRef); - // handle permission with 'resource' type - const hasNamedPermission = await this.hasImplicitPermission( - permissionName, - action, - roles, - ); - - // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` - if ( - request.permission.name === 'policy.entity.create' && - !hasNamedPermission - ) { - request.permission = { - attributes: { action: 'create' }, - type: 'resource', - resourceType: 'policy-entity', - name: 'policy.entity.create', - }; - } - - if (isResourcePermission(request.permission)) { - const resourceType = request.permission.resourceType; - // Let's set up higher priority for permission specified by name, than by resource type - const obj = hasNamedPermission ? permissionName : resourceType; - // handle conditions if they are present - const conditionResult = await this.handleConditions( - auditorEvent, - userEntityRef, - request, - roles, - user.info, - ); - - if (this.preferPermissionPolicy) { - const hasResourcedPermission = await this.hasImplicitPermission( - resourceType, - action, - roles, - ); - // Permission policy first - if (hasNamedPermission || hasResourcedPermission) { - status = await this.isAuthorized(userEntityRef, obj, action, roles); - } else if (conditionResult) { - return conditionResult; - } - } else { - if (conditionResult) return conditionResult; - status = await this.isAuthorized(userEntityRef, obj, action, roles); - } - } else { - // handle permission with 'basic' type - status = await this.isAuthorized( - userEntityRef, - permissionName, - action, - roles, - ); - } - - const result = status ? AuthorizeResult.ALLOW : AuthorizeResult.DENY; - - await auditorEvent.success({ meta: { result } }); - return { result }; - } catch (error) { - await auditorEvent.fail({ - error, - meta: { result: AuthorizeResult.DENY }, - }); - return { result: AuthorizeResult.DENY }; - } - } - - private async hasImplicitPermission( - permissionName: string, - action: string, - roles: string[], - ): Promise { - for (const role of roles) { - const perms = await this.enforcer.getFilteredPolicy( - 0, - role, - permissionName, - action, - ); - if (perms.length > 0) { - return true; - } - } - - return false; - } - - private isAuthorized = async ( - userIdentity: string, - permission: string, - action: string, - roles: string[], - ): Promise => { - return await this.enforcer.enforce(userIdentity, permission, action, roles); - }; - - private async handleConditions( - auditorEvent: AuditorServiceEvent, - userEntityRef: string, - request: PolicyQuery, - roles: string[], - userInfo: BackstageUserInfo, - ): Promise { - const permissionName = request.permission.name; - const resourceType = (request.permission as ResourcePermission) - .resourceType; - const action = toPermissionAction(request.permission.attributes); - - const conditions: PermissionCriteria< - PermissionCondition - >[] = []; - let pluginId = ''; - for (const role of roles) { - const conditionalDecisions = await this.conditionStorage.filterConditions( - role, - undefined, - resourceType, - [action], - [permissionName], - ); - - if (conditionalDecisions.length === 1) { - pluginId = conditionalDecisions[0].pluginId; - conditions.push(conditionalDecisions[0].conditions); - } - - // this error is unexpected and should not happen, but just in case handle it. - if (conditionalDecisions.length > 1) { - await auditorEvent.fail({ - error: new Error( - `Detected ${JSON.stringify( - conditionalDecisions, - )} collisions for conditional policies. Expected to find a stored single condition for permission with name ${permissionName}, resource type ${resourceType}, action ${action} for user ${userEntityRef}`, - ), - meta: { result: AuthorizeResult.DENY }, - }); - return { - result: AuthorizeResult.DENY, - }; - } - } - - if (conditions.length > 0) { - const result: ConditionalPolicyDecision = { - pluginId, - result: AuthorizeResult.CONDITIONAL, - resourceType, - conditions: { - anyOf: conditions as NonEmptyArray< - PermissionCriteria< - PermissionCondition - > - >, - }, - }; - - replaceAliases(result.conditions, userInfo); - - await auditorEvent.success({ meta: { ...result } }); - return result; - } - return undefined; - } -} diff --git a/plugins/rbac-backend/src/providers/connect-providers.test.ts b/plugins/rbac-backend/src/providers/connect-providers.test.ts deleted file mode 100644 index 1967324a3d..0000000000 --- a/plugins/rbac-backend/src/providers/connect-providers.test.ts +++ /dev/null @@ -1,851 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { mockServices } from '@backstage/backend-test-utils'; - -import { - Adapter, - Enforcer, - Model, - newEnforcer, - newModelFromString, -} from 'casbin'; -import * as Knex from 'knex'; -import { MockClient } from 'knex-mock-client'; - -import type { - RBACProvider, - RBACProviderConnection, -} from '@backstage-community/plugin-rbac-node'; - -import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; -import { - RoleMetadataDao, - RoleMetadataStorage, -} from '../database/role-metadata'; -import { BackstageRoleManager } from '../role-manager/role-manager'; -import { DefaultPermissionsReader } from '../default-permissions/default-permissions'; -import { EnforcerDelegate } from '../service/enforcer-delegate'; -import { MODEL } from '../service/permission-model'; -import { Connection, connectRBACProviders } from './connect-providers'; -import { - catalogMock, - mockAuditorService, - createEventMock, -} from '../../__fixtures__/mock-utils'; -import { - clearAuditorMock, - expectAuditorLog, -} from '../../__fixtures__/auditor-test-utils'; -import { - ActionType, - ConditionEvents, - PermissionEvents, -} from '../auditor/auditor'; -import { - PermissionAction, - PermissionInfo, - RoleConditionalPolicyDecision, -} from '@backstage-community/plugin-rbac-common'; -import { ConditionalStorage } from '../database/conditional-storage'; -import { ConflictError } from '@backstage/errors'; - -const mockLoggerService = mockServices.logger.mock(); - -const roleMetadataStorageMock: RoleMetadataStorage = { - filterRoleMetadata: jest - .fn() - .mockImplementation( - async ( - _roleEntityRef: string, - _trx: Knex.Knex.Transaction, - ): Promise => { - return [ - { - roleEntityRef: 'role:default/old-provider-role', - source: 'test', - modifiedBy: 'test', - }, - { - roleEntityRef: 'role:default/existing-provider-role', - source: 'test', - modifiedBy: 'test', - }, - ]; - }, - ), - findRoleMetadata: jest - .fn() - .mockImplementation( - async ( - roleEntityRef: string, - _trx: Knex.Knex.Transaction, - ): Promise => { - if (roleEntityRef === 'role:default/old-provider-role') { - return { - roleEntityRef: 'role:default/old-provider-role', - source: 'test', - modifiedBy: 'test', - }; - } else if (roleEntityRef === 'role:default/existing-provider-role') { - return { - roleEntityRef: 'role:default/existing-provider-role', - source: 'test', - modifiedBy: 'test', - }; - } else if (roleEntityRef === 'role:default/csv-role') { - return { - roleEntityRef: 'role:default/csv-role', - source: 'csv-file', - modifiedBy: 'csv-file', - }; - } - return undefined; - }, - ), - filterForOwnerRoleMetadata: jest.fn().mockImplementation(), - createRoleMetadata: jest.fn().mockImplementation(), - updateRoleMetadata: jest.fn().mockImplementation(), - removeRoleMetadata: jest.fn().mockImplementation(), - getCachedDefaultRoleMetadata: jest.fn().mockImplementation(), - getDefaultRole: jest.fn().mockResolvedValue(undefined), - syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), -}; - -const mockAuthService = mockServices.auth(); - -const mockClientKnex = Knex.knex({ client: MockClient }); - -const providerMock: RBACProvider = { - getProviderName: jest.fn().mockImplementation(), - connect: jest.fn().mockImplementation(), - refresh: jest.fn().mockImplementation(), -}; - -const roleToBeRemoved = ['user:default/old', 'role:default/old-provider-role']; -const roleMetaToBeRemoved = { - modifiedBy: 'test', - source: 'test', - roleEntityRef: roleToBeRemoved[1], -}; - -const existingRoles = [ - ['user:default/bruce', 'role:default/existing-provider-role'], - ['user:default/tony', 'role:default/existing-provider-role'], -]; -const existingRoleMetadata = { - modifiedBy: 'test', - source: 'test', - roleEntityRef: existingRoles[0][1], -}; -const existingPolicy = [ - ['role:default/existing-provider-role', 'catalog-entity', 'read', 'allow'], -]; -const existingConditionalPermission: RoleConditionalPolicyDecision[] = - [ - { - id: 1, - result: 'CONDITIONAL', - roleEntityRef: 'role:default/existing', - pluginId: 'catalog', - resourceType: 'catalog-entity', - permissionMapping: [{ name: 'read', action: 'read' }], - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/existing-team'], - }, - }, - }, - ]; - -const conditionalStorageMock: ConditionalStorage = { - filterConditions: jest - .fn() - .mockImplementation(() => existingConditionalPermission), - createCondition: jest.fn().mockImplementation(), - checkConflictedConditions: jest - .fn() - .mockImplementation( - async ( - roleEntityRef: string, - _resourceType: string, - _pluginId: string, - _queryConditionActions: PermissionAction[], - _idToExclude: number, - _trx?: Knex.Knex.Transaction, - ) => { - if (roleEntityRef === 'role:default/conflicting-role') { - throw new ConflictError(`Found conditional permission conflict.`); - } - }, - ), - getCondition: jest.fn().mockImplementation(), - deleteCondition: jest.fn().mockImplementation(), - updateCondition: jest.fn().mockImplementation(), -}; - -const config = mockServices.rootConfig({ - data: { - permission: { - rbac: {}, - }, - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - }, -}); - -describe('Connection', () => { - let provider: Connection; - let enforcerDelegate: EnforcerDelegate; - - beforeEach(async () => { - const id = 'test'; - const adapter = await new CasbinDBAdapterFactory( - config, - mockClientKnex, - ).createAdapter(); - - const stringModel = newModelFromString(MODEL); - const enf = await createEnforcer(stringModel, adapter, mockLoggerService); - - const knex = Knex.knex({ client: MockClient }); - - enforcerDelegate = new EnforcerDelegate( - enf, - mockAuditorService, - conditionalStorageMock, - roleMetadataStorageMock, - knex, - ); - - await enforcerDelegate.addGroupingPolicy( - roleToBeRemoved, - roleMetaToBeRemoved, - ); - - await enforcerDelegate.addGroupingPolicies( - existingRoles, - existingRoleMetadata, - ); - - await enforcerDelegate.addPolicies(existingPolicy); - - for (const conditionalPermission of existingConditionalPermission) { - await conditionalStorageMock.createCondition(conditionalPermission); - } - - provider = new Connection( - id, - enforcerDelegate, - roleMetadataStorageMock, - conditionalStorageMock, - mockLoggerService, - mockAuditorService, - ); - - clearAuditorMock(); - }); - - it('should initialize', () => { - expect(provider).toBeDefined(); - }); - - describe('applyRoles', () => { - let enfAddGroupingPolicySpy: jest.SpyInstance< - Promise, - [ - policy: string[], - roleMetadata: RoleMetadataDao, - externalTrx?: Knex.Knex.Transaction | undefined, - ], - any - >; - let enfRemoveGroupingPolicySpy: jest.SpyInstance< - Promise, - [ - policy: string[], - roleMetadata: RoleMetadataDao, - isUpdate?: boolean | undefined, - externalTrx?: Knex.Knex.Transaction | undefined, - ], - any - >; - - afterEach(() => { - (mockLoggerService.warn as jest.Mock).mockReset(); - }); - - it('should add the new roles', async () => { - enfAddGroupingPolicySpy = jest.spyOn( - enforcerDelegate, - 'addGroupingPolicy', - ); - - const roles = [ - ['user:default/test', 'role:default/test-provider'], // to add - ['user:default/bruce', 'role:default/existing-provider-role'], - ['user:default/tony', 'role:default/existing-provider-role'], - ['user:default/Adam', 'role:default/test-provider'], // to add - ]; - - await provider.applyRoles(roles); - expect(enfAddGroupingPolicySpy).toHaveBeenNthCalledWith( - 1, - ['user:default/test', 'role:default/test-provider'], - expect.objectContaining({ - modifiedBy: 'test', - source: 'test', - roleEntityRef: 'role:default/test-provider', - }), - ); - expect(enfAddGroupingPolicySpy).toHaveBeenNthCalledWith( - 2, - ['user:default/adam', 'role:default/test-provider'], - expect.objectContaining({ - modifiedBy: 'test', - source: 'test', - roleEntityRef: 'role:default/test-provider', - }), - ); - }); - - it('should remove the old roles', async () => { - enfRemoveGroupingPolicySpy = jest.spyOn( - enforcerDelegate, - 'removeGroupingPolicy', - ); - - await provider.applyRoles([ - ['user:default/bruce', 'role:default/existing-provider-role'], - ['user:default/tony', 'role:default/existing-provider-role'], - ]); - expect(enfRemoveGroupingPolicySpy).toHaveBeenCalledWith( - roleToBeRemoved, - roleMetaToBeRemoved, - false, - ); - }); - - it('should add a role to an already existing role', async () => { - enfAddGroupingPolicySpy = jest.spyOn( - enforcerDelegate, - 'addGroupingPolicy', - ); - - const roles = [ - ['user:default/peter', 'role:default/existing-provider-role'], - ['user:default/bruce', 'role:default/existing-provider-role'], - ['user:default/tony', 'role:default/existing-provider-role'], - ]; - - const roleToAdd = [ - ['user:default/peter', 'role:default/existing-provider-role'], - ]; - const roleMeta = { - modifiedBy: 'test', - source: 'test', - roleEntityRef: roleToAdd[0][1], - }; - - await provider.applyRoles(roles); - expect(enfAddGroupingPolicySpy).toHaveBeenCalledWith( - ...roleToAdd, - roleMeta, - ); - }); - - it('should remove a role member from an already existing role', async () => { - enfRemoveGroupingPolicySpy = jest.spyOn( - enforcerDelegate, - 'removeGroupingPolicy', - ); - - await provider.applyRoles([ - ['user:default/tony', 'role:default/existing-provider-role'], - ]); - expect(enfRemoveGroupingPolicySpy).toHaveBeenNthCalledWith( - 1, - roleToBeRemoved, - roleMetaToBeRemoved, - false, - ); - expect(enfRemoveGroupingPolicySpy).toHaveBeenNthCalledWith( - 2, - existingRoles[0], - existingRoleMetadata, - true, - ); - }); - - it('should log an error if a role is not valid', async () => { - const roles = [ - ['user:default/test', 'role:default/'], - ['user:default/bruce', 'role:default/existing-provider-role'], - ['user:default/tony', 'role:default/existing-provider-role'], - ]; - - const roleToAdd = `user:default/test,role:default/`; - - await provider.applyRoles(roles); - expect(mockLoggerService.warn).toHaveBeenCalledWith( - `Failed to validate group policy ${roleToAdd}. Cause: Entity reference "role:default/" was not on the form [:][/]`, - ); - }); - - it('should still add new role, even if there is an invalid role in array', async () => { - enfAddGroupingPolicySpy = jest.spyOn( - enforcerDelegate, - 'addGroupingPolicy', - ); - - const roles = [ - ['user:default/test', 'role:default/'], - ['user:default/test', 'role:default/test-provider'], - ['user:default/bruce', 'role:default/existing-provider-role'], - ['user:default/tony', 'role:default/existing-provider-role'], - ]; - - const failingRoleToAdd = `user:default/test,role:default/`; - const roleToAdd = [['user:default/test', 'role:default/test-provider']]; - - await provider.applyRoles(roles); - expect(mockLoggerService.warn).toHaveBeenCalledWith( - `Failed to validate group policy ${failingRoleToAdd}. Cause: Entity reference "role:default/" was not on the form [:][/]`, - ); - // Verify the call was made with the correct role, but ignore timestamp fields - expect(enfAddGroupingPolicySpy).toHaveBeenCalledWith( - ...roleToAdd, - expect.objectContaining({ - modifiedBy: 'test', - source: 'test', - roleEntityRef: roleToAdd[0][1], - }), - ); - }); - }); - - describe('applyPermissions', () => { - let enfAddPolicySpy: jest.SpyInstance< - Promise, - [ - policy: string[], - externalTrx?: Knex.Knex.Transaction | undefined, - ], - any - >; - let enfRemovePolicySpy: jest.SpyInstance< - Promise, - [ - policy: string[], - externalTrx?: Knex.Knex.Transaction | undefined, - ], - any - >; - - afterEach(() => { - (mockLoggerService.warn as jest.Mock).mockReset(); - }); - - it('should add new permissions', async () => { - enfAddPolicySpy = jest.spyOn(enforcerDelegate, 'addPolicy'); - - const policies = [ - ['role:default/provider-role', 'catalog-entity', 'read', 'allow'], - ]; - - await provider.applyPermissions(policies); - expect(enfAddPolicySpy).toHaveBeenCalledWith(...policies); - }); - - // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` - it('should add new permissions but log warning about `policy-entity, create` permission', async () => { - enfAddPolicySpy = jest.spyOn(enforcerDelegate, 'addPolicy'); - - const policies = [ - ['role:default/provider-role', 'policy-entity', 'create', 'allow'], - ]; - - await provider.applyPermissions(policies); - expect(enfAddPolicySpy).toHaveBeenCalledWith(...policies); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 1, - `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${policies[0]} to use 'policy.entity.create' instead of 'policy-entity' from source test`, - ); - }); - - it('should remove old permissions', async () => { - enfRemovePolicySpy = jest.spyOn(enforcerDelegate, 'removePolicy'); - - const policies = [ - ['role:default/provider-role', 'catalog-entity', 'read', 'allow'], - ]; - - await provider.applyPermissions(policies); - expect(enfRemovePolicySpy).toHaveBeenCalledWith(...existingPolicy); - }); - - it('should audit log an error for an invalid permission', async () => { - enfAddPolicySpy = jest.spyOn(enforcerDelegate, 'addPolicy'); - - const policies = [ - ...existingPolicy, - ['role:default/provider-role', 'catalog-entity', 'read', 'temp'], - ]; - - await provider.applyPermissions(policies); - expectAuditorLog([ - { - event: { - eventId: PermissionEvents.POLICY_WRITE, - meta: { actionType: ActionType.CREATE, source: 'test' }, - }, - fail: { - error: new Error( - `'effect' has invalid value: 'temp'. It should be: 'allow' or 'deny'`, - ), - meta: { - policies: [policies[1]], - }, - }, - }, - ]); - }); - - it('should audit log an error for an invalid permission by source', async () => { - enfAddPolicySpy = jest.spyOn(enforcerDelegate, 'addPolicy'); - - const policies = [ - ...existingPolicy, - ['role:default/csv-role', 'catalog-entity', 'read', 'allow'], - ]; - - await provider.applyPermissions(policies); - expectAuditorLog([ - { - event: { - eventId: PermissionEvents.POLICY_WRITE, - meta: { actionType: ActionType.CREATE, source: 'test' }, - }, - fail: { - error: new Error( - `source does not match originating role role:default/csv-role, consider making changes to the 'CSV-FILE'`, - ), - meta: { - policies: [policies[1]], - }, - }, - }, - ]); - }); - - it('should still add new permission, even if there is an invalid permission in array', async () => { - enfAddPolicySpy = jest.spyOn(enforcerDelegate, 'addPolicy'); - - const policies = [ - ...existingPolicy, // Keep existing policy to avoid removal event - ['role:default/provider-role', 'catalog-entity', 'read', 'temp'], // invalid - ['role:default/provider-role', 'catalog-entity', 'create', 'allow'], // valid - ]; - - const invalidPolicy = policies[1]; - const validPolicyToAdd = [policies[2]]; - - await provider.applyPermissions(policies); - - // Verify that the invalid permission triggered a fail event - const failedEvents = createEventMock.fail.mock.calls; - const invalidPolicyFailed = failedEvents.some( - call => - call[0].error?.message === - `'effect' has invalid value: 'temp'. It should be: 'allow' or 'deny'` && - call[0].meta?.policies?.[0] === invalidPolicy, - ); - expect(invalidPolicyFailed).toBe(true); - - // Verify that the valid permission was still added despite the invalid one - expect(enfAddPolicySpy).toHaveBeenCalledWith(...validPolicyToAdd); - // Verify that only the valid policy was added (not the invalid one) - expect(enfAddPolicySpy).toHaveBeenCalledTimes(1); - - // Verify that a success event was also logged for the valid permission - const succeededEvents = createEventMock.success.mock.calls; - const validPolicySucceeded = succeededEvents.some( - call => call[0].meta?.policies?.[0] === validPolicyToAdd[0], - ); - expect(validPolicySucceeded).toBe(true); - }); - }); - - describe('applyConditionalPermissions', () => { - beforeEach(() => { - (conditionalStorageMock.createCondition as jest.Mock).mockReset(); - (conditionalStorageMock.deleteCondition as jest.Mock).mockReset(); - }); - - afterEach(() => { - (mockLoggerService.warn as jest.Mock).mockReset(); - (conditionalStorageMock.createCondition as jest.Mock).mockReset(); - (conditionalStorageMock.deleteCondition as jest.Mock).mockReset(); - }); - - it('should create conditional permissions', async () => { - const policies: RoleConditionalPolicyDecision[] = [ - { - id: 0, - result: 'CONDITIONAL', - roleEntityRef: 'role:default/test', - pluginId: 'catalog', - resourceType: 'catalog-entity', - permissionMapping: [{ name: 'read', action: 'read' }], - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/team-a'], - }, - }, - }, - ]; - await provider.applyConditionalPermissions(policies); - expect(conditionalStorageMock.createCondition).toHaveBeenCalledWith( - ...policies, - ); - }); - - it('should remove old conditional permissions', async () => { - const policies: RoleConditionalPolicyDecision[] = [ - { - id: 0, - result: 'CONDITIONAL', - roleEntityRef: 'role:default/test', - pluginId: 'catalog', - resourceType: 'catalog-entity', - permissionMapping: [{ name: 'read', action: 'read' }], - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/team-a'], - }, - }, - }, - ]; - - await provider.applyConditionalPermissions(policies); - expect(conditionalStorageMock.deleteCondition).toHaveBeenCalledWith( - ...existingConditionalPermission.map(it => it.id), - ); - }); - - it('should not add policies that exist already, if not changed', async () => { - const policies: RoleConditionalPolicyDecision[] = [ - ...existingConditionalPermission, - ]; - - await provider.applyConditionalPermissions(policies); - expect(conditionalStorageMock.createCondition).not.toHaveBeenCalled(); - }); - - it('should not remove existing policies if not changed', async () => { - const policies: RoleConditionalPolicyDecision[] = [ - { - id: 0, - result: 'CONDITIONAL', - roleEntityRef: 'role:default/test', - pluginId: 'catalog', - resourceType: 'catalog-entity', - permissionMapping: [{ name: 'read', action: 'read' }], - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/team-a'], - }, - }, - }, - ...existingConditionalPermission, - ]; - - await provider.applyConditionalPermissions(policies); - expect(conditionalStorageMock.deleteCondition).toHaveBeenCalledTimes(0); - }); - - it('should replace changed policies', async () => { - const policies: RoleConditionalPolicyDecision[] = [ - { - id: existingConditionalPermission[0].id, - result: existingConditionalPermission[0].result, - roleEntityRef: existingConditionalPermission[0].roleEntityRef, - pluginId: existingConditionalPermission[0].pluginId, - resourceType: existingConditionalPermission[0].resourceType, - permissionMapping: [ - { name: 'read', action: 'read' }, - { name: 'delete', action: 'delete' }, - ], - conditions: existingConditionalPermission[0].conditions, - }, - ]; - - await provider.applyConditionalPermissions(policies); - expect(conditionalStorageMock.deleteCondition).toHaveBeenCalledTimes(1); - expect(conditionalStorageMock.createCondition).toHaveBeenCalledTimes(1); - }); - it('should replace permissions with changed condition', async () => { - const policies: RoleConditionalPolicyDecision[] = [ - { - id: existingConditionalPermission[0].id, - result: existingConditionalPermission[0].result, - roleEntityRef: existingConditionalPermission[0].roleEntityRef, - pluginId: existingConditionalPermission[0].pluginId, - resourceType: existingConditionalPermission[0].resourceType, - permissionMapping: existingConditionalPermission[0].permissionMapping, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: [ - 'group:default/existing-team', - 'group:default/one-more-team', - ], - }, - }, - }, - ]; - - await provider.applyConditionalPermissions(policies); - expect(conditionalStorageMock.deleteCondition).toHaveBeenCalledTimes(1); - expect(conditionalStorageMock.createCondition).toHaveBeenCalledTimes(1); - }); - it('should reject policies from an invalid source', async () => { - const anotherProvider = new Connection( - 'another-provider', - enforcerDelegate, - roleMetadataStorageMock, - conditionalStorageMock, - mockLoggerService, - mockAuditorService, - ); - - const policies: RoleConditionalPolicyDecision[] = [ - { - id: 0, - result: 'CONDITIONAL', - roleEntityRef: 'role:default/existing-provider-role', - pluginId: 'catalog', - resourceType: 'catalog-entity', - permissionMapping: [{ name: 'read', action: 'read' }], - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/team-a'], - }, - }, - }, - ...existingConditionalPermission, - ]; - - await anotherProvider.applyConditionalPermissions(policies); - expectAuditorLog([ - { - event: { - eventId: ConditionEvents.CONDITION_WRITE, - meta: { actionType: ActionType.CREATE, source: 'another-provider' }, - }, - fail: { - error: new Error( - `source does not match originating role role:default/existing-provider-role, consider making changes to the 'TEST'`, - ), - meta: { - policies: [policies[0]], - }, - }, - }, - ]); - }); - }); -}); - -describe('connectRBACProviders', () => { - let connectSpy: jest.SpyInstance< - Promise, - [connection: RBACProviderConnection], - any - >; - it('should initialize rbac providers', async () => { - connectSpy = jest.spyOn(providerMock, 'connect'); - - const adapter = await new CasbinDBAdapterFactory( - config, - mockClientKnex, - ).createAdapter(); - - const stringModel = newModelFromString(MODEL); - const enf = await createEnforcer(stringModel, adapter, mockLoggerService); - - const knex = Knex.knex({ client: MockClient }); - - const enforcerDelegate = new EnforcerDelegate( - enf, - mockAuditorService, - conditionalStorageMock, - roleMetadataStorageMock, - knex, - ); - - await connectRBACProviders( - [providerMock], - enforcerDelegate, - roleMetadataStorageMock, - conditionalStorageMock, - mockLoggerService, - mockAuditorService, - ); - - expect(connectSpy).toHaveBeenCalled(); - }); -}); - -async function createEnforcer( - theModel: Model, - adapter: Adapter, - logger: LoggerService, -): Promise { - const catalogDBClient = Knex.knex({ client: MockClient }); - const rbacDBClient = Knex.knex({ client: MockClient }); - const enf = await newEnforcer(theModel, adapter); - - const rm = new BackstageRoleManager( - catalogMock, - logger, - catalogDBClient, - rbacDBClient, - config, - mockAuthService, - new DefaultPermissionsReader(config), - ); - enf.setRoleManager(rm); - enf.enableAutoBuildRoleLinks(false); - await enf.buildRoleLinks(); - - return enf; -} diff --git a/plugins/rbac-backend/src/providers/connect-providers.ts b/plugins/rbac-backend/src/providers/connect-providers.ts deleted file mode 100644 index 52aa1b3b76..0000000000 --- a/plugins/rbac-backend/src/providers/connect-providers.ts +++ /dev/null @@ -1,438 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - AuditorService, - LoggerService, -} from '@backstage/backend-plugin-api'; - -import { - Enforcer, - newEnforcer, - newModelFromString, - StringAdapter, -} from 'casbin'; - -import type { - RBACProvider, - RBACProviderConnection, -} from '@backstage-community/plugin-rbac-node'; - -import { - ActionType, - ConditionEvents, - PermissionEvents, - RoleEvents, -} from '../auditor/auditor'; -import { RoleMetadataStorage } from '../database/role-metadata'; -import { - transformArrayToPolicy, - transformRolesGroupToLowercase, - typedPoliciesToString, -} from '../helper'; -import { EnforcerDelegate } from '../service/enforcer-delegate'; -import { MODEL } from '../service/permission-model'; -import { - validateGroupingPolicy, - validatePolicy, - validateSource, -} from '../validation/policies-validation'; -import { ConditionalStorage } from '../database/conditional-storage'; -import { - PermissionInfo, - RoleConditionalPolicyDecision, -} from '@backstage-community/plugin-rbac-common'; -import { isEqual } from 'lodash'; - -export class Connection implements RBACProviderConnection { - constructor( - private readonly id: string, - private readonly enforcer: EnforcerDelegate, - private readonly roleMetadataStorage: RoleMetadataStorage, - private readonly conditionStorage: ConditionalStorage, - private readonly logger: LoggerService, - private readonly auditor: AuditorService, - ) {} - - async applyRoles(roles: string[][]): Promise { - const lowercasedRoles = transformRolesGroupToLowercase(roles); - const stringPolicy = typedPoliciesToString(lowercasedRoles, 'g'); - const providerRolesforRemoval: string[][] = []; - - const tempEnforcer = await newEnforcer( - newModelFromString(MODEL), - new StringAdapter(stringPolicy), - ); - - const providerRoles = await this.getProviderRoles(); - - await this.enforcer.loadPolicy(); - // Get the roles for this provider coming from rbac plugin - for (const providerRole of providerRoles) { - providerRolesforRemoval.push( - ...(await this.enforcer.getFilteredGroupingPolicy(1, providerRole)), - ); - } - - // Remove role - // role exists in rbac but does not exist in provider - await this.removeRoles(providerRolesforRemoval, tempEnforcer); - - // Add the role - // role exists in provider but does not exist in rbac - await this.addRoles(lowercasedRoles); - } - - async applyPermissions(permissions: string[][]): Promise { - const stringPolicy = typedPoliciesToString(permissions, 'p'); - - const providerPermissions: string[][] = []; - - const tempEnforcer = await newEnforcer( - newModelFromString(MODEL), - new StringAdapter(stringPolicy), - ); - - const providerRoles = await this.getProviderRoles(); - - await this.enforcer.loadPolicy(); - // Get the roles for this provider coming from rbac plugin - for (const providerRole of providerRoles) { - providerPermissions.push( - ...(await this.enforcer.getFilteredPolicy(0, providerRole)), - ); - } - - await this.removePermissions(providerPermissions, tempEnforcer); - - await this.addPermissions(permissions); - } - - async applyConditionalPermissions( - conditionalPermissions: RoleConditionalPolicyDecision[], - ): Promise { - const storedConditionalPermissions = - await this.conditionStorage.filterConditions(); - - const conditionsToBeAdded: RoleConditionalPolicyDecision[] = - conditionalPermissions.filter( - conditionalPermission => - !storedConditionalPermissions.some( - stored => - conditionalPermission.roleEntityRef === stored.roleEntityRef && - conditionalPermission.pluginId === stored.pluginId && - conditionalPermission.resourceType === stored.resourceType && - isEqual( - conditionalPermission.permissionMapping, - stored.permissionMapping, - ) && - isEqual(conditionalPermission.conditions, stored.conditions), - ), - ); - - // Updated policies fails the 'some' check due to permissionMapping differences - const conditionsToBeRemoved: RoleConditionalPolicyDecision[] = - storedConditionalPermissions.filter( - stored => - !conditionalPermissions.some( - conditionalPermission => - stored.roleEntityRef === conditionalPermission.roleEntityRef && - stored.pluginId === conditionalPermission.pluginId && - stored.resourceType === conditionalPermission.resourceType && - isEqual( - stored.permissionMapping, - conditionalPermission.permissionMapping, - ) && - isEqual(stored.conditions, conditionalPermission.conditions), - ), - ); - - await this.removeConditionalPermissions(conditionsToBeRemoved); - - await this.addConditionalPermissions(conditionsToBeAdded); - } - - private async addRoles(roles: string[][]): Promise { - for (const role of roles) { - if (!(await this.enforcer.hasGroupingPolicy(...role))) { - const metadata = await this.roleMetadataStorage.findRoleMetadata( - role[1], - ); - const err = await validateGroupingPolicy(role, metadata, this.id); - - if (err) { - this.logger.warn(err.message); - continue; // Skip adding this role as there was an error - } - - let roleMeta = await this.roleMetadataStorage.findRoleMetadata(role[1]); - // role does not exist in rbac, create the metadata for it - if (!roleMeta) { - roleMeta = { - modifiedBy: this.id, - source: this.id, - roleEntityRef: role[1], - }; - } - - const auditorMeta = { - ...roleMeta, - members: [role[0]], - }; - const auditorEvent = await this.auditor.createEvent({ - eventId: RoleEvents.ROLE_WRITE, - severityLevel: 'medium', - meta: { - actionType: roleMeta ? ActionType.UPDATE : ActionType.CREATE, - source: auditorMeta.source, - }, - }); - - try { - await this.enforcer.addGroupingPolicy(role, roleMeta); - await auditorEvent.success({ meta: auditorMeta }); - } catch (error) { - await auditorEvent.fail({ - error, - meta: auditorMeta, - }); - } - } - } - } - - private async removeRoles( - providerRoles: string[][], - tempEnforcer: Enforcer, - ): Promise { - // Remove role - // role exists in rbac but does not exist in provider - const lowercasedProviderRoles = - transformRolesGroupToLowercase(providerRoles); - for (const role of lowercasedProviderRoles) { - if (!(await tempEnforcer.hasGroupingPolicy(...role))) { - const roleMeta = await this.roleMetadataStorage.findRoleMetadata( - role[1], - ); - - const currentRole = await this.enforcer.getFilteredGroupingPolicy( - 1, - role[1], - ); - - if (!roleMeta) { - this.logger.warn('role does not exist'); - continue; - } - - const singleRole = roleMeta && currentRole.length === 1; - const actionType = singleRole ? ActionType.DELETE : ActionType.UPDATE; - - const auditorMeta = { ...roleMeta, members: [role[0]] }; - const auditorEvent = await this.auditor.createEvent({ - eventId: RoleEvents.ROLE_WRITE, - severityLevel: 'medium', - meta: { actionType, source: roleMeta.source }, - }); - - try { - await this.enforcer.removeGroupingPolicy( - role, - roleMeta, - actionType === ActionType.UPDATE, - ); - await auditorEvent.success({ meta: auditorMeta }); - } catch (error) { - await auditorEvent.fail({ - error, - meta: auditorMeta, - }); - } - } - } - } - - private async addPermissions(permissions: string[][]): Promise { - for (const permission of permissions) { - // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` - if (permission[1] === 'policy-entity' && permission[2] === 'create') { - this.logger.warn( - `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${permission} to use 'policy.entity.create' instead of 'policy-entity' from source ${this.id}`, - ); - } - - if (!(await this.enforcer.hasPolicy(...permission))) { - const transformedPolicy = transformArrayToPolicy(permission); - const metadata = await this.roleMetadataStorage.findRoleMetadata( - permission[0], - ); - - const auditorMeta = { - policies: [permission], - }; - const auditorEvent = await this.auditor.createEvent({ - eventId: PermissionEvents.POLICY_WRITE, - severityLevel: 'medium', - meta: { actionType: ActionType.CREATE, source: this.id }, - }); - - let err = validatePolicy(transformedPolicy); - if (err) { - auditorEvent.fail({ error: err, meta: auditorMeta }); - continue; // Skip this invalid permission policy - } - - err = await validateSource(this.id, metadata); - if (err) { - auditorEvent.fail({ error: err, meta: auditorMeta }); - continue; - } - - try { - await this.enforcer.addPolicy(permission); - await auditorEvent.success({ meta: auditorMeta }); - } catch (error) { - await auditorEvent.fail({ error, meta: auditorMeta }); - } - } - } - } - - private async removePermissions( - providerPermissions: string[][], - tempEnforcer: Enforcer, - ): Promise { - for (const permission of providerPermissions) { - if (!(await tempEnforcer.hasPolicy(...permission))) { - const auditorMeta = { - policies: [permission], - }; - const auditorEvent = await this.auditor?.createEvent({ - eventId: PermissionEvents.POLICY_WRITE, - severityLevel: 'medium', - meta: { actionType: ActionType.DELETE, source: this.id }, - }); - - try { - await this.enforcer.removePolicy(permission); - await auditorEvent.success({ meta: auditorMeta }); - } catch (error) { - await auditorEvent.fail({ - error, - meta: auditorMeta, - }); - } - } - } - } - - private async addConditionalPermissions( - conditionalPermissions: RoleConditionalPolicyDecision[], - ): Promise { - for (const condition of conditionalPermissions) { - const auditorMeta = { - policies: [condition], - }; - const auditorEvent = await this.auditor.createEvent({ - eventId: ConditionEvents.CONDITION_WRITE, - severityLevel: 'medium', - meta: { - actionType: ActionType.CREATE, - source: this.id, - }, - }); - try { - const metadata = await this.roleMetadataStorage.findRoleMetadata( - condition.roleEntityRef, - ); - const err = await validateSource(this.id, metadata); - if (err) { - throw err; - } - await this.conditionStorage.createCondition(condition); - await auditorEvent.success({ meta: auditorMeta }); - } catch (error) { - await auditorEvent.fail({ error, meta: auditorMeta }); - } - } - } - - private async removeConditionalPermissions( - conditionalPermissions: RoleConditionalPolicyDecision[], - ): Promise { - for (const conditionalPermission of conditionalPermissions) { - const auditorMeta = { - policies: [conditionalPermission], - }; - const auditorEvent = await this.auditor.createEvent({ - eventId: ConditionEvents.CONDITION_WRITE, - severityLevel: 'medium', - meta: { actionType: ActionType.DELETE, source: this.id }, - }); - try { - const metadata = await this.roleMetadataStorage.findRoleMetadata( - conditionalPermission.roleEntityRef, - ); - const err = await validateSource(this.id, metadata); - if (err) { - throw err; - } - await this.conditionStorage.deleteCondition(conditionalPermission.id); - await auditorEvent.success({ meta: auditorMeta }); - } catch (error) { - await auditorEvent.fail({ - error, - meta: auditorMeta, - }); - } - } - } - - private async getProviderRoles(): Promise { - const currentRoles = await this.roleMetadataStorage.filterRoleMetadata( - this.id, - ); - return currentRoles.map(meta => meta.roleEntityRef); - } -} - -export async function connectRBACProviders( - providers: RBACProvider[], - enforcer: EnforcerDelegate, - roleMetadataStorage: RoleMetadataStorage, - conditionStorage: ConditionalStorage, - logger: LoggerService, - auditor: AuditorService, -) { - await Promise.all( - providers.map(async provider => { - try { - const connection = new Connection( - provider.getProviderName(), - enforcer, - roleMetadataStorage, - conditionStorage, - logger, - auditor, - ); - return provider.connect(connection); - } catch (error) { - throw new Error( - `Unable to connect provider ${provider.getProviderName()}, ${error}`, - ); - } - }), - ); -} diff --git a/plugins/rbac-backend/src/role-manager/ancestor-search-factory.ts b/plugins/rbac-backend/src/role-manager/ancestor-search-factory.ts deleted file mode 100644 index 7b6489ea18..0000000000 --- a/plugins/rbac-backend/src/role-manager/ancestor-search-factory.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Knex } from 'knex'; -import { AncestorSearchMemo, ASMGroup } from './ancestor-search-memo'; -import { AncestorSearchMemoPG } from './ancestor-search-memo-pg'; -import { AncestorSearchMemoSQLite } from './ancestor-search-memo-sqlite'; -import type { AuthService } from '@backstage/backend-plugin-api'; -import type { CatalogApi } from '@backstage/catalog-client'; -import type { Config } from '@backstage/config'; - -export class AncestorSearchFactory { - static async createAncestorSearchMemo( - userEntityRef: string, - config: Config, - catalogAPI: CatalogApi, - catalogDBClient: Knex, - authService: AuthService, - maxDepth?: number, - ): Promise> { - const databaseConfig = config.getOptionalConfig('backend.database'); - const client = databaseConfig?.getOptionalString('client'); - - if (client === 'pg') { - return new AncestorSearchMemoPG(userEntityRef, catalogDBClient, maxDepth); - } - - if (client === 'better-sqlite3') { - return new AncestorSearchMemoSQLite( - userEntityRef, - catalogAPI, - authService, - maxDepth, - ); - } - - throw new Error(`Unsupported database: ${client}`); - } -} diff --git a/plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.test.ts b/plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.test.ts deleted file mode 100644 index 544d6fd262..0000000000 --- a/plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as Knex from 'knex'; -import { createTracker, MockClient, Tracker } from 'knex-mock-client'; - -import { AncestorSearchMemoPG } from './ancestor-search-memo-pg'; -import { Relation } from './ancestor-search-memo'; - -describe('ancestor-search-memo', () => { - const userRelations = [ - { - source_entity_ref: 'user:default/adam', - target_entity_ref: 'group:default/team-a', - }, - ]; - - const allRelations = [ - { - source_entity_ref: 'user:default/adam', - target_entity_ref: 'group:default/team-a', - }, - { - source_entity_ref: 'group:default/team-a', - target_entity_ref: 'group:default/team-b', - }, - { - source_entity_ref: 'group:default/team-b', - target_entity_ref: 'group:default/team-c', - }, - { - source_entity_ref: 'user:default/george', - target_entity_ref: 'group:default/team-d', - }, - { - source_entity_ref: 'group:default/team-d', - target_entity_ref: 'group:default/team-e', - }, - { - source_entity_ref: 'group:default/team-e', - target_entity_ref: 'group:default/team-f', - }, - ]; - - const catalogDBClient = Knex.knex({ client: MockClient }); - - let asm: AncestorSearchMemoPG; - - beforeEach(() => { - asm = new AncestorSearchMemoPG('user:default/adam', catalogDBClient); - }); - - describe('getAllGroups and getAllRelations', () => { - let tracker: Tracker; - - beforeAll(() => { - tracker = createTracker(catalogDBClient); - }); - - afterEach(() => { - tracker.reset(); - }); - - it('should return all relations', async () => { - tracker.on - .select( - /select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/, - ) - .response(allRelations); - const allRelationsTest = await asm.getAllASMGroups(); - expect(allRelationsTest).toEqual(allRelations); - }); - - it('should fail to return anything when there is an error getting all relations', async () => { - const allRelationsTest = await asm.getAllASMGroups(); - expect(allRelationsTest).toEqual([]); - }); - }); - - describe('getUserGroups and getUserRelations', () => { - let tracker: Tracker; - - beforeAll(() => { - tracker = createTracker(catalogDBClient); - }); - - afterEach(() => { - tracker.reset(); - }); - - it('should return all user relations', async () => { - tracker.on - .select( - /select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/, - ) - .response(userRelations); - const relations = await asm.getUserASMGroups(); - - expect(relations).toEqual(userRelations); - }); - - it('should fail to return anything when there is an error getting user relations', async () => { - const relations = await asm.getUserASMGroups(); - - expect(relations).toEqual([]); - }); - }); - - describe('traverseRelations', () => { - let tracker: Tracker; - - beforeAll(() => { - tracker = createTracker(catalogDBClient); - }); - - afterEach(() => { - tracker.reset(); - }); - - // user:default/adam -> group:default/team-a -> group:default/team-b -> group:default/team-c - it('should build a graph for a particular user', async () => { - tracker.on - .select( - /select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/, - ) - .response(userRelations); - const userRelationsTest = await asm.getUserASMGroups(); - - tracker.reset(); - tracker.on - .select( - /select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/, - ) - .response(allRelations); - const allRelationsTest = await asm.getAllASMGroups(); - - userRelationsTest.forEach(relation => - asm.traverse(relation as Relation, allRelationsTest as Relation[], 0), - ); - - expect(asm.hasEntityRef('user:default/adam')).toBeTruthy(); - expect(asm.hasEntityRef('group:default/team-a')).toBeTruthy(); - expect(asm.hasEntityRef('group:default/team-b')).toBeTruthy(); - expect(asm.hasEntityRef('group:default/team-c')).toBeTruthy(); - expect(asm.hasEntityRef('group:default/team-d')).toBeFalsy(); - }); - - // maxDepth of one stops here - // | - // user:default/adam -> group:default/team-a -> group:default/team-b -> group:default/team-c - it('should build the graph but stop based on the maxDepth', async () => { - const asmMaxDepth = new AncestorSearchMemoPG( - 'user:default/adam', - catalogDBClient, - 1, - ); - - tracker.on - .select( - /select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/, - ) - .response(userRelations); - const userRelationsTest = await asmMaxDepth.getUserASMGroups(); - - tracker.reset(); - tracker.on - .select( - /select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/, - ) - .response(allRelations); - const allRelationsTest = await asmMaxDepth.getAllASMGroups(); - - userRelationsTest.forEach(relation => - asmMaxDepth.traverse( - relation as Relation, - allRelationsTest as Relation[], - 0, - ), - ); - - expect(asmMaxDepth.hasEntityRef('user:default/adam')).toBeTruthy(); - expect(asmMaxDepth.hasEntityRef('group:default/team-a')).toBeTruthy(); - expect(asmMaxDepth.hasEntityRef('group:default/team-b')).toBeTruthy(); - expect(asmMaxDepth.hasEntityRef('group:default/team-c')).toBeFalsy(); - expect(asmMaxDepth.hasEntityRef('group:default/team-d')).toBeFalsy(); - }); - }); - - describe('buildUserGraph', () => { - let tracker: Tracker; - - const asmUserGraph = new AncestorSearchMemoPG( - 'user:default/adam', - catalogDBClient, - ); - - const userRelationsSpy = jest - .spyOn(asmUserGraph, 'getUserASMGroups') - .mockImplementation(() => Promise.resolve(userRelations)); - const allRelationsSpy = jest - .spyOn(asmUserGraph, 'getAllASMGroups') - .mockImplementation(() => Promise.resolve(allRelations)); - - beforeAll(() => { - tracker = createTracker(catalogDBClient); - }); - - afterEach(() => { - tracker.reset(); - }); - - // user:default/adam -> group:default/team-a -> group:default/team-b -> group:default/team-c - it('should build the user graph using relations table', async () => { - await asmUserGraph.buildUserGraph(); - - expect(userRelationsSpy).toHaveBeenCalled(); - expect(allRelationsSpy).toHaveBeenCalled(); - expect(asmUserGraph.hasEntityRef('user:default/adam')).toBeTruthy(); - expect(asmUserGraph.hasEntityRef('group:default/team-a')).toBeTruthy(); - expect(asmUserGraph.hasEntityRef('group:default/team-b')).toBeTruthy(); - expect(asmUserGraph.hasEntityRef('group:default/team-c')).toBeTruthy(); - expect(asmUserGraph.hasEntityRef('group:default/team-d')).toBeFalsy(); - }); - }); -}); diff --git a/plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.ts b/plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.ts deleted file mode 100644 index 59de0e2e09..0000000000 --- a/plugins/rbac-backend/src/role-manager/ancestor-search-memo-pg.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Knex } from 'knex'; -import { AncestorSearchMemo, Relation } from './ancestor-search-memo'; - -export class AncestorSearchMemoPG extends AncestorSearchMemo { - constructor( - private readonly userEntityRef: string, - private readonly catalogDBClient: Knex, - private readonly maxDepth?: number, - ) { - super(); - } - - async getAllASMGroups(): Promise { - try { - const rows = await this.catalogDBClient('relations') - .select('source_entity_ref', 'target_entity_ref') - .where('type', 'childOf'); - return rows; - } catch (error) { - return []; - } - } - - async getUserASMGroups(): Promise { - try { - const rows = await this.catalogDBClient('relations') - .select('source_entity_ref', 'target_entity_ref') - .where({ type: 'memberOf', source_entity_ref: this.userEntityRef }); - return rows; - } catch (error) { - return []; - } - } - - traverse( - relation: Relation, - allRelations: Relation[], - current_depth: number, - ) { - // We add one to the maxDepth here because the user is considered the starting node - if (this.maxDepth !== undefined && current_depth >= this.maxDepth + 1) { - return; - } - const depth = current_depth + 1; - - if (!super.hasEntityRef(relation.source_entity_ref)) { - super.setNode(relation.source_entity_ref); - } - - super.setEdge(relation.target_entity_ref, relation.source_entity_ref); - - const parentGroup = allRelations.find( - g => g.source_entity_ref === relation.target_entity_ref, - ); - - if (parentGroup && super.isAcyclic()) { - this.traverse(parentGroup, allRelations, depth); - } - } - - async buildUserGraph() { - const userRelations = await this.getUserASMGroups(); - const allRelations = await this.getAllASMGroups(); - userRelations.forEach(group => - this.traverse(group as Relation, allRelations as Relation[], 0), - ); - } -} diff --git a/plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.test.ts b/plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.test.ts deleted file mode 100644 index 2156ba7958..0000000000 --- a/plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { mockServices } from '@backstage/backend-test-utils'; -import type { GroupEntity } from '@backstage/catalog-model'; - -import { AncestorSearchMemoSQLite } from './ancestor-search-memo-sqlite'; -import { catalogMock, testGroups } from '../../__fixtures__/mock-utils'; -import { convertGroupsToEntity } from '../../__fixtures__/test-utils'; - -const mockAuthService = mockServices.auth(); - -describe('ancestor-search-memo', () => { - const testUserGroups = convertGroupsToEntity([ - { - name: 'team-hr', - namespace: null, - title: 'HR Group', - children: [], - parent: 'team-management', - hasMember: ['user:default/sally'], - }, - ]); - - let asm: AncestorSearchMemoSQLite; - - beforeEach(() => { - asm = new AncestorSearchMemoSQLite( - 'user:default/sally', - catalogMock, - mockAuthService, - ); - }); - - describe('getAllGroups and getAllRelations', () => { - it('should return all groups', async () => { - const allGroupsTest = await asm.getAllASMGroups(); - // The map function aligns the entities with the `fields` definition - // used in `getAllASMGroups` for the `catalogApi.getEntities` call. - expect(allGroupsTest).toEqual( - testGroups.map(entity => ({ - kind: entity.kind, - metadata: { - name: entity.metadata.name, - namespace: entity.metadata.namespace, - }, - spec: { - parent: entity.spec?.parent, - }, - })), - ); - }); - }); - - describe('getUserGroups and getUserRelations', () => { - it('should return all user groups', async () => { - const userGroups = await asm.getUserASMGroups(); - // The map function aligns the entities with the `fields` definition - // used in `getUserASMGroups` for the `catalogApi.getEntities` call. - expect(userGroups).toEqual( - testUserGroups.map(entity => ({ - kind: entity.kind, - metadata: { - name: entity.metadata.name, - namespace: entity.metadata.namespace, - }, - spec: { - parent: entity.spec?.parent, - }, - })), - ); - }); - }); - - describe('traverseGroups', () => { - // user:default/sally - // |- group:default/team-hr - // |- group:default/team-management - // |- group:hq/team-management - // |- group:hq/team-administration - // |- group:default/root-group - it('should build a graph for a particular user', async () => { - const userGroupsTest = await asm.getUserASMGroups(); - - const allGroupsTest = await asm.getAllASMGroups(); - - userGroupsTest.forEach(group => - asm.traverse(group as GroupEntity, allGroupsTest as GroupEntity[], 0), - ); - - expect(asm.hasEntityRef('group:default/team-hr')).toBeTruthy(); - expect(asm.hasEntityRef('group:default/team-management')).toBeTruthy(); - expect(asm.hasEntityRef('group:hq/team-management')).toBeTruthy(); - expect(asm.hasEntityRef('group:hq/team-administration')).toBeTruthy(); - expect(asm.hasEntityRef('group:default/root-group')).toBeTruthy(); - expect(asm.hasEntityRef('group:default/team-b')).toBeFalsy(); - }); - - // maxDepth of one - // - // user:default/sally - // |- group:default/team-hr - // |- group:default/team-management <- stops here - // |- group:hq/team-management - // |- group:hq/team-administration - // |- group:default/root-group - it('should build the graph but stop based on the maxDepth', async () => { - const asmMaxDepth = new AncestorSearchMemoSQLite( - 'user:default/sally', - catalogMock, - mockAuthService, - 1, - ); - - const userGroupsTest = await asmMaxDepth.getUserASMGroups(); - - const allGroupsTest = await asmMaxDepth.getAllASMGroups(); - - userGroupsTest.forEach(group => - asmMaxDepth.traverse( - group as GroupEntity, - allGroupsTest as GroupEntity[], - 0, - ), - ); - - expect(asmMaxDepth.hasEntityRef('group:default/team-hr')).toBeTruthy(); - expect( - asmMaxDepth.hasEntityRef('group:default/team-management'), - ).toBeTruthy(); - expect(asmMaxDepth.hasEntityRef('group:hq/team-management')).toBeFalsy(); - expect( - asmMaxDepth.hasEntityRef('group:hq/team-administration'), - ).toBeFalsy(); - expect(asmMaxDepth.hasEntityRef('group:default/root-group')).toBeFalsy(); - expect(asmMaxDepth.hasEntityRef('group:default/team-b')).toBeFalsy(); - }); - }); -}); diff --git a/plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.ts b/plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.ts deleted file mode 100644 index 4e2e6eef08..0000000000 --- a/plugins/rbac-backend/src/role-manager/ancestor-search-memo-sqlite.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { AuthService } from '@backstage/backend-plugin-api'; -import type { CatalogApi } from '@backstage/catalog-client'; -import type { Entity } from '@backstage/catalog-model'; -import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; - -import { AncestorSearchMemo } from './ancestor-search-memo'; - -export class AncestorSearchMemoSQLite extends AncestorSearchMemo { - constructor( - private readonly userEntityRef: string, - private readonly catalogApi: CatalogApi, - private readonly auth: AuthService, - private readonly maxDepth?: number, - ) { - super(); - } - - async getAllASMGroups(): Promise { - const { token } = await this.auth.getPluginRequestToken({ - onBehalfOf: await this.auth.getOwnServiceCredentials(), - targetPluginId: 'catalog', - }); - - const { items } = await this.catalogApi.getEntities( - { - filter: { kind: 'Group' }, - fields: ['kind', 'metadata.name', 'metadata.namespace', 'spec.parent'], - }, - { token }, - ); - return items; - } - - async getUserASMGroups(): Promise { - const { token } = await this.auth.getPluginRequestToken({ - onBehalfOf: await this.auth.getOwnServiceCredentials(), - targetPluginId: 'catalog', - }); - const { items } = await this.catalogApi.getEntities( - { - filter: { kind: 'Group', 'relations.hasMember': this.userEntityRef }, - fields: ['kind', 'metadata.name', 'metadata.namespace', 'spec.parent'], - }, - { token }, - ); - return items; - } - - traverse(group: Entity, allGroups: Entity[], current_depth: number) { - const groupRef = stringifyEntityRef(group); - - if (!super.hasEntityRef(groupRef)) { - super.setNode(groupRef); - } - - if (this.maxDepth !== undefined && current_depth >= this.maxDepth) { - return; - } - const depth = current_depth + 1; - - const parent = group.spec?.parent as string; - if (!parent) { - return; - } - - const parentRef = stringifyEntityRef( - parseEntityRef(parent, { - defaultKind: 'group', - defaultNamespace: group.metadata.namespace, - }), - ); - - const parentGroup = allGroups.find( - g => stringifyEntityRef(g) === parentRef, - ); - - if (parentGroup) { - super.setEdge(parentRef, groupRef); - - if (super.isAcyclic()) { - this.traverse(parentGroup, allGroups, depth); - } - } - } - - async buildUserGraph() { - const userGroups = await this.getUserASMGroups(); - const allGroups = await this.getAllASMGroups(); - userGroups.forEach(group => - this.traverse(group as Entity, allGroups as Entity[], 0), - ); - } -} diff --git a/plugins/rbac-backend/src/role-manager/ancestor-search-memo.ts b/plugins/rbac-backend/src/role-manager/ancestor-search-memo.ts deleted file mode 100644 index d6e7fc6649..0000000000 --- a/plugins/rbac-backend/src/role-manager/ancestor-search-memo.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { Entity } from '@backstage/catalog-model'; - -import { alg, Graph } from '@dagrejs/graphlib'; - -export interface Relation { - source_entity_ref: string; - target_entity_ref: string; -} - -export type ASMGroup = Relation | Entity; - -// AncestorSearchMemo - should be used to build group hierarchy graph for User entity reference. -// It supports search group entity reference link in the graph. -// Also AncestorSearchMemo supports detection cycle dependencies between groups in the graph. -// -export abstract class AncestorSearchMemo { - protected graph: Graph; - - constructor() { - this.graph = new Graph({ directed: true }); - } - - isAcyclic(): boolean { - return alg.isAcyclic(this.graph); - } - - findCycles(): string[][] { - return alg.findCycles(this.graph); - } - - setEdge(parentEntityRef: string, childEntityRef: string) { - this.graph.setEdge(parentEntityRef, childEntityRef); - } - - setNode(entityRef: string): void { - this.graph.setNode(entityRef); - } - - hasEntityRef(groupRef: string): boolean { - return this.graph.hasNode(groupRef); - } - - debugNodesAndEdges(logger: LoggerService, userEntity: string): void { - logger.debug( - `SubGraph edges: ${JSON.stringify(this.graph.edges())} for ${userEntity}`, - ); - logger.debug( - `SubGraph nodes: ${JSON.stringify(this.graph.nodes())} for ${userEntity}`, - ); - } - - getNodes(): string[] { - return this.graph.nodes(); - } - - abstract traverse( - relation: T, - allRelations: T[], - current_depth: number, - ): void; - - abstract buildUserGraph(): Promise; - - abstract getUserASMGroups(): Promise; - - abstract getAllASMGroups(): Promise; -} diff --git a/plugins/rbac-backend/src/role-manager/member-list.test.ts b/plugins/rbac-backend/src/role-manager/member-list.test.ts deleted file mode 100644 index e86cbfc399..0000000000 --- a/plugins/rbac-backend/src/role-manager/member-list.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import * as Knex from 'knex'; -import { createTracker, MockClient, Tracker } from 'knex-mock-client'; - -import { RoleMemberList } from './member-list'; - -describe('RoleMemberList', () => { - const member = 'user:default/developer'; - - const rbacDBClient = Knex.knex({ client: MockClient }); - let roleList: RoleMemberList; - let newRole: RoleMemberList; - let memberList: RoleMemberList; - - beforeEach(() => { - roleList = new RoleMemberList('role:default/test'); - newRole = new RoleMemberList('role:default/extra'); - memberList = new RoleMemberList('user:default/test'); - }); - - describe('addMembers', () => { - it('should add members to the role', () => { - const members = ['user:default/test', 'user:default/developer']; - roleList.addMembers(members); - - expect(roleList.hasMember('user:default/test')).toBeTruthy(); - expect(roleList.hasMember('user:default/developer')).toBeTruthy(); - }); - }); - - describe('addMember', () => { - it('should add a single member to the role', () => { - roleList.addMember(member); - - expect(roleList.hasMember('user:default/developer')).toBeTruthy(); - }); - - it('should not add a duplicate of an existing member', () => { - roleList.addMember(member); - - expect(roleList.getMembers().length).toEqual(1); - - roleList.addMember(member); - expect(roleList.getMembers().length).not.toEqual(2); - }); - }); - - describe('deleteMember', () => { - it('should delete a member from a role', () => { - roleList.addMember(member); - - expect(roleList.getMembers().length).toEqual(1); - - roleList.deleteMember(member); - - expect(roleList.getMembers().length).not.toEqual(1); - }); - }); - - describe('buildMembers', () => { - let tracker: Tracker; - - beforeEach(() => { - tracker = createTracker(rbacDBClient); - }); - - afterEach(() => { - tracker.reset(); - }); - - it('should build the members associated with a role using the database', async () => { - const data = [{ v0: 'user:default/qa', v1: 'role:default/qa' }]; - - tracker.on.select('casbin_rule').response(data); - - await newRole.buildMembers(newRole, rbacDBClient); - expect(newRole.hasMember('user:default/qa')).toBeTruthy(); - }); - - it('should fail to retrieve users and log an error', async () => { - const error = new Error('test error'); - tracker.on.select('casbin_rule').simulateError(error); - - await expect( - newRole.buildMembers(newRole, rbacDBClient), - ).rejects.toMatchObject({ - message: expect.stringContaining('test error'), - }); - expect(newRole.getMembers().length).toEqual(0); - }); - }); - - describe('addRoles', () => { - it('should add roles to the role member list', () => { - const roles = ['role:default/test', 'role:default/developer']; - memberList.addRoles(roles); - - expect(memberList.getRoles().length).toEqual(2); - }); - }); - - describe('buildRoles', () => { - let tracker: Tracker; - const memberRoles = ['role:default/temp', 'role:default/qa']; - - beforeEach(() => { - tracker = createTracker(rbacDBClient); - }); - - afterEach(() => { - tracker.reset(); - }); - - it('should build the roles associated with a user using the database', async () => { - const data = [{ v0: 'user:default/test', v1: 'role:default/qa' }]; - - tracker.on.select('casbin_rule').response(data); - - await newRole.buildRoles(newRole, memberRoles, rbacDBClient); - const rolesExpect = newRole.getRoles(); - expect(rolesExpect.length).toEqual(1); - expect(rolesExpect[0]).toEqual('role:default/qa'); - }); - - it('should fail to retrieve roles and log an error', async () => { - const error = new Error('test error'); - tracker.on.select('casbin_rule').simulateError(error); - - await expect( - newRole.buildRoles(newRole, memberRoles, rbacDBClient), - ).rejects.toMatchObject({ - message: expect.stringContaining('test error'), - }); - expect(newRole.getRoles().length).toEqual(0); - }); - }); -}); diff --git a/plugins/rbac-backend/src/role-manager/member-list.ts b/plugins/rbac-backend/src/role-manager/member-list.ts deleted file mode 100644 index bb9517fdf9..0000000000 --- a/plugins/rbac-backend/src/role-manager/member-list.ts +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Knex } from 'knex'; - -export class RoleMemberList { - public name: string; - - private members: string[]; - private roles: string[]; - - public constructor(name: string) { - this.name = name; - this.members = []; - this.roles = []; - } - - /** - * addMembers will add members to the RoleMemberList - * @param members The members to be added. - */ - public addMembers(members: string[]): void { - this.members = members; - } - - /** - * addMember will add a single member to the RoleMemberList, skips adding the user in the - * event that they already exist in the members array. - * @param member The member to be added. - */ - public addMember(member: string): void { - if (this.members.some(n => n === member)) { - return; - } - this.members.push(member); - } - - /** - * hasMember will check if a particular member exists in the members array. - * @param name The member to be checked for. - */ - public hasMember(name: string): boolean { - return this.members.includes(name); - } - - /** - * deleteMember will remove a user from the members array. - * @param member The member to be removed. - */ - public deleteMember(member: string): void { - this.members = this.members.filter(n => n !== member); - } - - /** - * buildMembers will query the `casbin_rule` database table to ensure that the role - * that we have cached is up to date. - * This is important in multi node scenarios where the cached roles in role manager can become - * out of sync with the database. - * @param roleMemberList The RoleMemberList to be updated. - * @param client The database client. - */ - public async buildMembers( - roleMemberList: RoleMemberList, - client: Knex, - ): Promise { - try { - const members: string[] = await client - .table('casbin_rule') - .where('v1', this.name) - .pluck('v0') - .distinct(); - - roleMemberList.addMembers(members); - } catch (error) { - throw new Error( - `Unable to find members for the role ${this.name}. Cause: ${error}`, - ); - } - } - - /** - * getMembers will return the members of the RoleMemberList - * @returns The members. - */ - getMembers(): string[] { - return this.members; - } - - /** - * addRoles will add roles to the RoleMemberList - * @param roles The roles to be added. - */ - public addRoles(roles: string[]): void { - this.roles = roles; - } - - /** - * buildRoles will query the `casbin_rule` database table to quickly grab all of the - * roles that a particular user is attached to. - * @param roleMemberList The RoleMemberList to be updated. - * @param userAndGroups The user and groups to query with. - * @param client The database client. - */ - public async buildRoles( - roleMemberList: RoleMemberList, - userAndGroups: string[], - client: Knex, - ): Promise { - try { - const roles: string[] = await client - .table('casbin_rule') - .where('ptype', 'g') - .whereIn('v0', userAndGroups) - .pluck('v1') - .distinct(); - - roleMemberList.addRoles(roles); - } catch (error) { - throw new Error(`Unable to find all roles. Cause: ${error}`); - } - } - - /** - * getRoles will return the roles of the RoleMemberList. - * @returns The roles. - */ - getRoles(): string[] { - return this.roles; - } -} diff --git a/plugins/rbac-backend/src/role-manager/role-manager.test.ts b/plugins/rbac-backend/src/role-manager/role-manager.test.ts deleted file mode 100644 index a8eb2f8870..0000000000 --- a/plugins/rbac-backend/src/role-manager/role-manager.test.ts +++ /dev/null @@ -1,626 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { LoggerService } from '@backstage/backend-plugin-api'; -import { mockServices } from '@backstage/backend-test-utils'; -import { Config } from '@backstage/config'; - -import * as Knex from 'knex'; -import { createTracker, MockClient, Tracker } from 'knex-mock-client'; - -import { BackstageRoleManager } from '../role-manager/role-manager'; -import { DefaultPermissionsReader } from '../default-permissions/default-permissions'; -import { catalogMock } from '../../__fixtures__/mock-utils'; - -describe('BackstageRoleManager', () => { - const catalogDBClient = Knex.knex({ client: MockClient }); - const rbacDBClient = Knex.knex({ client: MockClient }); - - const mockLoggerService = mockServices.logger.mock(); - - const mockAuthService = mockServices.auth(); - - let roleManager: BackstageRoleManager; - beforeEach(() => { - const config = newConfig(); - - roleManager = new BackstageRoleManager( - catalogMock, - mockLoggerService as LoggerService, - catalogDBClient, - rbacDBClient, - config, - mockAuthService, - new DefaultPermissionsReader(config), - ); - }); - - jest.spyOn(catalogMock, 'getEntities'); - - describe('initialize', () => { - it('should initialize', () => { - expect(roleManager).not.toBeUndefined(); - }); - - it('should throw an error whenever max depth is less than 0', () => { - let expectedError; - let errorRoleManager; - const config = newConfig(-1); - - try { - errorRoleManager = new BackstageRoleManager( - catalogMock, - mockLoggerService as LoggerService, - catalogDBClient, - rbacDBClient, - config, - mockAuthService, - new DefaultPermissionsReader(config), - ); - } catch (error) { - expectedError = error; - } - - expect(errorRoleManager).toBeUndefined(); - expect(expectedError).toMatchObject({ - message: - 'Max Depth for RBAC group hierarchy must be greater than or equal to zero', - }); - }); - }); - - describe('unimplemented methods', () => { - it('should throw an error for syncedHasLink', () => { - expect(() => - roleManager.syncedHasLink!('user:default/role1', 'user:default/role2'), - ).toThrow('Method "syncedHasLink" not implemented.'); - }); - - it('should throw an error for getUsers', async () => { - await expect(roleManager.getUsers('name')).rejects.toThrow( - 'Method "getUsers" not implemented.', - ); - }); - }); - - describe('addLink test', () => { - it('should create a link between two entities', async () => { - roleManager.addLink('user:default/test', 'role:default/rbac_admin'); - const result = await roleManager.hasLink( - 'user:default/test', - 'role:default/rbac_admin', - ); - expect(result).toBe(true); - }); - }); - - describe('deleteLink test', () => { - it('should delete a link', async () => { - roleManager.addLink('user:default/test', 'role:default/test', ''); - roleManager.addLink('user:default/test', 'role:default/test2', ''); - - let roles = await roleManager.getRoles('user:default/test'); - expect(roles).toStrictEqual(['role:default/test', 'role:default/test2']); - - roleManager.deleteLink('user:default/test', 'role:default/test'); - roles = await roleManager.getRoles('user:default/test'); - expect(roles).toStrictEqual(['role:default/test2']); - }); - }); - - describe('hasLink tests', () => { - afterEach(() => { - (mockLoggerService.warn as jest.Mock).mockReset(); - }); - - it('should throw an error for unsupported domain', async () => { - await expect( - roleManager.hasLink( - 'user:default/mike', - 'group:default/somegroup', - 'someDomain', - ), - ).rejects.toThrow('domain argument is not supported.'); - }); - - it('should return true for hasLink when names are the same', async () => { - const result = await roleManager.hasLink( - 'user:default/mike', - 'user:default/mike', - ); - expect(result).toBe(true); - }); - - it('should return false for hasLink when name2 has a user kind', async () => { - const result = await roleManager.hasLink( - 'user:default/mike', - 'user:default/some-user', - ); - expect(result).toBe(false); - }); - - // user:default/bob should not inherits from group:default/team-x - // - // Hierarchy: - // - // user:default/b -> user without group - // - it('should return false for hasLink when user without group', async () => { - const result = await roleManager.hasLink( - 'user:default/bob', - 'group:default/team-x', - ); - expect(catalogMock.getEntities).toHaveBeenCalledWith( - { - filter: { - kind: 'Group', - }, - fields: [ - 'kind', - 'metadata.name', - 'metadata.namespace', - 'spec.parent', - ], - }, - { - token: 'mock-service-token:{"sub":"plugin:test","target":"catalog"}', - }, - ); - expect(result).toBeFalsy(); - }); - - // user:default/mike should inherits from group:default/team-b - // - // Hierarchy: - // - // group:default/team-b - // | - // user:default/mike - // - it('should return true for hasLink when user:default/mike inherits from group:default/team-b', async () => { - const result = await roleManager.hasLink( - 'user:default/mike', - 'group:default/team-b', - ); - expect(result).toBeTruthy(); - }); - - // user:default/mike should not inherits from group:default/team-x - // - // Hierarchy: - // - // group:default/team-b - // | - // user:default/mike - // - it('should return false for hasLink when user:default/mike does not inherits group:default/team-x', async () => { - const result = await roleManager.hasLink( - 'user:default/mike', - 'group:default/team-x', - ); - expect(result).toBeFalsy(); - }); - - // user:default/mike should inherits from group:default/team-a - // - // Hierarchy: - // - // group:default/team-a - // | - // group:default/team-b - // | - // user:default/mike - // - it('should return true for hasLink, when user:default/mike inherits from group:default/team-a', async () => { - const result = await roleManager.hasLink( - 'user:default/mike', - 'group:default/team-a', - ); - expect(result).toBeTruthy(); - }); - - // user:default/mike should inherits from group:default/team-a - // - // Hierarchy: - // - // group:default/team-a - // | - // group:default/team-b - // | - // user:default/mike - // - it('should disable group inheritance when max-depth=0', async () => { - // max-depth=0 - const config = newConfig(0); - const rm = new BackstageRoleManager( - catalogMock, - mockLoggerService as LoggerService, - catalogDBClient, - rbacDBClient, - config, - mockAuthService, - new DefaultPermissionsReader(config), - ); - let result = await rm.hasLink( - 'user:default/mike', - 'group:default/team-b', - ); - expect(result).toBeTruthy(); - - result = await rm.hasLink('user:default/mike', 'group:default/team-a'); - expect(result).toBeFalsy(); - }); - - // user:default/mike should inherits from group:default/team-b. - // - // Hierarchy: - // - // |---------group:default/team-a---------| - // | | | - // user:default/team-c group:default/team-b group:default/team-d - // | | | - // user:default/tom user:default/mike user:default:john - // - it('should return true for hasLink, when user:default/mike inherits from group:default/team-b', async () => { - const result = await roleManager.hasLink( - 'user:default/mike', - 'group:default/team-a', - ); - expect(result).toBeTruthy(); - }); - - // user:default/mike should not inherits from group:default/team-c - // - // Hierarchy: - // - // group:default/team-a - // | - // group:default/team-b - // | - // user:default/mike - // - it('should return false for hasLink, when user:default/mike does not inherits from group:default/team-c', async () => { - const result = await roleManager.hasLink( - 'user:default/mike', - 'group:default/team-c', - ); - expect(result).toBeFalsy(); - }); - - // user:default/mike should inherits from group:default/team-a - // - // Hierarchy: - // - // group:default/team-a group:default/team-z - // | | - // group:default/team-c group:default/team-y - // | | - // user:default/mike - // - it('should return true for hasLink, when user:default/mike inherits group tree with group:default/team-a', async () => { - const result = await roleManager.hasLink( - 'user:default/mike', - 'group:default/team-a', - ); - expect(result).toBeTruthy(); - }); - - // user:default/mike should not inherits from group:default/team-e - // - // Hierarchy: - // - // group:default/team-a group:default/team-z - // | | - // group:default/team-c group:default/team-y - // | | - // user:default/mike - // - it('should return false for hasLink, when user:default/mike inherits from group:default/team-e', async () => { - const result = await roleManager.hasLink( - 'user:default/mike', - 'group:default/team-e', - ); - expect(result).toBeFalsy(); - }); - - // user:default/john should inherits from group:default/team-e and group:default/team-f, but we have cycle dependency. - // So return false on call hasLink. - // - // Hierarchy: - // - // group:default/team-e - // ↓ ↑ - // group:default/team-f - // ↓ - // user:default/john - // - it('should return false for hasLink, when user:default/john inherits from group:default/team-e and group:default/team-f, but we have cycle dependency', async () => { - let result = await roleManager.hasLink( - 'user:default/john', - 'group:default/team-f', - ); - expect(result).toBeFalsy(); - expect(mockLoggerService.warn).toHaveBeenCalledWith( - 'Detected cycle dependencies in the Group graph: [["group:default/team-e","group:default/team-f"]]. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: [["group:default/team-e","group:default/team-f"]]', - ); - - result = await roleManager.hasLink( - 'user:default/john', - 'group:default/team-e', - ); - expect(result).toBeFalsy(); - expect(mockLoggerService.warn).toHaveBeenCalledWith( - 'Detected cycle dependencies in the Group graph: [["group:default/team-e","group:default/team-f"]]. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: [["group:default/team-e","group:default/team-f"]]', - ); - }); - - // user:default/bill should inherits from group:default/team-e, group:default/team-f, group:default/team-g, but we have cycle dependency. - // So return false on call hasLink. - // - // Hierarchy: - // - // group:default/team-e - // ↓ ↑ - // group:default/team-f - // ↓ - // group:default/team-g - // ↓ - // user:default/bill - // - it('should return false for hasLink, when user:default/bill inherits from group:default/team-g, group:default/team-f, group:default/team-e, but we have cycle dependency', async () => { - let result = await roleManager.hasLink( - 'user:default/bill', - 'group:default/team-g', - ); - expect(result).toBeFalsy(); - expect(mockLoggerService.warn).toHaveBeenCalledWith( - 'Detected cycle dependencies in the Group graph: [["group:default/team-e","group:default/team-f"]]. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: [["group:default/team-e","group:default/team-f"]]', - ); - - result = await roleManager.hasLink( - 'user:default/bill', - 'group:default/team-e', - ); - expect(result).toBeFalsy(); - expect(mockLoggerService.warn).toHaveBeenCalledWith( - 'Detected cycle dependencies in the Group graph: [["group:default/team-e","group:default/team-f"]]. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: [["group:default/team-e","group:default/team-f"]]', - ); - - result = await roleManager.hasLink( - 'user:default/bill', - 'group:default/team-f', - ); - expect(result).toBeFalsy(); - expect(mockLoggerService.warn).toHaveBeenCalledWith( - 'Detected cycle dependencies in the Group graph: [["group:default/team-e","group:default/team-f"]]. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: [["group:default/team-e","group:default/team-f"]]', - ); - }); - - // user:default/john should inherits from group:default/team-a, but we have cycle dependency: team-e -> team-f. - // So return false on call hasLink. - // - // Hierarchy: - // - // group:default/team-e group:default/team-a - // ↓ ↑ ↓ - // group:default/team-f group:default/team-d - // ↓ ↓ - // user:default/john - // - it('should return false for hasLink, when user:default/mike inherits group tree with group:default/team-a, but we cycle dependency', async () => { - const result = await roleManager.hasLink( - 'user:default/john', - 'group:default/team-e', - ); - expect(result).toBeFalsy(); - expect(mockLoggerService.warn).toHaveBeenCalledWith( - 'Detected cycle dependencies in the Group graph: [["group:default/team-e","group:default/team-f"]]. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: [["group:default/team-e","group:default/team-f"]]', - ); - }); - - // user:default/john should inherits from group:default/team-e, but we have cycle dependency: team-e -> team-f. - // So return false on call hasLink. - // - // user:default/tom should inherits from group:default/team-a. Cycle dependency in the neighbor subgraph, should - // not affect evaluation user:default/tom inheritance. - // - // Hierarchy: - // - // group:default/root - // ↓ ↓ - // group:default/team-e group:default/team-a - // ↓ ↑ ↓ - // group:default/team-f group:default/team-c - // ↓ ↓ - // user:default/john user:default/tom - // - it('should return false for hasLink for user:default/john and group:default/team-e(cycle dependency), but should be true for user:default/tom and group:default/team-a', async () => { - let result = await roleManager.hasLink( - 'user:default/john', - 'group:default/team-e', - ); - expect(result).toBeFalsy(); - expect(mockLoggerService.warn).toHaveBeenCalledWith( - 'Detected cycle dependencies in the Group graph: [["group:default/team-e","group:default/team-f"]]. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: [["group:default/team-e","group:default/team-f"]]', - ); - - result = await roleManager.hasLink( - 'user:default/tom', - 'group:default/team-a', - ); - expect(result).toBeTruthy(); - }); - }); - - describe('getRoles returns roles per user', () => { - it('should returns role per user', async () => { - roleManager.addLink('user:default/test', 'role:default/rbac_admin'); - roleManager.addLink('user:default/test-two', 'role:default/rbac_admin'); - roleManager.addLink( - 'user:default/test-three', - 'role:default/rbac_admin_test', - ); - - let roles = await roleManager.getRoles('user:default/test'); - expect(roles.length).toBe(1); - expect(roles[0]).toEqual('role:default/rbac_admin'); - - roles = await roleManager.getRoles('user:default/test-two'); - expect(roles.length).toBe(1); - expect(roles[0]).toEqual('role:default/rbac_admin'); - - roles = await roleManager.getRoles('user:default/test-three'); - expect(roles.length).toBe(1); - expect(roles[0]).toEqual('role:default/rbac_admin_test'); - }); - - it('getRoles returns role for user inherited from group', async () => { - roleManager.addLink('group:default/team-a', 'role:default/rbac_admin'); - - let roles = await roleManager.getRoles('user:default/mike'); - expect(roles.length).toBe(1); - expect(roles[0]).toEqual('role:default/rbac_admin'); - - // should return empty array for group - roles = await roleManager.getRoles('group:default/team-a'); - expect(roles.length).toBe(0); - - // should return empty array for role - roles = await roleManager.getRoles('role:default/rbac_admin'); - expect(roles.length).toBe(0); - }); - }); - - describe('getRoles returns roles per user with database', () => { - let tracker: Tracker; - - beforeEach(() => { - tracker = createTracker(rbacDBClient); - }); - - afterEach(() => { - tracker.reset(); - }); - - it('should returns role per user', async () => { - roleManager.isPGClient = jest.fn().mockImplementation(() => true); - - roleManager.addLink('user:default/test', 'role:default/rbac_admin'); - - let data = [{ v0: 'user:default/test', v1: 'role:default/rbac_admin' }]; - - tracker.on.select('casbin_rule').response(data); - - let roles = await roleManager.getRoles('user:default/test'); - expect(roles.length).toBe(1); - expect(roles[0]).toEqual('role:default/rbac_admin'); - - roleManager.addLink('user:default/test-two', 'role:default/rbac_admin'); - - tracker.resetHandlers(); - - data = [{ v0: 'user:default/test-two', v1: 'role:default/rbac_admin' }]; - - tracker.on.select('casbin_rule').response(data); - - roles = await roleManager.getRoles('user:default/test-two'); - expect(roles.length).toBe(1); - expect(roles[0]).toEqual('role:default/rbac_admin'); - - roleManager.addLink( - 'user:default/test-three', - 'role:default/rbac_admin_test', - ); - - tracker.resetHandlers(); - - data = [ - { v0: 'user:default/test-three', v1: 'role:default/rbac_admin_test' }, - ]; - - tracker.on.select('casbin_rule').response(data); - - roles = await roleManager.getRoles('user:default/test-three'); - expect(roles.length).toBe(1); - expect(roles[0]).toEqual('role:default/rbac_admin_test'); - }); - - it('getRoles returns role for user inherited from group', async () => { - roleManager.isPGClient = jest.fn().mockImplementation(() => true); - roleManager.addLink('group:default/team-a', 'role:default/rbac_admin'); - - const data = [ - { v0: 'group:default/team-a', v1: 'role:default/rbac_admin' }, - ]; - - tracker.on.select('casbin_rule').response(data); - - let roles = await roleManager.getRoles('user:default/test'); - expect(roles.length).toBe(1); - expect(roles[0]).toEqual('role:default/rbac_admin'); - - tracker.on - .select('select "v1" from "casbin_rule" where "v0" = ?') - .response([]); - - // should return empty array for group - roles = await roleManager.getRoles('group:default/team-a'); - expect(roles.length).toBe(0); - - tracker.on - .select('select "v1" from "casbin_rule" where "v0" = ?') - .response([]); - - // should return empty array for role - roles = await roleManager.getRoles('role:default/rbac_admin'); - expect(roles.length).toBe(0); - }); - }); -}); - -function newConfig( - maxDepth?: number, - users?: Array<{ name: string }>, - superUsers?: Array<{ name: string }>, -): Config { - const testUsers = [ - { - name: 'user:default/guest', - }, - { - name: 'group:default/guests', - }, - ]; - - return mockServices.rootConfig({ - data: { - permission: { - rbac: { - admin: { - users: users || testUsers, - superUsers: superUsers, - }, - maxDepth, - }, - }, - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - }, - }); -} diff --git a/plugins/rbac-backend/src/role-manager/role-manager.ts b/plugins/rbac-backend/src/role-manager/role-manager.ts deleted file mode 100644 index b82f1702cc..0000000000 --- a/plugins/rbac-backend/src/role-manager/role-manager.ts +++ /dev/null @@ -1,348 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { AuthService, LoggerService } from '@backstage/backend-plugin-api'; -import type { CatalogApi } from '@backstage/catalog-client'; -import { parseEntityRef } from '@backstage/catalog-model'; -import type { Config } from '@backstage/config'; - -import { RoleManager } from 'casbin'; -import { Knex } from 'knex'; - -import { AncestorSearchMemo, ASMGroup } from './ancestor-search-memo'; -import { RoleMemberList } from './member-list'; -import { AncestorSearchFactory } from './ancestor-search-factory'; -import { DefaultPermissionsReader } from '../default-permissions/default-permissions'; - -export class BackstageRoleManager implements RoleManager { - private allRoles: Map; - private maxDepth?: number; - private defaultRoleRef?: string; - constructor( - private readonly catalogApi: CatalogApi, - private readonly logger: LoggerService, - private readonly catalogDBClient: Knex, - private readonly rbacDBClient: Knex, - private readonly config: Config, - private readonly auth: AuthService, - defaultPermissionReader: DefaultPermissionsReader, - ) { - this.allRoles = new Map(); - const rbacConfig = this.config.getOptionalConfig('permission.rbac'); - this.maxDepth = rbacConfig?.getOptionalNumber('maxDepth'); - this.defaultRoleRef = defaultPermissionReader.readRole(); - if (this.maxDepth !== undefined && this.maxDepth! < 0) { - throw new Error( - 'Max Depth for RBAC group hierarchy must be greater than or equal to zero', - ); - } - } - - /** - * clear clears all stored data and resets the role manager to the initial state. - */ - async clear(): Promise { - // do nothing - } - - /** - * addLink adds the inheritance link between name1 and role: name2. - * aka name1 inherits role: name2. - * The link that is established is based on the defined grouping policies that are added by the enforcer. - * - * ex. `g, name1, name2`. - * @param name1 User or group that will be assigned to a role. - * @param name2 The role that will be created or updated. - * @param _domain Unimplemented prefix to the role. - */ - async addLink( - name1: string, - name2: string, - ..._domain: string[] - ): Promise { - if (!this.isPGClient()) { - const role1 = this.getOrCreateRole(name2); - role1.addMember(name1); - } - } - - /** - * deleteLink deletes the inheritance link between name1 and role: name2. - * aka name1 does not inherit role: name2 any more. - * The link that is deleted is based on the defined grouping policies that are removed by the enforcer. - * - * ex. `g, name1, name2`. - * @param name1 User or group that will be removed from assignment of a role. - * @param name2 The role that will be deleted or updated. - * @param _domain Unimplemented. - */ - async deleteLink( - name1: string, - name2: string, - ..._domain: string[] - ): Promise { - if (!this.isPGClient()) { - const role1 = this.getOrCreateRole(name2); - role1.deleteMember(name1); - - // Clean up in the event that there are no more members in the role - if (role1.getMembers().length === 0) { - this.allRoles.delete(name2); - } - } - } - - /** - * hasLink determines whether name1 inherits role: name2. - * Before this check is called in the background by the enforcer, - * we filter out all roles that the user is not connected to - * directly or indirectly through the use of retrieving roles through - * enforcer.getRolesForUser and apply those roles to a tempEnforcer. - * - * This means that hasLink will almost always be true in the event that a user - * is assigned to a role (either directly or indirectly) - * - * In the event that a user or group is not assigned to a role and instead - * are assigned directly to permissions, then name2 will become either that - * user or group through the filtering. In this case we will build the graph - * if necessary for name2 group presence or evaulate based on the names matching. - * @param name1 The user that we are authorizing. - * @param name2 The name of the role that we are checking against. - * @param domain Unimplemented. - * @returns True if the user is directly or indirectly attached to the role. - */ - async hasLink( - name1: string, - name2: string, - ...domain: string[] - ): Promise { - if (domain.length > 0) { - throw new Error('domain argument is not supported.'); - } - - // Name2 can be an empty string in the event that there is not a role associated with the user - // This happens because of the filtering of the roles reduces the number of roles that we iterate through. - if (name2.length === 0) { - return false; - } - - if (name1 === name2) { - return true; - } - - // name1 is always user in our case. - // name2 is user or group. - // user(name1) couldn't inherit user(name2). - // We can use this fact for optimization. - const { kind } = parseEntityRef(name2); - if (kind.toLocaleLowerCase() === 'user') { - return false; - } - - // if it is a group, then we will have to build the graph, - if (kind.toLocaleLowerCase() === 'group') { - const memo = await AncestorSearchFactory.createAncestorSearchMemo( - name1, - this.config, - this.catalogApi, - this.catalogDBClient, - this.auth, - this.maxDepth, - ); - - await memo.buildUserGraph(); - memo.debugNodesAndEdges(this.logger, name1); - - if (!memo.isAcyclic()) { - const cycles = memo.findCycles(); - - this.logger.warn( - `Detected cycle dependencies in the Group graph: ${JSON.stringify( - cycles, - )}. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: ${JSON.stringify( - cycles, - )}`, - ); - return false; - } - - return memo.hasEntityRef(name2); - } - - return true; - } - - /** - * syncedHasLink determines whether role: name1 inherits role: name2. - * domain is a prefix to the roles. - */ - syncedHasLink?( - _name1: string, - _name2: string, - ..._domain: string[] - ): boolean { - throw new Error('Method "syncedHasLink" not implemented.'); - } - - /** - * getRoles gets the roles that a subject inherits. - * - * name - is a string entity reference, for example: user:default/tom, role:default/dev, - * so format is :/. - * GetRoles method supports only two kind values: 'user' and 'role'. - * - * domain - is a prefix to the roles, unused parameter. - * - * If name's kind === 'user' we return all inherited roles from groups and roles directly assigned to the user. - * if name's kind === 'role' we return empty array, because we don't support role inheritance. - * Case kind === 'group' - should not happen, because: - * 1) Method getRoles returns only role entity references, so casbin engine doesn't call this - * method again to ask about name with kind "group". - * 2) We implemented getRoles method only to use: - * 'await enforcer.getImplicitPermissionsForUser(userEntityRef)', - * so name argument can be only with kind 'user' or 'role'. - * - * Info: when we call 'await enforcer.getImplicitPermissionsForUser(userEntityRef)', - * then casbin engine executes 'getRoles' method few times. - * Firstly casbin asks about roles for 'userEntityRef'. - * Let's imagine, that 'getRoles' returned two roles for userEntityRef. - * Then casbin calls 'getRoles' two more times to - * find parent roles. But we return empty array for each such call, - * because we don't support role inheritance and we notify casbin about end of the role sub-tree. - */ - async getRoles(name: string, ..._domain: string[]): Promise { - const { kind } = parseEntityRef(name); - if (kind === 'user') { - const memo = await AncestorSearchFactory.createAncestorSearchMemo( - name, - this.config, - this.catalogApi, - this.catalogDBClient, - this.auth, - this.maxDepth, - ); - await memo.buildUserGraph(); - memo.debugNodesAndEdges(this.logger, name); - - // Account for the user not being in the graph (this can happen during direct assignment to roles) - memo.setNode(name); - - if (!memo.isAcyclic()) { - const cycles = memo.findCycles(); - - this.logger.warn( - `Detected cycle dependencies in the Group graph: ${JSON.stringify( - cycles, - )}. Admin/(catalog owner) have to fix it to make RBAC permission evaluation correct for groups: ${JSON.stringify( - cycles, - )}`, - ); - return Promise.resolve([]); - } - - if (this.isPGClient()) { - const currentRole = new RoleMemberList(name); - await currentRole.buildRoles( - currentRole, - memo.getNodes(), - this.rbacDBClient, - ); - const roles = currentRole.getRoles(); - if (this.defaultRoleRef) roles.push(this.defaultRoleRef); - return Promise.resolve(roles); - } - - const allRoles: string[] = []; - for (const value of this.allRoles.values()) { - if (this.hasMember(value, memo)) { - allRoles.push(value.name); - } - } - - if (this.defaultRoleRef) allRoles.push(this.defaultRoleRef); - return Promise.resolve(allRoles); - } - - return []; - } - - /** - * getUsers gets the users that inherits a subject. - * domain is an unreferenced parameter here, may be used in other implementations. - */ - async getUsers(_name: string, ..._domain: string[]): Promise { - throw new Error('Method "getUsers" not implemented.'); - } - - /** - * printRoles prints all the roles to log. - */ - async printRoles(): Promise { - // do nothing - } - - /** - * getOrCreateRole will get a role if it has already been cached - * or it will create a new role to be cached. - * This cache is a simple tree that is used to quickly compare - * users and groups to roles. - * @param name The user or group whose cache we will be getting / creating. - * @returns The cached role as a RoleList. - */ - private getOrCreateRole(name: string): RoleMemberList { - const role = this.allRoles.get(name); - if (role) { - return role; - } - const newRole = new RoleMemberList(name); - this.allRoles.set(name, newRole); - - return newRole; - } - - /** - * isPGClient checks what the current database client is at them time. - * This is to ensure that we are querying the database in the event of postgres - * or using in memory cache for better sqlite3. - * @returns True if the database client is pg. - */ - isPGClient(): boolean { - const client = this.rbacDBClient.client.config.client; - return client === 'pg'; - } - - /** - * hasMember checks if the members from a particular role is associated with the user - * that the AncestorSearchMemo graph is built for. - * @param role The role that we are getting the members from. - * @param memo The user graph that we are comparing members with. - * @returns True if a member from the role is also associated with the user. - */ - private hasMember( - role: RoleMemberList | undefined, - memo: AncestorSearchMemo, - ): boolean { - if (role === undefined) { - return false; - } - - for (const member of role.getMembers()) { - if (memo.hasEntityRef(member)) { - return true; - } - } - return false; - } -} diff --git a/plugins/rbac-backend/src/service/enforcer-delegate.test.ts b/plugins/rbac-backend/src/service/enforcer-delegate.test.ts deleted file mode 100644 index c58409670f..0000000000 --- a/plugins/rbac-backend/src/service/enforcer-delegate.test.ts +++ /dev/null @@ -1,1305 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { mockServices } from '@backstage/backend-test-utils'; - -import { Model, newEnforcer, newModelFromString } from 'casbin'; -import * as Knex from 'knex'; -import { MockClient } from 'knex-mock-client'; - -import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; -import { - RoleMetadataDao, - RoleMetadataStorage, -} from '../database/role-metadata'; -import { BackstageRoleManager } from '../role-manager/role-manager'; -import { DefaultPermissionsReader } from '../default-permissions/default-permissions'; -import { EnforcerDelegate } from './enforcer-delegate'; -import { MODEL } from './permission-model'; -import { - catalogMock, - conditionalStorageMock, - mockAuditorService, -} from '../../__fixtures__/mock-utils'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import { - PermissionInfo, - RoleConditionalPolicyDecision, -} from '@backstage-community/plugin-rbac-common'; - -const roleMetadataStorageMock: RoleMetadataStorage = { - filterRoleMetadata: jest.fn().mockImplementation(() => []), - filterForOwnerRoleMetadata: jest.fn().mockImplementation(), - findRoleMetadata: jest.fn().mockImplementation(), - createRoleMetadata: jest.fn().mockImplementation(), - updateRoleMetadata: jest.fn().mockImplementation(), - removeRoleMetadata: jest.fn().mockImplementation(), - getCachedDefaultRoleMetadata: jest.fn().mockImplementation(), - getDefaultRole: jest.fn().mockResolvedValue(undefined), - syncDefaultRoleMetadata: jest.fn().mockResolvedValue(undefined), -}; - -const mockClientKnex = Knex.knex({ client: MockClient }); - -const mockAuthService = mockServices.auth(); - -const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - permission: { - rbac: {}, - }, - }, -}); -const policy = ['role:default/dev-team', 'policy-entity', 'read', 'allow']; -const secondPolicy = [ - 'role:default/qa-team', - 'catalog-entity', - 'create', - 'allow', -]; - -const groupingPolicy = ['user:default/tom', 'role:default/dev-team']; -const secondGroupingPolicy = ['user:default/tim', 'role:default/qa-team']; - -describe('EnforcerDelegate', () => { - let enfRemovePolicySpy: jest.SpyInstance, string[], any>; - let enfRemovePoliciesSpy: jest.SpyInstance< - Promise, - [rules: string[][]], - any - >; - let enfRemoveGroupingPolicySpy: jest.SpyInstance< - Promise, - string[], - any - >; - let adapterLoaderFilterGroupingPolicySpy: jest.SpyInstance< - Promise, - [model: Model, filter: any], - any - >; - let enfRemoveGroupingPoliciesSpy: jest.SpyInstance< - Promise, - [rules: string[][]], - any - >; - let enfAddPolicySpy: jest.SpyInstance< - Promise, - [...policy: string[]], - any - >; - let enfAddGroupingPolicySpy: jest.SpyInstance< - Promise, - [...policy: string[]], - any - >; - let enfAddGroupingPoliciesSpy: jest.SpyInstance< - Promise, - [policy: string[][]], - any - >; - let enfAddPoliciesSpy: jest.SpyInstance< - Promise, - [rules: string[][]], - any - >; - - const modifiedBy = 'user:default/some-admin'; - - beforeEach(() => { - (roleMetadataStorageMock.createRoleMetadata as jest.Mock).mockReset(); - (roleMetadataStorageMock.updateRoleMetadata as jest.Mock).mockReset(); - (roleMetadataStorageMock.findRoleMetadata as jest.Mock).mockReset(); - (roleMetadataStorageMock.removeRoleMetadata as jest.Mock).mockReset(); - }); - - const knex = Knex.knex({ client: MockClient }); - - async function createEnfDelegate( - policies?: string[][], - groupingPolicies?: string[][], - ): Promise { - const theModel = newModelFromString(MODEL); - const logger = mockServices.logger.mock(); - - const sqliteInMemoryAdapter = await new CasbinDBAdapterFactory( - config, - mockClientKnex, - ).createAdapter(); - adapterLoaderFilterGroupingPolicySpy = jest.spyOn( - sqliteInMemoryAdapter, - 'loadFilteredPolicy', - ); - - const catalogDBClient = Knex.knex({ client: MockClient }); - const rbacDBClient = Knex.knex({ client: MockClient }); - const enf = await newEnforcer(theModel, sqliteInMemoryAdapter); - enfRemovePolicySpy = jest.spyOn(enf, 'removePolicy'); - enfRemovePoliciesSpy = jest.spyOn(enf, 'removePolicies'); - enfRemoveGroupingPolicySpy = jest.spyOn(enf, 'removeGroupingPolicy'); - enfRemoveGroupingPoliciesSpy = jest.spyOn(enf, 'removeGroupingPolicies'); - enfAddPolicySpy = jest.spyOn(enf, 'addPolicy'); - enfAddGroupingPolicySpy = jest.spyOn(enf, 'addGroupingPolicy'); - enfAddGroupingPoliciesSpy = jest.spyOn(enf, 'addGroupingPolicies'); - enfAddPoliciesSpy = jest.spyOn(enf, 'addPolicies'); - - const rm = new BackstageRoleManager( - catalogMock, - logger, - catalogDBClient, - rbacDBClient, - config, - mockAuthService, - new DefaultPermissionsReader(config), - ); - enf.setRoleManager(rm); - enf.enableAutoBuildRoleLinks(false); - await enf.buildRoleLinks(); - - if (policies && policies.length > 0) { - await enf.addPolicies(policies); - } - if (groupingPolicies && groupingPolicies.length > 0) { - await enf.addGroupingPolicies(groupingPolicies); - } - - return new EnforcerDelegate( - enf, - mockAuditorService, - conditionalStorageMock, - roleMetadataStorageMock, - knex, - ); - } - - describe('hasPolicy', () => { - it('has policy should return false', async () => { - const enfDelegate = await createEnfDelegate(); - const result = await enfDelegate.hasPolicy(...policy); - - expect(result).toBeFalsy(); - }); - - it('has policy should return true', async () => { - const enfDelegate = await createEnfDelegate([policy]); - - const result = await enfDelegate.hasPolicy(...policy); - - expect(result).toBeTruthy(); - }); - }); - - describe('hasGroupingPolicy', () => { - it('has policy should return false', async () => { - const enfDelegate = await createEnfDelegate([policy]); - const result = await enfDelegate.hasGroupingPolicy(...groupingPolicy); - - expect(result).toBeFalsy(); - }); - - it('has policy should return true', async () => { - const enfDelegate = await createEnfDelegate([], [groupingPolicy]); - - const result = await enfDelegate.hasGroupingPolicy(...groupingPolicy); - - expect(result).toBeTruthy(); - }); - }); - - describe('getPolicy', () => { - it('should return empty array', async () => { - const enfDelegate = await createEnfDelegate(); - const policies = await enfDelegate.getPolicy(); - - expect(policies.length).toEqual(0); - }); - - it('should return policy', async () => { - const enfDelegate = await createEnfDelegate([policy]); - - const policies = await enfDelegate.getPolicy(); - - expect(policies.length).toEqual(1); - expect(policies[0]).toEqual(policy); - }); - }); - - describe('getGroupingPolicy', () => { - it('should return empty array', async () => { - const enfDelegate = await createEnfDelegate(); - const groupingPolicies = await enfDelegate.getGroupingPolicy(); - - expect(groupingPolicies.length).toEqual(0); - }); - - it('should return grouping policy', async () => { - const enfDelegate = await createEnfDelegate([], [groupingPolicy]); - - const policies = await enfDelegate.getGroupingPolicy(); - - expect(policies.length).toEqual(1); - expect(policies[0]).toEqual(groupingPolicy); - }); - }); - - describe('getFilteredPolicy', () => { - it('should return empty array', async () => { - const enfDelegate = await createEnfDelegate(); - // filter by policy assignment person - const policies = await enfDelegate.getFilteredPolicy(0, policy[0]); - - expect(policies.length).toEqual(0); - }); - - it('should return filtered policy by role name', async () => { - const enfDelegate = await createEnfDelegate([policy, secondPolicy]); - - // filter by policy assignment person - const policies = await enfDelegate.getFilteredPolicy( - 0, - 'role:default/qa-team', - ); - - expect(policies.length).toEqual(1); - expect(policies[0]).toEqual(secondPolicy); - }); - - it('should return filtered policy by policy name', async () => { - const enfDelegate = await createEnfDelegate([policy, secondPolicy]); - - const policyName = policy[1]; - const policies = await enfDelegate.getFilteredPolicy(0, '', policyName); - - expect(policies.length).toEqual(1); - expect(policies[0]).toEqual(policy); - }); - - it('should return filtered policy by policy name with index offset', async () => { - const enfDelegate = await createEnfDelegate([policy, secondPolicy]); - - const policyName = policy[1]; - const policies = await enfDelegate.getFilteredPolicy(1, policyName); - - expect(policies.length).toEqual(1); - expect(policies[0]).toEqual(policy); - }); - - it('should return filtered policy by policy action', async () => { - const enfDelegate = await createEnfDelegate([policy, secondPolicy]); - - const policyAction = policy[2]; - const policies = await enfDelegate.getFilteredPolicy( - 0, - '', - '', - policyAction, - ); - - expect(policies.length).toEqual(1); - expect(policies[0]).toEqual(policy); - }); - - it('should return filtered policy by policy effect', async () => { - const enfDelegate = await createEnfDelegate([policy, secondPolicy]); - - const policyEffect = policy[3]; - const policies = await enfDelegate.getFilteredPolicy( - 0, - '', - '', - '', - policyEffect, - ); - - expect(policies.length).toEqual(2); - expect(policies[0]).toEqual(policy); - expect(policies[1]).toEqual(secondPolicy); - }); - }); - - describe('getFilteredGroupingPolicy', () => { - it('should return empty array', async () => { - const enfDelegate = await createEnfDelegate(); - // filter by policy assignment person - const policies = await enfDelegate.getFilteredGroupingPolicy( - 0, - 'user:default/tim', - ); - - expect(policies.length).toEqual(0); - }); - - it('should return filtered grouping policy by role member', async () => { - const enfDelegate = await createEnfDelegate( - [], - [groupingPolicy, secondGroupingPolicy], - ); - - // filter by policy assignment person - const policies = await enfDelegate.getFilteredGroupingPolicy( - 0, - 'user:default/tim', - ); - - expect(policies.length).toEqual(1); - expect(policies[0]).toEqual(secondGroupingPolicy); - }); - - it('should return filtered grouping policy by role name', async () => { - const enfDelegate = await createEnfDelegate( - [], - [groupingPolicy, secondGroupingPolicy], - ); - - // filter by policy assignment person - const policies = await enfDelegate.getFilteredGroupingPolicy( - 0, - '', - 'role:default/qa-team', - ); - - expect(policies.length).toEqual(1); - expect(policies[0]).toEqual(secondGroupingPolicy); - }); - - it('should return filtered grouping policy by role name with index offset', async () => { - const enfDelegate = await createEnfDelegate( - [], - [groupingPolicy, secondGroupingPolicy], - ); - - // filter by policy assignment person - const policies = await enfDelegate.getFilteredGroupingPolicy( - 1, - 'role:default/qa-team', - ); - - expect(policies.length).toEqual(1); - expect(policies[0]).toEqual(secondGroupingPolicy); - }); - }); - - describe('addPolicy', () => { - it('should add policy', async () => { - const enfDelegate = await createEnfDelegate(); - enfAddPolicySpy.mockClear(); - - await enfDelegate.addPolicy(policy); - - expect(enfAddPolicySpy).toHaveBeenCalledWith(...policy); - - expect(await enfDelegate.getPolicy()).toEqual([policy]); - }); - }); - - describe('addPolicies', () => { - it('should be added single policy', async () => { - const enfDelegate = await createEnfDelegate(); - - await enfDelegate.addPolicies([policy]); - - const storePolicies = await enfDelegate.getPolicy(); - - expect(storePolicies).toEqual([policy]); - expect(enfAddPoliciesSpy).toHaveBeenCalledWith([policy]); - }); - - it('should be added few policies', async () => { - const enfDelegate = await createEnfDelegate(); - - await enfDelegate.addPolicies([policy, secondPolicy]); - - const storePolicies = await enfDelegate.getPolicy(); - - expect(storePolicies.length).toEqual(2); - expect(storePolicies).toEqual( - expect.arrayContaining([policy, secondPolicy]), - ); - expect(enfAddPoliciesSpy).toHaveBeenCalledWith([policy, secondPolicy]); - }); - - it('should not fail, when argument is empty array', async () => { - const enfDelegate = await createEnfDelegate(); - - enfDelegate.addPolicies([]); - - expect(enfAddPoliciesSpy).not.toHaveBeenCalled(); - expect((await enfDelegate.getPolicy()).length).toEqual(0); - }); - }); - - describe('addGroupingPolicy', () => { - it('should add grouping policy and create role metadata', async () => { - (roleMetadataStorageMock.findRoleMetadata as jest.Mock).mockReturnValue( - Promise.resolve(undefined), - ); - - const enfDelegate = await createEnfDelegate(); - - const roleEntityRef = 'role:default/dev-team'; - await enfDelegate.addGroupingPolicy(groupingPolicy, { - source: 'rest', - roleEntityRef: roleEntityRef, - author: modifiedBy, - modifiedBy, - }); - - expect(enfAddGroupingPolicySpy).toHaveBeenCalledWith(...groupingPolicy); - expect(roleMetadataStorageMock.createRoleMetadata).toHaveBeenCalled(); - expect( - (roleMetadataStorageMock.createRoleMetadata as jest.Mock).mock.calls - .length, - ).toEqual(1); - const metadata: RoleMetadataDao = ( - roleMetadataStorageMock.createRoleMetadata as jest.Mock - ).mock.calls[0][0]; - const createdAtData = new Date(`${metadata.createdAt}`); - const lastModified = new Date(`${metadata.lastModified}`); - expect(lastModified).toEqual(createdAtData); - - expect(metadata.source).toEqual('rest'); - expect(metadata.roleEntityRef).toEqual('role:default/dev-team'); - }); - - it('should fail to add policy, caused role metadata storage error', async () => { - const enfDelegate = await createEnfDelegate(); - - roleMetadataStorageMock.createRoleMetadata = jest - .fn() - .mockImplementation(() => { - throw new Error('some unexpected error'); - }); - - await expect( - enfDelegate.addGroupingPolicy(groupingPolicy, { - source: 'rest', - roleEntityRef: 'role:default/dev-team', - author: modifiedBy, - modifiedBy, - }), - ).rejects.toThrow('some unexpected error'); - }); - - it('should update role metadata on addGroupingPolicy, because metadata has been created', async () => { - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async ( - _roleEntityRef: string, - _trx: Knex.Knex.Transaction, - ): Promise => { - return { - source: 'csv-file', - roleEntityRef: 'role:default/dev-team', - createdAt: '2024-03-01 00:23:41+00', - author: modifiedBy, - modifiedBy, - }; - }, - ); - - const enfDelegate = await createEnfDelegate(); - - const roleEntityRef = 'role:default/dev-team'; - await enfDelegate.addGroupingPolicy(groupingPolicy, { - source: 'rest', - roleEntityRef, - author: modifiedBy, - modifiedBy, - }); - - expect(enfAddGroupingPolicySpy).toHaveBeenCalledWith(...groupingPolicy); - - expect(roleMetadataStorageMock.createRoleMetadata).not.toHaveBeenCalled(); - const metadata: RoleMetadataDao = ( - roleMetadataStorageMock.updateRoleMetadata as jest.Mock - ).mock.calls[0][0]; - const createdAtData = new Date(`${metadata.createdAt}`); - const lastModified = new Date(`${metadata.lastModified}`); - expect(lastModified > createdAtData).toBeTruthy(); - - expect(metadata.source).toEqual('rest'); - expect(metadata.roleEntityRef).toEqual('role:default/dev-team'); - }); - }); - - describe('addGroupingPolicies', () => { - it('should add grouping policies and create role metadata', async () => { - const enfDelegate = await createEnfDelegate(); - - const roleMetadataDao: RoleMetadataDao = { - roleEntityRef: 'role:default/security', - source: 'rest', - author: modifiedBy, - modifiedBy, - }; - await enfDelegate.addGroupingPolicies( - [groupingPolicy, secondGroupingPolicy], - roleMetadataDao, - ); - - const storedPolicies = await enfDelegate.getGroupingPolicy(); - expect(storedPolicies).toEqual([groupingPolicy, secondGroupingPolicy]); - - expect(enfAddGroupingPoliciesSpy).toHaveBeenCalledWith([ - groupingPolicy, - secondGroupingPolicy, - ]); - - expect(roleMetadataStorageMock.createRoleMetadata).toHaveBeenCalledWith( - roleMetadataDao, - expect.anything(), - ); - - const metadata: RoleMetadataDao = ( - roleMetadataStorageMock.createRoleMetadata as jest.Mock - ).mock.calls[0][0]; - - const createdAtData = new Date(`${metadata.createdAt}`); - const lastModified = new Date(`${metadata.lastModified}`); - expect(lastModified).toEqual(createdAtData); - expect(metadata.author).toEqual(modifiedBy); - expect(metadata.roleEntityRef).toEqual('role:default/security'); - expect(metadata.source).toEqual('rest'); - expect(metadata.description).toBeUndefined(); - }); - - it('should add grouping policies and create role metadata with description', async () => { - const enfDelegate = await createEnfDelegate(); - - const description = 'Role for security engineers'; - const roleMetadataDao: RoleMetadataDao = { - roleEntityRef: 'role:default/security', - source: 'rest', - description, - author: modifiedBy, - modifiedBy, - }; - await enfDelegate.addGroupingPolicies( - [groupingPolicy, secondGroupingPolicy], - roleMetadataDao, - ); - - const storedPolicies = await enfDelegate.getGroupingPolicy(); - expect(storedPolicies).toEqual([groupingPolicy, secondGroupingPolicy]); - - expect(enfAddGroupingPoliciesSpy).toHaveBeenCalledWith([ - groupingPolicy, - secondGroupingPolicy, - ]); - - expect(roleMetadataStorageMock.createRoleMetadata).toHaveBeenCalledWith( - roleMetadataDao, - expect.anything(), - ); - - const metadata: RoleMetadataDao = ( - roleMetadataStorageMock.createRoleMetadata as jest.Mock - ).mock.calls[0][0]; - - const createdAtData = new Date(`${metadata.createdAt}`); - const lastModified = new Date(`${metadata.lastModified}`); - expect(lastModified).toEqual(createdAtData); - expect(metadata.roleEntityRef).toEqual('role:default/security'); - expect(metadata.source).toEqual('rest'); - expect(metadata.description).toEqual('Role for security engineers'); - }); - - it('should fail to add grouping policy, because fail to create role metadata', async () => { - roleMetadataStorageMock.createRoleMetadata = jest - .fn() - .mockImplementation(() => { - throw new Error('some unexpected error'); - }); - - const enfDelegate = await createEnfDelegate(); - - const roleMetadataDao: RoleMetadataDao = { - roleEntityRef: 'role:default/security', - source: 'rest', - author: 'user:default/some-user', - modifiedBy: 'user:default/some-user', - }; - await expect( - enfDelegate.addGroupingPolicies( - [groupingPolicy, secondGroupingPolicy], - roleMetadataDao, - ), - ).rejects.toThrow('some unexpected error'); - - // shouldn't store group policies - const storedPolicies = await enfDelegate.getGroupingPolicy(); - expect(storedPolicies).toEqual([]); - }); - - it('should update role metadata, because metadata has been created', async () => { - (roleMetadataStorageMock.findRoleMetadata as jest.Mock) = jest - .fn() - .mockReturnValueOnce({ - source: 'csv-file', - roleEntityRef: 'role:default/dev-team', - author: 'user:default/some-user', - description: 'Role for dev engineers', - createdAt: '2024-03-01 00:23:41+00', - }); - - const enfDelegate = await createEnfDelegate(); - - const roleMetadataDao: RoleMetadataDao = { - roleEntityRef: 'role:default/dev-team', - source: 'rest', - author: 'user:default/some-user', - modifiedBy, - }; - await enfDelegate.addGroupingPolicies( - [ - ['user:default/tom', 'role:default/dev-team'], - ['user:default/tim', 'role:default/dev-team'], - ], - roleMetadataDao, - ); - const storedPolicies = await enfDelegate.getGroupingPolicy(); - - expect(storedPolicies).toEqual([ - ['user:default/tom', 'role:default/dev-team'], - ['user:default/tim', 'role:default/dev-team'], - ]); - - expect(enfAddGroupingPoliciesSpy).toHaveBeenCalledWith([ - ['user:default/tom', 'role:default/dev-team'], - ['user:default/tim', 'role:default/dev-team'], - ]); - - expect(roleMetadataStorageMock.createRoleMetadata).not.toHaveBeenCalled(); - - const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) - .mock.calls[0][0]; - - const createdAtData = new Date(`${metadata.createdAt}`); - const lastModified = new Date(`${metadata.lastModified}`); - expect(lastModified > createdAtData).toBeTruthy(); - expect(metadata.author).toEqual('user:default/some-user'); - expect(metadata.description).toEqual('Role for dev engineers'); - expect(metadata.modifiedBy).toEqual(modifiedBy); - expect(metadata.roleEntityRef).toEqual('role:default/dev-team'); - expect(metadata.source).toEqual('rest'); - }); - }); - - describe('updateGroupingPolicies', () => { - it('should update grouping policies: add one more policy and update roleMetadata with new modifiedBy', async () => { - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation(async (): Promise => { - return { - source: 'rest', - roleEntityRef: 'role:default/dev-team', - author: 'user:default/tom', - modifiedBy: 'user:default/tom', - description: 'Role for dev engineers', - createdAt: '2024-03-01 00:23:41+00', - }; - }); - - const enfDelegate = await createEnfDelegate([], [groupingPolicy]); - - const roleMetadataDao: RoleMetadataDao = { - roleEntityRef: 'role:default/dev-team', - source: 'rest', - author: modifiedBy, - modifiedBy: 'user:default/system-admin', - }; - - await enfDelegate.updateGroupingPolicies( - [groupingPolicy], - [groupingPolicy, secondGroupingPolicy], - roleMetadataDao, - ); - - const storedPolicies = await enfDelegate.getGroupingPolicy(); - expect(storedPolicies.length).toEqual(2); - - expect(enfRemoveGroupingPoliciesSpy).toHaveBeenCalledWith([ - groupingPolicy, - ]); - expect(enfAddGroupingPoliciesSpy).toHaveBeenCalledWith([ - groupingPolicy, - secondGroupingPolicy, - ]); - - const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) - .mock.calls[0][0]; - - const createdAtData = new Date(`${metadata.createdAt}`); - const lastModified = new Date(`${metadata.lastModified}`); - expect(lastModified > createdAtData).toBeTruthy(); - expect(metadata.author).toEqual('user:default/tom'); - expect(metadata.description).toEqual('Role for dev engineers'); - expect(metadata.modifiedBy).toEqual('user:default/system-admin'); - expect(metadata.roleEntityRef).toEqual('role:default/dev-team'); - expect(metadata.source).toEqual('rest'); - }); - - it('should update grouping policies: one policy should be removed for updateGroupingPolicies', async () => { - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation(async (): Promise => { - return { - source: 'rest', - roleEntityRef: 'role:default/dev-team', - author: modifiedBy, - modifiedBy, - description: 'Role for dev engineers', - createdAt: '2024-03-01 00:23:41+00', - }; - }); - - const enfDelegate = await createEnfDelegate( - [], - [groupingPolicy, secondGroupingPolicy], - ); - - const roleMetadataDao: RoleMetadataDao = { - roleEntityRef: 'role:default/dev-team', - source: 'rest', - author: modifiedBy, - modifiedBy: 'user:default/system-admin', - }; - await enfDelegate.updateGroupingPolicies( - [groupingPolicy, secondGroupingPolicy], - [groupingPolicy], - roleMetadataDao, - ); - - const storedPolicies = await enfDelegate.getGroupingPolicy(); - expect(storedPolicies.length).toEqual(1); - - expect(enfRemoveGroupingPoliciesSpy).toHaveBeenCalledWith([ - groupingPolicy, - secondGroupingPolicy, - ]); - expect(enfAddGroupingPoliciesSpy).toHaveBeenCalledWith([groupingPolicy]); - - const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) - .mock.calls[0][0]; - - const createdAtData = new Date(`${metadata.createdAt}`); - const lastModified = new Date(`${metadata.lastModified}`); - expect(lastModified > createdAtData).toBeTruthy(); - expect(metadata.author).toEqual(modifiedBy); - expect(metadata.description).toEqual('Role for dev engineers'); - expect(metadata.modifiedBy).toEqual('user:default/system-admin'); - expect(metadata.roleEntityRef).toEqual('role:default/dev-team'); - expect(metadata.source).toEqual('rest'); - }); - - it('should update grouping policies: one policy should be removed and description updated', async () => { - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation(async (): Promise => { - return { - source: 'rest', - roleEntityRef: 'role:default/dev-team', - author: 'user:default/some-user', - modifiedBy: 'user:default/some-user', - description: 'Role for dev engineers', - createdAt: '2024-03-01 00:23:41+00', - }; - }); - - const enfDelegate = await createEnfDelegate( - [], - [groupingPolicy, secondGroupingPolicy], - ); - - const roleMetadataDao: RoleMetadataDao = { - roleEntityRef: 'role:default/dev-team', - source: 'rest', - author: modifiedBy, - modifiedBy: 'user:default/system-admin', - description: 'updated description', - }; - await enfDelegate.updateGroupingPolicies( - [groupingPolicy, secondGroupingPolicy], - [groupingPolicy], - roleMetadataDao, - ); - - const storedPolicies = await enfDelegate.getGroupingPolicy(); - expect(storedPolicies.length).toEqual(1); - - expect(enfRemoveGroupingPoliciesSpy).toHaveBeenCalledWith([ - groupingPolicy, - secondGroupingPolicy, - ]); - expect(enfAddGroupingPoliciesSpy).toHaveBeenCalledWith([groupingPolicy]); - - const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) - .mock.calls[0][0]; - - const createdAtData = new Date(`${metadata.createdAt}`); - const lastModified = new Date(`${metadata.lastModified}`); - expect(lastModified > createdAtData).toBeTruthy(); - expect(metadata.author).toEqual('user:default/some-user'); - expect(metadata.description).toEqual('updated description'); - expect(metadata.modifiedBy).toEqual('user:default/system-admin'); - expect(metadata.roleEntityRef).toEqual('role:default/dev-team'); - expect(metadata.source).toEqual('rest'); - }); - - it('should update grouping policies: role should be renamed', async () => { - const oldRoleName = 'role:default/dev-team'; - const newRoleName = 'role:default/new-team-name'; - - const oldCondition = { - id: 1, - pluginId: 'catalog', - resourceType: 'catalog-entity', - actions: ['read'], - roleEntityRef: oldRoleName, - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['group:default/team-a'], - }, - }, - }; - ( - conditionalStorageMock.filterConditions as jest.Mock - ).mockReturnValueOnce([oldCondition]); - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async ( - roleEntityRef: string, - _trx: Knex.Knex.Transaction, - ): Promise => { - if (roleEntityRef === oldRoleName) { - return { - source: 'rest', - roleEntityRef: oldRoleName, - author: modifiedBy, - modifiedBy, - description: 'Role for dev engineers', - createdAt: '2024-03-01 00:23:41+00', - }; - } - return undefined; - }, - ); - - const secondGroupingPolicyWithOldRole = ['user:default/tim', oldRoleName]; - const policyWithOldRole = [ - oldRoleName, - 'catalog-entity', - 'delete', - 'allow', - ]; - const expectedPolicies = [ - secondPolicy, - [newRoleName, 'policy-entity', 'read', 'allow'], - [newRoleName, 'catalog-entity', 'delete', 'allow'], - ]; - - const enfDelegate = await createEnfDelegate( - [policy, secondPolicy, policyWithOldRole], - [groupingPolicy, secondGroupingPolicy, secondGroupingPolicyWithOldRole], - ); - - const groupingPolicyWithRenamedRole = ['user:default/tom', newRoleName]; - const secondGroupingPolicyWithRenamedRole = [ - 'user:default/tim', - newRoleName, - ]; - - const roleMetadataDao: RoleMetadataDao = { - roleEntityRef: newRoleName, - source: 'rest', - modifiedBy, - }; - await enfDelegate.updateGroupingPolicies( - [groupingPolicy, secondGroupingPolicyWithOldRole], - [groupingPolicyWithRenamedRole, secondGroupingPolicyWithRenamedRole], - roleMetadataDao, - ); - - const storedPolicies = await enfDelegate.getGroupingPolicy(); - expect(storedPolicies.length).toEqual(3); - expect(storedPolicies[0]).toEqual(secondGroupingPolicy); // different role remained unchanged - expect(storedPolicies[1]).toEqual(groupingPolicyWithRenamedRole); - expect(storedPolicies[2]).toEqual(secondGroupingPolicyWithRenamedRole); - - expect(enfRemoveGroupingPoliciesSpy).toHaveBeenCalledWith([ - groupingPolicy, - secondGroupingPolicyWithOldRole, - ]); - expect(enfAddGroupingPoliciesSpy).toHaveBeenCalledWith([ - groupingPolicyWithRenamedRole, - secondGroupingPolicyWithRenamedRole, - ]); - - const updatedCondition: RoleConditionalPolicyDecision = ( - conditionalStorageMock.updateCondition as jest.Mock - ).mock.calls[0][1]; - expect(updatedCondition).toEqual({ - ...oldCondition, - roleEntityRef: newRoleName, - }); - - const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) - .mock.calls[0][0]; - - const createdAtData = new Date(`${metadata.createdAt}`); - const lastModified = new Date(`${metadata.lastModified}`); - expect(lastModified > createdAtData).toBeTruthy(); - expect(metadata.author).toEqual(modifiedBy); - expect(metadata.description).toEqual('Role for dev engineers'); - expect(metadata.modifiedBy).toEqual(modifiedBy); - expect(metadata.roleEntityRef).toEqual(newRoleName); - expect(metadata.source).toEqual('rest'); - expect(await enfDelegate.getPolicy()).toEqual(expectedPolicies); - }); - - it('should update grouping policies: should be updated role description and source', async () => { - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation(async (): Promise => { - return { - source: 'legacy', - roleEntityRef: 'role:default/dev-team', - author: modifiedBy, - description: 'Role for dev engineers', - createdAt: '2024-03-01 00:23:41+00', - modifiedBy, - }; - }); - - const enfDelegate = await createEnfDelegate([], [groupingPolicy]); - - const roleMetadataDao: RoleMetadataDao = { - roleEntityRef: 'role:default/dev-team', - source: 'rest', - modifiedBy, - description: 'some-new-description', - }; - await enfDelegate.updateGroupingPolicies( - [groupingPolicy], - [groupingPolicy], - roleMetadataDao, - ); - - const storedPolicies = await enfDelegate.getGroupingPolicy(); - expect(storedPolicies.length).toEqual(1); - expect(storedPolicies).toEqual([groupingPolicy]); - - const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) - .mock.calls[0][0]; - - const createdAtData = new Date(`${metadata.createdAt}`); - const lastModified = new Date(`${metadata.lastModified}`); - expect(lastModified > createdAtData).toBeTruthy(); - expect(metadata.author).toEqual(modifiedBy); - expect(metadata.description).toEqual('some-new-description'); - expect(metadata.modifiedBy).toEqual(modifiedBy); - expect(metadata.roleEntityRef).toEqual('role:default/dev-team'); - expect(metadata.source).toEqual('rest'); - }); - }); - - describe('updatePolicies', () => { - it('should be updated single policy', async () => { - const enfDelegate = await createEnfDelegate([policy]); - enfAddPolicySpy.mockClear(); - enfRemovePoliciesSpy.mockClear(); - - const newPolicy = ['user:default/tom', 'policy-entity', 'read', 'deny']; - - await enfDelegate.updatePolicies([policy], [newPolicy]); - - expect(enfRemovePoliciesSpy).toHaveBeenCalledWith([policy]); - expect(enfAddPoliciesSpy).toHaveBeenCalledWith([newPolicy]); - }); - - it('should be added few policies', async () => { - const enfDelegate = await createEnfDelegate([policy, secondPolicy]); - enfAddPolicySpy.mockClear(); - enfRemovePoliciesSpy.mockClear(); - - const newPolicy1 = ['user:default/tom', 'policy-entity', 'read', 'deny']; - const newPolicy2 = [ - 'user:default/tim', - 'catalog-entity', - 'write', - 'allow', - ]; - - await enfDelegate.updatePolicies( - [policy, secondPolicy], - [newPolicy1, newPolicy2], - ); - - expect(enfRemovePoliciesSpy).toHaveBeenCalledWith([policy, secondPolicy]); - expect(enfAddPoliciesSpy).toHaveBeenCalledWith([newPolicy1, newPolicy2]); - }); - }); - - describe('removePolicy', () => { - const policyToDelete = [ - 'user:default/some-user', - 'catalog-entity', - 'read', - 'allow', - ]; - - it('policy should be removed', async () => { - const enfDelegate = await createEnfDelegate([policyToDelete]); - await enfDelegate.removePolicy(policyToDelete); - - expect(enfRemovePolicySpy).toHaveBeenCalledWith(...policyToDelete); - }); - }); - - describe('removePolicies', () => { - const policiesToDelete = [ - ['user:default/some-user', 'catalog-entity', 'read', 'allow'], - ['user:default/some-user-2', 'catalog-entity', 'read', 'allow'], - ]; - it('policies should be removed', async () => { - const enfDelegate = await createEnfDelegate(policiesToDelete); - await enfDelegate.removePolicies(policiesToDelete); - - expect(enfRemovePoliciesSpy).toHaveBeenCalledWith(policiesToDelete); - }); - }); - - describe('removeGroupingPolicy', () => { - const groupingPolicyToDelete = [ - 'user:default/some-user', - 'role:default/team-dev', - ]; - - beforeEach(() => { - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation(() => { - return { - source: 'rest', - roleEntityRef: 'role:default/team-dev', - createdAt: '2024-03-01 00:23:41+00', - }; - }); - }); - - it('should remove grouping policy and remove role metadata', async () => { - const enfDelegate = await createEnfDelegate([], [groupingPolicyToDelete]); - await enfDelegate.removeGroupingPolicy( - groupingPolicyToDelete, - { source: 'rest', roleEntityRef: 'role:default/team-dev', modifiedBy }, - false, - ); - - expect(roleMetadataStorageMock.findRoleMetadata).toHaveBeenCalledTimes(1); - expect(adapterLoaderFilterGroupingPolicySpy).toHaveBeenCalledTimes(1); - - expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( - 'role:default/team-dev', - expect.anything(), - ); - }); - - it('should remove grouping policy and update role metadata', async () => { - const enfDelegate = await createEnfDelegate( - [], - [ - groupingPolicyToDelete, - ['group:default/team-a', 'role:default/team-dev'], - ], - ); - await enfDelegate.removeGroupingPolicy( - groupingPolicyToDelete, - { source: 'rest', roleEntityRef: 'role:default/team-dev', modifiedBy }, - false, - ); - - expect(roleMetadataStorageMock.findRoleMetadata).toHaveBeenCalledTimes(1); - expect(adapterLoaderFilterGroupingPolicySpy).toHaveBeenCalledTimes(1); - - const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) - .mock.calls[0][0]; - - const createdAtData = new Date(`${metadata.createdAt}`); - const lastModified = new Date(`${metadata.lastModified}`); - expect(lastModified > createdAtData).toBeTruthy(); - - expect(metadata.roleEntityRef).toEqual('role:default/team-dev'); - expect(metadata.source).toEqual('rest'); - }); - - it('should remove grouping policy and not update or remove role metadata, because isUpdate flag set to true', async () => { - const enfDelegate = await createEnfDelegate([], [groupingPolicyToDelete]); - await enfDelegate.removeGroupingPolicy( - groupingPolicyToDelete, - { - source: 'rest', - roleEntityRef: 'role:default/dev-team', - modifiedBy: 'user:default/some-user', - }, - true, - ); - - expect(enfRemoveGroupingPolicySpy).toHaveBeenCalledWith( - ...groupingPolicyToDelete, - ); - - expect(roleMetadataStorageMock.findRoleMetadata).not.toHaveBeenCalled(); - expect(adapterLoaderFilterGroupingPolicySpy).not.toHaveBeenCalled(); - expect(roleMetadataStorageMock.removeRoleMetadata).not.toHaveBeenCalled(); - expect(roleMetadataStorageMock.updateRoleMetadata).not.toHaveBeenCalled(); - }); - }); - - describe('removeGroupingPolicies', () => { - const groupingPoliciesToDelete = [ - ['user:default/some-user', 'role:default/team-dev'], - ['group:default/team-a', 'role:default/team-dev'], - ]; - - it('should remove grouping policies and remove role metadata', async () => { - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation(() => { - return { - source: 'rest', - roleEntityRef: 'role:default/team-dev', - }; - }); - enfRemoveGroupingPoliciesSpy.mockReset(); - adapterLoaderFilterGroupingPolicySpy.mockReset(); - - const enfDelegate = await createEnfDelegate([], groupingPoliciesToDelete); - await enfDelegate.removeGroupingPolicies( - groupingPoliciesToDelete, - { - roleEntityRef: 'role:default/team-dev', - source: 'rest', - modifiedBy, - }, - false, - ); - - expect(enfRemoveGroupingPoliciesSpy).toHaveBeenCalledWith( - groupingPoliciesToDelete, - ); - - expect(roleMetadataStorageMock.findRoleMetadata).toHaveBeenCalledTimes(1); - expect(adapterLoaderFilterGroupingPolicySpy).toHaveBeenCalledTimes(1); - - expect(roleMetadataStorageMock.removeRoleMetadata).toHaveBeenCalledWith( - 'role:default/team-dev', - expect.anything(), - ); - }); - - it('should remove grouping policies and update role metadata', async () => { - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation(() => { - return { - source: 'rest', - roleEntityRef: 'role:default/team-dev', - createdAt: '2024-03-01 00:23:41+00', - }; - }); - enfRemoveGroupingPoliciesSpy.mockReset(); - adapterLoaderFilterGroupingPolicySpy.mockReset(); - - const remainingGroupPolicy = [ - 'user:default/some-user-2', - 'role:default/team-dev', - ]; - const enfDelegate = await createEnfDelegate( - [], - [...groupingPoliciesToDelete, remainingGroupPolicy], - ); - await enfDelegate.removeGroupingPolicies( - groupingPoliciesToDelete, - { - roleEntityRef: 'role:default/team-dev', - source: 'rest', - modifiedBy, - }, - false, - ); - - expect(enfRemoveGroupingPoliciesSpy).toHaveBeenCalledWith( - groupingPoliciesToDelete, - ); - - expect(roleMetadataStorageMock.findRoleMetadata).toHaveBeenCalledTimes(1); - expect(adapterLoaderFilterGroupingPolicySpy).toHaveBeenCalledTimes(1); - - const metadata = (roleMetadataStorageMock.updateRoleMetadata as jest.Mock) - .mock.calls[0][0]; - - const createdAtData = new Date(`${metadata.createdAt}`); - const lastModified = new Date(`${metadata.lastModified}`); - expect(lastModified > createdAtData).toBeTruthy(); - - expect(metadata.roleEntityRef).toEqual('role:default/team-dev'); - expect(metadata.source).toEqual('rest'); - }); - - it('should remove grouping policy and not update or remove role metadata, because isUpdate flag set to true', async () => { - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation(() => { - return { - source: 'rest', - roleEntityRef: 'role:default/team-dev', - }; - }); - enfRemoveGroupingPoliciesSpy.mockReset(); - adapterLoaderFilterGroupingPolicySpy.mockReset(); - - const enfDelegate = await createEnfDelegate([], groupingPoliciesToDelete); - await enfDelegate.removeGroupingPolicies( - groupingPoliciesToDelete, - { - roleEntityRef: 'role:default/team-dev', - source: 'rest', - modifiedBy: 'user:default/test-user', - }, - true, - ); - - expect(enfRemoveGroupingPoliciesSpy).toHaveBeenCalledWith( - groupingPoliciesToDelete, - ); - - expect(roleMetadataStorageMock.findRoleMetadata).not.toHaveBeenCalled(); - expect(adapterLoaderFilterGroupingPolicySpy).not.toHaveBeenCalled(); - expect(roleMetadataStorageMock.removeRoleMetadata).not.toHaveBeenCalled(); - expect(roleMetadataStorageMock.updateRoleMetadata).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/plugins/rbac-backend/src/service/enforcer-delegate.ts b/plugins/rbac-backend/src/service/enforcer-delegate.ts deleted file mode 100644 index 57d6eb8b71..0000000000 --- a/plugins/rbac-backend/src/service/enforcer-delegate.ts +++ /dev/null @@ -1,742 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { Enforcer, FilteredAdapter, newModelFromString } from 'casbin'; -import { Knex } from 'knex'; - -import EventEmitter from 'events'; - -import { ADMIN_ROLE_NAME } from '../admin-permissions/admin-creation'; -import { - RoleMetadataDao, - RoleMetadataStorage, -} from '../database/role-metadata'; -import { mergeRoleMetadata, policiesToString, policyToString } from '../helper'; -import { MODEL } from './permission-model'; -import { PoliciesData } from '../auditor/auditor'; -import { AuditorService } from '@backstage/backend-plugin-api'; -import { ConditionalStorage } from '../database/conditional-storage'; - -export type RoleEvents = 'roleAdded'; -export interface RoleEventEmitter { - on(event: T, listener: (roleEntityRef: string | string[]) => void): this; -} - -type EventMap = { - [event in RoleEvents]: any[]; -}; - -export class EnforcerDelegate implements RoleEventEmitter { - private readonly roleEventEmitter = new EventEmitter(); - - private loadPolicyPromise: Promise | null = null; - private editOperationsQueue: Promise[] = []; // Queue to track edit operations - - constructor( - private readonly enforcer: Enforcer, - private readonly auditor: AuditorService, - private readonly conditionalStorage: ConditionalStorage, - private readonly roleMetadataStorage: RoleMetadataStorage, - private readonly knex: Knex, - ) {} - - async loadPolicy(): Promise { - if (this.loadPolicyPromise) { - // If a load operation is already in progress, return the cached promise - return this.loadPolicyPromise; - } - - this.loadPolicyPromise = (async () => { - try { - await this.waitForEditOperationsToFinish(); - - await this.enforcer.loadPolicy(); - } catch (error) { - const auditorEvent = await this.auditor.createEvent({ - eventId: PoliciesData.PERMISSIONS_READ, - severityLevel: 'medium', - }); - await auditorEvent.fail({ error }); - } finally { - this.loadPolicyPromise = null; - } - })(); - - return this.loadPolicyPromise; - } - - private async waitForEditOperationsToFinish(): Promise { - await Promise.all(this.editOperationsQueue); - } - - async execOperation(operation: Promise): Promise { - this.editOperationsQueue.push(operation); - - let result; - try { - result = await operation; - } catch (err) { - throw err; - } finally { - const index = this.editOperationsQueue.indexOf(operation); - if (index !== -1) { - this.editOperationsQueue.splice(index, 1); - } - } - - return result; - } - - on(event: RoleEvents, listener: (role: string) => void): this { - this.roleEventEmitter.on(event, listener); - return this; - } - - async hasPolicy(...policy: string[]): Promise { - const tempModel = newModelFromString(MODEL); - await (this.enforcer.getAdapter() as FilteredAdapter).loadFilteredPolicy( - tempModel, - [ - { - ptype: 'p', - v0: policy[0], - v1: policy[1], - v2: policy[2], - v3: policy[3], - }, - ], - ); - return tempModel.hasPolicy('p', 'p', policy); - } - - async hasGroupingPolicy(...policy: string[]): Promise { - const tempModel = newModelFromString(MODEL); - await (this.enforcer.getAdapter() as FilteredAdapter).loadFilteredPolicy( - tempModel, - [ - { - ptype: 'g', - v0: policy[0], - v1: policy[1], - }, - ], - ); - return tempModel.hasPolicy('g', 'g', policy); - } - - async getPolicy(): Promise { - const tempModel = newModelFromString(MODEL); - await (this.enforcer.getAdapter() as FilteredAdapter).loadFilteredPolicy( - tempModel, - [{ ptype: 'p' }], - ); - return await tempModel.getPolicy('p', 'p'); - } - - async getGroupingPolicy(): Promise { - const tempModel = newModelFromString(MODEL); - await (this.enforcer.getAdapter() as FilteredAdapter).loadFilteredPolicy( - tempModel, - [{ ptype: 'g' }], - ); - return await tempModel.getPolicy('g', 'g'); - } - - async getRolesForUser(userEntityRef: string): Promise { - return await this.enforcer.getRolesForUser(userEntityRef); - } - - async getFilteredPolicy( - fieldIndex: number, - ...filter: string[] - ): Promise { - const tempModel = newModelFromString(MODEL); - - const filterObj: Record = { ptype: 'p' }; - for (let i = 0; i < filter.length; i++) { - if (filter[i]) { - filterObj[`v${i + fieldIndex}`] = filter[i]; - } - } - - await (this.enforcer.getAdapter() as FilteredAdapter).loadFilteredPolicy( - tempModel, - [filterObj], - ); - - return await tempModel.getPolicy('p', 'p'); - } - - async getFilteredGroupingPolicy( - fieldIndex: number, - ...filter: string[] - ): Promise { - const tempModel = newModelFromString(MODEL); - - const filterObj: Record = { ptype: 'g' }; - for (let i = 0; i < filter.length; i++) { - if (filter[i]) { - filterObj[`v${i + fieldIndex}`] = filter[i]; - } - } - - await (this.enforcer.getAdapter() as FilteredAdapter).loadFilteredPolicy( - tempModel, - [filterObj], - ); - - return await tempModel.getPolicy('g', 'g'); - } - - async addPolicy( - policy: string[], - externalTrx?: Knex.Transaction, - ): Promise { - const trx = externalTrx ?? (await this.knex.transaction()); - - if (await this.hasPolicy(...policy)) { - return; - } - try { - const ok = await this.enforcer.addPolicy(...policy); - if (!ok) { - throw new Error(`failed to create policy ${policyToString(policy)}`); - } - if (!externalTrx) { - await trx.commit(); - } - } catch (err) { - if (!externalTrx) { - await trx.rollback(err); - } - throw err; - } - } - - async addPolicies( - policies: string[][], - externalTrx?: Knex.Transaction, - ): Promise { - if (this.loadPolicyPromise) { - await this.loadPolicyPromise; - } else { - await this.loadPolicy(); - } - - const addPoliciesOperation = (async () => { - if (policies.length === 0) { - return; - } - - const trx = externalTrx || (await this.knex.transaction()); - - try { - const ok = await this.enforcer.addPolicies(policies); - if (!ok) { - throw new Error( - `Failed to store policies ${policiesToString(policies)}`, - ); - } - if (!externalTrx) { - await trx.commit(); - } - } catch (err) { - if (!externalTrx) { - await trx.rollback(err); - } - throw err; - } - })(); - await this.execOperation(addPoliciesOperation); - } - - async addGroupingPolicy( - policy: string[], - roleMetadata: RoleMetadataDao, - externalTrx?: Knex.Transaction, - ): Promise { - if (this.loadPolicyPromise) { - await this.loadPolicyPromise; - } else { - await this.loadPolicy(); - } - - const addGroupingPolicyOperation = (async () => { - const trx = externalTrx ?? (await this.knex.transaction()); - const entityRef = roleMetadata.roleEntityRef; - - if (await this.hasGroupingPolicy(...policy)) { - return; - } - try { - let currentMetadata; - if (entityRef.startsWith(`role:`)) { - currentMetadata = await this.roleMetadataStorage.findRoleMetadata( - entityRef, - trx, - ); - } - - if (currentMetadata) { - await this.roleMetadataStorage.updateRoleMetadata( - mergeRoleMetadata(currentMetadata, roleMetadata), - entityRef, - trx, - ); - } else { - const currentDate: Date = new Date(); - roleMetadata.createdAt = currentDate.toUTCString(); - roleMetadata.lastModified = currentDate.toUTCString(); - await this.roleMetadataStorage.createRoleMetadata(roleMetadata, trx); - } - - const ok = await this.enforcer.addGroupingPolicy(...policy); - if (!ok) { - throw new Error(`failed to create policy ${policyToString(policy)}`); - } - if (!externalTrx) { - await trx.commit(); - } - if (!currentMetadata) { - this.roleEventEmitter.emit('roleAdded', roleMetadata.roleEntityRef); - } - } catch (err) { - if (!externalTrx) { - await trx.rollback(err); - } - throw err; - } - })(); - await this.execOperation(addGroupingPolicyOperation); - } - - async addGroupingPolicies( - policies: string[][], - roleMetadata: RoleMetadataDao, - oldRoleEntityRef?: string, - externalTrx?: Knex.Transaction, - ): Promise { - if (this.loadPolicyPromise) { - await this.loadPolicyPromise; - } else { - await this.loadPolicy(); - } - - const addGroupingPoliciesOperation = (async () => { - if (policies.length === 0) { - return; - } - - const trx = externalTrx ?? (await this.knex.transaction()); - - try { - const currentRoleMetadata = - await this.roleMetadataStorage.findRoleMetadata( - oldRoleEntityRef ?? roleMetadata.roleEntityRef, - trx, - ); - if (currentRoleMetadata) { - await this.roleMetadataStorage.updateRoleMetadata( - mergeRoleMetadata(currentRoleMetadata, roleMetadata), - oldRoleEntityRef ?? roleMetadata.roleEntityRef, - trx, - ); - } else { - const currentDate: Date = new Date(); - roleMetadata.createdAt = currentDate.toUTCString(); - roleMetadata.lastModified = currentDate.toUTCString(); - await this.roleMetadataStorage.createRoleMetadata(roleMetadata, trx); - } - - const ok = await this.enforcer.addGroupingPolicies(policies); - if (!ok) { - throw new Error( - `Failed to store policies ${policiesToString(policies)}`, - ); - } - - if (!externalTrx) { - await trx.commit(); - } - if (!currentRoleMetadata) { - this.roleEventEmitter.emit('roleAdded', roleMetadata.roleEntityRef); - } - } catch (err) { - if (!externalTrx) { - await trx.rollback(err); - } - throw err; - } - })(); - await this.execOperation(addGroupingPoliciesOperation); - } - - async updateGroupingPolicies( - oldRole: string[][], - newRole: string[][], - newRoleMetadata: RoleMetadataDao, - ): Promise { - const oldRoleName = oldRole.at(0)?.at(1)!; - - const trx = await this.knex.transaction(); - try { - const currentMetadata = await this.roleMetadataStorage.findRoleMetadata( - oldRoleName, - trx, - ); - if (!currentMetadata) { - throw new Error(`Role metadata ${oldRoleName} was not found`); - } - - await this.removeGroupingPolicies(oldRole, currentMetadata, true, trx); - await this.addGroupingPolicies( - newRole, - newRoleMetadata, - currentMetadata.roleEntityRef, - trx, - ); - - // Role name changed -> update roleEntityRef in policies - if (newRoleMetadata.roleEntityRef !== currentMetadata.roleEntityRef) { - const oldPolicies = await this.enforcer.getFilteredPolicy( - 0, - currentMetadata.roleEntityRef, - ); - const updatedPolicies = oldPolicies.map(oldPolicy => [ - newRoleMetadata.roleEntityRef, - ...oldPolicy.slice(1), - ]); - await this.updatePolicies(oldPolicies, updatedPolicies, trx); - - const oldConditions = await this.conditionalStorage.filterConditions( - currentMetadata.roleEntityRef, - undefined, - undefined, - undefined, - undefined, - trx, - ); - for (const condition of oldConditions) { - await this.conditionalStorage.updateCondition( - condition.id, - { - ...condition, - roleEntityRef: newRoleMetadata.roleEntityRef, - }, - trx, - ); - } - } - - await trx.commit(); - } catch (err) { - await trx.rollback(err); - throw err; - } - } - - async updatePolicies( - oldPolicies: string[][], - newPolicies: string[][], - externalTrx?: Knex.Transaction, - ): Promise { - const trx = externalTrx ?? (await this.knex.transaction()); - - try { - await this.removePolicies(oldPolicies, trx); - await this.addPolicies(newPolicies, trx); - if (!externalTrx) { - await trx.commit(); - } - } catch (err) { - if (!externalTrx) { - await trx.rollback(err); - } - throw err; - } - } - - async removePolicy(policy: string[], externalTrx?: Knex.Transaction) { - if (this.loadPolicyPromise) { - await this.loadPolicyPromise; - } else { - await this.loadPolicy(); - } - - const removePolicyOperation = (async () => { - const trx = externalTrx ?? (await this.knex.transaction()); - - try { - const ok = await this.enforcer.removePolicy(...policy); - if (!ok) { - throw new Error(`fail to delete policy ${policy}`); - } - if (!externalTrx) { - await trx.commit(); - } - } catch (err) { - if (!externalTrx) { - await trx.rollback(err); - } - throw err; - } - })(); - await this.execOperation(removePolicyOperation); - } - - async removePolicies( - policies: string[][], - externalTrx?: Knex.Transaction, - ): Promise { - if (this.loadPolicyPromise) { - await this.loadPolicyPromise; - } else { - await this.loadPolicy(); - } - - const removePoliciesOperation = (async () => { - const trx = externalTrx ?? (await this.knex.transaction()); - - try { - const ok = await this.enforcer.removePolicies(policies); - if (!ok) { - throw new Error( - `Failed to delete policies ${policiesToString(policies)}`, - ); - } - - if (!externalTrx) { - await trx.commit(); - } - } catch (err) { - if (!externalTrx) { - await trx.rollback(err); - } - throw err; - } - })(); - await this.execOperation(removePoliciesOperation); - } - - async removeGroupingPolicy( - policy: string[], - roleMetadata: RoleMetadataDao, - isUpdate?: boolean, - externalTrx?: Knex.Transaction, - ): Promise { - if (this.loadPolicyPromise) { - await this.loadPolicyPromise; - } else { - await this.loadPolicy(); - } - - const removeGroupingPolicyOperation = (async () => { - const trx = externalTrx ?? (await this.knex.transaction()); - const roleEntity = policy[1]; - - try { - const ok = await this.enforcer.removeGroupingPolicy(...policy); - if (!ok) { - throw new Error(`Failed to delete policy ${policyToString(policy)}`); - } - - if (!isUpdate) { - const currentRoleMetadata = - await this.roleMetadataStorage.findRoleMetadata(roleEntity, trx); - const remainingGroupPolicies = await this.getFilteredGroupingPolicy( - 1, - roleEntity, - ); - if ( - currentRoleMetadata && - remainingGroupPolicies.length === 0 && - roleEntity !== ADMIN_ROLE_NAME - ) { - await this.roleMetadataStorage.removeRoleMetadata(roleEntity, trx); - } else if (currentRoleMetadata) { - await this.roleMetadataStorage.updateRoleMetadata( - mergeRoleMetadata(currentRoleMetadata, roleMetadata), - roleEntity, - trx, - ); - } - } - - if (!externalTrx) { - await trx.commit(); - } - } catch (err) { - if (!externalTrx) { - await trx.rollback(err); - } - throw err; - } - })(); - await this.execOperation(removeGroupingPolicyOperation); - } - - async removeGroupingPolicies( - policies: string[][], - roleMetadata: RoleMetadataDao, - isUpdate?: boolean, - externalTrx?: Knex.Transaction, - ): Promise { - if (this.loadPolicyPromise) { - await this.loadPolicyPromise; - } else { - await this.loadPolicy(); - } - - const removeGroupingPolicyOperation = (async () => { - const trx = externalTrx ?? (await this.knex.transaction()); - const roleEntity = roleMetadata.roleEntityRef; - - try { - const ok = await this.enforcer.removeGroupingPolicies(policies); - if (!ok) { - throw new Error( - `Failed to delete grouping policies: ${policiesToString(policies)}`, - ); - } - - if (!isUpdate) { - const currentRoleMetadata = - await this.roleMetadataStorage.findRoleMetadata(roleEntity, trx); - const remainingGroupPolicies = await this.getFilteredGroupingPolicy( - 1, - roleEntity, - ); - - if ( - currentRoleMetadata && - remainingGroupPolicies.length === 0 && - roleEntity !== ADMIN_ROLE_NAME - ) { - await this.roleMetadataStorage.removeRoleMetadata(roleEntity, trx); - } else if (currentRoleMetadata) { - await this.roleMetadataStorage.updateRoleMetadata( - mergeRoleMetadata(currentRoleMetadata, roleMetadata), - roleEntity, - trx, - ); - } - } - - if (!externalTrx) { - await trx.commit(); - } - } catch (err) { - if (!externalTrx) { - await trx.rollback(err); - } - throw err; - } - })(); - await this.execOperation(removeGroupingPolicyOperation); - } - - /** - * enforce aims to enforce a particular permission policy based on the user that it receives. - * Under the hood, enforce uses the `enforce` method from the enforcer`. - * - * Before enforcement, a filter is set up to reduce the number of permission policies that will - * be loaded in. - * This will reduce the amount of checks that need to be made to determine if a user is authorize - * to perform an action - * - * A temporary enforcer will also be used while enforcing. - * This is to ensure that the filter does not interact with the base enforcer. - * The temporary enforcer has lazy loading of the permission policies enabled to reduce the amount - * of time it takes to initialize the temporary enforcer. - * The justification for lazy loading is because permission policies are already present in the - * role manager / database and it will be filtered and loaded whenever `getFilteredPolicy` is called - * and permissions / roles are applied to the temp enforcer - * @param entityRef The user to enforce - * @param resourceType The resource type / name of the permission policy - * @param action The action of the permission policy - * @param roles Any roles that the user is directly or indirectly attached to. - * Used for filtering permission policies. - * @returns True if the user is allowed based on the particular permission - */ - async enforce( - entityRef: string, - resourceType: string, - action: string, - roles: string[], - ): Promise { - const model = newModelFromString(MODEL); - let policies: string[][] = []; - if (roles.length > 0) { - for (const role of roles) { - const filteredPolicy = await this.getFilteredPolicy( - 0, - role, - resourceType, - action, - ); - policies.push(...filteredPolicy); - } - } else { - const enforcePolicies = await this.getFilteredPolicy( - 1, - resourceType, - action, - ); - policies = enforcePolicies.filter( - policy => - policy[0].startsWith('user:') || policy[0].startsWith('group:'), - ); - } - - const roleManager = this.enforcer.getRoleManager(); - const tempEnforcer = new Enforcer(); - - model.addPolicies('p', 'p', policies); - - await tempEnforcer.initWithModelAndAdapter(model); - tempEnforcer.setRoleManager(roleManager); - await tempEnforcer.buildRoleLinks(); - - return await tempEnforcer.enforce(entityRef, resourceType, action); - } - - async getImplicitPermissionsForUser(user: string): Promise { - if (this.loadPolicyPromise) { - await this.loadPolicyPromise; - } else { - await this.loadPolicy(); - } - - const getPermissionsForUserOperation = (async () => { - return this.enforcer.getImplicitPermissionsForUser(user); - })(); - - return await this.execOperation(getPermissionsForUserOperation); - } - - async getAllRoles(): Promise { - if (this.loadPolicyPromise) { - await this.loadPolicyPromise; - } else { - await this.loadPolicy(); - } - - const getRolesOperation = (async () => { - return this.enforcer.getAllRoles(); - })(); - - return await this.execOperation(getRolesOperation); - } -} diff --git a/plugins/rbac-backend/src/service/extendable-id-provider.test.ts b/plugins/rbac-backend/src/service/extendable-id-provider.test.ts deleted file mode 100644 index 5cf63f5fa6..0000000000 --- a/plugins/rbac-backend/src/service/extendable-id-provider.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { mockServices } from '@backstage/backend-test-utils'; -import { - permissionDependentPluginStoreMock, - pluginIdProviderMock, -} from '../../__fixtures__/mock-utils'; -import { ExtendablePluginIdProvider } from './extendable-id-provider'; -import { Config } from '@backstage/config'; - -describe('ExtendableIdProvider', () => { - let config: Config; - - function createProvider(): ExtendablePluginIdProvider { - return new ExtendablePluginIdProvider( - permissionDependentPluginStoreMock, - pluginIdProviderMock, - config, - ); - } - - beforeEach(() => { - ( - permissionDependentPluginStoreMock.getPlugins as jest.Mock - ).mockResolvedValueOnce([]); - config = mockServices.rootConfig({ - data: { - permission: { - enabled: true, - rbac: { - pluginsWithPermission: ['argocd'], - }, - }, - }, - }); - }); - - it('should create an instance of ExtendableIdProvider', () => { - const extendableIdProvider = createProvider(); - expect(extendableIdProvider).toBeInstanceOf(ExtendablePluginIdProvider); - }); - - it('should return plugin ids only from application config', async () => { - const extendableIdProvider = createProvider(); - const pluginIds = await extendableIdProvider.getPluginIds(); - expect(pluginIds.length).toEqual(1); - expect(pluginIds).toContain('argocd'); - }); - - it('should merge plugin ids from application config and pluginIdProvider', async () => { - pluginIdProviderMock.getPluginIds.mockReturnValueOnce(['jenkins']); - - const extendableIdProvider = createProvider(); - const pluginIds = await extendableIdProvider.getPluginIds(); - expect(pluginIds.length).toEqual(2); - expect(pluginIds).toContain('argocd'); - expect(pluginIds).toContain('jenkins'); - }); - - it('should merge plugin ids from application config, pluginIdProvider and db storage', async () => { - (permissionDependentPluginStoreMock.getPlugins as jest.Mock).mockReset(); - ( - permissionDependentPluginStoreMock.getPlugins as jest.Mock - ).mockResolvedValueOnce([{ pluginId: 'scaffolder' }]); - pluginIdProviderMock.getPluginIds.mockReturnValueOnce(['jenkins']); - - const extendableIdProvider = createProvider(); - const pluginIds = await extendableIdProvider.getPluginIds(); - expect(pluginIds.length).toEqual(3); - expect(pluginIds).toContain('argocd'); - expect(pluginIds).toContain('jenkins'); - expect(pluginIds).toContain('scaffolder'); - }); - - it('should merge plugin ids from application config, pluginIdProvider and db storage without duplication', async () => { - (permissionDependentPluginStoreMock.getPlugins as jest.Mock).mockReset(); - ( - permissionDependentPluginStoreMock.getPlugins as jest.Mock - ).mockResolvedValueOnce([{ pluginId: 'jenkins' }]); - pluginIdProviderMock.getPluginIds.mockReturnValueOnce(['jenkins']); - - const extendableIdProvider = createProvider(); - const pluginIds = await extendableIdProvider.getPluginIds(); - expect(pluginIds.length).toEqual(2); - expect(pluginIds).toContain('argocd'); - expect(pluginIds).toContain('jenkins'); - }); - - it('should detect if plugin id is configured', () => { - (permissionDependentPluginStoreMock.getPlugins as jest.Mock).mockReset(); - ( - permissionDependentPluginStoreMock.getPlugins as jest.Mock - ).mockResolvedValueOnce([{ pluginId: 'scaffolder' }]); - pluginIdProviderMock.getPluginIds.mockReturnValueOnce(['jenkins']); - - const extendableIdProvider = createProvider(); - let isConfiguredPluginId = - extendableIdProvider.isConfiguredPluginId('argocd'); - expect(isConfiguredPluginId).toBe(true); - - isConfiguredPluginId = extendableIdProvider.isConfiguredPluginId('jenkins'); - expect(isConfiguredPluginId).toBe(true); - - isConfiguredPluginId = - extendableIdProvider.isConfiguredPluginId('scaffolder'); - expect(isConfiguredPluginId).toBe(false); - }); - - it('should remove conflicted plugin id, which came from database', async () => { - (permissionDependentPluginStoreMock.getPlugins as jest.Mock).mockReset(); - ( - permissionDependentPluginStoreMock.getPlugins as jest.Mock - ).mockResolvedValueOnce([{ pluginId: 'scaffolder' }]); - pluginIdProviderMock.getPluginIds.mockReturnValueOnce([ - 'jenkins', - 'scaffolder', - ]); - - const extendableIdProvider = createProvider(); - await extendableIdProvider.handleConflictedPluginIds(); - expect( - permissionDependentPluginStoreMock.deletePlugins, - ).toHaveBeenCalledWith(['scaffolder']); - }); -}); diff --git a/plugins/rbac-backend/src/service/extendable-id-provider.ts b/plugins/rbac-backend/src/service/extendable-id-provider.ts deleted file mode 100644 index 32bb383f97..0000000000 --- a/plugins/rbac-backend/src/service/extendable-id-provider.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { PluginIdProvider } from '@backstage-community/plugin-rbac-node'; -import { PermissionDependentPluginStore } from '../database/extra-permission-enabled-plugins-storage'; -import type { Config } from '@backstage/config'; -import { union } from 'lodash'; - -export class ExtendablePluginIdProvider { - // plugin ids which came from application config and PluginIdProvider - private readonly configurationPluginIds: string[]; - - constructor( - private readonly pluginStore: PermissionDependentPluginStore, - pluginIdProvider: PluginIdProvider, - config: Config, - ) { - const pluginIdsConfig = config.getOptionalStringArray( - 'permission.rbac.pluginsWithPermission', - ); - this.configurationPluginIds = pluginIdsConfig - ? union(pluginIdsConfig, pluginIdProvider.getPluginIds()) - : pluginIdProvider.getPluginIds(); - } - - isConfiguredPluginId(pluginId: string): boolean { - return this.configurationPluginIds.includes(pluginId); - } - - async handleConflictedPluginIds(): Promise { - const conflictedIds = await ( - await this.pluginStore.getPlugins() - ).filter(pId => this.configurationPluginIds.includes(pId.pluginId)); - await this.pluginStore.deletePlugins(conflictedIds.map(p => p.pluginId)); - } - - async getPluginIds(): Promise { - const extraPlugins = await this.pluginStore.getPlugins(); - return union( - this.configurationPluginIds, - extraPlugins.map(plugin => plugin.pluginId), - ); - } -} diff --git a/plugins/rbac-backend/src/service/permission-definition-routes.test.ts b/plugins/rbac-backend/src/service/permission-definition-routes.test.ts deleted file mode 100644 index 40b7614947..0000000000 --- a/plugins/rbac-backend/src/service/permission-definition-routes.test.ts +++ /dev/null @@ -1,452 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - credentials, - extendablePluginIdProviderMock, - mockAuditorService, - mockAuthService, - mockedAuthorize, - mockedAuthorizeConditional, - mockHttpAuth, - mockPermissionEvaluator, - permissionDependentPluginStoreMock, - pluginMetadataCollectorMock, -} from '../../__fixtures__/mock-utils'; -import { registerPermissionDefinitionRoutes } from './permission-definition-routes'; -import express from 'express'; -import { PluginMetadataResponseSerializedRule } from './plugin-endpoints'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; -import { - PluginPermissionMetaData, - policyEntityCreatePermission, - policyEntityDeletePermission, - policyEntityReadPermission, -} from '@backstage-community/plugin-rbac-common'; -import request from 'supertest'; -import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter'; -import { mockServices } from '@backstage/backend-test-utils'; -import { ExtendablePluginIdProvider } from './extendable-id-provider'; -import Router from 'express-promise-router'; - -describe('REST plugin policies metadata API', () => { - let app: express.Express; - - beforeEach(async () => { - const router = Router(); - - router.use(express.json()); - - registerPermissionDefinitionRoutes( - router, - pluginMetadataCollectorMock as any, - extendablePluginIdProviderMock as ExtendablePluginIdProvider, - permissionDependentPluginStoreMock, - { - auth: mockAuthService, - httpAuth: mockHttpAuth, - auditor: mockAuditorService, - permissions: mockPermissionEvaluator, - }, - ); - - const middleware = MiddlewareFactory.create({ - logger: mockServices.logger.mock(), - config: mockServices.rootConfig(), - }); - router.use(middleware.error()); - - app = express().use(router); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('list plugin permissions and condition rules API', () => { - it('should return list plugins permission', async () => { - const pluginMetadata: PluginPermissionMetaData[] = [ - { - pluginId: 'permissions', - policies: [ - { - name: 'catalog.entity.read', - resourceType: 'policy-entity', - policy: 'read', - }, - ], - }, - ]; - pluginMetadataCollectorMock.getPluginPolicies = jest - .fn() - .mockImplementation(async () => { - return pluginMetadata; - }); - const result = await request(app).get('/plugins/policies').send(); - expect(result.statusCode).toEqual(200); - expect(result.body).toEqual(pluginMetadata); - }); - - it('should return a status of Unauthorized for /plugins/policies', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app).get('/plugins/policies').send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityReadPermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should return list plugins condition rules', async () => { - const rules: PluginMetadataResponseSerializedRule[] = [ - { - pluginId: 'catalog', - rules: [ - { - description: 'Allow entities with the specified label', - name: 'HAS_LABEL', - paramsSchema: { - $schema: 'http://json-schema.org/draft-07/schema#', - additionalProperties: false, - properties: { - label: { - description: 'Name of the label to match on', - type: 'string', - }, - }, - required: ['label'], - type: 'object', - }, - resourceType: 'catalog-entity', - }, - ], - }, - ]; - pluginMetadataCollectorMock.getPluginConditionRules = jest - .fn() - .mockImplementation(async () => { - return rules; - }); - const result = await request(app).get('/plugins/condition-rules').send(); - expect(result.statusCode).toEqual(200); - expect(result.body).toEqual(rules); - }); - - it('should return a status of Unauthorized for /plugins/condition-rules', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app).get('/plugins/condition-rules').send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityReadPermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - }); - - describe('plugin ids API', () => { - it('should return a status of Unauthorized for /plugins/id GET', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app).get('/plugins/id').send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityReadPermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - }); - - it('should return list plugin ids object /plugins/id GET', async () => { - const result = await request(app).get('/plugins/id').send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityReadPermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(200); - expect(result.body).toBeDefined(); - expect(result.body.ids).toContain('catalog'); - }); - - it('should return a status of Unauthorized for /plugins/id POST', async () => { - mockedAuthorize.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app) - .post('/plugins/id') - .send({ ids: ['catalog'] }); - - expect(mockedAuthorize).toHaveBeenCalledWith( - [ - { - permission: policyEntityCreatePermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should add more plugin ids with help of /plugins/id POST', async () => { - mockedAuthorize.mockImplementationOnce(async () => [ - { result: AuthorizeResult.ALLOW }, - ]); - (extendablePluginIdProviderMock.getPluginIds as jest.Mock) - .mockResolvedValueOnce(['jenkins', 'catalog']) - .mockResolvedValueOnce(['jenkins', 'catalog', 'scaffolder']); - - const result = await request(app) - .post('/plugins/id') - .send({ ids: ['scaffolder'] }); - - expect(mockedAuthorize).toHaveBeenCalledWith( - [ - { - permission: policyEntityCreatePermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(permissionDependentPluginStoreMock.addPlugins).toHaveBeenCalledWith([ - { pluginId: 'scaffolder' }, - ]); - expect(result.statusCode).toBe(200); - expect(result.body).toBeDefined(); - expect(result.body.ids).toContain('jenkins'); - expect(result.body.ids).toContain('catalog'); - expect(result.body.ids).toContain('scaffolder'); - }); - - it('should fail to add more plugin ids, because of ConflictError', async () => { - mockedAuthorize.mockImplementationOnce(async () => [ - { result: AuthorizeResult.ALLOW }, - ]); - ( - extendablePluginIdProviderMock.getPluginIds as jest.Mock - ).mockResolvedValueOnce(['jenkins', 'catalog', 'scaffolder']); - - const result = await request(app) - .post('/plugins/id') - .send({ ids: ['scaffolder'] }); - - expect(mockedAuthorize).toHaveBeenCalledWith( - [ - { - permission: policyEntityCreatePermission, - }, - ], - { - credentials: credentials, - }, - ); - expect( - permissionDependentPluginStoreMock.addPlugins, - ).not.toHaveBeenCalledWith([{ pluginId: 'scaffolder' }]); - expect(result.statusCode).toBe(409); - expect(result.body).toEqual({ - error: { - message: - 'Plugin IDs ["scaffolder"] already exist in the system. Please use a different set of plugin ids.', - name: 'ConflictError', - }, - request: { - method: 'POST', - url: '/plugins/id', - }, - response: { statusCode: 409 }, - }); - }); - - it('should return a status of Unauthorized for /plugins/id DELETE', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app).delete('/plugins/id').send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityDeletePermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should delete plugin id with help of /plugins/id DELETE', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.ALLOW }, - ]); - (extendablePluginIdProviderMock.getPluginIds as jest.Mock) - .mockResolvedValueOnce(['jenkins', 'sonarqube', 'catalog']) - .mockResolvedValueOnce(['jenkins', 'sonarqube']); - - const result = await request(app) - .delete('/plugins/id') - .send({ ids: ['catalog'] }); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityDeletePermission, - }, - ], - { - credentials: credentials, - }, - ); - expect( - permissionDependentPluginStoreMock.deletePlugins, - ).toHaveBeenCalledWith(['catalog']); - expect(result.statusCode).toBe(200); - expect(result.body).toBeDefined(); - expect(result.body.ids).toContain('jenkins'); - expect(result.body.ids).toContain('sonarqube'); - expect(result.body.ids).not.toContain('catalog'); - }); - - it('should fail to delete plugin id with NotFoundError', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.ALLOW }, - ]); - const result = await request(app) - .delete('/plugins/id') - .send({ ids: ['jenkins'] }); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityDeletePermission, - }, - ], - { - credentials: credentials, - }, - ); - expect( - permissionDependentPluginStoreMock.deletePlugins, - ).not.toHaveBeenCalledWith(['jenkins']); - expect(result.statusCode).toBe(404); - expect(result.body).toEqual({ - error: { - message: 'Plugin IDs ["jenkins"] were not found.', - name: 'NotFoundError', - }, - request: { - method: 'DELETE', - url: '/plugins/id', - }, - response: { statusCode: 404 }, - }); - }); - - it('should fail to deletion plugin id, because it was configured', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.ALLOW }, - ]); - ( - extendablePluginIdProviderMock.isConfiguredPluginId as jest.Mock - ).mockReturnValueOnce(true); - const result = await request(app) - .delete('/plugins/id') - .send({ ids: ['jenkins'] }); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityDeletePermission, - }, - ], - { - credentials: credentials, - }, - ); - expect( - permissionDependentPluginStoreMock.deletePlugins, - ).not.toHaveBeenCalledWith(['jenkins']); - expect(result.statusCode).toBe(403); - expect(result.body).toEqual({ - error: { - message: - 'Plugin IDs ["jenkins"] can be removed only with help of configuration.', - name: 'NotAllowedError', - }, - request: { - method: 'DELETE', - url: '/plugins/id', - }, - response: { statusCode: 403 }, - }); - }); -}); diff --git a/plugins/rbac-backend/src/service/permission-definition-routes.ts b/plugins/rbac-backend/src/service/permission-definition-routes.ts deleted file mode 100644 index 7081430f84..0000000000 --- a/plugins/rbac-backend/src/service/permission-definition-routes.ts +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - PermissionDependentPluginList, - policyEntityCreatePermission, - policyEntityDeletePermission, - policyEntityReadPermission, -} from '@backstage-community/plugin-rbac-common'; -import { logAuditorEvent } from '../auditor/rest-interceptor'; -import { PluginPermissionMetadataCollector } from './plugin-endpoints'; -import { - PermissionDependentPluginDTO, - PermissionDependentPluginStore, -} from '../database/extra-permission-enabled-plugins-storage'; -import { authorizeConditional } from './policies-rest-api'; -import { - AuditorService, - AuthService, - HttpAuthService, - PermissionsService, -} from '@backstage/backend-plugin-api'; -import { ExtendablePluginIdProvider } from './extendable-id-provider'; -import { - ConflictError, - NotAllowedError, - NotFoundError, -} from '@backstage/errors'; -import { validatePermissionDependentPlugin } from '../validation/plugin-validation'; -import express from 'express'; - -export function registerPermissionDefinitionRoutes( - router: express.Router, - pluginPermMetaData: PluginPermissionMetadataCollector, - pluginIdProvider: ExtendablePluginIdProvider, - extraPluginsIdStorage: PermissionDependentPluginStore, - deps: { - auth: AuthService; - httpAuth: HttpAuthService; - auditor: AuditorService; - permissions: PermissionsService; - }, -) { - const { auth, auditor } = deps; - - router.get( - '/plugins/policies', - logAuditorEvent(auditor), - async (request, response) => { - await authorizeConditional(request, policyEntityReadPermission, deps); - - const body = await pluginPermMetaData.getPluginPolicies(auth); - - response.json(body); - }, - ); - - router.get( - '/plugins/condition-rules', - logAuditorEvent(auditor), - async (request, response) => { - await authorizeConditional(request, policyEntityReadPermission, deps); - - const body = await pluginPermMetaData.getPluginConditionRules(auth); - - response.json(body); - }, - ); - - router.get( - '/plugins/id', - logAuditorEvent(auditor), - async (request, response) => { - await authorizeConditional(request, policyEntityReadPermission, deps); - - const actualPluginIds = await pluginIdProvider.getPluginIds(); - response.status(200).json(pluginIdsToResponse(actualPluginIds)); - }, - ); - - router.post( - '/plugins/id', - logAuditorEvent(auditor), - async (request, response) => { - await authorizeConditional(request, policyEntityCreatePermission, deps); - const pluginIds: PermissionDependentPluginList = request.body; - validatePermissionDependentPlugin(pluginIds); - const pluginDtos = permissionDependentPluginListToDTO(pluginIds); - - let actualPluginIds = await pluginIdProvider.getPluginIds(); - const conflictedIds = pluginIds.ids.filter(id => - actualPluginIds.includes(id), - ); - if (conflictedIds.length > 0) { - throw new ConflictError( - `Plugin IDs ${JSON.stringify(conflictedIds)} already exist in the system. Please use a different set of plugin ids.`, - ); - } - await extraPluginsIdStorage.addPlugins(pluginDtos); - response.locals.meta = pluginIds; - - actualPluginIds = await pluginIdProvider.getPluginIds(); - response.status(200).json(pluginIdsToResponse(actualPluginIds)); - }, - ); - - router.delete( - '/plugins/id', - logAuditorEvent(auditor), - async (request, response) => { - await authorizeConditional(request, policyEntityDeletePermission, deps); - const pluginIds: PermissionDependentPluginList = request.body; - validatePermissionDependentPlugin(pluginIds); - const configuredPluginIds = pluginIds.ids.filter(pluginId => - pluginIdProvider.isConfiguredPluginId(pluginId), - ); - if (configuredPluginIds.length > 0) { - throw new NotAllowedError( - `Plugin IDs ${JSON.stringify(pluginIds.ids)} can be removed only with help of configuration.`, - ); - } - - let actualPluginIds = await pluginIdProvider.getPluginIds(); - const notFoundPlugins = pluginIds.ids.filter( - pluginToDel => !actualPluginIds.includes(pluginToDel), - ); - if (notFoundPlugins.length > 0) { - throw new NotFoundError( - `Plugin IDs ${JSON.stringify(notFoundPlugins)} were not found.`, - ); - } - - await extraPluginsIdStorage.deletePlugins(pluginIds.ids); - response.locals.meta = pluginIds; - - actualPluginIds = await pluginIdProvider.getPluginIds(); - response.status(200).json(pluginIdsToResponse(actualPluginIds)); - }, - ); -} - -export function pluginIdsToResponse( - pluginIds: string[], -): PermissionDependentPluginList { - return { ids: pluginIds }; -} - -export function permissionDependentPluginListToDTO( - pluginList: PermissionDependentPluginList, -): PermissionDependentPluginDTO[] { - return pluginList.ids.map(pluginId => { - return { pluginId }; - }); -} diff --git a/plugins/rbac-backend/src/service/permission-model.ts b/plugins/rbac-backend/src/service/permission-model.ts deleted file mode 100644 index e322a3f63d..0000000000 --- a/plugins/rbac-backend/src/service/permission-model.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export const MODEL = ` -[request_definition] -r = sub, obj, act - -[policy_definition] -p = sub, obj, act, eft - -[policy_effect] -e = some(where (p.eft == allow)) && !some(where (p.eft == deny)) - -[role_definition] -g = _, _ - -[matchers] -m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act -`; diff --git a/plugins/rbac-backend/src/service/plugin-endpoint.test.ts b/plugins/rbac-backend/src/service/plugin-endpoint.test.ts deleted file mode 100644 index a5acd31ec8..0000000000 --- a/plugins/rbac-backend/src/service/plugin-endpoint.test.ts +++ /dev/null @@ -1,543 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { mockServices } from '@backstage/backend-test-utils'; -import { NotFoundError } from '@backstage/errors'; - -import { PluginPermissionMetadataCollector } from './plugin-endpoints'; -import { policyEntityPermissions } from '@backstage-community/plugin-rbac-common'; -import { rbacRules } from '../permissions'; -import { extendablePluginIdProviderMock } from '../../__fixtures__/mock-utils'; -import { ExtendablePluginIdProvider } from './extendable-id-provider'; - -describe('plugin-endpoint', () => { - const mockPluginEndpointDiscovery = mockServices.discovery.mock({ - getBaseUrl: async (pluginId: string) => { - return `https://localhost:7007/api/${pluginId}`; - }, - }); - - const fetchMock = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - fetchMock.mockReset(); - global.fetch = fetchMock as any; - }); - - afterAll(() => { - // clean up global pollution - // eslint-disable-next-line @typescript-eslint/no-explicit-any - delete (global as any).fetch; - }); - - describe('Test list plugin policies', () => { - it('should return empty plugin policies list', async () => { - // asserts that when a plugin’s well-known endpoint is missing (404) - // the collector returns an empty policies list instead of throwing. - fetchMock.mockRejectedValueOnce(new NotFoundError()); - - const collector = new PluginPermissionMetadataCollector({ - deps: { - discovery: mockPluginEndpointDiscovery, - pluginIdProvider: - extendablePluginIdProviderMock as ExtendablePluginIdProvider, - logger: mockServices.logger.mock(), - config: mockServices.rootConfig(), - }, - }); - const policiesMetadata = await collector.getPluginPolicies( - mockServices.auth(), - ); - - expect(policiesMetadata.length).toEqual(0); - }); - - it('should return non empty plugin policies list with resourced permission', async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => { - return { - permissions: [ - { - type: 'resource', - name: 'catalog.entity.read', - attributes: { action: 'read' }, - resourceType: 'catalog-entity', - }, - ], - }; - }, - } as any); - - const collector = new PluginPermissionMetadataCollector({ - deps: { - discovery: mockPluginEndpointDiscovery, - pluginIdProvider: - extendablePluginIdProviderMock as ExtendablePluginIdProvider, - logger: mockServices.logger.mock(), - config: mockServices.rootConfig(), - }, - }); - const policiesMetadata = await collector.getPluginPolicies( - mockServices.auth(), - ); - - expect(policiesMetadata.length).toEqual(1); - expect(policiesMetadata[0].pluginId).toEqual('catalog'); - expect(policiesMetadata[0].policies).toEqual([ - { - name: 'catalog.entity.read', - resourceType: 'catalog-entity', - policy: 'read', - }, - ]); - }); - - it('should return non empty plugin policies list with non resourced permission', async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => { - return { - permissions: [ - { - type: 'basic', - name: 'catalog.entity.create', - attributes: { action: 'create' }, - }, - ], - }; - }, - } as any); - - const collector = new PluginPermissionMetadataCollector({ - deps: { - discovery: mockPluginEndpointDiscovery, - pluginIdProvider: - extendablePluginIdProviderMock as ExtendablePluginIdProvider, - logger: mockServices.logger.mock(), - config: mockServices.rootConfig(), - }, - }); - const policiesMetadata = await collector.getPluginPolicies( - mockServices.auth(), - ); - - expect(policiesMetadata.length).toEqual(1); - expect(policiesMetadata[0].pluginId).toEqual('catalog'); - expect(policiesMetadata[0].policies).toEqual([ - { - name: 'catalog.entity.create', - policy: 'create', - }, - ]); - }); - - it('should log warning for not found endpoint', async () => { - ( - extendablePluginIdProviderMock.getPluginIds as jest.Mock - ).mockReturnValueOnce(['catalog', 'unknown-plugin-id']); - - fetchMock.mockImplementation(async (wellKnownURL: string) => { - if ( - wellKnownURL === - 'https://localhost:7007/api/catalog/.well-known/backstage/permissions/metadata' - ) { - return { - ok: true, - json: async () => { - return { - permissions: [ - { - type: 'resource', - resourceType: 'catalog-entity', - name: 'catalog.entity.read', - attributes: { action: 'read' }, - }, - ], - }; - }, - } as any; - } - - throw new NotFoundError(); - }); - - const logger = mockServices.logger.mock(); - const errorSpy = jest.spyOn(logger, 'warn').mockClear(); - const collector = new PluginPermissionMetadataCollector({ - deps: { - discovery: mockPluginEndpointDiscovery, - pluginIdProvider: - extendablePluginIdProviderMock as ExtendablePluginIdProvider, - logger, - config: mockServices.rootConfig(), - }, - }); - const policiesMetadata = await collector.getPluginPolicies( - mockServices.auth(), - ); - - expect(policiesMetadata.length).toEqual(1); - expect(policiesMetadata[0].pluginId).toEqual('catalog'); - expect(policiesMetadata[0].policies).toEqual([ - { - name: 'catalog.entity.read', - resourceType: 'catalog-entity', - policy: 'read', - }, - ]); - - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'No permission metadata found for unknown-plugin-id. NotFoundError', - ), - ); - }); - - it('should log error when it is not possible to retrieve permission metadata for known endpoint', async () => { - ( - extendablePluginIdProviderMock.getPluginIds as jest.Mock - ).mockResolvedValueOnce(['scaffolder', 'catalog']); - - fetchMock.mockImplementation(async (wellKnownURL: string) => { - if ( - wellKnownURL === - 'https://localhost:7007/api/scaffolder/.well-known/backstage/permissions/metadata' - ) { - return { - ok: true, - json: async () => { - return { - permissions: [ - { - type: 'resource', - resourceType: 'scaffolder-template', - name: 'scaffolder.template.parameter.read', - attributes: { action: 'read' }, - }, - ], - }; - }, - } as any; - } - - throw new Error('Unexpected error'); - }); - - const logger = mockServices.logger.mock(); - const errorSpy = jest.spyOn(logger, 'error').mockClear(); - const collector = new PluginPermissionMetadataCollector({ - deps: { - discovery: mockPluginEndpointDiscovery, - pluginIdProvider: - extendablePluginIdProviderMock as ExtendablePluginIdProvider, - logger, - config: mockServices.rootConfig(), - }, - }); - - const policiesMetadata = await collector.getPluginPolicies( - mockServices.auth(), - ); - - expect(policiesMetadata.length).toEqual(1); - expect(policiesMetadata[0].pluginId).toEqual('scaffolder'); - expect(policiesMetadata[0].policies).toEqual([ - { - name: 'scaffolder.template.parameter.read', - resourceType: 'scaffolder-template', - policy: 'read', - }, - ]); - - expect(errorSpy).toHaveBeenCalledWith( - 'Failed to retrieve permission metadata for catalog. Error: Unexpected error', - ); - }); - - it('should not log error caused by non json permission metadata for known endpoint', async () => { - ( - extendablePluginIdProviderMock.getPluginIds as jest.Mock - ).mockReturnValueOnce(['scaffolder', 'catalog']); - fetchMock.mockImplementation(async (wellKnownURL: string) => { - if ( - wellKnownURL === - 'https://localhost:7007/api/scaffolder/.well-known/backstage/permissions/metadata' - ) { - return { - ok: true, - json: async () => { - return { - permissions: [ - { - type: 'resource', - resourceType: 'scaffolder-template', - name: 'scaffolder.template.parameter.read', - attributes: { action: 'read' }, - }, - ], - }; - }, - } as any; - } - - if ( - wellKnownURL === - 'https://localhost:7007/api/catalog/.well-known/backstage/permissions/metadata' - ) { - return { - ok: true, - json: async () => { - throw new Error('invalid json'); - }, - } as any; - } - - throw new Error('Unexpected error'); - }); - - const logger = mockServices.logger.mock(); - const errorSpy = jest.spyOn(logger, 'error').mockClear(); - - const collector = new PluginPermissionMetadataCollector({ - deps: { - discovery: mockPluginEndpointDiscovery, - pluginIdProvider: - extendablePluginIdProviderMock as ExtendablePluginIdProvider, - logger, - config: mockServices.rootConfig(), - }, - }); - const policiesMetadata = await collector.getPluginPolicies( - mockServices.auth(), - ); - - expect(policiesMetadata.length).toEqual(1); - expect(policiesMetadata[0].pluginId).toEqual('scaffolder'); - expect(policiesMetadata[0].policies).toEqual([ - { - name: 'scaffolder.template.parameter.read', - resourceType: 'scaffolder-template', - policy: 'read', - }, - ]); - - // workaround for https://issues.redhat.com/browse/RHIDP-1456 - expect(errorSpy).not.toHaveBeenCalled(); - }); - }); - - describe('Test list plugin condition rules', () => { - it('should return empty condition rule list', async () => { - ( - extendablePluginIdProviderMock.getPluginIds as jest.Mock - ).mockReturnValueOnce([]); - - const collector = new PluginPermissionMetadataCollector({ - deps: { - discovery: mockPluginEndpointDiscovery, - pluginIdProvider: - extendablePluginIdProviderMock as ExtendablePluginIdProvider, - logger: mockServices.logger.mock(), - config: mockServices.rootConfig(), - }, - }); - const conditionRulesMetadata = await collector.getPluginConditionRules( - mockServices.auth(), - ); - - expect(conditionRulesMetadata.length).toEqual(0); - }); - - it('should return non empty condition rule list', async () => { - ( - extendablePluginIdProviderMock.getPluginIds as jest.Mock - ).mockReturnValueOnce(['catalog']); - - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => { - return { - rules: [ - { - description: 'Allow entities with the specified label', - name: 'HAS_LABEL', - paramsSchema: { - $schema: 'http://json-schema.org/draft-07/schema#', - additionalProperties: false, - properties: { - label: { - description: 'Name of the label to match on', - type: 'string', - }, - }, - required: ['label'], - type: 'object', - }, - resourceType: 'catalog-entity', - }, - ], - }; - }, - } as any); - - const collector = new PluginPermissionMetadataCollector({ - deps: { - discovery: mockPluginEndpointDiscovery, - pluginIdProvider: - extendablePluginIdProviderMock as ExtendablePluginIdProvider, - logger: mockServices.logger.mock(), - config: mockServices.rootConfig(), - }, - }); - const conditionRulesMetadata = await collector.getPluginConditionRules( - mockServices.auth(), - ); - - expect(conditionRulesMetadata.length).toEqual(1); - expect(conditionRulesMetadata[0].pluginId).toEqual('catalog'); - expect(conditionRulesMetadata[0].rules).toEqual([ - { - description: 'Allow entities with the specified label', - name: 'HAS_LABEL', - paramsSchema: { - $schema: 'http://json-schema.org/draft-07/schema#', - additionalProperties: false, - properties: { - label: { - description: 'Name of the label to match on', - type: 'string', - }, - }, - required: ['label'], - type: 'object', - }, - resourceType: 'catalog-entity', - }, - ]); - }); - }); - - describe('Test get plugin metadata by id', () => { - it('should return metadata by id', async () => { - ( - extendablePluginIdProviderMock.getPluginIds as jest.Mock - ).mockReturnValueOnce(['catalog']); - - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => { - return { - permissions: [ - { - type: 'resource', - name: 'catalog.entity.read', - attributes: { action: 'read' }, - resourceType: 'catalog-entity', - }, - ], - rules: [ - { - description: 'Allow entities with the specified label', - name: 'HAS_LABEL', - paramsSchema: { - $schema: 'http://json-schema.org/draft-07/schema#', - additionalProperties: false, - properties: { - label: { - description: 'Name of the label to match on', - type: 'string', - }, - }, - required: ['label'], - type: 'object', - }, - resourceType: 'catalog-entity', - }, - ], - }; - }, - } as any); - - const collector = new PluginPermissionMetadataCollector({ - deps: { - discovery: mockPluginEndpointDiscovery, - pluginIdProvider: - extendablePluginIdProviderMock as ExtendablePluginIdProvider, - logger: mockServices.logger.mock(), - config: mockServices.rootConfig(), - }, - }); - const metadata = await collector.getMetadataByPluginId( - 'catalog', - undefined, - ); - - expect(metadata).not.toBeUndefined(); - expect(metadata?.permissions).toEqual([ - { - name: 'catalog.entity.read', - attributes: { action: 'read' }, - type: 'resource', - resourceType: 'catalog-entity', - }, - ]); - expect(metadata?.rules).toEqual([ - { - description: 'Allow entities with the specified label', - name: 'HAS_LABEL', - paramsSchema: { - $schema: 'http://json-schema.org/draft-07/schema#', - additionalProperties: false, - properties: { - label: { - description: 'Name of the label to match on', - type: 'string', - }, - }, - required: ['label'], - type: 'object', - }, - resourceType: 'catalog-entity', - }, - ]); - }); - - it('should return metadata by id (rbac-plugin)', async () => { - ( - extendablePluginIdProviderMock.getPluginIds as jest.Mock - ).mockReturnValue(['permission']); - - const collector = new PluginPermissionMetadataCollector({ - deps: { - discovery: mockPluginEndpointDiscovery, - pluginIdProvider: - extendablePluginIdProviderMock as ExtendablePluginIdProvider, - logger: mockServices.logger.mock(), - config: mockServices.rootConfig(), - }, - }); - const metadata = await collector.getMetadataByPluginId( - 'permission', - undefined, - ); - - expect(metadata).not.toBeUndefined(); - expect(metadata?.permissions).toEqual(policyEntityPermissions); - expect(metadata?.rules).toEqual([rbacRules]); - }); - }); -}); diff --git a/plugins/rbac-backend/src/service/plugin-endpoints.ts b/plugins/rbac-backend/src/service/plugin-endpoints.ts deleted file mode 100644 index d1edb8851e..0000000000 --- a/plugins/rbac-backend/src/service/plugin-endpoints.ts +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - AuthService, - DiscoveryService, - LoggerService, -} from '@backstage/backend-plugin-api'; -import type { Config } from '@backstage/config'; -import { isError } from '@backstage/errors'; -import { - isResourcePermission, - Permission, - type MetadataResponse, - type MetadataResponseSerializedRule, -} from '@backstage/plugin-permission-common'; - -import { - policyEntityPermissions, - type PluginPermissionMetaData, - type PolicyDetails, -} from '@backstage-community/plugin-rbac-common'; -import { rbacRules } from '../permissions'; -import { ExtendablePluginIdProvider } from './extendable-id-provider'; - -type PluginMetadataResponse = { - pluginId: string; - metaDataResponse: MetadataResponse; -}; - -export type PluginMetadataResponseSerializedRule = { - pluginId: string; - rules: MetadataResponseSerializedRule[]; -}; - -const rbacPermissionMetadata: MetadataResponse = { - permissions: policyEntityPermissions, - rules: [rbacRules], -}; - -export class PluginPermissionMetadataCollector { - private readonly pluginIdProvider: ExtendablePluginIdProvider; - private readonly discovery: DiscoveryService; - private readonly logger: LoggerService; - - constructor({ - deps, - }: { - deps: { - discovery: DiscoveryService; - pluginIdProvider: ExtendablePluginIdProvider; - logger: LoggerService; - config: Config; - }; - }) { - const { discovery, logger, pluginIdProvider } = deps; - this.discovery = discovery; - this.pluginIdProvider = pluginIdProvider; - this.logger = logger; - } - - async getPluginConditionRules( - auth: AuthService, - ): Promise { - const pluginMetadata = await this.getPluginMetaData(auth); - - return pluginMetadata - .filter(metadata => metadata.metaDataResponse.rules.length > 0) - .map(metadata => { - return { - pluginId: metadata.pluginId, - rules: metadata.metaDataResponse.rules, - }; - }); - } - - async getPluginPolicies( - auth: AuthService, - ): Promise { - const pluginMetadata = await this.getPluginMetaData(auth); - - return pluginMetadata - .filter(metadata => metadata.metaDataResponse.permissions !== undefined) - .map(metadata => { - return { - pluginId: metadata.pluginId, - policies: permissionsToCasbinPolicies( - metadata.metaDataResponse.permissions!, - ), - }; - }); - } - - private async getPluginMetaData( - auth: AuthService, - ): Promise { - let pluginResponses: PluginMetadataResponse[] = []; - - const pluginIds = await this.pluginIdProvider.getPluginIds(); - for (const pluginId of pluginIds) { - try { - const { token } = await auth.getPluginRequestToken({ - onBehalfOf: await auth.getOwnServiceCredentials(), - targetPluginId: pluginId, - }); - - const permMetaData = await this.getMetadataByPluginId(pluginId, token); - if (permMetaData) { - pluginResponses = [ - ...pluginResponses, - { - metaDataResponse: permMetaData, - pluginId, - }, - ]; - } - } catch (error) { - this.logger.error( - `Failed to retrieve permission metadata for ${pluginId}. ${error}`, - ); - } - } - - return pluginResponses; - } - - async getMetadataByPluginId( - pluginId: string, - token: string | undefined, - ): Promise { - let permMetaData: MetadataResponse | undefined; - - // Work around: This is needed for start up whenever a conditional policy for the plugin permission in the yaml file - // will make a check to the well known endpoint - // However, our plugin has not completely started and as such will throw a 503 error - // TODO: see if we are able to remove this after we migrate to the permission registry - if (pluginId === 'permission') { - return rbacPermissionMetadata; - } - - try { - const baseEndpoint = await this.discovery.getBaseUrl(pluginId); - const wellKnownURL = `${baseEndpoint}/.well-known/backstage/permissions/metadata`; - - const response = await fetch(wellKnownURL, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - if (!response.ok) { - throw new Error( - `Failed to fetch metadata for ${pluginId}: ${response.status}`, - ); - } - - try { - permMetaData = await response.json(); - } catch (err) { - // workaround for https://issues.redhat.com/browse/RHIDP-1456 - return undefined; - } - } catch (err) { - if (isError(err) && err.name === 'NotFoundError') { - this.logger.warn( - `No permission metadata found for ${pluginId}. ${err}`, - ); - return undefined; - } - this.logger.error( - `Failed to retrieve permission metadata for ${pluginId}. ${err}`, - ); - } - return permMetaData; - } -} - -function permissionsToCasbinPolicies( - permissions: Permission[], -): PolicyDetails[] { - const policies: PolicyDetails[] = []; - for (const permission of permissions) { - if (isResourcePermission(permission)) { - policies.push({ - resourceType: permission.resourceType, - name: permission.name, - policy: permission.attributes.action || 'use', - }); - } else { - policies.push({ - name: permission.name, - policy: permission.attributes.action || 'use', - }); - } - } - - return policies; -} diff --git a/plugins/rbac-backend/src/service/policies-rest-api.conditions.test.ts b/plugins/rbac-backend/src/service/policies-rest-api.conditions.test.ts deleted file mode 100644 index adec90a81a..0000000000 --- a/plugins/rbac-backend/src/service/policies-rest-api.conditions.test.ts +++ /dev/null @@ -1,993 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter'; -import { mockServices } from '@backstage/backend-test-utils'; -import { - AuthorizeResult, - MetadataResponse, -} from '@backstage/plugin-permission-common'; - -import express from 'express'; - -import { - PermissionAction, - PermissionInfo, - RoleConditionalPolicyDecision, -} from '@backstage-community/plugin-rbac-common'; - -import { EnforcerDelegate } from './enforcer-delegate'; -import { PluginPermissionMetadataCollector } from './plugin-endpoints'; -import { PoliciesServer } from './policies-rest-api'; -import { RBACRouterOptions } from './policy-builder'; -import { - mockAuditorService, - conditionalStorageMock, - credentials, - enforcerDelegateMock, - mockAuthService, - mockedAuthorize, - mockHttpAuth, - mockLoggerService, - pluginMetadataCollectorMock, - roleMetadataStorageMock, - mockPermissionRegistry, - permissionDependentPluginStoreMock, - extendablePluginIdProviderMock, -} from '../../__fixtures__/mock-utils'; -import request from 'supertest'; -import { RoleMetadataDao } from '../database/role-metadata'; -import { RBACFilters } from '../permissions/rules'; -import { ExtendablePluginIdProvider } from './extendable-id-provider'; - -jest.setTimeout(60000); - -jest.mock('@backstage/plugin-auth-node', () => ({ - getBearerTokenFromAuthorizationHeader: () => 'token', -})); - -const validateRoleConditionMock = jest.fn().mockImplementation(); -jest.mock('../validation/condition-validation', () => { - return { - validateRoleCondition: jest - .fn() - .mockImplementation( - (condition: RoleConditionalPolicyDecision) => { - validateRoleConditionMock(condition); - }, - ), - }; -}); - -jest.mock('../permissions/conditions', () => { - return { - conditionTransformerFunc: () => - jest.fn().mockReturnValue({ - anyOf: [{ key: 'owners', values: ['user:default/mock'] }], - }), - }; -}); - -const mockedAuthorizeConditional = jest.fn().mockImplementation(async () => [ - { - conditions: { - anyOf: [ - { - rule: 'IS_OWNER', - resourceType: 'policy-entity', - params: [{ owners: ['user:default/mock'] }], - }, - ], - }, - result: AuthorizeResult.CONDITIONAL, - }, -]); - -const mockPermissionEvaluator = { - authorize: mockedAuthorize, - authorizeConditional: mockedAuthorizeConditional, -}; - -const conditions: RoleConditionalPolicyDecision[] = [ - { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: [{ name: 'catalog.entity.read', action: 'read' }], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }, - { - id: 2, - pluginId: 'catalog', - roleEntityRef: 'role:default/guest', - resourceType: 'catalog-entity', - permissionMapping: [{ name: 'catalog.entity.read', action: 'read' }], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }, -]; - -const expectedConditions: RoleConditionalPolicyDecision[] = [ - { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: ['read'], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }, -]; - -describe('REST policies api with conditions', () => { - let app: express.Express; - - const config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - permission: { - enabled: true, - }, - }, - }); - - let server: PoliciesServer; - - beforeEach(async () => { - mockHttpAuth.credentials = jest.fn().mockImplementation(() => credentials); - enforcerDelegateMock.getFilteredGroupingPolicy = jest - .fn() - .mockImplementation( - async (_fieldIndex: number, ..._fieldValues: string[]) => { - return [['group:default/test', 'role:default/test']]; - }, - ); - - enforcerDelegateMock.getFilteredPolicy = jest - .fn() - .mockImplementation( - async (_fieldIndex: number, ...fieldValues: string[]) => { - if (fieldValues.length === 1) { - return [ - ['role:default/test', 'policy-entity', 'create', 'allow'], - ['role:default/test', 'policy-entity', 'read', 'allow'], - ]; - } - - if (fieldValues.length > 1) { - return [['role:default/test', 'policy-entity', 'read', 'allow']]; - } - - return []; - }, - ); - - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async (roleEntityRef: string): Promise => { - const owner = - roleEntityRef === 'role:default/test' ? 'user:default/mock' : ''; - return { - source: 'rest', - roleEntityRef: roleEntityRef, - modifiedBy: 'user:default/some_user', - owner, - }; - }, - ); - - roleMetadataStorageMock.filterForOwnerRoleMetadata = jest - .fn() - .mockImplementation( - async (filter?: RBACFilters): Promise => { - if (filter && 'anyOf' in filter) { - return [ - { - source: 'rest', - roleEntityRef: 'role:default/test', - modifiedBy: 'user:default/some_user', - owner: 'user:default/mock', - }, - ]; - } - - return [ - { - source: 'rest', - roleEntityRef: 'role:default/permission_admin', - modifiedBy: 'user:default/some_user', - owner: '', - }, - { - source: 'rest', - roleEntityRef: 'role:default/test', - modifiedBy: 'user:default/some_user', - owner: 'user:default/mock', - }, - ]; - }, - ); - - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[2] === 'update') { - return false; - } - return true; - }); - - conditionalStorageMock.filterConditions = jest - .fn() - .mockImplementation( - async ( - _roleEntityRef: string, - pluginId: string, - resourceType: string, - ) => { - if (resourceType === 'catalog-entity' || pluginId === 'catalog') { - return conditions; - } - - if ( - resourceType === 'scaffolder-template' || - pluginId === 'scaffolder' - ) { - return []; - } - return conditions; - }, - ); - - pluginMetadataCollectorMock.getMetadataByPluginId = jest - .fn() - .mockImplementation(() => { - const response: MetadataResponse = { - permissions: [ - { - name: 'catalog.entity.read', - attributes: { - action: 'read', - }, - type: 'resource', - resourceType: 'catalog-entity', - }, - ], - rules: [], - }; - return response; - }); - - conditionalStorageMock.getCondition = jest - .fn() - .mockImplementation(async (id: number) => { - return conditions[id - 1]; - }); - - const options: RBACRouterOptions = { - config: config, - logger: mockLoggerService, - httpAuth: mockHttpAuth, - auth: mockAuthService, - permissionsRegistry: mockPermissionRegistry, - auditor: mockAuditorService, - permissions: mockPermissionEvaluator, - }; - - server = new PoliciesServer( - options, - enforcerDelegateMock as EnforcerDelegate, - conditionalStorageMock, - pluginMetadataCollectorMock as PluginPermissionMetadataCollector, - roleMetadataStorageMock, - permissionDependentPluginStoreMock, - extendablePluginIdProviderMock as ExtendablePluginIdProvider, - undefined, - ); - - const router = await server.serve(); - app = express().use(router); - app.use( - MiddlewareFactory.create({ logger: mockLoggerService, config }).error(), - ); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('GET /roles', () => { - it('should be returned roles in which the user is assigned ownership', async () => { - enforcerDelegateMock.getGroupingPolicy = jest - .fn() - .mockImplementation(async () => { - return [ - ['group:default/test', 'role:default/test'], - ['group:default/team_a', 'role:default/team_a'], - ]; - }); - const result = await request(app).get('/roles').send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([ - { - memberReferences: ['group:default/test'], - name: 'role:default/test', - metadata: { - isDefault: false, - source: 'rest', - modifiedBy: 'user:default/some_user', - owner: 'user:default/mock', - }, - }, - ]); - }); - }); - - describe('GET /roles/:kind/:namespace/:name', () => { - it('should return role by role reference in which the user is an owner of', async () => { - const result = await request(app).get('/roles/role/default/test').send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([ - { - memberReferences: ['group:default/test'], - name: 'role:default/test', - metadata: { - isDefault: false, - source: 'rest', - modifiedBy: 'user:default/some_user', - owner: 'user:default/mock', - }, - }, - ]); - }); - - it('should return not found error by role reference in which the user is not an owner', async () => { - enforcerDelegateMock.getFilteredGroupingPolicy = jest - .fn() - .mockImplementation( - async (_fieldIndex: number, ..._fieldValues: string[]) => { - return [['group:default/team_a', 'role:default/team_a']]; - }, - ); - - const result = await request(app) - .get('/roles/role/default/team_a') - .send(); - expect(result.statusCode).toBe(404); - expect(result.body).toEqual({ - error: { message: '', name: 'NotFoundError' }, - request: { - method: 'GET', - url: '/roles/role/default/team_a', - }, - response: { statusCode: 404 }, - }); - }); - }); - - describe('PUT /roles/:kind/:namespace/:name', () => { - it('should fail to update role - old role not found because user is not an owner', async () => { - const result = await request(app) - .put('/roles/role/default/team_a') - .send({ - oldRole: { - memberReferences: ['group:default/team_a'], - }, - newRole: { - memberReferences: ['user:default/test'], - name: 'role:default/team_a', - }, - }); - - expect(result.statusCode).toEqual(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should update description and set owner for role that the user is an owner of', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/roles/role/default/test') - .send({ - oldRole: { - memberReferences: ['user:default/guest'], - metadata: { - source: 'rest', - description: 'some admin role.', - owner: 'user:default/mock', - }, - }, - newRole: { - memberReferences: ['user:default/guest'], - name: 'role:default/test', - metadata: { - source: 'rest', - description: 'some admin role.', - owner: 'user:default/some_user', - }, - }, - }); - - expect(result.statusCode).toEqual(200); - expect(enforcerDelegateMock.updateGroupingPolicies).toHaveBeenCalledWith( - [['user:default/guest', 'role:default/test']], - [['user:default/guest', 'role:default/test']], - { - description: 'some admin role.', - modifiedBy: 'user:default/mock', - roleEntityRef: 'role:default/test', - source: 'rest', - owner: 'user:default/some_user', - }, - ); - }); - - it.each([ - ['user:default/permission_admin', 'user:default/test'], - ['user:default/Permission_Admin', 'user:default/Test'], - ])('should update role that the user owns', async (oldUser, newUser) => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[0] === newUser.toLocaleLowerCase('en-US')) { - return false; - } - return true; - }); - enforcerDelegateMock.updateGroupingPolicies = jest - .fn() - .mockImplementation(); - - const result = await request(app) - .put('/roles/role/default/test') - .send({ - oldRole: { - memberReferences: [oldUser], - metadata: { - source: 'rest', - description: 'some admin role.', - owner: 'user:default/mock', - }, - }, - newRole: { - memberReferences: [newUser], - name: 'role:default/test', - metadata: { - source: 'rest', - description: 'some admin role.', - owner: 'user:default/mock', - }, - }, - }); - - expect(result.statusCode).toEqual(200); - expect(enforcerDelegateMock.hasGroupingPolicy).toHaveBeenNthCalledWith( - 1, - 'user:default/test', - 'role:default/test', - ); - expect(enforcerDelegateMock.hasGroupingPolicy).toHaveBeenNthCalledWith( - 2, - 'user:default/permission_admin', - 'role:default/test', - ); - }); - - it('should update role where newRole has multiple roles and where the user is an owner of', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if ( - param[0] === 'user:default/test' || - param[0] === 'user:default/test2' - ) { - return false; - } - return true; - }); - enforcerDelegateMock.updateGroupingPolicies = jest - .fn() - .mockImplementation(); - - const result = await request(app) - .put('/roles/role/default/test') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - metadata: { - source: 'rest', - description: 'some admin role.', - owner: 'user:default/mock', - }, - }, - newRole: { - memberReferences: ['user:default/test', 'user:default/test2'], - name: 'role:default/test', - metadata: { - source: 'rest', - description: 'some admin role.', - owner: 'user:default/mock', - }, - }, - }); - - expect(result.statusCode).toEqual(200); - }); - - it('should update role where newRole has multiple roles with one being from oldRole and where the user is an owner of', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[0] === 'user:default/test') { - return false; - } - return true; - }); - enforcerDelegateMock.updateGroupingPolicies = jest - .fn() - .mockImplementation(); - - const result = await request(app) - .put('/roles/role/default/test') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - metadata: { - source: 'rest', - description: 'some admin role.', - owner: 'user:default/mock', - }, - }, - newRole: { - memberReferences: [ - 'user:default/permission_admin', - 'user:default/test', - ], - name: 'role:default/test', - metadata: { - source: 'rest', - description: 'some admin role.', - owner: 'user:default/mock', - }, - }, - }); - - expect(result.statusCode).toEqual(200); - }); - - it('should update role name of a role that the user is an owner of', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[0] === 'user:default/test') { - return false; - } - return true; - }); - enforcerDelegateMock.updateGroupingPolicies = jest - .fn() - .mockImplementation(); - - const result = await request(app) - .put('/roles/role/default/test') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - metadata: { - source: 'rest', - description: 'some admin role.', - owner: 'user:default/mock', - }, - }, - newRole: { - memberReferences: ['user:default/test'], - name: 'role:default/new_name', - metadata: { - source: 'rest', - description: 'some admin role.', - owner: 'user:default/mock', - }, - }, - }); - - expect(result.statusCode).toEqual(200); - }); - }); - - describe('DELETE /roles/:kind/:namespace/:name', () => { - it('should fail to delete, because user is not an owner', async () => { - const result = await request(app) - .delete( - '/roles/role/default/team_a?memberReferences=group:default/test', - ) - .send(); - - expect(result.statusCode).toEqual(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it.each(['group:default/test', 'group:default/Test'])( - 'should delete a user / group %s from a role that the user is an owner of', - async member => { - const result = await request(app) - .delete(`/roles/role/default/test?memberReferences=${member}`) - .send(); - - expect(result.statusCode).toEqual(204); - expect( - enforcerDelegateMock.getFilteredGroupingPolicy, - ).toHaveBeenCalledWith(0, 'group:default/test', 'role:default/test'); - }, - ); - - it('should delete a role that the user is an owner of', async () => { - const result = await request(app) - .delete('/roles/role/default/test') - .send(); - - expect(result.statusCode).toEqual(204); - }); - }); - - describe('GET /policies', () => { - it('should all policies that the user owns', async () => { - const result = await request(app).get('/policies').send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([ - { - entityReference: 'role:default/test', - permission: 'policy.entity.create', - policy: 'create', - effect: 'allow', - metadata: { - source: 'rest', - }, - }, - { - entityReference: 'role:default/test', - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - metadata: { - source: 'rest', - }, - }, - ]); - }); - - it('should return filtered policies that the user owns', async () => { - const result = await request(app) - .get( - '/policies?entityRef=role:default/test&permission=policy-entity&policy=read&effect=allow', - ) - .send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([ - { - entityReference: 'role:default/test', - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - metadata: { - source: 'rest', - }, - }, - ]); - }); - - it('should be return no policies because the user is not an owner', async () => { - const result = await request(app) - .get( - '/policies?entityRef=role:default/guest&permission=policy-entity&policy=read&effect=allow', - ) - .send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([]); - }); - }); - - describe('GET /policies/:kind/:namespace/:name', () => { - it('should return permission policies by user reference that the user owns', async () => { - const result = await request(app) - .get('/policies/role/default/test') - .send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([ - { - entityReference: 'role:default/test', - permission: 'policy.entity.create', - policy: 'create', - effect: 'allow', - metadata: { - source: 'rest', - }, - }, - { - entityReference: 'role:default/test', - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - metadata: { - source: 'rest', - }, - }, - ]); - }); - - it('should not return policies by user reference not found because user does not own them', async () => { - const result = await request(app) - .get('/policies/user/default/permission_admin') - .send(); - expect(result.statusCode).toBe(404); - expect(result.body).toEqual({ - error: { message: '', name: 'NotFoundError' }, - request: { - method: 'GET', - url: '/policies/user/default/permission_admin', - }, - response: { statusCode: 404 }, - }); - }); - }); - - describe('PUT /policies/:kind/:namespace/:name', () => { - it('should fail to update policy - user does not own old policy', async () => { - const result = await request(app) - .put('/policies/role/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'create', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toEqual(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should update policy that a user owns', async () => { - enforcerDelegateMock.updatePolicies = jest.fn().mockImplementation(); - - const result = await request(app) - .put('/policies/role/default/test') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'update', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toEqual(200); - }); - }); - - describe('DELETE /policies/:kind/:namespace/:name', () => { - it('should fail to delete, because policy not found because user is not an owner', async () => { - const result = await request(app) - .delete('/policies/role/default/permission_admin') - .send([ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ]); - - expect(result.statusCode).toEqual(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should delete policy', async () => { - const result = await request(app) - .delete( - '/policies/role/default/test?permission=policy-entity&policy=read&effect=allow', - ) - .send([ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ]); - - expect(result.statusCode).toEqual(204); - }); - }); - - describe('GET /roles/conditions', () => { - it('should return all condition decisions that the user is an owner of', async () => { - const result = await request(app).get('/roles/conditions').send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual(expectedConditions); - expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); - }); - }); - - describe('GET /roles/condition/:id', () => { - it('should return condition decision by id', async () => { - conditionalStorageMock.getCondition = jest - .fn() - .mockImplementation(async (id: number) => { - if (id === 1) { - return conditions[0]; - } - return undefined; - }); - - const result = await request(app).get('/roles/conditions/1').send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual(expectedConditions[0]); - expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); - }); - - it('should return 404', async () => { - const result = await request(app).get('/roles/conditions/3').send(); - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual({ - message: '', - name: 'NotFoundError', - }); - }); - - it('should return nothing when the user is not an owner of the condition', async () => { - const result = await request(app).get('/roles/conditions/2').send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([]); - }); - }); - - describe('PUT /roles/conditions', () => { - it('should return return 403 for condition that the user is not an owner of', async () => { - const conditionDecision: RoleConditionalPolicyDecision = - { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: ['read'], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }; - const result = await request(app) - .put('/roles/conditions/2') - .send(conditionDecision); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - message: '', - name: 'NotAllowedError', - }); - }); - - it('should update condition decision that the user is an owner of', async () => { - const conditionDecision: RoleConditionalPolicyDecision = - { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: ['read'], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }; - const result = await request(app) - .put('/roles/conditions/1') - .send(conditionDecision); - - expect(validateRoleConditionMock).toHaveBeenCalledWith(conditionDecision); - - expect(result.statusCode).toBe(200); - expect(conditionalStorageMock.updateCondition).toHaveBeenCalledWith(1, { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: [ - { - action: 'read', - name: 'catalog.entity.read', - }, - ], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }); - expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); - }); - }); - - describe('DELETE /roles/conditions/:id', () => { - it('should delete condition decision by id where the user is an owner', async () => { - const result = await request(app).delete('/roles/conditions/1').send(); - - expect(result.statusCode).toEqual(204); - expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); - expect(conditionalStorageMock.deleteCondition).toHaveBeenCalled(); - }); - - it('should fail to delete condition decision by id because user is not an owner', async () => { - const result = await request(app).delete('/roles/conditions/2').send(); - - expect(result.statusCode).toEqual(403); - expect(result.body.error.message).toEqual(''); - }); - }); -}); diff --git a/plugins/rbac-backend/src/service/policies-rest-api.test.ts b/plugins/rbac-backend/src/service/policies-rest-api.test.ts deleted file mode 100644 index 8703091cb9..0000000000 --- a/plugins/rbac-backend/src/service/policies-rest-api.test.ts +++ /dev/null @@ -1,4239 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter'; -import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; -import { InputError } from '@backstage/errors'; -import { - AuthorizeResult, - type MetadataResponse, -} from '@backstage/plugin-permission-common'; - -import express from 'express'; -import request from 'supertest'; - -import { - PermissionAction, - PermissionInfo, - policyEntityCreatePermission, - policyEntityDeletePermission, - policyEntityReadPermission, - policyEntityUpdatePermission, - Role, - RoleConditionalPolicyDecision, - Source, -} from '@backstage-community/plugin-rbac-common'; - -import { RoleMetadataDao } from '../database/role-metadata'; -import { EnforcerDelegate } from './enforcer-delegate'; -import { - PluginMetadataResponseSerializedRule, - PluginPermissionMetadataCollector, -} from './plugin-endpoints'; -import { PoliciesServer } from './policies-rest-api'; -import { RBACRouterOptions } from './policy-builder'; -import { - mockAuditorService, - conditionalStorageMock, - credentials, - enforcerDelegateMock, - mockAuthService, - mockedAuthorizeConditional, - mockHttpAuth, - mockLoggerService, - mockPermissionEvaluator, - pluginMetadataCollectorMock, - providerMock, - roleMetadataStorageMock, - mockedAuthorize, - mockPermissionRegistry, - permissionDependentPluginStoreMock, - extendablePluginIdProviderMock, -} from '../../__fixtures__/mock-utils'; -import { ExtendablePluginIdProvider } from './extendable-id-provider'; - -jest.setTimeout(60000); - -jest.mock('@backstage/plugin-auth-node', () => ({ - getBearerTokenFromAuthorizationHeader: () => 'token', -})); - -const validateRoleConditionMock = jest.fn().mockImplementation(); -jest.mock('../validation/condition-validation', () => { - return { - validateRoleCondition: jest - .fn() - .mockImplementation( - (condition: RoleConditionalPolicyDecision) => { - validateRoleConditionMock(condition); - }, - ), - }; -}); - -const conditions: RoleConditionalPolicyDecision[] = [ - { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: [{ name: 'catalog.entity.read', action: 'read' }], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }, -]; - -const expectedConditions: RoleConditionalPolicyDecision[] = [ - { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: ['read'], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }, -]; - -const modifiedBy = 'user:default/some-admin'; - -describe('REST policies API', () => { - let app: express.Express; - let config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - permission: { - enabled: true, - }, - }, - }); - - let server: PoliciesServer; - - beforeEach(async () => { - conditionalStorageMock.filterConditions = jest - .fn() - .mockImplementation( - async ( - _roleEntityRef: string, - pluginId: string, - resourceType: string, - ) => { - if (resourceType === 'catalog-entity' || pluginId === 'catalog') { - return conditions; - } - - if ( - resourceType === 'scaffolder-template' || - pluginId === 'scaffolder' - ) { - return []; - } - return conditions; - }, - ); - - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return false; - }); - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return false; - }); - enforcerDelegateMock.getFilteredPolicy = jest - .fn() - .mockImplementation( - async (_fieldIndex: number, ..._fieldValues: string[]) => { - return [ - [ - 'user:default/permission_admin', - 'policy-entity', - 'create', - 'allow', - ], - ]; - }, - ); - enforcerDelegateMock.getFilteredGroupingPolicy = jest - .fn() - .mockImplementation( - async (_fieldIndex: number, ..._fieldValues: string[]) => { - return [['user:default/permission_admin', 'role:default/rbac_admin']]; - }, - ); - enforcerDelegateMock.removeGroupingPolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - enforcerDelegateMock.addGroupingPolicies = jest.fn().mockImplementation(); - - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async (roleEntityRef: string): Promise => { - return { - source: 'rest', - roleEntityRef: roleEntityRef, - modifiedBy: 'user:default/some-user', - }; - }, - ); - - roleMetadataStorageMock.filterForOwnerRoleMetadata = jest - .fn() - .mockImplementation(async (): Promise => { - return [ - { - source: 'rest', - roleEntityRef: 'role:default/permission_admin', - modifiedBy: 'user:default/some-user', - }, - { - source: 'rest', - roleEntityRef: 'role:default/guest', - modifiedBy: 'user:default/some-user', - }, - { - source: 'rest', - roleEntityRef: 'role:default/test', - modifiedBy: 'user:default/some-user', - }, - ]; - }); - - conditionalStorageMock.getCondition = jest - .fn() - .mockImplementation(async (id: number) => { - if (id === 1) { - return conditions[0]; - } - return undefined; - }); - - mockHttpAuth.credentials = jest.fn().mockImplementation(() => credentials); - - const options: RBACRouterOptions = { - config: config, - logger: mockLoggerService, - httpAuth: mockHttpAuth, - auth: mockAuthService, - permissionsRegistry: mockPermissionRegistry, - auditor: mockAuditorService, - permissions: mockPermissionEvaluator, - }; - - server = new PoliciesServer( - options, - enforcerDelegateMock as EnforcerDelegate, - conditionalStorageMock, - pluginMetadataCollectorMock as PluginPermissionMetadataCollector, - roleMetadataStorageMock, - permissionDependentPluginStoreMock, - extendablePluginIdProviderMock as ExtendablePluginIdProvider, - undefined, - ); - const router = await server.serve(); - app = express().use(router); - app.use( - MiddlewareFactory.create({ logger: mockLoggerService, config }).error(), - ); - validateRoleConditionMock.mockReset(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should build', () => { - expect(app).toBeTruthy(); - }); - - describe('GET /', () => { - it('should return a status of Authorized', async () => { - const result = await request(app).get('/').send(); - - expect(result.status).toBe(200); - expect(result.body).toEqual({ status: 'Authorized' }); - }); - - it('should return a status of Unauthorized', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app).get('/').send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityReadPermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - }); - - describe('POST /policies', () => { - afterEach(() => { - (enforcerDelegateMock.addPolicies as jest.Mock).mockReset(); - }); - - it('should return a status of Unauthorized', async () => { - mockedAuthorize.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app).post('/policies').send(); - - expect(mockedAuthorize).toHaveBeenCalledWith( - [ - { - permission: policyEntityCreatePermission, - }, - ], - { - credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should return a status of Unauthorized - non user request', async () => { - mockHttpAuth.credentials = jest - .fn() - .mockImplementationOnce(() => mockCredentials.service()); - const result = await request(app).post('/policies').send(); - - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: `Only credential principal with type 'user' permitted to modify permissions`, - }); - }); - - it('should not be created permission policy - req body is an empty', async () => { - const result = await request(app).post('/policies').send(); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `permission policy must be present`, - }); - }); - - it('should not be created permission policy - entityReference is empty', async () => { - const result = await request(app).post('/policies').send([{}]); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid policy definition. Cause: 'entityReference' must not be empty`, - }); - }); - - it('should not be created permission policy - entityReference is invalid', async () => { - const result = await request(app) - .post('/policies') - .send([{ entityReference: 'user' }]); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid policy definition. Cause: Entity reference "user" had missing or empty kind (e.g. did not start with "component:" or similar)`, - }); - }); - - it('should not be created permission policy - permission is an empty', async () => { - const result = await request(app) - .post('/policies') - .send([ - { - entityReference: 'user:default/permission_admin', - }, - ]); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid policy definition. Cause: 'permission' field must not be empty`, - }); - }); - - it('should not be created permission policy - policy is an empty', async () => { - const result = await request(app) - .post('/policies') - .send([ - { - entityReference: 'user:default/permission_admin', - permission: 'policy-entity', - }, - ]); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid policy definition. Cause: 'policy' field must not be empty`, - }); - }); - - it('should not be created permission policy - effect is an empty', async () => { - const result = await request(app) - .post('/policies') - .send([ - { - entityReference: 'user:default/permission_admin', - permission: 'policy-entity', - policy: 'read', - }, - ]); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid policy definition. Cause: 'effect' field must not be empty`, - }); - }); - - it('should be created permission policy', async () => { - const result = await request(app) - .post('/policies') - .send([ - { - entityReference: 'user:default/permission_admin', - permission: 'policy-entity', - policy: 'delete', - effect: 'deny', - }, - ]); - - expect(result.statusCode).toBe(201); - }); - - it('should fail to create permission policy, because of source mismatch', async () => { - const roleMeta: RoleMetadataDao = { - roleEntityRef: 'user:default/permission_admin', - source: 'csv-file', - modifiedBy, - }; - - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async (roleEntityRef: string): Promise => { - if (roleEntityRef === roleMeta.roleEntityRef) { - return roleMeta; - } - return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; - }, - ); - - const result = await request(app) - .post('/policies') - .send([ - { - entityReference: 'user:default/permission_admin', - permission: 'policy-entity', - policy: 'delete', - effect: 'deny', - }, - ]); - - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: `Unable to add policy user:default/permission_admin,policy-entity,delete,deny: source does not match originating role ${ - roleMeta.roleEntityRef - }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, - }); - }); - - it('should fail to add permission policy, with original source of configuration', async () => { - const roleMeta: RoleMetadataDao = { - roleEntityRef: 'user:default/permission_admin', - source: 'configuration', - modifiedBy, - }; - - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async (roleEntityRef: string): Promise => { - if (roleEntityRef === roleMeta.roleEntityRef) { - return roleMeta; - } - return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; - }, - ); - - const result = await request(app) - .post('/policies') - .send([ - { - entityReference: 'user:default/permission_admin', - permission: 'policy-entity', - policy: 'delete', - effect: 'deny', - }, - ]); - - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: `Unable to add policy user:default/permission_admin,policy-entity,delete,deny: source does not match originating role ${ - roleMeta.roleEntityRef - }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, - }); - }); - - it('should not be created permission policy, because it is has been already present', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param.at(2) === 'read') { - return Promise.resolve(true); - } - return Promise.resolve(false); - }); - - const result = await request(app) - .post('/policies') - .send([ - { - entityReference: 'user:default/permission_admin', - permission: 'policy-entity', - policy: 'read', - effect: 'deny', - }, - { - entityReference: 'user:default/permission_admin', - permission: 'policy-entity', - policy: 'delete', - effect: 'deny', - }, - ]); - - expect(result.statusCode).toBe(409); - }); - - it('should not be created permission policy caused some unexpected error', async () => { - enforcerDelegateMock.addPolicies = jest - .fn() - .mockImplementation(async (): Promise => { - throw new Error(`Failed to add policies`); - }); - - const result = await request(app) - .post('/policies') - .send([ - { - entityReference: 'user:default/permission_admin', - permission: 'policy-entity', - policy: 'delete', - effect: 'deny', - }, - ]); - - expect(result.statusCode).toBe(500); - }); - - it('should fail to create permission policy - duplication in req body', async () => { - const result = await request(app) - .post('/policies') - .send([ - { - entityReference: 'user:default/permission_admin', - permission: 'policy-entity', - policy: 'delete', - effect: 'deny', - }, - { - entityReference: 'user:default/permission_admin', - permission: 'policy-entity', - policy: 'delete', - effect: 'deny', - }, - ]); - - expect(result.statusCode).toBe(409); - expect(result.body.error).toEqual({ - name: 'ConflictError', - message: `Duplicate polices found; user:default/permission_admin, policy-entity, delete, deny is a duplicate`, - }); - }); - }); - - describe('GET /policies/:kind/:namespace/:name', () => { - it('should return a status of Unauthorized', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app) - .get('/policies/user/default/permission_admin') - .send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityReadPermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should be returned permission policies by user reference', async () => { - enforcerDelegateMock.getFilteredPolicy = jest - .fn() - .mockImplementation( - async (_fieldIndex: number, ..._fieldValues: string[]) => { - return [ - [ - 'role:default/permission_admin', - 'policy.entity.create', - 'create', - 'allow', - ], - ]; - }, - ); - const result = await request(app) - .get('/policies/role/default/permission_admin') - .send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([ - { - entityReference: 'role:default/permission_admin', - permission: 'policy.entity.create', - policy: 'create', - effect: 'allow', - metadata: { - source: 'rest', - }, - }, - ]); - }); - - // TODO: - it('should be returned permission policies with modified `policy-entity, create` permission by user reference', async () => { - const deprecatedPolicy = [ - 'role:default/permission_admin', - 'policy-entity', - 'create', - 'allow', - ]; - enforcerDelegateMock.getFilteredPolicy = jest - .fn() - .mockImplementation( - async (_fieldIndex: number, ..._fieldValues: string[]) => { - return [ - [ - 'role:default/permission_admin', - 'policy-entity', - 'create', - 'allow', - ], - ]; - }, - ); - const result = await request(app) - .get('/policies/role/default/permission_admin') - .send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([ - { - entityReference: 'role:default/permission_admin', - permission: 'policy.entity.create', - policy: 'create', - effect: 'allow', - metadata: { - source: 'rest', - }, - }, - ]); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 1, - `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${deprecatedPolicy} to use 'policy.entity.create' instead of 'policy-entity' from source rest`, - ); - }); - - it('should be returned policies by user reference not found', async () => { - enforcerDelegateMock.getFilteredPolicy = jest - .fn() - .mockImplementation( - async (_fieldIndex: number, ..._fieldValues: string[]) => { - return []; - }, - ); - - const result = await request(app) - .get('/policies/user/default/permission_admin') - .send(); - expect(result.statusCode).toBe(404); - expect(result.body).toEqual({ - error: { message: '', name: 'NotFoundError' }, - request: { - method: 'GET', - url: '/policies/user/default/permission_admin', - }, - response: { statusCode: 404 }, - }); - }); - }); - - describe('GET /policies', () => { - it('should return a status of Unauthorized', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app).get('/policies').send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityReadPermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should be returned list all policies', async () => { - enforcerDelegateMock.getFilteredPolicy = jest - .fn() - .mockImplementation( - async (_fieldIndex: number, ...fieldValues: string[]) => { - if (fieldValues[0] === 'role:default/permission_admin') { - return [ - [ - 'role:default/permission_admin', - 'policy.entity.create', - 'create', - 'allow', - ], - ]; - } - - if (fieldValues[0] === 'role:default/guest') { - return [ - [ - 'role:default/guest', - 'policy-entity', - 'read', - 'allow', - 'rest', - ], - ]; - } - - return []; - }, - ); - const result = await request(app).get('/policies').send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([ - { - entityReference: 'role:default/permission_admin', - permission: 'policy.entity.create', - policy: 'create', - effect: 'allow', - metadata: { - source: 'rest', - }, - }, - { - entityReference: 'role:default/guest', - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - metadata: { - source: 'rest', - }, - }, - ]); - }); - - // TODO: - it('should be returned list all policies with modified `policy-entity, create` permission', async () => { - const deprecatedPolicy = [ - 'role:default/guest', - 'policy-entity', - 'create', - 'allow', - ]; - enforcerDelegateMock.getFilteredPolicy = jest - .fn() - .mockImplementation( - async (_fieldIndex: number, ...fieldValues: string[]) => { - if (fieldValues[0] === 'role:default/permission_admin') { - return [ - [ - 'role:default/permission_admin', - 'policy.entity.create', - 'create', - 'allow', - ], - ]; - } - - if (fieldValues[0] === 'role:default/guest') { - return [ - [ - 'role:default/guest', - 'policy-entity', - 'read', - 'allow', - 'rest', - ], - [ - 'role:default/guest', - 'policy-entity', - 'create', - 'allow', - 'rest', - ], - ]; - } - - return []; - }, - ); - const result = await request(app).get('/policies').send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([ - { - entityReference: 'role:default/permission_admin', - permission: 'policy.entity.create', - policy: 'create', - effect: 'allow', - metadata: { - source: 'rest', - }, - }, - { - entityReference: 'role:default/guest', - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - metadata: { - source: 'rest', - }, - }, - { - entityReference: 'role:default/guest', - permission: 'policy.entity.create', - policy: 'create', - effect: 'allow', - metadata: { - source: 'rest', - }, - }, - ]); - expect(mockLoggerService.warn).toHaveBeenNthCalledWith( - 1, - `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${deprecatedPolicy} to use 'policy.entity.create' instead of 'policy-entity' from source rest`, - ); - }); - - it('should be returned list filtered policies', async () => { - enforcerDelegateMock.getFilteredPolicy = jest - .fn() - .mockImplementation( - async (_fieldIndex: number, ..._fieldValues: string[]) => { - return [ - ['role:default/guest', 'policy-entity', 'read', 'allow', 'rest'], - ]; - }, - ); - const result = await request(app) - .get( - '/policies?entityRef=role:default/guest&permission=policy-entity&policy=read&effect=allow', - ) - .send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([ - { - entityReference: 'role:default/guest', - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - metadata: { - source: 'rest', - }, - }, - ]); - }); - }); - - describe('DELETE /policies/:kind/:namespace/:name', () => { - it('should return a status of Unauthorized', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app) - .delete('/policies/user/default/permission_admin') - .send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityDeletePermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should fail to delete, request is empty', async () => { - const result = await request(app) - .delete('/policies/user/default/permission_admin') - .send(); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `permission policy must be present`, - }); - }); - - it('should fail to delete, because permission field is absent', async () => { - const result = await request(app) - .delete('/policies/user/default/permission_admin') - .send([{}]); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid policy definition. Cause: 'permission' field must not be empty`, - }); - }); - - it('should fail to delete, because policy field is absent', async () => { - const result = await request(app) - .delete('/policies/user/default/permission_admin') - .send([ - { - permission: 'policy-entity', - }, - ]); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid policy definition. Cause: 'policy' field must not be empty`, - }); - }); - - it('should fail to delete, because effect field is absent', async () => { - const result = await request(app) - .delete('/policies/user/default/permission_admin') - .send([ - { - permission: 'policy-entity', - policy: 'read', - }, - ]); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid policy definition. Cause: 'effect' field must not be empty`, - }); - }); - - it('should fail to delete, because policy not found', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return false; - }); - - const result = await request(app) - .delete('/policies/user/default/permission_admin') - .send([ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ]); - - expect(result.statusCode).toEqual(404); - expect(result.body.error).toEqual({ - name: 'NotFoundError', - message: `Policy '[user:default/permission_admin, policy-entity, read, allow]' not found`, - }); - }); - - it('should fail to delete, because unexpected error', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - enforcerDelegateMock.removePolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - throw new Error('Fail to delete policy'); - }); - - const result = await request(app) - .delete('/policies/user/default/permission_admin') - .send([ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ]); - - expect(result.statusCode).toEqual(500); - expect(result.body.error).toEqual({ - name: 'Error', - message: 'Fail to delete policy', - }); - }); - - it('should fail to delete, because source mismatch', async () => { - const roleMeta: RoleMetadataDao = { - roleEntityRef: 'user:default/permission_admin', - source: 'csv-file', - modifiedBy, - }; - - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async (roleEntityRef: string): Promise => { - if (roleEntityRef === roleMeta.roleEntityRef) { - return roleMeta; - } - return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; - }, - ); - - const result = await request(app) - .delete('/policies/user/default/permission_admin') - .send([ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ]); - - const policy = [ - 'user:default/permission_admin', - 'policy-entity', - 'read', - 'allow', - ]; - - expect(result.statusCode).toEqual(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: `Unable to delete policy ${policy}: source does not match originating role ${ - roleMeta.roleEntityRef - }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, - }); - }); - - it('should fail to delete policy, with original source of configuration', async () => { - const roleMeta: RoleMetadataDao = { - roleEntityRef: 'user:default/permission_admin', - source: 'configuration', - modifiedBy, - }; - - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async (roleEntityRef: string): Promise => { - if (roleEntityRef === roleMeta.roleEntityRef) { - return roleMeta; - } - return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; - }, - ); - - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - enforcerDelegateMock.removePolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - - const result = await request(app) - .delete('/policies/user/default/permission_admin') - .send([ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ]); - - const policy = [ - 'user:default/permission_admin', - 'policy-entity', - 'read', - 'allow', - ]; - - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: `Unable to delete policy ${policy}: source does not match originating role ${ - roleMeta.roleEntityRef - }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, - }); - }); - - it('should delete policy', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - enforcerDelegateMock.removePolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - - const result = await request(app) - .delete( - '/policies/user/default/permission_admin?permission=policy-entity&policy=read&effect=allow', - ) - .send([ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ]); - - expect(result.statusCode).toEqual(204); - }); - }); - - describe('PUT /policies/:kind/:namespace/:name', () => { - it('should return a status of Unauthorized', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityUpdatePermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should fail to update policy - old policy is absent', async () => { - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send([{}]); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `'oldPolicy' object must be present`, - }); - }); - - it('should fail to update policy - new policy is absent', async () => { - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ oldPolicy: [{}] }); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `'newPolicy' object must be present`, - }); - }); - - it('should fail to update policy - oldPolicy permission is absent', async () => { - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ oldPolicy: [{}], newPolicy: [{}] }); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid old policy definition. Cause: 'permission' field must not be empty`, - }); - }); - - it('should fail to update policy - oldPolicy policy is absent', async () => { - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [{ permission: 'policy-entity' }], - newPolicy: [{}], - }); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid old policy definition. Cause: 'policy' field must not be empty`, - }); - }); - - it('should fail to update policy - oldPolicy effect is absent', async () => { - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [{ permission: 'policy-entity', policy: 'read' }], - newPolicy: [{}], - }); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid old policy definition. Cause: 'effect' field must not be empty`, - }); - }); - - it('should fail to update policy - newPolicy permission is absent', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [{}], - }); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid new policy definition. Cause: 'permission' field must not be empty`, - }); - }); - - it('should fail to update policy - newPolicy policy is absent', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [{ permission: 'policy-entity' }], - }); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid new policy definition. Cause: 'policy' field must not be empty`, - }); - }); - - it('should fail to update policy - newPolicy effect is absent', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [{ permission: 'policy-entity', policy: 'create' }], - }); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid new policy definition. Cause: 'effect' field must not be empty`, - }); - }); - - it('should fail to update policy - newPolicy effect has invalid value', async () => { - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'unknown', - }, - ], - newPolicy: [{ permission: 'policy-entity', policy: 'create' }], - }); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid old policy definition. Cause: 'effect' has invalid value: 'unknown'. It should be: '${AuthorizeResult.ALLOW.toLocaleLowerCase()}' or '${AuthorizeResult.DENY.toLocaleLowerCase()}'`, - }); - }); - - it('should fail to update policy - old policy not found', async () => { - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'create', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toEqual(404); - expect(result.body.error).toEqual({ - name: 'NotFoundError', - message: `Policy '[user:default/permission_admin, policy-entity, read, allow]' not found`, - }); - }); - - it('should fail to update policy - old policy not found but old and new policies match', async () => { - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toEqual(404); - expect(result.body.error).toEqual({ - name: 'NotFoundError', - message: `Policy '[user:default/permission_admin, policy-entity, read, allow]' not found`, - }); - }); - - it('should fail to update policy - newPolicy is already present', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'create', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toEqual(409); - expect(result.body.error).toEqual({ - name: 'ConflictError', - message: `Policy '[user:default/permission_admin, policy-entity, create, allow]' has been already stored`, - }); - }); - - it('should nothing to update', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toEqual(204); - }); - - it('should nothing to update - same permissions with different policy in a different order', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - { - permission: 'policy-entity', - policy: 'delete', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'delete', - effect: 'allow', - }, - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toEqual(204); - }); - - it('should nothing to update - same permissions with different permission type in a different order', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - { - permission: 'catalog-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'catalog-entity', - policy: 'read', - effect: 'allow', - }, - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toEqual(204); - }); - - it('should fail to update policy - unable to remove oldPolicy', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[2] === 'create') { - return false; - } - return true; - }); - enforcerDelegateMock.updatePolicies = jest - .fn() - .mockImplementation(async (): Promise => { - throw new Error('Fail to remove policy'); - }); - - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'create', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toEqual(500); - expect(result.body.error).toEqual({ - name: 'Error', - message: 'Fail to remove policy', - }); - }); - - it('should fail to update policy - unable to add newPolicy', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[2] === 'create') { - return false; - } - return true; - }); - enforcerDelegateMock.updatePolicies = jest - .fn() - .mockImplementation( - async (_param: string[][], _source: Source): Promise => { - throw new Error('Fail to add policy'); - }, - ); - - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'create', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toEqual(500); - expect(result.body.error).toEqual({ - name: 'Error', - message: 'Fail to add policy', - }); - }); - - it('should update policy', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[2] === 'create') { - return false; - } - return true; - }); - enforcerDelegateMock.updatePolicies = jest.fn().mockImplementation(); - - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'create', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toEqual(200); - }); - - it('should fail to update permission policy - duplication in old policy', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[2] === 'create') { - return false; - } - return true; - }); - - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'create', - effect: 'allow', - }, - { - permission: 'policy-entity', - policy: 'create', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toBe(409); - expect(result.body.error).toEqual({ - name: 'ConflictError', - message: `Duplicate polices found; user:default/permission_admin, policy-entity, read, allow is a duplicate`, - }); - }); - - it('should fail to update permission policy - duplication in new policy', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[2] === 'update') { - return false; - } - return true; - }); - - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - { - permission: 'policy-entity', - policy: 'create', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'update', - effect: 'allow', - }, - { - permission: 'policy-entity', - policy: 'update', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toBe(409); - expect(result.body.error).toEqual({ - name: 'ConflictError', - message: `Duplicate polices found; user:default/permission_admin, policy-entity, update, allow is a duplicate`, - }); - }); - - it('should fail to update permission policy - oldPolicy has an additional permission', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - { - permission: 'policy-entity', - policy: 'create', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'delete', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `'oldPolicy' object has more permission policies compared to 'newPolicy' object`, - }); - }); - - it('should fail to update permission policy, because of source mismatch', async () => { - const roleMeta: RoleMetadataDao = { - roleEntityRef: 'user:default/permission_admin', - source: 'csv-file', - modifiedBy, - }; - - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async (roleEntityRef: string): Promise => { - if (roleEntityRef === roleMeta.roleEntityRef) { - return roleMeta; - } - return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; - }, - ); - - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - { - permission: 'policy-entity', - policy: 'create', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'delete', - effect: 'allow', - }, - ], - }); - - const policy = [ - 'user:default/permission_admin', - 'policy-entity', - 'read', - 'allow', - ]; - - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: `Unable to edit policy ${policy}: source does not match originating role ${ - roleMeta.roleEntityRef - }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, - }); - }); - - it('should fail to update permission policy, with original source of configuration', async () => { - const roleMeta: RoleMetadataDao = { - roleEntityRef: 'user:default/permission_admin', - source: 'configuration', - modifiedBy, - }; - - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async (roleEntityRef: string): Promise => { - if (roleEntityRef === roleMeta.roleEntityRef) { - return roleMeta; - } - return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; - }, - ); - - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[2] === 'delete') { - return false; - } - return true; - }); - enforcerDelegateMock.updatePolicies = jest.fn().mockImplementation(); - - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'delete', - effect: 'allow', - }, - ], - }); - - const policy = [ - 'user:default/permission_admin', - 'policy-entity', - 'read', - 'allow', - ]; - - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: `Unable to edit policy ${policy}: source does not match originating role ${ - roleMeta.roleEntityRef - }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, - }); - }); - }); - - describe('GET /roles', () => { - it('should return a status of Unauthorized', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app).get('/roles').send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityReadPermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should be returned list all roles', async () => { - enforcerDelegateMock.getGroupingPolicy = jest - .fn() - .mockImplementation(async () => { - return [ - ['group:default/test', 'role:default/test'], - ['group:default/team_a', 'role:default/team_a'], - ]; - }); - const result = await request(app).get('/roles').send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([ - { - memberReferences: ['group:default/test'], - name: 'role:default/test', - metadata: { - isDefault: false, - source: 'rest', - modifiedBy: 'user:default/some-user', - }, - }, - { - memberReferences: ['group:default/team_a'], - name: 'role:default/team_a', - metadata: { - isDefault: false, - source: 'rest', - modifiedBy: 'user:default/some-user', - }, - }, - ]); - }); - }); - - describe('GET /roles/:kind/:namespace/:name', () => { - it('should return a status of Unauthorized', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app) - .get('/roles/role/default/rbac_admin') - .send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityReadPermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should return an input error when kind is wrong', async () => { - const result = await request(app) - .get('/roles/test/default/rbac_admin') - .send(); - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Unsupported kind test. Supported value should be "role"`, - }); - }); - - it('should be returned role by role reference', async () => { - const result = await request(app) - .get('/roles/role/default/rbac_admin') - .send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([ - { - memberReferences: ['user:default/permission_admin'], - name: 'role:default/rbac_admin', - metadata: { - isDefault: false, - source: 'rest', - modifiedBy: 'user:default/some-user', - }, - }, - ]); - }); - - it('should be returned not found error by role reference', async () => { - enforcerDelegateMock.getFilteredGroupingPolicy = jest - .fn() - .mockImplementation( - async (_fieldIndex: number, ..._fieldValues: string[]) => { - return []; - }, - ); - - const result = await request(app) - .get('/roles/role/default/rbac_admin') - .send(); - expect(result.statusCode).toBe(404); - expect(result.body).toEqual({ - error: { message: '', name: 'NotFoundError' }, - request: { - method: 'GET', - url: '/roles/role/default/rbac_admin', - }, - response: { statusCode: 404 }, - }); - }); - }); - - describe('POST /roles', () => { - beforeEach(() => { - mockedAuthorizeConditional.mockImplementation(async () => [ - { result: AuthorizeResult.ALLOW }, - ]); - }); - it('should return a status of Unauthorized', async () => { - mockedAuthorize.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app).post('/roles').send(); - - expect(mockedAuthorize).toHaveBeenCalledWith( - [ - { - permission: policyEntityCreatePermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should not be created role - req body is an empty', async () => { - const result = await request(app).post('/roles').send(); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid role definition. Cause: 'name' field must not be empty`, - }); - }); - - it('should not be created role - memberReferences is missing', async () => { - const result = await request(app).post('/roles').send({ - name: 'role:default/test', - }); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid role definition. Cause: 'memberReferences' field must not be empty`, - }); - }); - - it('should not be created role - memberReferences is empty', async () => { - const result = await request(app).post('/roles').send({ - memberReferences: [], - name: 'role:default/test', - }); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid role definition. Cause: 'memberReferences' field must not be empty`, - }); - }); - - it('should not be created role - memberReferences is invalid', async () => { - const result = await request(app) - .post('/roles') - .send({ - memberReferences: ['user'], - name: 'role:default/test', - }); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid role definition. Cause: Entity reference "user" had missing or empty kind (e.g. did not start with "component:" or similar)`, - }); - }); - - it('should not be created role - name is empty', async () => { - const result = await request(app) - .post('/roles') - .send({ - memberReferences: ['user:default/permission_admin'], - }); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid role definition. Cause: 'name' field must not be empty`, - }); - }); - - it('should not create a role - name is invalid', async () => { - const result = await request(app) - .post('/roles') - .send({ - memberReferences: ['user:default/permission_admin'], - name: 'x:default/rbac_admin', - }); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid role definition. Cause: Unsupported kind x. Supported value should be "role"`, - }); - }); - - it('should be created role', async () => { - const result = await request(app) - .post('/roles') - .send({ - memberReferences: ['user:default/permission_admin'], - name: 'role:default/some_test_role', - }); - - expect(result.statusCode).toBe(201); - expect(enforcerDelegateMock.addGroupingPolicies).toHaveBeenCalledWith( - [['user:default/permission_admin', 'role:default/some_test_role']], - { - author: 'user:default/mock', - roleEntityRef: 'role:default/some_test_role', - source: 'rest', - description: '', - modifiedBy: 'user:default/mock', - owner: 'user:default/mock', - }, - ); - }); - - it.each(['user:default/permission_admin', 'user:default/Permission_Admin'])( - `should be created role with description`, - async member => { - const result = await request(app) - .post('/roles') - .send({ - memberReferences: [member], - name: 'role:default/some_test_role', - metadata: { - description: 'some test description', - }, - }); - - expect(result.statusCode).toBe(201); - expect(enforcerDelegateMock.addGroupingPolicies).toHaveBeenCalledWith( - [['user:default/permission_admin', 'role:default/some_test_role']], - { - roleEntityRef: 'role:default/some_test_role', - source: 'rest', - author: 'user:default/mock', - description: 'some test description', - modifiedBy: 'user:default/mock', - owner: 'user:default/mock', - }, - ); - }, - ); - - it('should not be created role, because it is has been already present', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - - const result = await request(app) - .post('/roles') - .send({ - memberReferences: ['user:default/permission_admin'], - name: 'role:default/rbac_admin', - }); - - expect(result.statusCode).toBe(409); - }); - - it('should not be created role caused some unexpected error', async () => { - enforcerDelegateMock.addGroupingPolicies = jest - .fn() - .mockImplementation(async (): Promise => { - throw new Error('Fail to create new policy'); - }); - - const result = await request(app) - .post('/roles') - .send({ - memberReferences: ['user:default/permission_admin'], - name: 'role:default/rbac_admin', - }); - - expect(result.statusCode).toBe(500); - expect(result.body.error).toEqual({ - name: 'Error', - message: 'Fail to create new policy', - }); - }); - - it.each(['user:default/permission_admin', 'user:default/Permission_Admin'])( - 'should fail to create role - duplicate', - async duplicate => { - const result = await request(app) - .post('/roles') - .send({ - memberReferences: ['user:default/permission_admin', duplicate], - name: 'role:default/rbac_admin', - }); - - expect(result.statusCode).toBe(409); - expect(result.body.error).toEqual({ - name: 'ConflictError', - message: `Duplicate role members found; user:default/permission_admin, role:default/rbac_admin is a duplicate`, - }); - }, - ); - - it('should fail to add role, because source mismatch', async () => { - const roleMeta: RoleMetadataDao = { - roleEntityRef: 'role:default/rbac_admin', - source: 'configuration', - modifiedBy, - }; - - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async (roleEntityRef: string): Promise => { - if (roleEntityRef === roleMeta.roleEntityRef) { - return roleMeta; - } - return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; - }, - ); - - const result = await request(app) - .post('/roles') - .send({ - memberReferences: ['user:default/permission_admin'], - name: 'role:default/rbac_admin', - }); - - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: `Unable to add role: source does not match originating role ${ - roleMeta.roleEntityRef - }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, - }); - }); - }); - - describe('PUT /roles/:kind/:namespace/:name', () => { - it('should return a status of Unauthorized', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityUpdatePermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should fail to update role - old role is absent', async () => { - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send(); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `'oldRole' object must be present`, - }); - }); - - it('should fail to update role - new role is absent', async () => { - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ oldRole: {} }); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `'newRole' object must be present`, - }); - }); - - it('should fail to update role - oldRole entity is absent', async () => { - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ oldRole: {}, newRole: {} }); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid old role object. Cause: 'memberReferences' field must not be empty`, - }); - }); - - it('should fail to update role - newRole entity is absent', async () => { - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { memberReferences: ['user:default/permission_admin'] }, - newRole: {}, - }); - - expect(result.statusCode).toEqual(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid new role object. Cause: 'name' field must not be empty`, - }); - }); - - it('should fail to update role - old role not found', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._policy: string[]): Promise => { - return false; - }); - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/test'], - name: 'role:default/rbac_admin', - }, - }); - - expect(result.statusCode).toEqual(404); - expect(result.body.error).toEqual({ - name: 'NotFoundError', - message: - 'Member reference: user:default/permission_admin was not found for role role:default/rbac_admin', - }); - }); - - it('should fail to update role - newRole is already present', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/test'], - name: 'role:default/rbac_admin', - }, - }); - - expect(result.statusCode).toEqual(409); - expect(result.body.error).toEqual({ - name: 'ConflictError', - message: '', - }); - }); - - it('should nothing to update', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/permission_admin'], - name: 'role:default/rbac_admin', - }, - }); - - expect(result.statusCode).toEqual(204); - }); - - it('should nothing to update, because role and metadata are the same', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - metadata: { - source: 'rest', - }, - }, - newRole: { - memberReferences: ['user:default/permission_admin'], - name: 'role:default/rbac_admin', - metadata: { - source: 'rest', - }, - }, - }); - - expect(result.statusCode).toEqual(204); - }); - - it('should nothing to update, because role and metadata are the same with case insensitive member', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/Permission_Admin'], - metadata: { - source: 'rest', - }, - }, - newRole: { - memberReferences: ['user:default/permission_ADMIN'], - name: 'role:default/rbac_admin', - metadata: { - source: 'rest', - }, - }, - }); - - expect(result.statusCode).toEqual(204); - }); - - it('should nothing to update, because role and metadata are the same, but old role metadata was not send', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/permission_admin'], - name: 'role:default/rbac_admin', - metadata: { - source: 'rest', - }, - }, - }); - - expect(result.statusCode).toEqual(204); - }); - - it('should update description and set author', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/permission_admin'], - name: 'role:default/rbac_admin', - metadata: { - source: 'rest', - description: 'some admin role.', - }, - }, - }); - - expect(result.statusCode).toEqual(200); - expect(enforcerDelegateMock.updateGroupingPolicies).toHaveBeenCalledWith( - [['user:default/permission_admin', 'role:default/rbac_admin']], - [['user:default/permission_admin', 'role:default/rbac_admin']], - { - description: 'some admin role.', - modifiedBy: 'user:default/mock', - roleEntityRef: 'role:default/rbac_admin', - source: 'rest', - owner: '', - }, - ); - }); - - it('should update role and role description', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[0] === 'user:default/permission_admin') { - return true; - } - return false; - }); - - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/test', 'user:default/dev'], - name: 'role:default/rbac_admin', - metadata: { - source: 'rest', - description: 'some admin role.', - }, - }, - }); - - expect(result.statusCode).toEqual(200); - - expect(enforcerDelegateMock.updateGroupingPolicies).toHaveBeenCalledWith( - [['user:default/permission_admin', 'role:default/rbac_admin']], - [ - ['user:default/test', 'role:default/rbac_admin'], - ['user:default/dev', 'role:default/rbac_admin'], - ], - { - description: 'some admin role.', - modifiedBy: 'user:default/mock', - roleEntityRef: 'role:default/rbac_admin', - source: 'rest', - owner: '', - }, - ); - }); - - it('should fail to update policy - role metadata could not be found', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[0] === 'user:default/test') { - return false; - } - return true; - }); - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation(async (): Promise => { - return undefined; - }); - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/test'], - name: 'role:default/rbac_admin', - }, - }); - - expect(result.statusCode).toEqual(404); - expect(result.body.error).toEqual({ - name: 'NotFoundError', - message: `Unable to find metadata for role:default/rbac_admin`, - }); - }); - - it('should fail to update role - unable to remove oldRole', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[0] === 'user:default/test') { - return false; - } - return true; - }); - enforcerDelegateMock.updateGroupingPolicies = jest - .fn() - .mockImplementation(async (): Promise => { - throw new Error('Unexpected error'); - }); - - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/test'], - name: 'role:default/rbac_admin', - }, - }); - - expect(result.statusCode).toEqual(500); - expect(result.body.error).toEqual({ - name: 'Error', - message: 'Unexpected error', - }); - }); - - it('should fail to update role - unable to add newRole', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[0] === 'user:default/test') { - return false; - } - return true; - }); - enforcerDelegateMock.updateGroupingPolicies = jest - .fn() - .mockImplementation( - async (_param: string[][], _source: Source): Promise => { - throw new Error('Unexpected error'); - }, - ); - - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/test'], - name: 'role:default/rbac_admin', - }, - }); - - expect(result.statusCode).toEqual(500); - expect(result.body.error).toEqual({ - name: 'Error', - message: 'Unexpected error', - }); - }); - - it.each([ - ['user:default/permission_admin', 'user:default/test'], - ['user:default/Permission_Admin', 'user:default/Test'], - ])('should update role', async (oldUser, newUser) => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[0] === newUser.toLocaleLowerCase('en-US')) { - return false; - } - return true; - }); - enforcerDelegateMock.updateGroupingPolicies = jest - .fn() - .mockImplementation(); - - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: [oldUser], - }, - newRole: { - memberReferences: [newUser], - name: 'role:default/rbac_admin', - }, - }); - - expect(result.statusCode).toEqual(200); - expect(enforcerDelegateMock.hasGroupingPolicy).toHaveBeenNthCalledWith( - 1, - 'user:default/test', - 'role:default/rbac_admin', - ); - expect(enforcerDelegateMock.hasGroupingPolicy).toHaveBeenNthCalledWith( - 2, - 'user:default/permission_admin', - 'role:default/rbac_admin', - ); - }); - - it('should update role where newRole has multiple roles', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if ( - param[0] === 'user:default/test' || - param[0] === 'user:default/test2' - ) { - return false; - } - return true; - }); - enforcerDelegateMock.updateGroupingPolicies = jest - .fn() - .mockImplementation(); - - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/test', 'user:default/test2'], - name: 'role:default/rbac_admin', - }, - }); - - expect(result.statusCode).toEqual(200); - }); - - it('should update role where newRole has multiple roles with one being from oldRole', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[0] === 'user:default/test') { - return false; - } - return true; - }); - enforcerDelegateMock.updateGroupingPolicies = jest - .fn() - .mockImplementation(); - - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: [ - 'user:default/permission_admin', - 'user:default/test', - ], - name: 'role:default/rbac_admin', - }, - }); - - expect(result.statusCode).toEqual(200); - }); - - it('should update role name', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[0] === 'user:default/test') { - return false; - } - return true; - }); - enforcerDelegateMock.updateGroupingPolicies = jest - .fn() - .mockImplementation(); - - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/test'], - name: 'role:default/test', - }, - }); - - expect(result.statusCode).toEqual(200); - }); - - it('should fail to update role - duplicate roles in oldRole', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[0] === 'user:default/test') { - return false; - } - return true; - }); - - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: [ - 'user:default/permission_admin', - 'user:default/permission_admin', - ], - }, - newRole: { - memberReferences: ['user:default/test'], - name: 'role:default/rbac_admin', - }, - }); - - expect(result.statusCode).toBe(409); - expect(result.body.error).toEqual({ - name: 'ConflictError', - message: `Duplicate role members found; user:default/permission_admin, role:default/rbac_admin is a duplicate`, - }); - }); - - it('should fail to update role - duplicate roles in newRole', async () => { - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/test', 'user:default/test'], - name: 'role:default/rbac_admin', - }, - }); - - expect(result.statusCode).toBe(409); - expect(result.body.error).toEqual({ - name: 'ConflictError', - message: `Duplicate role members found; user:default/test, role:default/rbac_admin is a duplicate`, - }); - }); - - it('should fail to update role name when role name is invalid', async () => { - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/test'], - name: 'role:default/', - }, - }); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Invalid new role object. Cause: Entity reference "role:default/" was not on the form [:][/]`, - }); - }); - - it('should fail to update - oldRole name is invalid', async () => { - const result = await request(app) - .put('/roles/x/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/test'], - name: 'role:default/', - }, - }); - - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - name: 'InputError', - message: `Unsupported kind x. Supported value should be "role"`, - }); - }); - - it('should fail to update role, because source mismatch', async () => { - const roleMeta: RoleMetadataDao = { - roleEntityRef: 'role:default/rbac_admin', - source: 'configuration', - modifiedBy, - }; - - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async (roleEntityRef: string): Promise => { - if (roleEntityRef === roleMeta.roleEntityRef) { - return roleMeta; - } - return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; - }, - ); - - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/test'], - name: 'role:default/rbac_admin', - }, - }); - - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: `Unable to edit role: source does not match originating role ${ - roleMeta.roleEntityRef - }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, - }); - }); - }); - - describe('DELETE /roles/:kind/:namespace/:name', () => { - it('should return a status of Unauthorized', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app) - .delete('/roles/role/default/rbac_admin') - .send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityDeletePermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should fail to delete, because unexpected error', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - enforcerDelegateMock.removeGroupingPolicies = jest - .fn() - .mockImplementation( - async (_param: string[][], _source: Source): Promise => { - throw new Error('Unexpected error'); - }, - ); - enforcerDelegateMock.getFilteredGroupingPolicy = jest - .fn() - .mockImplementation( - async (_index: number, ..._filter: string[]): Promise => { - return [['group:default/test', 'role/default/rbac_admin', 'rest']]; - }, - ); - - const result = await request(app) - .delete( - '/roles/role/default/rbac_admin?memberReferences=group:default/test', - ) - .send(); - - expect(result.statusCode).toEqual(500); - expect(result.body.error).toEqual({ - name: 'Error', - message: 'Unexpected error', - }); - }); - - it('should fail to delete, because not found error', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return false; - }); - enforcerDelegateMock.getFilteredGroupingPolicy = jest - .fn() - .mockImplementation( - async (_index: number, ..._filter: string[]): Promise => { - return []; - }, - ); - - const result = await request(app) - .delete( - '/roles/role/default/rbac_admin?memberReferences=group:default/test', - ) - .send(); - - expect(result.statusCode).toEqual(404); - expect(result.body.error).toEqual({ - name: 'NotFoundError', - message: `role member 'group:default/test' was not found`, - }); - }); - - it.each(['group:default/test', 'group:default/Test'])( - 'should delete a user / group %s from a role', - async member => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - if (_param[0] === 'group:default/test') { - return true; - } - return false; - }); - enforcerDelegateMock.removeGroupingPolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - enforcerDelegateMock.getFilteredGroupingPolicy = jest - .fn() - .mockImplementation( - async ( - _index: number, - ..._filter: string[] - ): Promise => { - return [ - ['group:default/test', 'role/default/rbac_admin', 'rest'], - ]; - }, - ); - - const result = await request(app) - .delete(`/roles/role/default/rbac_admin?memberReferences=${member}`) - .send(); - - expect(result.statusCode).toEqual(204); - expect( - enforcerDelegateMock.getFilteredGroupingPolicy, - ).toHaveBeenCalledWith( - 0, - 'group:default/test', - 'role:default/rbac_admin', - ); - }, - ); - - it('should delete a role', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - enforcerDelegateMock.removeGroupingPolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - - const result = await request(app) - .delete('/roles/role/default/rbac_admin') - .send(); - - expect(result.statusCode).toEqual(204); - }); - - it('should fail to delete role, because source mismatch', async () => { - const roleMeta: RoleMetadataDao = { - roleEntityRef: 'role:default/rbac_admin', - source: 'configuration', - modifiedBy, - }; - - roleMetadataStorageMock.findRoleMetadata = jest - .fn() - .mockImplementation( - async (roleEntityRef: string): Promise => { - if (roleEntityRef === roleMeta.roleEntityRef) { - return roleMeta; - } - return { source: 'rest', roleEntityRef: roleEntityRef, modifiedBy }; - }, - ); - enforcerDelegateMock.getFilteredGroupingPolicy = jest - .fn() - .mockImplementation( - async (_index: number, ..._filter: string[]): Promise => { - return [['group:default/test', 'role/default/rbac_admin', 'rest']]; - }, - ); - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - - const result = await request(app) - .delete('/roles/role/default/rbac_admin') - .send(); - - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: `Unable to delete role: source does not match originating role ${ - roleMeta.roleEntityRef - }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, - }); - }); - }); - - describe('GetFirstQuery', () => { - it('should return an empty string for undefined query value', () => { - const result = server.getFirstQuery(undefined); - expect(result).toBe(''); - }); - - it('should return the first string value from a string array', async () => { - const queryValue = ['value1', 'value2']; - const result = server.getFirstQuery(queryValue); - expect(result).toBe('value1'); - }); - - it('should throw an InputError for an array of ParsedQs', () => { - const queryValue = [{ key: 'value' }, { key: 'value2' }]; - expect(() => server.getFirstQuery(queryValue)).toThrow(InputError); - }); - - it('should return the string value when query value is a string', () => { - const queryValue = 'singleValue'; - const result = server.getFirstQuery(queryValue); - expect(result).toBe('singleValue'); - }); - - it('should throw an InputError for ParsedQs', () => { - const queryValue = { key: 'value' }; - expect(() => server.getFirstQuery(queryValue)).toThrow(InputError); - }); - }); - - describe('transformRoleArray', () => { - it('should combine two roles together that are similar', async () => { - const roles = [ - ['group:default/test', 'role:default/test'], - ['user:default/test', 'role:default/test'], - ]; - - const expectedResult: Role[] = [ - { - memberReferences: ['group:default/test', 'user:default/test'], - name: 'role:default/test', - metadata: { - author: undefined, - createdAt: undefined, - description: undefined, - isDefault: false, - lastModified: undefined, - modifiedBy: 'user:default/some-user', - owner: undefined, - source: 'rest', - }, - }, - ]; - - const transformedRoles = await server.transformRoleArray( - undefined, - ...roles, - ); - expect(transformedRoles).toStrictEqual(expectedResult); - }); - }); - - describe('transformMemberReferencesToLowercase', () => { - it('should lowercase memberReferences', () => { - const role = { - memberReferences: [ - 'user:default/Permission_Admin', - 'group:default/TEST', - ], - name: 'role:default/Rbac_Admin', - }; - server.transformMemberReferencesToLowercase(role); - expect(role).toEqual({ - memberReferences: [ - 'user:default/permission_admin', - 'group:default/test', - ], - name: 'role:default/Rbac_Admin', - }); - }); - }); - - // Define a test suite for the GET /conditions endpoint - describe('GET /roles/conditions', () => { - it('should return a status of Unauthorized', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - - // Perform the GET request to the endpoint - const result = await request(app).get('/roles/conditions').send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityReadPermission, - }, - ], - { - credentials: credentials, - }, - ); - - // Assert the response status code and error message - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should be returned list all condition decisions', async () => { - const result = await request(app).get('/roles/conditions').send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual(expectedConditions); - expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); - }); - - it('should be returned condition decision by pluginId', async () => { - const result = await request(app) - .get('/roles/conditions?pluginId=catalog') - .send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual(expectedConditions); - }); - - it('should be returned empty condition decision list by pluginId', async () => { - const result = await request(app) - .get('/roles/conditions?pluginId=scaffolder') - .send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual([]); - }); - - it('should be returned condition decision by resourceType', async () => { - const result = await request(app) - .get('/roles/conditions?resourceType=catalog-entity') - .send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual(expectedConditions); - }); - }); - - describe('DELETE /roles/conditions/:id', () => { - it('should return a status of Unauthorized', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - - const result = await request(app).delete('/roles/conditions/1').send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityDeletePermission, - }, - ], - { - credentials: credentials, - }, - ); - - // Assert the response status code and error message - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should delete condition decision by id', async () => { - const result = await request(app).delete('/roles/conditions/1').send(); - - expect(result.statusCode).toEqual(204); - expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); - expect(conditionalStorageMock.deleteCondition).toHaveBeenCalled(); - }); - - it('should fail to delete condition decision by id', async () => { - conditionalStorageMock.deleteCondition = jest.fn(() => { - throw new Error('Failed to delete condition decision by id'); - }); - - const result = await request(app).delete('/roles/conditions/1').send(); - - expect(result.statusCode).toEqual(500); - expect(result.body.error.message).toEqual( - 'Failed to delete condition decision by id', - ); - }); - - it('should fail to delete condition decision by id 404', async () => { - const result = await request(app).delete('/roles/conditions/2').send(); - - expect(result.statusCode).toEqual(404); - expect(result.body.error.message).toEqual( - 'Condition with id 2 was not found', - ); - }); - - it('should return return 400', async () => { - const result = await request(app) - .delete('/roles/conditions/non-number') - .send(); - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - message: 'Id is not a valid number.', - name: 'InputError', - }); - }); - }); - - describe('GET /roles/condition/:id', () => { - it('should return a status of Unauthorized', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - - const result = await request(app).get('/roles/conditions/1').send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityReadPermission, - }, - ], - { - credentials: credentials, - }, - ); - - // Assert the response status code and error message - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should return condition decision by id', async () => { - const result = await request(app).get('/roles/conditions/1').send(); - expect(result.statusCode).toBe(200); - expect(result.body).toEqual(expectedConditions[0]); - expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); - }); - - it('should return return 404', async () => { - const result = await request(app).get('/roles/conditions/2').send(); - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual({ - message: '', - name: 'NotFoundError', - }); - }); - - it('should return return 400', async () => { - const result = await request(app) - .get('/roles/conditions/non-number') - .send(); - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - message: 'Id is not a valid number.', - name: 'InputError', - }); - }); - }); - - describe('POST /roles/conditions', () => { - it('should return a status of Unauthorized', async () => { - mockedAuthorize.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - - const result = await request(app).post('/roles/conditions').send(); - - expect(mockedAuthorize).toHaveBeenCalledWith( - [ - { - permission: policyEntityCreatePermission, - }, - ], - { - credentials: credentials, - }, - ); - - // Assert the response status code and error message - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should be created condition', async () => { - conditionalStorageMock.createCondition = jest - .fn() - .mockImplementation(() => { - return 1; - }); - pluginMetadataCollectorMock.getMetadataByPluginId = jest - .fn() - .mockImplementation(() => { - const response: MetadataResponse = { - permissions: [ - { - name: 'catalog.entity.read', - attributes: { - action: 'read', - }, - type: 'resource', - resourceType: 'catalog-entity', - }, - ], - rules: [], - }; - return response; - }); - - const roleCondition: RoleConditionalPolicyDecision = { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: ['read'], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }; - const result = await request(app) - .post('/roles/conditions') - .send(roleCondition); - - expect(result.statusCode).toBe(201); - expect(validateRoleConditionMock).toHaveBeenCalledWith(roleCondition); - expect(result.body).toEqual({ id: 1 }); - expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); - }); - - it('should create condition with the correct permission name for different resource types but similar actions', async () => { - conditionalStorageMock.createCondition = jest - .fn() - .mockImplementation(() => { - return 1; - }); - pluginMetadataCollectorMock.getMetadataByPluginId = jest - .fn() - .mockImplementation(() => { - const response: MetadataResponse = { - permissions: [ - { - name: 'catalog.location.read', - attributes: { - action: 'read', - }, - type: 'resource', - resourceType: 'catalog-location', - }, - { - name: 'catalog.entity.read', - attributes: { - action: 'read', - }, - type: 'resource', - resourceType: 'catalog-entity', - }, - ], - rules: [], - }; - return response; - }); - - const roleCondition: RoleConditionalPolicyDecision = { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: ['read'], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }; - - const roleConditionToBeSaved: Partial< - RoleConditionalPolicyDecision - > & - Required< - Pick< - RoleConditionalPolicyDecision, - 'permissionMapping' - > - > = { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: [{ action: 'read', name: 'catalog.entity.read' }], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }; - - const result = await request(app) - .post('/roles/conditions') - .send(roleCondition); - - expect(result.statusCode).toBe(201); - expect(validateRoleConditionMock).toHaveBeenCalledWith(roleCondition); - expect(result.body).toEqual({ id: 1 }); - expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); - expect(conditionalStorageMock.createCondition).toHaveBeenCalledWith( - roleConditionToBeSaved, - ); - }); - - it('should create condition and set the action to use whenever there is no action', async () => { - conditionalStorageMock.createCondition = jest - .fn() - .mockImplementation(() => { - return 1; - }); - pluginMetadataCollectorMock.getMetadataByPluginId = jest - .fn() - .mockImplementation(() => { - const response: MetadataResponse = { - permissions: [ - { - name: 'catalog.location.use', - attributes: {}, - type: 'resource', - resourceType: 'catalog-location', - }, - { - name: 'catalog.entity.read', - attributes: { - action: 'read', - }, - type: 'resource', - resourceType: 'catalog-entity', - }, - ], - rules: [], - }; - return response; - }); - - const roleCondition: RoleConditionalPolicyDecision = { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-location', - permissionMapping: ['use'], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-location', - params: { claims: ['group:default/team-a'] }, - }, - }; - - const roleConditionToBeSaved: Partial< - RoleConditionalPolicyDecision - > & - Required< - Pick< - RoleConditionalPolicyDecision, - 'permissionMapping' - > - > = { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-location', - permissionMapping: [{ action: 'use', name: 'catalog.location.use' }], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-location', - params: { claims: ['group:default/team-a'] }, - }, - }; - - const result = await request(app) - .post('/roles/conditions') - .send(roleCondition); - - expect(result.statusCode).toBe(201); - expect(validateRoleConditionMock).toHaveBeenCalledWith(roleCondition); - expect(result.body).toEqual({ id: 1 }); - expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); - expect(conditionalStorageMock.createCondition).toHaveBeenCalledWith( - roleConditionToBeSaved, - ); - }); - }); - - describe('PUT /roles/conditions', () => { - it('should return a status of Unauthorized', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - - const result = await request(app).put('/roles/conditions/1').send(); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityUpdatePermission, - }, - ], - { - credentials: credentials, - }, - ); - - // Assert the response status code and error message - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should return return 400', async () => { - const result = await request(app) - .put('/roles/conditions/non-number') - .send(); - expect(result.statusCode).toBe(400); - expect(result.body.error).toEqual({ - message: 'Id is not a valid number.', - name: 'InputError', - }); - }); - - it('should update condition decision', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.ALLOW }, - ]); - const conditionDecision: RoleConditionalPolicyDecision = - { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: ['read'], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }; - const result = await request(app) - .put('/roles/conditions/1') - .send(conditionDecision); - - expect(mockedAuthorizeConditional).toHaveBeenCalledWith( - [ - { - permission: policyEntityUpdatePermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(validateRoleConditionMock).toHaveBeenCalledWith(conditionDecision); - - expect(result.statusCode).toBe(200); - expect(conditionalStorageMock.updateCondition).toHaveBeenCalledWith(1, { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: [ - { - action: 'read', - name: 'catalog.entity.read', - }, - ], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }); - expect(mockHttpAuth.credentials).toHaveBeenCalledTimes(1); - }); - - it('should fail to update condition decision because old condition does not exist', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.ALLOW }, - ]); - const conditionDecision: RoleConditionalPolicyDecision = - { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: ['read'], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }; - const result = await request(app) - .put('/roles/conditions/2') - .send(conditionDecision); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual({ - message: 'Condition with id 2 was not found', - name: 'NotFoundError', - }); - }); - }); - - describe('POST /refresh/:id', () => { - let appWithProvider: express.Express; - - beforeEach(async () => { - mockedAuthorizeConditional.mockImplementation(async () => [ - { result: AuthorizeResult.ALLOW }, - ]); - - const options: RBACRouterOptions = { - config: config, - logger: mockLoggerService, - httpAuth: mockHttpAuth, - auth: mockAuthService, - permissionsRegistry: mockPermissionRegistry, - auditor: mockAuditorService, - permissions: mockPermissionEvaluator, - }; - - server = new PoliciesServer( - options, - enforcerDelegateMock as EnforcerDelegate, - conditionalStorageMock, - pluginMetadataCollectorMock as PluginPermissionMetadataCollector, - roleMetadataStorageMock, - permissionDependentPluginStoreMock, - extendablePluginIdProviderMock as ExtendablePluginIdProvider, - [providerMock], - ); - const router = await server.serve(); - appWithProvider = express().use(router); - appWithProvider.use( - MiddlewareFactory.create({ logger: mockLoggerService, config }).error(), - ); - }); - - it('should return a status of Unauthorized', async () => { - mockedAuthorize.mockImplementationOnce(async () => [ - { result: AuthorizeResult.DENY }, - ]); - const result = await request(app).post('/refresh/test').send(); - - expect(mockedAuthorize).toHaveBeenCalledWith( - [ - { - permission: policyEntityCreatePermission, - }, - ], - { - credentials: credentials, - }, - ); - expect(result.statusCode).toBe(403); - expect(result.body.error).toEqual({ - name: 'NotAllowedError', - message: '', - }); - }); - - it('should return a 200 for successful refresh set', async () => { - const result = await request(appWithProvider) - .post('/refresh/testProvider') - .send(); - expect(result.statusCode).toBe(200); - }); - - it('should return a 404 when there are no rbac providers', async () => { - const result = await request(app).post('/refresh/test').send(); - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual({ - message: 'No RBAC providers were found', - name: 'NotFoundError', - }); - }); - - it('should return a 404 when the rbac provider does not exist', async () => { - const result = await request(appWithProvider) - .post('/refresh/test') - .send(); - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual({ - message: 'The RBAC provider test was not found', - name: 'NotFoundError', - }); - }); - }); - - describe('test rest API when permission framework disabled', () => { - beforeAll(() => { - config = mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - permission: { - enabled: false, - }, - }, - }); - }); - - it('should not delete policy, because permission framework was disabled', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - enforcerDelegateMock.removePolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - - const result = await request(app) - .delete( - '/policies/user/default/permission_admin?permission=policy-entity&policy=read&effect=allow', - ) - .send([ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ]); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not create policies, because permission framework was disabled', async () => { - const result = await request(app).post('/policies').send(); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not return policies, because permission framework was disabled', async () => { - const result = await request(app) - .get('/policies/user/default/permission_admin') - .send(); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not update policy, because permission framework was disabled', async () => { - enforcerDelegateMock.hasPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[2] === 'create') { - return false; - } - return true; - }); - enforcerDelegateMock.updatePolicies = jest.fn().mockImplementation(); - - const result = await request(app) - .put('/policies/user/default/permission_admin') - .send({ - oldPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - newPolicy: [ - { - permission: 'policy-entity', - policy: 'read', - effect: 'allow', - }, - ], - }); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not return list all policies, because permission framework was disabled', async () => { - enforcerDelegateMock.getFilteredPolicy = jest - .fn() - .mockImplementation(async () => { - return [ - [ - 'role:default/permission_admin', - 'policy-entity', - 'create', - 'allow', - ], - ['role:default/guest', 'policy-entity', 'read', 'allow', 'rest'], - ]; - }); - const result = await request(app).get('/policies').send(); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not return list all roles, because permission framework was disabled', async () => { - enforcerDelegateMock.getGroupingPolicy = jest - .fn() - .mockImplementation(async () => { - return [ - ['group:default/test', 'role:default/test'], - ['group:default/team_a', 'role:default/team_a'], - ]; - }); - - const result = await request(app).get('/roles').send(); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not return role by role reference, because permission framework was disabled', async () => { - const result = await request(app) - .get('/roles/role/default/rbac_admin') - .send(); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not create role, because permission framework was disabled', async () => { - const result = await request(app) - .post('/roles') - .send({ - memberReferences: ['user:default/permission_admin'], - name: 'role:default/rbac_admin', - }); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not update role, because permission framework was disabled', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (...param: string[]): Promise => { - if (param[0] === 'user:default/permission_admin') { - return true; - } - return false; - }); - - const result = await request(app) - .put('/roles/role/default/rbac_admin') - .send({ - oldRole: { - memberReferences: ['user:default/permission_admin'], - }, - newRole: { - memberReferences: ['user:default/test', 'user:default/dev'], - name: 'role:default/rbac_admin', - metadata: { - source: 'rest', - description: 'some admin role.', - }, - }, - }); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not delete a role, because permission framework was disabled', async () => { - enforcerDelegateMock.hasGroupingPolicy = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - enforcerDelegateMock.removeGroupingPolicies = jest - .fn() - .mockImplementation(async (..._param: string[]): Promise => { - return true; - }); - - const result = await request(app) - .delete('/roles/role/default/rbac_admin') - .send(); - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not return list of all condition decisions, because permission framework was disabled', async () => { - const result = await request(app).get('/roles/conditions').send(); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not delete condition decision, because permission framework was disabled', async () => { - const result = await request(app).delete('/roles/conditions/1').send(); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not return condition decision by id, because permission framework was disabled', async () => { - const result = await request(app).get('/roles/conditions/1').send(); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not create condition, because permission framework was disabled', async () => { - conditionalStorageMock.createCondition = jest - .fn() - .mockImplementation(() => { - return 1; - }); - pluginMetadataCollectorMock.getMetadataByPluginId = jest - .fn() - .mockImplementation(() => { - const response: MetadataResponse = { - permissions: [ - { - name: 'catalog.entity.read', - attributes: { - action: 'read', - }, - type: 'resource', - resourceType: 'catalog-entity', - }, - { - name: 'catalog.location.read', - attributes: { - action: 'read', - }, - type: 'resource', - resourceType: 'catalog-location', - }, - ], - rules: [], - }; - return response; - }); - - const roleCondition: RoleConditionalPolicyDecision = { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: ['read'], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }; - - const result = await request(app) - .post('/roles/conditions') - .send(roleCondition); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not update condition decision, because permission framework was disabled', async () => { - mockedAuthorizeConditional.mockImplementationOnce(async () => [ - { result: AuthorizeResult.ALLOW }, - ]); - const conditionDecision: RoleConditionalPolicyDecision = - { - id: 1, - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - resourceType: 'catalog-entity', - permissionMapping: ['read'], - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { claims: ['group:default/team-a'] }, - }, - }; - - const result = await request(app) - .put('/roles/conditions/1') - .send(conditionDecision); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - - it('should not return list plugins condition rules, because permission framework was disabled', async () => { - const rules: PluginMetadataResponseSerializedRule[] = [ - { - pluginId: 'catalog', - rules: [ - { - description: 'Allow entities with the specified label', - name: 'HAS_LABEL', - paramsSchema: { - $schema: 'http://json-schema.org/draft-07/schema#', - additionalProperties: false, - properties: { - label: { - description: 'Name of the label to match on', - type: 'string', - }, - }, - required: ['label'], - type: 'object', - }, - resourceType: 'catalog-entity', - }, - ], - }, - ]; - pluginMetadataCollectorMock.getPluginConditionRules = jest - .fn() - .mockImplementation(async () => { - return rules; - }); - - const result = await request(app).get('/plugins/condition-rules').send(); - - expect(result.statusCode).toBe(404); - expect(result.body.error).toEqual(undefined); - }); - }); -}); diff --git a/plugins/rbac-backend/src/service/policies-rest-api.ts b/plugins/rbac-backend/src/service/policies-rest-api.ts deleted file mode 100644 index 600a05a513..0000000000 --- a/plugins/rbac-backend/src/service/policies-rest-api.ts +++ /dev/null @@ -1,1324 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - AuthService, - BackstageCredentials, - BackstageServicePrincipal, - BackstageUserPrincipal, - HttpAuthService, - PermissionsService, -} from '@backstage/backend-plugin-api'; -import { - ConflictError, - InputError, - NotAllowedError, - NotFoundError, -} from '@backstage/errors'; -import { - AuthorizeResult, - BasicPermission, - PolicyDecision, - ResourcePermission, -} from '@backstage/plugin-permission-common'; - -import express, { Request } from 'express'; -import { isEmpty, isEqual } from 'lodash'; -import type { ParsedQs } from 'qs'; - -import { - policyEntityCreatePermission, - policyEntityDeletePermission, - policyEntityReadPermission, - policyEntityUpdatePermission, - type PermissionAction, - type Role, - type RoleBasedPolicy, - type RoleConditionalPolicyDecision, -} from '@backstage-community/plugin-rbac-common'; -import type { RBACProvider } from '@backstage-community/plugin-rbac-node'; - -import { setAuditorError, logAuditorEvent } from '../auditor/rest-interceptor'; -import { ConditionalStorage } from '../database/conditional-storage'; -import { - daoToMetadata, - RoleMetadataDao, - RoleMetadataStorage, -} from '../database/role-metadata'; -import { - buildRoleSourceMap, - deepSortedEqual, - isPermissionAction, - policyToString, - processConditionMapping, - matches, -} from '../helper'; -import { validateRoleCondition } from '../validation/condition-validation'; -import { - validateEntityReference, - validatePolicy, - validateRole, - validateSource, -} from '../validation/policies-validation'; -import { EnforcerDelegate } from './enforcer-delegate'; -import { PluginPermissionMetadataCollector } from './plugin-endpoints'; -import { RBACRouterOptions } from './policy-builder'; -import { conditionTransformerFunc, RBACFilters } from '../permissions'; -import { registerPermissionDefinitionRoutes } from './permission-definition-routes'; -import { PermissionDependentPluginStore } from '../database/extra-permission-enabled-plugins-storage'; -import { ExtendablePluginIdProvider } from './extendable-id-provider'; -import { createRouter } from './router'; - -export async function authorizeConditional( - request: Request, - permission: ResourcePermission<'policy-entity'> | BasicPermission, - deps: { - auth: AuthService; - httpAuth: HttpAuthService; - permissions: PermissionsService; - }, -): Promise<{ - decision: PolicyDecision; - credentials: BackstageCredentials< - BackstageUserPrincipal | BackstageServicePrincipal - >; -}> { - const { auth, httpAuth, permissions } = deps; - - const credentials = await httpAuth.credentials(request, { - allow: ['user', 'service'], - }); - - // allow service to service communication, but only with read permission - if ( - auth.isPrincipal(credentials, 'service') && - permission !== policyEntityReadPermission - ) { - throw new NotAllowedError( - `Only credential principal with type 'user' permitted to modify permissions`, - ); - } - - let decision: PolicyDecision; - if (permission.type === 'resource') { - decision = ( - await permissions.authorizeConditional([{ permission }], { - credentials, - }) - )[0]; - } else { - decision = ( - await permissions.authorize([{ permission }], { - credentials, - }) - )[0]; - } - - if (decision.result === AuthorizeResult.DENY) { - throw new NotAllowedError(); // 403 - } - - return { decision, credentials }; -} - -export class PoliciesServer { - constructor( - private readonly options: RBACRouterOptions, - private readonly enforcer: EnforcerDelegate, - private readonly conditionalStorage: ConditionalStorage, - private readonly pluginPermMetaData: PluginPermissionMetadataCollector, - private readonly roleMetadata: RoleMetadataStorage, - private readonly extraPluginsIdStorage: PermissionDependentPluginStore, - private readonly pluginIdProvider: ExtendablePluginIdProvider, - private readonly rbacProviders?: RBACProvider[], - ) {} - - async serve(): Promise { - const router = await createRouter(this.options); - - const { logger, auditor, auth, permissionsRegistry } = this.options; - - const defRoleMeta = this.roleMetadata.getCachedDefaultRoleMetadata(); - let defRole: Role | undefined; - if (defRoleMeta) { - defRole = { - name: defRoleMeta.roleEntityRef, - memberReferences: [], - metadata: daoToMetadata(defRoleMeta), - }; - } - - const isPluginEnabled = - this.options.config.getOptionalBoolean('permission.enabled'); - if (!isPluginEnabled) { - return router; - } - - const transformConditions = conditionTransformerFunc(permissionsRegistry); - - router.get('/', async (request, response) => { - await authorizeConditional( - request, - policyEntityReadPermission, - this.options, - ); - - response.send({ status: 'Authorized' }); - }); - - // Policy CRUD - - router.get( - '/policies', - logAuditorEvent(auditor), - async (request, response) => { - let conditionsFilter: RBACFilters | undefined; - const { decision } = await authorizeConditional( - request, - policyEntityReadPermission, - this.options, - ); - - if (decision.result === AuthorizeResult.CONDITIONAL) { - conditionsFilter = transformConditions(decision.conditions); - } - - const roleMetadata = - await this.roleMetadata.filterForOwnerRoleMetadata(conditionsFilter); - - let policies: string[][] = []; - if (this.isPolicyFilterEnabled(request)) { - const entityRef = this.getFirstQuery(request.query.entityRef); - const permission = this.getFirstQuery(request.query.permission); - const policy = this.getFirstQuery(request.query.policy); - const effect = this.getFirstQuery(request.query.effect); - - const matchedRoleName = roleMetadata.flatMap( - role => role.roleEntityRef, - ); - - const filter: string[] = [entityRef, permission, policy, effect]; - policies = matchedRoleName.includes(entityRef) - ? await this.enforcer.getFilteredPolicy(0, ...filter) - : []; - } else { - for (const role of roleMetadata) { - policies.push( - ...(await this.enforcer.getFilteredPolicy( - 0, - ...[role.roleEntityRef], - )), - ); - } - } - - const body = await this.transformPolicyArray(...policies); - // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` - body.map(policy => { - if ( - policy.permission === 'policy-entity' && - policy.policy === 'create' - ) { - policy.permission = 'policy.entity.create'; - logger.warn( - `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${[policy.entityReference, 'policy-entity', policy.policy, policy.effect]} to use 'policy.entity.create' instead of 'policy-entity' from source ${policy.metadata?.source}`, - ); - } - }); - - response.json(body); - }, - ); - - router.get( - '/policies/:kind/:namespace/:name', - logAuditorEvent(auditor), - async (request, response) => { - let conditionsFilter: RBACFilters | undefined; - const { decision } = await authorizeConditional( - request, - policyEntityReadPermission, - this.options, - ); - - if (decision.result === AuthorizeResult.CONDITIONAL) { - conditionsFilter = transformConditions(decision.conditions); - } - - const roleMetadata = - await this.roleMetadata.filterForOwnerRoleMetadata(conditionsFilter); - - const matchedRoleName = roleMetadata.flatMap(role => { - return role.roleEntityRef; - }); - - const entityRef = this.getEntityReference(request); - - const policy = matchedRoleName.includes(entityRef) - ? await this.enforcer.getFilteredPolicy(0, entityRef) - : []; - if (policy.length !== 0) { - const body = await this.transformPolicyArray(...policy); - // TODO: Temporary workaround to prevent breakages after the removal of the resource type `policy-entity` from the permission `policy.entity.create` - body.map(bodyPolicy => { - if ( - bodyPolicy.permission === 'policy-entity' && - bodyPolicy.policy === 'create' - ) { - bodyPolicy.permission = 'policy.entity.create'; - logger.warn( - `Permission policy with resource type 'policy-entity' and action 'create' has been removed. Please consider updating policy ${[bodyPolicy.entityReference, 'policy-entity', bodyPolicy.policy, bodyPolicy.effect]} to use 'policy.entity.create' instead of 'policy-entity' from source ${bodyPolicy.metadata?.source}`, - ); - } - }); - - response.json(body); - } else { - throw new NotFoundError(); // 404 - } - }, - ); - - router.delete( - '/policies/:kind/:namespace/:name', - logAuditorEvent(auditor), - async (request, response) => { - let conditionsFilter: RBACFilters | undefined; - const { decision } = await authorizeConditional( - request, - policyEntityDeletePermission, - this.options, - ); - - if (decision.result === AuthorizeResult.CONDITIONAL) { - conditionsFilter = transformConditions(decision.conditions); - } - - const entityRef = this.getEntityReference(request); - - const policyRaw: RoleBasedPolicy[] = request.body; - if (isEmpty(policyRaw)) { - throw new InputError(`permission policy must be present`); // 400 - } - - policyRaw.forEach(element => { - element.entityReference = entityRef; - }); - - const processedPolicies = await this.processPolicies( - policyRaw, - true, - undefined, - conditionsFilter, - ); - - await this.enforcer.removePolicies(processedPolicies); - - response.locals.meta = { policies: processedPolicies }; // auditor - - response.status(204).end(); - }, - ); - - router.post( - '/policies', - logAuditorEvent(auditor), - async (request, response) => { - await authorizeConditional( - request, - policyEntityCreatePermission, - this.options, - ); - - const policyRaw: RoleBasedPolicy[] = request.body; - - if (isEmpty(policyRaw)) { - throw new InputError(`permission policy must be present`); // 400 - } - - const processedPolicies = await this.processPolicies( - policyRaw, - false, - undefined, - ); - - const entityRef = processedPolicies[0][0]; - const roleMetadata = - await this.roleMetadata.findRoleMetadata(entityRef); - if (entityRef.startsWith('role:default') && !roleMetadata) { - throw new Error(`Corresponding role ${entityRef} was not found`); - } - - await this.enforcer.addPolicies(processedPolicies); - - response.locals.meta = { policies: processedPolicies }; // auditor - - response.status(201).end(); - }, - ); - - router.put( - '/policies/:kind/:namespace/:name', - logAuditorEvent(auditor), - async (request, response) => { - let conditionsFilter: RBACFilters | undefined; - const { decision } = await authorizeConditional( - request, - policyEntityUpdatePermission, - this.options, - ); - - if (decision.result === AuthorizeResult.CONDITIONAL) { - conditionsFilter = transformConditions(decision.conditions); - } - - const entityRef = this.getEntityReference(request); - - const oldPolicyRaw: RoleBasedPolicy[] = request.body.oldPolicy; - if (isEmpty(oldPolicyRaw)) { - throw new InputError(`'oldPolicy' object must be present`); // 400 - } - const newPolicyRaw: RoleBasedPolicy[] = request.body.newPolicy; - if (isEmpty(newPolicyRaw)) { - throw new InputError(`'newPolicy' object must be present`); // 400 - } - - [...oldPolicyRaw, ...newPolicyRaw].forEach(element => { - element.entityReference = entityRef; - }); - - const processedOldPolicy = await this.processPolicies( - oldPolicyRaw, - true, - 'old policy', - conditionsFilter, - ); - - oldPolicyRaw.sort((a, b) => - a.permission === b.permission - ? this.nameSort(a.policy!, b.policy!) - : this.nameSort(a.permission!, b.permission!), - ); - - newPolicyRaw.sort((a, b) => - a.permission === b.permission - ? this.nameSort(a.policy!, b.policy!) - : this.nameSort(a.permission!, b.permission!), - ); - - if ( - isEqual(oldPolicyRaw, newPolicyRaw) && - !oldPolicyRaw.some(isEmpty) - ) { - response.status(204).end(); - } else if (oldPolicyRaw.length > newPolicyRaw.length) { - throw new InputError( - `'oldPolicy' object has more permission policies compared to 'newPolicy' object`, - ); - } - - const processedNewPolicy = await this.processPolicies( - newPolicyRaw, - false, - 'new policy', - conditionsFilter, - ); - - const roleMetadata = - await this.roleMetadata.findRoleMetadata(entityRef); - if (entityRef.startsWith('role:default') && !roleMetadata) { - throw new Error(`Corresponding role ${entityRef} was not found`); - } - - await this.enforcer.updatePolicies( - processedOldPolicy, - processedNewPolicy, - ); - - response.locals.meta = { policies: processedNewPolicy }; // auditor - - response.status(200).end(); - }, - ); - - // Role CRUD - - router.get( - '/roles', - logAuditorEvent(auditor), - async (request, response) => { - let conditionsFilter: RBACFilters | undefined; - const { decision } = await authorizeConditional( - request, - policyEntityReadPermission, - this.options, - ); - - if (decision.result === AuthorizeResult.CONDITIONAL) { - conditionsFilter = transformConditions(decision.conditions); - } - - const roles = await this.enforcer.getGroupingPolicy(); - const body = await this.transformRoleArray(conditionsFilter, ...roles); - - if (defRole) { - body.push(defRole); - } - - response.json(body); - }, - ); - - router.get( - '/roles/:kind/:namespace/:name', - logAuditorEvent(auditor), - async (request, response) => { - let conditionsFilter: RBACFilters | undefined; - const { decision } = await authorizeConditional( - request, - policyEntityReadPermission, - this.options, - ); - - if (decision.result === AuthorizeResult.CONDITIONAL) { - conditionsFilter = transformConditions(decision.conditions); - } - - const roleEntityRef = this.getEntityReference(request, true); - - let body: Role[]; - if (defRole && roleEntityRef === defRole.name) { - body = [defRole]; - } else { - const role = await this.enforcer.getFilteredGroupingPolicy( - 1, - roleEntityRef, - ); - body = await this.transformRoleArray(conditionsFilter, ...role); - } - if (body.length !== 0) { - response.json(body); - } else { - throw new NotFoundError(); // 404 - } - }, - ); - - router.post( - '/roles', - logAuditorEvent(auditor), - async (request, response) => { - const uniqueItems = new Set(); - const { credentials } = await authorizeConditional( - request, - policyEntityCreatePermission, - this.options, - ); - - const roleRaw: Role = request.body; - let err = validateRole(roleRaw); - if (err) { - throw new InputError( // 400 - `Invalid role definition. Cause: ${err.message}`, - ); - } - this.transformMemberReferencesToLowercase(roleRaw); - - const rMetadata = await this.roleMetadata.findRoleMetadata( - roleRaw.name, - ); - - err = await validateSource('rest', rMetadata); - if (err) { - throw new NotAllowedError(`Unable to add role: ${err.message}`); - } - - const roles = this.transformRoleToArray(roleRaw); - - for (const role of roles) { - if (await this.enforcer.hasGroupingPolicy(...role)) { - throw new ConflictError(); // 409 - } - const roleString = JSON.stringify(role); - - if (uniqueItems.has(roleString)) { - throw new ConflictError( - `Duplicate role members found; ${role.at(0)}, ${role.at( - 1, - )} is a duplicate`, - ); - } else { - uniqueItems.add(roleString); - } - } - - const modifiedBy = ( - credentials as BackstageCredentials - ).principal.userEntityRef; - const metadata: RoleMetadataDao = { - roleEntityRef: roleRaw.name, - source: 'rest', - description: roleRaw.metadata?.description ?? '', - author: modifiedBy, - modifiedBy, - owner: roleRaw.metadata?.owner ?? modifiedBy, - }; - - await this.enforcer.addGroupingPolicies(roles, metadata); - - response.locals.meta = { ...metadata, members: roles.map(gp => gp[0]) }; // auditor - - response.status(201).end(); - }, - ); - - router.put( - '/roles/:kind/:namespace/:name', - logAuditorEvent(auditor), - async (request, response) => { - const uniqueItems = new Set(); - let conditionsFilter: RBACFilters | undefined; - const { decision, credentials } = await authorizeConditional( - request, - policyEntityUpdatePermission, - this.options, - ); - - if (decision.result === AuthorizeResult.CONDITIONAL) { - conditionsFilter = transformConditions(decision.conditions); - } - - const roleEntityRef = this.getEntityReference(request, true); - - const oldRoleRaw: Role = request.body.oldRole; - - if (!oldRoleRaw) { - throw new InputError(`'oldRole' object must be present`); // 400 - } - const newRoleRaw: Role = request.body.newRole; - if (!newRoleRaw) { - throw new InputError(`'newRole' object must be present`); // 400 - } - - oldRoleRaw.name = roleEntityRef; - let err = validateRole(oldRoleRaw); - if (err) { - throw new InputError( // 400 - `Invalid old role object. Cause: ${err.message}`, - ); - } - err = validateRole(newRoleRaw); - if (err) { - throw new InputError( // 400 - `Invalid new role object. Cause: ${err.message}`, - ); - } - this.transformMemberReferencesToLowercase(oldRoleRaw); - this.transformMemberReferencesToLowercase(newRoleRaw); - - const oldRole = this.transformRoleToArray(oldRoleRaw); - const newRole = this.transformRoleToArray(newRoleRaw); - // todo shell we allow newRole with an empty array?... - - const modifiedBy = ( - credentials as BackstageCredentials - ).principal.userEntityRef; - const newMetadata: RoleMetadataDao = { - ...newRoleRaw.metadata, - source: newRoleRaw.metadata?.source ?? 'rest', - roleEntityRef: newRoleRaw.name, - modifiedBy, - owner: newRoleRaw.metadata?.owner ?? '', - }; - - const oldMetadata = - await this.roleMetadata.findRoleMetadata(roleEntityRef); - if (!oldMetadata) { - throw new NotFoundError( - `Unable to find metadata for ${roleEntityRef}`, - ); - } - - err = await validateSource('rest', oldMetadata); - if (err) { - throw new NotAllowedError(`Unable to edit role: ${err.message}`); - } - - if (!matches(daoToMetadata(oldMetadata), conditionsFilter)) { - throw new NotAllowedError(); // 403 - } - - if ( - isEqual(oldRole, newRole) && - deepSortedEqual(oldMetadata, newMetadata, [ - 'author', - 'modifiedBy', - 'createdAt', - 'lastModified', - 'owner', - ]) - ) { - // no content: old role and new role are equal and their metadata too - response.status(204).end(); - return; - } - - for (const role of newRole) { - const hasRole = oldRole.some(element => { - return isEqual(element, role); - }); - // if the role is already part of old role and is a grouping policy we want to skip returning a conflict error - // to allow for other roles to be checked and added - if (await this.enforcer.hasGroupingPolicy(...role)) { - if (!hasRole) { - throw new ConflictError(); // 409 - } - } - const roleString = JSON.stringify(role); - - if (uniqueItems.has(roleString)) { - throw new ConflictError( - `Duplicate role members found; ${role.at(0)}, ${role.at( - 1, - )} is a duplicate`, - ); - } else { - uniqueItems.add(roleString); - } - } - - uniqueItems.clear(); - for (const role of oldRole) { - if (!(await this.enforcer.hasGroupingPolicy(...role))) { - throw new NotFoundError( - `Member reference: ${role[0]} was not found for role ${roleEntityRef}`, - ); // 404 - } - const roleString = JSON.stringify(role); - - if (uniqueItems.has(roleString)) { - throw new ConflictError( - `Duplicate role members found; ${role.at(0)}, ${role.at( - 1, - )} is a duplicate`, - ); - } else { - uniqueItems.add(roleString); - } - } - - await this.enforcer.updateGroupingPolicies( - oldRole, - newRole, - newMetadata, - ); - - let message = `Updated ${oldMetadata.roleEntityRef}.`; - if (newMetadata.roleEntityRef !== oldMetadata.roleEntityRef) { - message = `${message}. Role entity reference renamed to ${newMetadata.roleEntityRef}`; - } - response.locals.meta = { - ...newMetadata, - members: newRole.map(gp => gp[0]), - }; // auditor - - response.status(200).end(); - }, - ); - - router.delete( - '/roles/:kind/:namespace/:name', - logAuditorEvent(auditor), - async (request, response) => { - let conditionsFilter: RBACFilters | undefined; - const { decision, credentials } = await authorizeConditional( - request, - policyEntityDeletePermission, - this.options, - ); - - if (decision.result === AuthorizeResult.CONDITIONAL) { - conditionsFilter = transformConditions(decision.conditions); - } - - const roleEntityRef = this.getEntityReference(request, true); - - const currentMetadata = - await this.roleMetadata.findRoleMetadata(roleEntityRef); - - if ( - !currentMetadata || - !matches(daoToMetadata(currentMetadata), conditionsFilter) - ) { - throw new NotAllowedError(); // 403 - } - - const err = await validateSource('rest', currentMetadata); - if (err) { - throw new NotAllowedError(`Unable to delete role: ${err.message}`); - } - - let roleMembers = []; - if (request.query.memberReferences) { - const memberReference = this.getFirstQuery( - request.query.memberReferences!, - ).toLocaleLowerCase('en-US'); - const gp = await this.enforcer.getFilteredGroupingPolicy( - 0, - memberReference, - roleEntityRef, - ); - if (gp.length > 0) { - roleMembers.push(gp[0]); - } else { - throw new NotFoundError( - `role member '${memberReference}' was not found`, - ); // 404 - } - } else { - roleMembers = await this.enforcer.getFilteredGroupingPolicy( - 1, - roleEntityRef, - ); - } - - for (const role of roleMembers) { - if (!(await this.enforcer.hasGroupingPolicy(...role))) { - throw new NotFoundError(`role member '${role[0]}' was not found`); - } - } - - const modifiedBy = ( - credentials as BackstageCredentials - ).principal.userEntityRef; - const metadata: RoleMetadataDao = { - roleEntityRef, - source: 'rest', - modifiedBy, - }; - - await this.enforcer.removeGroupingPolicies( - roleMembers, - metadata, - false, - ); - - response.locals.meta = { - ...metadata, - members: roleMembers.map(gp => gp[0]), - }; // auditor - - response.status(204).end(); - }, - ); - - router.get( - '/roles/conditions', - logAuditorEvent(auditor), - async (request, response) => { - let conditionsFilter: RBACFilters | undefined; - const { decision } = await authorizeConditional( - request, - policyEntityReadPermission, - this.options, - ); - - if (decision.result === AuthorizeResult.CONDITIONAL) { - conditionsFilter = transformConditions(decision.conditions); - } - - const roleMetadata = - await this.roleMetadata.filterForOwnerRoleMetadata(conditionsFilter); - - const matchedRoleName = roleMetadata.flatMap(role => { - return role.roleEntityRef; - }); - - const conditions = await this.conditionalStorage.filterConditions( - this.getFirstQuery(request.query.roleEntityRef), - this.getFirstQuery(request.query.pluginId), - this.getFirstQuery(request.query.resourceType), - this.getActionQueries(request.query.actions), - ); - - const body: RoleConditionalPolicyDecision[] = - conditions - .map(condition => { - return { - ...condition, - permissionMapping: condition.permissionMapping.map( - pm => pm.action, - ), - }; - }) - .filter(condition => { - return matchedRoleName.includes(condition.roleEntityRef); - }); - - response.json(body); - }, - ); - - router.post( - '/roles/conditions', - logAuditorEvent(auditor), - async (request, response) => { - await authorizeConditional( - request, - policyEntityCreatePermission, - this.options, - ); - - const roleConditionPolicy: RoleConditionalPolicyDecision = - request.body; - validateRoleCondition(roleConditionPolicy); - - const conditionToCreate = await processConditionMapping( - roleConditionPolicy, - this.pluginPermMetaData, - auth, - ); - - const id = - await this.conditionalStorage.createCondition(conditionToCreate); - - const body = { id: id }; - - response.locals.meta = { condition: roleConditionPolicy }; // auditor - - response.status(201).json(body); - }, - ); - - router.get( - '/roles/conditions/:id', - logAuditorEvent(auditor), - async (request, response) => { - let conditionsFilter: RBACFilters | undefined; - const { decision } = await authorizeConditional( - request, - policyEntityReadPermission, - this.options, - ); - - const id: number = parseInt(request.params.id, 10); - if (isNaN(id)) { - throw new InputError('Id is not a valid number.'); - } - - const condition = await this.conditionalStorage.getCondition(id); - if (!condition) { - throw new NotFoundError(); - } - - if (decision.result === AuthorizeResult.CONDITIONAL) { - conditionsFilter = transformConditions(decision.conditions); - } - - const roleMetadata = - await this.roleMetadata.filterForOwnerRoleMetadata(conditionsFilter); - - const matchedRoleName = roleMetadata.flatMap(role => { - return role.roleEntityRef; - }); - - const body: RoleConditionalPolicyDecision | [] = - matchedRoleName.includes(condition.roleEntityRef) - ? { - ...condition, - permissionMapping: condition.permissionMapping.map( - pm => pm.action, - ), - } - : []; - - response.json(body); - }, - ); - - router.delete( - '/roles/conditions/:id', - logAuditorEvent(auditor), - async (request, response) => { - let conditionsFilter: RBACFilters | undefined; - const { decision } = await authorizeConditional( - request, - policyEntityDeletePermission, - this.options, - ); - - if (decision.result === AuthorizeResult.CONDITIONAL) { - conditionsFilter = transformConditions(decision.conditions); - } - - const id: number = parseInt(request.params.id, 10); - if (isNaN(id)) { - throw new InputError('Id is not a valid number.'); - } - - const condition = await this.conditionalStorage.getCondition(id); - if (!condition) { - throw new NotFoundError(`Condition with id ${id} was not found`); - } - const conditionToDelete: RoleConditionalPolicyDecision = - { - ...condition, - permissionMapping: condition.permissionMapping.map(pm => pm.action), - }; - - const roleMetadata = await this.roleMetadata.findRoleMetadata( - conditionToDelete.roleEntityRef, - ); - - if ( - !roleMetadata || - !matches(daoToMetadata(roleMetadata), conditionsFilter) - ) { - throw new NotAllowedError(); // 403 - } - - await this.conditionalStorage.deleteCondition(id); - response.locals.meta = { condition: conditionToDelete }; // auditor - - response.status(204).end(); - }, - ); - - router.put( - '/roles/conditions/:id', - logAuditorEvent(auditor), - async (request, response) => { - let conditionsFilter: RBACFilters | undefined; - const { decision } = await authorizeConditional( - request, - policyEntityUpdatePermission, - this.options, - ); - - if (decision.result === AuthorizeResult.CONDITIONAL) { - conditionsFilter = transformConditions(decision.conditions); - } - - const id: number = parseInt(request.params.id, 10); - if (isNaN(id)) { - throw new InputError('Id is not a valid number.'); - } - - const condition = await this.conditionalStorage.getCondition(id); - - if (!condition) { - throw new NotFoundError(`Condition with id ${id} was not found`); - } - - const roleMetadata = await this.roleMetadata.findRoleMetadata( - condition.roleEntityRef, - ); - - if ( - !roleMetadata || - !matches(daoToMetadata(roleMetadata), conditionsFilter) - ) { - throw new NotAllowedError(); // 403 - } - - const roleConditionPolicy: RoleConditionalPolicyDecision = - request.body; - - validateRoleCondition(roleConditionPolicy); - - const conditionToUpdate = await processConditionMapping( - roleConditionPolicy, - this.pluginPermMetaData, - auth, - ); - - await this.conditionalStorage.updateCondition(id, conditionToUpdate); - - response.locals.meta = { condition: roleConditionPolicy }; // auditor - - response.status(200).end(); - }, - ); - - router.post( - '/refresh/:id', - logAuditorEvent(auditor), - async (request, response) => { - await authorizeConditional( - request, - policyEntityCreatePermission, - this.options, - ); - - if (!this.rbacProviders) { - throw new NotFoundError(`No RBAC providers were found`); - } - - const idProvider = this.rbacProviders.find(provider => { - const id = provider.getProviderName(); - return id === request.params.id; - }); - - if (!idProvider) { - throw new NotFoundError( - `The RBAC provider ${request.params.id} was not found`, - ); - } - - await idProvider.refresh(); - response.status(200).end(); - }, - ); - - registerPermissionDefinitionRoutes( - router, - this.pluginPermMetaData, - this.pluginIdProvider, - this.extraPluginsIdStorage, - this.options, - ); - - router.use(setAuditorError()); - - return router; - } - - getEntityReference(request: Request, role?: boolean): string { - const kind = request.params.kind; - const namespace = request.params.namespace; - const name = request.params.name; - const entityRef = `${kind}:${namespace}/${name}`; - - const err = validateEntityReference(entityRef, role); - if (err) { - throw new InputError(err.message); - } - - return entityRef; - } - - async transformPolicyArray( - ...policies: string[][] - ): Promise { - const roleToSourceMap = await buildRoleSourceMap( - policies, - this.roleMetadata, - ); - - const roleBasedPolices: RoleBasedPolicy[] = []; - for (const p of policies) { - const [entityReference, permission, policy, effect] = p; - roleBasedPolices.push({ - entityReference, - permission, - policy, - effect, - metadata: { source: roleToSourceMap.get(entityReference)! }, - }); - } - - return roleBasedPolices; - } - - async transformRoleArray( - filter?: RBACFilters, - ...roles: string[][] - ): Promise { - const combinedRoles: { [key: string]: string[] } = {}; - - roles.forEach(([value, role]) => { - if (combinedRoles.hasOwnProperty(role)) { - combinedRoles[role].push(value); - } else { - combinedRoles[role] = [value]; - } - }); - - const result: Role[] = await Promise.all( - Object.entries(combinedRoles).flatMap(async ([role, value]) => { - const metadataDao = await this.roleMetadata.findRoleMetadata(role); - const metadata = metadataDao ? daoToMetadata(metadataDao) : undefined; - return Promise.resolve({ - memberReferences: value, - name: role, - metadata, - }); - }), - ); - - const filteredResult = result.filter(role => { - return role.metadata && matches(role.metadata, filter); - }); - - return filteredResult; - } - - transformPolicyToArray(policy: RoleBasedPolicy): string[] { - return [ - policy.entityReference!, - policy.permission!, - policy.policy!, - policy.effect!, - ]; - } - - transformRoleToArray(role: Role): string[][] { - const roles: string[][] = []; - for (const entity of role.memberReferences) { - roles.push([entity, role.name]); - } - return roles; - } - - transformMemberReferencesToLowercase(role: Role) { - role.memberReferences = role.memberReferences.map(member => - member.toLocaleLowerCase('en-US'), - ); - } - - getActionQueries( - queryValue: string | ParsedQs | (string | ParsedQs)[] | undefined, - ): PermissionAction[] | undefined { - if (!queryValue) { - return undefined; - } - if (Array.isArray(queryValue)) { - const permissionNames: PermissionAction[] = []; - for (const permissionQuery of queryValue) { - if ( - typeof permissionQuery === 'string' && - isPermissionAction(permissionQuery) - ) { - permissionNames.push(permissionQuery); - } else { - throw new InputError( - `Invalid permission action query value: ${permissionQuery}. Permission name should be string.`, - ); - } - } - return permissionNames; - } - - if (typeof queryValue === 'string' && isPermissionAction(queryValue)) { - return [queryValue]; - } - throw new InputError( - `Invalid permission action query value: ${queryValue}. Permission name should be string.`, - ); - } - - getFirstQuery( - queryValue: string | ParsedQs | (string | ParsedQs)[] | undefined, - ): string { - if (!queryValue) { - return ''; - } - if (Array.isArray(queryValue)) { - if (typeof queryValue[0] === 'string') { - return queryValue[0].toString(); - } - throw new InputError(`This api doesn't support nested query`); - } - - if (typeof queryValue === 'string') { - return queryValue; - } - throw new InputError(`This api doesn't support nested query`); - } - - isPolicyFilterEnabled(request: Request): boolean { - return ( - !!request.query.entityRef || - !!request.query.permission || - !!request.query.policy || - !!request.query.effect - ); - } - - async processPolicies( - policyArray: RoleBasedPolicy[], - isOld?: boolean, - errorMessage?: string, - filter?: RBACFilters, - ): Promise { - const policies: string[][] = []; - const uniqueItems = new Set(); - for (const policy of policyArray) { - let err = validatePolicy(policy); - if (err) { - throw new InputError( - `Invalid ${errorMessage ?? 'policy'} definition. Cause: ${ - err.message - }`, - ); // 400 - } - - const metadata = await this.roleMetadata.findRoleMetadata( - policy.entityReference!, - ); - - if (!metadata || !matches(daoToMetadata(metadata), filter)) { - throw new NotAllowedError(); // 403 - } - - let action = errorMessage ? 'edit' : 'delete'; - action = isOld ? action : 'add'; - - err = await validateSource('rest', metadata); - if (err) { - throw new NotAllowedError( - `Unable to ${action} policy ${policy.entityReference},${policy.permission},${policy.policy},${policy.effect}: ${err.message}`, - ); - } - - const transformedPolicy = this.transformPolicyToArray(policy); - if (isOld && !(await this.enforcer.hasPolicy(...transformedPolicy))) { - throw new NotFoundError( - `Policy '${policyToString(transformedPolicy)}' not found`, - ); // 404 - } - - if (!isOld && (await this.enforcer.hasPolicy(...transformedPolicy))) { - throw new ConflictError( - `Policy '${policyToString( - transformedPolicy, - )}' has been already stored`, - ); // 409 - } - - // We want to ensure that there are not duplicate permission policies - const rowString = JSON.stringify(transformedPolicy); - if (uniqueItems.has(rowString)) { - throw new ConflictError( - `Duplicate polices found; ${policy.entityReference}, ${policy.permission}, ${policy.policy}, ${policy.effect} is a duplicate`, - ); - } else { - uniqueItems.add(rowString); - policies.push(transformedPolicy); - } - } - return policies; - } - - nameSort(nameA: string, nameB: string): number { - if (nameA.toLocaleUpperCase('en-US') < nameB.toLocaleUpperCase('en-US')) { - return -1; - } - if (nameA.toLocaleUpperCase('en-US') > nameB.toLocaleUpperCase('en-US')) { - return 1; - } - return 0; - } -} diff --git a/plugins/rbac-backend/src/service/policy-builder.test.ts b/plugins/rbac-backend/src/service/policy-builder.test.ts deleted file mode 100644 index 065a658bbe..0000000000 --- a/plugins/rbac-backend/src/service/policy-builder.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { mockServices } from '@backstage/backend-test-utils'; - -import type { Adapter, Enforcer } from 'casbin'; -import type { Router } from 'express'; -import type TypeORMAdapter from 'typeorm-adapter'; - -import type { RBACProvider } from '@backstage-community/plugin-rbac-node'; - -import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; -import { RBACPermissionPolicy } from '../policies/permission-policy'; -import { PluginPermissionMetadataCollector } from './plugin-endpoints'; -import { PoliciesServer } from './policies-rest-api'; -import { PolicyBuilder } from './policy-builder'; -import { - extendablePluginIdProviderMock, - mockPermissionRegistry, -} from '../../__fixtures__/mock-utils'; - -import { PolicyExtensionPoint } from '@backstage/plugin-permission-node/alpha'; - -const enforcerMock: Partial = { - loadPolicy: jest.fn().mockImplementation(async () => {}), - enableAutoSave: jest.fn().mockImplementation(() => {}), - setRoleManager: jest.fn().mockImplementation(() => {}), - enableAutoBuildRoleLinks: jest.fn().mockImplementation(() => {}), - buildRoleLinks: jest.fn().mockImplementation(() => {}), -}; - -jest.mock('casbin', () => { - const actualCasbin = jest.requireActual('casbin'); - return { - ...actualCasbin, - newEnforcer: jest.fn((): Promise> => { - return Promise.resolve(enforcerMock); - }), - FileAdapter: jest.fn((): Adapter => { - return {} as Adapter; - }), - }; -}); - -const dataBaseAdapterFactoryMock: Partial = { - createAdapter: jest.fn((): Promise => { - return Promise.resolve({} as TypeORMAdapter); - }), -}; - -jest.mock('../database/casbin-adapter-factory', () => { - return { - CasbinDBAdapterFactory: jest.fn((): Partial => { - return dataBaseAdapterFactoryMock; - }), - }; -}); - -const pluginMetadataCollectorMock: Partial = - { - getPluginConditionRules: jest.fn().mockImplementation(), - getPluginPolicies: jest.fn().mockImplementation(), - getMetadataByPluginId: jest.fn().mockImplementation(), - }; - -jest.mock('./plugin-endpoints', () => { - return { - PluginPermissionMetadataCollector: jest - .fn() - .mockImplementation(() => pluginMetadataCollectorMock), - }; -}); - -const mockRouter: Router = {} as Router; -const policiesServerMock: Partial = { - serve: jest.fn().mockImplementation(async () => { - return mockRouter; - }), -}; - -jest.mock('./policies-rest-api', () => { - return { - PoliciesServer: jest.fn().mockImplementation(() => policiesServerMock), - }; -}); - -jest.mock('../policies/permission-policy', () => { - return { - RBACPermissionPolicy: { - build: jest.fn((): Promise => { - return Promise.resolve({} as RBACPermissionPolicy); - }), - }, - }; -}); - -jest.mock('./extendable-id-provider', () => { - return { - ExtendablePluginIdProvider: jest - .fn() - .mockImplementation(() => extendablePluginIdProviderMock), - }; -}); - -const providerMock: RBACProvider = { - getProviderName: jest.fn().mockImplementation(), - connect: jest.fn().mockImplementation(), - refresh: jest.fn().mockImplementation(), -}; - -const policyExtensionPointMock: PolicyExtensionPoint = { - setPolicy: jest.fn().mockImplementation(), -}; - -describe('PolicyBuilder', () => { - const backendPluginIDsProviderMock = { - getPluginIds: jest.fn().mockImplementation(() => { - return []; - }), - }; - - const mockLoggerService = mockServices.logger.mock(); - - beforeEach(async () => { - jest.clearAllMocks(); - }); - - it('should build policy server', async () => { - const router = await PolicyBuilder.build( - { - config: mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - permission: { - enabled: true, - rbac: {}, - }, - }, - }), - logger: mockLoggerService, - discovery: mockServices.discovery.mock(), - permissions: mockServices.permissions.mock(), - auth: mockServices.auth.mock(), - httpAuth: mockServices.httpAuth.mock(), - auditor: mockServices.auditor.mock(), - lifecycle: mockServices.lifecycle.mock(), - permissionsRegistry: mockPermissionRegistry, - policy: policyExtensionPointMock, - }, - backendPluginIDsProviderMock, - ); - expect(CasbinDBAdapterFactory).toHaveBeenCalled(); - expect(enforcerMock.loadPolicy).toHaveBeenCalled(); - expect(enforcerMock.enableAutoSave).toHaveBeenCalled(); - expect(RBACPermissionPolicy.build).toHaveBeenCalled(); - - expect(PoliciesServer).toHaveBeenCalled(); - expect(policiesServerMock.serve).toHaveBeenCalled(); - expect(router).toBeTruthy(); - expect(router).toBe(mockRouter); - expect(mockLoggerService.info).toHaveBeenCalledWith( - 'RBAC backend plugin was enabled', - ); - expect( - extendablePluginIdProviderMock.handleConflictedPluginIds, - ).toHaveBeenCalled(); - }); - - it('should build policy server with rbac providers', async () => { - const router = await PolicyBuilder.build( - { - config: mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - permission: { - enabled: true, - rbac: {}, - }, - }, - }), - logger: mockLoggerService, - discovery: mockServices.discovery.mock(), - permissions: mockServices.permissions.mock(), - auth: mockServices.auth.mock(), - httpAuth: mockServices.httpAuth.mock(), - auditor: mockServices.auditor.mock(), - lifecycle: mockServices.lifecycle.mock(), - permissionsRegistry: mockPermissionRegistry, - policy: policyExtensionPointMock, - }, - backendPluginIDsProviderMock, - [providerMock], - ); - expect(CasbinDBAdapterFactory).toHaveBeenCalled(); - expect(enforcerMock.loadPolicy).toHaveBeenCalled(); - expect(enforcerMock.enableAutoSave).toHaveBeenCalled(); - expect(RBACPermissionPolicy.build).toHaveBeenCalled(); - expect(providerMock.connect).toHaveBeenCalled(); - - expect(PoliciesServer).toHaveBeenCalled(); - expect(policiesServerMock.serve).toHaveBeenCalled(); - expect(router).toBeTruthy(); - expect(router).toBe(mockRouter); - expect(mockLoggerService.info).toHaveBeenCalledWith( - 'RBAC backend plugin was enabled', - ); - }); - - it('should build policy server, but log warning that permission framework disabled', async () => { - const router = await PolicyBuilder.build( - { - config: mockServices.rootConfig({ - data: { - backend: { - database: { - client: 'better-sqlite3', - connection: ':memory:', - }, - }, - permission: { - enabled: false, - rbac: {}, - }, - }, - }), - logger: mockLoggerService, - discovery: mockServices.discovery.mock(), - permissions: mockServices.permissions.mock(), - auth: mockServices.auth.mock(), - httpAuth: mockServices.httpAuth.mock(), - auditor: mockServices.auditor.mock(), - lifecycle: mockServices.lifecycle.mock(), - permissionsRegistry: mockPermissionRegistry, - policy: policyExtensionPointMock, - }, - backendPluginIDsProviderMock, - ); - expect(CasbinDBAdapterFactory).toHaveBeenCalled(); - expect(enforcerMock.loadPolicy).toHaveBeenCalled(); - expect(enforcerMock.enableAutoSave).toHaveBeenCalled(); - expect(RBACPermissionPolicy.build).not.toHaveBeenCalled(); - - expect(PoliciesServer).toHaveBeenCalled(); - expect(policiesServerMock.serve).toHaveBeenCalled(); - expect(router).toBeTruthy(); - expect(router).toBe(mockRouter); - expect(mockLoggerService.warn).toHaveBeenCalledWith( - 'RBAC backend plugin was disabled by application config permission.enabled: false', - ); - }); -}); diff --git a/plugins/rbac-backend/src/service/policy-builder.ts b/plugins/rbac-backend/src/service/policy-builder.ts deleted file mode 100644 index 5dd33132ca..0000000000 --- a/plugins/rbac-backend/src/service/policy-builder.ts +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { DatabaseManager } from '@backstage/backend-defaults/database'; -import type { - AuditorService, - AuthService, - DiscoveryService, - HttpAuthService, - LifecycleService, - LoggerService, - PermissionsRegistryService, - PermissionsService, -} from '@backstage/backend-plugin-api'; -import { CatalogClient } from '@backstage/catalog-client'; -import type { Config } from '@backstage/config'; -import type { PermissionEvaluator } from '@backstage/plugin-permission-common'; - -import { newEnforcer, newModelFromString } from 'casbin'; -import type { Router } from 'express'; - -import type { - PluginIdProvider, - RBACProvider, -} from '@backstage-community/plugin-rbac-node'; - -import { CasbinDBAdapterFactory } from '../database/casbin-adapter-factory'; -import { DataBaseConditionalStorage } from '../database/conditional-storage'; -import { migrate } from '../database/migration'; -import { DataBaseRoleMetadataStorage } from '../database/role-metadata'; -import { AllowAllPolicy } from '../policies/allow-all-policy'; -import { RBACPermissionPolicy } from '../policies/permission-policy'; -import { connectRBACProviders } from '../providers/connect-providers'; -import { BackstageRoleManager } from '../role-manager/role-manager'; -import { EnforcerDelegate } from './enforcer-delegate'; -import { MODEL } from './permission-model'; -import { PluginPermissionMetadataCollector } from './plugin-endpoints'; -import { PoliciesServer } from './policies-rest-api'; -import { policyEntityPermissions } from '@backstage-community/plugin-rbac-common'; -import { rules } from '../permissions'; -import { permissionMetadataResourceRef } from '../permissions/resource'; -import { PermissionDependentPluginDatabaseStore } from '../database/extra-permission-enabled-plugins-storage'; -import { ExtendablePluginIdProvider } from './extendable-id-provider'; -import { PolicyExtensionPoint } from '@backstage/plugin-permission-node/alpha'; -import { - DefaultPermissionsReader, - DefaultPermissionsSyncher, -} from '../default-permissions/default-permissions'; - -/** - * @public - */ -export type EnvOptions = { - config: Config; - logger: LoggerService; - discovery: DiscoveryService; - permissions: PermissionEvaluator; - auth: AuthService; - httpAuth: HttpAuthService; - auditor: AuditorService; - lifecycle: LifecycleService; - permissionsRegistry: PermissionsRegistryService; - policy: PolicyExtensionPoint; -}; - -/** - * @public - */ -export type RBACRouterOptions = { - config: Config; - logger: LoggerService; - auth: AuthService; - httpAuth: HttpAuthService; - permissions: PermissionsService; - permissionsRegistry: PermissionsRegistryService; - auditor: AuditorService; -}; - -/** - * @public - */ -export class PolicyBuilder { - public static async build( - env: EnvOptions, - pluginIdProvider: PluginIdProvider = { getPluginIds: () => [] }, - rbacProviders?: Array, - ): Promise { - const databaseManager = DatabaseManager.fromConfig(env.config).forPlugin( - 'permission', - { logger: env.logger, lifecycle: env.lifecycle }, - ); - - const databaseClient = await databaseManager.getClient(); - - const adapter = await new CasbinDBAdapterFactory( - env.config, - databaseClient, - ).createAdapter(); - - const enf = await newEnforcer(newModelFromString(MODEL), adapter); - await enf.loadPolicy(); - enf.enableAutoSave(true); - - const catalogClient = new CatalogClient({ discoveryApi: env.discovery }); - const catalogDBClient = await DatabaseManager.fromConfig(env.config) - .forPlugin('catalog', { logger: env.logger, lifecycle: env.lifecycle }) - .getClient(); - - const defPermReader = new DefaultPermissionsReader(env.config); - - const rm = new BackstageRoleManager( - catalogClient, - env.logger, - catalogDBClient, - databaseClient, - env.config, - env.auth, - defPermReader, - ); - enf.setRoleManager(rm); - enf.enableAutoBuildRoleLinks(false); - await enf.buildRoleLinks(); - - await migrate(databaseManager); - - const conditionStorage = new DataBaseConditionalStorage(databaseClient); - - const roleMetadataStorage = new DataBaseRoleMetadataStorage(databaseClient); - const enforcerDelegate = new EnforcerDelegate( - enf, - env.auditor, - conditionStorage, - roleMetadataStorage, - databaseClient, - ); - - const defPermSyncher = new DefaultPermissionsSyncher( - roleMetadataStorage, - enforcerDelegate, - defPermReader, - ); - await defPermSyncher.sync(); - - env.permissionsRegistry.addResourceType({ - resourceRef: permissionMetadataResourceRef, - getResources: resourceRefs => - Promise.all( - resourceRefs.map(ref => { - if ( - ref === - roleMetadataStorage.getCachedDefaultRoleMetadata()?.roleEntityRef - ) { - return roleMetadataStorage.getCachedDefaultRoleMetadata(); - } - return roleMetadataStorage.findRoleMetadata(ref); - }), - ), - permissions: policyEntityPermissions, - rules: Object.values(rules), - }); - - if (rbacProviders) { - await connectRBACProviders( - rbacProviders, - enforcerDelegate, - roleMetadataStorage, - conditionStorage, - env.logger, - env.auditor, - ); - } - - const extraPluginsIdStorage = new PermissionDependentPluginDatabaseStore( - databaseClient, - ); - const extendablePluginIdProvider = new ExtendablePluginIdProvider( - extraPluginsIdStorage, - pluginIdProvider, - env.config, - ); - await extendablePluginIdProvider.handleConflictedPluginIds(); - const pluginPermMetaData = new PluginPermissionMetadataCollector({ - deps: { - discovery: env.discovery, - pluginIdProvider: extendablePluginIdProvider, - logger: env.logger, - config: env.config, - }, - }); - - const isPluginEnabled = env.config.getOptionalBoolean('permission.enabled'); - if (isPluginEnabled) { - env.logger.info('RBAC backend plugin was enabled'); - - env.policy.setPolicy( - await RBACPermissionPolicy.build( - env.logger, - env.auditor, - env.config, - conditionStorage, - enforcerDelegate, - roleMetadataStorage, - databaseClient, - pluginPermMetaData, - env.auth, - ), - ); - } else { - env.logger.warn( - 'RBAC backend plugin was disabled by application config permission.enabled: false', - ); - - env.policy.setPolicy(new AllowAllPolicy()); - } - - const options: RBACRouterOptions = { - config: env.config, - logger: env.logger, - auth: env.auth, - httpAuth: env.httpAuth, - permissions: env.permissions, - permissionsRegistry: env.permissionsRegistry, - auditor: env.auditor, - }; - - const server = new PoliciesServer( - options, - enforcerDelegate, - conditionStorage, - pluginPermMetaData, - roleMetadataStorage, - extraPluginsIdStorage, - extendablePluginIdProvider, - rbacProviders, - ); - return server.serve(); - } -} diff --git a/plugins/rbac-backend/src/service/router.test.ts b/plugins/rbac-backend/src/service/router.test.ts deleted file mode 100644 index f3a7d25afd..0000000000 --- a/plugins/rbac-backend/src/service/router.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { mockServices } from '@backstage/backend-test-utils'; - -import express from 'express'; -import request from 'supertest'; - -import { createRouter } from './router'; - -describe('createRouter', () => { - let app: express.Express; - - beforeAll(async () => { - const router = await createRouter({ - logger: mockServices.logger.mock(), - config: mockServices.rootConfig(), - }); - app = express().use(router); - }); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - describe('GET /health', () => { - it('returns ok', async () => { - const response = await request(app).get('/health'); - - expect(response.status).toEqual(200); - expect(response.body).toEqual({ status: 'ok' }); - }); - }); -}); diff --git a/plugins/rbac-backend/src/service/router.ts b/plugins/rbac-backend/src/service/router.ts deleted file mode 100644 index 159d698a7b..0000000000 --- a/plugins/rbac-backend/src/service/router.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter'; -import type { LoggerService } from '@backstage/backend-plugin-api'; -import type { Config } from '@backstage/config'; -import Router from 'express-promise-router'; - -import express from 'express'; - -/** - * @public - */ -export interface RouterOptions { - logger: LoggerService; - config: Config; -} - -/** - * @public - */ -export async function createRouter( - options: RouterOptions, -): Promise { - const { logger, config } = options; - - const router = Router(); - router.use(express.json()); - - router.get('/health', (_, response) => { - logger.info('PONG!'); - response.json({ status: 'ok' }); - }); - - const middleware = MiddlewareFactory.create({ logger, config }); - - router.use(middleware.error()); - return router; -} diff --git a/plugins/rbac-backend/src/setupTests.ts b/plugins/rbac-backend/src/setupTests.ts deleted file mode 100644 index c7ce5c0988..0000000000 --- a/plugins/rbac-backend/src/setupTests.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export {}; diff --git a/plugins/rbac-backend/src/validation/condition-validation.test.ts b/plugins/rbac-backend/src/validation/condition-validation.test.ts deleted file mode 100644 index dd809ed887..0000000000 --- a/plugins/rbac-backend/src/validation/condition-validation.test.ts +++ /dev/null @@ -1,988 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { AuthorizeResult } from '@backstage/plugin-permission-common'; - -import type { - PermissionAction, - RoleConditionalPolicyDecision, -} from '@backstage-community/plugin-rbac-common'; - -import { validateRoleCondition } from './condition-validation'; - -describe('condition-validation', () => { - describe('validation common fields', () => { - it('should fail validation role condition without pluginId', () => { - const condition: any = { - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'pluginId' must be specified in the role condition`, - ); - }); - - it('should fail validation role condition without resourceType', () => { - const condition: any = { - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'resourceType' must be specified in the role condition`, - ); - }); - - it('should fail validation role condition without permissionMapping', () => { - const condition: any = { - resourceType: 'catalog-entity', - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'permissionMapping' must be non empty array in the role condition`, - ); - }); - - it('should fail validation role condition with empty array permissionMapping', () => { - const condition: any = { - resourceType: 'catalog-entity', - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: [], - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'permissionMapping' must be non empty array in the role condition`, - ); - }); - - it('should fail validation role condition with array permissionMapping, but with wrong action value', () => { - const condition: any = { - resourceType: 'catalog-entity', - pluginId: 'catalog', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['wrong-value'], - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'permissionMapping' array contains non action value: 'wrong-value'`, - ); - }); - - it('should fail validation role condition with policy-entity resource type and create action', () => { - const condition: any = { - resourceType: 'policy-entity', - pluginId: 'permission', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['create'], - conditions: { - anyOf: [ - { - rule: 'IS_OWNER', - resourceType: 'policy-entity', - params: { key: 'owner', values: ['user:default/mock'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `Conditional policy can not be created for resource type 'policy-entity' with the permission action 'create'`, - ); - }); - - it('should fail validation role condition without role entity reference', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'roleEntityRef' must be specified in the role condition`, - ); - }); - - it('should fail validation role condition without result', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - permissionMapping: ['read'], - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'result' must be specified in the role condition`, - ); - }); - - it('should fail validation role condition without conditions', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'conditions' must be specified in the role condition`, - ); - }); - }); - - describe('validate simple condition', () => { - it('should fail validation role-condition.conditions without rule', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'rule' must be specified in the roleCondition.conditions.condition`, - ); - }); - - it('should fail validation role-condition.conditions without resourceType', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - rule: 'IS_ENTITY_OWNER', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'resourceType' must be specified in the roleCondition.conditions.condition`, - ); - }); - - it('should validate role-condition.conditions without errors', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - }; - let unexpectedErr; - try { - validateRoleCondition(condition); - } catch (err) { - unexpectedErr = err; - } - expect(unexpectedErr).toBeUndefined(); - }); - - it('should validate role-condition.conditions with permission policy action of use without errors', () => { - const condition: any = { - pluginId: 'scaffolder', - resourceType: 'scaffolder-action', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['use'], - conditions: { - rule: 'HAS_ACTION_ID', - resourceType: 'scaffolder-action', - params: { - actionId: 'quay:create-repository', - }, - }, - }; - let unexpectedErr; - try { - validateRoleCondition(condition); - } catch (err) { - unexpectedErr = err; - } - expect(unexpectedErr).toBeUndefined(); - }); - }); - - describe('validate "not" criteria', () => { - it('should fail validation role-condition.conditions.not without rule', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - permissionMapping: ['read'], - result: AuthorizeResult.CONDITIONAL, - conditions: { - not: { - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'rule' must be specified in the roleCondition.conditions.not.condition`, - ); - }); - - it('should fail validation role-condition.conditions.not without resourceType', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - not: { - rule: 'IS_ENTITY_OWNER', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'resourceType' must be specified in the roleCondition.conditions.not.condition`, - ); - }); - - it('should validate role-condition.conditions.not without errors', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - not: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - }, - }; - let unexpectedErr; - try { - validateRoleCondition(condition); - } catch (err) { - unexpectedErr = err; - } - - expect(unexpectedErr).toBeUndefined(); - }); - }); - - describe('validate anyOf criteria', () => { - it('should fail validation role-condition.conditions.anyOf with an empty array value', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - anyOf: [], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `roleCondition.conditions.anyOf criteria must be non empty array`, - ); - }); - - it('should fail validation role-condition.conditions.anyOf with non array value', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - anyOf: { - rule: 'IS_ENTITY_OWNER', - params: { - claims: ['group:default/team-a'], - }, - }, - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `roleCondition.conditions.anyOf criteria must be non empty array`, - ); - }); - - it('should fail validation role-condition.conditions.anyOf without resourceType in the first param', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'resourceType' must be specified in the roleCondition.conditions.anyOf[0].condition`, - ); - }); - - it('should fail validation role-condition.conditions.anyOf without resourceType in the second param', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'resourceType' must be specified in the roleCondition.conditions.anyOf[1].condition`, - ); - }); - - it('should fail validation role-condition.conditions.anyOf without rule in the first param', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - anyOf: [ - { - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'rule' must be specified in the roleCondition.conditions.anyOf[0].condition`, - ); - }); - - it('should fail validation role-condition.conditions.anyOf without rule in the second param', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'rule' must be specified in the roleCondition.conditions.anyOf[1].condition`, - ); - }); - - it('should validate role-condition.conditions.anyOf without errors', () => { - const condition: RoleConditionalPolicyDecision = { - id: 1, - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - let unexpectedErr; - try { - validateRoleCondition(condition); - } catch (err) { - unexpectedErr = err; - } - expect(unexpectedErr).toBeUndefined(); - }); - }); - - describe('validate allOf criteria', () => { - it('should fail validation role-condition.conditions.allOf with an empty array value', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - allOf: [], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `roleCondition.conditions.allOf criteria must be non empty array`, - ); - }); - - it('should fail validation role-condition.conditions.allOf with non array value', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - allOf: { - rule: 'IS_ENTITY_OWNER', - params: { - claims: ['group:default/team-a'], - }, - }, - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `roleCondition.conditions.allOf criteria must be non empty array`, - ); - }); - - it('should fail validation role-condition.conditions.allOf without resourceType in the first param', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'resourceType' must be specified in the roleCondition.conditions.allOf[0].condition`, - ); - }); - - it('should fail validation role-condition.conditions.allOf without resourceType in the second param', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'resourceType' must be specified in the roleCondition.conditions.allOf[1].condition`, - ); - }); - - it('should fail validation role-condition.conditions.allOf without rule in the first param', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - allOf: [ - { - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'rule' must be specified in the roleCondition.conditions.allOf[0].condition`, - ); - }); - - it('should fail validation role-condition.conditions.allOf without rule in the second param', () => { - const condition: any = { - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `'rule' must be specified in the roleCondition.conditions.allOf[1].condition`, - ); - }); - - it('should success validation role-condition.conditions.allOf', () => { - const condition: RoleConditionalPolicyDecision = { - id: 1, - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - let unexpectedErr; - try { - validateRoleCondition(condition); - } catch (err) { - unexpectedErr = err; - } - expect(unexpectedErr).toBeUndefined(); - }); - }); - - describe('complex conditions', () => { - it('should fail validation of role-condition.conditions in parallel with condition rule', () => { - const condition: RoleConditionalPolicyDecision = { - id: 1, - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `RBAC plugin does not support parallel conditions alongside rules, consider reworking request to include nested condition criteria. Conditional criteria causing the error allOf, 'rule: IS_ENTITY_OWNER'.`, - ); - }); - - it('should fail validation of role-condition.conditions criteria (allOf, not) in parallel', () => { - const condition: RoleConditionalPolicyDecision = { - id: 1, - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - not: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `RBAC plugin does not support parallel conditions, consider reworking request to include nested condition criteria. Conditional criteria causing the error allOf,not.`, - ); - }); - - it('should fail validation of role-condition.conditions criteria (allOf, anyOf) in parallel', () => { - const condition: RoleConditionalPolicyDecision = { - id: 1, - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - allOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `RBAC plugin does not support parallel conditions, consider reworking request to include nested condition criteria. Conditional criteria causing the error allOf,anyOf.`, - ); - }); - - it('should fail validation of role-condition.conditions criteria (not, anyOf) in parallel', () => { - const condition: RoleConditionalPolicyDecision = { - id: 1, - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - not: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - anyOf: [ - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - expect(() => validateRoleCondition(condition)).toThrow( - `RBAC plugin does not support parallel conditions, consider reworking request to include nested condition criteria. Conditional criteria causing the error anyOf,not.`, - ); - }); - - it('should validate role-condition.conditions that are nested', () => { - const condition: RoleConditionalPolicyDecision = { - id: 1, - pluginId: 'catalog', - resourceType: 'catalog-entity', - roleEntityRef: 'role:default/test', - result: AuthorizeResult.CONDITIONAL, - permissionMapping: ['read'], - conditions: { - anyOf: [ - { - not: { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - }, - { - rule: 'IS_ENTITY_OWNER', - resourceType: 'catalog-entity', - params: { - claims: ['user:default/logarifm', 'group:default/team-a'], - }, - }, - { - rule: 'IS_ENTITY_KIND', - resourceType: 'catalog-entity', - params: { kinds: ['Group'] }, - }, - ], - }, - }; - - let unexpectedErr; - try { - validateRoleCondition(condition); - } catch (err) { - unexpectedErr = err; - } - expect(unexpectedErr).toBeUndefined(); - }); - }); -}); diff --git a/plugins/rbac-backend/src/validation/condition-validation.ts b/plugins/rbac-backend/src/validation/condition-validation.ts deleted file mode 100644 index ce0b75959a..0000000000 --- a/plugins/rbac-backend/src/validation/condition-validation.ts +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - PermissionCondition, - PermissionCriteria, - PermissionRuleParams, -} from '@backstage/plugin-permission-common'; - -import type { - PermissionAction, - RoleConditionalPolicyDecision, -} from '@backstage-community/plugin-rbac-common'; - -import { isPermissionAction } from '../helper'; - -export function validateRoleCondition( - condition: RoleConditionalPolicyDecision, -) { - if (!condition.roleEntityRef) { - throw new Error(`'roleEntityRef' must be specified in the role condition`); - } - if (!condition.result) { - throw new Error(`'result' must be specified in the role condition`); - } - if (!condition.pluginId) { - throw new Error(`'pluginId' must be specified in the role condition`); - } - if (!condition.resourceType) { - throw new Error(`'resourceType' must be specified in the role condition`); - } - - if ( - !condition.permissionMapping || - condition.permissionMapping.length === 0 - ) { - throw new Error( - `'permissionMapping' must be non empty array in the role condition`, - ); - } - const nonActionValue = condition.permissionMapping.find( - action => !isPermissionAction(action), - ); - if (nonActionValue) { - throw new Error( - `'permissionMapping' array contains non action value: '${nonActionValue}'`, - ); - } - - if ( - condition.resourceType === 'policy-entity' && - condition.permissionMapping.includes('create') - ) { - throw new Error( - `Conditional policy can not be created for resource type 'policy-entity' with the permission action 'create'`, - ); - } - - if (!condition.conditions) { - throw new Error(`'conditions' must be specified in the role condition`); - } - if (condition.conditions) { - validatePermissionCondition( - condition.conditions, - 'roleCondition.conditions', - ); - } -} - -/** - * validatePermissionCondition validate conditional permission policies using validateCriteria and validateRule. - * @param conditionOrCriteria The Permission Criteria of the conditional permission. - * @param jsonPathLocator The location in the JSON of the current check. - * @returns undefined. - */ -function validatePermissionCondition( - conditionOrCriteria: PermissionCriteria< - PermissionCondition - >, - jsonPathLocator: string, -) { - validateCriteria(conditionOrCriteria, jsonPathLocator); - - if ('not' in conditionOrCriteria) { - validatePermissionCondition( - conditionOrCriteria.not, - `${jsonPathLocator}.not`, - ); - return; - } - - if ('allOf' in conditionOrCriteria) { - if ( - !Array.isArray(conditionOrCriteria.allOf) || - conditionOrCriteria.allOf.length === 0 - ) { - throw new Error( - `${jsonPathLocator}.allOf criteria must be non empty array`, - ); - } - for (const [index, elem] of conditionOrCriteria.allOf.entries()) { - validatePermissionCondition(elem, `${jsonPathLocator}.allOf[${index}]`); - } - return; - } - - if ('anyOf' in conditionOrCriteria) { - if ( - !Array.isArray(conditionOrCriteria.anyOf) || - conditionOrCriteria.anyOf.length === 0 - ) { - throw new Error( - `${jsonPathLocator}.anyOf criteria must be non empty array`, - ); - } - for (const [index, elem] of conditionOrCriteria.anyOf.entries()) { - validatePermissionCondition(elem, `${jsonPathLocator}.anyOf[${index}]`); - } - } -} - -/** - * validateRule ensures that there is a rule and resource type associated with each conditional permission. - * @param conditionOrCriteria The Permission Criteria of the conditional permission. - * @param jsonPathLocator The location in the JSON of the current check. - */ -function validateRule( - conditionOrCriteria: PermissionCriteria< - PermissionCondition - >, - jsonPathLocator: string, -) { - if (!('resourceType' in conditionOrCriteria)) { - throw new Error( - `'resourceType' must be specified in the ${jsonPathLocator}.condition`, - ); - } - if (!('rule' in conditionOrCriteria)) { - throw new Error( - `'rule' must be specified in the ${jsonPathLocator}.condition`, - ); - } -} - -/** - * validateCriteria ensures that there is only one of the following criteria: allOf, anyOf, and not, at any given level. - * We want to make sure that there are no parallel conditional criteria for conditional permission policies as this is - * not support by the permission framework. - * - * If more than one criteria are at a given level, we throw an error about the inability to support parallel conditions. - * If no criteria are found, we validate the rule. - * - * @param conditionOrCriteria The Permission Criteria of the conditional permission. - * @param jsonPathLocator The location in the JSON of the current check. - */ -function validateCriteria( - conditionOrCriteria: PermissionCriteria< - PermissionCondition - >, - jsonPathLocator: string, -) { - const criteriaList = ['allOf', 'anyOf', 'not']; - const found: string[] = []; - - for (const crit of criteriaList) { - if (crit in conditionOrCriteria) { - found.push(crit); - } - } - - if (found.length > 1) { - throw new Error( - `RBAC plugin does not support parallel conditions, consider reworking request to include nested condition criteria. Conditional criteria causing the error ${found}.`, - ); - } else if (found.length === 0) { - validateRule(conditionOrCriteria, jsonPathLocator); - } - - if (found.length === 1 && 'rule' in conditionOrCriteria) { - throw new Error( - `RBAC plugin does not support parallel conditions alongside rules, consider reworking request to include nested condition criteria. Conditional criteria causing the error ${found}, 'rule: ${conditionOrCriteria.rule}'.`, - ); - } -} diff --git a/plugins/rbac-backend/src/validation/plugin-validation.test.ts b/plugins/rbac-backend/src/validation/plugin-validation.test.ts deleted file mode 100644 index 277b356241..0000000000 --- a/plugins/rbac-backend/src/validation/plugin-validation.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { validatePermissionDependentPlugin } from './plugin-validation'; - -describe('validatePermissionDependentPlugin', () => { - it('does not throw when ids is a valid array of strings', () => { - expect(() => - validatePermissionDependentPlugin({ - ids: ['plugin-a', 'plugin-b'], - }), - ).not.toThrow(); - }); - - it('throws if ids is missing', () => { - expect(() => validatePermissionDependentPlugin({} as any)).toThrow( - `'ids' must be specified in the permission dependent plugin`, - ); - }); - - it('throws if ids is not an array', () => { - expect(() => - validatePermissionDependentPlugin({ ids: 'plugin-a' } as any), - ).toThrow(`'ids' must be an array of string plugin ID values`); - }); - - it('throws if ids contains non-string values', () => { - expect(() => - validatePermissionDependentPlugin({ ids: ['plugin-a', 123] } as any), - ).toThrow(`'ids' must be an array of string plugin ID values`); - }); -}); diff --git a/plugins/rbac-backend/src/validation/plugin-validation.ts b/plugins/rbac-backend/src/validation/plugin-validation.ts deleted file mode 100644 index 16c4f76ba8..0000000000 --- a/plugins/rbac-backend/src/validation/plugin-validation.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { PermissionDependentPluginList } from '@backstage-community/plugin-rbac-common'; - -export function validatePermissionDependentPlugin( - plugin: PermissionDependentPluginList, -) { - if (!plugin.ids) { - throw new Error( - `'ids' must be specified in the permission dependent plugin`, - ); - } - if ( - !Array.isArray(plugin.ids) || - !plugin.ids.every(id => typeof id === 'string') - ) { - throw new Error(`'ids' must be an array of string plugin ID values`); - } -} diff --git a/plugins/rbac-backend/src/validation/policies-validation.test.ts b/plugins/rbac-backend/src/validation/policies-validation.test.ts deleted file mode 100644 index a076453386..0000000000 --- a/plugins/rbac-backend/src/validation/policies-validation.test.ts +++ /dev/null @@ -1,410 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { - RoleBasedPolicy, - Source, -} from '@backstage-community/plugin-rbac-common'; - -import { RoleMetadataDao } from '../database/role-metadata'; -import { - validateEntityReference, - validateGroupingPolicy, - validatePolicy, - validateRole, - validateSource, -} from './policies-validation'; - -const modifiedBy = 'user:default/some-admin'; - -describe('rest data validation', () => { - describe('validate entity referenced policy', () => { - it('should return an error when entity reference is empty', () => { - const policy: RoleBasedPolicy = {}; - const err = validatePolicy(policy); - expect(err).toBeTruthy(); - expect(err?.message).toEqual(`'entityReference' must not be empty`); - }); - - it('should return an error when permission is empty', () => { - const policy: RoleBasedPolicy = { - entityReference: 'user:default/guest', - }; - const err = validatePolicy(policy); - expect(err).toBeTruthy(); - expect(err?.message).toEqual(`'permission' field must not be empty`); - }); - - it('should return an error when policy is empty', () => { - const policy: RoleBasedPolicy = { - entityReference: 'user:default/guest', - permission: 'catalog-entity', - }; - const err = validatePolicy(policy); - expect(err).toBeTruthy(); - expect(err?.message).toEqual(`'policy' field must not be empty`); - }); - - it('should return an error when policy has an invalid value', () => { - const policy: RoleBasedPolicy = { - entityReference: 'user:default/guest', - permission: 'catalog-entity', - policy: 'invalid-policy', - effect: 'allow', - }; - const err = validatePolicy(policy); - expect(err).toBeTruthy(); - expect(err?.message).toEqual( - `'policy' has invalid value: 'invalid-policy'. It should be one of: create, read, update, delete, use`, - ); - }); - - it('should return an error when effect is empty', () => { - const policy: RoleBasedPolicy = { - entityReference: 'user:default/guest', - permission: 'catalog-entity', - policy: 'read', - }; - const err = validatePolicy(policy); - expect(err).toBeTruthy(); - expect(err?.message).toEqual(`'effect' field must not be empty`); - }); - - it('should return an error when effect has an invalid value', () => { - const policy: RoleBasedPolicy = { - entityReference: 'user:default/guest', - permission: 'catalog-entity', - policy: 'read', - effect: 'invalid-effect', - }; - const err = validatePolicy(policy); - expect(err).toBeTruthy(); - expect(err?.message).toEqual( - `'effect' has invalid value: 'invalid-effect'. It should be: 'allow' or 'deny'`, - ); - }); - - it(`pass validation when all fields are valid. Effect 'allow' should be valid`, () => { - const policy: RoleBasedPolicy = { - entityReference: 'user:default/guest', - permission: 'catalog-entity', - policy: 'read', - effect: 'allow', - }; - const err = validatePolicy(policy); - expect(err).toBeUndefined(); - }); - - it(`pass validation when all fields are valid. Effect 'deny' should be valid`, () => { - const policy: RoleBasedPolicy = { - entityReference: 'user:default/guest', - permission: 'catalog-entity', - policy: 'read', - effect: 'deny', - }; - const err = validatePolicy(policy); - expect(err).toBeUndefined(); - }); - }); - - describe('validate entity reference', () => { - it('should return an error when entity reference is an empty', () => { - const err = validateEntityReference(''); - expect(err).toBeTruthy(); - expect(err?.message).toEqual(`'entityReference' must not be empty`); - }); - - it('should return an error when entity reference is not full or invalid', () => { - const invalidOrUnsupportedEntityRefs = [ - { - ref: 'admin', - expectedError: `Entity reference "admin" had missing or empty kind (e.g. did not start with "component:" or similar)`, - }, - { - ref: 'admin:default', - expectedError: `entity reference 'admin:default' does not match the required format [:][/]. Provide, please, full entity reference.`, - }, - { - ref: 'admin/guest', - expectedError: `Entity reference "admin/guest" had missing or empty kind (e.g. did not start with "component:" or similar)`, - }, - { - ref: 'admin/guest/somewhere', - expectedError: `Entity reference "admin/guest/somewhere" had missing or empty kind (e.g. did not start with "component:" or similar)`, - }, - { - ref: ':default/admin', - expectedError: `Entity reference ":default/admin" was not on the form [:][/]`, - }, - { - ref: 'user:/admin', - expectedError: `Entity reference "user:/admin" was not on the form [:][/]`, - }, - { - ref: 'user:default/', - expectedError: `Entity reference "user:default/" was not on the form [:][/]`, - }, - { - ref: 'user:/', - expectedError: `Entity reference "user:/" was not on the form [:][/]`, - }, - { - ref: ':default/', - expectedError: `Entity reference ":default/" was not on the form [:][/]`, - }, - { - ref: ':/guest', - expectedError: `Entity reference ":/guest" was not on the form [:][/]`, - }, - { - ref: ':/', - expectedError: `Entity reference ":/" was not on the form [:][/]`, - }, - { - ref: '/admin', - expectedError: `Entity reference "/admin" was not on the form [:][/]`, - }, - { - ref: 'user/', - expectedError: `Entity reference "user/" was not on the form [:][/]`, - }, - { - ref: ':default', - expectedError: `Entity reference ":default" was not on the form [:][/]`, - }, - { - ref: 'user:', - expectedError: `Entity reference "user:" was not on the form [:][/]`, - }, - { - ref: 'admin:default/test', - expectedError: `Unsupported kind admin. List supported values ["user", "group", "role"]`, - }, - ]; - for (const entityRef of invalidOrUnsupportedEntityRefs) { - const err = validateEntityReference(entityRef.ref); - expect(err).toBeTruthy(); - expect(err?.message).toEqual(entityRef.expectedError); - } - }); - - it('should return an error when entity reference name is invalid', () => { - const invalidEntityNames = [ - 'john@doe', - 'John Doe', - 'John/Doe', - 'invalid-', - 'invalid_', - '.invalid', - `too-long${'1'.repeat(60)}`, - ]; - - for (const invalidName of invalidEntityNames) { - const expectedError = `The name '${invalidName}' in the entity reference must be a string that is sequences of [a-zA-Z0-9] separated by any of [-_.], at most 63 characters in total`; - const entityRef = `user:default/${invalidName}`; - const err = validateEntityReference(entityRef); - expect(err).toBeTruthy(); - expect(err?.message).toEqual(expectedError); - } - }); - - it('should return an error when entity reference namespace is invalid', () => { - const invalidEntityNamespaces = [ - 'INVALID', - 'invalid-', - '-invalid', - 'invalid$namespace', - `too-long${'1'.repeat(60)}`, - ]; - - for (const invalidNamespace of invalidEntityNamespaces) { - const expectedError = `The namespace '${invalidNamespace}' in the entity reference must be a string that is sequences of [a-z0-9] separated by [-], at most 63 characters in total`; - const entityRef = `user:${invalidNamespace}/doe`; - const err = validateEntityReference(entityRef); - expect(err).toBeTruthy(); - expect(err?.message).toEqual(expectedError); - } - }); - - it('should pass entity reference validation', () => { - const validEntityRefs = [ - 'user:default/guest', - 'role:default/team-a', - 'role:default/team_1', - 'role:default/team.A', - 'role:custom-1/doe', - ]; - for (const entityRef of validEntityRefs) { - const err = validateEntityReference(entityRef); - expect(err).toBeFalsy(); - } - }); - }); - - describe('validateRole', () => { - it('should return an error when "memberReferences" query param is missing', () => { - const request = { name: 'role:default/user' } as any; - const err = validateRole(request); - expect(err).toBeTruthy(); - expect(err?.message).toEqual( - `'memberReferences' field must not be empty`, - ); - }); - - it('should return an error when "owner" param is in an invalid format', () => { - const request = { - memberReferences: ['user:default/guest'], - name: 'role:default/user', - metadata: { - owner: 'test:default/some_owner', - }, - } as any; - const err = validateRole(request); - expect(err).toBeTruthy(); - expect(err?.message).toEqual( - `Unsupported kind test. List supported values [\"user\", \"group\"]`, - ); - }); - - it('should pass validation when all required query params are present', () => { - const request = { - memberReferences: ['user:default/guest'], - name: 'role:default/user', - } as any; - const err = validateRole(request); - expect(err).toBeUndefined(); - }); - }); - - describe('validateSource', () => { - const roleMeta: RoleMetadataDao = { - roleEntityRef: 'role:default/catalog-reader', - source: 'rest', - modifiedBy, - }; - - it('should not return an error whenever the source that is passed matches the source of the role', async () => { - const source: Source = 'rest'; - - const err = await validateSource(source, roleMeta); - - expect(err).toBeUndefined(); - }); - - it('should not return an error whenever the source that is passed does not match a legacy source role', async () => { - const roleMetaLegacy: RoleMetadataDao = { - roleEntityRef: 'role:default/legacy-reader', - source: 'legacy', - modifiedBy, - }; - - const source: Source = 'rest'; - - const err = await validateSource(source, roleMetaLegacy); - - expect(err).toBeUndefined(); - }); - - it('should return an error whenever the source that is passed does not match the source of the role', async () => { - const source: Source = 'csv-file'; - - const err = await validateSource(source, roleMeta); - - expect(err).toBeTruthy(); - expect(err?.message).toEqual( - `source does not match originating role ${ - roleMeta.roleEntityRef - }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, - ); - }); - }); - - describe('validateGroupingPolicy', () => { - let groupPolicy = ['user:default/test', 'role:default/catalog-reader']; - let source: Source = 'rest'; - const roleMeta: RoleMetadataDao = { - roleEntityRef: 'role:default/catalog-reader', - source: 'rest', - modifiedBy, - }; - - it('should not return an error during validation', async () => { - const err = await validateGroupingPolicy(groupPolicy, roleMeta, source); - - expect(err).toBeUndefined(); - }); - - it('should return an error if the grouping policy is too long', async () => { - groupPolicy = [ - 'user:default/test', - 'role:default/catalog-reader', - 'extra', - ]; - - const err = await validateGroupingPolicy(groupPolicy, roleMeta, source); - - expect(err).toBeTruthy(); - expect(err?.message).toEqual(`Group policy should have length 2`); - }); - - it('should return an error if a member starts with role:', async () => { - groupPolicy = ['role:default/test', 'role:default/catalog-reader']; - - const err = await validateGroupingPolicy(groupPolicy, roleMeta, source); - - expect(err).toBeTruthy(); - expect(err?.message).toEqual( - `Group policy is invalid: ${groupPolicy}. rbac-backend plugin doesn't support role inheritance.`, - ); - }); - - it('should return an error for group inheritance (user to group)', async () => { - groupPolicy = ['user:default/test', 'group:default/catalog-reader']; - - const err = await validateGroupingPolicy(groupPolicy, roleMeta, source); - - expect(err).toBeTruthy(); - expect(err?.message).toEqual( - `Group policy is invalid: ${groupPolicy}. User membership information could be provided only with help of Catalog API.`, - ); - }); - - it('should return an error for group inheritance (group to group)', async () => { - groupPolicy = ['group:default/test', 'group:default/catalog-reader']; - - const err = await validateGroupingPolicy(groupPolicy, roleMeta, source); - - expect(err).toBeTruthy(); - expect(err?.message).toEqual( - `Group policy is invalid: ${groupPolicy}. Group inheritance information could be provided only with help of Catalog API.`, - ); - }); - - it('should return an error for mismatch source', async () => { - groupPolicy = ['user:default/test', 'role:default/catalog-reader']; - source = 'csv-file'; - - const err = await validateGroupingPolicy(groupPolicy, roleMeta, source); - - expect(err).toBeTruthy(); - expect(err?.name).toEqual('NotAllowedError'); - expect(err?.message).toEqual( - `Unable to validate role ${groupPolicy}. Cause: source does not match originating role ${ - roleMeta.roleEntityRef - }, consider making changes to the '${roleMeta.source.toLocaleUpperCase()}'`, - ); - }); - }); -}); diff --git a/plugins/rbac-backend/src/validation/policies-validation.ts b/plugins/rbac-backend/src/validation/policies-validation.ts deleted file mode 100644 index 0d42a06b25..0000000000 --- a/plugins/rbac-backend/src/validation/policies-validation.ts +++ /dev/null @@ -1,305 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { CompoundEntityRef, parseEntityRef } from '@backstage/catalog-model'; -import { NotAllowedError } from '@backstage/errors'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; - -import { Enforcer } from 'casbin'; - -import { - isValidPermissionAction, - PermissionActionValues, - Role, - RoleBasedPolicy, - Source, -} from '@backstage-community/plugin-rbac-common'; - -import { RoleMetadataDao } from '../database/role-metadata'; - -/** - * validateSource validates the source to the role that is being modified. This includes comparing the source from the - * originating role to the source that the modification is coming from. - * We do this to ensure consistency between permissions and roles and where they are originally defined. - * This is a strict comparison where the source of all new roles (grouping policies) and permissions must match - * the source of the first role that was created. - * We are not strict for permission policies defined with an originating role source of configuration. - * @param source The source in which the modification is coming from - * @param roleMetadata The original role that was created - * @returns An error in the event that the source does not match the originating role - */ -export const validateSource = async ( - source: Source, - roleMetadata: RoleMetadataDao | undefined, -): Promise => { - if (!roleMetadata) { - return undefined; // Role does not exist yet, there is no conflict with the source - } - - if (roleMetadata.source !== source && roleMetadata.source !== 'legacy') { - return new Error( - `source does not match originating role ${ - roleMetadata.roleEntityRef - }, consider making changes to the '${roleMetadata.source.toLocaleUpperCase()}'`, - ); - } - - return undefined; -}; - -// This should be called on add and edit and delete -export function validatePolicy(policy: RoleBasedPolicy): Error | undefined { - const err = validateEntityReference(policy.entityReference); - if (err) { - return err; - } - - if (!policy.permission) { - return new Error(`'permission' field must not be empty`); - } - - if (!policy.policy) { - return new Error(`'policy' field must not be empty`); - } else if (!isValidPermissionAction(policy.policy)) { - return new Error( - `'policy' has invalid value: '${ - policy.policy - }'. It should be one of: ${PermissionActionValues.join(', ')}`, - ); - } - - if (!policy.effect) { - return new Error(`'effect' field must not be empty`); - } else if (!isValidEffectValue(policy.effect)) { - return new Error( - `'effect' has invalid value: '${ - policy.effect - }'. It should be: '${AuthorizeResult.ALLOW.toLocaleLowerCase()}' or '${AuthorizeResult.DENY.toLocaleLowerCase()}'`, - ); - } - - return undefined; -} - -export function validateRole(role: Role): Error | undefined { - if (!role.name) { - return new Error(`'name' field must not be empty`); - } - - let err = validateEntityReference(role.name, true); - if (err) { - return err; - } - - if (!role.memberReferences || role.memberReferences.length === 0) { - return new Error(`'memberReferences' field must not be empty`); - } - - for (const member of role.memberReferences) { - err = validateEntityReference(member); - if (err) { - return err; - } - } - - if (role.metadata && role.metadata.owner) { - err = validateEntityReference(role.metadata.owner, false, true); - if (err) { - return err; - } - } - - return undefined; -} - -function isValidEffectValue(effect: string): boolean { - return ( - effect === AuthorizeResult.ALLOW.toLocaleLowerCase() || - effect === AuthorizeResult.DENY.toLocaleLowerCase() - ); -} - -function isValidEntityName(name: string): boolean { - const validNamePattern = /^[a-zA-Z0-9]+([._-][a-zA-Z0-9]+)*$/; - return validNamePattern.test(name) && name.length <= 63; -} - -function isValidEntityNamespace(namespace: string): boolean { - const validNamespacePattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; - return validNamespacePattern.test(namespace) && namespace.length <= 63; -} - -// We supports only full form entity reference: [:][/] -export function validateEntityReference( - entityRef?: string, - role?: boolean, - owner?: boolean, -): Error | undefined { - if (!entityRef) { - return new Error(`'entityReference' must not be empty`); - } - - let entityRefCompound: CompoundEntityRef; - try { - entityRefCompound = parseEntityRef(entityRef); - } catch (err) { - return err as Error; - } - - const entityRefFull = `${entityRefCompound.kind}:${entityRefCompound.namespace}/${entityRefCompound.name}`; - if (entityRefFull !== entityRef) { - return new Error( - `entity reference '${entityRef}' does not match the required format [:][/]. Provide, please, full entity reference.`, - ); - } - - if (role && entityRefCompound.kind !== 'role') { - return new Error( - `Unsupported kind ${entityRefCompound.kind}. Supported value should be "role"`, - ); - } - - if ( - owner && - entityRefCompound.kind !== 'user' && - entityRefCompound.kind !== 'group' - ) { - return new Error( - `Unsupported kind ${entityRefCompound.kind}. List supported values ["user", "group"]`, - ); - } - - if ( - entityRefCompound.kind !== 'user' && - entityRefCompound.kind !== 'group' && - entityRefCompound.kind !== 'role' - ) { - return new Error( - `Unsupported kind ${entityRefCompound.kind}. List supported values ["user", "group", "role"]`, - ); - } - - if (!isValidEntityName(entityRefCompound.name)) { - return new Error( - `The name '${entityRefCompound.name}' in the entity reference must be a string that is sequences of [a-zA-Z0-9] separated by any of [-_.], at most 63 characters in total`, - ); - } - - if (!isValidEntityNamespace(entityRefCompound.namespace)) { - return new Error( - `The namespace '${entityRefCompound.namespace}' in the entity reference must be a string that is sequences of [a-z0-9] separated by [-], at most 63 characters in total`, - ); - } - - return undefined; -} - -export async function validateGroupingPolicy( - groupPolicy: string[], - metadata: RoleMetadataDao | undefined, - source: Source, -): Promise { - if (groupPolicy.length !== 2) { - return new Error(`Group policy should have length 2`); - } - - const member = groupPolicy[0]; - let err = validateEntityReference(member); - if (err) { - return new Error( - `Failed to validate group policy ${groupPolicy}. Cause: ${err.message}`, - ); - } - const parent = groupPolicy[1]; - err = validateEntityReference(parent); - if (err) { - return new Error( - `Failed to validate group policy ${groupPolicy}. Cause: ${err.message}`, - ); - } - if (member.startsWith(`role:`)) { - return new Error( - `Group policy is invalid: ${groupPolicy}. rbac-backend plugin doesn't support role inheritance.`, - ); - } - if (member.startsWith(`group:`) && parent.startsWith(`group:`)) { - return new Error( - `Group policy is invalid: ${groupPolicy}. Group inheritance information could be provided only with help of Catalog API.`, - ); - } - if (member.startsWith(`user:`) && parent.startsWith(`group:`)) { - return new Error( - `Group policy is invalid: ${groupPolicy}. User membership information could be provided only with help of Catalog API.`, - ); - } - - err = await validateSource(source, metadata); - if (metadata && err) { - return new NotAllowedError( - `Unable to validate role ${groupPolicy}. Cause: ${err.message}`, - ); - } - - return undefined; -} - -export const checkForDuplicatePolicies = async ( - fileEnf: Enforcer, - policy: string[], - policyFile: string, -): Promise => { - const duplicates = await fileEnf.getFilteredPolicy(0, ...policy); - if (duplicates.length > 1) { - return new Error( - `Duplicate policy: ${policy} found in the file ${policyFile}`, - ); - } - - const flipPolicyEffect = [ - policy[0], - policy[1], - policy[2], - policy[3] === 'deny' ? 'allow' : 'deny', - ]; - - // Check if the same policy exists but with a different effect - const dupWithDifferentEffect = await fileEnf.getFilteredPolicy( - 0, - ...flipPolicyEffect, - ); - - if (dupWithDifferentEffect.length > 0) { - return new Error( - `Duplicate policy: ${policy[0]}, ${policy[1]}, ${policy[2]} with different effect found in the file ${policyFile}`, - ); - } - - return undefined; -}; - -export const checkForDuplicateGroupPolicies = async ( - fileEnf: Enforcer, - policy: string[], - policyFile: string, -): Promise => { - const duplicates = await fileEnf.getFilteredGroupingPolicy(0, ...policy); - - if (duplicates.length > 1) { - return new Error( - `Duplicate role: ${policy} found in the file ${policyFile}`, - ); - } - return undefined; -}; diff --git a/plugins/rbac-backend/tsconfig.json b/plugins/rbac-backend/tsconfig.json deleted file mode 100644 index 0f5dc138b2..0000000000 --- a/plugins/rbac-backend/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "@backstage/cli/config/tsconfig.json", - "include": ["src", "migrations", "config.d.ts"], - "exclude": ["node_modules", "**/*.test.ts", "__fixtures__"], - "compilerOptions": { - "outDir": "../../dist-types/plugins/rbac-backend", - "rootDir": ".", - "useUnknownInCatchVariables": false, - "skipLibCheck": true, - "noCheck": true - } -} diff --git a/plugins/rbac-backend/turbo.json b/plugins/rbac-backend/turbo.json deleted file mode 100644 index 9fe704e3fc..0000000000 --- a/plugins/rbac-backend/turbo.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": ["//"], - "tasks": { - "tsc": { - "outputs": ["../../dist-types/plugins/rbac-backend/**"] - } - } -} diff --git a/tsconfig.json b/tsconfig.json index 0502d5b032..0b1027fc1a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,6 @@ "outDir": "dist-types", "rootDir": ".", "skipLibCheck": true, - "jsx": "preserve", - "useUnknownInCatchVariables": false + "jsx": "preserve" } } diff --git a/yarn.lock b/yarn.lock index eea887ff22..c7a47284b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1527,7 +1527,18 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.15, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.26.2, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.26.2, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/parser@npm:7.29.0" + dependencies: + "@babel/types": "npm:^7.29.0" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/333b2aa761264b91577a74bee86141ef733f9f9f6d4fc52548e4847dc35dfbf821f58c46832c637bfa761a6d9909d6a68f7d1ed59e17e4ffbb958dc510c17b62 + languageName: node + linkType: hard + +"@babel/parser@npm:^7.20.15": version: 7.29.2 resolution: "@babel/parser@npm:7.29.2" dependencies: @@ -2956,7 +2967,7 @@ __metadata: languageName: node linkType: hard -"@backstage/catalog-client@npm:1.14.0": +"@backstage/catalog-client@npm:1.14.0, @backstage/catalog-client@npm:^1.14.0": version: 1.14.0 resolution: "@backstage/catalog-client@npm:1.14.0" dependencies: @@ -2970,7 +2981,7 @@ __metadata: languageName: node linkType: hard -"@backstage/catalog-client@npm:^1.14.0, @backstage/catalog-client@npm:^1.15.0": +"@backstage/catalog-client@npm:^1.15.0": version: 1.15.0 resolution: "@backstage/catalog-client@npm:1.15.0" dependencies: @@ -2984,7 +2995,7 @@ __metadata: languageName: node linkType: hard -"@backstage/catalog-model@npm:1.7.7": +"@backstage/catalog-model@npm:1.7.7, @backstage/catalog-model@npm:^1.7.6, @backstage/catalog-model@npm:^1.7.7": version: 1.7.7 resolution: "@backstage/catalog-model@npm:1.7.7" dependencies: @@ -2996,7 +3007,7 @@ __metadata: languageName: node linkType: hard -"@backstage/catalog-model@npm:^1.7.6, @backstage/catalog-model@npm:^1.7.7, @backstage/catalog-model@npm:^1.8.0": +"@backstage/catalog-model@npm:^1.8.0": version: 1.8.0 resolution: "@backstage/catalog-model@npm:1.8.0" dependencies: @@ -3473,7 +3484,7 @@ __metadata: languageName: node linkType: hard -"@backstage/config@npm:1.3.6": +"@backstage/config@npm:1.3.6, @backstage/config@npm:^1.3.2, @backstage/config@npm:^1.3.6": version: 1.3.6 resolution: "@backstage/config@npm:1.3.6" dependencies: @@ -3484,7 +3495,7 @@ __metadata: languageName: node linkType: hard -"@backstage/config@npm:^1.3.2, @backstage/config@npm:^1.3.6, @backstage/config@npm:^1.3.7": +"@backstage/config@npm:^1.3.7": version: 1.3.7 resolution: "@backstage/config@npm:1.3.7" dependencies: @@ -3495,7 +3506,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-app-api@npm:1.19.6": +"@backstage/core-app-api@npm:1.19.6, @backstage/core-app-api@npm:^1.19.6": version: 1.19.6 resolution: "@backstage/core-app-api@npm:1.19.6" dependencies: @@ -3524,7 +3535,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-app-api@npm:^1.19.6, @backstage/core-app-api@npm:^1.20.0": +"@backstage/core-app-api@npm:^1.20.0": version: 1.20.0 resolution: "@backstage/core-app-api@npm:1.20.0" dependencies: @@ -3553,7 +3564,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-compat-api@npm:0.5.9": +"@backstage/core-compat-api@npm:0.5.9, @backstage/core-compat-api@npm:^0.5.9": version: 0.5.9 resolution: "@backstage/core-compat-api@npm:0.5.9" dependencies: @@ -3579,7 +3590,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-compat-api@npm:^0.5.10, @backstage/core-compat-api@npm:^0.5.9": +"@backstage/core-compat-api@npm:^0.5.10": version: 0.5.10 resolution: "@backstage/core-compat-api@npm:0.5.10" dependencies: @@ -3605,7 +3616,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-components@npm:0.18.8": +"@backstage/core-components@npm:0.18.8, @backstage/core-components@npm:^0.18.8": version: 0.18.8 resolution: "@backstage/core-components@npm:0.18.8" dependencies: @@ -3663,7 +3674,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-components@npm:^0.18.8, @backstage/core-components@npm:^0.18.9": +"@backstage/core-components@npm:^0.18.9": version: 0.18.9 resolution: "@backstage/core-components@npm:0.18.9" dependencies: @@ -3721,7 +3732,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-plugin-api@npm:1.12.4": +"@backstage/core-plugin-api@npm:1.12.4, @backstage/core-plugin-api@npm:^1.12.4": version: 1.12.4 resolution: "@backstage/core-plugin-api@npm:1.12.4" dependencies: @@ -3744,7 +3755,7 @@ __metadata: languageName: node linkType: hard -"@backstage/core-plugin-api@npm:^1.12.4, @backstage/core-plugin-api@npm:^1.12.5": +"@backstage/core-plugin-api@npm:^1.12.5": version: 1.12.5 resolution: "@backstage/core-plugin-api@npm:1.12.5" dependencies: @@ -3767,7 +3778,7 @@ __metadata: languageName: node linkType: hard -"@backstage/errors@npm:1.2.7": +"@backstage/errors@npm:1.2.7, @backstage/errors@npm:^1.2.7": version: 1.2.7 resolution: "@backstage/errors@npm:1.2.7" dependencies: @@ -3777,7 +3788,7 @@ __metadata: languageName: node linkType: hard -"@backstage/errors@npm:^1.2.7, @backstage/errors@npm:^1.3.0": +"@backstage/errors@npm:^1.3.0": version: 1.3.0 resolution: "@backstage/errors@npm:1.3.0" dependencies: @@ -3797,7 +3808,20 @@ __metadata: languageName: node linkType: hard -"@backstage/filter-predicates@npm:^0.1.1, @backstage/filter-predicates@npm:^0.1.2": +"@backstage/filter-predicates@npm:^0.1.1": + version: 0.1.1 + resolution: "@backstage/filter-predicates@npm:0.1.1" + dependencies: + "@backstage/config": "npm:^1.3.6" + "@backstage/errors": "npm:^1.2.7" + "@backstage/types": "npm:^1.2.2" + zod: "npm:^3.25.76 || ^4.0.0" + zod-validation-error: "npm:^4.0.2" + checksum: 10c0/f4bce2259af0e953ef30d292394aeea614ae42fbd825678a3abc36a97a6020a9964f40046aa3dc189f040ecf9674fb30dbcbbfd2ff3f807a513ad0353094bea5 + languageName: node + linkType: hard + +"@backstage/filter-predicates@npm:^0.1.2": version: 0.1.2 resolution: "@backstage/filter-predicates@npm:0.1.2" dependencies: @@ -3980,7 +4004,7 @@ __metadata: languageName: node linkType: hard -"@backstage/integration-react@npm:1.2.16": +"@backstage/integration-react@npm:1.2.16, @backstage/integration-react@npm:^1.2.16": version: 1.2.16 resolution: "@backstage/integration-react@npm:1.2.16" dependencies: @@ -4001,7 +4025,7 @@ __metadata: languageName: node linkType: hard -"@backstage/integration-react@npm:^1.2.16, @backstage/integration-react@npm:^1.2.17": +"@backstage/integration-react@npm:^1.2.17": version: 1.2.17 resolution: "@backstage/integration-react@npm:1.2.17" dependencies: @@ -4022,7 +4046,26 @@ __metadata: languageName: node linkType: hard -"@backstage/integration@npm:^2.0.0, @backstage/integration@npm:^2.0.1": +"@backstage/integration@npm:^2.0.0": + version: 2.0.0 + resolution: "@backstage/integration@npm:2.0.0" + dependencies: + "@azure/identity": "npm:^4.0.0" + "@azure/storage-blob": "npm:^12.5.0" + "@backstage/config": "npm:^1.3.6" + "@backstage/errors": "npm:^1.2.7" + "@octokit/auth-app": "npm:^4.0.0" + "@octokit/rest": "npm:^19.0.3" + cross-fetch: "npm:^4.0.0" + git-url-parse: "npm:^15.0.0" + lodash: "npm:^4.17.21" + luxon: "npm:^3.0.0" + p-throttle: "npm:^4.1.1" + checksum: 10c0/d7e0e45cc11277ca2b843f98d15df8150b8c264852b734279a1965ccc81ef2724871e048aa1b0c4b3fe656c041d96ab0ca8c97db2bd236582d8f4349a93cd5cd + languageName: node + linkType: hard + +"@backstage/integration@npm:^2.0.1": version: 2.0.1 resolution: "@backstage/integration@npm:2.0.1" dependencies: @@ -4138,7 +4181,26 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-app-react@npm:^0.2.1, @backstage/plugin-app-react@npm:^0.2.2": +"@backstage/plugin-app-react@npm:^0.2.1": + version: 0.2.1 + resolution: "@backstage/plugin-app-react@npm:0.2.1" + dependencies: + "@backstage/core-plugin-api": "npm:^1.12.4" + "@backstage/frontend-plugin-api": "npm:^0.15.0" + "@material-ui/core": "npm:^4.9.13" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + react-router-dom: ^6.30.2 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/e343bc8b67105bd1484824c66628005e8bfc8f5815960a7fd894ddb7ca3c14915c778095c549591f78cbe9a8f3acd4cfed565b5ffc569a2d7f07555ba5ab1886 + languageName: node + linkType: hard + +"@backstage/plugin-app-react@npm:^0.2.2": version: 0.2.2 resolution: "@backstage/plugin-app-react@npm:0.2.2" dependencies: @@ -4601,7 +4663,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-catalog-common@npm:1.1.8": +"@backstage/plugin-catalog-common@npm:1.1.8, @backstage/plugin-catalog-common@npm:^1.1.8": version: 1.1.8 resolution: "@backstage/plugin-catalog-common@npm:1.1.8" dependencies: @@ -4612,7 +4674,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-catalog-common@npm:^1.1.8, @backstage/plugin-catalog-common@npm:^1.1.9": +"@backstage/plugin-catalog-common@npm:^1.1.9": version: 1.1.9 resolution: "@backstage/plugin-catalog-common@npm:1.1.9" dependencies: @@ -4718,7 +4780,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-catalog-react@npm:2.1.1": +"@backstage/plugin-catalog-react@npm:2.1.1, @backstage/plugin-catalog-react@npm:^2.1.0": version: 2.1.1 resolution: "@backstage/plugin-catalog-react@npm:2.1.1" dependencies: @@ -4763,7 +4825,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-catalog-react@npm:^2.1.0, @backstage/plugin-catalog-react@npm:^2.1.2": +"@backstage/plugin-catalog-react@npm:^2.1.2": version: 2.1.4 resolution: "@backstage/plugin-catalog-react@npm:2.1.4" dependencies: @@ -5046,7 +5108,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-permission-common@npm:0.9.7": +"@backstage/plugin-permission-common@npm:0.9.7, @backstage/plugin-permission-common@npm:^0.9.6, @backstage/plugin-permission-common@npm:^0.9.7": version: 0.9.7 resolution: "@backstage/plugin-permission-common@npm:0.9.7" dependencies: @@ -5061,7 +5123,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-permission-common@npm:^0.9.6, @backstage/plugin-permission-common@npm:^0.9.7, @backstage/plugin-permission-common@npm:^0.9.8": +"@backstage/plugin-permission-common@npm:^0.9.8": version: 0.9.8 resolution: "@backstage/plugin-permission-common@npm:0.9.8" dependencies: @@ -5076,7 +5138,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-permission-node@npm:0.10.11, @backstage/plugin-permission-node@npm:^0.10.11": +"@backstage/plugin-permission-node@npm:^0.10.11": version: 0.10.11 resolution: "@backstage/plugin-permission-node@npm:0.10.11" dependencies: @@ -5448,7 +5510,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-search-common@npm:1.2.22": +"@backstage/plugin-search-common@npm:1.2.22, @backstage/plugin-search-common@npm:^1.2.22": version: 1.2.22 resolution: "@backstage/plugin-search-common@npm:1.2.22" dependencies: @@ -5458,7 +5520,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-search-common@npm:^1.2.22, @backstage/plugin-search-common@npm:^1.2.23": +"@backstage/plugin-search-common@npm:^1.2.23": version: 1.2.23 resolution: "@backstage/plugin-search-common@npm:1.2.23" dependencies: @@ -5754,7 +5816,7 @@ __metadata: languageName: node linkType: hard -"@backstage/theme@npm:0.7.2": +"@backstage/theme@npm:0.7.2, @backstage/theme@npm:^0.7.2": version: 0.7.2 resolution: "@backstage/theme@npm:0.7.2" dependencies: @@ -5774,7 +5836,7 @@ __metadata: languageName: node linkType: hard -"@backstage/theme@npm:^0.7.2, @backstage/theme@npm:^0.7.3": +"@backstage/theme@npm:^0.7.3": version: 0.7.3 resolution: "@backstage/theme@npm:0.7.3" dependencies: @@ -5894,15 +5956,6 @@ __metadata: languageName: node linkType: hard -"@casbin/expression-eval@npm:^5.3.0": - version: 5.3.0 - resolution: "@casbin/expression-eval@npm:5.3.0" - dependencies: - jsep: "npm:^0.3.0" - checksum: 10c0/1fa2fd703036b065821fbeb8d0f0c274ba50331737d19b3a77b7c9cd571f5df2580145bda1d90f2dd46863a66aae9f5256974eb168b7ccbb9facbcb796f5cb7a - languageName: node - linkType: hard - "@changesets/types@npm:^4.0.1": version: 4.1.0 resolution: "@changesets/types@npm:4.1.0" @@ -6108,13 +6161,6 @@ __metadata: languageName: node linkType: hard -"@dagrejs/graphlib@npm:^4.0.0": - version: 4.0.1 - resolution: "@dagrejs/graphlib@npm:4.0.1" - checksum: 10c0/03ab574f2eb7d87173af0b9d8bbae87c10e225778b8144a800c663afe307ff71d851c13d96d32ec91db85e325d64914cdabbab1ce76fb043e0a5538e60bb51bd - languageName: node - linkType: hard - "@date-io/core@npm:1.x, @date-io/core@npm:^1.3.13": version: 1.3.13 resolution: "@date-io/core@npm:1.3.13" @@ -7375,48 +7421,6 @@ __metadata: languageName: unknown linkType: soft -"@internal/plugin-rbac-backend@npm:*, @internal/plugin-rbac-backend@workspace:plugins/rbac-backend": - version: 0.0.0-use.local - resolution: "@internal/plugin-rbac-backend@workspace:plugins/rbac-backend" - dependencies: - "@azure/identity": "npm:^4.0.0" - "@backstage-community/plugin-rbac-common": "npm:1.26.1" - "@backstage-community/plugin-rbac-node": "npm:1.20.1" - "@backstage/backend-defaults": "npm:0.16.0" - "@backstage/backend-plugin-api": "npm:1.8.0" - "@backstage/backend-test-utils": "npm:1.11.1" - "@backstage/catalog-client": "npm:1.14.0" - "@backstage/catalog-model": "npm:1.7.7" - "@backstage/cli": "npm:0.36.0" - "@backstage/config": "npm:1.3.6" - "@backstage/core-plugin-api": "npm:1.12.4" - "@backstage/errors": "npm:^1.2.7" - "@backstage/plugin-catalog-node": "npm:2.1.0" - "@backstage/plugin-permission-common": "npm:0.9.7" - "@backstage/plugin-permission-node": "npm:0.10.11" - "@backstage/types": "npm:^1.2.2" - "@dagrejs/graphlib": "npm:^4.0.0" - "@types/express": "npm:4.17.25" - "@types/js-yaml": "npm:^4.0.9" - "@types/lodash": "npm:^4.14.151" - "@types/node": "npm:22.19.17" - "@types/supertest": "npm:7.2.0" - casbin: "npm:5.27.1" - chokidar: "npm:^3.6.0" - csv-parse: "npm:^6.0.0" - express: "npm:^4.18.2" - express-promise-router: "npm:^4.1.0" - js-yaml: "npm:^4.1.0" - knex: "npm:^3.0.0" - knex-mock-client: "npm:3.0.2" - lodash: "npm:^4.17.21" - qs: "npm:6.15.1" - supertest: "npm:7.2.2" - typeorm-adapter: "npm:^1.6.1" - zod: "npm:^4.3.6" - languageName: unknown - linkType: soft - "@internal/plugin-scalprum-backend@npm:*, @internal/plugin-scalprum-backend@workspace:plugins/scalprum-backend": version: 0.0.0-use.local resolution: "@internal/plugin-scalprum-backend@workspace:plugins/scalprum-backend" @@ -7442,7 +7446,16 @@ __metadata: languageName: unknown linkType: soft -"@internationalized/date@npm:^3.12.0, @internationalized/date@npm:^3.12.1": +"@internationalized/date@npm:^3.11.0, @internationalized/date@npm:^3.12.0": + version: 3.12.0 + resolution: "@internationalized/date@npm:3.12.0" + dependencies: + "@swc/helpers": "npm:^0.5.0" + checksum: 10c0/6a26495d32f010b227a1f506da02cdf8438506014b41cfb81576c707a3dfe3d0fd207f80bcf28acd9eef8248a2c2da115cf9016515d513653ea1b22a796d0246 + languageName: node + linkType: hard + +"@internationalized/date@npm:^3.12.1": version: 3.12.1 resolution: "@internationalized/date@npm:3.12.1" dependencies: @@ -7461,7 +7474,16 @@ __metadata: languageName: node linkType: hard -"@internationalized/number@npm:^3.6.5, @internationalized/number@npm:^3.6.6": +"@internationalized/number@npm:^3.6.5": + version: 3.6.5 + resolution: "@internationalized/number@npm:3.6.5" + dependencies: + "@swc/helpers": "npm:^0.5.0" + checksum: 10c0/f87d710863a8dbf057aac311193c82f3c42e862abdd99e5b71034f1022926036552620eab5dd00c23e975f28b9e41e830cb342ba0264436749d9cdc5ae031d44 + languageName: node + linkType: hard + +"@internationalized/number@npm:^3.6.6": version: 3.6.6 resolution: "@internationalized/number@npm:3.6.6" dependencies: @@ -7470,7 +7492,16 @@ __metadata: languageName: node linkType: hard -"@internationalized/string@npm:^3.2.7, @internationalized/string@npm:^3.2.8": +"@internationalized/string@npm:^3.2.7": + version: 3.2.7 + resolution: "@internationalized/string@npm:3.2.7" + dependencies: + "@swc/helpers": "npm:^0.5.0" + checksum: 10c0/8f7bea379ce047026ef20d535aa1bd7612a5e5a5108d1e514965696a46bce34e38111411943b688d00dae2c81eae7779ae18343961310696d32ebb463a19b94a + languageName: node + linkType: hard + +"@internationalized/string@npm:^3.2.8": version: 3.2.8 resolution: "@internationalized/string@npm:3.2.8" dependencies: @@ -9976,6 +10007,17 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/core@npm:2.5.1": + version: 2.5.1 + resolution: "@opentelemetry/core@npm:2.5.1" + dependencies: + "@opentelemetry/semantic-conventions": "npm:^1.29.0" + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: 10c0/cbaf36953364d1295ef2ff4587c3f99eca121c7c2dbd2553699100ccbd91017f20fb1a710ac76fad832d9762dc98ae009ce0e96ab8fb00e5b539dc401d57f217 + languageName: node + linkType: hard + "@opentelemetry/core@npm:2.7.1, @opentelemetry/core@npm:^2.0.0": version: 2.7.1 resolution: "@opentelemetry/core@npm:2.7.1" @@ -10843,7 +10885,7 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/resources@npm:2.7.1, @opentelemetry/resources@npm:^2.0.0": +"@opentelemetry/resources@npm:2.7.1": version: 2.7.1 resolution: "@opentelemetry/resources@npm:2.7.1" dependencies: @@ -10855,6 +10897,18 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/resources@npm:^2.0.0": + version: 2.5.1 + resolution: "@opentelemetry/resources@npm:2.5.1" + dependencies: + "@opentelemetry/core": "npm:2.5.1" + "@opentelemetry/semantic-conventions": "npm:^1.29.0" + peerDependencies: + "@opentelemetry/api": ">=1.3.0 <1.10.0" + checksum: 10c0/c336d5066fa7457272bcffb5a9826f090e1e07c2a70c5976942cf2bb188be685842658982a0f323ddfc1d6fbc364f123b6b0e433e230b023aefd88ec60062ba4 + languageName: node + linkType: hard + "@opentelemetry/sdk-logs@npm:0.218.0": version: 0.218.0 resolution: "@opentelemetry/sdk-logs@npm:0.218.0" @@ -10942,13 +10996,20 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/semantic-conventions@npm:^1.24.0, @opentelemetry/semantic-conventions@npm:^1.27.0, @opentelemetry/semantic-conventions@npm:^1.29.0, @opentelemetry/semantic-conventions@npm:^1.30.0, @opentelemetry/semantic-conventions@npm:^1.33.0, @opentelemetry/semantic-conventions@npm:^1.33.1, @opentelemetry/semantic-conventions@npm:^1.34.0, @opentelemetry/semantic-conventions@npm:^1.36.0, @opentelemetry/semantic-conventions@npm:^1.37.0": +"@opentelemetry/semantic-conventions@npm:^1.24.0, @opentelemetry/semantic-conventions@npm:^1.33.0, @opentelemetry/semantic-conventions@npm:^1.33.1, @opentelemetry/semantic-conventions@npm:^1.34.0, @opentelemetry/semantic-conventions@npm:^1.36.0, @opentelemetry/semantic-conventions@npm:^1.37.0": version: 1.41.1 resolution: "@opentelemetry/semantic-conventions@npm:1.41.1" checksum: 10c0/c54b1edf845766e93026d30fd95e15da9dba8d7a5b58f8c320c5d36ab542c77b37868f3e8e3d78ec162da8ee2afd24781f0a65934c9bdbc1aea86b47b12f074c languageName: node linkType: hard +"@opentelemetry/semantic-conventions@npm:^1.27.0, @opentelemetry/semantic-conventions@npm:^1.29.0, @opentelemetry/semantic-conventions@npm:^1.30.0": + version: 1.40.0 + resolution: "@opentelemetry/semantic-conventions@npm:1.40.0" + checksum: 10c0/3259de0ea11b52eb70e44c12eba21448392baf9cb74c37b62071c4a5ed7fb89b61e194f3898d40ac6bfa7293617a0e132876cb6e355472b66de0cdb13c50b529 + languageName: node + linkType: hard + "@opentelemetry/sql-common@npm:^0.41.2": version: 0.41.2 resolution: "@opentelemetry/sql-common@npm:0.41.2" @@ -11900,6 +11961,48 @@ __metadata: languageName: node linkType: hard +"@react-aria/autocomplete@npm:3.0.0-rc.5": + version: 3.0.0-rc.5 + resolution: "@react-aria/autocomplete@npm:3.0.0-rc.5" + dependencies: + "@react-aria/combobox": "npm:^3.14.2" + "@react-aria/focus": "npm:^3.21.4" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/listbox": "npm:^3.15.2" + "@react-aria/searchfield": "npm:^3.8.11" + "@react-aria/textfield": "npm:^3.18.4" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/autocomplete": "npm:3.0.0-beta.4" + "@react-stately/combobox": "npm:^3.12.2" + "@react-types/autocomplete": "npm:3.0.0-alpha.37" + "@react-types/button": "npm:^3.15.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/9ae82bc6e271dac7ae1d7612900520ab3e14215f6af1af752a6564dbf7bed90386ff4080c0ff67e840234f155e85a5e189c6e24c92275980eb24a3d880c84def + languageName: node + linkType: hard + +"@react-aria/breadcrumbs@npm:^3.5.31": + version: 3.5.31 + resolution: "@react-aria/breadcrumbs@npm:3.5.31" + dependencies: + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/link": "npm:^3.8.8" + "@react-aria/utils": "npm:^3.33.0" + "@react-types/breadcrumbs": "npm:^3.7.18" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/b28dc7b5def4a742f652cbf5237c91cd15fa5ddf6e90517ad4a55f5f7b78a8f940df0a10033be19413ad29cb31a6e1b8338a7572c9712e3814726fe7e3df0fb0 + languageName: node + linkType: hard + "@react-aria/button@npm:^3.14.3": version: 3.14.5 resolution: "@react-aria/button@npm:3.14.5" @@ -11918,7 +12021,220 @@ __metadata: languageName: node linkType: hard -"@react-aria/focus@npm:^3.21.5": +"@react-aria/button@npm:^3.14.4": + version: 3.14.4 + resolution: "@react-aria/button@npm:3.14.4" + dependencies: + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/toolbar": "npm:3.0.0-beta.23" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/toggle": "npm:^3.9.4" + "@react-types/button": "npm:^3.15.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/99b640d9d50478c36c57eb0be05ad6dc86f9ac0f80501c5d73c1ae316d958ed07bfb4ec8b61fd31093fb6b62491df5422e3bff229eb86be1e43dcda7cdd41350 + languageName: node + linkType: hard + +"@react-aria/calendar@npm:^3.9.4": + version: 3.9.4 + resolution: "@react-aria/calendar@npm:3.9.4" + dependencies: + "@internationalized/date": "npm:^3.11.0" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/live-announcer": "npm:^3.4.4" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/calendar": "npm:^3.9.2" + "@react-types/button": "npm:^3.15.0" + "@react-types/calendar": "npm:^3.8.2" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/f016f4908b06d943fff28794d4ba07f70f8da906be0f24d5f9752e705eccb19d4195d25e9ac09206cdd9ab9616c430b617f9753d0383b04c082206e8fcc67ebf + languageName: node + linkType: hard + +"@react-aria/checkbox@npm:^3.16.4": + version: 3.16.4 + resolution: "@react-aria/checkbox@npm:3.16.4" + dependencies: + "@react-aria/form": "npm:^3.1.4" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/label": "npm:^3.7.24" + "@react-aria/toggle": "npm:^3.12.4" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/checkbox": "npm:^3.7.4" + "@react-stately/form": "npm:^3.2.3" + "@react-stately/toggle": "npm:^3.9.4" + "@react-types/checkbox": "npm:^3.10.3" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/8285efd0d790a0f08c97998f167e5c44af1ed87b6b9606a0a3769df1c881dcea45339b2ce48b84ec657fdc82e11710be2acaf5e7588eadff9ba3a6c4321b615e + languageName: node + linkType: hard + +"@react-aria/collections@npm:^3.0.2": + version: 3.0.2 + resolution: "@react-aria/collections@npm:3.0.2" + dependencies: + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/ssr": "npm:^3.9.10" + "@react-aria/utils": "npm:^3.33.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + use-sync-external-store: "npm:^1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/2476975c72c8804b5f588cf58677ce9f05255651d14f938540049e49eee6e9411b1a791ddd6ba5571bcff19ada709089f1b78724ad7a2c291578ea9632c6841a + languageName: node + linkType: hard + +"@react-aria/color@npm:^3.1.4": + version: 3.1.4 + resolution: "@react-aria/color@npm:3.1.4" + dependencies: + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/numberfield": "npm:^3.12.4" + "@react-aria/slider": "npm:^3.8.4" + "@react-aria/spinbutton": "npm:^3.7.1" + "@react-aria/textfield": "npm:^3.18.4" + "@react-aria/utils": "npm:^3.33.0" + "@react-aria/visually-hidden": "npm:^3.8.30" + "@react-stately/color": "npm:^3.9.4" + "@react-stately/form": "npm:^3.2.3" + "@react-types/color": "npm:^3.1.3" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/7081040b2e508fd1260667bcf3c4ca2bf90e5b014dae9b32947feab0fcee3c95cdfeb3afcd0b1f8e772cf9a8cfd9669c0f5ace8c25341d086c8cff8f8a81efaa + languageName: node + linkType: hard + +"@react-aria/combobox@npm:^3.14.2": + version: 3.14.2 + resolution: "@react-aria/combobox@npm:3.14.2" + dependencies: + "@react-aria/focus": "npm:^3.21.4" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/listbox": "npm:^3.15.2" + "@react-aria/live-announcer": "npm:^3.4.4" + "@react-aria/menu": "npm:^3.20.0" + "@react-aria/overlays": "npm:^3.31.1" + "@react-aria/selection": "npm:^3.27.1" + "@react-aria/textfield": "npm:^3.18.4" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/collections": "npm:^3.12.9" + "@react-stately/combobox": "npm:^3.12.2" + "@react-stately/form": "npm:^3.2.3" + "@react-types/button": "npm:^3.15.0" + "@react-types/combobox": "npm:^3.13.11" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/83251e2be09aa6017f140071bf3763a26be95aa91966cb573da804d804b68f36762338ce08f085e31fa3266db49488cd289c19b28e19faf13c294795db54007b + languageName: node + linkType: hard + +"@react-aria/datepicker@npm:^3.16.0": + version: 3.16.0 + resolution: "@react-aria/datepicker@npm:3.16.0" + dependencies: + "@internationalized/date": "npm:^3.11.0" + "@internationalized/number": "npm:^3.6.5" + "@internationalized/string": "npm:^3.2.7" + "@react-aria/focus": "npm:^3.21.4" + "@react-aria/form": "npm:^3.1.4" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/label": "npm:^3.7.24" + "@react-aria/spinbutton": "npm:^3.7.1" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/datepicker": "npm:^3.16.0" + "@react-stately/form": "npm:^3.2.3" + "@react-types/button": "npm:^3.15.0" + "@react-types/calendar": "npm:^3.8.2" + "@react-types/datepicker": "npm:^3.13.4" + "@react-types/dialog": "npm:^3.5.23" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/e100649593ae417fe8567781969eaecd7df016da1988747b4170a92993e327a08ed0cfef4b07a6cc523bb31d9484ddde0aeaa22a0e93d48cd0a3a3f4fbd166f3 + languageName: node + linkType: hard + +"@react-aria/dialog@npm:^3.5.33": + version: 3.5.33 + resolution: "@react-aria/dialog@npm:3.5.33" + dependencies: + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/overlays": "npm:^3.31.1" + "@react-aria/utils": "npm:^3.33.0" + "@react-types/dialog": "npm:^3.5.23" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/8ccaee23239942d010c37c4e8e5a8a75163ea6ece8060e8f581148c2df99d46b4a59585c1d9f1a6a9f3ee29e91788bd43c900ad1d2f65c6da505e1b504e6ea46 + languageName: node + linkType: hard + +"@react-aria/disclosure@npm:^3.1.2": + version: 3.1.2 + resolution: "@react-aria/disclosure@npm:3.1.2" + dependencies: + "@react-aria/ssr": "npm:^3.9.10" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/disclosure": "npm:^3.0.10" + "@react-types/button": "npm:^3.15.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/dd8f01fe5b27e571a9c79edf58f815f7c86cac58e0a07de079ff100f4459d594dca150935fe142130eca6a5822ff6ccead93b10bc215e6aaeb6c697fa1e8f994 + languageName: node + linkType: hard + +"@react-aria/dnd@npm:^3.11.5": + version: 3.11.5 + resolution: "@react-aria/dnd@npm:3.11.5" + dependencies: + "@internationalized/string": "npm:^3.2.7" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/live-announcer": "npm:^3.4.4" + "@react-aria/overlays": "npm:^3.31.1" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/collections": "npm:^3.12.9" + "@react-stately/dnd": "npm:^3.7.3" + "@react-types/button": "npm:^3.15.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/f1a2b5ca3702950e54271bbfa4ea163a23cdfe6127e45421a0100037e575d516900fef9753533afdc109d4d010def29d23b955a0b4f1d2d62f93aa3bdc85b134 + languageName: node + linkType: hard + +"@react-aria/focus@npm:^3.21.4, @react-aria/focus@npm:^3.21.5": version: 3.21.5 resolution: "@react-aria/focus@npm:3.21.5" dependencies: @@ -11934,7 +12250,68 @@ __metadata: languageName: node linkType: hard -"@react-aria/i18n@npm:^3.12.16": +"@react-aria/form@npm:^3.1.4": + version: 3.1.4 + resolution: "@react-aria/form@npm:3.1.4" + dependencies: + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/form": "npm:^3.2.3" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/d1fe8bf88f6cd5d1a3a065ea2c753809627f4db1395fc5acf0c3e213b060a5e823a7453bb966b4d71da9455f688150f4f5c5813fa306a34bfaed7d1e49794c79 + languageName: node + linkType: hard + +"@react-aria/grid@npm:^3.14.7": + version: 3.14.7 + resolution: "@react-aria/grid@npm:3.14.7" + dependencies: + "@react-aria/focus": "npm:^3.21.4" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/live-announcer": "npm:^3.4.4" + "@react-aria/selection": "npm:^3.27.1" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/collections": "npm:^3.12.9" + "@react-stately/grid": "npm:^3.11.8" + "@react-stately/selection": "npm:^3.20.8" + "@react-types/checkbox": "npm:^3.10.3" + "@react-types/grid": "npm:^3.3.7" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/a5668d8cd0d8b90e32a0af1b887a5e4859e6240ad895215d904a553e88607d7950c24ed55f0a75e6a189afe26f264be3e4a6094a54d5735fc3c0765067050d46 + languageName: node + linkType: hard + +"@react-aria/gridlist@npm:^3.14.3": + version: 3.14.3 + resolution: "@react-aria/gridlist@npm:3.14.3" + dependencies: + "@react-aria/focus": "npm:^3.21.4" + "@react-aria/grid": "npm:^3.14.7" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/selection": "npm:^3.27.1" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/list": "npm:^3.13.3" + "@react-stately/tree": "npm:^3.9.5" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/e5f03bd92411053801a6739225716548f1f5bacef2247a112ec913d4de42f0847ab310dd32479d366caa929fd0d6858080c19714925a0814c52dae947937e43a + languageName: node + linkType: hard + +"@react-aria/i18n@npm:^3.12.15, @react-aria/i18n@npm:^3.12.16": version: 3.12.16 resolution: "@react-aria/i18n@npm:3.12.16" dependencies: @@ -11953,7 +12330,7 @@ __metadata: languageName: node linkType: hard -"@react-aria/interactions@npm:^3.27.1": +"@react-aria/interactions@npm:^3.27.0, @react-aria/interactions@npm:^3.27.1": version: 3.27.1 resolution: "@react-aria/interactions@npm:3.27.1" dependencies: @@ -11969,6 +12346,20 @@ __metadata: languageName: node linkType: hard +"@react-aria/label@npm:^3.7.24": + version: 3.7.24 + resolution: "@react-aria/label@npm:3.7.24" + dependencies: + "@react-aria/utils": "npm:^3.33.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/0d34ab2479c65255f1b1af22d35fa63bda36058d85bb281f614371cbcac8276a86357456c15b2e2c3105d98a5b0685b81af5dbe64ac3b1140dda581cdc39b5ca + languageName: node + linkType: hard + "@react-aria/landmark@npm:^3.0.10": version: 3.0.10 resolution: "@react-aria/landmark@npm:3.0.10" @@ -11984,6 +12375,300 @@ __metadata: languageName: node linkType: hard +"@react-aria/landmark@npm:^3.0.9": + version: 3.0.9 + resolution: "@react-aria/landmark@npm:3.0.9" + dependencies: + "@react-aria/utils": "npm:^3.33.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + use-sync-external-store: "npm:^1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/e430a5cf7517d6a674ea6ac8a76d55cede471f4991385a1acae70771b136fb21636fb28cca2e5dfe4d362a557eb9abc9200421fb2749f3cf5ed51980a06ad744 + languageName: node + linkType: hard + +"@react-aria/link@npm:^3.8.8": + version: 3.8.8 + resolution: "@react-aria/link@npm:3.8.8" + dependencies: + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/utils": "npm:^3.33.0" + "@react-types/link": "npm:^3.6.6" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/b86b2c0fc148c7ac810cc94f9228fd80aa6a00e8853ca0a0ad5c25e1c63ef26f5f712979a0fe6882e9f9f82be053f63e3a8f7b5a9380d902ca0d71cedbae50e4 + languageName: node + linkType: hard + +"@react-aria/listbox@npm:^3.15.2": + version: 3.15.2 + resolution: "@react-aria/listbox@npm:3.15.2" + dependencies: + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/label": "npm:^3.7.24" + "@react-aria/selection": "npm:^3.27.1" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/collections": "npm:^3.12.9" + "@react-stately/list": "npm:^3.13.3" + "@react-types/listbox": "npm:^3.7.5" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/3ada41c1cf0191a669ca80d31091228a5586f0342533318a5faa0f4233d8e987aa0b3b8ff4a9d3d8276eedd295f5a184976513dfeaea041f2166009296e29260 + languageName: node + linkType: hard + +"@react-aria/live-announcer@npm:^3.4.4": + version: 3.4.4 + resolution: "@react-aria/live-announcer@npm:3.4.4" + dependencies: + "@swc/helpers": "npm:^0.5.0" + checksum: 10c0/1598372e773ee8dbb2f1d2a946652384f5140ab54106416e2a182c72eaabc1b3739e624bac7aea3d95429ba16487074c782ff90db093be36dd1d4cf84f9f9a17 + languageName: node + linkType: hard + +"@react-aria/menu@npm:^3.20.0": + version: 3.20.0 + resolution: "@react-aria/menu@npm:3.20.0" + dependencies: + "@react-aria/focus": "npm:^3.21.4" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/overlays": "npm:^3.31.1" + "@react-aria/selection": "npm:^3.27.1" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/collections": "npm:^3.12.9" + "@react-stately/menu": "npm:^3.9.10" + "@react-stately/selection": "npm:^3.20.8" + "@react-stately/tree": "npm:^3.9.5" + "@react-types/button": "npm:^3.15.0" + "@react-types/menu": "npm:^3.10.6" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/8a0462bbbad8a50b7e3ecfbcfd6005942ff73c71ad061ac7406c1e80cbdb25ce60be413f385fcec323861f2e57b3a4437c169a78c3104937e3f2a6191beb3e1e + languageName: node + linkType: hard + +"@react-aria/meter@npm:^3.4.29": + version: 3.4.29 + resolution: "@react-aria/meter@npm:3.4.29" + dependencies: + "@react-aria/progress": "npm:^3.4.29" + "@react-types/meter": "npm:^3.4.14" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/4937814a69bcaef6de28619fee31c41cb8fdb97813e25317be97a5700da27f3ad54d2db6a5cebae2d576b1c125c7d5ee5e7559a645f895f420e0f1b840e04c13 + languageName: node + linkType: hard + +"@react-aria/numberfield@npm:^3.12.4": + version: 3.12.4 + resolution: "@react-aria/numberfield@npm:3.12.4" + dependencies: + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/spinbutton": "npm:^3.7.1" + "@react-aria/textfield": "npm:^3.18.4" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/form": "npm:^3.2.3" + "@react-stately/numberfield": "npm:^3.10.4" + "@react-types/button": "npm:^3.15.0" + "@react-types/numberfield": "npm:^3.8.17" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/a454ec9a9ce707706f75f0382a352d8a1c9020d176fc590d550703ebb8d3799ded954337592e36aa8fc2d54d59dfa891db75773e095a5fd4b416905a01bd693b + languageName: node + linkType: hard + +"@react-aria/overlays@npm:^3.31.1": + version: 3.31.1 + resolution: "@react-aria/overlays@npm:3.31.1" + dependencies: + "@react-aria/focus": "npm:^3.21.4" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/ssr": "npm:^3.9.10" + "@react-aria/utils": "npm:^3.33.0" + "@react-aria/visually-hidden": "npm:^3.8.30" + "@react-stately/overlays": "npm:^3.6.22" + "@react-types/button": "npm:^3.15.0" + "@react-types/overlays": "npm:^3.9.3" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/5de1c072d665547fa759446ef5c47a34d9c0fc8e404adf2d43b2ffc446940299dd28187ad04be9b5ceb1678d85c65c1634dfa5aae55d9739070c52adf4b5652c + languageName: node + linkType: hard + +"@react-aria/progress@npm:^3.4.29": + version: 3.4.29 + resolution: "@react-aria/progress@npm:3.4.29" + dependencies: + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/label": "npm:^3.7.24" + "@react-aria/utils": "npm:^3.33.0" + "@react-types/progress": "npm:^3.5.17" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/befc2f5bd31a536ace54471b07b446b6f740220d96537ead71f4cd917d68d9a0b00c6dca9ca6ffeed7f9e9a441d59e8e65fe14244f15f0c171909dfbe2948790 + languageName: node + linkType: hard + +"@react-aria/radio@npm:^3.12.4": + version: 3.12.4 + resolution: "@react-aria/radio@npm:3.12.4" + dependencies: + "@react-aria/focus": "npm:^3.21.4" + "@react-aria/form": "npm:^3.1.4" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/label": "npm:^3.7.24" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/radio": "npm:^3.11.4" + "@react-types/radio": "npm:^3.9.3" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/cee7ba4c75d9275cd9b0dd2796ee7ad56dadd56c72377db36646a72348a8b457bb9c11fada976e73df7ae59e8a9df5a15eb5fbceeb2677a048a4334ef74a1510 + languageName: node + linkType: hard + +"@react-aria/searchfield@npm:^3.8.11": + version: 3.8.11 + resolution: "@react-aria/searchfield@npm:3.8.11" + dependencies: + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/textfield": "npm:^3.18.4" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/searchfield": "npm:^3.5.18" + "@react-types/button": "npm:^3.15.0" + "@react-types/searchfield": "npm:^3.6.7" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/816eaf3e71cf70f15d75e697f5be88397569788f82f2d1b3de199d96caeeadff2542153b811781ba5e7e60b1131b5f8ded4074db66a85f25573b02235d18b9eb + languageName: node + linkType: hard + +"@react-aria/select@npm:^3.17.2": + version: 3.17.2 + resolution: "@react-aria/select@npm:3.17.2" + dependencies: + "@react-aria/form": "npm:^3.1.4" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/label": "npm:^3.7.24" + "@react-aria/listbox": "npm:^3.15.2" + "@react-aria/menu": "npm:^3.20.0" + "@react-aria/selection": "npm:^3.27.1" + "@react-aria/utils": "npm:^3.33.0" + "@react-aria/visually-hidden": "npm:^3.8.30" + "@react-stately/select": "npm:^3.9.1" + "@react-types/button": "npm:^3.15.0" + "@react-types/select": "npm:^3.12.1" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/a58f08229dd5b82faba4c51cf69e89219aa0dff716ae50ee9546ef6794d0659e3557c15015a44aa3071abf9b3dcb2ebc6943cd6e7028ad5d58cdf65053e0359f + languageName: node + linkType: hard + +"@react-aria/selection@npm:^3.27.1": + version: 3.27.1 + resolution: "@react-aria/selection@npm:3.27.1" + dependencies: + "@react-aria/focus": "npm:^3.21.4" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/selection": "npm:^3.20.8" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/0386549c0557a300288ea841bc5d9d2882e67c69dbfa3a7df1b27ce1c2dbba22987e0cfcee758dd60d3d4852ed1b02df6d7f75d961842e3d8f556fb4dc2239c5 + languageName: node + linkType: hard + +"@react-aria/separator@npm:^3.4.15": + version: 3.4.15 + resolution: "@react-aria/separator@npm:3.4.15" + dependencies: + "@react-aria/utils": "npm:^3.33.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/e9618ed089cb3398aa9bfd2fa224e3ad09f2d9eda4a24651eb2b5d2836f569a9026c2415ff9314b8024ce25ecdf50ed921006d186479a49b49f700827d45c5d5 + languageName: node + linkType: hard + +"@react-aria/slider@npm:^3.8.4": + version: 3.8.4 + resolution: "@react-aria/slider@npm:3.8.4" + dependencies: + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/label": "npm:^3.7.24" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/slider": "npm:^3.7.4" + "@react-types/shared": "npm:^3.33.0" + "@react-types/slider": "npm:^3.8.3" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/72f28c6d7661b31876c64295c2ed6927d8488f65c1f4014f9c27818154e3ef0c74f17b84dfa5a8009020ce853e208cada3c4c89b04d659cf5eec5eb5e5492e91 + languageName: node + linkType: hard + +"@react-aria/spinbutton@npm:^3.7.1": + version: 3.7.1 + resolution: "@react-aria/spinbutton@npm:3.7.1" + dependencies: + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/live-announcer": "npm:^3.4.4" + "@react-aria/utils": "npm:^3.33.0" + "@react-types/button": "npm:^3.15.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/8108b88aeb7d9cd02fe0cb2abb97c2d30cfc7ef8c5757c9280da33b46739d843acd8cc69eba3a0bc61dcd6bba09b77521c40d9957b4f062d0b85db3f00fe5b70 + languageName: node + linkType: hard + "@react-aria/ssr@npm:^3.9.10": version: 3.9.10 resolution: "@react-aria/ssr@npm:3.9.10" @@ -11995,7 +12680,109 @@ __metadata: languageName: node linkType: hard -"@react-aria/toast@npm:^3.0.9": +"@react-aria/switch@npm:^3.7.10": + version: 3.7.10 + resolution: "@react-aria/switch@npm:3.7.10" + dependencies: + "@react-aria/toggle": "npm:^3.12.4" + "@react-stately/toggle": "npm:^3.9.4" + "@react-types/shared": "npm:^3.33.0" + "@react-types/switch": "npm:^3.5.16" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/1883a2b8901595611f57ddbae3e411a1a50cf334b63e529a440e213cbb75b3ea7fb0f360fcfe1839a3763de6d9337fcbf1d9568357f25f014d3c7bf8d3748159 + languageName: node + linkType: hard + +"@react-aria/table@npm:^3.17.10": + version: 3.17.10 + resolution: "@react-aria/table@npm:3.17.10" + dependencies: + "@react-aria/focus": "npm:^3.21.4" + "@react-aria/grid": "npm:^3.14.7" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/live-announcer": "npm:^3.4.4" + "@react-aria/utils": "npm:^3.33.0" + "@react-aria/visually-hidden": "npm:^3.8.30" + "@react-stately/collections": "npm:^3.12.9" + "@react-stately/flags": "npm:^3.1.2" + "@react-stately/table": "npm:^3.15.3" + "@react-types/checkbox": "npm:^3.10.3" + "@react-types/grid": "npm:^3.3.7" + "@react-types/shared": "npm:^3.33.0" + "@react-types/table": "npm:^3.13.5" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/d211082255fdc20e76ca7ce92ea614ffc93fb2ebf3dff5776cb1634517cc546ba5e40d9564e69a94b4150adf3c7c2b862084706b1cb64020a8da46c04c7f3abc + languageName: node + linkType: hard + +"@react-aria/tabs@npm:^3.11.0": + version: 3.11.0 + resolution: "@react-aria/tabs@npm:3.11.0" + dependencies: + "@react-aria/focus": "npm:^3.21.4" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/selection": "npm:^3.27.1" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/tabs": "npm:^3.8.8" + "@react-types/shared": "npm:^3.33.0" + "@react-types/tabs": "npm:^3.3.21" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/617c6a79a754cfa52862809bb46fd68e98655bf3f5b8d68440d8e013e1747665be6baf1f23e4bcf68527765e7a031ecac08b6b7634b99c36e8f11074f5e9fe9d + languageName: node + linkType: hard + +"@react-aria/tag@npm:^3.8.0": + version: 3.8.0 + resolution: "@react-aria/tag@npm:3.8.0" + dependencies: + "@react-aria/gridlist": "npm:^3.14.3" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/label": "npm:^3.7.24" + "@react-aria/selection": "npm:^3.27.1" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/list": "npm:^3.13.3" + "@react-types/button": "npm:^3.15.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/0fc389de14991b2e9cdaf0b26d8b041dc9ab9c5c9185d213d074ca126fa6166f4346a9ff2b4102e0a87e23743cb975422250a0e2c6a9b61114ad9515b45bb19d + languageName: node + linkType: hard + +"@react-aria/textfield@npm:^3.18.4": + version: 3.18.4 + resolution: "@react-aria/textfield@npm:3.18.4" + dependencies: + "@react-aria/form": "npm:^3.1.4" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/label": "npm:^3.7.24" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/form": "npm:^3.2.3" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/shared": "npm:^3.33.0" + "@react-types/textfield": "npm:^3.12.7" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/5b28ce4afe987d6eeb2ddf51d9c5237fe589a6041c7ea41bbc1eac2c9ac626710e935d56cf6c041db0ff407af40f33813a11bf7295653426adb505dc60d553b8 + languageName: node + linkType: hard + +"@react-aria/toast@npm:^3.0.10, @react-aria/toast@npm:^3.0.9": version: 3.0.11 resolution: "@react-aria/toast@npm:3.0.11" dependencies: @@ -12014,6 +12801,39 @@ __metadata: languageName: node linkType: hard +"@react-aria/toggle@npm:^3.12.4": + version: 3.12.4 + resolution: "@react-aria/toggle@npm:3.12.4" + dependencies: + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/toggle": "npm:^3.9.4" + "@react-types/checkbox": "npm:^3.10.3" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/8b35130059de34f37ba47814037a62c776db117819e7235cc3b57d67f08ae95bac303b47d236d51d92b4e69748a9df3eb3a40032b2b2849f0feff5c617626062 + languageName: node + linkType: hard + +"@react-aria/toolbar@npm:3.0.0-beta.23": + version: 3.0.0-beta.23 + resolution: "@react-aria/toolbar@npm:3.0.0-beta.23" + dependencies: + "@react-aria/focus": "npm:^3.21.4" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/utils": "npm:^3.33.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/5a1a9a24638618a509c1f7195768acda60c832d138f13d6c44023b5c87c2fec51db043276d369465f4abe6bfbdbd69ea99c6317b5bd04ee27195702f8c90a256 + languageName: node + linkType: hard + "@react-aria/toolbar@npm:3.0.0-beta.24": version: 3.0.0-beta.24 resolution: "@react-aria/toolbar@npm:3.0.0-beta.24" @@ -12030,7 +12850,43 @@ __metadata: languageName: node linkType: hard -"@react-aria/utils@npm:^3.33.1": +"@react-aria/tooltip@npm:^3.9.1": + version: 3.9.1 + resolution: "@react-aria/tooltip@npm:3.9.1" + dependencies: + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/tooltip": "npm:^3.5.10" + "@react-types/shared": "npm:^3.33.0" + "@react-types/tooltip": "npm:^3.5.1" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/b6cde7d5567567e948bb62037c42d91c37873e1b40316e287b17f1f1d10a5c7a76d095a0bdae604e5ac48103fa7d0029c665a53ed501517ee30a0d720e95f05f + languageName: node + linkType: hard + +"@react-aria/tree@npm:^3.1.6": + version: 3.1.6 + resolution: "@react-aria/tree@npm:3.1.6" + dependencies: + "@react-aria/gridlist": "npm:^3.14.3" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/selection": "npm:^3.27.1" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/tree": "npm:^3.9.5" + "@react-types/button": "npm:^3.15.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/7d3678347e19895be31b17aa2cfeed07f4796123e99018d591a11928da2b475de21f13c7282569b740ad48cb572353baa926000d4c7dc389bece33f8017cd546 + languageName: node + linkType: hard + +"@react-aria/utils@npm:^3.33.0, @react-aria/utils@npm:^3.33.1": version: 3.33.1 resolution: "@react-aria/utils@npm:3.33.1" dependencies: @@ -12047,6 +12903,38 @@ __metadata: languageName: node linkType: hard +"@react-aria/virtualizer@npm:^4.1.12": + version: 4.1.12 + resolution: "@react-aria/virtualizer@npm:4.1.12" + dependencies: + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/utils": "npm:^3.33.0" + "@react-stately/virtualizer": "npm:^4.4.5" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/f02a181f46fe6de29bba7c0330d3d6b592f7e595e43dde71a8995d81677d2ed979c987eb40f9443c76e35315c3163052cb7f7396d5f5bedddc28e91466e643c0 + languageName: node + linkType: hard + +"@react-aria/visually-hidden@npm:^3.8.30": + version: 3.8.30 + resolution: "@react-aria/visually-hidden@npm:3.8.30" + dependencies: + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/utils": "npm:^3.33.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/ff0324a222ff05ad3743d1df56feb8d1e2d95791cd360dbfbc03fad4b8e9346eb7b65a29ffda85f80665a5edc1f7705fcd047eaecb4f38573ee7e9c5d562392e + languageName: node + linkType: hard + "@react-hookz/deep-equal@npm:^1.0.4": version: 1.0.4 resolution: "@react-hookz/deep-equal@npm:1.0.4" @@ -12070,6 +12958,154 @@ __metadata: languageName: node linkType: hard +"@react-stately/autocomplete@npm:3.0.0-beta.4": + version: 3.0.0-beta.4 + resolution: "@react-stately/autocomplete@npm:3.0.0-beta.4" + dependencies: + "@react-stately/utils": "npm:^3.11.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/1dd69bc262bd761f21b2ff7c594f8ffbfc34e12bbb721d6077739a59646be9cbcdff8652874ca1402c8644ac5639275fa928172e478db10770ae951af629b03f + languageName: node + linkType: hard + +"@react-stately/calendar@npm:^3.9.2": + version: 3.9.2 + resolution: "@react-stately/calendar@npm:3.9.2" + dependencies: + "@internationalized/date": "npm:^3.11.0" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/calendar": "npm:^3.8.2" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/d910836573b6088a25bef3f503b9977980c0a11feccacff08a41f8402b510255b0d26d524db63733875f25ef5e192aa6a5fbebc6e34129dbe0e4b0714ff96162 + languageName: node + linkType: hard + +"@react-stately/checkbox@npm:^3.7.4": + version: 3.7.4 + resolution: "@react-stately/checkbox@npm:3.7.4" + dependencies: + "@react-stately/form": "npm:^3.2.3" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/checkbox": "npm:^3.10.3" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/2a87a0160ed50954886dce3635be62b085edd86eac3c508f34ad019411e2848fe103ae1c44a3b2e8c22eb38df80ceabc8583b7496fe4f842df681df14ef0c44a + languageName: node + linkType: hard + +"@react-stately/collections@npm:^3.12.9": + version: 3.12.9 + resolution: "@react-stately/collections@npm:3.12.9" + dependencies: + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/d7e68eaf89a2b79c73fbf7437d32f65a2d65bf59123bcb896176132652c020490d7187e52aff22169b033fefde4c728b4bb89a224e0061618c00d84ae0f373b6 + languageName: node + linkType: hard + +"@react-stately/color@npm:^3.9.4": + version: 3.9.4 + resolution: "@react-stately/color@npm:3.9.4" + dependencies: + "@internationalized/number": "npm:^3.6.5" + "@internationalized/string": "npm:^3.2.7" + "@react-stately/form": "npm:^3.2.3" + "@react-stately/numberfield": "npm:^3.10.4" + "@react-stately/slider": "npm:^3.7.4" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/color": "npm:^3.1.3" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/30ee71641bcc9e8c2a03ce443b7ca694659626292110a4146f52fa8619f10e74ba0dc59ed3c35d0e33bb47c649915ca9d79280989c2e2ff3fe196ea27736cf44 + languageName: node + linkType: hard + +"@react-stately/combobox@npm:^3.12.2": + version: 3.12.2 + resolution: "@react-stately/combobox@npm:3.12.2" + dependencies: + "@react-stately/collections": "npm:^3.12.9" + "@react-stately/form": "npm:^3.2.3" + "@react-stately/list": "npm:^3.13.3" + "@react-stately/overlays": "npm:^3.6.22" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/combobox": "npm:^3.13.11" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/e5fef62e7dd4502a695ae6158d3afe2047036da2b8974e34d0021e4220b6028571f6c0dbf49616f5258147cf250bd231a1be622f04c5be19004fdc0898125474 + languageName: node + linkType: hard + +"@react-stately/data@npm:^3.15.1": + version: 3.15.1 + resolution: "@react-stately/data@npm:3.15.1" + dependencies: + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/c9e131546bd2ddac696a7bba3c269108688db5af763cd17dd0a624215e23f7ea6d6b434a794416a6c572968cae4026d42f766be222c07ce5fc5aaea481d1d0e2 + languageName: node + linkType: hard + +"@react-stately/datepicker@npm:^3.16.0": + version: 3.16.0 + resolution: "@react-stately/datepicker@npm:3.16.0" + dependencies: + "@internationalized/date": "npm:^3.11.0" + "@internationalized/number": "npm:^3.6.5" + "@internationalized/string": "npm:^3.2.7" + "@react-stately/form": "npm:^3.2.3" + "@react-stately/overlays": "npm:^3.6.22" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/datepicker": "npm:^3.13.4" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/37da44a664284b06730e141b033d9d2128803c97191e7f72a1e50b7f204d52f6e4ee4d310a4d2a59f0d3e838b334adb1d4b57e9008e5670d5ed4222ba4d44a00 + languageName: node + linkType: hard + +"@react-stately/disclosure@npm:^3.0.10": + version: 3.0.10 + resolution: "@react-stately/disclosure@npm:3.0.10" + dependencies: + "@react-stately/utils": "npm:^3.11.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/acfc0346f5503b8693dabd145fc2eff36f59eb140f7a0cadd956ab102453bdf1dd63503fe9611106b6b4348d85be3b29c5ab1f743945148dcd23d518035f7dbb + languageName: node + linkType: hard + +"@react-stately/dnd@npm:^3.7.3": + version: 3.7.3 + resolution: "@react-stately/dnd@npm:3.7.3" + dependencies: + "@react-stately/selection": "npm:^3.20.8" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/a6ef1cfe1ba0335837462f57fbe5dac637a861945b9f958e875dd8ad440312e10dd93df3c2740cff93106bc20d1e1145c881e4fc8ec947b135d0133b23af5b94 + languageName: node + linkType: hard + "@react-stately/flags@npm:^3.1.2": version: 3.1.2 resolution: "@react-stately/flags@npm:3.1.2" @@ -12079,6 +13115,214 @@ __metadata: languageName: node linkType: hard +"@react-stately/form@npm:^3.2.3": + version: 3.2.3 + resolution: "@react-stately/form@npm:3.2.3" + dependencies: + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/bfab5e15a9b3080a0bcf0a65d3e37aba561ec78a9aebb3ce7eeed52bec72def2b3db82ec51bf60f1f453b7a116b240350c37ae7fa9c9042cac3b5a73296daf59 + languageName: node + linkType: hard + +"@react-stately/grid@npm:^3.11.8": + version: 3.11.8 + resolution: "@react-stately/grid@npm:3.11.8" + dependencies: + "@react-stately/collections": "npm:^3.12.9" + "@react-stately/selection": "npm:^3.20.8" + "@react-types/grid": "npm:^3.3.7" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/1f7ebe345c3621abc81374cf0429f070a02d49d461a702ef2419f7f827f3de4827d742a441efe7de3f617bbcbac664bfb7bdf2e2c015275a986537f8f928f3d4 + languageName: node + linkType: hard + +"@react-stately/layout@npm:^4.5.3": + version: 4.5.3 + resolution: "@react-stately/layout@npm:4.5.3" + dependencies: + "@react-stately/collections": "npm:^3.12.9" + "@react-stately/table": "npm:^3.15.3" + "@react-stately/virtualizer": "npm:^4.4.5" + "@react-types/grid": "npm:^3.3.7" + "@react-types/shared": "npm:^3.33.0" + "@react-types/table": "npm:^3.13.5" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/828310862317b6fe612be1c0940ee7b90fc964c5e0d4168f90e3548da1dcc5675c31892a30422b077499ed9610552f1471e442a06a68370378f666acde72ae15 + languageName: node + linkType: hard + +"@react-stately/list@npm:^3.13.3": + version: 3.13.3 + resolution: "@react-stately/list@npm:3.13.3" + dependencies: + "@react-stately/collections": "npm:^3.12.9" + "@react-stately/selection": "npm:^3.20.8" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/790bc56513dcef53678509f134e9aecc04051ffdc37961b8d51f5ca7628f05d854438716d54a8ebcd26b93488628a8b0f6b7b952bf147cadacbfe2b1ac210662 + languageName: node + linkType: hard + +"@react-stately/menu@npm:^3.9.10": + version: 3.9.10 + resolution: "@react-stately/menu@npm:3.9.10" + dependencies: + "@react-stately/overlays": "npm:^3.6.22" + "@react-types/menu": "npm:^3.10.6" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/937d16c769f8fcea94060d530e1238f96a50262621ee05afdfeeb1a18771b0ae04ae5d1fadaa1dd7718f4ae9006f286eb6ebe329bf5068bad8f196af813e5ad9 + languageName: node + linkType: hard + +"@react-stately/numberfield@npm:^3.10.4": + version: 3.10.4 + resolution: "@react-stately/numberfield@npm:3.10.4" + dependencies: + "@internationalized/number": "npm:^3.6.5" + "@react-stately/form": "npm:^3.2.3" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/numberfield": "npm:^3.8.17" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/7a6635a823ca7c03064c3b65e99d1122e1851f0e88795542da6a7b27198269d6aec764daffa0b97da57af1fc6bd202bf5f9cfd6ac26e8a209b7a717e1e707c0f + languageName: node + linkType: hard + +"@react-stately/overlays@npm:^3.6.22": + version: 3.6.22 + resolution: "@react-stately/overlays@npm:3.6.22" + dependencies: + "@react-stately/utils": "npm:^3.11.0" + "@react-types/overlays": "npm:^3.9.3" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/c5da7009c5cba86c852e438a334d3e8fcc17021ec8d4fd5d82135ffb83e19929f97bf12f0b038911af648e3207307e461b332952fa38abd9c82d4adee833f2c8 + languageName: node + linkType: hard + +"@react-stately/radio@npm:^3.11.4": + version: 3.11.4 + resolution: "@react-stately/radio@npm:3.11.4" + dependencies: + "@react-stately/form": "npm:^3.2.3" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/radio": "npm:^3.9.3" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/ee31fd738d0c0913a4393479e8064af14439aac7e8f05f7b3042cf9ddf30e5ca138258209b3b33909b21574a6a9b051871bfcfd4b1a8311841e3f2ce26a03660 + languageName: node + linkType: hard + +"@react-stately/searchfield@npm:^3.5.18": + version: 3.5.18 + resolution: "@react-stately/searchfield@npm:3.5.18" + dependencies: + "@react-stately/utils": "npm:^3.11.0" + "@react-types/searchfield": "npm:^3.6.7" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/3fab036b4461bd6748e8e9bd17456e7908b76e1df4349f2ee34d426cf3f4ce7b6ff8266078eec3d29485beeb8d89d4426bfbc4e3613442eb489fb0f2867f70cd + languageName: node + linkType: hard + +"@react-stately/select@npm:^3.9.1": + version: 3.9.1 + resolution: "@react-stately/select@npm:3.9.1" + dependencies: + "@react-stately/form": "npm:^3.2.3" + "@react-stately/list": "npm:^3.13.3" + "@react-stately/overlays": "npm:^3.6.22" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/select": "npm:^3.12.1" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/4104bb87a0a1efc377c67c675b85aa3b568b43025d0f36aed3494d7ecacc07b35574b5df2f5d2e2b39d23de3eb9391eb0d674ed520658bee96d1780df2168148 + languageName: node + linkType: hard + +"@react-stately/selection@npm:^3.20.8": + version: 3.20.8 + resolution: "@react-stately/selection@npm:3.20.8" + dependencies: + "@react-stately/collections": "npm:^3.12.9" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/8127e2adf3b96591e5ec31abcf3ca998da3f111306beec809d08675d5c563cfc6fc012b8b7570d8c5794d656e7049a8613663b0596430ddaf37dba2048ad71ac + languageName: node + linkType: hard + +"@react-stately/slider@npm:^3.7.4": + version: 3.7.4 + resolution: "@react-stately/slider@npm:3.7.4" + dependencies: + "@react-stately/utils": "npm:^3.11.0" + "@react-types/shared": "npm:^3.33.0" + "@react-types/slider": "npm:^3.8.3" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/a8fe1ed9f5144e2b66a9ce58d38d624511e5db6bbd9dec2cb9051b08caac8f17b60f614643ef82f50dd2f4aa1bb13f0e19c7700cce344db5f0d71abb57e76d75 + languageName: node + linkType: hard + +"@react-stately/table@npm:^3.15.3": + version: 3.15.3 + resolution: "@react-stately/table@npm:3.15.3" + dependencies: + "@react-stately/collections": "npm:^3.12.9" + "@react-stately/flags": "npm:^3.1.2" + "@react-stately/grid": "npm:^3.11.8" + "@react-stately/selection": "npm:^3.20.8" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/grid": "npm:^3.3.7" + "@react-types/shared": "npm:^3.33.0" + "@react-types/table": "npm:^3.13.5" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/691625eea3b3fc4014a4e1f18149d717ee9907473bca80e742944740fb5ca2317b6be6a58eaabca670765cdb7be2a543a9a3b094fc1fa3c0f66f9d57b16bf80f + languageName: node + linkType: hard + +"@react-stately/tabs@npm:^3.8.8": + version: 3.8.8 + resolution: "@react-stately/tabs@npm:3.8.8" + dependencies: + "@react-stately/list": "npm:^3.13.3" + "@react-types/shared": "npm:^3.33.0" + "@react-types/tabs": "npm:^3.3.21" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/8e43e4fdbe57ac7ba4bc7be4b6757207f87301f253b5335c4736be230a1206975213c5aa3141903c9e2739e5638c76f33e22d7842e2a2e3998f65566b39341eb + languageName: node + linkType: hard + "@react-stately/toast@npm:^3.1.2, @react-stately/toast@npm:^3.1.3": version: 3.1.3 resolution: "@react-stately/toast@npm:3.1.3" @@ -12091,7 +13335,7 @@ __metadata: languageName: node linkType: hard -"@react-stately/toggle@npm:^3.9.5": +"@react-stately/toggle@npm:^3.9.4, @react-stately/toggle@npm:^3.9.5": version: 3.9.5 resolution: "@react-stately/toggle@npm:3.9.5" dependencies: @@ -12105,6 +13349,34 @@ __metadata: languageName: node linkType: hard +"@react-stately/tooltip@npm:^3.5.10": + version: 3.5.10 + resolution: "@react-stately/tooltip@npm:3.5.10" + dependencies: + "@react-stately/overlays": "npm:^3.6.22" + "@react-types/tooltip": "npm:^3.5.1" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/3d73aa49044f0e48ff0d0f30157638dac8df18347dcc7effcb6ecfa50885bdadad37550bfc4facc37211b9b06e05518cc1f451b4fb09c1c70b09549cbb85dcc3 + languageName: node + linkType: hard + +"@react-stately/tree@npm:^3.9.5": + version: 3.9.5 + resolution: "@react-stately/tree@npm:3.9.5" + dependencies: + "@react-stately/collections": "npm:^3.12.9" + "@react-stately/selection": "npm:^3.20.8" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/2d9acb2d7172bacac9aefabeb01cbf47c8539bea7e57c81ff6d09a4bc501f9f297e2e63bb35d4a9abe2cfccd536400555d23f13dd4aa476af19be1d60bc50870 + languageName: node + linkType: hard + "@react-stately/utils@npm:^3.11.0": version: 3.11.0 resolution: "@react-stately/utils@npm:3.11.0" @@ -12116,7 +13388,45 @@ __metadata: languageName: node linkType: hard -"@react-types/button@npm:^3.15.1": +"@react-stately/virtualizer@npm:^4.4.5": + version: 4.4.5 + resolution: "@react-stately/virtualizer@npm:4.4.5" + dependencies: + "@react-types/shared": "npm:^3.33.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/704f236a991bfb33dc11910c47ab1fd6087e7d20fff543c78cc33a30212bcc84e3f6ebed36ed8cbff2e12361136520b7b4a016438d84135837b11142210b8ae2 + languageName: node + linkType: hard + +"@react-types/autocomplete@npm:3.0.0-alpha.37": + version: 3.0.0-alpha.37 + resolution: "@react-types/autocomplete@npm:3.0.0-alpha.37" + dependencies: + "@react-types/combobox": "npm:^3.13.11" + "@react-types/searchfield": "npm:^3.6.7" + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/e7d9a86dc3cd67fddbc9f0e1660cd359df8b3ffef2db47f76b9cb8e9ce1e9e55652e38bb9707347e90ebd4ef8500b4053a38d0738b5a77cb0248169c22647325 + languageName: node + linkType: hard + +"@react-types/breadcrumbs@npm:^3.7.18": + version: 3.7.18 + resolution: "@react-types/breadcrumbs@npm:3.7.18" + dependencies: + "@react-types/link": "npm:^3.6.6" + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/49391e83b97d1a9c362aa27142416f689e93167542b4e5a4f6b6b2b0c384a186df7f9c660f5a75a8d0653c25683bfd290f66cf3b4b073ccd2d825047ecfe53b7 + languageName: node + linkType: hard + +"@react-types/button@npm:^3.15.0, @react-types/button@npm:^3.15.1": version: 3.15.1 resolution: "@react-types/button@npm:3.15.1" dependencies: @@ -12127,7 +13437,19 @@ __metadata: languageName: node linkType: hard -"@react-types/checkbox@npm:^3.10.4": +"@react-types/calendar@npm:^3.8.2": + version: 3.8.2 + resolution: "@react-types/calendar@npm:3.8.2" + dependencies: + "@internationalized/date": "npm:^3.11.0" + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/e7944b60c798563666cf6b35fdf347e6048ef238fe3016095f2e7283791599cc471dbbc6cb20bb83e46fc230f8ea064340ec33727569e2dcae7d8e9fc3f6c729 + languageName: node + linkType: hard + +"@react-types/checkbox@npm:^3.10.3, @react-types/checkbox@npm:^3.10.4": version: 3.10.4 resolution: "@react-types/checkbox@npm:3.10.4" dependencies: @@ -12138,7 +13460,199 @@ __metadata: languageName: node linkType: hard -"@react-types/shared@npm:^3.33.1, @react-types/shared@npm:^3.34.0": +"@react-types/color@npm:^3.1.3": + version: 3.1.3 + resolution: "@react-types/color@npm:3.1.3" + dependencies: + "@react-types/shared": "npm:^3.33.0" + "@react-types/slider": "npm:^3.8.3" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/8bc783d3adbcd18426926156248bb9decf916abd5fef9b97ad8c5ef8efbf4371c3c3277315aba2b3daab17598fd627dce5eefa87b86a668a5f91ec68596369cf + languageName: node + linkType: hard + +"@react-types/combobox@npm:^3.13.11": + version: 3.13.11 + resolution: "@react-types/combobox@npm:3.13.11" + dependencies: + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/8b704943d603fb8fcc9055cb10559677c578e9872f9e9084e3bc7f2dc9ede24ac09582b3ff8da645b77d0cabd302c994e85ce1d1e15d0b3f7d09f070c8eb00b0 + languageName: node + linkType: hard + +"@react-types/datepicker@npm:^3.13.4": + version: 3.13.4 + resolution: "@react-types/datepicker@npm:3.13.4" + dependencies: + "@internationalized/date": "npm:^3.11.0" + "@react-types/calendar": "npm:^3.8.2" + "@react-types/overlays": "npm:^3.9.3" + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/2f716c97a8a8662f311e951f94bbcee69ac902341b24a2a682535c40cbce105e93819f1995a9eefeb25a7d1610ab112cd7ac85658bb73c9f608240c18f675bbc + languageName: node + linkType: hard + +"@react-types/dialog@npm:^3.5.23": + version: 3.5.23 + resolution: "@react-types/dialog@npm:3.5.23" + dependencies: + "@react-types/overlays": "npm:^3.9.3" + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/9fbee4f73f22d4c8270e04d49021bf40077c4fda30c01add24d256cf7c5c71f9c91be03b99f651af974526e78fb14ae571b78a6855aa61637f3667e44d42baeb + languageName: node + linkType: hard + +"@react-types/form@npm:^3.7.17": + version: 3.7.17 + resolution: "@react-types/form@npm:3.7.17" + dependencies: + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/998942b98280f15cb11a147fda403b471dce45ed8ee00f56778d1aa718ecff8f17496617a8f52cd1d2844a65e5823196157a5d6669cb7e22dc84e453c9b972f0 + languageName: node + linkType: hard + +"@react-types/grid@npm:^3.3.7": + version: 3.3.7 + resolution: "@react-types/grid@npm:3.3.7" + dependencies: + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/cc153cc08bf564e12f04c51b3456bfcd3393f2ea361b6f35cf70a41100f3261dc86825842e50b3c6d84fe01944285717eeff002ed67a4dffd429c1e74ab0f41c + languageName: node + linkType: hard + +"@react-types/link@npm:^3.6.6": + version: 3.6.6 + resolution: "@react-types/link@npm:3.6.6" + dependencies: + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/aca552f96362760e9fbe6d15aa90c27383c747d33dff91b4d5ff223b4395603a6d77bee032f714aa56b790d3392640456992c09d112c7956953315f6c88526e7 + languageName: node + linkType: hard + +"@react-types/listbox@npm:^3.7.5": + version: 3.7.5 + resolution: "@react-types/listbox@npm:3.7.5" + dependencies: + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/c24b09cdde6dc0a2456c7e45db5854c8ba0f75d02a220ab27418c03be017d58d8ef26eff85b7e16ce9d19cfb7a265ccfdd3c81247e5533109201d55c81c6bf46 + languageName: node + linkType: hard + +"@react-types/menu@npm:^3.10.6": + version: 3.10.6 + resolution: "@react-types/menu@npm:3.10.6" + dependencies: + "@react-types/overlays": "npm:^3.9.3" + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/15960881debe12f37918a9aa04f26f44b2c31df3c6aa81e3208fd97b3a67050de4c4af42a18cdf69e8e281744d58a1739d5d38c105d93db5a5bf476202163911 + languageName: node + linkType: hard + +"@react-types/meter@npm:^3.4.14": + version: 3.4.14 + resolution: "@react-types/meter@npm:3.4.14" + dependencies: + "@react-types/progress": "npm:^3.5.17" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/c4de6bc227f32824029fcc882793bb12fa0ef3d55530460979fc4145dee27f1cd3bedd156870d25e2877c2e3629ab9f0bd05a71e6bad95b055bd48860c0ace45 + languageName: node + linkType: hard + +"@react-types/numberfield@npm:^3.8.17": + version: 3.8.17 + resolution: "@react-types/numberfield@npm:3.8.17" + dependencies: + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/cf6227d3a67ec7f927c70db9f4c188612e263f28190b718a9f8b68cff60937c39dfcf8422ad23555659ef25508bd7a52989c87d012c54afacf39318ead324ba1 + languageName: node + linkType: hard + +"@react-types/overlays@npm:^3.9.3": + version: 3.9.3 + resolution: "@react-types/overlays@npm:3.9.3" + dependencies: + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/e7dde940ba785d3995f87641f2b67e502f2090fa8fdef508db1f81c052f4822df608b8bc6ae91dd270ba6055cdf7d8f0e6585c8deb2d34537fb7c8eae7d3099a + languageName: node + linkType: hard + +"@react-types/progress@npm:^3.5.17": + version: 3.5.17 + resolution: "@react-types/progress@npm:3.5.17" + dependencies: + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/b334ca1ab03b75d9324daec35ab64485b97fe34672c73110f08ab1b282ef0144972d6bf0a5733a7f18a60617f7b9b9c708aff517a445c604b8f79bec06f0974a + languageName: node + linkType: hard + +"@react-types/radio@npm:^3.9.3": + version: 3.9.3 + resolution: "@react-types/radio@npm:3.9.3" + dependencies: + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/9074145535712bddeadd7e8ffa3ef51bb6606e0aa752fafd401257c7f0f8b0314bf61b30cb776b3eabda70a8a6d1d479f8a346eb529c022dda147b27a73dbe02 + languageName: node + linkType: hard + +"@react-types/searchfield@npm:^3.6.7": + version: 3.6.7 + resolution: "@react-types/searchfield@npm:3.6.7" + dependencies: + "@react-types/shared": "npm:^3.33.0" + "@react-types/textfield": "npm:^3.12.7" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/8ef81774d98054fc7efdf68f225692d453da5b21e97d6dd088c007edb0be0167ab3010b9c2c17796dde848ce51fd5c492c5989c770d46d26fe904cbefc3aaf92 + languageName: node + linkType: hard + +"@react-types/select@npm:^3.12.1": + version: 3.12.1 + resolution: "@react-types/select@npm:3.12.1" + dependencies: + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/9706fd1bbd68a113995c35339ae8d7dfaf47a370dae7ddc8244422dc72c345c9c2adbbc2b08e7755747579bc438d68a096a74afc2fb8975e1ca84947f10d9aa4 + languageName: node + linkType: hard + +"@react-types/shared@npm:^3.33.0, @react-types/shared@npm:^3.33.1": + version: 3.33.1 + resolution: "@react-types/shared@npm:3.33.1" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/072dbf92de80e3535441fbf4a9075009636221974d0b70bd1078ec1e343ae1373d69d2c02c2fba0963e50f4b134872144ffaf1573daec8477233e408f96e008c + languageName: node + linkType: hard + +"@react-types/shared@npm:^3.34.0": version: 3.34.0 resolution: "@react-types/shared@npm:3.34.0" peerDependencies: @@ -12147,6 +13661,74 @@ __metadata: languageName: node linkType: hard +"@react-types/slider@npm:^3.8.3": + version: 3.8.3 + resolution: "@react-types/slider@npm:3.8.3" + dependencies: + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/93656c42a9c56779363c050f13bd88abe59958c93dc43caef6ebc5193a9a6320c56108ede022e869741b430716ef632ffc2498fa25a4854124a39aa8c8cd65b3 + languageName: node + linkType: hard + +"@react-types/switch@npm:^3.5.16": + version: 3.5.16 + resolution: "@react-types/switch@npm:3.5.16" + dependencies: + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/2fe6bd9f0e053004a277bcae534d2b52cb9e92f7f3223bc3d062ae035a75d1c024797a36412c96d845d3646dd1d84fffaf77c7826e0918c215b95570a5c6ea64 + languageName: node + linkType: hard + +"@react-types/table@npm:^3.13.5": + version: 3.13.5 + resolution: "@react-types/table@npm:3.13.5" + dependencies: + "@react-types/grid": "npm:^3.3.7" + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/a19e1cf38903baa9549f37f6f2331b035ef5606ec7767313949b555ee9e21466ee8aec1384c70d92484a2aefbad6b91029f72699430d0be1ee2b3ae43abd9e87 + languageName: node + linkType: hard + +"@react-types/tabs@npm:^3.3.21": + version: 3.3.21 + resolution: "@react-types/tabs@npm:3.3.21" + dependencies: + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/e4b02a4c77d4e57d67541cfc38ed55b3ce683d84074237c720879bed5d12f7e8631d606aa552237feab622cc7edc0a4b176983bf8053547215231323b32886c0 + languageName: node + linkType: hard + +"@react-types/textfield@npm:^3.12.7": + version: 3.12.7 + resolution: "@react-types/textfield@npm:3.12.7" + dependencies: + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/6e644519a6a6eb1d00fe83a92508596079131042c48406439cd32c359c0d3a70cc309e6530a7c1b51c8f4b477cb6e4d5aa13810c242597fd470f73031c7c80de + languageName: node + linkType: hard + +"@react-types/tooltip@npm:^3.5.1": + version: 3.5.1 + resolution: "@react-types/tooltip@npm:3.5.1" + dependencies: + "@react-types/overlays": "npm:^3.9.3" + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/cf563a16bfece724ec32d5d344f1221a16256ef07c6b83d0b673ec443e6ac22218b05cb11ad351b6082286feb139a72e38ef34625941992c6b0c993966aeb31e + languageName: node + linkType: hard + "@red-hat-developer-hub/backstage-plugin-theme@npm:0.14.4": version: 0.14.4 resolution: "@red-hat-developer-hub/backstage-plugin-theme@npm:0.14.4" @@ -13527,13 +15109,6 @@ __metadata: languageName: node linkType: hard -"@sqltools/formatter@npm:^1.2.5": - version: 1.2.5 - resolution: "@sqltools/formatter@npm:1.2.5" - checksum: 10c0/4b4fa62b8cd4880784b71cc5edd4a13da04fda0a915c14282765a8ec1a900a495e69b322704413e2052d221b5646d9fb0e20e87911f9a8f438f33180eecb11a4 - languageName: node - linkType: hard - "@standard-schema/spec@npm:^1.0.0, @standard-schema/spec@npm:^1.1.0": version: 1.1.0 resolution: "@standard-schema/spec@npm:1.1.0" @@ -15411,13 +16986,6 @@ __metadata: languageName: node linkType: hard -"@types/js-yaml@npm:^4.0.9": - version: 4.0.9 - resolution: "@types/js-yaml@npm:4.0.9" - checksum: 10c0/24de857aa8d61526bbfbbaa383aa538283ad17363fcd5bb5148e2c7f604547db36646440e739d78241ed008702a8920665d1add5618687b6743858fae00da211 - languageName: node - linkType: hard - "@types/jsdom@npm:^21.1.7": version: 21.1.7 resolution: "@types/jsdom@npm:21.1.7" @@ -15469,7 +17037,7 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:^4.14.151, @types/lodash@npm:^4.14.175": +"@types/lodash@npm:^4.14.175": version: 4.17.24 resolution: "@types/lodash@npm:4.17.24" checksum: 10c0/b72f60d4daacdad1fa643edb3faba204c02a01eb1ac00a83ff73496a6d236fc55e459c06106e8ced42277dba932d087d8fc090f8de4ef590d3f91e6d6f7ce85a @@ -15605,15 +17173,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:22.19.17": - version: 22.19.17 - resolution: "@types/node@npm:22.19.17" - dependencies: - undici-types: "npm:~6.21.0" - checksum: 10c0/b66c484c0a9f6d88b1ef360b0f487717234ee1a482cb2551ff73d9f3c43a42a777daf4c8a5eee970960728f8fe1f3877d3d8c6ffabcbca74cb401a59db700fa4 - languageName: node - linkType: hard - "@types/node@npm:24.12.2": version: 24.12.2 resolution: "@types/node@npm:24.12.2" @@ -16004,16 +17563,6 @@ __metadata: languageName: node linkType: hard -"@types/supertest@npm:7.2.0": - version: 7.2.0 - resolution: "@types/supertest@npm:7.2.0" - dependencies: - "@types/methods": "npm:^1.1.4" - "@types/superagent": "npm:^8.1.0" - checksum: 10c0/78c33e968acd45207acdd965ccbd5eb7a279813ff68fab1acc438937ed017698102cc077cef8aa60ec6caefff2fa61171e902eab40607fd7ce82ead3a82b766e - languageName: node - linkType: hard - "@types/tedious@npm:^4.0.14": version: 4.0.14 resolution: "@types/tedious@npm:4.0.14" @@ -17070,13 +18619,6 @@ __metadata: languageName: node linkType: hard -"ansis@npm:^4.2.0": - version: 4.3.0 - resolution: "ansis@npm:4.3.0" - checksum: 10c0/ef9992c645ca9713f509488e02e600c87986366888c7338fbea300a86a9a82949b011687084275670f6c6a2e009aa2174c06428f6f279aaf3263811b12a25bea - languageName: node - linkType: hard - "any-promise@npm:^1.0.0": version: 1.3.0 resolution: "any-promise@npm:1.3.0" @@ -17162,13 +18704,6 @@ __metadata: languageName: unknown linkType: soft -"app-root-path@npm:^3.1.0": - version: 3.1.0 - resolution: "app-root-path@npm:3.1.0" - checksum: 10c0/4a0fd976de1bffcdb18a5e1f8050091f15d0780e0582bca99aaa9d52de71f0e08e5185355fcffc781180bfb898499e787a2f5ed79b9c448b942b31dc947acaa9 - languageName: node - linkType: hard - "app@workspace:*, app@workspace:packages/app": version: 0.0.0-use.local resolution: "app@workspace:packages/app" @@ -17632,13 +19167,6 @@ __metadata: languageName: node linkType: hard -"await-lock@npm:^2.0.1": - version: 2.2.2 - resolution: "await-lock@npm:2.2.2" - checksum: 10c0/bedf00dad44c6325a655bf3bd523ab9e1ce41023da6a8c379990c76ac1d942ac7e5301627ab84ba37917ab5247506ba429b7f6e4bf77074093f255571b9ad5ee - languageName: node - linkType: hard - "aws-ssl-profiles@npm:^1.1.2": version: 1.1.2 resolution: "aws-ssl-profiles@npm:1.1.2" @@ -17653,7 +19181,18 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.12.0, axios@npm:^1.12.2, axios@npm:^1.7.3, axios@npm:^1.7.4": +"axios@npm:^1.12.0, axios@npm:^1.12.2, axios@npm:^1.7.4": + version: 1.13.5 + resolution: "axios@npm:1.13.5" + dependencies: + follow-redirects: "npm:^1.15.11" + form-data: "npm:^4.0.5" + proxy-from-env: "npm:^1.1.0" + checksum: 10c0/abf468c34f2d145f3dc7dbc0f1be67e520630624307bda69a41bbe8d386bd672d87b4405c4ee77f9ff54b235ab02f96a9968fb00e75b13ce64706e352a3068fd + languageName: node + linkType: hard + +"axios@npm:^1.7.3": version: 1.13.6 resolution: "axios@npm:1.13.6" dependencies: @@ -17858,7 +19397,6 @@ __metadata: "@backstage/plugin-user-settings-backend": "npm:0.4.1" "@internal/plugin-dynamic-plugins-info-backend": "npm:*" "@internal/plugin-licensed-users-info-backend": "npm:*" - "@internal/plugin-rbac-backend": "npm:*" "@internal/plugin-scalprum-backend": "npm:*" "@opentelemetry/api": "npm:1.9.1" "@opentelemetry/auto-instrumentations-node": "npm:0.76.0" @@ -18171,9 +19709,9 @@ __metadata: languageName: node linkType: hard -"body-parser@npm:^1.15.2, body-parser@npm:~1.20.3, body-parser@npm:~1.20.5": - version: 1.20.5 - resolution: "body-parser@npm:1.20.5" +"body-parser@npm:^1.15.2, body-parser@npm:~1.20.3": + version: 1.20.4 + resolution: "body-parser@npm:1.20.4" dependencies: bytes: "npm:~3.1.2" content-type: "npm:~1.0.5" @@ -18183,11 +19721,11 @@ __metadata: http-errors: "npm:~2.0.1" iconv-lite: "npm:~0.4.24" on-finished: "npm:~2.4.1" - qs: "npm:~6.15.1" + qs: "npm:~6.14.0" raw-body: "npm:~2.5.3" type-is: "npm:~1.6.18" unpipe: "npm:~1.0.0" - checksum: 10c0/ad777ca5e4711eae253c93f50fdc4608c60b76a9710d79e5e5b84581c76691e6ad21ecc9158986d9ea2b365df73e403ca33c27a8bccc1a7cfc2ccc248548118d + checksum: 10c0/569c1e896297d1fcd8f34026c8d0ab70b90d45343c15c5d8dff5de2bad08125fc1e2f8c2f3f4c1ac6c0caaad115218202594d37dcb8d89d9b5dcae1c2b736aa9 languageName: node linkType: hard @@ -18676,32 +20214,6 @@ __metadata: languageName: node linkType: hard -"casbin@npm:5.27.1": - version: 5.27.1 - resolution: "casbin@npm:5.27.1" - dependencies: - await-lock: "npm:^2.0.1" - buffer: "npm:^6.0.3" - csv-parse: "npm:^5.3.5" - expression-eval: "npm:^5.0.0" - minimatch: "npm:^7.4.2" - checksum: 10c0/7113eb7fd9a2a2b8e36093eecc687bad30f4751dc2bb9b7931e89ed02fce13c84b4221aadff157210eb9201d0cb822b92a309cdb510a5801d9d112235c22e6c0 - languageName: node - linkType: hard - -"casbin@npm:^5.27.0": - version: 5.50.0 - resolution: "casbin@npm:5.50.0" - dependencies: - "@casbin/expression-eval": "npm:^5.3.0" - await-lock: "npm:^2.0.1" - buffer: "npm:^6.0.3" - csv-parse: "npm:^5.5.6" - minimatch: "npm:^10.2.1" - checksum: 10c0/c0c54e2b589802d25a332e0333bbad4bddb7e920efe99dcaa09ad659d329bd2455e752f3fb5526e2e98453e38532d86f7d04c07f846f2e4c5df64143acb3cc16 - languageName: node - linkType: hard - "catharsis@npm:^0.9.0": version: 0.9.0 resolution: "catharsis@npm:0.9.0" @@ -19361,7 +20873,7 @@ __metadata: languageName: node linkType: hard -"component-emitter@npm:^1.3.0, component-emitter@npm:^1.3.1": +"component-emitter@npm:^1.3.0": version: 1.3.1 resolution: "component-emitter@npm:1.3.1" checksum: 10c0/e4900b1b790b5e76b8d71b328da41482118c0f3523a516a41be598dc2785a07fd721098d9bf6e22d89b19f4fa4e1025160dc00317ea111633a3e4f75c2b86032 @@ -19570,7 +21082,7 @@ __metadata: languageName: node linkType: hard -"cookie-signature@npm:^1.2.1, cookie-signature@npm:^1.2.2": +"cookie-signature@npm:^1.2.1": version: 1.2.2 resolution: "cookie-signature@npm:1.2.2" checksum: 10c0/54e05df1a293b3ce81589b27dddc445f462f6fa6812147c033350cd3561a42bc14481674e05ed14c7bd0ce1e8bb3dc0e40851bad75415733711294ddce0b7bc6 @@ -20109,20 +21621,6 @@ __metadata: languageName: node linkType: hard -"csv-parse@npm:^5.3.5, csv-parse@npm:^5.5.6": - version: 5.6.0 - resolution: "csv-parse@npm:5.6.0" - checksum: 10c0/52f5e6c45359902e0c8e57fc2eeed41366dc6b6d283b495b538dd50c8e8510413d6f924096ea056319cbbb8ed26e111c3a3485d7985c021bcf5abaa9e92425c7 - languageName: node - linkType: hard - -"csv-parse@npm:^6.0.0": - version: 6.2.1 - resolution: "csv-parse@npm:6.2.1" - checksum: 10c0/8b6f14b244ca62476d4217aac721131ba0ada3e4ed7614e43ebc99203807564dcb054144d1de4ef22ee8b0c63b431640f75d46a0e1e0f72853a954b279ba1c61 - languageName: node - linkType: hard - "ctrlc-windows@npm:^2.1.0": version: 2.2.0 resolution: "ctrlc-windows@npm:2.2.0" @@ -20325,13 +21823,6 @@ __metadata: languageName: node linkType: hard -"dayjs@npm:^1.11.20": - version: 1.11.21 - resolution: "dayjs@npm:1.11.21" - checksum: 10c0/bd97dfdc4bfea3c66268635690313828b386faa040fbc1f829ff42a2bd748b72c9d9b3c8f9616ce9e61fcb78923f1461a462c969c54b1084458ae1b715898fb0 - languageName: node - linkType: hard - "debounce-promise@npm:^3.1.2": version: 3.1.2 resolution: "debounce-promise@npm:3.1.2" @@ -20355,7 +21846,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.3.7, debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3": +"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -20413,15 +21904,15 @@ __metadata: languageName: node linkType: hard -"dedent@npm:^1.6.0, dedent@npm:^1.7.2": - version: 1.7.2 - resolution: "dedent@npm:1.7.2" +"dedent@npm:^1.6.0": + version: 1.7.1 + resolution: "dedent@npm:1.7.1" peerDependencies: babel-plugin-macros: ^3.1.0 peerDependenciesMeta: babel-plugin-macros: optional: true - checksum: 10c0/acaff07cac355b93f17b1b17ebbb84d3cc55af6ab4b7814c3f505e061903e168bc6bf9ddce331552d64dee1525f0b4c549c9ade46aebfac6f69caaed74e90751 + checksum: 10c0/ae29ec1c5bd5216c698c9f23acaa5b720260fd4cef3c8b5af887eb5f8c9e6fdd5fed8668767437b4efea35e2991bd798987717633411a1734807c28255769b78 languageName: node linkType: hard @@ -20945,13 +22436,6 @@ __metadata: languageName: node linkType: hard -"dotenv@npm:^16.6.1": - version: 16.6.1 - resolution: "dotenv@npm:16.6.1" - checksum: 10c0/15ce56608326ea0d1d9414a5c8ee6dcf0fffc79d2c16422b4ac2268e7e2d76ff5a572d37ffe747c377de12005f14b3cc22361e79fc7f1061cce81f77d2c973dc - languageName: node - linkType: hard - "drange@npm:^1.0.2": version: 1.1.1 resolution: "drange@npm:1.1.1" @@ -22420,7 +23904,7 @@ __metadata: languageName: node linkType: hard -"express@npm:4.22.1": +"express@npm:4.22.1, express@npm:^4.14.0, express@npm:^4.17.3, express@npm:^4.22.0, express@npm:^4.22.1": version: 4.22.1 resolution: "express@npm:4.22.1" dependencies: @@ -22459,45 +23943,6 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.14.0, express@npm:^4.17.3, express@npm:^4.18.2, express@npm:^4.22.0, express@npm:^4.22.1": - version: 4.22.2 - resolution: "express@npm:4.22.2" - dependencies: - accepts: "npm:~1.3.8" - array-flatten: "npm:1.1.1" - body-parser: "npm:~1.20.5" - content-disposition: "npm:~0.5.4" - content-type: "npm:~1.0.4" - cookie: "npm:~0.7.1" - cookie-signature: "npm:~1.0.6" - debug: "npm:2.6.9" - depd: "npm:2.0.0" - encodeurl: "npm:~2.0.0" - escape-html: "npm:~1.0.3" - etag: "npm:~1.8.1" - finalhandler: "npm:~1.3.1" - fresh: "npm:~0.5.2" - http-errors: "npm:~2.0.0" - merge-descriptors: "npm:1.0.3" - methods: "npm:~1.1.2" - on-finished: "npm:~2.4.1" - parseurl: "npm:~1.3.3" - path-to-regexp: "npm:~0.1.12" - proxy-addr: "npm:~2.0.7" - qs: "npm:~6.15.1" - range-parser: "npm:~1.2.1" - safe-buffer: "npm:5.2.1" - send: "npm:~0.19.0" - serve-static: "npm:~1.16.2" - setprototypeof: "npm:1.2.0" - statuses: "npm:~2.0.1" - type-is: "npm:~1.6.18" - utils-merge: "npm:1.0.1" - vary: "npm:~1.1.2" - checksum: 10c0/d06dd4379fd217440b30f8abbe45f0e74931114c1395034f03e7d635196ecdab530d4835a1962a6aa34838d61967dc6f1f77846999bba3032373e9e714222c44 - languageName: node - linkType: hard - "express@npm:^5.2.1": version: 5.2.1 resolution: "express@npm:5.2.1" @@ -22534,15 +23979,6 @@ __metadata: languageName: node linkType: hard -"expression-eval@npm:^5.0.0": - version: 5.0.1 - resolution: "expression-eval@npm:5.0.1" - dependencies: - jsep: "npm:^0.3.0" - checksum: 10c0/74f9e1e54e50b3c924a71bcddf1550c51f15e24646f2b6cb8c45c7dd3731eb3f0e1e9a6dbf895549ddc445fe66909c373779ea6bf7f6f1e90d6bcef1590543ff - languageName: node - linkType: hard - "extend-shallow@npm:^2.0.1": version: 2.0.1 resolution: "extend-shallow@npm:2.0.1" @@ -22665,6 +24101,13 @@ __metadata: languageName: node linkType: hard +"fast-xml-builder@npm:^1.0.0": + version: 1.0.0 + resolution: "fast-xml-builder@npm:1.0.0" + checksum: 10c0/2631fda265c81e8008884d08944eeed4e284430116faa5b8b7a43a3602af367223b7bf01c933215c9ad2358b8666e45041bc038d64877156a2f88821841b3014 + languageName: node + linkType: hard + "fast-xml-builder@npm:^1.1.5": version: 1.1.5 resolution: "fast-xml-builder@npm:1.1.5" @@ -22674,7 +24117,7 @@ __metadata: languageName: node linkType: hard -"fast-xml-parser@npm:5.7.1, fast-xml-parser@npm:^5.0.7, fast-xml-parser@npm:^5.3.4": +"fast-xml-parser@npm:5.7.1": version: 5.7.1 resolution: "fast-xml-parser@npm:5.7.1" dependencies: @@ -22688,6 +24131,18 @@ __metadata: languageName: node linkType: hard +"fast-xml-parser@npm:^5.0.7, fast-xml-parser@npm:^5.3.4": + version: 5.4.1 + resolution: "fast-xml-parser@npm:5.4.1" + dependencies: + fast-xml-builder: "npm:^1.0.0" + strnum: "npm:^2.1.2" + bin: + fxparser: src/cli/cli.js + checksum: 10c0/8c696438a0c64135faf93ea6a93879208d649b7c9a3293d30d6eb750dc7f766fd083c0df5a82786b60809c3ead64fad155f28dbed25efea91017aaf9f64c91e5 + languageName: node + linkType: hard + "fastest-stable-stringify@npm:^2.0.2": version: 2.0.2 resolution: "fastest-stable-stringify@npm:2.0.2" @@ -22983,7 +24438,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.15.11": +"follow-redirects@npm:^1.0.0": version: 1.16.0 resolution: "follow-redirects@npm:1.16.0" peerDependenciesMeta: @@ -22993,6 +24448,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.11": + version: 1.15.11 + resolution: "follow-redirects@npm:1.15.11" + peerDependenciesMeta: + debug: + optional: true + checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343 + languageName: node + linkType: hard + "for-each@npm:^0.3.3, for-each@npm:^0.3.5": version: 0.3.5 resolution: "for-each@npm:0.3.5" @@ -23155,17 +24620,6 @@ __metadata: languageName: node linkType: hard -"formidable@npm:^3.5.4": - version: 3.5.4 - resolution: "formidable@npm:3.5.4" - dependencies: - "@paralleldrive/cuid2": "npm:^2.2.2" - dezalgo: "npm:^1.0.4" - once: "npm:^1.4.0" - checksum: 10c0/3a311ce57617eb8f532368e91c0f2bbfb299a0f1a35090e085bd6ca772298f196fbb0b66f0d4b5549d7bf3c5e1844439338d4402b7b6d1fedbe206ad44a931f8 - languageName: node - linkType: hard - "formstream@npm:^1.5.1": version: 1.5.2 resolution: "formstream@npm:1.5.2" @@ -26643,13 +28097,6 @@ __metadata: languageName: node linkType: hard -"jsep@npm:^0.3.0": - version: 0.3.5 - resolution: "jsep@npm:0.3.5" - checksum: 10c0/fb5def7a4ba1cee41d144ebdd0d477785dc84b6bc1fed6cf5169f106de980dbe363bf99cb36a450435d7fd952d22b1d76e1609aeb5c7e7cbbbdb6d15fad03614 - languageName: node - linkType: hard - "jsep@npm:^1.2.0, jsep@npm:^1.4.0": version: 1.4.0 resolution: "jsep@npm:1.4.0" @@ -27117,17 +28564,6 @@ __metadata: languageName: node linkType: hard -"knex-mock-client@npm:3.0.2": - version: 3.0.2 - resolution: "knex-mock-client@npm:3.0.2" - dependencies: - lodash.clonedeep: "npm:^4.5.0" - peerDependencies: - knex: ">=2.0.0" - checksum: 10c0/95b0430a7d5f074afb142644c0b60f8824f065608982a6321e816b9b1fcf6edb7c532cf8930372d8f5f86c57a407ab89bb076e4419cf5d0a10fb6df550dd7020 - languageName: node - linkType: hard - "knex@npm:3, knex@npm:3.1.0, knex@npm:^3.0.0": version: 3.1.0 resolution: "knex@npm:3.1.0" @@ -28827,15 +30263,6 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^7.4.2": - version: 7.4.9 - resolution: "minimatch@npm:7.4.9" - dependencies: - brace-expansion: "npm:^2.0.2" - checksum: 10c0/8d5406a9697edb9b7ea02697d58cabcb3d3a9a4a02caa1cf57b9ab5ae22c78b2945600661a78f91d1545f77521f97f3cb5f8cb066e58356a121b50e4e60ccdbe - languageName: node - linkType: hard - "minimatch@npm:^9.0.4": version: 9.0.9 resolution: "minimatch@npm:9.0.9" @@ -31933,7 +33360,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:6.15.1": +"qs@npm:^6.10.1, qs@npm:^6.11.0, qs@npm:^6.12.1, qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.14.1, qs@npm:^6.9.4": version: 6.15.1 resolution: "qs@npm:6.15.1" dependencies: @@ -31942,15 +33369,6 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.10.1, qs@npm:^6.11.0, qs@npm:^6.12.1, qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.14.1, qs@npm:^6.9.4, qs@npm:~6.15.1": - version: 6.15.2 - resolution: "qs@npm:6.15.2" - dependencies: - side-channel: "npm:^1.1.0" - checksum: 10c0/e6fd5f6f0aab06d480fe9ab15cebfc4ce4235303e2f91dc69a8f7f4df1e668a61c11d1cfbabacf4295cbbeb7b670ed23db45307480726259761f98e5695e93a7 - languageName: node - linkType: hard - "qs@npm:~6.14.0": version: 6.14.2 resolution: "qs@npm:6.14.2" @@ -32164,7 +33582,47 @@ __metadata: languageName: node linkType: hard -"react-aria-components@npm:^1.14.0, react-aria-components@npm:~1.17.0": +"react-aria-components@npm:^1.14.0": + version: 1.15.1 + resolution: "react-aria-components@npm:1.15.1" + dependencies: + "@internationalized/date": "npm:^3.11.0" + "@internationalized/string": "npm:^3.2.7" + "@react-aria/autocomplete": "npm:3.0.0-rc.5" + "@react-aria/collections": "npm:^3.0.2" + "@react-aria/dnd": "npm:^3.11.5" + "@react-aria/focus": "npm:^3.21.4" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/live-announcer": "npm:^3.4.4" + "@react-aria/overlays": "npm:^3.31.1" + "@react-aria/ssr": "npm:^3.9.10" + "@react-aria/textfield": "npm:^3.18.4" + "@react-aria/toolbar": "npm:3.0.0-beta.23" + "@react-aria/utils": "npm:^3.33.0" + "@react-aria/virtualizer": "npm:^4.1.12" + "@react-stately/autocomplete": "npm:3.0.0-beta.4" + "@react-stately/layout": "npm:^4.5.3" + "@react-stately/selection": "npm:^3.20.8" + "@react-stately/table": "npm:^3.15.3" + "@react-stately/utils": "npm:^3.11.0" + "@react-stately/virtualizer": "npm:^4.4.5" + "@react-types/form": "npm:^3.7.17" + "@react-types/grid": "npm:^3.3.7" + "@react-types/shared": "npm:^3.33.0" + "@react-types/table": "npm:^3.13.5" + "@swc/helpers": "npm:^0.5.0" + client-only: "npm:^0.0.1" + react-aria: "npm:^3.46.0" + react-stately: "npm:^3.44.0" + use-sync-external-store: "npm:^1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/6aa61901aa67086d403d9d7b52ea8dd3ca4239bd04bbeff83e09292131e31da91bc94dee6841cb53f36e32ff614e708d22c5590e14eae6eba5712256e31658f4 + languageName: node + linkType: hard + +"react-aria-components@npm:~1.17.0": version: 1.17.0 resolution: "react-aria-components@npm:1.17.0" dependencies: @@ -32201,6 +33659,59 @@ __metadata: languageName: node linkType: hard +"react-aria@npm:^3.46.0": + version: 3.46.0 + resolution: "react-aria@npm:3.46.0" + dependencies: + "@internationalized/string": "npm:^3.2.7" + "@react-aria/breadcrumbs": "npm:^3.5.31" + "@react-aria/button": "npm:^3.14.4" + "@react-aria/calendar": "npm:^3.9.4" + "@react-aria/checkbox": "npm:^3.16.4" + "@react-aria/color": "npm:^3.1.4" + "@react-aria/combobox": "npm:^3.14.2" + "@react-aria/datepicker": "npm:^3.16.0" + "@react-aria/dialog": "npm:^3.5.33" + "@react-aria/disclosure": "npm:^3.1.2" + "@react-aria/dnd": "npm:^3.11.5" + "@react-aria/focus": "npm:^3.21.4" + "@react-aria/gridlist": "npm:^3.14.3" + "@react-aria/i18n": "npm:^3.12.15" + "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/label": "npm:^3.7.24" + "@react-aria/landmark": "npm:^3.0.9" + "@react-aria/link": "npm:^3.8.8" + "@react-aria/listbox": "npm:^3.15.2" + "@react-aria/menu": "npm:^3.20.0" + "@react-aria/meter": "npm:^3.4.29" + "@react-aria/numberfield": "npm:^3.12.4" + "@react-aria/overlays": "npm:^3.31.1" + "@react-aria/progress": "npm:^3.4.29" + "@react-aria/radio": "npm:^3.12.4" + "@react-aria/searchfield": "npm:^3.8.11" + "@react-aria/select": "npm:^3.17.2" + "@react-aria/selection": "npm:^3.27.1" + "@react-aria/separator": "npm:^3.4.15" + "@react-aria/slider": "npm:^3.8.4" + "@react-aria/ssr": "npm:^3.9.10" + "@react-aria/switch": "npm:^3.7.10" + "@react-aria/table": "npm:^3.17.10" + "@react-aria/tabs": "npm:^3.11.0" + "@react-aria/tag": "npm:^3.8.0" + "@react-aria/textfield": "npm:^3.18.4" + "@react-aria/toast": "npm:^3.0.10" + "@react-aria/tooltip": "npm:^3.9.1" + "@react-aria/tree": "npm:^3.1.6" + "@react-aria/utils": "npm:^3.33.0" + "@react-aria/visually-hidden": "npm:^3.8.30" + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/1147777f6ee54f38e53ff5b8aca3d99d5f3c68c16cbabe07d8572b6f34142d3457d58a67c9ab547f0b4e2ecca3f111baf8d283a0e294115a1818af90f86daea1 + languageName: node + linkType: hard + "react-beautiful-dnd@npm:^13.0.0": version: 13.1.1 resolution: "react-beautiful-dnd@npm:13.1.1" @@ -32672,6 +34183,42 @@ __metadata: languageName: node linkType: hard +"react-stately@npm:^3.44.0": + version: 3.44.0 + resolution: "react-stately@npm:3.44.0" + dependencies: + "@react-stately/calendar": "npm:^3.9.2" + "@react-stately/checkbox": "npm:^3.7.4" + "@react-stately/collections": "npm:^3.12.9" + "@react-stately/color": "npm:^3.9.4" + "@react-stately/combobox": "npm:^3.12.2" + "@react-stately/data": "npm:^3.15.1" + "@react-stately/datepicker": "npm:^3.16.0" + "@react-stately/disclosure": "npm:^3.0.10" + "@react-stately/dnd": "npm:^3.7.3" + "@react-stately/form": "npm:^3.2.3" + "@react-stately/list": "npm:^3.13.3" + "@react-stately/menu": "npm:^3.9.10" + "@react-stately/numberfield": "npm:^3.10.4" + "@react-stately/overlays": "npm:^3.6.22" + "@react-stately/radio": "npm:^3.11.4" + "@react-stately/searchfield": "npm:^3.5.18" + "@react-stately/select": "npm:^3.9.1" + "@react-stately/selection": "npm:^3.20.8" + "@react-stately/slider": "npm:^3.7.4" + "@react-stately/table": "npm:^3.15.3" + "@react-stately/tabs": "npm:^3.8.8" + "@react-stately/toast": "npm:^3.1.3" + "@react-stately/toggle": "npm:^3.9.4" + "@react-stately/tooltip": "npm:^3.5.10" + "@react-stately/tree": "npm:^3.9.5" + "@react-types/shared": "npm:^3.33.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/0476362742d729033fc3fe5f601c587605d2374cd44adadeee77b9d476791c20f5879473f3bcbeccc5c1fcae6c7d424d7bebbdc1828a1792e0a42e667b4ded46 + languageName: node + linkType: hard + "react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3": version: 2.2.3 resolution: "react-style-singleton@npm:2.2.3" @@ -32965,13 +34512,6 @@ __metadata: languageName: node linkType: hard -"reflect-metadata@npm:^0.1.13": - version: 0.1.14 - resolution: "reflect-metadata@npm:0.1.14" - checksum: 10c0/3a6190c7f6cb224f26a012d11f9e329360c01c1945e2cbefea23976a8bacf9db6b794aeb5bf18adcb673c448a234fbc06fc41853c00a6c206b30f0777ecf019e - languageName: node - linkType: hard - "reflect-metadata@npm:^0.2.2": version: 0.2.2 resolution: "reflect-metadata@npm:0.2.2" @@ -34665,13 +36205,6 @@ __metadata: languageName: node linkType: hard -"sql-highlight@npm:^6.1.0": - version: 6.1.0 - resolution: "sql-highlight@npm:6.1.0" - checksum: 10c0/9614f4608bfde8ea7bf9b2fe9233dcc99a619c91cbc3f5cd85a6fb5ad4b2177f4ac8ca4a0191f4243ff8aea3b6f2a1229efc88635298269e0049b2ac08bde263 - languageName: node - linkType: hard - "ssh-remote-port-forward@npm:^1.0.4": version: 1.0.4 resolution: "ssh-remote-port-forward@npm:1.0.4" @@ -35177,6 +36710,13 @@ __metadata: languageName: node linkType: hard +"strnum@npm:^2.1.2": + version: 2.1.2 + resolution: "strnum@npm:2.1.2" + checksum: 10c0/4e04753b793540d79cd13b2c3e59e298440477bae2b853ab78d548138385193b37d766d95b63b7046475d68d44fb1fca692f0a3f72b03f4168af076c7b246df9 + languageName: node + linkType: hard + "strnum@npm:^2.2.3": version: 2.2.3 resolution: "strnum@npm:2.2.3" @@ -35286,23 +36826,6 @@ __metadata: languageName: node linkType: hard -"superagent@npm:^10.3.0": - version: 10.3.0 - resolution: "superagent@npm:10.3.0" - dependencies: - component-emitter: "npm:^1.3.1" - cookiejar: "npm:^2.1.4" - debug: "npm:^4.3.7" - fast-safe-stringify: "npm:^2.1.1" - form-data: "npm:^4.0.5" - formidable: "npm:^3.5.4" - methods: "npm:^1.1.2" - mime: "npm:2.6.0" - qs: "npm:^6.14.1" - checksum: 10c0/7792ec2ba3a877eb1db57ad9149bf0e108688a40af0e75084bdc4bba82604b7962248267159565be4f5ab8aa392f1285a08d2e5a8cb2c3b0a51932499caf6ee6 - languageName: node - linkType: hard - "superagent@npm:^8.1.2": version: 8.1.2 resolution: "superagent@npm:8.1.2" @@ -35331,17 +36854,6 @@ __metadata: languageName: node linkType: hard -"supertest@npm:7.2.2": - version: 7.2.2 - resolution: "supertest@npm:7.2.2" - dependencies: - cookie-signature: "npm:^1.2.2" - methods: "npm:^1.1.2" - superagent: "npm:^10.3.0" - checksum: 10c0/9de987aefbec50c5dfac79ff699bbc23c89cdbfe59ede165309fb3cf00c306117b30c5059fe6f7085c7525aab315ac27c77a1dc6056b993c7e0bb154a56c2b78 - languageName: node - linkType: hard - "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -36499,94 +38011,6 @@ __metadata: languageName: node linkType: hard -"typeorm-adapter@npm:^1.6.1": - version: 1.9.0 - resolution: "typeorm-adapter@npm:1.9.0" - dependencies: - casbin: "npm:^5.27.0" - reflect-metadata: "npm:^0.1.13" - typeorm: "npm:^0.3.17" - checksum: 10c0/13a8cfdad81b0b262c2b38ca83bece96e4ca6c703a40ce1594b186b08e2d8afa8614af864c24b809a4f8b14df04f213fa322303dfaa8ea11401215fa9bd33f85 - languageName: node - linkType: hard - -"typeorm@npm:^0.3.17": - version: 0.3.30 - resolution: "typeorm@npm:0.3.30" - dependencies: - "@sqltools/formatter": "npm:^1.2.5" - ansis: "npm:^4.2.0" - app-root-path: "npm:^3.1.0" - buffer: "npm:^6.0.3" - dayjs: "npm:^1.11.20" - debug: "npm:^4.4.3" - dedent: "npm:^1.7.2" - dotenv: "npm:^16.6.1" - glob: "npm:^10.5.0" - reflect-metadata: "npm:^0.2.2" - sha.js: "npm:^2.4.12" - sql-highlight: "npm:^6.1.0" - tslib: "npm:^2.8.1" - uuid: "npm:^11.1.1" - yargs: "npm:^17.7.2" - peerDependencies: - "@google-cloud/spanner": ^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - "@sap/hana-client": ^2.14.22 - better-sqlite3: ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 - ioredis: ^5.0.4 - mongodb: ^5.8.0 || ^6.0.0 - mssql: ^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0 - mysql2: ^2.2.5 || ^3.0.1 - oracledb: ^6.3.0 - pg: ^8.5.1 - pg-native: ^3.0.0 - pg-query-stream: ^4.0.0 - redis: ^3.1.1 || ^4.0.0 || ^5.0.14 - sql.js: ^1.4.0 - sqlite3: ^5.0.3 - ts-node: ^10.7.0 - typeorm-aurora-data-api-driver: ^2.0.0 || ^3.0.0 - peerDependenciesMeta: - "@google-cloud/spanner": - optional: true - "@sap/hana-client": - optional: true - better-sqlite3: - optional: true - ioredis: - optional: true - mongodb: - optional: true - mssql: - optional: true - mysql2: - optional: true - oracledb: - optional: true - pg: - optional: true - pg-native: - optional: true - pg-query-stream: - optional: true - redis: - optional: true - sql.js: - optional: true - sqlite3: - optional: true - ts-node: - optional: true - typeorm-aurora-data-api-driver: - optional: true - bin: - typeorm: cli.js - typeorm-ts-node-commonjs: cli-ts-node-commonjs.js - typeorm-ts-node-esm: cli-ts-node-esm.js - checksum: 10c0/7102d1e1d65ed69642414bfb4705ef658ecdd00eb73b557b79a2c7be5b2aadeb366d623762a4773d31088737d30f331e42378e4e66d77572dd94181b37b30c9e - languageName: node - linkType: hard - "types-ramda@npm:^0.30.1": version: 0.30.1 resolution: "types-ramda@npm:0.30.1" @@ -36779,13 +38203,6 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~6.21.0": - version: 6.21.0 - resolution: "undici-types@npm:6.21.0" - checksum: 10c0/c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04 - languageName: node - linkType: hard - "undici-types@npm:~7.16.0": version: 7.16.0 resolution: "undici-types@npm:7.16.0" @@ -36800,13 +38217,27 @@ __metadata: languageName: node linkType: hard -"undici@npm:7.25.0, undici@npm:^7.1.1, undici@npm:^7.16.0, undici@npm:^7.2.3, undici@npm:^7.21.0, undici@npm:^7.22.0": +"undici@npm:7.25.0, undici@npm:^7.1.1": version: 7.25.0 resolution: "undici@npm:7.25.0" checksum: 10c0/02a0b45dc14eb91bc488948750232450fe52f27a6b08086d6ac6736bb47908d600fe3a96d346f12eab24729c782e5c2f693bc8e8eca6696d4e4c09b1ed4cb4ec languageName: node linkType: hard +"undici@npm:^7.16.0": + version: 7.24.6 + resolution: "undici@npm:7.24.6" + checksum: 10c0/0f5413ccb20bafe27637a3a02cada731c53ee75f1df79029099db3af1eaaed410488489d9f430c09bd30bf0b925cb75fc30c39dff0689f656fd6fb7d75ded95f + languageName: node + linkType: hard + +"undici@npm:^7.2.3, undici@npm:^7.21.0, undici@npm:^7.22.0": + version: 7.22.0 + resolution: "undici@npm:7.22.0" + checksum: 10c0/09777c06f3f18f761f03e3a4c9c04fd9fcca8ad02ccea43602ee4adf73fcba082806f1afb637f6ea714ef6279c5323c25b16d435814c63db720f63bfc20d316b + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.1 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.1" @@ -37321,12 +38752,12 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^11.0.0, uuid@npm:^11.0.2, uuid@npm:^11.1.1": - version: 11.1.1 - resolution: "uuid@npm:11.1.1" +"uuid@npm:^11.0.0, uuid@npm:^11.0.2": + version: 11.1.0 + resolution: "uuid@npm:11.1.0" bin: uuid: dist/esm/bin/uuid - checksum: 10c0/9e3af58eba872ece5a5e76f4773a94fc78a0ef2c2444c38dbe6b42f41dadf76c01850fd783604f27986f6195e6286aef064d45987d401b2a33127b98ddf7c0c5 + checksum: 10c0/34aa51b9874ae398c2b799c88a127701408cd581ee89ec3baa53509dd8728cbb25826f2a038f9465f8b7be446f0fbf11558862965b18d21c993684297628d4d3 languageName: node linkType: hard @@ -38451,14 +39882,14 @@ __metadata: languageName: node linkType: hard -"zod@npm:4.3.6": +"zod@npm:4.3.6, zod@npm:^4.1.13": version: 4.3.6 resolution: "zod@npm:4.3.6" checksum: 10c0/860d25a81ab41d33aa25f8d0d07b091a04acb426e605f396227a796e9e800c44723ed96d0f53a512b57be3d1520f45bf69c0cb3b378a232a00787a2609625307 languageName: node linkType: hard -"zod@npm:^4.0.0, zod@npm:^4.1.13, zod@npm:^4.3.6": +"zod@npm:^4.0.0": version: 4.4.3 resolution: "zod@npm:4.4.3" checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3