From de9122d4567da9395f20f5f1a476cf6ff2dda119 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 30 Dec 2024 11:42:16 +0100 Subject: [PATCH 01/55] include organization owner id in Organization type --- packages/services/api/src/shared/entities.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index f61f85abda..b10488ad88 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -192,6 +192,7 @@ export interface Organization { appDeployments: boolean; }; zendeskId: string | null; + /** ID of the user that owns the organization */ ownerId: string; } From 905d21a2750bf8c96a502deccdf5f73192339788 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 30 Dec 2024 11:44:47 +0100 Subject: [PATCH 02/55] feat: database migration --- packages/migrations/src/run-pg-migrations.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index bcb8af6edb..7d1303047b 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -155,5 +155,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri await import('./actions/2025.01.09T00-00-00.legacy-member-scopes'), await import('./actions/2025.01.10T00.00.00.breaking-changes-request-count'), await import('./actions/2025.01.13T10-08-00.default-role'), + await import('./actions/2025.01.30T00-00-00.granular-member-role-permissions'), ], }); From 022a52776f5a62899ad6026e7f9ee2efb5affa70 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 30 Dec 2024 13:30:44 +0100 Subject: [PATCH 03/55] transform legacy permissions to new format --- .../api/src/modules/auth/lib/authz.ts | 165 +++++-- .../modules/auth/lib/supertokens-strategy.ts | 307 ++++++------- .../auth/providers/organization-members.ts | 421 ++++++++++++++++++ packages/services/server/src/index.ts | 2 + 4 files changed, 700 insertions(+), 195 deletions(-) create mode 100644 packages/services/api/src/modules/auth/providers/organization-members.ts diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index cf762bbb23..c109a6c816 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -1,4 +1,5 @@ import stringify from 'fast-json-stable-stringify'; +import { z } from 'zod'; import { FastifyReply, FastifyRequest } from '@hive/service-common'; import type { User } from '../../../shared/entities'; import { AccessError } from '../../../shared/errors'; @@ -273,7 +274,7 @@ function isResourceIdMatch( return true; } -function defaultOrgIdentity(args: { organizationId: string }) { +export function defaultOrgIdentity(args: { organizationId: string }) { return [`organization/${args.organizationId}`]; } @@ -313,55 +314,135 @@ function schemaCheckOrPublishIdentity( return ids; } +/** Typed Object.fromEntries */ +function objectFromEntries<$Key extends string, $Value>( + entries: Array<[$Key, $Value]>, +): Record<$Key, $Value> { + return Object.fromEntries(entries) as Record<$Key, $Value>; +} + +function objectEntries<$Key extends string, $Value>( + object: Record<$Key, $Value>, +): Array<[$Key, $Value]> { + return Object.entries(object) as Array<[$Key, $Value]>; +} + /** * Object map containing all possible actions - * and resource identifier builder functions required for checking whether an action can be performed. * * Used within the `Session.assertPerformAction` function for a fully type-safe experience. * If you are adding new permissions to the existing system. * This is the place to do so. */ +const permissionGroups = { + organization: [ + z.literal('organization:describe'), + z.literal('organization:modifySlug'), + z.literal('organization:delete'), + z.literal('gitHubIntegration:modify'), + z.literal('slackIntegration:modify'), + z.literal('oidc:modify'), + z.literal('support:manageTickets'), + z.literal('billing:describe'), + z.literal('billing:update'), + z.literal('member:describe'), + z.literal('member:assignRole'), + z.literal('member:modifyRole'), + z.literal('member:removeMember'), + z.literal('member:manageInvites'), + z.literal('project:create'), + z.literal('schemaLinting:modifyOrganizationRules'), + z.literal('auditLog:export'), + ], + project: [ + z.literal('project:describe'), + z.literal('project:delete'), + z.literal('project:modifySettings'), + z.literal('alert:modify'), + z.literal('schemaLinting:modifyProjectRules'), + z.literal('target:create'), + ], + target: [ + z.literal('targetAccessToken:modify'), + z.literal('cdnAccessToken:modify'), + z.literal('target:delete'), + z.literal('target:modifySettings'), + z.literal('laboratory:describe'), + z.literal('laboratory:modify'), + z.literal('laboratory:modifyPreflightScript'), + z.literal('appDeployment:describe'), + z.literal('schema:loadFromRegistry'), + z.literal('schema:compose'), + ], + service: [ + z.literal('schemaCheck:create'), + z.literal('schemaCheck:approve'), + z.literal('schemaVersion:publish'), + z.literal('schemaVersion:deleteService'), + ], + appDeployment: [ + z.literal('appDeployment:create'), + z.literal('appDeployment:publish'), + z.literal('appDeployment:retire'), + ], +} as const; + +export const PermissionGroupsModel = z.object({ + organization: z.set(z.union(permissionGroups.organization)), + project: z.set(z.union(permissionGroups.project)), + target: z.set(z.union(permissionGroups.target)), + service: z.set(z.union(permissionGroups.service)), + appDeployment: z.set(z.union(permissionGroups.appDeployment)), +}); + +export type PermissionGroups = z.TypeOf; + +export const AllPermissionsModel = z.union([ + ...permissionGroups.organization, + ...permissionGroups.project, + ...permissionGroups.target, + ...permissionGroups.service, + ...permissionGroups.appDeployment, +]); + +const lookupMap = new Map< + z.TypeOf, + 'organization' | 'project' | 'target' | 'service' | 'appDeployment' +>(); + +for (const [key, permissions] of objectEntries(permissionGroups)) { + for (const permission of permissions) { + lookupMap.set(permission.value, key); + } +} + +/** Get the permission group for a specific permissions */ +export function getPermissionGroup( + permission: z.TypeOf, +): 'organization' | 'project' | 'target' | 'service' | 'appDeployment' { + const group = lookupMap.get(permission); + + if (group === undefined) { + throw new Error(`Could not find group for permission '${permission}'.`); + } + + return group; +} + const actionDefinitions = { - 'organization:describe': defaultOrgIdentity, - 'organization:modifySlug': defaultOrgIdentity, - 'organization:delete': defaultOrgIdentity, - 'gitHubIntegration:modify': defaultOrgIdentity, - 'slackIntegration:modify': defaultOrgIdentity, - 'oidc:modify': defaultOrgIdentity, - 'support:manageTickets': defaultOrgIdentity, - 'billing:describe': defaultOrgIdentity, - 'billing:update': defaultOrgIdentity, - 'targetAccessToken:modify': defaultTargetIdentity, - 'cdnAccessToken:modify': defaultTargetIdentity, - 'member:describe': defaultOrgIdentity, - 'member:assignRole': defaultOrgIdentity, - 'member:modifyRole': defaultOrgIdentity, - 'member:removeMember': defaultOrgIdentity, - 'member:manageInvites': defaultOrgIdentity, - 'project:create': defaultOrgIdentity, - 'project:describe': defaultProjectIdentity, - 'project:delete': defaultProjectIdentity, - 'project:modifySettings': defaultProjectIdentity, - 'alert:modify': defaultProjectIdentity, - 'schemaLinting:modifyOrganizationRules': defaultOrgIdentity, - 'schemaLinting:modifyProjectRules': defaultProjectIdentity, - 'target:create': defaultProjectIdentity, - 'target:delete': defaultTargetIdentity, - 'target:modifySettings': defaultTargetIdentity, - 'laboratory:describe': defaultTargetIdentity, - 'laboratory:modify': defaultTargetIdentity, - 'laboratory:modifyPreflightScript': defaultTargetIdentity, - 'appDeployment:describe': defaultTargetIdentity, - 'appDeployment:create': defaultAppDeploymentIdentity, - 'appDeployment:publish': defaultAppDeploymentIdentity, - 'appDeployment:retire': defaultAppDeploymentIdentity, - 'schemaCheck:create': schemaCheckOrPublishIdentity, - 'schemaCheck:approve': schemaCheckOrPublishIdentity, - 'schemaVersion:publish': schemaCheckOrPublishIdentity, - 'schemaVersion:deleteService': schemaCheckOrPublishIdentity, - 'schema:loadFromRegistry': defaultTargetIdentity, - 'schema:compose': defaultTargetIdentity, - 'auditLog:export': defaultOrgIdentity, + ...objectFromEntries(permissionGroups['organization'].map(t => [t.value, defaultOrgIdentity])), + ...objectFromEntries( + permissionGroups['project'].map(t => [t.value, defaultProjectIdentity] as const), + ), + ...objectFromEntries( + permissionGroups['target'].map(t => [t.value, defaultTargetIdentity] as const), + ), + ...objectFromEntries( + permissionGroups['service'].map(t => [t.value, schemaCheckOrPublishIdentity] as const), + ), + ...objectFromEntries( + permissionGroups['appDeployment'].map(t => [t.value, defaultAppDeploymentIdentity] as const), + ), } satisfies ActionDefinitionMap; type ActionDefinitionMap = { diff --git a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts index 19d6cd1ed2..1a75bdc345 100644 --- a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts @@ -8,22 +8,23 @@ import { isUUID } from '../../../shared/is-uuid'; import { Logger } from '../../shared/providers/logger'; import type { Storage } from '../../shared/providers/storage'; import { - OrganizationAccessScope, - ProjectAccessScope, - TargetAccessScope, -} from '../providers/scopes'; + OrganizationMembers, + OrganizationMembershipRoleAssignment, +} from '../providers/organization-members'; import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz'; export class SuperTokensCookieBasedSession extends Session { public superTokensUserId: string; + private organizationMembers: OrganizationMembers; private storage: Storage; constructor( args: { superTokensUserId: string; email: string }, - deps: { storage: Storage; logger: Logger }, + deps: { organizationMembers: OrganizationMembers; storage: Storage; logger: Logger }, ) { super({ logger: deps.logger }); this.superTokensUserId = args.superTokensUserId; + this.organizationMembers = deps.organizationMembers; this.storage = deps.storage; } @@ -32,17 +33,51 @@ export class SuperTokensCookieBasedSession extends Session { ): Promise> { const user = await this.getViewer(); + this.logger.debug( + 'Loading policy statements for organization. (userId=%s, organizationId=%s)', + user.id, + organizationId, + ); + if (!isUUID(organizationId)) { + this.logger.debug( + 'Invalid organization ID provided. (userId=%s, organizationId=%s)', + user.id, + organizationId, + ); + return []; } - const member = await this.storage.getOrganizationMember({ + this.logger.debug( + 'Load organization membership for user. (userId=%s, organizationId=%s)', + user.id, organizationId, + ); + const organization = await this.storage.getOrganization({ organizationId }); + const organizationMembership = await this.organizationMembers.findOrganizationMembership({ + organization, userId: user.id, }); + if (!organizationMembership) { + this.logger.debug( + 'No membership found, resolve empty policy statements. (userId=%s, organizationId=%s)', + user.id, + organizationId, + ); + + return []; + } + // owner of organization should have full right to do anything. - if (member?.isOwner) { + if (organizationMembership?.isAdmin) { + this.logger.debug( + 'User is organization owner, resolve admin access policy. (userId=%s, organizationId=%s)', + user.id, + organizationId, + ); + return [ { action: '*', @@ -52,11 +87,18 @@ export class SuperTokensCookieBasedSession extends Session { ]; } - if (Array.isArray(member?.scopes)) { - return transformOrganizationMemberLegacyScopes({ organizationId, scopes: member.scopes }); - } + this.logger.debug( + 'Translate organization role assignments to policy statements. (userId=%s, organizationId=%s)', + user.id, + organizationId, + ); - return []; + const policyStatements = this.translateAssignedRolesToAuthorizationPolicyStatements( + organizationId, + organizationMembership.assignedRoles, + ); + + return policyStatements; } public async getViewer(): Promise { @@ -74,15 +116,114 @@ export class SuperTokensCookieBasedSession extends Session { public isViewer() { return true; } + + private translateAssignedRolesToAuthorizationPolicyStatements( + organizationId: string, + organizationMembershipRoleAssignments: Array, + ): Array { + const policyStatements: Array = []; + + const orgResourceId = `hrn:${organizationId}:organization/${organizationId}`; + + for (const assignedRole of organizationMembershipRoleAssignments) { + /** + * Currently, we have the following hierarchy + * + * organization + * v + * project + * v + * target + * v v + * app deployment service + * + * If one level specifies "*", it needs to inherit the resources defined on the next upper level. + */ + + let parentResource: Array = [orgResourceId]; + + if (assignedRole.role.permissions.organization.size) { + policyStatements.push({ + action: Array.from(assignedRole.role.permissions.organization), + effect: 'allow', + resource: parentResource, + }); + } + + if (assignedRole.role.permissions.project.size) { + if (Array.isArray(assignedRole.resources.project)) { + parentResource = assignedRole.resources.project.map( + uuid => `hrn:${organizationId}:project/${uuid}`, + ); + } + + policyStatements.push({ + action: Array.from(assignedRole.role.permissions.project), + effect: 'allow', + resource: parentResource, + }); + } + + if (assignedRole.role.permissions.target.size) { + if (Array.isArray(assignedRole.resources.target)) { + parentResource = assignedRole.resources.target.map( + uuid => `hrn:${organizationId}:target/${uuid}`, + ); + } + + policyStatements.push({ + action: Array.from(assignedRole.role.permissions.target), + effect: 'allow', + resource: parentResource, + }); + } + + if (assignedRole.role.permissions.service.size) { + policyStatements.push({ + action: Array.from(assignedRole.role.permissions.service), + effect: 'allow', + resource: + assignedRole.resources.service === '*' + ? parentResource + : assignedRole.resources.service.map( + ([targetId, serviceName]) => + `hrn:${organizationId}:target/${targetId}/service/${serviceName}`, + ), + }); + } + + if (assignedRole.role.permissions.appDeployment.size) { + policyStatements.push({ + action: Array.from(assignedRole.role.permissions.appDeployment), + effect: 'allow', + resource: + assignedRole.resources.appDeployment === '*' + ? parentResource + : assignedRole.resources.appDeployment.map( + ([targetId, serviceName]) => + `hrn:${organizationId}:target/${targetId}/appDeployment/${serviceName}`, + ), + }); + } + } + + return policyStatements; + } } export class SuperTokensUserAuthNStrategy extends AuthNStrategy { private logger: ServiceLogger; + private organizationMembers: OrganizationMembers; private storage: Storage; - constructor(deps: { logger: ServiceLogger; storage: Storage }) { + constructor(deps: { + logger: ServiceLogger; + storage: Storage; + organizationMembers: OrganizationMembers; + }) { super(); this.logger = deps.logger.child({ module: 'SuperTokensUserAuthNStrategy' }); + this.organizationMembers = deps.organizationMembers; this.storage = deps.storage; } @@ -173,6 +314,7 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy; -}) { - const policies: Array = []; - for (const scope of args.scopes) { - switch (scope) { - case OrganizationAccessScope.READ: { - policies.push({ - effect: 'allow', - action: [ - 'support:manageTickets', - 'project:create', - 'project:describe', - 'organization:describe', - ], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case OrganizationAccessScope.SETTINGS: { - policies.push({ - effect: 'allow', - action: [ - 'organization:modifySlug', - 'schemaLinting:modifyOrganizationRules', - 'billing:describe', - 'billing:update', - ], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case OrganizationAccessScope.DELETE: { - policies.push({ - effect: 'allow', - action: ['organization:delete'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case OrganizationAccessScope.INTEGRATIONS: { - policies.push({ - effect: 'allow', - action: ['oidc:modify', 'gitHubIntegration:modify', 'slackIntegration:modify'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case OrganizationAccessScope.MEMBERS: { - policies.push({ - effect: 'allow', - action: [ - 'member:manageInvites', - 'member:removeMember', - 'member:assignRole', - 'member:modifyRole', - 'member:describe', - ], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case ProjectAccessScope.ALERTS: { - policies.push({ - effect: 'allow', - action: ['alert:modify'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case ProjectAccessScope.READ: { - policies.push({ - effect: 'allow', - action: ['project:describe'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case ProjectAccessScope.DELETE: { - policies.push({ - effect: 'allow', - action: ['project:delete'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case ProjectAccessScope.SETTINGS: { - policies.push({ - effect: 'allow', - action: ['project:delete', 'project:modifySettings', 'schemaLinting:modifyProjectRules'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case TargetAccessScope.READ: { - policies.push({ - effect: 'allow', - action: ['appDeployment:describe', 'laboratory:describe', 'target:create'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case TargetAccessScope.REGISTRY_WRITE: { - policies.push({ - effect: 'allow', - action: ['schemaCheck:approve', 'laboratory:modify'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case TargetAccessScope.TOKENS_WRITE: { - policies.push({ - effect: 'allow', - action: ['targetAccessToken:modify', 'cdnAccessToken:modify'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case TargetAccessScope.SETTINGS: { - policies.push({ - effect: 'allow', - action: ['target:modifySettings', 'laboratory:modifyPreflightScript'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - case TargetAccessScope.DELETE: { - policies.push({ - effect: 'allow', - action: ['target:delete'], - resource: [`hrn:${args.organizationId}:organization/${args.organizationId}`], - }); - break; - } - } - } - - return policies; -} diff --git a/packages/services/api/src/modules/auth/providers/organization-members.ts b/packages/services/api/src/modules/auth/providers/organization-members.ts new file mode 100644 index 0000000000..f3c41c4a39 --- /dev/null +++ b/packages/services/api/src/modules/auth/providers/organization-members.ts @@ -0,0 +1,421 @@ +import { Inject, Injectable, Scope } from 'graphql-modules'; +import { sql, type DatabasePool } from 'slonik'; +import { z } from 'zod'; +import { Organization } from '../../../shared/entities'; +import { batchBy } from '../../../shared/helpers'; +import { isUUID } from '../../../shared/is-uuid'; +import { Logger } from '../../shared/providers/logger'; +import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; +import { + AllPermissionsModel, + getPermissionGroup, + PermissionGroups, + PermissionGroupsModel, +} from '../lib/authz'; +import { OrganizationAccessScope, ProjectAccessScope, TargetAccessScope } from './scopes'; + +const RawOrganizationMembershipModel = z.object({ + userId: z.string(), + /** Legacy scopes on membership, way of assigning permissions before the introduction of roles */ + legacyScopes: z + .array(z.string()) + .transform( + value => value as Array, + ) + .nullable(), + /** Legacy role id, way of assigning permissions via a role before the introduction of assigning multiple roles */ + legacyRoleId: z.string().nullable(), +}); + +const RawMemberRoleModel = z.intersection( + z.object({ + id: z.string(), + description: z.string(), + isLocked: z.boolean(), + }), + z.union([ + z.object({ + legacyScopes: z + .array(z.string()) + .transform( + value => value as Array, + ), + permissions: z.null(), + }), + z.object({ + legacyScopes: z.null(), + permissions: z.array(AllPermissionsModel).transform(rawPermissions => { + const permissions = { + organization: new Set(), + project: new Set(), + target: new Set(), + service: new Set(), + appDeployment: new Set(), + }; + + for (const permission of rawPermissions) { + permissions[getPermissionGroup(permission)].add(permission); + } + + return PermissionGroupsModel.parse(permissions); + }), + }), + ]), +); + +const UUIDResourceAssignmentModel = z.union([z.literal('*'), z.array(z.string().uuid())]); + +/** + * String in the form `targetId/serviceName` + * Example: `f81ce726-2abf-4653-bf4c-d8436cde255a/users` + */ +const ServiceResourceAssignmentStringModel = z + .string() + .refine(value => { + const [targetId, serviceName = ''] = value.split('/'); + if (isUUID(targetId) === false || serviceName === '') { + return false; + } + return true; + }, 'Invalid service resource assignment') + .transform(value => value.split('/') as [targetId: string, serviceName: string]); + +const ServiceResourceAssignmentModel = z.union([ + z.literal('*'), + z.array(ServiceResourceAssignmentStringModel), +]); + +const ResourceAssignmentGroupModel = z.object({ + /** Resources assigned to a 'projects' permission group */ + project: UUIDResourceAssignmentModel, + /** Resources assigned to a 'targets' permission group */ + target: UUIDResourceAssignmentModel, + /** Resources assigned to a 'service' permission group */ + service: ServiceResourceAssignmentModel, + /** Resources assigned to a 'appDeployment' permission group */ + appDeployment: ServiceResourceAssignmentModel, +}); + +type ResourceAssignmentGroup = z.TypeOf; + +type MemberRoleType = { + id: string; + description: string; + isLocked: boolean; + permissions: PermissionGroups; +}; + +export type OrganizationMembershipRoleAssignment = { + role: MemberRoleType; + resources: ResourceAssignmentGroup; +}; + +type OrganizationMembership = { + organizationId: string; + isAdmin: boolean; + userId: string; + assignedRoles: Array; + /** + * legacy role assigned to this membership. + * Note: The role is already resolved to a "OrganizationMembershipRoleAssignment" within the assignedRoles property. + **/ + legacyRoleId: string | null; + /** + * Legacy scope assigned to this membership. + * Note: They are already resolved to a "OrganizationMembershipRoleAssignment" within the assignedRoles property. + **/ + legacyScopes: Array | null; +}; + +@Injectable({ + scope: Scope.Operation, +}) +export class OrganizationMembers { + private logger: Logger; + + constructor( + @Inject(PG_POOL_CONFIG) private pool: DatabasePool, + logger: Logger, + ) { + this.logger = logger.child({ + source: 'OrganizationMembers', + }); + } + + private async findOrganizationMembersById(organizationId: string, userIds: Array) { + const query = sql` + SELECT + "om"."user_id" AS "userId" + , "om"."role_id" AS "legacyRoleId" + , "om"."scopes" AS "legacyScopes" + FROM + "organization_member" AS "om" + WHERE + "om"."organization_id" = ${organizationId} + AND "om"."user_id" = ANY(${sql.array(userIds, 'uuid')}) + `; + + const result = await this.pool.any(query); + return result.map(row => RawOrganizationMembershipModel.parse(row)); + } + + /** Find member roles by their ID */ + private async findMemberRolesByIds(roleIds: Array) { + this.logger.debug('Find organization membership roles. (roleIds=%o)', roleIds); + + const query = sql` + SELECT + "id" + , "name" + , "description" + , "locked" AS "isLocked" + , "scopes" AS "legacyScopes" + , "permissions" + FROM + "organization_member_roles" + WHERE + "id" = ANY(${sql.array(roleIds, 'uuid')}) + `; + + const result = await this.pool.any(query); + + const rowsById = new Map(); + + for (const row of result) { + const record = RawMemberRoleModel.parse(row); + + rowsById.set(record.id, { + id: record.id, + isLocked: record.isLocked, + description: record.description, + permissions: + record.permissions ?? + transformOrganizationMemberLegacyScopesIntoPermissionGroup(record.legacyScopes), + }); + } + return rowsById; + } + + /** + * Batched loader function for a organization membership. + * + * Handles legacy scopes and role assignments and automatically transforms + * them into resource based role assignments. + */ + findOrganizationMembership = batchBy( + (args: { organization: Organization; userId: string }) => args.organization.id, + async args => { + const organization = args[0].organization; + const userIds = args.map(arg => arg.userId); + + this.logger.debug( + 'Find organization membership for users. (organizationId=%s, userIds=%o)', + organization.id, + userIds, + ); + + const organizationMembers = await this.findOrganizationMembersById(organization.id, userIds); + const mapping = new Map(); + + // Roles that are assigned using the legacy "single role" way + const pendingLegacyRoleLookups = new Set(); + const pendingLegacyRoleMembershipAssignments: Array<{ + legacyRoleId: string; + assignedRoles: OrganizationMembership['assignedRoles']; + }> = []; + + // Users whose role assignments need to be loaded as they are not using any legacy roles + // const pendingRoleRoleAssignmentLookupUsersIds = new Set(); + + for (const record of organizationMembers) { + const organizationMembership: OrganizationMembership = { + organizationId: organization.id, + userId: record.userId, + isAdmin: organization.ownerId === record.userId, + assignedRoles: [], + legacyRoleId: record.legacyRoleId, + legacyScopes: record.legacyScopes, + }; + mapping.set(record.userId, organizationMembership); + + if (record.legacyRoleId) { + // legacy "single assigned role" + pendingLegacyRoleLookups.add(record.legacyRoleId); + pendingLegacyRoleMembershipAssignments.push({ + legacyRoleId: record.legacyRoleId, + assignedRoles: organizationMembership.assignedRoles, + }); + } else if (record.legacyScopes !== null) { + // legacy "scopes" on organization member -> migration wizard has not been used + + // In this case we translate the legacy scopes to a single permission group on the "organization" + // resource typ. Then assign the users organization to the group, so it has the same behavior as previously. + organizationMembership.assignedRoles.push({ + role: { + id: 'legacy-scope-role', + description: 'This role has been automatically generated from the assigned scopes.', + isLocked: true, + permissions: transformOrganizationMemberLegacyScopesIntoPermissionGroup( + record.legacyScopes, + ), + }, + // allow all permissions for all resources within the organization. + resources: { + project: '*', + target: '*', + service: '*', + appDeployment: '*', + }, + }); + } + // else { + // // normal role assignment lookup + // pendingRoleRoleAssignmentLookupUsersIds.add(organizationMembership); + // } + } + + if (pendingLegacyRoleLookups.size) { + // This handles the legacy "single" role assignments + // We load the roles and then attach them to the already loaded membership role + const roleIds = Array.from(pendingLegacyRoleLookups); + + this.logger.debug('Lookup legacy role assignments. (roleIds=%o)', roleIds); + + const memberRolesById = await this.findMemberRolesByIds(roleIds); + + for (const record of pendingLegacyRoleMembershipAssignments) { + const membershipRole = memberRolesById.get(record.legacyRoleId); + if (!membershipRole) { + continue; + } + record.assignedRoles.push({ + resources: { + project: '*', + target: '*', + service: '*', + appDeployment: '*', + }, + role: membershipRole, + }); + } + } + + // if (pendingRoleRoleAssignmentLookupUsersIds.size) { + // const usersIds = Array.from(pendingRoleRoleAssignmentLookupUsersIds).map( + // membership => membership.userId, + // ); + // this.logger.debug( + // 'Lookup role assignments within organization for users. (organizationId=%s, userIds=%o)', + // organization.id, + // usersIds, + // ); + + // const roleAssignments = await this.findRoleAssignmentsForUsersInOrganization( + // organization.id, + // usersIds, + // ); + + // for (const membership of pendingRoleRoleAssignmentLookupUsersIds) { + // membership.assignedRoles.push(...(roleAssignments.get(membership.userId) ?? [])); + // } + // } + + return userIds.map(async userId => mapping.get(userId) ?? null); + }, + ); +} + +function transformOrganizationMemberLegacyScopesIntoPermissionGroup( + scopes: Array, +): z.TypeOf { + const permissions: z.TypeOf = { + organization: new Set(), + project: new Set(), + target: new Set(), + service: new Set(), + appDeployment: new Set(), + }; + for (const scope of scopes) { + switch (scope) { + case OrganizationAccessScope.READ: { + permissions.organization.add('organization:describe'); + permissions.organization.add('support:manageTickets'); + permissions.organization.add('project:create'); + permissions.project.add('project:describe'); + break; + } + case OrganizationAccessScope.SETTINGS: { + permissions.organization.add('organization:modifySlug'); + permissions.organization.add('schemaLinting:modifyOrganizationRules'); + permissions.organization.add('billing:describe'); + permissions.organization.add('billing:update'); + permissions.organization.add('auditLog:export'); + break; + } + case OrganizationAccessScope.DELETE: { + permissions.organization.add('organization:delete'); + break; + } + case OrganizationAccessScope.INTEGRATIONS: { + permissions.organization.add('oidc:modify'); + permissions.organization.add('gitHubIntegration:modify'); + permissions.organization.add('slackIntegration:modify'); + + break; + } + case OrganizationAccessScope.MEMBERS: { + permissions.organization.add('member:manageInvites'); + permissions.organization.add('member:removeMember'); + permissions.organization.add('member:assignRole'); + permissions.organization.add('member:modifyRole'); + permissions.organization.add('member:describe'); + break; + } + case ProjectAccessScope.ALERTS: { + permissions.project.add('alert:modify'); + break; + } + case ProjectAccessScope.READ: { + permissions.project.add('project:describe'); + break; + } + case ProjectAccessScope.DELETE: { + permissions.project.add('project:delete'); + break; + } + case ProjectAccessScope.SETTINGS: { + permissions.project.add('project:delete'); + permissions.project.add('project:modifySettings'); + permissions.project.add('schemaLinting:modifyProjectRules'); + break; + } + case TargetAccessScope.READ: { + permissions.project.add('target:create'); + permissions.target.add('appDeployment:describe'); + permissions.target.add('laboratory:describe'); + break; + } + case TargetAccessScope.REGISTRY_WRITE: { + permissions.target.add('laboratory:modify'); + permissions.service.add('schemaCheck:approve'); + break; + } + case TargetAccessScope.TOKENS_WRITE: { + permissions.target.add('targetAccessToken:modify'); + permissions.target.add('cdnAccessToken:modify'); + break; + } + case TargetAccessScope.SETTINGS: { + permissions.target.add('target:modifySettings'); + permissions.target.add('laboratory:modifyPreflightScript'); + break; + } + case TargetAccessScope.DELETE: { + permissions.target.add('target:delete'); + break; + } + } + } + + return permissions; +} diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index b4e6627314..bd0c51889a 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -12,6 +12,7 @@ import { createRedisEventTarget } from '@graphql-yoga/redis-event-target'; import 'reflect-metadata'; import { hostname } from 'os'; import { createPubSub } from 'graphql-yoga'; +import { OrganizationMembers } from 'packages/services/api/src/modules/auth/providers/organization-members'; import { z } from 'zod'; import formDataPlugin from '@fastify/formbody'; import { createRegistry, createTaskRunner, CryptoProvider, LogFn, Logger } from '@hive/api'; @@ -401,6 +402,7 @@ export async function main() { new SuperTokensUserAuthNStrategy({ logger: server.log, storage, + organizationMembers: new OrganizationMembers(storage.pool, server.log), }), new TargetAccessTokenStrategy({ logger: server.log, From 05b12ec13875ad83e3d670bca81677965f468896 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 30 Dec 2024 14:31:03 +0100 Subject: [PATCH 04/55] rename some things --- .../api/src/modules/auth/lib/authz.ts | 94 ++++++++++++------- .../auth/providers/organization-members.ts | 32 ++----- 2 files changed, 68 insertions(+), 58 deletions(-) diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index c109a6c816..617857d962 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -334,7 +334,7 @@ function objectEntries<$Key extends string, $Value>( * If you are adding new permissions to the existing system. * This is the place to do so. */ -const permissionGroups = { +const permissionsByLevel = { organization: [ z.literal('organization:describe'), z.literal('organization:modifySlug'), @@ -387,40 +387,44 @@ const permissionGroups = { ], } as const; -export const PermissionGroupsModel = z.object({ - organization: z.set(z.union(permissionGroups.organization)), - project: z.set(z.union(permissionGroups.project)), - target: z.set(z.union(permissionGroups.target)), - service: z.set(z.union(permissionGroups.service)), - appDeployment: z.set(z.union(permissionGroups.appDeployment)), +export const PermissionsPerResourceLevelAssignmentModel = z.object({ + organization: z.set(z.union(permissionsByLevel.organization)), + project: z.set(z.union(permissionsByLevel.project)), + target: z.set(z.union(permissionsByLevel.target)), + service: z.set(z.union(permissionsByLevel.service)), + appDeployment: z.set(z.union(permissionsByLevel.appDeployment)), }); -export type PermissionGroups = z.TypeOf; +export type PermissionsPerResourceLevelAssignment = z.TypeOf< + typeof PermissionsPerResourceLevelAssignmentModel +>; -export const AllPermissionsModel = z.union([ - ...permissionGroups.organization, - ...permissionGroups.project, - ...permissionGroups.target, - ...permissionGroups.service, - ...permissionGroups.appDeployment, +type ResourceLevels = keyof PermissionsPerResourceLevelAssignment; + +export const PermissionsModel = z.union([ + ...permissionsByLevel.organization, + ...permissionsByLevel.project, + ...permissionsByLevel.target, + ...permissionsByLevel.service, + ...permissionsByLevel.appDeployment, ]); -const lookupMap = new Map< - z.TypeOf, - 'organization' | 'project' | 'target' | 'service' | 'appDeployment' +type Permissions = z.TypeOf; + +const permissionResourceLevelLookupMap = new Map< + z.TypeOf, + ResourceLevels >(); -for (const [key, permissions] of objectEntries(permissionGroups)) { +for (const [key, permissions] of objectEntries(permissionsByLevel)) { for (const permission of permissions) { - lookupMap.set(permission.value, key); + permissionResourceLevelLookupMap.set(permission.value, key); } } /** Get the permission group for a specific permissions */ -export function getPermissionGroup( - permission: z.TypeOf, -): 'organization' | 'project' | 'target' | 'service' | 'appDeployment' { - const group = lookupMap.get(permission); +function getPermissionGroup(permission: Permissions): ResourceLevels { + const group = permissionResourceLevelLookupMap.get(permission); if (group === undefined) { throw new Error(`Could not find group for permission '${permission}'.`); @@ -429,26 +433,44 @@ export function getPermissionGroup( return group; } +/** + * Transforms a flat permission array into an object that groups the permissions per resource level. + */ +export function permissionsToPermissionsPerResourceLevelAssignment( + permissions: Array, +): PermissionsPerResourceLevelAssignment { + const assignment: PermissionsPerResourceLevelAssignment = { + organization: new Set(), + project: new Set(), + target: new Set(), + service: new Set(), + appDeployment: new Set(), + }; + + for (const permission of permissions) { + const group = getPermissionGroup(permission); + (assignment[group] as Set).add(permission); + } + + return assignment; +} + +type ActionDefinitionMap = { + [key: `${string}:${string}`]: (args: any) => Array; +}; + const actionDefinitions = { - ...objectFromEntries(permissionGroups['organization'].map(t => [t.value, defaultOrgIdentity])), + ...objectFromEntries(permissionsByLevel['organization'].map(t => [t.value, defaultOrgIdentity])), + ...objectFromEntries(permissionsByLevel['project'].map(t => [t.value, defaultProjectIdentity])), + ...objectFromEntries(permissionsByLevel['target'].map(t => [t.value, defaultTargetIdentity])), ...objectFromEntries( - permissionGroups['project'].map(t => [t.value, defaultProjectIdentity] as const), + permissionsByLevel['service'].map(t => [t.value, schemaCheckOrPublishIdentity]), ), ...objectFromEntries( - permissionGroups['target'].map(t => [t.value, defaultTargetIdentity] as const), - ), - ...objectFromEntries( - permissionGroups['service'].map(t => [t.value, schemaCheckOrPublishIdentity] as const), - ), - ...objectFromEntries( - permissionGroups['appDeployment'].map(t => [t.value, defaultAppDeploymentIdentity] as const), + permissionsByLevel['appDeployment'].map(t => [t.value, defaultAppDeploymentIdentity]), ), } satisfies ActionDefinitionMap; -type ActionDefinitionMap = { - [key: `${string}:${string}`]: (args: any) => Array; -}; - type Actions = keyof typeof actionDefinitions; type ActionStrings = Actions | '*'; diff --git a/packages/services/api/src/modules/auth/providers/organization-members.ts b/packages/services/api/src/modules/auth/providers/organization-members.ts index f3c41c4a39..5ece2ebdb1 100644 --- a/packages/services/api/src/modules/auth/providers/organization-members.ts +++ b/packages/services/api/src/modules/auth/providers/organization-members.ts @@ -7,10 +7,10 @@ import { isUUID } from '../../../shared/is-uuid'; import { Logger } from '../../shared/providers/logger'; import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; import { - AllPermissionsModel, - getPermissionGroup, - PermissionGroups, - PermissionGroupsModel, + PermissionsModel, + PermissionsPerResourceLevelAssignment, + PermissionsPerResourceLevelAssignmentModel, + permissionsToPermissionsPerResourceLevelAssignment, } from '../lib/authz'; import { OrganizationAccessScope, ProjectAccessScope, TargetAccessScope } from './scopes'; @@ -44,21 +44,9 @@ const RawMemberRoleModel = z.intersection( }), z.object({ legacyScopes: z.null(), - permissions: z.array(AllPermissionsModel).transform(rawPermissions => { - const permissions = { - organization: new Set(), - project: new Set(), - target: new Set(), - service: new Set(), - appDeployment: new Set(), - }; - - for (const permission of rawPermissions) { - permissions[getPermissionGroup(permission)].add(permission); - } - - return PermissionGroupsModel.parse(permissions); - }), + permissions: z + .array(PermissionsModel) + .transform(permissions => permissionsToPermissionsPerResourceLevelAssignment(permissions)), }), ]), ); @@ -102,7 +90,7 @@ type MemberRoleType = { id: string; description: string; isLocked: boolean; - permissions: PermissionGroups; + permissions: PermissionsPerResourceLevelAssignment; }; export type OrganizationMembershipRoleAssignment = { @@ -327,8 +315,8 @@ export class OrganizationMembers { function transformOrganizationMemberLegacyScopesIntoPermissionGroup( scopes: Array, -): z.TypeOf { - const permissions: z.TypeOf = { +): z.TypeOf { + const permissions: z.TypeOf = { organization: new Set(), project: new Set(), target: new Set(), From edb7fd1c74301746511a006ccf65282c42a581ad Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 30 Dec 2024 14:32:53 +0100 Subject: [PATCH 05/55] remove export --- packages/services/api/src/modules/auth/lib/authz.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index 617857d962..70eebc95fd 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -274,7 +274,7 @@ function isResourceIdMatch( return true; } -export function defaultOrgIdentity(args: { organizationId: string }) { +function defaultOrgIdentity(args: { organizationId: string }) { return [`organization/${args.organizationId}`]; } From b42ae1e7396f2e2cbda59c0fd850c2c918df056c Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 30 Dec 2024 16:03:41 +0100 Subject: [PATCH 06/55] move business logic for hierarchy resolution to the organization members module --- .../modules/auth/lib/supertokens-strategy.ts | 104 ++++++----- .../auth/providers/organization-members.ts | 165 ++++++++++++++++-- 2 files changed, 210 insertions(+), 59 deletions(-) diff --git a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts index 1a75bdc345..18afb0cfda 100644 --- a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts @@ -10,6 +10,7 @@ import type { Storage } from '../../shared/providers/storage'; import { OrganizationMembers, OrganizationMembershipRoleAssignment, + ResourceAssignment, } from '../providers/organization-members'; import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz'; @@ -117,64 +118,79 @@ export class SuperTokensCookieBasedSession extends Session { return true; } + private toResourceIdentifier(organizationId: string, resource: ResourceAssignment): string; + private toResourceIdentifier( + organizationId: string, + resource: ResourceAssignment | Array, + ): Array; + private toResourceIdentifier( + organizationId: string, + resource: ResourceAssignment | Array, + ): string | Array { + if (Array.isArray(resource)) { + return resource.map(resource => this.toResourceIdentifier(organizationId, resource)); + } + + if (resource.type === 'organization') { + return `hrn:${organizationId}:organization/${resource.organizationId}`; + } + + if (resource.type === 'project') { + return `hrn:${organizationId}:project/${resource.projectId}`; + } + + if (resource.type === 'target') { + return `hrn:${organizationId}:project/${resource.targetId}`; + } + + if (resource.type === 'service') { + return `hrn:${organizationId}:target/${resource.targetId}/service/${resource.serviceName}`; + } + + if (resource.type === 'appDeployment') { + return `hrn:${organizationId}:target/${resource.targetId}/appDeployment/${resource.appDeploymentName}`; + } + + throw new Error('never'); + } + private translateAssignedRolesToAuthorizationPolicyStatements( organizationId: string, organizationMembershipRoleAssignments: Array, ): Array { const policyStatements: Array = []; - const orgResourceId = `hrn:${organizationId}:organization/${organizationId}`; - for (const assignedRole of organizationMembershipRoleAssignments) { - /** - * Currently, we have the following hierarchy - * - * organization - * v - * project - * v - * target - * v v - * app deployment service - * - * If one level specifies "*", it needs to inherit the resources defined on the next upper level. - */ - - let parentResource: Array = [orgResourceId]; - if (assignedRole.role.permissions.organization.size) { policyStatements.push({ action: Array.from(assignedRole.role.permissions.organization), effect: 'allow', - resource: parentResource, + resource: this.toResourceIdentifier( + organizationId, + assignedRole.resolvedResources.organization, + ), }); } if (assignedRole.role.permissions.project.size) { - if (Array.isArray(assignedRole.resources.project)) { - parentResource = assignedRole.resources.project.map( - uuid => `hrn:${organizationId}:project/${uuid}`, - ); - } - policyStatements.push({ action: Array.from(assignedRole.role.permissions.project), effect: 'allow', - resource: parentResource, + resource: this.toResourceIdentifier( + organizationId, + assignedRole.resolvedResources.project, + ), }); } if (assignedRole.role.permissions.target.size) { - if (Array.isArray(assignedRole.resources.target)) { - parentResource = assignedRole.resources.target.map( - uuid => `hrn:${organizationId}:target/${uuid}`, - ); - } - policyStatements.push({ action: Array.from(assignedRole.role.permissions.target), effect: 'allow', - resource: parentResource, + resource: this.toResourceIdentifier( + organizationId, + assignedRole.resolvedResources.target, + ), }); } @@ -182,13 +198,10 @@ export class SuperTokensCookieBasedSession extends Session { policyStatements.push({ action: Array.from(assignedRole.role.permissions.service), effect: 'allow', - resource: - assignedRole.resources.service === '*' - ? parentResource - : assignedRole.resources.service.map( - ([targetId, serviceName]) => - `hrn:${organizationId}:target/${targetId}/service/${serviceName}`, - ), + resource: this.toResourceIdentifier( + organizationId, + assignedRole.resolvedResources.service, + ), }); } @@ -196,13 +209,10 @@ export class SuperTokensCookieBasedSession extends Session { policyStatements.push({ action: Array.from(assignedRole.role.permissions.appDeployment), effect: 'allow', - resource: - assignedRole.resources.appDeployment === '*' - ? parentResource - : assignedRole.resources.appDeployment.map( - ([targetId, serviceName]) => - `hrn:${organizationId}:target/${targetId}/appDeployment/${serviceName}`, - ), + resource: this.toResourceIdentifier( + organizationId, + assignedRole.resolvedResources.appDeployment, + ), }); } } diff --git a/packages/services/api/src/modules/auth/providers/organization-members.ts b/packages/services/api/src/modules/auth/providers/organization-members.ts index 5ece2ebdb1..e10b014ad5 100644 --- a/packages/services/api/src/modules/auth/providers/organization-members.ts +++ b/packages/services/api/src/modules/auth/providers/organization-members.ts @@ -84,6 +84,9 @@ const ResourceAssignmentGroupModel = z.object({ appDeployment: ServiceResourceAssignmentModel, }); +/** + * Resource assignments as stored within the database. + */ type ResourceAssignmentGroup = z.TypeOf; type MemberRoleType = { @@ -95,7 +98,14 @@ type MemberRoleType = { export type OrganizationMembershipRoleAssignment = { role: MemberRoleType; + /** + * Resource assignments as stored within the database. + */ resources: ResourceAssignmentGroup; + /** + * Actual resolved resource groups + */ + resolvedResources: ResolvedResourceAssignments; }; type OrganizationMembership = { @@ -238,6 +248,13 @@ export class OrganizationMembers { // In this case we translate the legacy scopes to a single permission group on the "organization" // resource typ. Then assign the users organization to the group, so it has the same behavior as previously. + const resources: ResourceAssignmentGroup = { + project: '*', + target: '*', + service: '*', + appDeployment: '*', + }; + organizationMembership.assignedRoles.push({ role: { id: 'legacy-scope-role', @@ -248,12 +265,11 @@ export class OrganizationMembers { ), }, // allow all permissions for all resources within the organization. - resources: { - project: '*', - target: '*', - service: '*', - appDeployment: '*', - }, + resources, + resolvedResources: resolveResourceAssignment({ + organizationId: organization.id, + groups: resources, + }), }); } // else { @@ -276,13 +292,18 @@ export class OrganizationMembers { if (!membershipRole) { continue; } + const resources: ResourceAssignmentGroup = { + project: '*', + target: '*', + service: '*', + appDeployment: '*', + }; record.assignedRoles.push({ - resources: { - project: '*', - target: '*', - service: '*', - appDeployment: '*', - }, + resources, + resolvedResources: resolveResourceAssignment({ + organizationId: organization.id, + groups: resources, + }), role: membershipRole, }); } @@ -407,3 +428,123 @@ function transformOrganizationMemberLegacyScopesIntoPermissionGroup( return permissions; } + +type OrganizationAssignment = { + type: 'organization'; + organizationId: string; +}; + +type ProjectAssignment = { + type: 'project'; + projectId: string; +}; + +type TargetAssignment = { + type: 'target'; + targetId: string; +}; + +type ServiceAssignment = { + type: 'service'; + targetId: string; + serviceName: string; +}; + +type AppDeploymentAssignment = { + type: 'appDeployment'; + targetId: string; + appDeploymentName: string; +}; + +export type ResourceAssignment = + | OrganizationAssignment + | ProjectAssignment + | TargetAssignment + | ServiceAssignment + | AppDeploymentAssignment; + +type ResolvedResourceAssignments = { + organization: OrganizationAssignment; + project: OrganizationAssignment | Array; + target: OrganizationAssignment | Array | Array; + service: + | OrganizationAssignment + | Array + | Array + | Array; + appDeployment: + | OrganizationAssignment + | Array + | Array + | Array; +}; + +/** + * This function resolves the "stored-in-database", user configuration to the actual resolved structure + * Currently, we have the following hierarchy + * + * organization + * v + * project + * v + * target + * v v + * app deployment service + * + * If one level specifies "*", it needs to inherit the resources defined on the next upper level. + */ +function resolveResourceAssignment(args: { + organizationId: string; + groups: ResourceAssignmentGroup; +}): ResolvedResourceAssignments { + const organization: OrganizationAssignment = { + type: 'organization', + organizationId: args.organizationId, + }; + + let project: ResolvedResourceAssignments['project'] = organization; + + if (args.groups.project !== '*') { + project = args.groups.project.map(projectId => ({ + type: 'project', + projectId, + })); + } + + let target: ResolvedResourceAssignments['target'] = project; + + if (args.groups.target !== '*') { + target = args.groups.target.map(targetId => ({ + type: 'target', + targetId, + })); + } + + let service: ResolvedResourceAssignments['service'] = target; + + if (args.groups.service !== '*') { + service = args.groups.service.map(([targetId, serviceName]) => ({ + type: 'service', + targetId, + serviceName, + })); + } + + let appDeployment: ResolvedResourceAssignments['appDeployment'] = target; + + if (args.groups.service !== '*') { + appDeployment = args.groups.service.map(([targetId, appDeploymentName]) => ({ + type: 'appDeployment', + targetId, + appDeploymentName, + })); + } + + return { + organization, + project, + target, + service, + appDeployment, + }; +} From e3ce5d32ff402d8e80045cca27aca49d01864157 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 30 Dec 2024 16:20:34 +0100 Subject: [PATCH 07/55] fix import --- packages/services/api/src/index.ts | 1 + .../src/modules/auth/providers/organization-members.ts | 2 +- packages/services/server/src/index.ts | 10 ++++++++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/services/api/src/index.ts b/packages/services/api/src/index.ts index 0d11764899..591d8b52f7 100644 --- a/packages/services/api/src/index.ts +++ b/packages/services/api/src/index.ts @@ -37,3 +37,4 @@ export { ProjectAccessScope, TargetAccessScope, } from './__generated__/types'; +export { OrganizationMembers } from './modules/auth/providers/organization-members'; diff --git a/packages/services/api/src/modules/auth/providers/organization-members.ts b/packages/services/api/src/modules/auth/providers/organization-members.ts index e10b014ad5..c981f4c0a0 100644 --- a/packages/services/api/src/modules/auth/providers/organization-members.ts +++ b/packages/services/api/src/modules/auth/providers/organization-members.ts @@ -103,7 +103,7 @@ export type OrganizationMembershipRoleAssignment = { */ resources: ResourceAssignmentGroup; /** - * Actual resolved resource groups + * Resolved resource groups, used for runtime permission checks. */ resolvedResources: ResolvedResourceAssignments; }; diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index bd0c51889a..9d31d6847f 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -12,10 +12,16 @@ import { createRedisEventTarget } from '@graphql-yoga/redis-event-target'; import 'reflect-metadata'; import { hostname } from 'os'; import { createPubSub } from 'graphql-yoga'; -import { OrganizationMembers } from 'packages/services/api/src/modules/auth/providers/organization-members'; import { z } from 'zod'; import formDataPlugin from '@fastify/formbody'; -import { createRegistry, createTaskRunner, CryptoProvider, LogFn, Logger } from '@hive/api'; +import { + createRegistry, + createTaskRunner, + CryptoProvider, + LogFn, + Logger, + OrganizationMembers, +} from '@hive/api'; import { HivePubSub } from '@hive/api/src/modules/shared/providers/pub-sub'; import { createRedisClient } from '@hive/api/src/modules/shared/providers/redis'; import { createArtifactRequestHandler } from '@hive/cdn-script/artifact-handler'; From 1ba564e25d9de3df4fefaaa1f097bf3a86ecb4e3 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 2 Jan 2025 11:03:10 +0100 Subject: [PATCH 08/55] expose membership permissions via GraphQL API --- .../api/src/modules/auth/lib/authz.ts | 20 +- .../lib/organization-member-permissions.ts | 311 ++++++++++++++++++ .../modules/auth/module.graphql.mappers.ts | 3 + .../api/src/modules/auth/module.graphql.ts | 29 ++ .../modules/auth/resolvers/Organization.ts | 21 ++ .../src/modules/auth/resolvers/Permission.ts | 21 ++ .../modules/auth/resolvers/PermissionGroup.ts | 14 + 7 files changed, 413 insertions(+), 6 deletions(-) create mode 100644 packages/services/api/src/modules/auth/lib/organization-member-permissions.ts create mode 100644 packages/services/api/src/modules/auth/resolvers/Organization.ts create mode 100644 packages/services/api/src/modules/auth/resolvers/Permission.ts create mode 100644 packages/services/api/src/modules/auth/resolvers/PermissionGroup.ts diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index 70eebc95fd..78c6ed22b1 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -387,6 +387,14 @@ const permissionsByLevel = { ], } as const; +export const allPermissions = [ + ...permissionsByLevel.organization.map(v => v.value), + ...permissionsByLevel.project.map(v => v.value), + ...permissionsByLevel.target.map(v => v.value), + ...permissionsByLevel.service.map(v => v.value), + ...permissionsByLevel.appDeployment.map(v => v.value), +] as const; + export const PermissionsPerResourceLevelAssignmentModel = z.object({ organization: z.set(z.union(permissionsByLevel.organization)), project: z.set(z.union(permissionsByLevel.project)), @@ -399,7 +407,7 @@ export type PermissionsPerResourceLevelAssignment = z.TypeOf< typeof PermissionsPerResourceLevelAssignmentModel >; -type ResourceLevels = keyof PermissionsPerResourceLevelAssignment; +export type ResourceLevel = keyof PermissionsPerResourceLevelAssignment; export const PermissionsModel = z.union([ ...permissionsByLevel.organization, @@ -409,11 +417,11 @@ export const PermissionsModel = z.union([ ...permissionsByLevel.appDeployment, ]); -type Permissions = z.TypeOf; +export type Permission = z.TypeOf; const permissionResourceLevelLookupMap = new Map< z.TypeOf, - ResourceLevels + ResourceLevel >(); for (const [key, permissions] of objectEntries(permissionsByLevel)) { @@ -423,7 +431,7 @@ for (const [key, permissions] of objectEntries(permissionsByLevel)) { } /** Get the permission group for a specific permissions */ -function getPermissionGroup(permission: Permissions): ResourceLevels { +export function getPermissionGroup(permission: Permission): ResourceLevel { const group = permissionResourceLevelLookupMap.get(permission); if (group === undefined) { @@ -437,7 +445,7 @@ function getPermissionGroup(permission: Permissions): ResourceLevels { * Transforms a flat permission array into an object that groups the permissions per resource level. */ export function permissionsToPermissionsPerResourceLevelAssignment( - permissions: Array, + permissions: Array, ): PermissionsPerResourceLevelAssignment { const assignment: PermissionsPerResourceLevelAssignment = { organization: new Set(), @@ -449,7 +457,7 @@ export function permissionsToPermissionsPerResourceLevelAssignment( for (const permission of permissions) { const group = getPermissionGroup(permission); - (assignment[group] as Set).add(permission); + (assignment[group] as Set).add(permission); } return assignment; diff --git a/packages/services/api/src/modules/auth/lib/organization-member-permissions.ts b/packages/services/api/src/modules/auth/lib/organization-member-permissions.ts new file mode 100644 index 0000000000..64149bb0d4 --- /dev/null +++ b/packages/services/api/src/modules/auth/lib/organization-member-permissions.ts @@ -0,0 +1,311 @@ +import { allPermissions, Permission } from './authz'; + +export type PermissionRecord = { + id: Permission; + title: string; + description: string; + dependsOn?: Permission; + readOnly?: true; +}; + +export type PermissionGroup = { + id: string; + title: string; + permissions: Array; +}; + +export const allPermissionGroups: Array = [ + { + id: 'organization', + title: 'Organization', + permissions: [ + { + id: 'organization:describe', + title: 'View organization', + description: 'Member can see the organization. Permission can not be modified.', + readOnly: true, + }, + { + id: 'support:manageTickets', + title: 'Access support tickets', + description: 'Member can access, create and reply to support tickets.', + }, + { + id: 'organization:modifySlug', + title: 'Update organization slug', + description: 'Member can modify the organization slug.', + }, + { + id: 'auditLog:export', + title: 'Export audit log', + description: 'Member can access and export the audit log.', + }, + { + id: 'organization:delete', + title: 'Delete organization', + description: 'Member can delete the Organization.', + }, + ], + }, + { + id: 'members', + title: 'Members', + permissions: [ + { + id: 'member:describe', + title: 'View members', + description: 'Member can access the organization member overview.', + }, + { + id: 'member:assignRole', + title: 'Assign member role', + description: 'Member can assign roles to users.', + dependsOn: 'member:describe', + }, + { + id: 'member:modifyRole', + title: 'Modify member role', + description: 'Member can modify, create and delete roles.', + dependsOn: 'member:describe', + }, + { + id: 'member:removeMember', + title: 'Remove member', + description: 'Member can remove users from the organization.', + dependsOn: 'member:describe', + }, + { + id: 'member:manageInvites', + title: 'Manage invites', + description: 'Member can invite users via email and modify or delete pending invites.', + dependsOn: 'member:describe', + }, + ], + }, + { + id: 'billing', + title: 'Billing', + permissions: [ + { + id: 'billing:describe', + title: 'View billing', + description: 'Member can view the billing information.', + }, + { + id: 'billing:update', + title: 'Update billing', + description: 'Member can change the organization plan.', + dependsOn: 'billing:describe', + }, + ], + }, + { + id: 'oidc', + title: 'OpenID Connect', + permissions: [ + { + id: 'oidc:modify', + title: 'Manage OpenID Connect integration', + description: 'Member can connect, modify, and remove an OIDC provider to the connection.', + }, + ], + }, + { + id: 'github', + title: 'GitHub Integration', + permissions: [ + { + id: 'gitHubIntegration:modify', + title: 'Manage GitHub integration', + description: + 'Member can connect, modify, and remove access for the GitHub integration and repository access.', + }, + ], + }, + { + id: 'slack', + title: 'Slack Integration', + permissions: [ + { + id: 'slackIntegration:modify', + title: 'Manage Slack integration', + description: + 'Member can connect, modify, and remove access for the Slack integration and repository access.', + }, + ], + }, + { + id: 'project', + title: 'Project', + permissions: [ + { + id: 'project:create', + title: 'Create project', + description: 'Member can create new projects.', + }, + { + id: 'project:describe', + title: 'View project', + description: 'Member can access the specified projects.', + }, + { + id: 'project:delete', + title: 'Delete project', + description: 'Member can access the specified projects.', + dependsOn: 'project:describe', + }, + { + id: 'project:modifySettings', + title: 'Modify Settings', + description: 'Member can access the specified projects.', + dependsOn: 'project:describe', + }, + ], + }, + { + id: 'schema-linting', + title: 'Schema Linting', + permissions: [ + { + id: 'schemaLinting:modifyOrganizationRules', + title: 'Manage organization level schema linting', + description: 'Member can view and modify the organization schema linting rules.', + }, + { + id: 'schemaLinting:modifyProjectRules', + title: 'Manage project level schema linting', + description: 'Member can view and modify the projects schema linting rules.', + dependsOn: 'project:describe', + }, + ], + }, + { + id: 'target', + title: 'Target', + permissions: [ + { + id: 'target:create', + title: 'Create target', + description: 'Member can create new projects.', + dependsOn: 'project:describe', + }, + { + id: 'target:delete', + title: 'Delete target', + description: 'Member can access the specified projects.', + dependsOn: 'project:describe', + }, + { + id: 'target:modifySettings', + title: 'Modify settings', + description: 'Member can access the specified projects.', + dependsOn: 'project:describe', + }, + { + id: 'alert:modify', + title: 'Modify alerts', + description: 'Can create alerts for schema versions.', + dependsOn: 'project:describe', + }, + { + id: 'schemaVersion:approve', + title: 'Approve schema version (legacy)', + description: 'Can approve schema versions on projects using the legacy registry model.', + }, + { + id: 'targetAccessToken:modify', + title: 'Manage registry access tokens', + description: 'Allow managing access tokens for CLI and Usage Reporting.', + dependsOn: 'project:describe', + }, + { + id: 'cdnAccessToken:modify', + title: 'Manage CDN access tokens', + description: 'Allow managing access tokens for the CDN.', + dependsOn: 'project:describe', + }, + ], + }, + { + id: 'laboratory', + title: 'Laboratory', + permissions: [ + { + id: 'laboratory:describe', + title: 'View laboratory', + description: 'Member can access the laboratory, view and execute GraphQL documents.', + dependsOn: 'project:describe', + }, + { + id: 'laboratory:modify', + title: 'Modify laboratory', + description: + 'Member can create, delete and update collections and documents in the laboratory.', + dependsOn: 'laboratory:describe', + }, + { + id: 'laboratory:modifyPreflightScript', + title: 'Modify the laboratory preflight script', + description: 'Member can update the laboratory preflight script.', + dependsOn: 'laboratory:describe', + }, + ], + }, + { + id: 'app-deployments', + title: 'App Deployments', + permissions: [ + { + id: 'appDeployment:describe', + title: 'View app deployments', + description: 'Member can view app deployments.', + dependsOn: 'project:describe', + }, + ], + }, + { + id: 'schema-checks', + title: 'Schema Checks', + permissions: [ + { + id: 'schemaCheck:approve', + title: 'Approve schema check', + description: 'Member can approve failed schema checks.', + dependsOn: 'project:describe', + }, + ], + }, +] as const; + +function assertAllRulesAreAssigned(excluded: Array) { + const p = new Set(allPermissions); + for (const item of excluded) { + p.delete(item); + } + + for (const group of allPermissionGroups) { + for (const per of group.permissions) { + p.delete(per.id); + } + } + + if (p.size) { + throw new Error('The following permissions are not assigned: \n' + Array.from(p).join(`\n`)); + } +} + +/** + * This seems like the easiest way to make sure that all the permissions we have are + * assignable and exposed via our API. + */ +assertAllRulesAreAssigned([ + /** These are CLI only actions for now. */ + 'schema:loadFromRegistry', + 'schema:compose', + 'schemaCheck:create', + 'schemaVersion:publish', + 'schemaVersion:deleteService', + 'appDeployment:create', + 'appDeployment:publish', + 'appDeployment:retire', +]); diff --git a/packages/services/api/src/modules/auth/module.graphql.mappers.ts b/packages/services/api/src/modules/auth/module.graphql.mappers.ts index 9520d4e635..3ed4eec01e 100644 --- a/packages/services/api/src/modules/auth/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/auth/module.graphql.mappers.ts @@ -1,4 +1,5 @@ import type { Member, User } from '../../shared/entities'; +import { PermissionGroup, PermissionRecord } from './lib/organization-member-permissions'; import type { OrganizationAccessScope } from './providers/organization-access'; import type { ProjectAccessScope } from './providers/project-access'; import type { TargetAccessScope } from './providers/target-access'; @@ -10,3 +11,5 @@ export type UserConnectionMapper = readonly User[]; export type MemberConnectionMapper = readonly Member[]; export type MemberMapper = Member; export type UserMapper = User; +export type PermissionGroupMapper = PermissionGroup; +export type PermissionMapper = PermissionRecord; diff --git a/packages/services/api/src/modules/auth/module.graphql.ts b/packages/services/api/src/modules/auth/module.graphql.ts index 239db6ba17..a651f3133b 100644 --- a/packages/services/api/src/modules/auth/module.graphql.ts +++ b/packages/services/api/src/modules/auth/module.graphql.ts @@ -110,4 +110,33 @@ export default gql` TOKENS_READ TOKENS_WRITE } + + enum PermissionLevel { + organization + project + target + service + appDeployment + } + + type Permission { + id: ID! + title: String! + description: String! + level: PermissionLevel! + dependsOnId: ID + } + + type PermissionGroup { + id: ID! + title: String! + permissions: [Permission!]! + } + + extend type Organization { + """ + List of available permission groups that can be assigned to users. + """ + availableMemberPermissionGroups: [PermissionGroup!]! + } `; diff --git a/packages/services/api/src/modules/auth/resolvers/Organization.ts b/packages/services/api/src/modules/auth/resolvers/Organization.ts new file mode 100644 index 0000000000..af78014f17 --- /dev/null +++ b/packages/services/api/src/modules/auth/resolvers/Organization.ts @@ -0,0 +1,21 @@ +import { allPermissionGroups } from '../lib/organization-member-permissions'; +import type { OrganizationResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "OrganizationMapper" is declared. This is to ensure runtime safety. + * + * When a mapper is used, it is possible to hit runtime errors in some scenarios: + * - given a field name, the schema type's field type does not match mapper's field type + * - or a schema type's field does not exist in the mapper's fields + * + * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. + */ +export const Organization: Pick< + OrganizationResolvers, + 'availableMemberPermissionGroups' | '__isTypeOf' +> = { + /* Implement Organization resolver logic here */ + availableMemberPermissionGroups: async (_parent, _arg, _ctx) => { + return allPermissionGroups; + }, +}; diff --git a/packages/services/api/src/modules/auth/resolvers/Permission.ts b/packages/services/api/src/modules/auth/resolvers/Permission.ts new file mode 100644 index 0000000000..b8b6c29879 --- /dev/null +++ b/packages/services/api/src/modules/auth/resolvers/Permission.ts @@ -0,0 +1,21 @@ +import { getPermissionGroup } from '../lib/authz'; +import type { PermissionResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "PermissionMapper" is declared. This is to ensure runtime safety. + * + * When a mapper is used, it is possible to hit runtime errors in some scenarios: + * - given a field name, the schema type's field type does not match mapper's field type + * - or a schema type's field does not exist in the mapper's fields + * + * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. + */ +export const Permission: PermissionResolvers = { + dependsOnId: async (parent, _arg, _ctx) => { + /* Permission.dependsOnId resolver is required because Permission.dependsOnId exists but PermissionMapper.dependsOnId does not */ + return parent.dependsOn ?? null; + }, + level: async (parent, _arg, _ctx) => { + return getPermissionGroup(parent.id); + }, +}; diff --git a/packages/services/api/src/modules/auth/resolvers/PermissionGroup.ts b/packages/services/api/src/modules/auth/resolvers/PermissionGroup.ts new file mode 100644 index 0000000000..4486f36f03 --- /dev/null +++ b/packages/services/api/src/modules/auth/resolvers/PermissionGroup.ts @@ -0,0 +1,14 @@ +import type { PermissionGroupResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "PermissionGroupMapper" is declared. This is to ensure runtime safety. + * + * When a mapper is used, it is possible to hit runtime errors in some scenarios: + * - given a field name, the schema type's field type does not match mapper's field type + * - or a schema type's field does not exist in the mapper's fields + * + * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. + */ +export const PermissionGroup: PermissionGroupResolvers = { + /* Implement PermissionGroup resolver logic here */ +}; From 8a046df5e55fbab4ffffb7c9d965f563b13c366a Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 3 Jan 2025 13:21:49 +0100 Subject: [PATCH 09/55] feat: permission picker and viewer --- .../lib/organization-member-permissions.ts | 4 +- .../api/src/modules/auth/module.graphql.ts | 1 + .../src/modules/auth/resolvers/Permission.ts | 12 +- .../members/permission-selector.tsx | 227 ++++++++++ .../members/selected-permission-overview.tsx | 181 ++++++++ .../app/src/pages/organization-members.tsx | 1 + .../stories/permission-selector.stories.tsx | 53 +++ .../selected-permission-overview.stories.tsx | 55 +++ packages/web/app/src/stories/utils.ts | 388 ++++++++++++++++++ 9 files changed, 915 insertions(+), 7 deletions(-) create mode 100644 packages/web/app/src/components/organization/members/permission-selector.tsx create mode 100644 packages/web/app/src/components/organization/members/selected-permission-overview.tsx create mode 100644 packages/web/app/src/stories/permission-selector.stories.tsx create mode 100644 packages/web/app/src/stories/selected-permission-overview.stories.tsx create mode 100644 packages/web/app/src/stories/utils.ts diff --git a/packages/services/api/src/modules/auth/lib/organization-member-permissions.ts b/packages/services/api/src/modules/auth/lib/organization-member-permissions.ts index 64149bb0d4..101d781cca 100644 --- a/packages/services/api/src/modules/auth/lib/organization-member-permissions.ts +++ b/packages/services/api/src/modules/auth/lib/organization-member-permissions.ts @@ -5,7 +5,7 @@ export type PermissionRecord = { title: string; description: string; dependsOn?: Permission; - readOnly?: true; + isReadyOnly?: true; }; export type PermissionGroup = { @@ -23,7 +23,7 @@ export const allPermissionGroups: Array = [ id: 'organization:describe', title: 'View organization', description: 'Member can see the organization. Permission can not be modified.', - readOnly: true, + isReadyOnly: true, }, { id: 'support:manageTickets', diff --git a/packages/services/api/src/modules/auth/module.graphql.ts b/packages/services/api/src/modules/auth/module.graphql.ts index a651f3133b..6ad050a05d 100644 --- a/packages/services/api/src/modules/auth/module.graphql.ts +++ b/packages/services/api/src/modules/auth/module.graphql.ts @@ -125,6 +125,7 @@ export default gql` description: String! level: PermissionLevel! dependsOnId: ID + isReadOnly: Boolean! } type PermissionGroup { diff --git a/packages/services/api/src/modules/auth/resolvers/Permission.ts b/packages/services/api/src/modules/auth/resolvers/Permission.ts index b8b6c29879..ebad4c0c39 100644 --- a/packages/services/api/src/modules/auth/resolvers/Permission.ts +++ b/packages/services/api/src/modules/auth/resolvers/Permission.ts @@ -11,11 +11,13 @@ import type { PermissionResolvers } from './../../../__generated__/types'; * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. */ export const Permission: PermissionResolvers = { - dependsOnId: async (parent, _arg, _ctx) => { - /* Permission.dependsOnId resolver is required because Permission.dependsOnId exists but PermissionMapper.dependsOnId does not */ - return parent.dependsOn ?? null; + dependsOnId: async (permission, _arg, _ctx) => { + return permission.dependsOn ?? null; }, - level: async (parent, _arg, _ctx) => { - return getPermissionGroup(parent.id); + isReadOnly: (permission, _arg, _ctx) => { + return permission.isReadyOnly ?? false; + }, + level: async (permission, _arg, _ctx) => { + return getPermissionGroup(permission.id); }, }; diff --git a/packages/web/app/src/components/organization/members/permission-selector.tsx b/packages/web/app/src/components/organization/members/permission-selector.tsx new file mode 100644 index 0000000000..feefe18ec6 --- /dev/null +++ b/packages/web/app/src/components/organization/members/permission-selector.tsx @@ -0,0 +1,227 @@ +import { useMemo, useRef, useState } from 'react'; +import { InfoIcon } from 'lucide-react'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { cn } from '@/lib/utils'; +import { ResultOf } from '@graphql-typed-document-node/core'; + +export const PermissionSelector_OrganizationFragment = graphql(` + fragment PermissionSelector_OrganizationFragment on Organization { + id + availableMemberPermissionGroups { + id + title + permissions { + id + dependsOnId + description + level + title + isReadOnly + } + } + } +`); + +type AvailableMembershipPermissions = ResultOf< + typeof PermissionSelector_OrganizationFragment +>['availableMemberPermissionGroups']; + +type MembershipPermissionGroup = AvailableMembershipPermissions[number]; + +export type PermissionSelectorProps = { + organization: FragmentType; + selectedPermissionIds: ReadonlySet; + onSelectedPermissionsChange: (selectedPermissionIds: ReadonlySet) => void; +}; + +export function PermissionSelector(props: PermissionSelectorProps) { + const organization = useFragment(PermissionSelector_OrganizationFragment, props.organization); + const [filteredGroups, permissionGroupMapping] = useMemo(() => { + const filteredGroups: Array< + MembershipPermissionGroup & { + selectedPermissionCount: number; + } + > = []; + const permissionGroupMapping = new Map(); + + for (const group of organization.availableMemberPermissionGroups) { + let selectedPermissionCount = 0; + + for (const permission of group.permissions) { + if (props.selectedPermissionIds.has(permission.id)) { + selectedPermissionCount++; + } + } + + filteredGroups.push({ + ...group, + selectedPermissionCount, + }); + } + + return [filteredGroups, permissionGroupMapping] as const; + }, [organization.availableMemberPermissionGroups]); + + const permissionRefs = useRef(new Map()); + const [focusedPermission, setFocusedPermission] = useState(null as string | null); + const [openAccordions, setOpenAccordions] = useState([] as Array); + + return ( + setOpenAccordions(values)} + > + {filteredGroups.map(group => { + const dependencyGraph = new Map>(); + for (const permission of group.permissions) { + if (!permission.dependsOnId) { + continue; + } + let arr = dependencyGraph.get(permission.dependsOnId); + if (!arr) { + arr = []; + dependencyGraph.set(permission.dependsOnId, arr); + } + arr.push(permission.id); + } + + return ( + + + {group.title}{' '} + + {group.selectedPermissionCount > 0 && ( + + {group.selectedPermissionCount} selected + + )} + + + + {group.permissions.map(permission => { + const needsDependency = + !!permission.dependsOnId && + !props.selectedPermissionIds.has(permission.dependsOnId); + + return ( +
{ + if (ref) { + permissionRefs.current.set(permission.id, ref); + } + }} + > +
+
{permission.title}
+
{permission.description}
+
+ {!!permission.dependsOnId && + permissionGroupMapping.has(permission.dependsOnId) && ( +
+ + + + + + +

+ This permission depends on another permission.{' '} + +

+
+
+
+
+ )} + +
+ ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/packages/web/app/src/components/organization/members/selected-permission-overview.tsx b/packages/web/app/src/components/organization/members/selected-permission-overview.tsx new file mode 100644 index 0000000000..73c339ca88 --- /dev/null +++ b/packages/web/app/src/components/organization/members/selected-permission-overview.tsx @@ -0,0 +1,181 @@ +import { useMemo } from 'react'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; +import { CheckIcon, XIcon } from '@/components/ui/icon'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { PermissionLevel } from '@/gql/graphql'; +import { ResultOf } from '@graphql-typed-document-node/core'; + +export const SelectedPermissionOverview_MemberPermissionGroupsFragment = graphql(` + fragment SelectedPermissionOverview_MemberPermissionGroupsFragment on Organization { + availableMemberPermissionGroups { + id + title + permissions { + id + dependsOnId + description + level + title + } + } + } +`); + +export type SelectedPermissionOverviewProps = { + organization: FragmentType; + activePermissionIds: Array; + showOnlyAllowedPermissions: boolean; +}; + +export function SelectedPermissionOverview(props: SelectedPermissionOverviewProps) { + const organization = useFragment( + SelectedPermissionOverview_MemberPermissionGroupsFragment, + props.organization, + ); + const activePermissionIds = useMemo>( + () => new Set(props.activePermissionIds), + [props.activePermissionIds], + ); + + // TODO: maybe these should also be sent from the API, so it is the full source of truth + return [ + { + level: PermissionLevel.Organization, + title: 'Organization', + }, + { + level: PermissionLevel.Project, + title: 'Project', + }, + { + level: PermissionLevel.Target, + title: 'Target', + }, + { + level: PermissionLevel.Service, + title: 'Service', + }, + { + level: PermissionLevel.AppDeployment, + title: 'App Deployment', + }, + ].map(group => ( + + )); +} + +type AvailableMembershipPermissions = ResultOf< + typeof SelectedPermissionOverview_MemberPermissionGroupsFragment +>['availableMemberPermissionGroups']; + +type MembershipPermissionGroup = AvailableMembershipPermissions[number]; + +function PermissionLevelGroup(props: { + title: string; + permissionLevel: PermissionLevel; + memberPermissionGroups: AvailableMembershipPermissions; + activePermissionIds: ReadonlySet; + /** whether only allowed permissions should be shown */ + showOnlyAllowedPermissions: boolean; +}) { + const [filteredGroups, totalAllowedCount] = useMemo(() => { + let totalAllowedCount = 0; + const filteredGroups: Array< + MembershipPermissionGroup & { + totalAllowedCount: number; + } + > = []; + for (const group of props.memberPermissionGroups) { + let groupTotalAllowedCount = 0; + + const filteredPermissions = group.permissions.filter(permission => { + if (permission.level !== props.permissionLevel) { + return false; + } + + if (props.activePermissionIds.has(permission.id)) { + totalAllowedCount++; + groupTotalAllowedCount++; + } + + return true; + }); + + if (filteredPermissions.length === 0) { + continue; + } + + filteredGroups.push({ + ...group, + permissions: filteredPermissions, + totalAllowedCount: groupTotalAllowedCount, + }); + } + + return [filteredGroups, totalAllowedCount]; + }, [props.permissionLevel, props.activePermissionIds, props.memberPermissionGroups]); + + if (totalAllowedCount === 0 && props.showOnlyAllowedPermissions) { + // hide group fully if no permissions are selected + return null; + } + + return ( + 0 ? props.title : undefined} + collapsible + > + + + {props.title} + {totalAllowedCount} allowed + + + {filteredGroups.map(group => + props.showOnlyAllowedPermissions && group.totalAllowedCount === 0 ? null : ( +
+ + + + + {group.permissions.map(permission => + props.showOnlyAllowedPermissions && + props.activePermissionIds.has(permission.id) === false ? null : ( + + + + + ), + )} +
{group.title}
{permission.title} + {props.activePermissionIds.has(permission.id) ? ( + + Allowed + + ) : ( + + Deny + + )} +
+
+ ), + )} +
+
+
+ ); +} diff --git a/packages/web/app/src/pages/organization-members.tsx b/packages/web/app/src/pages/organization-members.tsx index bb4eb437bf..f01b7a3d24 100644 --- a/packages/web/app/src/pages/organization-members.tsx +++ b/packages/web/app/src/pages/organization-members.tsx @@ -17,6 +17,7 @@ const OrganizationMembersPage_OrganizationFragment = graphql(` ...OrganizationInvitations_OrganizationFragment ...OrganizationMemberRoles_OrganizationFragment ...OrganizationMembers_OrganizationFragment + ...SelectedPermissionOverview_MemberPermissionGroupsFragment viewerCanManageInvitations viewerCanManageRoles } diff --git a/packages/web/app/src/stories/permission-selector.stories.tsx b/packages/web/app/src/stories/permission-selector.stories.tsx new file mode 100644 index 0000000000..6a7d44bf1a --- /dev/null +++ b/packages/web/app/src/stories/permission-selector.stories.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { makeFragmentData } from '@/gql'; +import { Meta, StoryObj } from '@storybook/react'; +import { + PermissionSelector, + PermissionSelector_OrganizationFragment, + PermissionSelectorProps, +} from '../components/organization/members/permission-selector'; +import { availableMemberPermissionGroups } from './utils'; + +const meta: Meta = { + title: 'Permission Selector', + component: PermissionSelector, +}; + +export default meta; + +type Story = StoryObj; + +const defaultProps: Omit< + PermissionSelectorProps, + 'selectedPermissionIds' | 'onSelectedPermissionsChange' +> = { + organization: makeFragmentData( + { + __typename: 'Organization', + id: 'foo', + availableMemberPermissionGroups: availableMemberPermissionGroups as any, + }, + PermissionSelector_OrganizationFragment, + ), +}; + +function Template(args: PermissionSelectorProps) { + const [selectedPermissionIds, setSelectedPermissionIds] = useState(() => new Set()); + + return ( + { + setSelectedPermissionIds(new Set(set)); + }} + /> + ); +} + +export const Default: Story = { + name: 'Default', + render: Template, + args: {}, +}; diff --git a/packages/web/app/src/stories/selected-permission-overview.stories.tsx b/packages/web/app/src/stories/selected-permission-overview.stories.tsx new file mode 100644 index 0000000000..690a76ab43 --- /dev/null +++ b/packages/web/app/src/stories/selected-permission-overview.stories.tsx @@ -0,0 +1,55 @@ +import { makeFragmentData } from '@/gql'; +import { Meta, StoryObj } from '@storybook/react'; +import { + SelectedPermissionOverview, + SelectedPermissionOverview_MemberPermissionGroupsFragment, + SelectedPermissionOverviewProps, +} from '../components/organization/members/selected-permission-overview'; +import { availableMemberPermissionGroups } from './utils'; + +const meta: Meta = { + title: 'Selected Permission Overview', + component: SelectedPermissionOverview, +}; + +export default meta; +type Story = StoryObj; + +const defaultProps: SelectedPermissionOverviewProps = { + organization: makeFragmentData( + { + __typename: 'Organization', + availableMemberPermissionGroups: availableMemberPermissionGroups as any, + }, + SelectedPermissionOverview_MemberPermissionGroupsFragment, + ), + activePermissionIds: [], + showOnlyAllowedPermissions: false, +}; + +function Template(args: SelectedPermissionOverviewProps) { + return ; +} + +export const Default: Story = { + name: 'Default', + render: Template, + args: { + activePermissionIds: [], + showOnlyAllowedPermissions: false, + }, +}; + +export const OnlyAllowedPermissions: Story = { + name: 'OnlyAllowedPermissions', + render: Template, + args: { + activePermissionIds: [ + 'organization:describe', + 'billing:describe', + 'billing:update', + 'project:describe', + ], + showOnlyAllowedPermissions: true, + }, +}; diff --git a/packages/web/app/src/stories/utils.ts b/packages/web/app/src/stories/utils.ts new file mode 100644 index 0000000000..eafc348411 --- /dev/null +++ b/packages/web/app/src/stories/utils.ts @@ -0,0 +1,388 @@ +import { Organization, PermissionLevel } from '@/gql/graphql'; + +export const availableMemberPermissionGroups: Organization['availableMemberPermissionGroups'] = [ + { + __typename: 'PermissionGroup', + id: 'organization', + title: 'Organization', + permissions: [ + { + __typename: 'Permission', + isReadOnly: true, + id: 'organization:describe', + dependsOnId: null, + description: 'Member can see the organization. Permission can not be modified.', + level: PermissionLevel.Organization, + title: 'View organization', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'support:manageTickets', + dependsOnId: null, + description: 'Member can access, create and reply to support tickets.', + level: PermissionLevel.Organization, + title: 'Access support tickets', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'organization:modifySlug', + dependsOnId: null, + description: 'Member can modify the organization slug.', + level: PermissionLevel.Organization, + title: 'Update organization slug', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'auditLog:export', + dependsOnId: null, + description: 'Member can access and export the audit log.', + level: PermissionLevel.Organization, + title: 'Export audit log', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'organization:delete', + dependsOnId: null, + description: 'Member can delete the Organization.', + level: PermissionLevel.Organization, + title: 'Delete organization', + }, + ], + }, + { + __typename: 'PermissionGroup', + id: 'members', + title: 'Members', + permissions: [ + { + __typename: 'Permission', + isReadOnly: false, + id: 'member:describe', + dependsOnId: null, + description: 'Member can access the organization member overview.', + level: PermissionLevel.Organization, + title: 'View members', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'member:assignRole', + dependsOnId: 'member:describe', + description: 'Member can assign roles to users.', + level: PermissionLevel.Organization, + title: 'Assign member role', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'member:modifyRole', + dependsOnId: 'member:describe', + description: 'Member can modify, create and delete roles.', + level: PermissionLevel.Organization, + title: 'Modify member role', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'member:removeMember', + dependsOnId: 'member:describe', + description: 'Member can remove users from the organization.', + level: PermissionLevel.Organization, + title: 'Remove member', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'member:manageInvites', + dependsOnId: 'member:describe', + description: 'Member can invite users via email and modify or delete pending invites.', + level: PermissionLevel.Organization, + title: 'Manage invites', + }, + ], + }, + { + __typename: 'PermissionGroup', + id: 'billing', + title: 'Billing', + permissions: [ + { + __typename: 'Permission', + isReadOnly: false, + id: 'billing:describe', + dependsOnId: null, + description: 'Member can view the billing information.', + level: PermissionLevel.Organization, + title: 'View billing', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'billing:update', + dependsOnId: 'billing:describe', + description: 'Member can change the organization plan.', + level: PermissionLevel.Organization, + title: 'Update billing', + }, + ], + }, + { + __typename: 'PermissionGroup', + id: 'oidc', + title: 'OpenID Connect', + permissions: [ + { + __typename: 'Permission', + isReadOnly: false, + id: 'oidc:modify', + dependsOnId: null, + description: 'Member can connect, modify, and remove an OIDC provider to the connection.', + level: PermissionLevel.Organization, + title: 'Manage OpenID Connect integration', + }, + ], + }, + { + __typename: 'PermissionGroup', + id: 'github', + title: 'GitHub Integration', + permissions: [ + { + __typename: 'Permission', + isReadOnly: false, + id: 'gitHubIntegration:modify', + dependsOnId: null, + description: + 'Member can connect, modify, and remove access for the GitHub integration and repository access.', + level: PermissionLevel.Organization, + title: 'Manage GitHub integration', + }, + ], + }, + { + __typename: 'PermissionGroup', + id: 'slack', + title: 'Slack Integration', + permissions: [ + { + __typename: 'Permission', + isReadOnly: false, + id: 'slackIntegration:modify', + dependsOnId: null, + description: + 'Member can connect, modify, and remove access for the Slack integration and repository access.', + level: PermissionLevel.Organization, + title: 'Manage Slack integration', + }, + ], + }, + { + __typename: 'PermissionGroup', + id: 'project', + title: 'Project', + permissions: [ + { + __typename: 'Permission', + isReadOnly: false, + id: 'project:create', + dependsOnId: null, + description: 'Member can create new projects.', + level: PermissionLevel.Organization, + title: 'Create project', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'project:describe', + dependsOnId: null, + description: 'Member can access the specified projects.', + level: PermissionLevel.Project, + title: 'View project', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'project:delete', + dependsOnId: 'project:describe', + description: 'Member can access the specified projects.', + level: PermissionLevel.Project, + title: 'Delete project', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'project:modifySettings', + dependsOnId: 'project:describe', + description: 'Member can access the specified projects.', + level: PermissionLevel.Project, + title: 'Modify Settings', + }, + ], + }, + { + __typename: 'PermissionGroup', + id: 'schema-linting', + title: 'Schema Linting', + permissions: [ + { + __typename: 'Permission', + isReadOnly: false, + id: 'schemaLinting:modifyOrganizationRules', + dependsOnId: null, + description: 'Member can view and modify the organization schema linting rules.', + level: PermissionLevel.Organization, + title: 'Manage organization level schema linting', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'schemaLinting:modifyProjectRules', + dependsOnId: 'project:describe', + description: 'Member can view and modify the projects schema linting rules.', + level: PermissionLevel.Project, + title: 'Manage project level schema linting', + }, + ], + }, + { + __typename: 'PermissionGroup', + id: 'target', + title: 'Target', + permissions: [ + { + __typename: 'Permission', + isReadOnly: false, + id: 'target:create', + dependsOnId: 'project:describe', + description: 'Member can create new projects.', + level: PermissionLevel.Project, + title: 'Create target', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'target:delete', + dependsOnId: 'project:describe', + description: 'Member can access the specified projects.', + level: PermissionLevel.Target, + title: 'Delete target', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'target:modifySettings', + dependsOnId: 'project:describe', + description: 'Member can access the specified projects.', + level: PermissionLevel.Target, + title: 'Modify settings', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'alert:modify', + dependsOnId: 'project:describe', + description: 'Can create alerts for schema versions.', + level: PermissionLevel.Project, + title: 'Modify alerts', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'schemaVersion:approve', + dependsOnId: null, + description: 'Can approve schema versions on projects using the legacy registry model.', + level: PermissionLevel.Target, + title: 'Approve schema version (legacy)', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'targetAccessToken:modify', + dependsOnId: 'project:describe', + description: 'Allow managing access tokens for CLI and Usage Reporting.', + level: PermissionLevel.Target, + title: 'Manage registry access tokens', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'cdnAccessToken:modify', + dependsOnId: 'project:describe', + description: 'Allow managing access tokens for the CDN.', + level: PermissionLevel.Target, + title: 'Manage CDN access tokens', + }, + ], + }, + { + __typename: 'PermissionGroup', + id: 'laboratory', + title: 'Laboratory', + permissions: [ + { + __typename: 'Permission', + isReadOnly: false, + id: 'laboratory:describe', + dependsOnId: 'project:describe', + description: 'Member can access the laboratory, view and execute GraphQL documents.', + level: PermissionLevel.Target, + title: 'View laboratory', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'laboratory:modify', + dependsOnId: 'laboratory:describe', + description: + 'Member can create, delete and update collections and documents in the laboratory.', + level: PermissionLevel.Target, + title: 'Modify laboratory', + }, + { + __typename: 'Permission', + isReadOnly: false, + id: 'laboratory:modifyPreflightScript', + dependsOnId: 'laboratory:describe', + description: 'Member can update the laboratory preflight script.', + level: PermissionLevel.Target, + title: 'Modify the laboratory preflight script', + }, + ], + }, + { + __typename: 'PermissionGroup', + id: 'app-deployments', + title: 'App Deployments', + permissions: [ + { + __typename: 'Permission', + isReadOnly: false, + id: 'appDeployment:describe', + dependsOnId: 'project:describe', + description: 'Member can view app deployments.', + level: PermissionLevel.Target, + title: 'View app deployments', + }, + ], + }, + { + __typename: 'PermissionGroup', + id: 'schema-checks', + title: 'Schema Checks', + permissions: [ + { + __typename: 'Permission', + isReadOnly: false, + id: 'schemaCheck:approve', + dependsOnId: 'project:describe', + description: 'Member can approve failed schema checks.', + level: PermissionLevel.Service, + title: 'Approve schema check', + }, + ], + }, +]; From 422f62b5e737c6ca4740ac531e77ef747f66ed26 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 3 Jan 2025 14:34:46 +0100 Subject: [PATCH 10/55] fix hints --- .../members/permission-selector.tsx | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/web/app/src/components/organization/members/permission-selector.tsx b/packages/web/app/src/components/organization/members/permission-selector.tsx index feefe18ec6..959a9112f1 100644 --- a/packages/web/app/src/components/organization/members/permission-selector.tsx +++ b/packages/web/app/src/components/organization/members/permission-selector.tsx @@ -51,13 +51,14 @@ export type PermissionSelectorProps = { export function PermissionSelector(props: PermissionSelectorProps) { const organization = useFragment(PermissionSelector_OrganizationFragment, props.organization); - const [filteredGroups, permissionGroupMapping] = useMemo(() => { + const [groups, permissionToGroupTitleMapping, dependencyGraph] = useMemo(() => { const filteredGroups: Array< MembershipPermissionGroup & { selectedPermissionCount: number; } > = []; - const permissionGroupMapping = new Map(); + const permissionToGroupTitleMapping = new Map(); + const dependencyGraph = new Map>(); for (const group of organization.availableMemberPermissionGroups) { let selectedPermissionCount = 0; @@ -66,6 +67,16 @@ export function PermissionSelector(props: PermissionSelectorProps) { if (props.selectedPermissionIds.has(permission.id)) { selectedPermissionCount++; } + + if (permission.dependsOnId) { + let arr = dependencyGraph.get(permission.dependsOnId); + if (!arr) { + arr = []; + dependencyGraph.set(permission.dependsOnId, arr); + } + arr.push(permission.id); + } + permissionToGroupTitleMapping.set(permission.id, group.title); } filteredGroups.push({ @@ -74,7 +85,7 @@ export function PermissionSelector(props: PermissionSelectorProps) { }); } - return [filteredGroups, permissionGroupMapping] as const; + return [filteredGroups, permissionToGroupTitleMapping, dependencyGraph] as const; }, [organization.availableMemberPermissionGroups]); const permissionRefs = useRef(new Map()); @@ -88,20 +99,7 @@ export function PermissionSelector(props: PermissionSelectorProps) { value={openAccordions} onValueChange={values => setOpenAccordions(values)} > - {filteredGroups.map(group => { - const dependencyGraph = new Map>(); - for (const permission of group.permissions) { - if (!permission.dependsOnId) { - continue; - } - let arr = dependencyGraph.get(permission.dependsOnId); - if (!arr) { - arr = []; - dependencyGraph.set(permission.dependsOnId, arr); - } - arr.push(permission.id); - } - + {groups.map(group => { return ( @@ -139,7 +137,7 @@ export function PermissionSelector(props: PermissionSelectorProps) {
{permission.description}
{!!permission.dependsOnId && - permissionGroupMapping.has(permission.dependsOnId) && ( + permissionToGroupTitleMapping.has(permission.dependsOnId) && (
@@ -164,7 +162,7 @@ export function PermissionSelector(props: PermissionSelectorProps) { } setOpenAccordions(values => { const groupName = - permissionGroupMapping.get(dependencyPermission); + permissionToGroupTitleMapping.get(dependencyPermission); if (groupName && values.includes(groupName) === false) { return [...values, groupName]; From ec66b1790110726dff0f94cc5a131d98663dc089 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 3 Jan 2025 14:34:54 +0100 Subject: [PATCH 11/55] db types brr --- packages/services/storage/src/db/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index deb2a52f97..9102b23e57 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -191,7 +191,8 @@ export interface organization_member_roles { locked: boolean; name: string; organization_id: string; - scopes: Array; + permissions: Array | null; + scopes: Array | null; } export interface organizations { From 0ab8aee0a9dd1f2174d3d1b91d4a16af56b3eb49 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 10 Jan 2025 17:01:50 +0100 Subject: [PATCH 12/55] wip --- codegen.mts | 24 +- ...-00-00.granular-member-role-permissions.ts | 11 + packages/services/api/src/index.ts | 3 +- .../modules/auth/lib/supertokens-strategy.ts | 103 ++-- .../modules/auth/module.graphql.mappers.ts | 9 +- .../api/src/modules/auth/module.graphql.ts | 21 - .../auth/providers/organization-members.ts | 550 ------------------ .../api/src/modules/auth/resolvers/Member.ts | 32 - .../modules/auth/resolvers/Organization.ts | 21 - .../api/src/modules/organization/index.ts | 4 +- .../lib/organization-member-permissions.ts | 45 +- .../organization/module.graphql.mappers.ts | 5 +- .../modules/organization/module.graphql.ts | 34 +- .../providers/organization-manager.ts | 414 +++++-------- .../providers/organization-member-roles.ts | 316 ++++++++++ .../providers/organization-members.ts | 363 ++++++++++++ .../modules/organization/resolvers/Member.ts | 30 +- .../resolvers/MemberConnection.ts | 2 +- .../organization/resolvers/MemberRole.ts | 85 +-- .../resolvers/Mutation/assignMemberRole.ts | 5 +- .../resolvers/Mutation/createMemberRole.ts | 29 +- .../resolvers/Mutation/updateMemberRole.ts | 30 +- .../organization/resolvers/Organization.ts | 40 +- .../resolvers/OrganizationInvitation.ts | 8 + .../src/modules/shared/providers/storage.ts | 30 - .../support/providers/support-manager.ts | 24 +- .../modules/token/providers/token-manager.ts | 34 +- packages/services/api/src/shared/entities.ts | 25 +- packages/services/server/src/index.ts | 7 +- packages/services/storage/src/index.ts | 114 +--- .../src/components/layouts/organization.tsx | 3 - .../components/organization/Permissions.tsx | 201 ------- .../components/organization/members/list.tsx | 71 +-- .../components/organization/members/roles.tsx | 439 ++++++-------- .../members/selected-permission-overview.tsx | 19 +- .../target/settings/registry-access-token.tsx | 2 +- .../web/app/src/lib/access/organization.ts | 56 -- packages/web/app/src/lib/access/project.ts | 59 -- packages/web/app/src/lib/access/target.ts | 24 - .../use-operation-collections-plugin.tsx | 9 - .../app/src/pages/organization-members.tsx | 2 +- packages/web/app/src/pages/project-policy.tsx | 4 - .../web/app/src/pages/project-settings.tsx | 4 - .../web/app/src/pages/target-settings.tsx | 69 +-- packages/web/app/src/pages/target.tsx | 22 +- .../selected-permission-overview.stories.tsx | 4 +- 46 files changed, 1299 insertions(+), 2107 deletions(-) create mode 100644 packages/migrations/src/actions/2025.01.30T00-00-00.granular-member-role-permissions.ts delete mode 100644 packages/services/api/src/modules/auth/providers/organization-members.ts delete mode 100644 packages/services/api/src/modules/auth/resolvers/Member.ts delete mode 100644 packages/services/api/src/modules/auth/resolvers/Organization.ts rename packages/services/api/src/modules/{auth => organization}/lib/organization-member-permissions.ts (87%) create mode 100644 packages/services/api/src/modules/organization/providers/organization-member-roles.ts create mode 100644 packages/services/api/src/modules/organization/providers/organization-members.ts rename packages/services/api/src/modules/{auth => organization}/resolvers/MemberConnection.ts (90%) delete mode 100644 packages/web/app/src/lib/access/organization.ts delete mode 100644 packages/web/app/src/lib/access/project.ts delete mode 100644 packages/web/app/src/lib/access/target.ts diff --git a/codegen.mts b/codegen.mts index ae23bb9ff7..0f0e796ed4 100644 --- a/codegen.mts +++ b/codegen.mts @@ -102,18 +102,18 @@ const config: CodegenConfig = { plugins: ['typescript', 'typescript-operations'], }, // Integration tests - './integration-tests/testkit/gql/': { - documents: ['./integration-tests/(testkit|tests)/**/*.ts'], - preset: 'client', - plugins: [], - config: { - scalars: { - DateTime: 'string', - Date: 'string', - SafeInt: 'number', - }, - }, - }, + // './integration-tests/testkit/gql/': { + // documents: ['./integration-tests/(testkit|tests)/**/*.ts'], + // preset: 'client', + // plugins: [], + // config: { + // scalars: { + // DateTime: 'string', + // Date: 'string', + // SafeInt: 'number', + // }, + // }, + // }, }, }; diff --git a/packages/migrations/src/actions/2025.01.30T00-00-00.granular-member-role-permissions.ts b/packages/migrations/src/actions/2025.01.30T00-00-00.granular-member-role-permissions.ts new file mode 100644 index 0000000000..8e1e01fe8d --- /dev/null +++ b/packages/migrations/src/actions/2025.01.30T00-00-00.granular-member-role-permissions.ts @@ -0,0 +1,11 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2025-01-30T00-00-00.granular-member-role-permissions.ts', + run: ({ sql }) => sql` + ALTER TABLE "organization_member_roles" + ALTER "scopes" DROP NOT NULL + , ADD COLUMN "permissions" text[] + ; + `, +} satisfies MigrationExecutor; diff --git a/packages/services/api/src/index.ts b/packages/services/api/src/index.ts index 591d8b52f7..96e0591a70 100644 --- a/packages/services/api/src/index.ts +++ b/packages/services/api/src/index.ts @@ -37,4 +37,5 @@ export { ProjectAccessScope, TargetAccessScope, } from './__generated__/types'; -export { OrganizationMembers } from './modules/auth/providers/organization-members'; +export { OrganizationMembers } from './modules/organization/providers/organization-members'; +export { OrganizationMemberRoles } from './modules/organization/providers/organization-member-roles'; diff --git a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts index 18afb0cfda..5d624ca28f 100644 --- a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts @@ -5,13 +5,13 @@ import { captureException } from '@sentry/node'; import type { User } from '../../../shared/entities'; import { AccessError, HiveError } from '../../../shared/errors'; import { isUUID } from '../../../shared/is-uuid'; -import { Logger } from '../../shared/providers/logger'; -import type { Storage } from '../../shared/providers/storage'; import { OrganizationMembers, OrganizationMembershipRoleAssignment, ResourceAssignment, -} from '../providers/organization-members'; +} from '../../organization/providers/organization-members'; +import { Logger } from '../../shared/providers/logger'; +import type { Storage } from '../../shared/providers/storage'; import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz'; export class SuperTokensCookieBasedSession extends Session { @@ -72,7 +72,7 @@ export class SuperTokensCookieBasedSession extends Session { } // owner of organization should have full right to do anything. - if (organizationMembership?.isAdmin) { + if (organizationMembership?.isOwner) { this.logger.debug( 'User is organization owner, resolve admin access policy. (userId=%s, organizationId=%s)', user.id, @@ -96,7 +96,7 @@ export class SuperTokensCookieBasedSession extends Session { const policyStatements = this.translateAssignedRolesToAuthorizationPolicyStatements( organizationId, - organizationMembership.assignedRoles, + organizationMembership.assignedRole, ); return policyStatements; @@ -156,65 +156,54 @@ export class SuperTokensCookieBasedSession extends Session { private translateAssignedRolesToAuthorizationPolicyStatements( organizationId: string, - organizationMembershipRoleAssignments: Array, + assignedRole: OrganizationMembershipRoleAssignment, ): Array { const policyStatements: Array = []; - for (const assignedRole of organizationMembershipRoleAssignments) { - if (assignedRole.role.permissions.organization.size) { - policyStatements.push({ - action: Array.from(assignedRole.role.permissions.organization), - effect: 'allow', - resource: this.toResourceIdentifier( - organizationId, - assignedRole.resolvedResources.organization, - ), - }); - } + if (assignedRole.role.permissions.organization.size) { + policyStatements.push({ + action: Array.from(assignedRole.role.permissions.organization), + effect: 'allow', + resource: this.toResourceIdentifier( + organizationId, + assignedRole.resolvedResources.organization, + ), + }); + } - if (assignedRole.role.permissions.project.size) { - policyStatements.push({ - action: Array.from(assignedRole.role.permissions.project), - effect: 'allow', - resource: this.toResourceIdentifier( - organizationId, - assignedRole.resolvedResources.project, - ), - }); - } + if (assignedRole.role.permissions.project.size) { + policyStatements.push({ + action: Array.from(assignedRole.role.permissions.project), + effect: 'allow', + resource: this.toResourceIdentifier(organizationId, assignedRole.resolvedResources.project), + }); + } - if (assignedRole.role.permissions.target.size) { - policyStatements.push({ - action: Array.from(assignedRole.role.permissions.target), - effect: 'allow', - resource: this.toResourceIdentifier( - organizationId, - assignedRole.resolvedResources.target, - ), - }); - } + if (assignedRole.role.permissions.target.size) { + policyStatements.push({ + action: Array.from(assignedRole.role.permissions.target), + effect: 'allow', + resource: this.toResourceIdentifier(organizationId, assignedRole.resolvedResources.target), + }); + } - if (assignedRole.role.permissions.service.size) { - policyStatements.push({ - action: Array.from(assignedRole.role.permissions.service), - effect: 'allow', - resource: this.toResourceIdentifier( - organizationId, - assignedRole.resolvedResources.service, - ), - }); - } + if (assignedRole.role.permissions.service.size) { + policyStatements.push({ + action: Array.from(assignedRole.role.permissions.service), + effect: 'allow', + resource: this.toResourceIdentifier(organizationId, assignedRole.resolvedResources.service), + }); + } - if (assignedRole.role.permissions.appDeployment.size) { - policyStatements.push({ - action: Array.from(assignedRole.role.permissions.appDeployment), - effect: 'allow', - resource: this.toResourceIdentifier( - organizationId, - assignedRole.resolvedResources.appDeployment, - ), - }); - } + if (assignedRole.role.permissions.appDeployment.size) { + policyStatements.push({ + action: Array.from(assignedRole.role.permissions.appDeployment), + effect: 'allow', + resource: this.toResourceIdentifier( + organizationId, + assignedRole.resolvedResources.appDeployment, + ), + }); } return policyStatements; diff --git a/packages/services/api/src/modules/auth/module.graphql.mappers.ts b/packages/services/api/src/modules/auth/module.graphql.mappers.ts index 3ed4eec01e..9374cdb7dc 100644 --- a/packages/services/api/src/modules/auth/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/auth/module.graphql.mappers.ts @@ -1,5 +1,8 @@ -import type { Member, User } from '../../shared/entities'; -import { PermissionGroup, PermissionRecord } from './lib/organization-member-permissions'; +import type { User } from '../../shared/entities'; +import { + PermissionGroup, + PermissionRecord, +} from '../organization/lib/organization-member-permissions'; import type { OrganizationAccessScope } from './providers/organization-access'; import type { ProjectAccessScope } from './providers/project-access'; import type { TargetAccessScope } from './providers/target-access'; @@ -8,8 +11,6 @@ export type OrganizationAccessScopeMapper = OrganizationAccessScope; export type ProjectAccessScopeMapper = ProjectAccessScope; export type TargetAccessScopeMapper = TargetAccessScope; export type UserConnectionMapper = readonly User[]; -export type MemberConnectionMapper = readonly Member[]; -export type MemberMapper = Member; export type UserMapper = User; export type PermissionGroupMapper = PermissionGroup; export type PermissionMapper = PermissionRecord; diff --git a/packages/services/api/src/modules/auth/module.graphql.ts b/packages/services/api/src/modules/auth/module.graphql.ts index 6ad050a05d..e6f069e5aa 100644 --- a/packages/services/api/src/modules/auth/module.graphql.ts +++ b/packages/services/api/src/modules/auth/module.graphql.ts @@ -57,20 +57,6 @@ export default gql` total: Int! } - type Member { - id: ID! - user: User! - isOwner: Boolean! - organizationAccessScopes: [OrganizationAccessScope!]! - projectAccessScopes: [ProjectAccessScope!]! - targetAccessScopes: [TargetAccessScope!]! - } - - type MemberConnection { - nodes: [Member!]! - total: Int! - } - enum AuthProvider { GOOGLE GITHUB @@ -133,11 +119,4 @@ export default gql` title: String! permissions: [Permission!]! } - - extend type Organization { - """ - List of available permission groups that can be assigned to users. - """ - availableMemberPermissionGroups: [PermissionGroup!]! - } `; diff --git a/packages/services/api/src/modules/auth/providers/organization-members.ts b/packages/services/api/src/modules/auth/providers/organization-members.ts deleted file mode 100644 index c981f4c0a0..0000000000 --- a/packages/services/api/src/modules/auth/providers/organization-members.ts +++ /dev/null @@ -1,550 +0,0 @@ -import { Inject, Injectable, Scope } from 'graphql-modules'; -import { sql, type DatabasePool } from 'slonik'; -import { z } from 'zod'; -import { Organization } from '../../../shared/entities'; -import { batchBy } from '../../../shared/helpers'; -import { isUUID } from '../../../shared/is-uuid'; -import { Logger } from '../../shared/providers/logger'; -import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; -import { - PermissionsModel, - PermissionsPerResourceLevelAssignment, - PermissionsPerResourceLevelAssignmentModel, - permissionsToPermissionsPerResourceLevelAssignment, -} from '../lib/authz'; -import { OrganizationAccessScope, ProjectAccessScope, TargetAccessScope } from './scopes'; - -const RawOrganizationMembershipModel = z.object({ - userId: z.string(), - /** Legacy scopes on membership, way of assigning permissions before the introduction of roles */ - legacyScopes: z - .array(z.string()) - .transform( - value => value as Array, - ) - .nullable(), - /** Legacy role id, way of assigning permissions via a role before the introduction of assigning multiple roles */ - legacyRoleId: z.string().nullable(), -}); - -const RawMemberRoleModel = z.intersection( - z.object({ - id: z.string(), - description: z.string(), - isLocked: z.boolean(), - }), - z.union([ - z.object({ - legacyScopes: z - .array(z.string()) - .transform( - value => value as Array, - ), - permissions: z.null(), - }), - z.object({ - legacyScopes: z.null(), - permissions: z - .array(PermissionsModel) - .transform(permissions => permissionsToPermissionsPerResourceLevelAssignment(permissions)), - }), - ]), -); - -const UUIDResourceAssignmentModel = z.union([z.literal('*'), z.array(z.string().uuid())]); - -/** - * String in the form `targetId/serviceName` - * Example: `f81ce726-2abf-4653-bf4c-d8436cde255a/users` - */ -const ServiceResourceAssignmentStringModel = z - .string() - .refine(value => { - const [targetId, serviceName = ''] = value.split('/'); - if (isUUID(targetId) === false || serviceName === '') { - return false; - } - return true; - }, 'Invalid service resource assignment') - .transform(value => value.split('/') as [targetId: string, serviceName: string]); - -const ServiceResourceAssignmentModel = z.union([ - z.literal('*'), - z.array(ServiceResourceAssignmentStringModel), -]); - -const ResourceAssignmentGroupModel = z.object({ - /** Resources assigned to a 'projects' permission group */ - project: UUIDResourceAssignmentModel, - /** Resources assigned to a 'targets' permission group */ - target: UUIDResourceAssignmentModel, - /** Resources assigned to a 'service' permission group */ - service: ServiceResourceAssignmentModel, - /** Resources assigned to a 'appDeployment' permission group */ - appDeployment: ServiceResourceAssignmentModel, -}); - -/** - * Resource assignments as stored within the database. - */ -type ResourceAssignmentGroup = z.TypeOf; - -type MemberRoleType = { - id: string; - description: string; - isLocked: boolean; - permissions: PermissionsPerResourceLevelAssignment; -}; - -export type OrganizationMembershipRoleAssignment = { - role: MemberRoleType; - /** - * Resource assignments as stored within the database. - */ - resources: ResourceAssignmentGroup; - /** - * Resolved resource groups, used for runtime permission checks. - */ - resolvedResources: ResolvedResourceAssignments; -}; - -type OrganizationMembership = { - organizationId: string; - isAdmin: boolean; - userId: string; - assignedRoles: Array; - /** - * legacy role assigned to this membership. - * Note: The role is already resolved to a "OrganizationMembershipRoleAssignment" within the assignedRoles property. - **/ - legacyRoleId: string | null; - /** - * Legacy scope assigned to this membership. - * Note: They are already resolved to a "OrganizationMembershipRoleAssignment" within the assignedRoles property. - **/ - legacyScopes: Array | null; -}; - -@Injectable({ - scope: Scope.Operation, -}) -export class OrganizationMembers { - private logger: Logger; - - constructor( - @Inject(PG_POOL_CONFIG) private pool: DatabasePool, - logger: Logger, - ) { - this.logger = logger.child({ - source: 'OrganizationMembers', - }); - } - - private async findOrganizationMembersById(organizationId: string, userIds: Array) { - const query = sql` - SELECT - "om"."user_id" AS "userId" - , "om"."role_id" AS "legacyRoleId" - , "om"."scopes" AS "legacyScopes" - FROM - "organization_member" AS "om" - WHERE - "om"."organization_id" = ${organizationId} - AND "om"."user_id" = ANY(${sql.array(userIds, 'uuid')}) - `; - - const result = await this.pool.any(query); - return result.map(row => RawOrganizationMembershipModel.parse(row)); - } - - /** Find member roles by their ID */ - private async findMemberRolesByIds(roleIds: Array) { - this.logger.debug('Find organization membership roles. (roleIds=%o)', roleIds); - - const query = sql` - SELECT - "id" - , "name" - , "description" - , "locked" AS "isLocked" - , "scopes" AS "legacyScopes" - , "permissions" - FROM - "organization_member_roles" - WHERE - "id" = ANY(${sql.array(roleIds, 'uuid')}) - `; - - const result = await this.pool.any(query); - - const rowsById = new Map(); - - for (const row of result) { - const record = RawMemberRoleModel.parse(row); - - rowsById.set(record.id, { - id: record.id, - isLocked: record.isLocked, - description: record.description, - permissions: - record.permissions ?? - transformOrganizationMemberLegacyScopesIntoPermissionGroup(record.legacyScopes), - }); - } - return rowsById; - } - - /** - * Batched loader function for a organization membership. - * - * Handles legacy scopes and role assignments and automatically transforms - * them into resource based role assignments. - */ - findOrganizationMembership = batchBy( - (args: { organization: Organization; userId: string }) => args.organization.id, - async args => { - const organization = args[0].organization; - const userIds = args.map(arg => arg.userId); - - this.logger.debug( - 'Find organization membership for users. (organizationId=%s, userIds=%o)', - organization.id, - userIds, - ); - - const organizationMembers = await this.findOrganizationMembersById(organization.id, userIds); - const mapping = new Map(); - - // Roles that are assigned using the legacy "single role" way - const pendingLegacyRoleLookups = new Set(); - const pendingLegacyRoleMembershipAssignments: Array<{ - legacyRoleId: string; - assignedRoles: OrganizationMembership['assignedRoles']; - }> = []; - - // Users whose role assignments need to be loaded as they are not using any legacy roles - // const pendingRoleRoleAssignmentLookupUsersIds = new Set(); - - for (const record of organizationMembers) { - const organizationMembership: OrganizationMembership = { - organizationId: organization.id, - userId: record.userId, - isAdmin: organization.ownerId === record.userId, - assignedRoles: [], - legacyRoleId: record.legacyRoleId, - legacyScopes: record.legacyScopes, - }; - mapping.set(record.userId, organizationMembership); - - if (record.legacyRoleId) { - // legacy "single assigned role" - pendingLegacyRoleLookups.add(record.legacyRoleId); - pendingLegacyRoleMembershipAssignments.push({ - legacyRoleId: record.legacyRoleId, - assignedRoles: organizationMembership.assignedRoles, - }); - } else if (record.legacyScopes !== null) { - // legacy "scopes" on organization member -> migration wizard has not been used - - // In this case we translate the legacy scopes to a single permission group on the "organization" - // resource typ. Then assign the users organization to the group, so it has the same behavior as previously. - const resources: ResourceAssignmentGroup = { - project: '*', - target: '*', - service: '*', - appDeployment: '*', - }; - - organizationMembership.assignedRoles.push({ - role: { - id: 'legacy-scope-role', - description: 'This role has been automatically generated from the assigned scopes.', - isLocked: true, - permissions: transformOrganizationMemberLegacyScopesIntoPermissionGroup( - record.legacyScopes, - ), - }, - // allow all permissions for all resources within the organization. - resources, - resolvedResources: resolveResourceAssignment({ - organizationId: organization.id, - groups: resources, - }), - }); - } - // else { - // // normal role assignment lookup - // pendingRoleRoleAssignmentLookupUsersIds.add(organizationMembership); - // } - } - - if (pendingLegacyRoleLookups.size) { - // This handles the legacy "single" role assignments - // We load the roles and then attach them to the already loaded membership role - const roleIds = Array.from(pendingLegacyRoleLookups); - - this.logger.debug('Lookup legacy role assignments. (roleIds=%o)', roleIds); - - const memberRolesById = await this.findMemberRolesByIds(roleIds); - - for (const record of pendingLegacyRoleMembershipAssignments) { - const membershipRole = memberRolesById.get(record.legacyRoleId); - if (!membershipRole) { - continue; - } - const resources: ResourceAssignmentGroup = { - project: '*', - target: '*', - service: '*', - appDeployment: '*', - }; - record.assignedRoles.push({ - resources, - resolvedResources: resolveResourceAssignment({ - organizationId: organization.id, - groups: resources, - }), - role: membershipRole, - }); - } - } - - // if (pendingRoleRoleAssignmentLookupUsersIds.size) { - // const usersIds = Array.from(pendingRoleRoleAssignmentLookupUsersIds).map( - // membership => membership.userId, - // ); - // this.logger.debug( - // 'Lookup role assignments within organization for users. (organizationId=%s, userIds=%o)', - // organization.id, - // usersIds, - // ); - - // const roleAssignments = await this.findRoleAssignmentsForUsersInOrganization( - // organization.id, - // usersIds, - // ); - - // for (const membership of pendingRoleRoleAssignmentLookupUsersIds) { - // membership.assignedRoles.push(...(roleAssignments.get(membership.userId) ?? [])); - // } - // } - - return userIds.map(async userId => mapping.get(userId) ?? null); - }, - ); -} - -function transformOrganizationMemberLegacyScopesIntoPermissionGroup( - scopes: Array, -): z.TypeOf { - const permissions: z.TypeOf = { - organization: new Set(), - project: new Set(), - target: new Set(), - service: new Set(), - appDeployment: new Set(), - }; - for (const scope of scopes) { - switch (scope) { - case OrganizationAccessScope.READ: { - permissions.organization.add('organization:describe'); - permissions.organization.add('support:manageTickets'); - permissions.organization.add('project:create'); - permissions.project.add('project:describe'); - break; - } - case OrganizationAccessScope.SETTINGS: { - permissions.organization.add('organization:modifySlug'); - permissions.organization.add('schemaLinting:modifyOrganizationRules'); - permissions.organization.add('billing:describe'); - permissions.organization.add('billing:update'); - permissions.organization.add('auditLog:export'); - break; - } - case OrganizationAccessScope.DELETE: { - permissions.organization.add('organization:delete'); - break; - } - case OrganizationAccessScope.INTEGRATIONS: { - permissions.organization.add('oidc:modify'); - permissions.organization.add('gitHubIntegration:modify'); - permissions.organization.add('slackIntegration:modify'); - - break; - } - case OrganizationAccessScope.MEMBERS: { - permissions.organization.add('member:manageInvites'); - permissions.organization.add('member:removeMember'); - permissions.organization.add('member:assignRole'); - permissions.organization.add('member:modifyRole'); - permissions.organization.add('member:describe'); - break; - } - case ProjectAccessScope.ALERTS: { - permissions.project.add('alert:modify'); - break; - } - case ProjectAccessScope.READ: { - permissions.project.add('project:describe'); - break; - } - case ProjectAccessScope.DELETE: { - permissions.project.add('project:delete'); - break; - } - case ProjectAccessScope.SETTINGS: { - permissions.project.add('project:delete'); - permissions.project.add('project:modifySettings'); - permissions.project.add('schemaLinting:modifyProjectRules'); - break; - } - case TargetAccessScope.READ: { - permissions.project.add('target:create'); - permissions.target.add('appDeployment:describe'); - permissions.target.add('laboratory:describe'); - break; - } - case TargetAccessScope.REGISTRY_WRITE: { - permissions.target.add('laboratory:modify'); - permissions.service.add('schemaCheck:approve'); - break; - } - case TargetAccessScope.TOKENS_WRITE: { - permissions.target.add('targetAccessToken:modify'); - permissions.target.add('cdnAccessToken:modify'); - break; - } - case TargetAccessScope.SETTINGS: { - permissions.target.add('target:modifySettings'); - permissions.target.add('laboratory:modifyPreflightScript'); - break; - } - case TargetAccessScope.DELETE: { - permissions.target.add('target:delete'); - break; - } - } - } - - return permissions; -} - -type OrganizationAssignment = { - type: 'organization'; - organizationId: string; -}; - -type ProjectAssignment = { - type: 'project'; - projectId: string; -}; - -type TargetAssignment = { - type: 'target'; - targetId: string; -}; - -type ServiceAssignment = { - type: 'service'; - targetId: string; - serviceName: string; -}; - -type AppDeploymentAssignment = { - type: 'appDeployment'; - targetId: string; - appDeploymentName: string; -}; - -export type ResourceAssignment = - | OrganizationAssignment - | ProjectAssignment - | TargetAssignment - | ServiceAssignment - | AppDeploymentAssignment; - -type ResolvedResourceAssignments = { - organization: OrganizationAssignment; - project: OrganizationAssignment | Array; - target: OrganizationAssignment | Array | Array; - service: - | OrganizationAssignment - | Array - | Array - | Array; - appDeployment: - | OrganizationAssignment - | Array - | Array - | Array; -}; - -/** - * This function resolves the "stored-in-database", user configuration to the actual resolved structure - * Currently, we have the following hierarchy - * - * organization - * v - * project - * v - * target - * v v - * app deployment service - * - * If one level specifies "*", it needs to inherit the resources defined on the next upper level. - */ -function resolveResourceAssignment(args: { - organizationId: string; - groups: ResourceAssignmentGroup; -}): ResolvedResourceAssignments { - const organization: OrganizationAssignment = { - type: 'organization', - organizationId: args.organizationId, - }; - - let project: ResolvedResourceAssignments['project'] = organization; - - if (args.groups.project !== '*') { - project = args.groups.project.map(projectId => ({ - type: 'project', - projectId, - })); - } - - let target: ResolvedResourceAssignments['target'] = project; - - if (args.groups.target !== '*') { - target = args.groups.target.map(targetId => ({ - type: 'target', - targetId, - })); - } - - let service: ResolvedResourceAssignments['service'] = target; - - if (args.groups.service !== '*') { - service = args.groups.service.map(([targetId, serviceName]) => ({ - type: 'service', - targetId, - serviceName, - })); - } - - let appDeployment: ResolvedResourceAssignments['appDeployment'] = target; - - if (args.groups.service !== '*') { - appDeployment = args.groups.service.map(([targetId, appDeploymentName]) => ({ - type: 'appDeployment', - targetId, - appDeploymentName, - })); - } - - return { - organization, - project, - target, - service, - appDeployment, - }; -} diff --git a/packages/services/api/src/modules/auth/resolvers/Member.ts b/packages/services/api/src/modules/auth/resolvers/Member.ts deleted file mode 100644 index 5d0b4920be..0000000000 --- a/packages/services/api/src/modules/auth/resolvers/Member.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { AuthManager } from '../providers/auth-manager'; -import type { MemberResolvers } from './../../../__generated__/types'; - -export const Member: Pick< - MemberResolvers, - | 'id' - | 'isOwner' - | 'organizationAccessScopes' - | 'projectAccessScopes' - | 'targetAccessScopes' - | 'user' - | '__isTypeOf' -> = { - organizationAccessScopes: (member, _, { injector }) => { - return injector.get(AuthManager).getMemberOrganizationScopes({ - userId: member.user.id, - organizationId: member.organization, - }); - }, - projectAccessScopes: (member, _, { injector }) => { - return injector.get(AuthManager).getMemberProjectScopes({ - userId: member.user.id, - organizationId: member.organization, - }); - }, - targetAccessScopes: (member, _, { injector }) => { - return injector.get(AuthManager).getMemberTargetScopes({ - userId: member.user.id, - organizationId: member.organization, - }); - }, -}; diff --git a/packages/services/api/src/modules/auth/resolvers/Organization.ts b/packages/services/api/src/modules/auth/resolvers/Organization.ts deleted file mode 100644 index af78014f17..0000000000 --- a/packages/services/api/src/modules/auth/resolvers/Organization.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { allPermissionGroups } from '../lib/organization-member-permissions'; -import type { OrganizationResolvers } from './../../../__generated__/types'; - -/* - * Note: This object type is generated because "OrganizationMapper" is declared. This is to ensure runtime safety. - * - * When a mapper is used, it is possible to hit runtime errors in some scenarios: - * - given a field name, the schema type's field type does not match mapper's field type - * - or a schema type's field does not exist in the mapper's fields - * - * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. - */ -export const Organization: Pick< - OrganizationResolvers, - 'availableMemberPermissionGroups' | '__isTypeOf' -> = { - /* Implement Organization resolver logic here */ - availableMemberPermissionGroups: async (_parent, _arg, _ctx) => { - return allPermissionGroups; - }, -}; diff --git a/packages/services/api/src/modules/organization/index.ts b/packages/services/api/src/modules/organization/index.ts index 0c5936e9ea..37db80f104 100644 --- a/packages/services/api/src/modules/organization/index.ts +++ b/packages/services/api/src/modules/organization/index.ts @@ -1,5 +1,7 @@ import { createModule } from 'graphql-modules'; import { OrganizationManager } from './providers/organization-manager'; +import { OrganizationMemberRoles } from './providers/organization-member-roles'; +import { OrganizationMembers } from './providers/organization-members'; import { resolvers } from './resolvers.generated'; import typeDefs from './module.graphql'; @@ -8,5 +10,5 @@ export const organizationModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [OrganizationManager], + providers: [OrganizationManager, OrganizationMemberRoles, OrganizationMembers], }); diff --git a/packages/services/api/src/modules/auth/lib/organization-member-permissions.ts b/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts similarity index 87% rename from packages/services/api/src/modules/auth/lib/organization-member-permissions.ts rename to packages/services/api/src/modules/organization/lib/organization-member-permissions.ts index 101d781cca..b7caf4b599 100644 --- a/packages/services/api/src/modules/auth/lib/organization-member-permissions.ts +++ b/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts @@ -1,4 +1,4 @@ -import { allPermissions, Permission } from './authz'; +import { allPermissions, Permission } from '../../auth/lib/authz'; export type PermissionRecord = { id: Permission; @@ -278,19 +278,22 @@ export const allPermissionGroups: Array = [ ] as const; function assertAllRulesAreAssigned(excluded: Array) { - const p = new Set(allPermissions); + const permissionsToCheck = new Set(allPermissions); + for (const item of excluded) { - p.delete(item); + permissionsToCheck.delete(item); } for (const group of allPermissionGroups) { - for (const per of group.permissions) { - p.delete(per.id); + for (const permission of group.permissions) { + permissionsToCheck.delete(permission.id); } } - if (p.size) { - throw new Error('The following permissions are not assigned: \n' + Array.from(p).join(`\n`)); + if (permissionsToCheck.size) { + throw new Error( + 'The following permissions are not assigned: \n' + Array.from(permissionsToCheck).join(`\n`), + ); } } @@ -309,3 +312,31 @@ assertAllRulesAreAssigned([ 'appDeployment:publish', 'appDeployment:retire', ]); + +/** + * List of permissions that are assignable + */ +export const permissions = (() => { + const assignable = new Set(); + const readOnly = new Set(); + for (const group of allPermissionGroups) { + for (const permission of group.permissions) { + if (permission.isReadyOnly === true) { + readOnly.add(permission.id); + continue; + } + assignable.add(permission.id); + } + } + + return { + /** + * List of permissions that are assignable by the user (these should be stored in the database) + */ + assignable: assignable as ReadonlySet, + /** + * List of permissions that are assigned by default (these do not need to be stored in the database) + */ + default: readOnly as ReadonlySet, + }; +})(); diff --git a/packages/services/api/src/modules/organization/module.graphql.mappers.ts b/packages/services/api/src/modules/organization/module.graphql.mappers.ts index 9c5f17c0f0..4701b0b37a 100644 --- a/packages/services/api/src/modules/organization/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/organization/module.graphql.mappers.ts @@ -2,11 +2,14 @@ import type { Organization, OrganizationGetStarted, OrganizationInvitation, - OrganizationMemberRole, } from '../../shared/entities'; +import { OrganizationMemberRole } from './providers/organization-member-roles'; +import { OrganizationMembership } from './providers/organization-members'; export type OrganizationConnectionMapper = readonly Organization[]; export type OrganizationMapper = Organization; export type MemberRoleMapper = OrganizationMemberRole; export type OrganizationGetStartedMapper = OrganizationGetStarted; export type OrganizationInvitationMapper = OrganizationInvitation; +export type MemberConnectionMapper = readonly OrganizationMembership[]; +export type MemberMapper = OrganizationMembership; diff --git a/packages/services/api/src/modules/organization/module.graphql.ts b/packages/services/api/src/modules/organization/module.graphql.ts index 429f1356c4..6025930e28 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -240,6 +240,10 @@ export default gql` The organization's audit logs. This field is only available to members with the Admin role. """ viewerCanExportAuditLogs: Boolean! + """ + List of available permission groups that can be assigned to users. + """ + availableMemberPermissionGroups: [PermissionGroup!]! } type OrganizationConnection { @@ -303,7 +307,6 @@ export default gql` extend type Member { canLeaveOrganization: Boolean! role: MemberRole! - isAdmin: Boolean! """ Whether the viewer can remove this member from the organization. """ @@ -318,9 +321,6 @@ export default gql` Whether the role is a built-in role. Built-in roles cannot be deleted or modified. """ locked: Boolean! - organizationAccessScopes: [OrganizationAccessScope!]! - projectAccessScopes: [ProjectAccessScope!]! - targetAccessScopes: [TargetAccessScope!]! """ Whether the role can be deleted (based on current user's permissions) """ @@ -333,16 +333,21 @@ export default gql` Whether the role can be used to invite new members (based on current user's permissions) """ canInvite: Boolean! + """ + Amount of users within the organization that have this role assigned. + """ membersCount: Int! + """ + List of permissions attached to this member role. + """ + permissions: [String!]! } input CreateMemberRoleInput { organizationSlug: String! name: String! description: String! - organizationAccessScopes: [OrganizationAccessScope!]! - projectAccessScopes: [ProjectAccessScope!]! - targetAccessScopes: [TargetAccessScope!]! + selectedPermissions: [String!]! } type CreateMemberRoleOk { @@ -375,9 +380,7 @@ export default gql` roleId: ID! name: String! description: String! - organizationAccessScopes: [OrganizationAccessScope!]! - projectAccessScopes: [ProjectAccessScope!]! - targetAccessScopes: [TargetAccessScope!]! + selectedPermissions: [String!]! } type UpdateMemberRoleOk { @@ -448,4 +451,15 @@ export default gql` ok: AssignMemberRoleOk error: AssignMemberRoleError } + + type Member { + id: ID! + user: User! + isOwner: Boolean! + } + + type MemberConnection { + nodes: [Member!]! + total: Int! + } `; diff --git a/packages/services/api/src/modules/organization/providers/organization-manager.ts b/packages/services/api/src/modules/organization/providers/organization-manager.ts index e4fa779aa0..35b6ba210f 100644 --- a/packages/services/api/src/modules/organization/providers/organization-manager.ts +++ b/packages/services/api/src/modules/organization/providers/organization-manager.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { Inject, Injectable, Scope } from 'graphql-modules'; -import { Organization, OrganizationMemberRole } from '../../../shared/entities'; +import { Organization } from '../../../shared/entities'; import { HiveError } from '../../../shared/errors'; import { cache, share } from '../../../shared/helpers'; import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; @@ -18,34 +18,14 @@ import type { OrganizationSelector } from '../../shared/providers/storage'; import { Storage } from '../../shared/providers/storage'; import { WEB_APP_URL } from '../../shared/providers/tokens'; import { TokenStorage } from '../../token/providers/token-storage'; +import { createOrUpdateMemberRoleInputSchema } from '../validation'; import { organizationAdminScopes, organizationViewerScopes, reservedOrganizationSlugs, } from './organization-config'; - -function ensureReadAccess( - scopes: readonly (OrganizationAccessScope | ProjectAccessScope | TargetAccessScope)[], -) { - const newScopes: (OrganizationAccessScope | ProjectAccessScope | TargetAccessScope)[] = [ - ...scopes, - ]; - - if (!scopes.includes(OrganizationAccessScope.READ)) { - newScopes.push(OrganizationAccessScope.READ); - } - - if (!scopes.includes(ProjectAccessScope.READ)) { - newScopes.push(ProjectAccessScope.READ); - } - - if (!scopes.includes(TargetAccessScope.READ)) { - newScopes.push(TargetAccessScope.READ); - } - - // Remove duplicates - return newScopes.filter((scope, i, all) => all.indexOf(scope) === i); -} +import { OrganizationMemberRole, OrganizationMemberRoles } from './organization-member-roles'; +import { OrganizationMembers } from './organization-members'; /** * Responsible for auth checks. @@ -68,6 +48,8 @@ export class OrganizationManager { private billingProvider: BillingProvider, private oidcIntegrationProvider: OIDCIntegrationsProvider, private emails: Emails, + private organizationMemberRoles: OrganizationMemberRoles, + private organizationMembers: OrganizationMembers, @Inject(WEB_APP_URL) private appBaseUrl: string, private idTranslator: IdTranslator, ) { @@ -271,7 +253,13 @@ export class OrganizationManager { } async getOrganizationMember(selector: OrganizationSelector & { userId: string }) { - const member = await this.storage.getOrganizationMember(selector); + const organization = await this.storage.getOrganization({ + organizationId: selector.organizationId, + }); + const member = await this.organizationMembers.findOrganizationMembership({ + organization, + userId: selector.userId, + }); if (!member) { throw new HiveError('Member not found'); @@ -549,21 +537,16 @@ export class OrganizationManager { } const role = input.role - ? await this.storage.getOrganizationMemberRole({ - organizationId: organization.id, - roleId: input.role, - }) - : await this.storage.getViewerOrganizationMemberRole({ - organizationId: organization.id, - }); + ? await this.organizationMemberRoles.findMemberRoleById(input.role) + : await this.organizationMemberRoles.findViewerRoleByOrganizationId(input.organization); + if (!role) { throw new HiveError(`Role not found`); } // Ensure user has access to all scopes in the role - const currentUserMissingScopes = role.scopes.filter( - scope => !currentUserAccessScopes.includes(scope), - ); + const currentUserMissingScopes = + role.legacyScopes?.filter(scope => !currentUserAccessScopes.includes(scope)) ?? []; if (currentUserMissingScopes.length > 0) { this.logger.debug(`Logged user scopes: %s`, currentUserAccessScopes.join(',')); @@ -909,57 +892,48 @@ export class OrganizationManager { } async createMemberRole(input: { - organizationId: string; + organizationSlug: string; name: string; description: string; - organizationAccessScopes: readonly OrganizationAccessScope[]; - projectAccessScopes: readonly ProjectAccessScope[]; - targetAccessScopes: readonly TargetAccessScope[]; + permissions: ReadonlyArray; }) { + const organizationId = await this.idTranslator.translateOrganizationId(input); + await this.session.assertPerformAction({ action: 'member:modifyRole', - organizationId: input.organizationId, + organizationId, params: { - organizationId: input.organizationId, + organizationId, }, }); - const scopes = ensureReadAccess([ - ...input.organizationAccessScopes, - ...input.projectAccessScopes, - ...input.targetAccessScopes, - ]); - - const currentUser = await this.session.getViewer(); - const currentUserAsMember = await this.getOrganizationMember({ - organizationId: input.organizationId, - userId: currentUser.id, + const inputValidation = createOrUpdateMemberRoleInputSchema.safeParse({ + name: input.name, + description: input.description, + // TODO: validate permissions }); - // Ensure user has access to all scopes in the role - const currentMemberMissingScopes = scopes.filter( - scope => !currentUserAsMember.scopes.includes(scope), - ); - - if (currentMemberMissingScopes.length > 0) { - this.logger.debug(`Logged user scopes: %s`, currentUserAsMember.scopes.join(', ')); - this.logger.debug(`Missing scopes: %s`, currentMemberMissingScopes.join(', ')); + if (!inputValidation.success) { return { error: { - message: `Missing access to some of the selected scopes`, + message: 'Please check your input.', + inputErrors: { + name: inputValidation.error.formErrors.fieldErrors.name?.[0], + description: inputValidation.error.formErrors.fieldErrors.description?.[0], + }, }, }; } const roleName = input.name.trim(); - const nameExists = await this.storage.hasOrganizationMemberRoleName({ - organizationId: input.organizationId, + const foundRole = await this.organizationMemberRoles.findRoleByOrganizationIdAndName( + organizationId, roleName, - }); + ); // Ensure name is unique in the organization - if (nameExists) { + if (foundRole) { const msg = 'Role name already exists. Please choose a different name.'; return { @@ -972,16 +946,16 @@ export class OrganizationManager { }; } - const role = await this.storage.createOrganizationMemberRole({ - organizationId: input.organizationId, + const role = await this.organizationMemberRoles.createOrganizationMemberRole({ + organizationId, name: roleName, description: input.description, - scopes, + permissions: input.permissions, }); await this.auditLog.record({ eventType: 'ROLE_CREATED', - organizationId: input.organizationId, + organizationId, metadata: { roleId: role.id, roleName: role.name, @@ -991,7 +965,7 @@ export class OrganizationManager { return { ok: { updatedOrganization: await this.storage.getOrganization({ - organizationId: input.organizationId, + organizationId, }), createdRole: role, }, @@ -1007,10 +981,7 @@ export class OrganizationManager { }, }); - const role = await this.storage.getOrganizationMemberRole({ - organizationId: input.organizationId, - roleId: input.roleId, - }); + const role = await this.organizationMemberRoles.findMemberRoleById(input.roleId); if (!role) { return { @@ -1020,13 +991,7 @@ export class OrganizationManager { }; } - const currentUser = await this.session.getViewer(); - const currentUserAsMember = await this.getOrganizationMember({ - organizationId: input.organizationId, - userId: currentUser.id, - }); - - const accessCheckResult = await this.canDeleteRole(role, currentUserAsMember.scopes); + const accessCheckResult = await this.canDeleteRole(role); if (!accessCheckResult.ok) { return { @@ -1060,36 +1025,32 @@ export class OrganizationManager { }; } - async assignMemberRole(input: { organizationId: string; userId: string; roleId: string }) { + async assignMemberRole(input: { organizationSlug: string; userId: string; roleId: string }) { + const organizationId = await this.idTranslator.translateOrganizationId(input); + await this.session.assertPerformAction({ action: 'member:assignRole', - organizationId: input.organizationId, + organizationId, params: { - organizationId: input.organizationId, + organizationId, }, }); + const organization = await this.storage.getOrganization({ + organizationId, + }); + // Ensure selected member is part of the organization - const member = await this.storage.getOrganizationMember({ - organizationId: input.organizationId, + const previousMembership = await this.organizationMembers.findOrganizationMembership({ + organization, userId: input.userId, }); - if (!member) { + if (!previousMembership) { throw new Error(`Member is not part of the organization`); } - const currentUser = await this.session.getViewer(); - const [currentUserAsMember, newRole] = await Promise.all([ - this.getOrganizationMember({ - organizationId: input.organizationId, - userId: currentUser.id, - }), - this.storage.getOrganizationMemberRole({ - organizationId: input.organizationId, - roleId: input.roleId, - }), - ]); + const newRole = await this.organizationMemberRoles.findMemberRoleById(input.roleId); if (!newRole) { return { @@ -1099,58 +1060,9 @@ export class OrganizationManager { }; } - // Ensure user has access to all scopes in the new role - const currentUserMissingScopesInNewRole = newRole.scopes.filter( - scope => !currentUserAsMember.scopes.includes(scope), - ); - if (currentUserMissingScopesInNewRole.length > 0) { - this.logger.debug(`Logged user scopes: %s`, currentUserAsMember.scopes.join(', ')); - this.logger.debug(`No access to scopes: %s`, currentUserMissingScopesInNewRole.join(', ')); - - return { - error: { - message: `Missing access to some of the scopes of the new role`, - }, - }; - } - - // Ensure user has access to all scopes in the old role - const currentUserMissingScopesInOldRole = member.scopes.filter( - scope => !currentUserAsMember.scopes.includes(scope), - ); - - if (currentUserMissingScopesInOldRole.length > 0) { - this.logger.debug(`Logged user scopes: %s`, currentUserAsMember.scopes.join(', ')); - this.logger.debug(`No access to scopes: %s`, currentUserMissingScopesInOldRole.join(', ')); - - return { - error: { - message: `Missing access to some of the scopes of the existing role`, - }, - }; - } - - const memberMissingScopesInNewRole = member.scopes.filter( - scope => !newRole.scopes.includes(scope), - ); - - // Ensure new role has at least the same access scopes as the old role, to avoid downgrading members - if (memberMissingScopesInNewRole.length > 0) { - // Admin role is an exception, admin can downgrade members - if (!this.isAdminRole(currentUserAsMember.role)) { - this.logger.debug(`New role scopes: %s`, newRole.scopes.join(', ')); - this.logger.debug(`Old role scopes: %s`, member.scopes.join(', ')); - return { - error: { - message: `Cannot downgrade member to a role with less access scopes`, - }, - }; - } - } - // Assign the role to the member await this.storage.assignOrganizationMemberRole({ - organizationId: input.organizationId, + organizationId, userId: input.userId, roleId: input.roleId, }); @@ -1159,22 +1071,35 @@ export class OrganizationManager { this.authManager.resetAccessCache(); this.session.reset(); + const previousMemberRole = previousMembership.assignedRole.role ?? null; + const updatedMembership = await this.organizationMembers.findOrganizationMembership({ + organization, + userId: input.userId, + }); + + if (!updatedMembership) { + throw new Error('Somethign went wrong.'); + } + const result = { - updatedMember: await this.getOrganizationMember({ - organizationId: input.organizationId, - userId: input.userId, - }), - previousMemberRole: member.role, + updatedMember: updatedMembership, + previousMemberRole, }; + const user = await this.storage.getUserById({ id: previousMembership.userId }); + + if (!user) { + throw new Error('User not found.'); + } + if (result) { await this.auditLog.record({ eventType: 'ROLE_ASSIGNED', - organizationId: input.organizationId, + organizationId, metadata: { - previousMemberRole: member.role ? member.role.name : null, + previousMemberRole: previousMemberRole ? previousMemberRole.name : null, roleId: newRole.id, - updatedMember: member.user.email, + updatedMember: user.email, userIdAssigned: input.userId, }, }); @@ -1186,66 +1111,56 @@ export class OrganizationManager { } async updateMemberRole(input: { - organizationId: string; + organizationSlug: string; roleId: string; name: string; description: string; - organizationAccessScopes: readonly OrganizationAccessScope[]; - projectAccessScopes: readonly ProjectAccessScope[]; - targetAccessScopes: readonly TargetAccessScope[]; + permissions: readonly string[]; }) { + const organizationId = await this.idTranslator.translateOrganizationId(input); await this.session.assertPerformAction({ action: 'member:modifyRole', - organizationId: input.organizationId, + organizationId, params: { - organizationId: input.organizationId, + organizationId, }, }); - const currentUser = await this.session.getViewer(); - const [role, currentUserAsMember] = await Promise.all([ - this.storage.getOrganizationMemberRole({ - organizationId: input.organizationId, - roleId: input.roleId, - }), - this.getOrganizationMember({ - organizationId: input.organizationId, - userId: currentUser.id, - }), - ]); - if (!role) { + const inputValidation = createOrUpdateMemberRoleInputSchema.safeParse({ + name: input.name, + description: input.description, + }); + + if (!inputValidation.success) { return { error: { - message: 'Role not found', + message: 'Please check your input.', + inputErrors: { + name: inputValidation.error.formErrors.fieldErrors.name?.[0], + description: inputValidation.error.formErrors.fieldErrors.description?.[0], + }, }, }; } - const newScopes = ensureReadAccess([ - ...input.organizationAccessScopes, - ...input.projectAccessScopes, - ...input.targetAccessScopes, - ]); - - const accessCheckResult = this.canUpdateRole(role, currentUserAsMember.scopes); + const role = await this.organizationMemberRoles.findMemberRoleById(input.roleId); - if (!accessCheckResult.ok) { + if (!role) { return { error: { - message: accessCheckResult.message, + message: 'Role not found', }, }; } // Ensure name is unique in the organization const roleName = input.name.trim(); - const nameExists = await this.storage.hasOrganizationMemberRoleName({ - organizationId: input.organizationId, + const foundRole = await this.organizationMemberRoles.findRoleByOrganizationIdAndName( + organizationId, roleName, - excludeRoleId: input.roleId, - }); + ); - if (nameExists) { + if (foundRole && foundRole.id === input.roleId) { const msg = 'Role name already exists. Please choose a different name.'; return { @@ -1258,50 +1173,13 @@ export class OrganizationManager { }; } - const existingRoleScopes = role.scopes; - const hasAssignedMembers = role.membersCount > 0; - - // Ensure user has access to all new scopes in the role - const currentUserMissingAccessInNewRole = newScopes.filter( - scope => !currentUserAsMember.scopes.includes(scope), - ); - - if (currentUserMissingAccessInNewRole.length > 0) { - this.logger.debug(`Logged user scopes: %s`, currentUserAsMember.scopes.join(', ')); - this.logger.debug(`No access to scopes: %s`, currentUserMissingAccessInNewRole.join(', ')); - - return { - error: { - message: `Missing access to some of the selected scopes`, - }, - }; - } - - const missingOldRoleScopesInNewRole = existingRoleScopes.filter( - scope => !newScopes.includes(scope), - ); - - // Ensure new role has at least the same access scopes as the old role, to avoid downgrading members - if (hasAssignedMembers && missingOldRoleScopesInNewRole.length > 0) { - // Admin role is an exception, admin can downgrade members - if (!this.isAdminRole(currentUserAsMember.role)) { - this.logger.debug(`New role scopes: %s`, newScopes.join(', ')); - this.logger.debug(`Old role scopes: %s`, existingRoleScopes.join(', ')); - return { - error: { - message: `Cannot downgrade member to a role with less access scopes`, - }, - }; - } - } - // Update the role - const updatedRole = await this.storage.updateOrganizationMemberRole({ - organizationId: input.organizationId, + const updatedRole = await this.organizationMemberRoles.updateOrganizationMemberRole({ + organizationId, roleId: input.roleId, name: roleName, description: input.description, - scopes: newScopes, + permissions: input.permissions, }); // Access cache is stale by now @@ -1310,17 +1188,18 @@ export class OrganizationManager { await this.auditLog.record({ eventType: 'ROLE_UPDATED', - organizationId: input.organizationId, + organizationId, metadata: { roleId: updatedRole.id, roleName: updatedRole.name, updatedFields: JSON.stringify({ name: roleName, description: input.description, - scopes: newScopes, + permissions: input.permissions, }), }, }); + return { ok: { updatedRole, @@ -1337,24 +1216,25 @@ export class OrganizationManager { }, }); - return this.storage.getOrganizationMemberRoles({ - organizationId: selector.organizationId, - }); + return this.organizationMemberRoles.getMemberRolesForOrganizationId(selector.organizationId); } async getMemberRole(selector: { organizationId: string; roleId: string }) { + const role = await this.organizationMemberRoles.findMemberRoleById(selector.roleId); + + if (!role) { + return null; + } + await this.session.assertPerformAction({ action: 'member:describe', - organizationId: selector.organizationId, + organizationId: role.organizationId, params: { - organizationId: selector.organizationId, + organizationId: role.organizationId, }, }); - return this.storage.getOrganizationMemberRole({ - organizationId: selector.organizationId, - roleId: selector.roleId, - }); + return role; } async getViewerMemberRole(selector: { organizationId: string }) { @@ -1366,19 +1246,10 @@ export class OrganizationManager { }, }); - return this.storage.getViewerOrganizationMemberRole({ - organizationId: selector.organizationId, - }); + return this.organizationMemberRoles.findViewerRoleByOrganizationId(selector.organizationId); } - async canDeleteRole( - role: OrganizationMemberRole, - currentUserScopes: readonly ( - | OrganizationAccessScope - | ProjectAccessScope - | TargetAccessScope - )[], - ): Promise< + async canDeleteRole(role: OrganizationMemberRole): Promise< | { ok: false; message: string; @@ -1388,26 +1259,24 @@ export class OrganizationManager { } > { // Ensure role is not locked (can't be deleted) - if (role.locked) { + if (role.isLocked) { return { ok: false, message: `Cannot delete a built-in role`, }; } // Ensure role has no members - let membersCount: number | undefined = role.membersCount; + let membersCount: number | null = role.membersCount; if (typeof membersCount !== 'number') { - const freshRole = await this.storage.getOrganizationMemberRole({ - organizationId: role.organizationId, - roleId: role.id, - }); + const freshRole = await this.organizationMemberRoles.findMemberRoleById(role.id); if (!freshRole) { throw new Error('Role not found'); } - membersCount = freshRole.membersCount; + // TODO: check this + membersCount = freshRole.membersCount ?? 0; } if (membersCount > 0) { @@ -1417,21 +1286,6 @@ export class OrganizationManager { }; } - // Ensure user has access to all scopes in the role - const currentUserMissingScopes = role.scopes.filter( - scope => !currentUserScopes.includes(scope), - ); - - if (currentUserMissingScopes.length > 0) { - this.logger.debug(`Logged user scopes: %s`, currentUserScopes.join(', ')); - this.logger.debug(`No access to scopes: %s`, currentUserMissingScopes.join(', ')); - - return { - ok: false, - message: `Missing access to some of the scopes of the role`, - }; - } - return { ok: true, }; @@ -1453,7 +1307,7 @@ export class OrganizationManager { ok: true; } { // Ensure role is not locked (can't be updated) - if (role.locked) { + if (role.isLocked) { return { ok: false, message: `Cannot update a built-in role`, @@ -1461,9 +1315,8 @@ export class OrganizationManager { } // Ensure user has access to all scopes in the role - const currentUserMissingScopes = role.scopes.filter( - scope => !currentUserScopes.includes(scope), - ); + const currentUserMissingScopes = + role.legacyScopes?.filter(scope => !currentUserScopes.includes(scope)) ?? []; if (currentUserMissingScopes.length > 0) { this.logger.debug(`Logged user scopes: %s`, currentUserScopes.join(', ')); @@ -1496,9 +1349,8 @@ export class OrganizationManager { ok: true; } { // Ensure user has access to all scopes in the role - const currentUserMissingScopes = role.scopes.filter( - scope => !currentUserScopes.includes(scope), - ); + const currentUserMissingScopes = + role.legacyScopes?.filter(scope => !currentUserScopes.includes(scope)) ?? []; if (currentUserMissingScopes.length > 0) { this.logger.debug(`Logged user scopes: %s`, currentUserScopes.join(', ')); @@ -1514,8 +1366,4 @@ export class OrganizationManager { ok: true, }; } - - isAdminRole(role: { name: string; locked: boolean } | null) { - return role?.name === 'Admin' && role.locked === true; - } } diff --git a/packages/services/api/src/modules/organization/providers/organization-member-roles.ts b/packages/services/api/src/modules/organization/providers/organization-member-roles.ts new file mode 100644 index 0000000000..8f5a0dc2fd --- /dev/null +++ b/packages/services/api/src/modules/organization/providers/organization-member-roles.ts @@ -0,0 +1,316 @@ +import { Inject, Injectable, Scope } from 'graphql-modules'; +import { sql, type DatabasePool } from 'slonik'; +import { z } from 'zod'; +import { batch } from '../../../shared/helpers'; +import { + Permission, + PermissionsModel, + PermissionsPerResourceLevelAssignmentModel, + permissionsToPermissionsPerResourceLevelAssignment, +} from '../../auth/lib/authz'; +import { + OrganizationAccessScope, + ProjectAccessScope, + TargetAccessScope, +} from '../../auth/providers/scopes'; +import { Logger } from '../../shared/providers/logger'; +import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; +import * as OrganizationMemberPermissions from '../lib/organization-member-permissions'; + +// function omit(obj: T, key: K): Omit { +// const { [key]: _, ...rest } = obj; +// return rest; +// } + +const MemberRoleModel = z + .intersection( + z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + isLocked: z.boolean(), + organizationId: z.string(), + membersCount: z.number(), + }), + z.union([ + z.object({ + legacyScopes: z + .array(z.string()) + .transform( + value => + value as Array, + ), + permissions: z.null(), + }), + z.object({ + legacyScopes: z.null(), + permissions: z.array(PermissionsModel), + }), + ]), + ) + .transform(record => ({ + // TODO: omit "legacyScopes" property + ...record, + permissions: record.permissions + ? permissionsToPermissionsPerResourceLevelAssignment([ + ...OrganizationMemberPermissions.permissions.default, + ...record.permissions, + ]) + : transformOrganizationMemberLegacyScopesIntoPermissionGroup(record.legacyScopes), + })); + +export type OrganizationMemberRole = z.TypeOf; + +@Injectable({ + scope: Scope.Operation, + global: true, +}) +export class OrganizationMemberRoles { + private logger: Logger; + + constructor( + @Inject(PG_POOL_CONFIG) private pool: DatabasePool, + logger: Logger, + ) { + this.logger = logger.child({ + source: 'OrganizationMemberRoles', + }); + } + + async getMemberRolesForOrganizationId(organizationId: string) { + const query = sql` + SELECT + ${organizationMemberRoleFields} + FROM + "organization_member_roles" + WHERE + "organization_id" = ${organizationId} + `; + + const records = await this.pool.any(query); + + return records.map(row => MemberRoleModel.parse(row)); + } + + /** Find member roles by their ID */ + async findMemberRolesByIds(roleIds: Array) { + this.logger.debug('Find organization membership roles. (roleIds=%o)', roleIds); + + const query = sql` + SELECT + ${organizationMemberRoleFields} + FROM + "organization_member_roles" + WHERE + "id" = ANY(${sql.array(roleIds, 'uuid')}) + `; + + const result = await this.pool.any(query); + + const rowsById = new Map(); + + for (const row of result) { + const record = MemberRoleModel.parse(row); + + rowsById.set(record.id, record); + } + return rowsById; + } + + findMemberRoleById = batch(async roleIds => { + const roles = await this.findMemberRolesByIds(roleIds); + return roleIds.map(async roleId => roles.get(roleId) ?? null); + }); + + async findRoleByOrganizationIdAndName(organizationId: string, name: string) { + const result = await this.pool.maybeOne(sql`/* findViewerRoleForOrganizationId */ + SELECT + ${organizationMemberRoleFields} + FROM + "organization_member_roles" + WHERE + "organization_id" = ${organizationId} + AND "name" = ${name} + LIMIT 1 + `); + + if (result === null) { + return null; + } + + return MemberRoleModel.parse(result); + } + + async findViewerRoleByOrganizationId(organizationId: string) { + return this.findRoleByOrganizationIdAndName(organizationId, 'Viewer'); + } + + async createOrganizationMemberRole(args: { + organizationId: string; + name: string; + description: string; + permissions: ReadonlyArray; + }): Promise { + const permissions = args.permissions.filter(permission => + OrganizationMemberPermissions.permissions.assignable.has(permission as Permission), + ); + const role = await this.pool.one( + sql`/* createOrganizationMemberRole */ + INSERT INTO "organization_member_roles" ( + "organization_id" + , "name" + , "description" + , "scopes" + , "permissions" + ) + VALUES ( + ${args.organizationId} + , ${args.name} + , ${args.description} + , NULL + , ${sql.array(permissions, 'text')} + ) + RETURNING + ${organizationMemberRoleFields} + `, + ); + + return MemberRoleModel.parse(role); + } + + async updateOrganizationMemberRole(args: { + organizationId: string; + roleId: string; + name: string; + permissions: ReadonlyArray; + description: string; + }) { + const permissions = args.permissions.filter(permission => + OrganizationMemberPermissions.permissions.assignable.has(permission as Permission), + ); + + const role = await this.pool.one( + sql`/* updateOrganizationMemberRole */ + UPDATE + "organization_member_roles" + SET + "name" = ${args.name} + , "description" = ${args.description} + , "scopes" = NULL + , "permissions" = ${sql.array(permissions, 'text')} + WHERE + "organization_id" = ${args.organizationId} AND id = ${args.roleId} + RETURNING + ${organizationMemberRoleFields} + `, + ); + + return MemberRoleModel.parse(role); + } +} + +export function transformOrganizationMemberLegacyScopesIntoPermissionGroup( + scopes: Array, +): z.TypeOf { + const permissions = permissionsToPermissionsPerResourceLevelAssignment([ + ...OrganizationMemberPermissions.permissions.default, + ]); + for (const scope of scopes) { + switch (scope) { + case OrganizationAccessScope.READ: { + permissions.organization.add('organization:describe'); + permissions.organization.add('support:manageTickets'); + permissions.organization.add('project:create'); + permissions.project.add('project:describe'); + break; + } + case OrganizationAccessScope.SETTINGS: { + permissions.organization.add('organization:modifySlug'); + permissions.organization.add('schemaLinting:modifyOrganizationRules'); + permissions.organization.add('billing:describe'); + permissions.organization.add('billing:update'); + permissions.organization.add('auditLog:export'); + break; + } + case OrganizationAccessScope.DELETE: { + permissions.organization.add('organization:delete'); + break; + } + case OrganizationAccessScope.INTEGRATIONS: { + permissions.organization.add('oidc:modify'); + permissions.organization.add('gitHubIntegration:modify'); + permissions.organization.add('slackIntegration:modify'); + + break; + } + case OrganizationAccessScope.MEMBERS: { + permissions.organization.add('member:manageInvites'); + permissions.organization.add('member:removeMember'); + permissions.organization.add('member:assignRole'); + permissions.organization.add('member:modifyRole'); + permissions.organization.add('member:describe'); + break; + } + case ProjectAccessScope.ALERTS: { + permissions.project.add('alert:modify'); + break; + } + case ProjectAccessScope.READ: { + permissions.project.add('project:describe'); + break; + } + case ProjectAccessScope.DELETE: { + permissions.project.add('project:delete'); + break; + } + case ProjectAccessScope.SETTINGS: { + permissions.project.add('project:delete'); + permissions.project.add('project:modifySettings'); + permissions.project.add('schemaLinting:modifyProjectRules'); + break; + } + case TargetAccessScope.READ: { + permissions.project.add('target:create'); + permissions.target.add('appDeployment:describe'); + permissions.target.add('laboratory:describe'); + break; + } + case TargetAccessScope.REGISTRY_WRITE: { + permissions.target.add('laboratory:modify'); + permissions.service.add('schemaCheck:approve'); + break; + } + case TargetAccessScope.TOKENS_WRITE: { + permissions.target.add('targetAccessToken:modify'); + permissions.target.add('cdnAccessToken:modify'); + break; + } + case TargetAccessScope.SETTINGS: { + permissions.target.add('target:modifySettings'); + permissions.target.add('laboratory:modifyPreflightScript'); + break; + } + case TargetAccessScope.DELETE: { + permissions.target.add('target:delete'); + break; + } + } + } + + return permissions; +} + +const organizationMemberRoleFields = sql` + "organization_member_roles"."id" + , "organization_member_roles"."name" + , "organization_member_roles"."description" + , "organization_member_roles"."locked" AS "isLocked" + , "organization_member_roles"."scopes" AS "legacyScopes" + , "organization_member_roles"."permissions" + , "organization_member_roles"."organization_id" AS "organizationId" + , ( + SELECT COUNT(*) + FROM "organization_member" AS "om" + WHERE "om"."role_id" = "organization_member_roles"."id" + ) AS "membersCount" +`; diff --git a/packages/services/api/src/modules/organization/providers/organization-members.ts b/packages/services/api/src/modules/organization/providers/organization-members.ts new file mode 100644 index 0000000000..de995c072c --- /dev/null +++ b/packages/services/api/src/modules/organization/providers/organization-members.ts @@ -0,0 +1,363 @@ +import { Inject, Injectable, Scope } from 'graphql-modules'; +import { sql, type DatabasePool } from 'slonik'; +import { z } from 'zod'; +import { Organization } from '../../../shared/entities'; +import { batchBy } from '../../../shared/helpers'; +import { isUUID } from '../../../shared/is-uuid'; +import { Logger } from '../../shared/providers/logger'; +import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; +import { OrganizationMemberRole, OrganizationMemberRoles } from './organization-member-roles'; + +const RawOrganizationMembershipModel = z.object({ + userId: z.string(), + roleId: z.string(), + connectedToZendesk: z + .boolean() + .nullable() + .transform(value => value ?? false), +}); + +const UUIDResourceAssignmentModel = z.union([z.literal('*'), z.array(z.string().uuid())]); + +/** + * String in the form `targetId/serviceName` + * Example: `f81ce726-2abf-4653-bf4c-d8436cde255a/users` + */ +const ServiceResourceAssignmentStringModel = z + .string() + .refine(value => { + const [targetId, serviceName = ''] = value.split('/'); + if (isUUID(targetId) === false || serviceName === '') { + return false; + } + return true; + }, 'Invalid service resource assignment') + .transform(value => value.split('/') as [targetId: string, serviceName: string]); + +const ServiceResourceAssignmentModel = z.union([ + z.literal('*'), + z.array(ServiceResourceAssignmentStringModel), +]); + +const ResourceAssignmentGroupModel = z.object({ + /** Resources assigned to a 'projects' permission group */ + project: UUIDResourceAssignmentModel, + /** Resources assigned to a 'targets' permission group */ + target: UUIDResourceAssignmentModel, + /** Resources assigned to a 'service' permission group */ + service: ServiceResourceAssignmentModel, + /** Resources assigned to a 'appDeployment' permission group */ + appDeployment: ServiceResourceAssignmentModel, +}); + +/** + * Resource assignments as stored within the database. + */ +type ResourceAssignmentGroup = z.TypeOf; + +export type OrganizationMembershipRoleAssignment = { + role: OrganizationMemberRole; + /** + * Resource assignments as stored within the database. + */ + resources: ResourceAssignmentGroup; + /** + * Resolved resource groups, used for runtime permission checks. + */ + resolvedResources: ResolvedResourceAssignments; +}; + +export type OrganizationMembership = { + organizationId: string; + isOwner: boolean; + userId: string; + assignedRole: OrganizationMembershipRoleAssignment; + connectedToZendesk: boolean; +}; + +@Injectable({ + scope: Scope.Operation, + global: true, +}) +export class OrganizationMembers { + private logger: Logger; + + constructor( + @Inject(PG_POOL_CONFIG) private pool: DatabasePool, + private organizationMemberRoles: OrganizationMemberRoles, + logger: Logger, + ) { + this.logger = logger.child({ + source: 'OrganizationMembers', + }); + } + + private async findOrganizationMembers( + organizationId: string, + userIds: Array | null = null, + ) { + const query = sql` + SELECT + "om"."user_id" AS "userId" + , "om"."role_id" AS "legacyRoleId" + , "om"."scopes" AS "legacyScopes" + , "om"."connected_to_zendesk" AS "connectedToZendesk" + FROM + "organization_member" AS "om" + WHERE + "om"."organization_id" = ${organizationId} + ${userIds ? sql`AND "om"."user_id" = ANY(${sql.array(userIds, 'uuid')})` : sql``} + `; + + const result = await this.pool.any(query); + return result.map(row => RawOrganizationMembershipModel.parse(row)); + } + + /** + * Handles legacy scopes and role assignments and automatically transforms + * them into resource based role assignments. + */ + private async resolveMemberships( + organization: Organization, + organizationMembers: Array>, + ) { + const organizationMembershipByUserId = new Map(); + + // Roles that are assigned using the legacy "single role" way + const roleLookups = new Set(); + + for (const record of organizationMembers) { + roleLookups.add(record.roleId); + } + + if (roleLookups.size) { + // This handles the legacy "single" role assignments + // We load the roles and then attach them to the already loaded membership role + const roleIds = Array.from(roleLookups); + + this.logger.debug('Lookup role assignments. (roleIds=%o)', roleIds); + + const memberRolesById = await this.organizationMemberRoles.findMemberRolesByIds(roleIds); + + for (const record of organizationMembers) { + const membershipRole = memberRolesById.get(record.roleId); + if (!membershipRole) { + throw new Error('Could not resolve role.'); + } + + // TODO: see if membership has resource assignments + const resources: ResourceAssignmentGroup = { + project: '*', + target: '*', + service: '*', + appDeployment: '*', + }; + + organizationMembershipByUserId.set(record.userId, { + organizationId: organization.id, + userId: record.userId, + isOwner: organization.ownerId === record.userId, + connectedToZendesk: record.connectedToZendesk, + assignedRole: { + resources, + resolvedResources: resolveResourceAssignment({ + organizationId: organization.id, + groups: resources, + }), + role: membershipRole, + }, + }); + } + } + + return organizationMembershipByUserId; + } + + async findOrganizationMembersForOrganization(organization: Organization) { + this.logger.debug( + 'Find organization members for organization. (organizationId=%s)', + organization.id, + ); + const organizationMembers = await this.findOrganizationMembers(organization.id); + const mapping = await this.resolveMemberships(organization, organizationMembers); + + return organizationMembers.map(record => { + const member = mapping.get(record.userId); + if (!member) { + throw new Error('Could not find member.'); + } + return member; + }); + } + + /** + * Batched loader function for a organization membership. + */ + findOrganizationMembership = batchBy( + (args: { organization: Organization; userId: string }) => args.organization.id, + async args => { + const organization = args[0].organization; + const userIds = args.map(arg => arg.userId); + + this.logger.debug( + 'Find organization membership for users. (organizationId=%s, userIds=%o)', + organization.id, + userIds, + ); + + const organizationMembers = await this.findOrganizationMembers(organization.id, userIds); + const mapping = await this.resolveMemberships(organization, organizationMembers); + + return userIds.map(async userId => mapping.get(userId) ?? null); + }, + ); + + findOrganizationOwner(organization: Organization) { + return this.findOrganizationMembership({ + organization, + userId: organization.ownerId, + }); + } + + /** + * Find the organization members that have no role assigned and use the legacy scopes. + */ + async findOrganizationMembersWithoutAssignedRole(organization: Organization) { + const query = sql` + SELECT + "om"."user_id" AS "userId" + , "om"."role_id" AS "legacyRoleId" + , "om"."connected_to_zendesk" AS "connectedToZendesk" + FROM + "organization_member" AS "om" + WHERE + "om"."organization_id" = ${organization.id} + AND "om"."role_id" IS NULL + `; + + const records = await this.pool.any(query); + const organizationMembers = records.map(row => RawOrganizationMembershipModel.parse(row)); + const mapping = await this.resolveMemberships(organization, organizationMembers); + return Array.from(mapping.values()); + } +} + +type OrganizationAssignment = { + type: 'organization'; + organizationId: string; +}; + +type ProjectAssignment = { + type: 'project'; + projectId: string; +}; + +type TargetAssignment = { + type: 'target'; + targetId: string; +}; + +type ServiceAssignment = { + type: 'service'; + targetId: string; + serviceName: string; +}; + +type AppDeploymentAssignment = { + type: 'appDeployment'; + targetId: string; + appDeploymentName: string; +}; + +export type ResourceAssignment = + | OrganizationAssignment + | ProjectAssignment + | TargetAssignment + | ServiceAssignment + | AppDeploymentAssignment; + +type ResolvedResourceAssignments = { + organization: OrganizationAssignment; + project: OrganizationAssignment | Array; + target: OrganizationAssignment | Array | Array; + service: + | OrganizationAssignment + | Array + | Array + | Array; + appDeployment: + | OrganizationAssignment + | Array + | Array + | Array; +}; + +/** + * This function resolves the "stored-in-database", user configuration to the actual resolved structure + * Currently, we have the following hierarchy + * + * organization + * v + * project + * v + * target + * v v + * app deployment service + * + * If one level specifies "*", it needs to inherit the resources defined on the next upper level. + */ +function resolveResourceAssignment(args: { + organizationId: string; + groups: ResourceAssignmentGroup; +}): ResolvedResourceAssignments { + const organization: OrganizationAssignment = { + type: 'organization', + organizationId: args.organizationId, + }; + + let project: ResolvedResourceAssignments['project'] = organization; + + if (args.groups.project !== '*') { + project = args.groups.project.map(projectId => ({ + type: 'project', + projectId, + })); + } + + let target: ResolvedResourceAssignments['target'] = project; + + if (args.groups.target !== '*') { + target = args.groups.target.map(targetId => ({ + type: 'target', + targetId, + })); + } + + let service: ResolvedResourceAssignments['service'] = target; + + if (args.groups.service !== '*') { + service = args.groups.service.map(([targetId, serviceName]) => ({ + type: 'service', + targetId, + serviceName, + })); + } + + let appDeployment: ResolvedResourceAssignments['appDeployment'] = target; + + if (args.groups.service !== '*') { + appDeployment = args.groups.service.map(([targetId, appDeploymentName]) => ({ + type: 'appDeployment', + targetId, + appDeploymentName, + })); + } + + return { + organization, + project, + target, + service, + appDeployment, + }; +} diff --git a/packages/services/api/src/modules/organization/resolvers/Member.ts b/packages/services/api/src/modules/organization/resolvers/Member.ts index 9e450fd00d..fad2a3ba14 100644 --- a/packages/services/api/src/modules/organization/resolvers/Member.ts +++ b/packages/services/api/src/modules/organization/resolvers/Member.ts @@ -1,21 +1,16 @@ +import { Storage } from '../../shared/providers/storage'; import { OrganizationManager } from '../providers/organization-manager'; import type { MemberResolvers } from './../../../__generated__/types'; -export const Member: Pick< - MemberResolvers, - 'canLeaveOrganization' | 'isAdmin' | 'role' | 'viewerCanRemove' | '__isTypeOf' -> = { +export const Member: MemberResolvers = { canLeaveOrganization: async (member, _, { injector }) => { const { result } = await injector.get(OrganizationManager).canLeaveOrganization({ - organizationId: member.organization, - userId: member.user.id, + organizationId: member.organizationId, + userId: member.userId, }); return result; }, - isAdmin: (member, _, { injector }) => { - return member.isOwner || injector.get(OrganizationManager).isAdminRole(member.role); - }, viewerCanRemove: async (member, _arg, { session }) => { if (member.isOwner) { return false; @@ -23,10 +18,23 @@ export const Member: Pick< return await session.canPerformAction({ action: 'member:removeMember', - organizationId: member.organization, + organizationId: member.organizationId, params: { - organizationId: member.organization, + organizationId: member.organizationId, }, }); }, + role: (member, _arg, _ctx) => { + return member.assignedRole.role; + }, + id: async (member, _arg, _ctx) => { + return member.userId; + }, + user: async (member, _arg, { injector }) => { + const user = await injector.get(Storage).getUserById({ id: member.userId }); + if (!user) { + throw new Error('User not found.'); + } + return user; + }, }; diff --git a/packages/services/api/src/modules/auth/resolvers/MemberConnection.ts b/packages/services/api/src/modules/organization/resolvers/MemberConnection.ts similarity index 90% rename from packages/services/api/src/modules/auth/resolvers/MemberConnection.ts rename to packages/services/api/src/modules/organization/resolvers/MemberConnection.ts index 91f3e1cd7f..56d1a370b4 100644 --- a/packages/services/api/src/modules/auth/resolvers/MemberConnection.ts +++ b/packages/services/api/src/modules/organization/resolvers/MemberConnection.ts @@ -1,5 +1,5 @@ +import type { MemberConnectionResolvers, ResolversTypes } from '../../../__generated__/types'; import { createConnection } from '../../../shared/schema'; -import type { MemberConnectionResolvers, ResolversTypes } from './../../../__generated__/types'; const connection = createConnection(); diff --git a/packages/services/api/src/modules/organization/resolvers/MemberRole.ts b/packages/services/api/src/modules/organization/resolvers/MemberRole.ts index f988985252..2e94a09983 100644 --- a/packages/services/api/src/modules/organization/resolvers/MemberRole.ts +++ b/packages/services/api/src/modules/organization/resolvers/MemberRole.ts @@ -1,77 +1,50 @@ import { Session } from '../../auth/lib/authz'; -import { isOrganizationScope } from '../../auth/providers/organization-access'; -import { isProjectScope } from '../../auth/providers/project-access'; -import { isTargetScope } from '../../auth/providers/target-access'; -import { OrganizationManager } from '../providers/organization-manager'; import type { MemberRoleResolvers } from './../../../__generated__/types'; export const MemberRole: MemberRoleResolvers = { - organizationAccessScopes: role => { - return role.scopes.filter(isOrganizationScope); - }, - projectAccessScopes: role => { - return role.scopes.filter(isProjectScope); - }, - targetAccessScopes: role => { - return role.scopes.filter(isTargetScope); - }, - membersCount: async (role, _, { injector }) => { - if (role.membersCount) { - return role.membersCount; - } - - return injector - .get(OrganizationManager) - .getMemberRole({ - organizationId: role.organizationId, - roleId: role.id, - }) - .then(r => r?.membersCount ?? 0); - }, canDelete: async (role, _, { injector }) => { - if (role.locked) { + if (role.isLocked) { return false; } - - const currentUser = await injector.get(Session).getViewer(); - const currentUserAsMember = await injector.get(OrganizationManager).getOrganizationMember({ + return await injector.get(Session).canPerformAction({ + action: 'member:modifyRole', organizationId: role.organizationId, - userId: currentUser.id, + params: { + organizationId: role.organizationId, + }, }); - - const result = await injector - .get(OrganizationManager) - .canDeleteRole(role, currentUserAsMember.scopes); - - return result.ok; }, canUpdate: async (role, _, { injector }) => { - if (role.locked) { + if (role.isLocked) { return false; } - const currentUser = await injector.get(Session).getViewer(); - const currentUserAsMember = await injector.get(OrganizationManager).getOrganizationMember({ + return await injector.get(Session).canPerformAction({ + action: 'member:modifyRole', organizationId: role.organizationId, - userId: currentUser.id, + params: { + organizationId: role.organizationId, + }, }); - - const result = injector - .get(OrganizationManager) - .canUpdateRole(role, currentUserAsMember.scopes); - - return result.ok; }, canInvite: async (role, _, { injector }) => { - const currentUser = await injector.get(Session).getViewer(); - const currentUserAsMember = await injector.get(OrganizationManager).getOrganizationMember({ + return await injector.get(Session).canPerformAction({ + action: 'member:manageInvites', organizationId: role.organizationId, - userId: currentUser.id, + params: { + organizationId: role.organizationId, + }, }); - - const result = injector - .get(OrganizationManager) - .canInviteRole(role, currentUserAsMember.scopes); - - return result.ok; + }, + locked: async (role, _arg, _ctx) => { + return role.isLocked; + }, + permissions: (role, _arg, _ctx) => { + return [ + ...role.permissions.organization, + ...role.permissions.project, + ...role.permissions.target, + ...role.permissions.service, + ...role.permissions.appDeployment, + ]; }, }; diff --git a/packages/services/api/src/modules/organization/resolvers/Mutation/assignMemberRole.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/assignMemberRole.ts index 5c63e6f8c4..1ea7417ea4 100644 --- a/packages/services/api/src/modules/organization/resolvers/Mutation/assignMemberRole.ts +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/assignMemberRole.ts @@ -1,4 +1,3 @@ -import { IdTranslator } from '../../../shared/providers/id-translator'; import { OrganizationManager } from '../../providers/organization-manager'; import type { MutationResolvers } from './../../../../__generated__/types'; @@ -7,10 +6,8 @@ export const assignMemberRole: NonNullable { - const organizationId = await injector.get(IdTranslator).translateOrganizationId(input); - return injector.get(OrganizationManager).assignMemberRole({ - organizationId, + organizationSlug: input.organizationSlug, userId: input.userId, roleId: input.roleId, }); diff --git a/packages/services/api/src/modules/organization/resolvers/Mutation/createMemberRole.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/createMemberRole.ts index c8c481abaf..f35219273a 100644 --- a/packages/services/api/src/modules/organization/resolvers/Mutation/createMemberRole.ts +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/createMemberRole.ts @@ -1,6 +1,4 @@ -import { IdTranslator } from '../../../shared/providers/id-translator'; import { OrganizationManager } from '../../providers/organization-manager'; -import { createOrUpdateMemberRoleInputSchema } from '../../validation'; import type { MutationResolvers } from './../../../../__generated__/types'; export const createMemberRole: NonNullable = async ( @@ -8,31 +6,10 @@ export const createMemberRole: NonNullable { - const inputValidation = createOrUpdateMemberRoleInputSchema.safeParse({ + return await injector.get(OrganizationManager).createMemberRole({ + organizationSlug: input.organizationSlug, name: input.name, description: input.description, - }); - - if (!inputValidation.success) { - return { - error: { - message: 'Please check your input.', - inputErrors: { - name: inputValidation.error.formErrors.fieldErrors.name?.[0], - description: inputValidation.error.formErrors.fieldErrors.description?.[0], - }, - }, - }; - } - - const organizationId = await injector.get(IdTranslator).translateOrganizationId(input); - - return injector.get(OrganizationManager).createMemberRole({ - organizationId, - name: inputValidation.data.name, - description: inputValidation.data.description, - organizationAccessScopes: input.organizationAccessScopes, - projectAccessScopes: input.projectAccessScopes, - targetAccessScopes: input.targetAccessScopes, + permissions: input.selectedPermissions, }); }; diff --git a/packages/services/api/src/modules/organization/resolvers/Mutation/updateMemberRole.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/updateMemberRole.ts index ed206463f8..6f641e1ca8 100644 --- a/packages/services/api/src/modules/organization/resolvers/Mutation/updateMemberRole.ts +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/updateMemberRole.ts @@ -1,6 +1,4 @@ -import { IdTranslator } from '../../../shared/providers/id-translator'; import { OrganizationManager } from '../../providers/organization-manager'; -import { createOrUpdateMemberRoleInputSchema } from '../../validation'; import type { MutationResolvers } from './../../../../__generated__/types'; export const updateMemberRole: NonNullable = async ( @@ -8,31 +6,11 @@ export const updateMemberRole: NonNullable { - const inputValidation = createOrUpdateMemberRoleInputSchema.safeParse({ - name: input.name, - description: input.description, - }); - - if (!inputValidation.success) { - return { - error: { - message: 'Please check your input.', - inputErrors: { - name: inputValidation.error.formErrors.fieldErrors.name?.[0], - description: inputValidation.error.formErrors.fieldErrors.description?.[0], - }, - }, - }; - } - const organizationId = await injector.get(IdTranslator).translateOrganizationId(input); - return injector.get(OrganizationManager).updateMemberRole({ - organizationId, + organizationSlug: input.organizationSlug, roleId: input.roleId, - name: inputValidation.data.name, - description: inputValidation.data.description, - organizationAccessScopes: input.organizationAccessScopes, - projectAccessScopes: input.projectAccessScopes, - targetAccessScopes: input.targetAccessScopes, + name: input.name, + description: input.description, + permissions: input.selectedPermissions, }); }; diff --git a/packages/services/api/src/modules/organization/resolvers/Organization.ts b/packages/services/api/src/modules/organization/resolvers/Organization.ts index 87d8dd6741..f449df39de 100644 --- a/packages/services/api/src/modules/organization/resolvers/Organization.ts +++ b/packages/services/api/src/modules/organization/resolvers/Organization.ts @@ -1,9 +1,13 @@ import { Session } from '../../auth/lib/authz'; +import { allPermissionGroups } from '../lib/organization-member-permissions'; import { OrganizationManager } from '../providers/organization-manager'; +import { OrganizationMemberRoles } from '../providers/organization-member-roles'; +import { OrganizationMembers } from '../providers/organization-members'; import type { OrganizationResolvers } from './../../../__generated__/types'; export const Organization: Pick< OrganizationResolvers, + | 'availableMemberPermissionGroups' | 'cleanId' | 'getStarted' | 'id' @@ -28,23 +32,30 @@ export const Organization: Pick< __isTypeOf: organization => { return !!organization.id; }, - owner: (organization, _, { injector }) => { - return injector - .get(OrganizationManager) - .getOrganizationOwner({ organizationId: organization.id }); + owner: async (organization, _, { injector }) => { + const owner = await injector.get(OrganizationMembers).findOrganizationOwner(organization); + if (!owner) { + throw new Error('Not found.'); + } + + return owner; }, me: async (organization, _, { injector }) => { const me = await injector.get(Session).getViewer(); - const members = await injector - .get(OrganizationManager) - .getOrganizationMembers({ organizationId: organization.id }); - return members.find(m => m.id === me.id)!; + const member = await injector.get(OrganizationMembers).findOrganizationMembership({ + organization, + userId: me.id, + }); + + if (!member) { + throw new Error('Could not find member.'); + } + + return member; }, members: (organization, _, { injector }) => { - return injector - .get(OrganizationManager) - .getOrganizationMembers({ organizationId: organization.id }); + return injector.get(OrganizationMembers).findOrganizationMembersForOrganization(organization); }, invitations: async (organization, _, { injector }) => { const invitations = await injector.get(OrganizationManager).getInvitations({ @@ -57,9 +68,7 @@ export const Organization: Pick< }; }, memberRoles: (organization, _, { injector }) => { - return injector.get(OrganizationManager).getMemberRoles({ - organizationId: organization.id, - }); + return injector.get(OrganizationMemberRoles).getMemberRolesForOrganizationId(organization.id); }, cleanId: organization => organization.slug, viewerCanDelete: async (organization, _arg, { session }) => { @@ -173,4 +182,7 @@ export const Organization: Pick< }, }); }, + availableMemberPermissionGroups: () => { + return allPermissionGroups; + }, }; diff --git a/packages/services/api/src/modules/organization/resolvers/OrganizationInvitation.ts b/packages/services/api/src/modules/organization/resolvers/OrganizationInvitation.ts index 6054e715d4..9b83668b70 100644 --- a/packages/services/api/src/modules/organization/resolvers/OrganizationInvitation.ts +++ b/packages/services/api/src/modules/organization/resolvers/OrganizationInvitation.ts @@ -1,3 +1,4 @@ +import { OrganizationMemberRoles } from '../providers/organization-member-roles'; import type { OrganizationInvitationResolvers } from './../../../__generated__/types'; export const OrganizationInvitation: OrganizationInvitationResolvers = { @@ -12,4 +13,11 @@ export const OrganizationInvitation: OrganizationInvitationResolvers = { expiresAt: invitation => { return invitation.expires_at; }, + role: async (invitation, _arg, { injector }) => { + const role = await injector.get(OrganizationMemberRoles).findMemberRoleById(invitation.roleId); + if (!role) { + throw new Error('Not found.'); + } + return role; + }, }; diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index c57f242e9c..1b4d145bf1 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -24,7 +24,6 @@ import type { Organization, OrganizationBilling, OrganizationInvitation, - OrganizationMemberRole, PaginatedDocumentCollectionOperations, PaginatedDocumentCollections, Project, @@ -198,35 +197,6 @@ export interface Storage { deleteOrganizationMember(_: OrganizationSelector & { userId: string }): Promise; - hasOrganizationMemberRoleName(_: { - organizationId: string; - roleName: string; - excludeRoleId?: string; - }): Promise; - getOrganizationMemberRoles(_: { - organizationId: string; - }): Promise>; - getViewerOrganizationMemberRole(_: { organizationId: string }): Promise; - getAdminOrganizationMemberRole(_: { organizationId: string }): Promise; - getOrganizationMemberRole(_: { organizationId: string; roleId: string }): Promise< - | (OrganizationMemberRole & { - membersCount: number; - }) - | null - >; - createOrganizationMemberRole(_: { - organizationId: string; - name: string; - description: string; - scopes: ReadonlyArray; - }): Promise; - updateOrganizationMemberRole(_: { - organizationId: string; - roleId: string; - name: string; - description: string; - scopes: ReadonlyArray; - }): Promise; assignOrganizationMemberRole(_: { organizationId: string; roleId: string; diff --git a/packages/services/api/src/modules/support/providers/support-manager.ts b/packages/services/api/src/modules/support/providers/support-manager.ts index 53b3068bff..cc96d9ba48 100644 --- a/packages/services/api/src/modules/support/providers/support-manager.ts +++ b/packages/services/api/src/modules/support/providers/support-manager.ts @@ -196,19 +196,25 @@ export class SupportManager { organizationId: string; }): Promise { const organizationZendeskId = await this.ensureZendeskOrganizationId(input.organizationId); - const userAsMember = await this.organizationManager.getOrganizationMember({ + const membership = await this.organizationManager.getOrganizationMember({ organizationId: input.organizationId, userId: input.userId, }); - if (!userAsMember.user.zendeskId) { + const user = await this.storage.getUserById({ id: membership.userId }); + + if (!user) { + throw new Error('Missing user.'); + } + + if (!user.zendeskId) { this.logger.info( 'Attempt to find user via Zendesk API. (organizationID: %s, userId: %s)', input.organizationId, input.userId, ); - const email = userAsMember.user.email; + const email = user.email; // Before attempting to create the user we need to check whether an user with that email might already exist. let userZendeskId = await this.httpClient @@ -275,9 +281,9 @@ export class SupportManager { }, json: { user: { - name: userAsMember.user.fullName, + name: user.fullName, email, - external_id: userAsMember.user.id, + external_id: user.id, identities: [ { type: 'foreign', @@ -303,10 +309,10 @@ export class SupportManager { userId: input.userId, zendeskId: String(userZendeskId), }); - userAsMember.user.zendeskId = String(userZendeskId); + user.zendeskId = String(userZendeskId); } - if (!userAsMember.connectedToZendesk) { + if (!membership.connectedToZendesk) { this.logger.info( 'Connecting user to zendesk organization (organization: %s, user: %s)', input.organizationId, @@ -328,7 +334,7 @@ export class SupportManager { }, json: { organization_membership: { - user_id: parseInt(userAsMember.user.zendeskId, 10), + user_id: parseInt(user.zendeskId, 10), organization_id: parseInt(organizationZendeskId, 10), }, }, @@ -340,7 +346,7 @@ export class SupportManager { }); } - return userAsMember.user.zendeskId; + return user.zendeskId; } async getUsers(ids: number[]) { diff --git a/packages/services/api/src/modules/token/providers/token-manager.ts b/packages/services/api/src/modules/token/providers/token-manager.ts index d98689b863..037354f970 100644 --- a/packages/services/api/src/modules/token/providers/token-manager.ts +++ b/packages/services/api/src/modules/token/providers/token-manager.ts @@ -2,12 +2,13 @@ import { Injectable, Scope } from 'graphql-modules'; import { maskToken } from '@hive/service-common'; import type { Token } from '../../../shared/entities'; import { HiveError } from '../../../shared/errors'; -import { diffArrays, pushIfMissing } from '../../../shared/helpers'; +import { pushIfMissing } from '../../../shared/helpers'; import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; import { OrganizationAccessScope } from '../../auth/providers/organization-access'; import { ProjectAccessScope } from '../../auth/providers/project-access'; import { TargetAccessScope } from '../../auth/providers/target-access'; +import { OrganizationMembers } from '../../organization/providers/organization-members'; import { Logger } from '../../shared/providers/logger'; import { Storage, TargetSelector } from '../../shared/providers/storage'; import type { CreateTokenResult } from './token-storage'; @@ -35,6 +36,7 @@ export class TokenManager { private session: Session, private tokenStorage: TokenStorage, private storage: Storage, + private organizationMembers: OrganizationMembers, private auditLog: AuditLogRecorder, logger: Logger, ) { @@ -56,9 +58,13 @@ export class TokenManager { const scopes = [...input.organizationScopes, ...input.projectScopes, ...input.targetScopes]; - const currentUser = await this.session.getViewer(); - const currentMember = await this.storage.getOrganizationMember({ + const organization = await this.storage.getOrganization({ organizationId: input.organizationId, + }); + + const currentUser = await this.session.getViewer(); + const currentMember = await this.organizationMembers.findOrganizationMembership({ + organization, userId: currentUser.id, }); @@ -66,20 +72,20 @@ export class TokenManager { throw new HiveError('User is not a member of the organization'); } - const newScopes = [...input.organizationScopes, ...input.projectScopes, ...input.targetScopes]; + // const newScopes = [...input.organizationScopes, ...input.projectScopes, ...input.targetScopes]; - // See what scopes were removed or added - const modifiedScopes = diffArrays(currentMember.scopes, newScopes); + // // See what scopes were removed or added + // const modifiedScopes = diffArrays(currentMember.scopes, newScopes); - // Check if the current user has rights to set these scopes. - const currentUserMissingScopes = modifiedScopes.filter( - scope => !currentMember.scopes.includes(scope), - ); + // // Check if the current user has rights to set these scopes. + // const currentUserMissingScopes = modifiedScopes.filter( + // scope => !currentMember.scopes.includes(scope), + // ); - if (currentUserMissingScopes.length > 0) { - this.logger.debug(`Logged user scopes: %o`, currentMember.scopes); - throw new HiveError(`No access to the scopes: ${currentUserMissingScopes.join(', ')}`); - } + // if (currentUserMissingScopes.length > 0) { + // this.logger.debug(`Logged user scopes: %o`, currentMember.scopes); + // throw new HiveError(`No access to the scopes: ${currentUserMissingScopes.join(', ')}`); + // } pushIfMissing(scopes, TargetAccessScope.READ); pushIfMissing(scopes, ProjectAccessScope.READ); diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index b10488ad88..0b4d31de8a 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -202,7 +202,7 @@ export interface OrganizationInvitation { email: string; created_at: string; expires_at: string; - role: OrganizationMemberRole; + roleId: string; } export interface OrganizationBilling { @@ -444,26 +444,3 @@ export type SchemaPolicy = { }; export type SchemaPolicyAvailableRuleObject = AvailableRulesResponse[0]; - -export const OrganizationMemberRoleModel = z - .object({ - id: z.string(), - organization_id: z.string(), - name: z.string(), - description: z.string(), - locked: z.boolean(), - scopes: z.array(z.string()), - members_count: z.number().optional(), - }) - .transform(role => ({ - id: role.id, - // Why? When using organizationId alias for a column, the column name is converted to organizationid - organizationId: role.organization_id, - membersCount: role.members_count, - name: role.name, - description: role.description, - locked: role.locked, - // Cast string to an array of enum - scopes: role.scopes as (OrganizationAccessScope | ProjectAccessScope | TargetAccessScope)[], - })); -export type OrganizationMemberRole = z.infer; diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index 9d31d6847f..23ddd43dfe 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -20,6 +20,7 @@ import { CryptoProvider, LogFn, Logger, + OrganizationMemberRoles, OrganizationMembers, } from '@hive/api'; import { HivePubSub } from '@hive/api/src/modules/shared/providers/pub-sub'; @@ -408,7 +409,11 @@ export async function main() { new SuperTokensUserAuthNStrategy({ logger: server.log, storage, - organizationMembers: new OrganizationMembers(storage.pool, server.log), + organizationMembers: new OrganizationMembers( + storage.pool, + new OrganizationMemberRoles(storage.pool, server.log), + server.log, + ), }), new TargetAccessTokenStrategy({ logger: server.log, diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 9c99228a07..f30cccb3f0 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -25,7 +25,6 @@ import { context, SpanKind, SpanStatusCode, trace } from '@hive/service-common'; import type { SchemaCoordinatesDiffResult } from '../../api/src/modules/schema/providers/inspector'; import { createSDLHash, - OrganizationMemberRoleModel, ProjectType, type CDNAccessToken, type OIDCIntegration, @@ -227,7 +226,7 @@ export async function createStorage( code: invitation.code, created_at: invitation.created_at as any, expires_at: invitation.expires_at as any, - role: OrganizationMemberRoleModel.parse(invitation.role), + roleId: invitation.role.id, }; } @@ -525,15 +524,18 @@ export async function createStorage( }, connection: Connection, ) { - const result = await connection.one(sql`/* getOrganizationMemberRoleByName */ + const roleId = await connection.oneFirst(sql`/* getOrganizationMemberRoleByName */ SELECT - id, name, description, scopes, locked, organization_id - FROM organization_member_roles - WHERE organization_id = ${args.organizationId} AND name = ${args.roleName} + "id" + FROM + "organization_member_roles" + WHERE + "organization_id" = ${args.organizationId} + AND "name" = ${args.roleName} LIMIT 1 `); - return OrganizationMemberRoleModel.parse(result); + return roleId; }, }; @@ -965,72 +967,6 @@ export async function createStorage( return Promise.resolve(null); }); }), - getAdminOrganizationMemberRole({ organizationId }) { - return shared.getOrganizationMemberRoleByName( - { - organizationId, - roleName: 'Admin', - }, - pool, - ); - }, - getViewerOrganizationMemberRole({ organizationId }) { - return shared.getOrganizationMemberRoleByName( - { - organizationId, - roleName: 'Viewer', - }, - pool, - ); - }, - async getOrganizationMemberRoles(selector) { - const results = await pool.many(sql`/* getOrganizationMemberRoles */ - SELECT - id, name, description, scopes, locked, organization_id - FROM organization_member_roles - WHERE organization_id = ${selector.organizationId} - ORDER BY array_length(scopes, 1) DESC, name ASC - `); - - return results.map(role => OrganizationMemberRoleModel.parse(role)); - }, - async getOrganizationMemberRole(selector) { - const result = await pool.maybeOne<{ - members_count: number; - }>(sql`/* getOrganizationMemberRole */ - SELECT - id, name, description, scopes, locked, organization_id, - ( - SELECT count(*) - FROM organization_member - WHERE role_id = ${selector.roleId} AND organization_id = ${selector.organizationId} - ) AS members_count - FROM organization_member_roles - WHERE organization_id = ${selector.organizationId} AND id = ${selector.roleId} - LIMIT 1 - `); - - if (!result) { - return null; - } - - return { - ...OrganizationMemberRoleModel.parse(result), - membersCount: result.members_count, - }; - }, - hasOrganizationMemberRoleName({ organizationId, roleName, excludeRoleId }) { - return pool.exists(sql`/* hasOrganizationMemberRoleName */ - SELECT 1 - FROM organization_member_roles - WHERE - organization_id = ${organizationId} - AND - name = ${roleName} - ${excludeRoleId ? sql`AND id != ${excludeRoleId}` : sql``} - LIMIT 1 - `); - }, getOrganizationInvitations: batch(async selectors => { const organizations = selectors.map(s => s.organizationId); const allInvitations = await pool.query< @@ -1305,7 +1241,7 @@ export async function createStorage( return; } - const adminRole = await shared.getOrganizationMemberRoleByName( + const adminRoleId = await shared.getOrganizationMemberRoleByName( { organizationId: organization, roleName: 'Admin', @@ -1316,7 +1252,7 @@ export async function createStorage( // set admin role await tsx.query(sql`/* setAdminRole */ UPDATE organization_member - SET role_id = ${adminRole.id} + SET role_id = ${adminRoleId} WHERE organization_id = ${organization} AND user_id = ${user} `); @@ -1341,34 +1277,6 @@ export async function createStorage( `, ); }, - async createOrganizationMemberRole({ organizationId, name, scopes, description }) { - const role = await pool.one( - sql`/* createOrganizationMemberRole */ - INSERT INTO organization_member_roles - (organization_id, name, description, scopes) - VALUES - (${organizationId}, ${name}, ${description}, ${sql.array(scopes, 'text')}) - RETURNING * - `, - ); - - return OrganizationMemberRoleModel.parse(role); - }, - async updateOrganizationMemberRole({ organizationId, roleId, name, scopes, description }) { - const role = await pool.one( - sql`/* updateOrganizationMemberRole */ - UPDATE organization_member_roles - SET - name = ${name}, - description = ${description}, - scopes = ${sql.array(scopes, 'text')} - WHERE organization_id = ${organizationId} AND id = ${roleId} - RETURNING * - `, - ); - - return OrganizationMemberRoleModel.parse(role); - }, async assignOrganizationMemberRole({ userId, organizationId, roleId }) { await pool.query( sql`/* assignOrganizationMemberRole */ diff --git a/packages/web/app/src/components/layouts/organization.tsx b/packages/web/app/src/components/layouts/organization.tsx index 1ae0a56a0f..461b11a1c0 100644 --- a/packages/web/app/src/components/layouts/organization.tsx +++ b/packages/web/app/src/components/layouts/organization.tsx @@ -60,9 +60,6 @@ const OrganizationLayout_OrganizationFragment = graphql(` viewerCanDescribeBilling viewerCanAccessSettings viewerCanSeeMembers - me { - ...CanAccessOrganization_MemberFragment - } ...ProPlanBilling_OrganizationFragment ...RateLimitWarn_OrganizationFragment } diff --git a/packages/web/app/src/components/organization/Permissions.tsx b/packages/web/app/src/components/organization/Permissions.tsx index 69a34c61bd..97fcb5b4a1 100644 --- a/packages/web/app/src/components/organization/Permissions.tsx +++ b/packages/web/app/src/components/organization/Permissions.tsx @@ -1,4 +1,3 @@ -import { memo, ReactElement, useCallback, useEffect, useState } from 'react'; import clsx from 'clsx'; import { Select, @@ -7,27 +6,11 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { TabsContent } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { FragmentType, graphql, useFragment } from '@/gql'; import { OrganizationAccessScope, ProjectAccessScope, TargetAccessScope } from '@/gql/graphql'; import { NoAccess, Scope } from '@/lib/access/common'; -import { canAccessOrganization } from '@/lib/access/organization'; -import { canAccessProject } from '@/lib/access/project'; -import { canAccessTarget } from '@/lib/access/target'; import { truthy } from '@/utils'; -interface Props { - title: string; - scopes: readonly Scope[]; - initialScopes: readonly T[]; - selectedScopes: readonly T[]; - onChange: (scopes: T[]) => void; - checkAccess: (scope: T) => boolean; - noDowngrade?: boolean; - disabled?: boolean; -} - function isLowerThen(targetScope: T, sourceScope: T, scopesInLowerToHigherOrder: readonly T[]) { const sourceIndex = scopesInLowerToHigherOrder.indexOf(sourceScope); const targetIndex = scopesInLowerToHigherOrder.indexOf(targetScope); @@ -35,38 +18,6 @@ function isLowerThen(targetScope: T, sourceScope: T, scopesInLowerToHigherOrd return targetIndex < sourceIndex; } -function matchScope( - list: readonly T[], - defaultValue: TDefault, - lowerPriority?: T, - higherPriority?: T, -) { - let hasHigher = false; - let hasLower = false; - - for (const item of list) { - if (item === higherPriority) { - hasHigher = true; - } else if (item === lowerPriority) { - hasLower = true; - } - } - - if (hasHigher) { - return higherPriority; - } - - if (hasLower) { - return lowerPriority; - } - - return defaultValue; -} - -function isDefined(value: T | null | undefined): value is T { - return value !== undefined && value !== null; -} - export const PermissionScopeItem = < T extends OrganizationAccessScope | ProjectAccessScope | TargetAccessScope, >(props: { @@ -161,155 +112,3 @@ export const PermissionScopeItem = < ); }; - -function PermissionsSpaceInner(props: Props): ReactElement; -function PermissionsSpaceInner(props: Props): ReactElement; -function PermissionsSpaceInner(props: Props): ReactElement; -function PermissionsSpaceInner< - T extends OrganizationAccessScope | ProjectAccessScope | TargetAccessScope, ->(props: Props) { - const { title, scopes, initialScopes, selectedScopes, onChange, checkAccess, disabled } = props; - - return ( - - {scopes.map(scope => { - const possibleScope = [scope.mapping['read-only'], scope.mapping['read-write']].filter( - isDefined, - ); - const readOnlyScope = scope.mapping['read-only']; - const hasReadOnly = typeof readOnlyScope !== 'undefined'; - - return ( - - disabled={disabled} - scope={scope} - key={scope.name} - initialScope={matchScope( - initialScopes, - NoAccess, - scope.mapping['read-only'], - scope.mapping['read-write'], - )} - selectedScope={matchScope( - selectedScopes, - NoAccess, - scope.mapping['read-only'], - scope.mapping['read-write'], - )} - checkAccess={checkAccess} - possibleScope={possibleScope} - canManageScope={possibleScope.some(checkAccess)} - noDowngrade={props.noDowngrade} - onChange={value => { - if (value === NoAccess) { - // Remove all possible scopes - onChange(selectedScopes.filter(scope => !possibleScope.includes(scope))); - return; - } - const isReadWrite = value === scope.mapping['read-write']; - - // Remove possible scopes - const newScopes = selectedScopes.filter(scope => !possibleScope.includes(scope)); - - if (isReadWrite) { - newScopes.push(scope.mapping['read-write']); - - if (hasReadOnly) { - // Include read-only as well - newScopes.push(readOnlyScope); - } - } else if (readOnlyScope) { - // just read-only - newScopes.push(readOnlyScope); - } - - props.onChange(newScopes); - }} - /> - ); - })} - - ); -} - -export const PermissionsSpace = memo( - PermissionsSpaceInner, -) as unknown as typeof PermissionsSpaceInner; - -const UsePermissionManager_OrganizationFragment = graphql(` - fragment UsePermissionManager_OrganizationFragment on Organization { - slug - me { - ...CanAccessOrganization_MemberFragment - ...CanAccessProject_MemberFragment - ...CanAccessTarget_MemberFragment - } - } -`); - -const UsePermissionManager_MemberFragment = graphql(` - fragment UsePermissionManager_MemberFragment on Member { - id - user { - id - } - targetAccessScopes - projectAccessScopes - organizationAccessScopes - } -`); - -export function usePermissionsManager({ - passMemberScopes, - ...props -}: { - organization: FragmentType; - member: FragmentType; - passMemberScopes: boolean; -}) { - const member = useFragment(UsePermissionManager_MemberFragment, props.member); - const organization = useFragment(UsePermissionManager_OrganizationFragment, props.organization); - - const [targetScopes, setTargetScopes] = useState( - passMemberScopes ? member.targetAccessScopes : [], - ); - const [projectScopes, setProjectScopes] = useState( - passMemberScopes ? member.projectAccessScopes : [], - ); - const [organizationScopes, setOrganizationScopes] = useState( - passMemberScopes ? member.organizationAccessScopes : [], - ); - - useEffect(() => { - if (passMemberScopes) { - setTargetScopes(member.targetAccessScopes); - setProjectScopes(member.projectAccessScopes); - setOrganizationScopes(member.organizationAccessScopes); - } - }, [member, passMemberScopes, setTargetScopes, setProjectScopes, setOrganizationScopes]); - - return { - // Set - setOrganizationScopes, - setProjectScopes, - setTargetScopes, - // Get - organizationScopes, - projectScopes, - targetScopes, - noneSelected: !organizationScopes.length && !projectScopes.length && !targetScopes.length, - // Methods - canAccessOrganization: useCallback( - (scope: OrganizationAccessScope) => canAccessOrganization(scope, organization.me), - [organization], - ), - canAccessProject: useCallback( - (scope: ProjectAccessScope) => canAccessProject(scope, organization.me), - [organization], - ), - canAccessTarget: useCallback( - (scope: TargetAccessScope) => canAccessTarget(scope, organization.me), - [organization], - ), - }; -} diff --git a/packages/web/app/src/components/organization/members/list.tsx b/packages/web/app/src/components/organization/members/list.tsx index ea15bd8fd0..e7d3f17e03 100644 --- a/packages/web/app/src/components/organization/members/list.tsx +++ b/packages/web/app/src/components/organization/members/list.tsx @@ -62,13 +62,6 @@ const OrganizationMemberRoleSwitcher_OrganizationFragment = graphql(` id slug viewerCanAssignUserRoles - me { - id - isAdmin - organizationAccessScopes - projectAccessScopes - targetAccessScopes - } owner { id } @@ -77,9 +70,6 @@ const OrganizationMemberRoleSwitcher_OrganizationFragment = graphql(` name description locked - organizationAccessScopes - projectAccessScopes - targetAccessScopes } } `); @@ -87,9 +77,6 @@ const OrganizationMemberRoleSwitcher_OrganizationFragment = graphql(` const OrganizationMemberRoleSwitcher_MemberFragment = graphql(` fragment OrganizationMemberRoleSwitcher_MemberFragment on Member { id - organizationAccessScopes - projectAccessScopes - targetAccessScopes user { id } @@ -108,11 +95,8 @@ function OrganizationMemberRoleSwitcher(props: { props.organization, ); const member = useFragment(OrganizationMemberRoleSwitcher_MemberFragment, props.member); - const { me } = organization; - const isOwner = props.memberId === organization.owner.id; - const isMe = props.memberId === me.id; // A user can't change its own role - const canAssignRole = !isOwner && !isMe && organization.viewerCanAssignUserRoles; + const canAssignRole = organization.viewerCanAssignUserRoles; const roles = organization.memberRoles ?? []; const { toast } = useToast(); const [assignRoleState, assignRole] = useMutation( @@ -170,38 +154,6 @@ function OrganizationMemberRoleSwitcher(props: { disabled={!canAssignRole || assignRoleState.fetching} isRoleActive={role => { const isCurrentRole = role.id === props.memberRoleId; - const canDowngrade = me.isAdmin; - const hasAccessToScopesOfRole = - role.organizationAccessScopes.every(scope => - me.organizationAccessScopes.includes(scope), - ) && - role.projectAccessScopes.every(scope => me.projectAccessScopes.includes(scope)) && - role.targetAccessScopes.every(scope => me.targetAccessScopes.includes(scope)); - // If the new role has more or equal access scopes than the current role, we can assign it - const newRoleHasMoreOrEqualAccess = - // organization - role.organizationAccessScopes.length >= memberRole.organizationAccessScopes.length && - role.organizationAccessScopes.every(scope => - memberRole.organizationAccessScopes.includes(scope), - ) && - // project - role.projectAccessScopes.length >= memberRole.projectAccessScopes.length && - role.projectAccessScopes.every(scope => - memberRole.projectAccessScopes.includes(scope), - ) && - // target - role.targetAccessScopes.length >= memberRole.targetAccessScopes.length && - role.targetAccessScopes.every(scope => memberRole.targetAccessScopes.includes(scope)); - const canAssign = - (hasAccessToScopesOfRole && newRoleHasMoreOrEqualAccess) || canDowngrade; - // - // A new role can be assigned to the member if: - // - the member is not the owner - // - the member is not the current user - // - the current user has access to all the access scopes of the new role - // - the new role has more or equal access scopes than the current role (or the current user is an admin) - // - the new role is not the current role - // if (isCurrentRole) { return { @@ -210,27 +162,6 @@ function OrganizationMemberRoleSwitcher(props: { }; } - if (canAssign) { - return { - active: true, - }; - } - - if (!hasAccessToScopesOfRole) { - return { - active: false, - reason: 'You do not have enough access to assign this role', - }; - } - - if (!newRoleHasMoreOrEqualAccess) { - return { - active: false, - reason: - 'The member will experience a downgrade as this role lacks certain permissions they currently possess.', - }; - } - return { active: false, }; diff --git a/packages/web/app/src/components/organization/members/roles.tsx b/packages/web/app/src/components/organization/members/roles.tsx index f96fe41384..b3a17c21c3 100644 --- a/packages/web/app/src/components/organization/members/roles.tsx +++ b/packages/web/app/src/components/organization/members/roles.tsx @@ -3,7 +3,6 @@ import { LockIcon, MoreHorizontalIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { useMutation } from 'urql'; import { z } from 'zod'; -import { PermissionsSpace } from '@/components/organization/Permissions'; import { AlertDialog, AlertDialogAction, @@ -16,6 +15,7 @@ import { } from '@/components/ui/alert-dialog'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, @@ -41,15 +41,14 @@ import { } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout'; -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useToast } from '@/components/ui/use-toast'; import { FragmentType, graphql, useFragment } from '@/gql'; -import { OrganizationAccessScope, ProjectAccessScope, TargetAccessScope } from '@/gql/graphql'; -import { scopes } from '@/lib/access/common'; import { zodResolver } from '@hookform/resolvers/zod'; import { Link } from '@tanstack/react-router'; +import { PermissionSelector } from './permission-selector'; +import { SelectedPermissionOverview } from './selected-permission-overview'; export const roleFormSchema = z.object({ name: z @@ -71,17 +70,11 @@ export const roleFormSchema = z.object({ .trim() .min(2, 'Too short') .max(256, 'Description is too long'), - organizationScopes: z.array(z.string()), - projectScopes: z.array(z.string()), - targetScopes: z.array(z.string()), + selectedPermissions: z.array(z.string()), }); type RoleFormValues = z.infer; -function canAccessScope(scope: T, currentUserScopes: readonly T[]) { - return currentUserScopes.includes(scope); -} - const OrganizationMemberRoleEditor_UpdateMemberRoleMutation = graphql(` mutation OrganizationMemberRoleEditor_UpdateMemberRoleMutation($input: UpdateMemberRoleInput!) { updateMemberRole(input: $input) { @@ -102,25 +95,25 @@ const OrganizationMemberRoleEditor_UpdateMemberRoleMutation = graphql(` } `); -const OrganizationMemberRoleEditor_MeFragment = graphql(` - fragment OrganizationMemberRoleEditor_MeFragment on Member { +const OrganizationMemberRoleEditor_OrganizationFragment = graphql(` + fragment OrganizationMemberRoleEditor_OrganizationFragment on Organization { id - isAdmin - organizationAccessScopes - projectAccessScopes - targetAccessScopes + slug + ...PermissionSelector_OrganizationFragment } `); function OrganizationMemberRoleEditor(props: { mode?: 'edit' | 'read-only'; close(): void; - organizationSlug: string; - me: FragmentType; role: FragmentType; + organization: FragmentType; }) { - const me = useFragment(OrganizationMemberRoleEditor_MeFragment, props.me); const role = useFragment(OrganizationMemberRoleRow_MemberRoleFragment, props.role); + const organization = useFragment( + OrganizationMemberRoleEditor_OrganizationFragment, + props.organization, + ); const [updateMemberRoleState, updateMemberRole] = useMutation( OrganizationMemberRoleEditor_UpdateMemberRoleMutation, ); @@ -132,70 +125,29 @@ function OrganizationMemberRoleEditor(props: { defaultValues: { name: role.name, description: role.description, - organizationScopes: [...role.organizationAccessScopes], - projectScopes: [...role.projectAccessScopes], - targetScopes: [...role.targetAccessScopes], + selectedPermissions: [...role.permissions], }, disabled: isDisabled, }); - const initialScopes = { - organization: [...role.organizationAccessScopes], - project: [...role.projectAccessScopes], - target: [...role.targetAccessScopes], - }; - - const [targetScopes, setTargetScopes] = useState([ - ...role.targetAccessScopes, - ]); - const [projectScopes, setProjectScopes] = useState([ - ...role.projectAccessScopes, - ]); - const [organizationScopes, setOrganizationScopes] = useState([ - ...role.organizationAccessScopes, - ]); - - const updateTargetScopes = useCallback( - (scopes: TargetAccessScope[]) => { - setTargetScopes(scopes); - form.setValue('targetScopes', [...scopes]); - }, - [targetScopes], - ); - - const updateProjectScopes = useCallback( - (scopes: ProjectAccessScope[]) => { - setProjectScopes(scopes); - form.setValue('projectScopes', [...scopes]); - }, - [projectScopes], + const [selectedPermissions, setSelectedPermissions] = useState>( + () => new Set(role.permissions), ); - const updateOrganizationScopes = useCallback( - (scopes: OrganizationAccessScope[]) => { - setOrganizationScopes(scopes); - form.setValue('organizationScopes', [...scopes]); - }, - [organizationScopes], - ); + const onChangeSelectedPermissions = useCallback((permissions: ReadonlySet) => { + setSelectedPermissions(new Set(permissions)); + form.setValue('selectedPermissions', [...permissions]); + }, []); async function onSubmit(data: RoleFormValues) { try { const result = await updateMemberRole({ input: { - organizationSlug: props.organizationSlug, + organizationSlug: organization.slug, roleId: role.id, name: data.name, description: data.description, - organizationAccessScopes: data.organizationScopes.filter(scope => - Object.values(OrganizationAccessScope).includes(scope as OrganizationAccessScope), - ) as OrganizationAccessScope[], - projectAccessScopes: data.projectScopes.filter(scope => - Object.values(ProjectAccessScope).includes(scope as ProjectAccessScope), - ) as ProjectAccessScope[], - targetAccessScopes: data.targetScopes.filter(scope => - Object.values(TargetAccessScope).includes(scope as TargetAccessScope), - ) as TargetAccessScope[], + selectedPermissions: data.selectedPermissions, }, }); @@ -238,30 +190,13 @@ function OrganizationMemberRoleEditor(props: { } } - const hasMembers = role.membersCount > 0; - const { isAdmin } = me; - const noDowngrade = hasMembers && !isAdmin; - return (
Member Role{props.mode === 'read-only' ? '' : ' Editor'} - - {isAdmin ? ( - 'As an admin, you can add or remove permissions from the role.' - ) : hasMembers ? ( - <> - This role is assigned to at least one member. -
- You can only add permissions to the role,{' '} - you cannot downgrade its members. - - ) : ( - 'You can add or remove permissions from the role as it has no members.' - )} -
+ Adjust the permissions of this role.
@@ -293,45 +228,15 @@ function OrganizationMemberRoleEditor(props: { />
-
+
Permissions - - - Organization - Projects - Targets - - canAccessScope(scope, me.organizationAccessScopes)} - noDowngrade={noDowngrade} - /> - canAccessScope(scope, me.projectAccessScopes)} - noDowngrade={noDowngrade} - /> - canAccessScope(scope, me.targetAccessScopes)} - noDowngrade={noDowngrade} +
+ - +
@@ -347,11 +252,7 @@ function OrganizationMemberRoleEditor(props: { !form.formState.isValid || form.formState.isSubmitting || form.formState.disabled } > - {form.formState.isSubmitting - ? 'Creating...' - : targetScopes.length + projectScopes.length + organizationScopes.length === 0 - ? 'Submit a read-only role' - : 'Submit'} + {form.formState.isSubmitting ? 'Creating...' : 'Confirm selection'} )} @@ -384,21 +285,23 @@ const OrganizationMemberRoleCreator_CreateMemberRoleMutation = graphql(` } `); -const OrganizationMemberRoleCreator_MeFragment = graphql(` - fragment OrganizationMemberRoleCreator_MeFragment on Member { +const OrganizationMemberRoleCreator_OrganizationFragment = graphql(` + fragment OrganizationMemberRoleCreator_OrganizationFragment on Organization { id - organizationAccessScopes - projectAccessScopes - targetAccessScopes + slug + ...PermissionSelector_OrganizationFragment + ...SelectedPermissionOverview_OrganizationFragment } `); function OrganizationMemberRoleCreator(props: { close(): void; - organizationSlug: string; - me: FragmentType; + organization: FragmentType; }) { - const me = useFragment(OrganizationMemberRoleCreator_MeFragment, props.me); + const organization = useFragment( + OrganizationMemberRoleCreator_OrganizationFragment, + props.organization, + ); const [createMemberRoleState, createMemberRole] = useMutation( OrganizationMemberRoleCreator_CreateMemberRoleMutation, ); @@ -409,57 +312,29 @@ function OrganizationMemberRoleCreator(props: { defaultValues: { name: '', description: '', - organizationScopes: [], - projectScopes: [], - targetScopes: [], + selectedPermissions: [], }, disabled: createMemberRoleState.fetching, }); - const [targetScopes, setTargetScopes] = useState([]); - const [projectScopes, setProjectScopes] = useState([]); - const [organizationScopes, setOrganizationScopes] = useState([]); + const [selectedPermissions, setSelectedPermissions] = useState(() => new Set()); - const updateTargetScopes = useCallback( - (scopes: TargetAccessScope[]) => { - setTargetScopes(scopes); - form.setValue('targetScopes', [...scopes]); - }, - [targetScopes], - ); - - const updateProjectScopes = useCallback( - (scopes: ProjectAccessScope[]) => { - setProjectScopes(scopes); - form.setValue('projectScopes', [...scopes]); - }, - [projectScopes], - ); + const onChangeSelectedPermissions = useCallback((permissions: ReadonlySet) => { + setSelectedPermissions(new Set(permissions)); + form.setValue('selectedPermissions', [...selectedPermissions]); + }, []); - const updateOrganizationScopes = useCallback( - (scopes: OrganizationAccessScope[]) => { - setOrganizationScopes(scopes); - form.setValue('organizationScopes', [...scopes]); - }, - [organizationScopes], - ); + const [showOnlyGrantedPermissions, setShowOnlyGrantedPermissions] = useState(true); + const [state, setState] = useState('select' as 'select' | 'confirm'); async function onSubmit(data: RoleFormValues) { try { const result = await createMemberRole({ input: { - organizationSlug: props.organizationSlug, + organizationSlug: organization.slug, name: data.name, description: data.description, - organizationAccessScopes: data.organizationScopes.filter(scope => - Object.values(OrganizationAccessScope).includes(scope as OrganizationAccessScope), - ) as OrganizationAccessScope[], - projectAccessScopes: data.projectScopes.filter(scope => - Object.values(ProjectAccessScope).includes(scope as ProjectAccessScope), - ) as ProjectAccessScope[], - targetAccessScopes: data.targetScopes.filter(scope => - Object.values(TargetAccessScope).includes(scope as TargetAccessScope), - ) as TargetAccessScope[], + selectedPermissions: data.selectedPermissions, }, }); @@ -504,7 +379,7 @@ function OrganizationMemberRoleCreator(props: { return ( - + Member Role Creator @@ -512,89 +387,114 @@ function OrganizationMemberRoleCreator(props: { Create a new role that can be assigned to members of this organization. -
-
- ( - - Name - - - - - - )} - /> - ( - - Description - -