diff --git a/controlplane/migrations/0131_known_stepford_cuckoos.sql b/controlplane/migrations/0131_known_stepford_cuckoos.sql new file mode 100644 index 0000000000..ea233a5f0a --- /dev/null +++ b/controlplane/migrations/0131_known_stepford_cuckoos.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."organization_role" ADD VALUE 'subgraph-checker' BEFORE 'subgraph-viewer'; \ No newline at end of file diff --git a/controlplane/migrations/meta/0129_snapshot.json b/controlplane/migrations/meta/0131_snapshot.json similarity index 99% rename from controlplane/migrations/meta/0129_snapshot.json rename to controlplane/migrations/meta/0131_snapshot.json index 1c7a51651b..c6525657d3 100644 --- a/controlplane/migrations/meta/0129_snapshot.json +++ b/controlplane/migrations/meta/0131_snapshot.json @@ -1,6 +1,6 @@ { - "id": "1bb12880-ed57-4cad-b9aa-dcd0a3d08f67", - "prevId": "e8d72d6a-3744-48e9-88a2-d8617e9a867c", + "id": "aa949867-16a4-42b3-abac-089abf6a6e4d", + "prevId": "d090b695-c13a-4b3d-ad8f-0516d5689336", "version": "7", "dialect": "postgresql", "tables": { @@ -2798,6 +2798,27 @@ } }, "indexes": { + "lsc_schema_check_id_linked_schema_check_id_unique": { + "name": "lsc_schema_check_id_linked_schema_check_id_unique", + "columns": [ + { + "expression": "schema_check_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linked_schema_check_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, "lsc_schema_check_id_idx": { "name": "lsc_schema_check_id_idx", "columns": [ @@ -2858,15 +2879,7 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": { - "linked_schema_checks_schema_check_id_unique": { - "name": "linked_schema_checks_schema_check_id_unique", - "nullsNotDistinct": false, - "columns": [ - "schema_check_id" - ] - } - }, + "uniqueConstraints": {}, "checkConstraints": {} }, "public.linked_subgraphs": { @@ -8425,6 +8438,7 @@ "graph-viewer", "subgraph-admin", "subgraph-publisher", + "subgraph-checker", "subgraph-viewer" ] }, diff --git a/controlplane/migrations/meta/_journal.json b/controlplane/migrations/meta/_journal.json index 9d74f300dc..c6b147f70e 100644 --- a/controlplane/migrations/meta/_journal.json +++ b/controlplane/migrations/meta/_journal.json @@ -918,6 +918,13 @@ "when": 1756988174576, "tag": "0130_skinny_solo", "breakpoints": true + }, + { + "idx": 131, + "version": "7", + "when": 1757542818295, + "tag": "0131_known_stepford_cuckoos", + "breakpoints": true } ] } \ No newline at end of file diff --git a/controlplane/src/core/bufservices/subgraph/checkSubgraphSchema.ts b/controlplane/src/core/bufservices/subgraph/checkSubgraphSchema.ts index 96fbe9e6c1..af7b43bb72 100644 --- a/controlplane/src/core/bufservices/subgraph/checkSubgraphSchema.ts +++ b/controlplane/src/core/bufservices/subgraph/checkSubgraphSchema.ts @@ -129,7 +129,7 @@ export function checkSubgraphSchema( } } - if (subgraph && !authContext.rbac.hasSubGraphWriteAccess(subgraph)) { + if (subgraph && !authContext.rbac.hasSubGraphCheckAccess(subgraph)) { throw new UnauthorizedError(); } else if (!subgraph) { if (!authContext.rbac.canCreateSubGraph(namespace)) { diff --git a/controlplane/src/core/services/RBACEvaluator.ts b/controlplane/src/core/services/RBACEvaluator.ts index 1dac4b5ba7..68ffa75489 100644 --- a/controlplane/src/core/services/RBACEvaluator.ts +++ b/controlplane/src/core/services/RBACEvaluator.ts @@ -50,23 +50,31 @@ export class RBACEvaluator { this.isApiKey = !!isApiKey; this.isLegacyApiKey = this.isApiKey && groups.length === 0; - const flattenRules = groups.flatMap((group) => group.rules); - const rulesGroupedByRole = Object.groupBy(flattenRules, (rule) => rule.role); - - const result = new Map(); - for (const [role, ruleData] of Object.entries(rulesGroupedByRole)) { - result.set(role as OrganizationRole, { - namespaces: [...new Set(ruleData.flatMap((r) => r.namespaces))], - resources: [...new Set(ruleData.flatMap((r) => r.resources))], - }); - } + this.roles = []; + this.namespaces = []; + this.resources = []; + this.rules = new Map(); + + if (!this.isLegacyApiKey) { + // Only evaluate the rules if the user is not a legacy API key + const flattenRules = groups.flatMap((group) => group.rules); + const rulesGroupedByRole = Object.groupBy(flattenRules, (rule) => rule.role); + + const result = new Map(); + for (const [role, ruleData] of Object.entries(rulesGroupedByRole)) { + result.set(role as OrganizationRole, { + namespaces: [...new Set(ruleData.flatMap((r) => r.namespaces))], + resources: [...new Set(ruleData.flatMap((r) => r.resources))], + }); + } - this.roles = Array.from(result.keys(), (k) => k); - this.namespaces = [...new Set(Array.from(result.values(), (res) => res.namespaces).flat())]; - this.resources = [...new Set(Array.from(result.values(), (res) => res.resources).flat())]; - this.rules = result; + this.roles = Array.from(result.keys(), (k) => k); + this.namespaces = [...new Set(Array.from(result.values(), (res) => res.namespaces).flat())]; + this.resources = [...new Set(Array.from(result.values(), (res) => res.resources).flat())]; + this.rules = result; + } - this.isOrganizationAdmin = this.roles.includes('organization-admin') || this.isLegacyApiKey; + this.isOrganizationAdmin = this.isLegacyApiKey || this.roles.includes('organization-admin'); this.isOrganizationAdminOrDeveloper = this.isOrganizationAdmin || this.roles.includes('organization-developer'); this.isOrganizationApiKeyManager = this.isOrganizationAdmin || !!this.ruleFor('organization-apikey-manager'); this.isOrganizationViewer = this.isOrganizationAdminOrDeveloper || this.roles.includes('organization-viewer'); @@ -88,11 +96,6 @@ export class RBACEvaluator { } hasNamespaceReadAccess(namespace: Namespace) { - if (this.isLegacyApiKey) { - // When using an API without a group, fallback to always allow (legacy implementation) - return true; - } - return this.isOrganizationViewer || this.checkNamespaceAccess(namespace, ['namespace-admin', 'namespace-viewer']); } @@ -100,56 +103,29 @@ export class RBACEvaluator { return this.canCreateFederatedGraph(namespace); } - canCreateFeatureFlag(namespace: Namespace) { + canCreateFeatureFlag(_: Namespace) { return this.isOrganizationAdminOrDeveloper; } - hasFeatureFlagWriteAccess(featureFlag: FeatureFlag) { + hasFeatureFlagWriteAccess(_: FeatureFlag) { return this.isOrganizationAdminOrDeveloper; } - hasFeatureFlagReadAccess(featureFlag: FeatureFlag) { - if (this.isLegacyApiKey) { - // When using an API without a group, fallback to always allow (legacy implementation) - return true; - } - + hasFeatureFlagReadAccess(_: FeatureFlag) { return this.isOrganizationViewer; } canCreateFederatedGraph(namespace: Namespace) { - if (this.isOrganizationAdminOrDeveloper) { - return true; - } - - const rule = this.ruleFor('graph-admin'); - if (!rule) { - return false; - } - - if (rule.namespaces.length === 0 && rule.resources.length === 0) { - return true; - } else if (rule.namespaces.length > 0) { - return rule.namespaces.includes(namespace.id); - } - - return false; + return ( + this.isOrganizationAdminOrDeveloper || this.hasRoleWithAccessToAllOrGivenNamespace('graph-admin', namespace.id) + ); } canDeleteFederatedGraph(graph: Target) { - if (graph.creatorUserId && this.userId && graph.creatorUserId === this.userId) { - // The graph creator should always have access to the provided target - return true; - } - - if (this.isOrganizationAdminOrDeveloper) { - return true; - } - - const rule = this.ruleFor('graph-admin'); return ( - !!rule && - ((rule.namespaces.length === 0 && rule.resources.length === 0) || rule.namespaces.includes(graph.namespaceId)) + this.isOrganizationAdminOrDeveloper || + this.isTargetOwnedByUser(graph) || + this.hasRoleWithAccessToAllOrGivenNamespace('graph-admin', graph.namespaceId) ); } @@ -158,31 +134,17 @@ export class RBACEvaluator { } hasFederatedGraphReadAccess(graph: Target) { - if (this.isLegacyApiKey) { - // When using an API without a group, fallback to always allow (legacy implementation) - return true; - } - - return this.isOrganizationViewer || this.checkTargetAccess(graph, ['graph-admin', 'graph-viewer']); + return ( + this.isOrganizationViewer || + this.hasFederatedGraphWriteAccess(graph) || + this.checkTargetAccess(graph, ['graph-viewer']) + ); } canCreateSubGraph(namespace: Namespace) { - if (this.isOrganizationAdminOrDeveloper) { - return true; - } - - const rule = this.ruleFor('subgraph-admin'); - if (!rule) { - return false; - } - - if (rule.namespaces.length === 0 && rule.resources.length === 0) { - return true; - } else if (rule.namespaces.length > 0) { - return rule.namespaces.includes(namespace.id); - } - - return false; + return ( + this.isOrganizationAdminOrDeveloper || this.hasRoleWithAccessToAllOrGivenNamespace('subgraph-admin', namespace.id) + ); } canUpdateSubGraph(graph: Target) { @@ -190,19 +152,10 @@ export class RBACEvaluator { } canDeleteSubGraph(graph: Target) { - if (!this.isApiKey && graph.creatorUserId && this.userId && graph.creatorUserId === this.userId) { - // The graph creator should always have access to the provided target - return true; - } - - if (this.isOrganizationAdminOrDeveloper) { - return true; - } - - const rule = this.ruleFor('subgraph-admin'); return ( - !!rule && - ((rule.namespaces.length === 0 && rule.resources.length === 0) || rule.namespaces.includes(graph.namespaceId)) + this.isOrganizationAdminOrDeveloper || + this.isTargetOwnedByUser(graph) || + this.hasRoleWithAccessToAllOrGivenNamespace('subgraph-admin', graph.namespaceId) ); } @@ -212,15 +165,26 @@ export class RBACEvaluator { ); } - hasSubGraphReadAccess(graph: Target) { - if (this.isLegacyApiKey) { - // When using an API without a group, fallback to always allow (legacy implementation) - return true; - } + hasSubGraphCheckAccess(graph: Target) { + return this.hasSubGraphWriteAccess(graph) || this.checkTargetAccess(graph, ['subgraph-checker']); + } + hasSubGraphReadAccess(graph: Target) { return ( this.isOrganizationViewer || - this.checkTargetAccess(graph, ['subgraph-admin', 'subgraph-publisher', 'subgraph-viewer']) + this.hasSubGraphCheckAccess(graph) || + this.checkTargetAccess(graph, ['subgraph-viewer']) + ); + } + + private hasRoleWithAccessToAllOrGivenNamespace(role: OrganizationRole, namespaceId: string) { + const rule = this.ruleFor(role); + return ( + !!rule && + // The rule has access to every namespace + ((rule.namespaces.length === 0 && rule.resources.length === 0) || + // The rule has access to the given namespace + (rule.namespaces.length > 0 && rule.namespaces.includes(namespaceId))) ); } @@ -237,7 +201,7 @@ export class RBACEvaluator { } if ( - // The rule have access to every namespace + // The rule has access to every namespace rule.namespaces.length === 0 || // The rule was given write access to the namespace (rule.namespaces.length > 0 && rule.namespaces.includes(ns.id)) @@ -249,6 +213,10 @@ export class RBACEvaluator { return false; } + private isTargetOwnedByUser(target: Target) { + return !this.isApiKey && target.creatorUserId && this.userId && target.creatorUserId === this.userId; + } + private checkTargetAccess(target: Target, requiredRoles: OrganizationRole[]) { if (!this.isApiKey && target.creatorUserId && this.userId && target.creatorUserId === this.userId) { // The target creator should always have access to the provided target @@ -262,9 +230,9 @@ export class RBACEvaluator { } if ( - // The rule have access to every resource + // The rule has access to every resource (rule.namespaces.length === 0 && rule.resources.length === 0) || - // The rule was given write access to the namespace + // The rule was given access to the namespace (rule.namespaces.length > 0 && rule.namespaces.includes(target.namespaceId)) || // The rule was given write access to the resource (rule.resources.length > 0 && rule.resources.includes(target.targetId)) diff --git a/controlplane/src/db/schema.ts b/controlplane/src/db/schema.ts index 963408552d..abccb50b24 100644 --- a/controlplane/src/db/schema.ts +++ b/controlplane/src/db/schema.ts @@ -1418,6 +1418,7 @@ export const organizationRoleEnum = pgEnum('organization_role', [ 'graph-viewer', 'subgraph-admin', 'subgraph-publisher', + 'subgraph-checker', 'subgraph-viewer', ] as const); diff --git a/controlplane/test/check-subgraph-schema.test.ts b/controlplane/test/check-subgraph-schema.test.ts index 36c3c85350..095172b98b 100644 --- a/controlplane/test/check-subgraph-schema.test.ts +++ b/controlplane/test/check-subgraph-schema.test.ts @@ -49,7 +49,7 @@ describe('CheckSubgraphSchema', (ctx) => { await afterAllSetup(dbname); }); - test.each(['organization-admin', 'organization-developer', 'subgraph-admin', 'subgraph-publisher'])( + test.each(['organization-admin', 'organization-developer', 'subgraph-admin', 'subgraph-publisher', 'subgraph-checker'])( '%s should be able to create a subgraph, publish the schema and then check with new schema', async (role) => { const { client, server, authenticator, users } = await SetupTest({ dbname, chClient }); @@ -158,7 +158,7 @@ describe('CheckSubgraphSchema', (ctx) => { await server.close(); }); - test.each(['subgraph-admin', 'subgraph-publisher'])( + test.each(['subgraph-admin', 'subgraph-publisher', 'subgraph-checker'])( '%s should be able to check with new schema on allowed namespaces', async (role) => { const { client, server, authenticator, users } = await SetupTest({ dbname, chClient }); diff --git a/controlplane/test/rbac-evaluator.test.ts b/controlplane/test/rbac-evaluator.test.ts index 86e29f4431..db723338e6 100644 --- a/controlplane/test/rbac-evaluator.test.ts +++ b/controlplane/test/rbac-evaluator.test.ts @@ -16,6 +16,7 @@ describe('RBAC Evaluator', () => { const subgraphAdmin = createTestGroup({ role: 'subgraph-admin' }); const subgraphPublisher = createTestGroup({ role: 'subgraph-publisher' }); + const subgraphChecker = createTestGroup({ role: 'subgraph-checker' }); const subgraphViewer = createTestGroup({ role: 'subgraph-viewer' }); test('Should not have access to anything when no groups are provided', () => { @@ -40,6 +41,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget())).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget())).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget())).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget())).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget())).toBe(false); }); @@ -105,6 +107,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget())).toBe(true); expect(rbac.canDeleteSubGraph(fakeTarget())).toBe(true); expect(rbac.hasSubGraphWriteAccess(fakeTarget())).toBe(true); + expect(rbac.hasSubGraphCheckAccess(fakeTarget())).toBe(true); expect(rbac.hasSubGraphReadAccess(fakeTarget())).toBe(true); }); }); @@ -133,6 +136,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget())).toBe(true); expect(rbac.canDeleteSubGraph(fakeTarget())).toBe(true); expect(rbac.hasSubGraphWriteAccess(fakeTarget())).toBe(true); + expect(rbac.hasSubGraphCheckAccess(fakeTarget())).toBe(true); expect(rbac.hasSubGraphReadAccess(fakeTarget())).toBe(true); }); }); @@ -161,6 +165,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget())).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget())).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget())).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget())).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget())).toBe(false); }); }); @@ -189,6 +194,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget())).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget())).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget())).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget())).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget())).toBe(true); }); }); @@ -217,6 +223,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget())).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget())).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget())).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget())).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget())).toBe(false); }); @@ -246,6 +253,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget({ namespace: ns1.id }))).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget({ namespace: ns1.id }))).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); }); }); @@ -274,6 +282,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget())).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget())).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget())).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget())).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget())).toBe(false); }); @@ -302,6 +311,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canCreateSubGraph(ns1)).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget({ namespace: ns1.id }))).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); }); }); @@ -331,6 +341,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget())).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget())).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget())).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget())).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget())).toBe(false); }); @@ -364,6 +375,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget({ namespace: ns1.id }))).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget({ namespace: ns2.id }))).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); }); @@ -394,6 +406,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget({ namespace: ns }))).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget({ namespace: ns }))).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget({ namespace: ns }))).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget({ namespace: ns }))).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget({ namespace: ns }))).toBe(false); }); }); @@ -423,6 +436,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget())).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget())).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget())).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget())).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget())).toBe(false); }); @@ -452,6 +466,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget({ namespace: ns1.id }))).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget({ namespace: ns1.id }))).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); }); @@ -480,6 +495,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget({ namespace: ns }))).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget({ namespace: ns }))).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget({ namespace: ns }))).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget({ namespace: ns }))).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget({ namespace: ns }))).toBe(false); }); }); @@ -508,6 +524,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget())).toBe(true); expect(rbac.canDeleteSubGraph(fakeTarget())).toBe(true); expect(rbac.hasSubGraphWriteAccess(fakeTarget())).toBe(true); + expect(rbac.hasSubGraphCheckAccess(fakeTarget())).toBe(true); expect(rbac.hasSubGraphReadAccess(fakeTarget())).toBe(true); }); @@ -540,6 +557,8 @@ describe('RBAC Evaluator', () => { expect(rbac.canDeleteSubGraph(fakeTarget({ namespace: ns2.id }))).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget({ namespace: ns1.id }))).toBe(true); expect(rbac.hasSubGraphWriteAccess(fakeTarget({ namespace: ns2.id }))).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget({ namespace: ns1.id }))).toBe(true); + expect(rbac.hasSubGraphCheckAccess(fakeTarget({ namespace: ns2.id }))).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget({ namespace: ns1.id }))).toBe(true); expect(rbac.hasSubGraphReadAccess(fakeTarget({ namespace: ns2.id }))).toBe(false); }); @@ -573,6 +592,8 @@ describe('RBAC Evaluator', () => { expect(rbac.canDeleteSubGraph(graph2)).toBe(false); expect(rbac.hasSubGraphWriteAccess(graph1)).toBe(true); expect(rbac.hasSubGraphWriteAccess(graph2)).toBe(false); + expect(rbac.hasSubGraphCheckAccess(graph1)).toBe(true); + expect(rbac.hasSubGraphCheckAccess(graph2)).toBe(false); expect(rbac.hasSubGraphReadAccess(graph1)).toBe(true); expect(rbac.hasSubGraphReadAccess(graph2)).toBe(false); }); @@ -602,6 +623,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget())).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget())).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget())).toBe(true); + expect(rbac.hasSubGraphCheckAccess(fakeTarget())).toBe(true); expect(rbac.hasSubGraphReadAccess(fakeTarget())).toBe(true); }); @@ -631,6 +653,8 @@ describe('RBAC Evaluator', () => { expect(rbac.canDeleteSubGraph(fakeTarget({ namespace: ns1.id }))).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget({ namespace: ns1.id }))).toBe(true); expect(rbac.hasSubGraphWriteAccess(fakeTarget({ namespace: ns2.id }))).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget({ namespace: ns1.id }))).toBe(true); + expect(rbac.hasSubGraphCheckAccess(fakeTarget({ namespace: ns2.id }))).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget({ namespace: ns1.id }))).toBe(true); expect(rbac.hasSubGraphReadAccess(fakeTarget({ namespace: ns2.id }))).toBe(false); }); @@ -662,6 +686,100 @@ describe('RBAC Evaluator', () => { expect(rbac.canDeleteSubGraph(graph1)).toBe(false); expect(rbac.hasSubGraphWriteAccess(graph1)).toBe(true); expect(rbac.hasSubGraphWriteAccess(graph2)).toBe(false); + expect(rbac.hasSubGraphCheckAccess(graph1)).toBe(true); + expect(rbac.hasSubGraphCheckAccess(graph2)).toBe(false); + expect(rbac.hasSubGraphReadAccess(graph1)).toBe(true); + expect(rbac.hasSubGraphReadAccess(graph2)).toBe(false); + }); + }); + + describe('subgraph-checker', () => { + test('Should have check access to every graph', () => { + const rbac = createTestRBACEvaluator(subgraphChecker); + + expect(rbac.groups).toHaveLength(1); + expect(rbac.isOrganizationAdmin).toBe(false); + expect(rbac.isOrganizationAdminOrDeveloper).toBe(false); + expect(rbac.isOrganizationApiKeyManager).toBe(false); + expect(rbac.isOrganizationViewer).toBe(false); + expect(rbac.canCreateNamespace).toBe(false); + expect(rbac.hasNamespaceWriteAccess(fakeNamespace())).toBe(false); + expect(rbac.hasNamespaceReadAccess(fakeNamespace())).toBe(false); + expect(rbac.canCreateContract(fakeNamespace())).toBe(false); + expect(rbac.canCreateFeatureFlag(fakeNamespace())).toBe(false); + expect(rbac.hasFeatureFlagWriteAccess(fakeFeatureFlag())).toBe(false); + expect(rbac.hasFeatureFlagReadAccess(fakeFeatureFlag())).toBe(false); + expect(rbac.canCreateFederatedGraph(fakeNamespace())).toBe(false); + expect(rbac.canDeleteFederatedGraph(fakeTarget())).toBe(false); + expect(rbac.hasFederatedGraphWriteAccess(fakeTarget())).toBe(false); + expect(rbac.hasFederatedGraphReadAccess(fakeTarget())).toBe(false); + expect(rbac.canCreateSubGraph(fakeNamespace())).toBe(false); + expect(rbac.canUpdateSubGraph(fakeTarget())).toBe(false); + expect(rbac.canDeleteSubGraph(fakeTarget())).toBe(false); + expect(rbac.hasSubGraphWriteAccess(fakeTarget())).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget())).toBe(true); + expect(rbac.hasSubGraphReadAccess(fakeTarget())).toBe(true); + }); + + test('Should have check access to every graph in granted namespace', () => { + const ns1 = fakeNamespace(); + const ns2 = fakeNamespace(); + const rbac = createTestRBACEvaluator(createTestGroup({ role: 'subgraph-checker', namespaces: [ns1.id] })); + + expect(rbac.groups).toHaveLength(1); + expect(rbac.isOrganizationAdmin).toBe(false); + expect(rbac.isOrganizationAdminOrDeveloper).toBe(false); + expect(rbac.isOrganizationApiKeyManager).toBe(false); + expect(rbac.isOrganizationViewer).toBe(false); + expect(rbac.canCreateNamespace).toBe(false); + expect(rbac.hasNamespaceWriteAccess(ns1)).toBe(false); + expect(rbac.hasNamespaceReadAccess(ns1)).toBe(false); + expect(rbac.canCreateContract(ns1)).toBe(false); + expect(rbac.canCreateFeatureFlag(ns1)).toBe(false); + expect(rbac.hasFeatureFlagWriteAccess(fakeFeatureFlag(ns1.id))).toBe(false); + expect(rbac.hasFeatureFlagReadAccess(fakeFeatureFlag(ns1.id))).toBe(false); + expect(rbac.canCreateFederatedGraph(ns1)).toBe(false); + expect(rbac.canDeleteFederatedGraph(fakeTarget({ namespace: ns1.id }))).toBe(false); + expect(rbac.hasFederatedGraphWriteAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); + expect(rbac.hasFederatedGraphReadAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); + expect(rbac.canCreateSubGraph(ns1)).toBe(false); + expect(rbac.canUpdateSubGraph(fakeTarget({ namespace: ns1.id }))).toBe(false); + expect(rbac.canDeleteSubGraph(fakeTarget({ namespace: ns1.id }))).toBe(false); + expect(rbac.hasSubGraphWriteAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget({ namespace: ns1.id }))).toBe(true); + expect(rbac.hasSubGraphCheckAccess(fakeTarget({ namespace: ns2.id }))).toBe(false); + expect(rbac.hasSubGraphReadAccess(fakeTarget({ namespace: ns1.id }))).toBe(true); + expect(rbac.hasSubGraphReadAccess(fakeTarget({ namespace: ns2.id }))).toBe(false); + }); + + test('Should have check access to granted graphs', () => { + const ns = randomUUID(); + const graph1 = fakeTarget({ namespace: ns }); + const graph2 = fakeTarget(); + const rbac = createTestRBACEvaluator(createTestGroup({ role: 'subgraph-checker', resources: [graph1.targetId] })); + + expect(rbac.groups).toHaveLength(1); + expect(rbac.isOrganizationAdmin).toBe(false); + expect(rbac.isOrganizationAdminOrDeveloper).toBe(false); + expect(rbac.isOrganizationApiKeyManager).toBe(false); + expect(rbac.isOrganizationViewer).toBe(false); + expect(rbac.canCreateNamespace).toBe(false); + expect(rbac.hasNamespaceWriteAccess(fakeNamespace(ns))).toBe(false); + expect(rbac.hasNamespaceReadAccess(fakeNamespace(ns))).toBe(false); + expect(rbac.canCreateContract(fakeNamespace(ns))).toBe(false); + expect(rbac.canCreateFeatureFlag(fakeNamespace(ns))).toBe(false); + expect(rbac.hasFeatureFlagWriteAccess(fakeFeatureFlag(ns))).toBe(false); + expect(rbac.canCreateFederatedGraph(fakeNamespace(ns))).toBe(false); + expect(rbac.canDeleteFederatedGraph(fakeTarget({ namespace: ns }))).toBe(false); + expect(rbac.hasFederatedGraphWriteAccess(fakeTarget({ namespace: ns }))).toBe(false); + expect(rbac.hasFederatedGraphReadAccess(fakeTarget({ namespace: ns }))).toBe(false); + expect(rbac.hasFederatedGraphReadAccess(fakeTarget({ namespace: ns }))).toBe(false); + expect(rbac.canCreateSubGraph(fakeNamespace(ns))).toBe(false); + expect(rbac.canUpdateSubGraph(graph1)).toBe(false); + expect(rbac.canDeleteSubGraph(graph1)).toBe(false); + expect(rbac.hasSubGraphWriteAccess(graph1)).toBe(false); + expect(rbac.hasSubGraphCheckAccess(graph1)).toBe(true); + expect(rbac.hasSubGraphCheckAccess(graph2)).toBe(false); expect(rbac.hasSubGraphReadAccess(graph1)).toBe(true); expect(rbac.hasSubGraphReadAccess(graph2)).toBe(false); }); @@ -691,6 +809,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget())).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget())).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget())).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget())).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget())).toBe(true); }); @@ -719,6 +838,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(fakeTarget({ namespace: ns1.id }))).toBe(false); expect(rbac.canDeleteSubGraph(fakeTarget({ namespace: ns1.id }))).toBe(false); expect(rbac.hasSubGraphWriteAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); + expect(rbac.hasSubGraphCheckAccess(fakeTarget({ namespace: ns1.id }))).toBe(false); expect(rbac.hasSubGraphReadAccess(fakeTarget({ namespace: ns1.id }))).toBe(true); expect(rbac.hasSubGraphReadAccess(fakeTarget({ namespace: ns2.id }))).toBe(false); }); @@ -749,6 +869,7 @@ describe('RBAC Evaluator', () => { expect(rbac.canUpdateSubGraph(graph1)).toBe(false); expect(rbac.canDeleteSubGraph(graph1)).toBe(false); expect(rbac.hasSubGraphWriteAccess(graph1)).toBe(false); + expect(rbac.hasSubGraphCheckAccess(graph1)).toBe(false); expect(rbac.hasSubGraphReadAccess(graph1)).toBe(true); expect(rbac.hasSubGraphReadAccess(graph2)).toBe(false); }); diff --git a/controlplane/test/subgraph/create-subgraph.test.ts b/controlplane/test/subgraph/create-subgraph.test.ts index 1297ecd137..d8d30c8f85 100644 --- a/controlplane/test/subgraph/create-subgraph.test.ts +++ b/controlplane/test/subgraph/create-subgraph.test.ts @@ -190,6 +190,7 @@ describe('Create subgraph tests', () => { 'graph-admin', 'graph-viewer', 'subgraph-publisher', + 'subgraph-checker', 'subgraph-viewer', ])('%s should not be able to create regular subgraph', async (role) => { const { client, server, authenticator, users } = await SetupTest({ dbname }); @@ -459,6 +460,7 @@ describe('Create subgraph tests', () => { 'graph-admin', 'graph-viewer', 'subgraph-publisher', + 'subgraph-checker', 'subgraph-viewer', ])('%s should not be able to create subgraphs', async (role) => { const { client, server, users, authenticator } = await SetupTest({ @@ -755,6 +757,7 @@ describe('Create subgraph tests', () => { 'graph-admin', 'graph-viewer', 'subgraph-publisher', + 'subgraph-checker', 'subgraph-viewer', ])('%s should not be able to create plugin subgraphs', async (role) => { const { client, server, users, authenticator } = await SetupTest({ diff --git a/controlplane/test/subgraph/publish-subgraph.test.ts b/controlplane/test/subgraph/publish-subgraph.test.ts index 0f2c79342d..ab92949be7 100644 --- a/controlplane/test/subgraph/publish-subgraph.test.ts +++ b/controlplane/test/subgraph/publish-subgraph.test.ts @@ -134,6 +134,7 @@ describe('Publish subgraph tests', () => { 'namespace-viewer', 'graph-admin', 'graph-viewer', + 'subgraph-checker', 'subgraph-viewer', ])('%s should not be able to publish to existing regular subgraph', async (role) => { const { client, server, authenticator, users } = await SetupTest({ dbname }); @@ -270,6 +271,7 @@ describe('Publish subgraph tests', () => { 'graph-admin', 'graph-viewer', 'subgraph-publisher', + 'subgraph-checker', 'subgraph-viewer', ])('%s should not be able to publish regular subgraph without already being created', async (role) => { const { client, server, authenticator, users } = await SetupTest({ dbname }); @@ -736,6 +738,7 @@ describe('Publish subgraph tests', () => { 'graph-admin', 'graph-viewer', 'subgraph-publisher', + 'subgraph-checker', 'subgraph-viewer', ])('%s should not be able to create and publish plugin subgraph', async (role) => { const { client, server, authenticator, users } = await SetupTest({ diff --git a/controlplane/test/subgraph/update-subgraph.test.ts b/controlplane/test/subgraph/update-subgraph.test.ts index 57b7fc46c7..8a9af906d3 100644 --- a/controlplane/test/subgraph/update-subgraph.test.ts +++ b/controlplane/test/subgraph/update-subgraph.test.ts @@ -125,6 +125,7 @@ describe('Update subgraph tests', () => { 'graph-admin', 'graph-viewer', 'subgraph-publisher', + 'subgraph-checker', 'subgraph-viewer', ])('%s should not be able to update subgraph', async (role ) => { const { client, server, authenticator, users } = await SetupTest({ dbname }); diff --git a/studio/src/lib/constants.ts b/studio/src/lib/constants.ts index c8362052c9..f24bfe2379 100644 --- a/studio/src/lib/constants.ts +++ b/studio/src/lib/constants.ts @@ -273,6 +273,12 @@ export const roles = [ displayName: "Publisher", description: "Grants publish access to the selected subgraphs.", }, + { + key: "subgraph-checker", + category: "subgraph", + displayName: "Checker", + description: "Grants access to creating checks for the selected subgraphs.", + }, { key: "subgraph-viewer", category: "subgraph",