From b7e4052ecfd8f70fefe39c27886619a24faa7526 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 29 Jan 2025 10:31:47 +0100 Subject: [PATCH] feat: granular permission assignment for organization members (#6231) --- .changeset/tall-islands-occur.md | 16 + integration-tests/testkit/flow.ts | 31 +- integration-tests/testkit/seed.ts | 30 +- .../tests/api/organization/members.spec.ts | 338 ++------ .../tests/api/organization/transfer.spec.ts | 21 +- .../tests/api/project/crud.spec.ts | 63 +- .../tests/api/schema/check.spec.ts | 346 +++++++- .../tests/api/target/crud.spec.ts | 8 +- ...-00-00.granular-member-role-permissions.ts | 15 + packages/migrations/src/run-pg-migrations.ts | 1 + packages/services/api/src/index.ts | 7 +- .../providers/app-deployments-manager.ts | 24 - .../providers/app-deployments.ts | 2 +- .../app-deployments/resolvers/Target.ts | 13 +- .../api/src/modules/auth/lib/authz.ts | 186 ++++- .../modules/auth/lib/supertokens-strategy.ts | 308 +++---- .../auth/lib/target-access-token-strategy.ts | 1 - .../modules/auth/module.graphql.mappers.ts | 10 +- .../api/src/modules/auth/module.graphql.ts | 38 +- .../modules/auth/providers/auth-manager.ts | 2 - .../api/src/modules/auth/resolvers/Member.ts | 32 - .../src/modules/auth/resolvers/Permission.ts | 26 + .../modules/auth/resolvers/PermissionGroup.ts | 14 + .../providers/oidc-integrations.provider.ts | 19 +- .../resolvers/OIDCIntegration.ts | 18 +- .../api/src/modules/organization/index.ts | 4 +- .../lib/organization-member-permissions.ts | 311 +++++++ .../organization/module.graphql.mappers.ts | 5 +- .../modules/organization/module.graphql.ts | 152 +++- .../providers/organization-config.ts | 20 - .../providers/organization-manager.ts | 598 ++++---------- .../providers/organization-member-roles.ts | 344 ++++++++ .../providers/organization-member.spec.ts | 541 ++++++++++++ .../providers/organization-members.ts | 703 ++++++++++++++++ .../modules/organization/resolvers/Member.ts | 37 +- .../resolvers/MemberConnection.ts | 2 +- .../organization/resolvers/MemberRole.ts | 83 +- .../resolvers/Mutation/assignMemberRole.ts | 6 +- .../resolvers/Mutation/createMemberRole.ts | 29 +- .../resolvers/Mutation/updateMemberRole.ts | 30 +- .../organization/resolvers/Organization.ts | 46 +- .../resolvers/OrganizationInvitation.ts | 8 + .../schema/providers/schema-version-helper.ts | 4 +- .../src/modules/shared/providers/storage.ts | 44 +- .../support/providers/support-manager.ts | 24 +- .../modules/token/providers/token-manager.ts | 27 +- packages/services/api/src/shared/entities.ts | 26 +- packages/services/api/src/shared/helpers.ts | 13 + packages/services/server/src/index.ts | 16 +- packages/services/storage/src/db/types.ts | 4 +- packages/services/storage/src/index.ts | 237 +++--- .../src/components/layouts/organization.tsx | 3 - .../components/organization/Permissions.tsx | 201 ----- .../organization/members/common.tsx | 3 +- .../components/organization/members/list.tsx | 308 ++----- .../members/member-role-picker.tsx | 238 ++++++ .../members/member-role-selector.tsx | 75 ++ .../members/permission-selector.tsx | 250 ++++++ .../members/resource-selector.tsx | 776 ++++++++++++++++++ .../components/organization/members/roles.tsx | 535 ++++++------ .../members/selected-permission-overview.tsx | 208 +++++ .../target/settings/registry-access-token.tsx | 2 +- packages/web/app/src/lib/access/common.ts | 95 +-- .../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 | 1 + 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 +- .../stories/permission-selector.stories.tsx | 54 ++ .../selected-permission-overview.stories.tsx | 57 ++ packages/web/app/src/stories/utils.ts | 338 ++++++++ 75 files changed, 5718 insertions(+), 2556 deletions(-) create mode 100644 .changeset/tall-islands-occur.md 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/resolvers/Member.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 create mode 100644 packages/services/api/src/modules/organization/lib/organization-member-permissions.ts 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-member.spec.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%) create mode 100644 packages/web/app/src/components/organization/members/member-role-picker.tsx create mode 100644 packages/web/app/src/components/organization/members/member-role-selector.tsx create mode 100644 packages/web/app/src/components/organization/members/permission-selector.tsx create mode 100644 packages/web/app/src/components/organization/members/resource-selector.tsx create mode 100644 packages/web/app/src/components/organization/members/selected-permission-overview.tsx 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 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/.changeset/tall-islands-occur.md b/.changeset/tall-islands-occur.md new file mode 100644 index 0000000000..d0ab6dd71e --- /dev/null +++ b/.changeset/tall-islands-occur.md @@ -0,0 +1,16 @@ +--- +'hive': major +--- + +Introduce new permission system for organization member roles. + +The existing scopes assigned to organization member users are now replaced with permissions. +Using the permissions allows more granular access control to features in Hive. + +This introduces the following breaking changes: + +- Organization members with the default `Viewer` role, will experience downgraded permissions. They will no longer be able to create targets or projects. +- Organization member roles permissions for inviting, removing or assigning roles have been revoked. A organization admin will have to re-apply the permissions to the desired member roles. +- Organization members with permissions for managing invites, removing members, assigning roles or modifying roles are no longer restrained in granting more rights to other users. Please be aware when granting these permissions to a user role. We recommend only assigning these to member roles that are considered "Admin" user roles. + +A future update will introduce resource based access control (based on project, target, service or app deployments) for organization members. diff --git a/integration-tests/testkit/flow.ts b/integration-tests/testkit/flow.ts index 07710d6818..d41251f549 100644 --- a/integration-tests/testkit/flow.ts +++ b/integration-tests/testkit/flow.ts @@ -50,9 +50,10 @@ export function createOrganization(input: CreateOrganizationInput, authToken: st slug owner { id - organizationAccessScopes - projectAccessScopes - targetAccessScopes + role { + id + permissions + } } memberRoles { id @@ -178,9 +179,11 @@ export function joinOrganization(code: string, authToken: string) { user { id } - organizationAccessScopes - projectAccessScopes - targetAccessScopes + role { + id + name + permissions + } } } } @@ -213,10 +216,8 @@ export function getOrganizationMembers(selector: OrganizationSelectorInput, auth role { id name + permissions } - organizationAccessScopes - projectAccessScopes - targetAccessScopes } } } @@ -664,9 +665,7 @@ export function createMemberRole(input: CreateMemberRoleInput, authToken: string name description locked - organizationAccessScopes - projectAccessScopes - targetAccessScopes + permissions } } } @@ -724,9 +723,7 @@ export function deleteMemberRole(input: DeleteMemberRoleInput, authToken: string name description locked - organizationAccessScopes - projectAccessScopes - targetAccessScopes + permissions } } } @@ -754,9 +751,7 @@ export function updateMemberRole(input: UpdateMemberRoleInput, authToken: string name description locked - organizationAccessScopes - projectAccessScopes - targetAccessScopes + permissions } } error { diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index 34f306e94d..d48dc568dc 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -48,6 +48,7 @@ import { updateMemberRole, updateTargetValidationSettings, } from './flow'; +import * as GraphQLSchema from './gql/graphql'; import { BreakingChangeFormula, OrganizationAccessScope, @@ -185,10 +186,10 @@ export function initSeed() { return members; }, - async projects() { + async projects(token = ownerToken) { const projectsResult = await getOrganizationProjects( { organizationSlug: organization.slug }, - ownerToken, + token, ).then(r => r.expectNoGraphQLErrors()); const projects = projectsResult.organization?.organization.projects.nodes; @@ -806,6 +807,7 @@ export function initSeed() { input: { roleId: string; userId: string; + resources?: GraphQLSchema.ResourceAssignmentInput; }, options: { useMemberToken?: boolean } = { useMemberToken: false, @@ -816,6 +818,10 @@ export function initSeed() { organizationSlug: organization.slug, userId: input.userId, roleId: input.roleId, + resources: input.resources ?? { + mode: GraphQLSchema.ResourceAssignmentMode.All, + projects: [], + }, }, options.useMemberToken ? memberToken : ownerToken, ).then(r => r.expectNoGraphQLErrors()); @@ -847,11 +853,7 @@ export function initSeed() { return memberRoleDeletionResult.deleteMemberRole.ok?.updatedOrganization; }, async createMemberRole( - scopes: { - organization: OrganizationAccessScope[]; - project: ProjectAccessScope[]; - target: TargetAccessScope[]; - }, + permissions: Array, options: { useMemberToken?: boolean } = { useMemberToken: false, }, @@ -867,9 +869,7 @@ export function initSeed() { organizationSlug: organization.slug, name, description: 'some description', - organizationAccessScopes: scopes.organization, - projectAccessScopes: scopes.project, - targetAccessScopes: scopes.target, + selectedPermissions: permissions, }, options.useMemberToken ? memberToken : ownerToken, ).then(r => r.expectNoGraphQLErrors()); @@ -908,11 +908,7 @@ export function initSeed() { name: string; description: string; }, - scopes: { - organization: OrganizationAccessScope[]; - project: ProjectAccessScope[]; - target: TargetAccessScope[]; - }, + permissions: Array, options: { useMemberToken?: boolean } = { useMemberToken: false, }, @@ -923,9 +919,7 @@ export function initSeed() { roleId: role.id, name: role.name, description: role.description, - organizationAccessScopes: scopes.organization, - projectAccessScopes: scopes.project, - targetAccessScopes: scopes.target, + selectedPermissions: permissions, }, options.useMemberToken ? memberToken : ownerToken, ).then(r => r.expectNoGraphQLErrors()); diff --git a/integration-tests/tests/api/organization/members.spec.ts b/integration-tests/tests/api/organization/members.spec.ts index 6b0f3ced84..2d359fa018 100644 --- a/integration-tests/tests/api/organization/members.spec.ts +++ b/integration-tests/tests/api/organization/members.spec.ts @@ -1,8 +1,3 @@ -import { - OrganizationAccessScope, - ProjectAccessScope, - TargetAccessScope, -} from 'testkit/gql/graphql'; import { history } from '../../../testkit/emails'; import { initSeed } from '../../../testkit/seed'; @@ -10,17 +5,38 @@ test.concurrent('owner of an organization should have all scopes', async ({ expe const { createOrg } = await initSeed().createOwner(); const { organization } = await createOrg(); - Object.values(OrganizationAccessScope).forEach(scope => { - expect(organization.owner.organizationAccessScopes).toContain(scope); - }); - - Object.values(ProjectAccessScope).forEach(scope => { - expect(organization.owner.projectAccessScopes).toContain(scope); - }); - - Object.values(TargetAccessScope).forEach(scope => { - expect(organization.owner.targetAccessScopes).toContain(scope); - }); + expect(organization.owner.role.permissions).toMatchInlineSnapshot(` + [ + organization:describe, + support:manageTickets, + organization:modifySlug, + auditLog:export, + organization:delete, + member:describe, + member:modify, + billing:describe, + billing:update, + oidc:modify, + gitHubIntegration:modify, + slackIntegration:modify, + project:create, + schemaLinting:modifyOrganizationRules, + project:describe, + project:delete, + project:modifySettings, + schemaLinting:modifyProjectRules, + target:create, + alert:modify, + target:delete, + target:modifySettings, + targetAccessToken:modify, + cdnAccessToken:modify, + laboratory:describe, + laboratory:modify, + laboratory:modifyPreflightScript, + schemaCheck:approve, + ] + `); }); test.concurrent('invited member should have basic scopes (Viewer role)', async ({ expect }) => { @@ -28,230 +44,32 @@ test.concurrent('invited member should have basic scopes (Viewer role)', async ( const { inviteAndJoinMember } = await createOrg(); const { member } = await inviteAndJoinMember(); - // Should have only organization:read access - expect(member.organizationAccessScopes).toContainEqual(OrganizationAccessScope.Read); - // Nothing more - expect(member.organizationAccessScopes).toHaveLength(1); - // Should have only project:read and project:operations-store:read access - expect(member.projectAccessScopes).toContainEqual(ProjectAccessScope.Read); - expect(member.projectAccessScopes).toContainEqual(ProjectAccessScope.OperationsStoreRead); - // Nothing more - expect(member.projectAccessScopes).toHaveLength(2); - // Should have only target:read and target:registry:read access - expect(member.targetAccessScopes).toContainEqual(TargetAccessScope.Read); - expect(member.targetAccessScopes).toContainEqual(TargetAccessScope.RegistryRead); - // Nothing more - expect(member.targetAccessScopes).toHaveLength(2); + expect(member.role.permissions).toMatchInlineSnapshot(` + [ + organization:describe, + support:manageTickets, + project:describe, + laboratory:describe, + ] + `); }); test.concurrent( - 'cannot create a role with an access scope that user has no access to', - async ({ expect }) => { - const { createOrg } = await initSeed().createOwner(); - const { inviteAndJoinMember, organization } = await createOrg(); - const { createMemberRole, assignMemberRole, member } = await inviteAndJoinMember(); - - // Create a role with organization:members access, so we could perform the test. - // To create a role, user must have access to organization:members first. - const membersManagerRole = await createMemberRole({ - organization: [OrganizationAccessScope.Members], - project: [], - target: [], - }); - await assignMemberRole({ - roleId: membersManagerRole.id, - userId: member.user.id, - }); - - await expect( - createMemberRole( - { - organization: [OrganizationAccessScope.Settings], // <-- this scope is not part of the membersManagerRole, so it should fail - project: [], - target: [], - }, - { - useMemberToken: true, - }, - ), - ).rejects.toThrowError('Missing access'); - }, -); - -test.concurrent( - 'cannot grant an access scope to another user if user has no access to that scope', - async ({ expect }) => { - const { createOrg } = await initSeed().createOwner(); - const { inviteAndJoinMember, organization } = await createOrg(); - const { createMemberRole, assignMemberRole, member } = await inviteAndJoinMember(); - const { member: viewerRoleMember } = await inviteAndJoinMember(); - - // Create a role with organization:members access, so we could perform the test. - // To create a role, user must have access to organization:members first. - const membersManagerRole = await createMemberRole({ - organization: [OrganizationAccessScope.Members], - project: [], - target: [], - }); - await assignMemberRole({ - roleId: membersManagerRole.id, - userId: member.user.id, - }); - - const adminRoleId = organization.memberRoles?.find(r => r.name === 'Admin')?.id; - - if (!adminRoleId) { - throw new Error('Admin role not found'); - } - - await expect( - assignMemberRole( - { - roleId: adminRoleId, - userId: viewerRoleMember.user.id, - }, - { - useMemberToken: true, - }, - ), - ).rejects.toThrowError('Missing access'); - }, -); - -test.concurrent( - 'granting no scopes is equal to setting read-only for org, project and target', + 'granting no permissions is equal to setting read-only access for the organization', async ({ expect }) => { const { createOrg } = await initSeed().createOwner(); const { inviteAndJoinMember } = await createOrg(); const { createMemberRole } = await inviteAndJoinMember(); - const readOnlyRole = await createMemberRole({ - organization: [], - project: [], - target: [], - }); - expect(readOnlyRole?.organizationAccessScopes).toHaveLength(1); - expect(readOnlyRole?.organizationAccessScopes).toContainEqual(OrganizationAccessScope.Read); - expect(readOnlyRole?.projectAccessScopes).toHaveLength(1); - expect(readOnlyRole?.projectAccessScopes).toContainEqual(ProjectAccessScope.Read); - expect(readOnlyRole?.targetAccessScopes).toHaveLength(1); - expect(readOnlyRole?.targetAccessScopes).toContainEqual(TargetAccessScope.Read); + const readOnlyRole = await createMemberRole([]); + expect(readOnlyRole.permissions).toMatchInlineSnapshot(` + [ + organization:describe, + ] + `); }, ); -test.concurrent('cannot downgrade a member when assigning a new role', async ({ expect }) => { - const { createOrg } = await initSeed().createOwner(); - const { inviteAndJoinMember } = await createOrg(); - const { createMemberRole, assignMemberRole, member } = await inviteAndJoinMember(); - const { member: viewerRoleMember } = await inviteAndJoinMember(); - - const managerRole = await createMemberRole({ - organization: [OrganizationAccessScope.Members], - project: [ProjectAccessScope.Settings], - target: [ - TargetAccessScope.RegistryRead, - TargetAccessScope.RegistryWrite, - TargetAccessScope.Settings, - ], - }); - const originalRole = await createMemberRole({ - organization: [], - project: [], - target: [ - TargetAccessScope.RegistryRead, - TargetAccessScope.RegistryWrite, - TargetAccessScope.Settings, - ], - }); - const roleWithLessAccess = await createMemberRole({ - organization: [], - project: [], - target: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite], - }); - await assignMemberRole({ - roleId: managerRole.id, - userId: member.user.id, - }); - await assignMemberRole({ - roleId: originalRole.id, - userId: viewerRoleMember.user.id, - }); - - // non-admin member cannot downgrade another member - await expect( - assignMemberRole( - { - roleId: roleWithLessAccess.id, - userId: viewerRoleMember.user.id, - }, - { - useMemberToken: true, - }, - ), - ).rejects.toThrowError('Cannot downgrade member'); - // admin can downgrade another member - await assignMemberRole({ - roleId: roleWithLessAccess.id, - userId: viewerRoleMember.user.id, - }); -}); - -test.concurrent('cannot downgrade a member when modifying a role', async ({ expect }) => { - const { createOrg } = await initSeed().createOwner(); - const { inviteAndJoinMember } = await createOrg(); - const { createMemberRole, assignMemberRole, member, updateMemberRole } = - await inviteAndJoinMember(); - const { member: viewerRoleMember } = await inviteAndJoinMember(); - - const managerRole = await createMemberRole({ - organization: [OrganizationAccessScope.Members], - project: [ProjectAccessScope.Settings], - target: [ - TargetAccessScope.RegistryRead, - TargetAccessScope.RegistryWrite, - TargetAccessScope.Settings, - ], - }); - const roleToBeUpdated = await createMemberRole({ - organization: [], - project: [], - target: [ - TargetAccessScope.RegistryRead, - TargetAccessScope.RegistryWrite, - TargetAccessScope.Settings, - ], - }); - await assignMemberRole({ - roleId: managerRole.id, - userId: member.user.id, - }); - await assignMemberRole({ - roleId: roleToBeUpdated.id, - userId: viewerRoleMember.user.id, - }); - - // non-admin member cannot downgrade another member - await expect( - updateMemberRole( - roleToBeUpdated, - { - organization: [], - project: [], - target: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite], // <-- downgrade (missing: TargetAccessScope.Settings) - }, - { - useMemberToken: true, - }, - ), - ).rejects.toThrowError('Cannot downgrade member'); - // admin can downgrade another member - await updateMemberRole(roleToBeUpdated, { - organization: [], - project: [], - target: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite], // <-- downgrade (missing: TargetAccessScope.Settings) - }); -}); - test.concurrent('cannot delete a role with members', async ({ expect }) => { const { createOrg } = await initSeed().createOwner(); const { inviteAndJoinMember } = await createOrg(); @@ -259,16 +77,8 @@ test.concurrent('cannot delete a role with members', async ({ expect }) => { await inviteAndJoinMember(); const { member: viewerRoleMember } = await inviteAndJoinMember(); - const membersManagerRole = await createMemberRole({ - organization: [OrganizationAccessScope.Members], - project: [], - target: [], - }); - const readOnlyRole = await createMemberRole({ - organization: [], - project: [], - target: [], - }); + const membersManagerRole = await createMemberRole([]); + const readOnlyRole = await createMemberRole([]); await assignMemberRole({ roleId: membersManagerRole.id, userId: member.user.id, @@ -282,56 +92,6 @@ test.concurrent('cannot delete a role with members', async ({ expect }) => { await expect(deleteMemberRole(readOnlyRole.id)).rejects.toThrowError( 'Cannot delete a role with members', ); - // delete the role as a member with enough access to delete the role - await expect( - deleteMemberRole(readOnlyRole.id, { - useMemberToken: true, - }), - ).rejects.toThrowError('Cannot delete a role with members'); -}); - -test.concurrent('cannot invite a member with more access than the inviter', async ({ expect }) => { - const seed = initSeed(); - const { createOrg } = await seed.createOwner(); - const { inviteMember, inviteAndJoinMember, organization } = await createOrg(); - const { - member: invitingMember, - memberToken: invitingMemberToken, - createMemberRole, - assignMemberRole, - } = await inviteAndJoinMember(); - - const adminRoleId = organization.memberRoles?.find(r => r.name === 'Admin')?.id; - if (!adminRoleId) { - throw new Error('Admin role not found'); - } - - const membersManagerRole = await createMemberRole({ - organization: [OrganizationAccessScope.Members], - project: [], - target: [], - }); - const readOnlyRole = await createMemberRole({ - organization: [], - project: [], - target: [], - }); - - // give the inviting member a role with enough access to invite other members - await assignMemberRole({ - roleId: membersManagerRole.id, - userId: invitingMember.user.id, - }); - - const inviteEmail = seed.generateEmail(); - const failedInvitationResult = await inviteMember(inviteEmail, invitingMemberToken, adminRoleId); - expect(failedInvitationResult.error?.message).toEqual( - expect.stringContaining('Not enough access to invite a member'), - ); - - const invitationResult = await inviteMember(inviteEmail, invitingMemberToken, readOnlyRole.id); - const inviteCode = invitationResult.ok?.code; - expect(inviteCode).toBeDefined(); }); test.concurrent('email invitation', async ({ expect }) => { diff --git a/integration-tests/tests/api/organization/transfer.spec.ts b/integration-tests/tests/api/organization/transfer.spec.ts index fa65c65478..d399afcd88 100644 --- a/integration-tests/tests/api/organization/transfer.spec.ts +++ b/integration-tests/tests/api/organization/transfer.spec.ts @@ -342,20 +342,18 @@ test.concurrent( // current owner const owner = orgMembers.find(m => m.user.email === ownerEmail)!; - expect(orgMembers.find(m => m.id === owner.id)).toEqual( + expect(orgMembers.find(m => m.id === owner.id)?.role).toEqual( expect.objectContaining({ - organizationAccessScopes: owner.organizationAccessScopes, - projectAccessScopes: owner.projectAccessScopes, - targetAccessScopes: owner.targetAccessScopes, + id: owner.role.id, + permissions: owner.role.permissions, }), ); // potential new owner - expect(orgMembers.find(m => m.id === member.id)).toEqual( + expect(orgMembers.find(m => m.id === member.id)?.role).toEqual( expect.objectContaining({ - organizationAccessScopes: member.organizationAccessScopes, - projectAccessScopes: member.projectAccessScopes, - targetAccessScopes: member.targetAccessScopes, + id: member.role.id, + permissions: member.role.permissions, }), ); }, @@ -407,11 +405,10 @@ test.concurrent( expect(previousOwner.role?.name).toBe('Admin'); // other members should not be affected - expect(orgMembers.find(m => m.id === lonelyMember.id)).toEqual( + expect(orgMembers.find(m => m.id === lonelyMember.id)?.role).toEqual( expect.objectContaining({ - organizationAccessScopes: lonelyMember.organizationAccessScopes, - projectAccessScopes: lonelyMember.projectAccessScopes, - targetAccessScopes: lonelyMember.targetAccessScopes, + id: lonelyMember.role.id, + permissions: lonelyMember.role.permissions, }), ); }, diff --git a/integration-tests/tests/api/project/crud.spec.ts b/integration-tests/tests/api/project/crud.spec.ts index 2900058189..26cb0e8780 100644 --- a/integration-tests/tests/api/project/crud.spec.ts +++ b/integration-tests/tests/api/project/crud.spec.ts @@ -1,4 +1,4 @@ -import { ProjectType } from 'testkit/gql/graphql'; +import { ProjectType, ResourceAssignmentMode } from 'testkit/gql/graphql'; import { updateProjectSlug } from '../../../testkit/flow'; import { initSeed } from '../../../testkit/seed'; @@ -199,3 +199,64 @@ test.concurrent( expect(renameResult.updateProjectSlug.error?.message).toBeDefined(); }, ); + +test.concurrent('prevent access to projects with assigned resources on member', async () => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, inviteAndJoinMember, projects: getProjects } = await createOrg(); + const { project } = await createProject(ProjectType.Single); + // By default the viewer will have the "Viewer" role. + const { member, assignMemberRole, memberToken } = await inviteAndJoinMember(); + + // By default the user should have access to all the projects within the organization. + let projects = await getProjects(memberToken); + expect(projects).toHaveLength(1); + expect(projects.at(0)?.id).toEqual(project.id); + + // Limit the users access to no projects using the "Viewer" role + await assignMemberRole({ + roleId: member.role.id, + userId: member.user.id, + resources: { + mode: ResourceAssignmentMode.Granular, + projects: [], + }, + }); + + projects = await getProjects(memberToken); + expect(projects).toHaveLength(0); +}); + +test.concurrent('restrict access to single project with assigned resources on member', async () => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, inviteAndJoinMember, projects: getProjects } = await createOrg(); + const { project: firstProject } = await createProject(ProjectType.Single); + const { project: secondProject } = await createProject(ProjectType.Single); + + // By default the viewer will have the "Viewer" role. + const { member, assignMemberRole, memberToken } = await inviteAndJoinMember(); + + // By default the user should have access to all the projects within the organization. + let projects = await getProjects(memberToken); + expect(projects).toHaveLength(2); + expect(projects.at(0)?.id).toEqual(secondProject.id); + expect(projects.at(1)?.id).toEqual(firstProject.id); + + // Limit the users access to a single project using the "Viewer" role + await assignMemberRole({ + roleId: member.role.id, + userId: member.user.id, + resources: { + mode: ResourceAssignmentMode.Granular, + projects: [ + { + projectId: firstProject.id, + targets: { mode: ResourceAssignmentMode.All }, + }, + ], + }, + }); + + projects = await getProjects(memberToken); + expect(projects).toHaveLength(1); + expect(projects.at(0)?.id).toEqual(firstProject.id); +}); diff --git a/integration-tests/tests/api/schema/check.spec.ts b/integration-tests/tests/api/schema/check.spec.ts index dff7abbd8f..bacd1ba38b 100644 --- a/integration-tests/tests/api/schema/check.spec.ts +++ b/integration-tests/tests/api/schema/check.spec.ts @@ -1,4 +1,8 @@ -import { ProjectType, RuleInstanceSeverityLevel } from 'testkit/gql/graphql'; +import { + ProjectType, + ResourceAssignmentMode, + RuleInstanceSeverityLevel, +} from 'testkit/gql/graphql'; // eslint-disable-next-line import/no-extraneous-dependencies import { createStorage } from '@hive/storage'; import { graphql } from '../../../testkit/gql'; @@ -1315,6 +1319,346 @@ test.concurrent( }, ); +test.concurrent( + 'can not approve schema check with insufficient permissions granted by default user role', + async () => { + const { createOrg } = await initSeed().createOwner(); + const { organization, createProject, inviteAndJoinMember } = await createOrg(); + + // Setup Start: Create a failed schema check + + const { project, createTargetAccessToken, target } = await createProject(ProjectType.Single); + + // Create a token with write rights + const writeToken = await createTargetAccessToken({}); + + // Publish schema with write rights + const publishResult = await writeToken + .publishSchema({ + sdl: /* GraphQL */ ` + type Query { + ping: String + } + `, + }) + .then(r => r.expectNoGraphQLErrors()); + + // Schema publish should be successful + expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); + + const checkResult = await writeToken + .checkSchema(/* GraphQL */ ` + type Query { + ping: Float + } + `) + .then(r => r.expectNoGraphQLErrors()); + + if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') { + throw new Error('Invalid result: ' + checkResult.schemaCheck.__typename); + } + const schemaCheckId = await checkResult.schemaCheck.schemaCheck?.id; + if (!schemaCheckId) { + throw new Error('Invalid result: ' + JSON.stringify(checkResult, null, 2)); + } + + // Setup Done: Create a failed schema check + + // Create a member with no access to projects + const { member, assignMemberRole, memberToken } = await inviteAndJoinMember(); + expect(member.role.name).toEqual('Viewer'); + await assignMemberRole({ + roleId: member.role.id, + userId: member.user.id, + resources: { mode: ResourceAssignmentMode.Granular, projects: [] }, + }); + + // Attempt approving the failed schema check + const errors = await execute({ + document: ApproveFailedSchemaCheckMutation, + variables: { + input: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + schemaCheckId, + }, + }, + authToken: memberToken, + }).then(r => r.expectGraphQLErrors()); + expect(errors).toHaveLength(1); + expect(errors.at(0)).toMatchObject({ + extensions: { + code: 'UNAUTHORISED', + }, + message: `No access (reason: "Missing permission for performing 'schemaCheck:approve' on resource")`, + path: ['approveFailedSchemaCheck'], + }); + }, +); + +test.concurrent( + 'can not approve schema check with insufficient permissions granted by member role (no access to project resource)', + async () => { + const { createOrg } = await initSeed().createOwner(); + const { organization, createProject, inviteAndJoinMember } = await createOrg(); + + // Setup Start: Create a failed schema check + + const { project, createTargetAccessToken, target } = await createProject(ProjectType.Single); + + // Create a token with write rights + const writeToken = await createTargetAccessToken({}); + + // Publish schema with write rights + const publishResult = await writeToken + .publishSchema({ + sdl: /* GraphQL */ ` + type Query { + ping: String + } + `, + }) + .then(r => r.expectNoGraphQLErrors()); + + // Schema publish should be successful + expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); + + const checkResult = await writeToken + .checkSchema(/* GraphQL */ ` + type Query { + ping: Float + } + `) + .then(r => r.expectNoGraphQLErrors()); + + if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') { + throw new Error('Invalid result: ' + checkResult.schemaCheck.__typename); + } + const schemaCheckId = await checkResult.schemaCheck.schemaCheck?.id; + if (!schemaCheckId) { + throw new Error('Invalid result: ' + JSON.stringify(checkResult, null, 2)); + } + + // Setup Done: Create a failed schema check + + // Create a member with no access to projects + const { member, assignMemberRole, createMemberRole, memberToken } = await inviteAndJoinMember(); + const memberRole = await createMemberRole(['schemaCheck:approve', 'project:describe']); + await assignMemberRole({ + roleId: memberRole.id, + userId: member.user.id, + resources: { mode: ResourceAssignmentMode.Granular, projects: [] }, + }); + + // Attempt approving the failed schema check + const errors = await execute({ + document: ApproveFailedSchemaCheckMutation, + variables: { + input: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + schemaCheckId, + }, + }, + authToken: memberToken, + }).then(r => r.expectGraphQLErrors()); + expect(errors).toHaveLength(1); + expect(errors.at(0)).toMatchObject({ + extensions: { + code: 'UNAUTHORISED', + }, + message: `No access (reason: "Missing permission for performing 'schemaCheck:approve' on resource")`, + path: ['approveFailedSchemaCheck'], + }); + }, +); + +test.concurrent( + 'can not approve schema check with insufficient permissions granted by member role (no access to target resource)', + async () => { + const { createOrg } = await initSeed().createOwner(); + const { organization, createProject, inviteAndJoinMember } = await createOrg(); + + // Setup Start: Create a failed schema check + + const { project, createTargetAccessToken, target, targets } = await createProject( + ProjectType.Single, + ); + + // Create a token with write rights + const writeToken = await createTargetAccessToken({}); + + // Publish schema with write rights + const publishResult = await writeToken + .publishSchema({ + sdl: /* GraphQL */ ` + type Query { + ping: String + } + `, + }) + .then(r => r.expectNoGraphQLErrors()); + + // Schema publish should be successful + expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); + + const checkResult = await writeToken + .checkSchema(/* GraphQL */ ` + type Query { + ping: Float + } + `) + .then(r => r.expectNoGraphQLErrors()); + + if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') { + throw new Error('Invalid result: ' + checkResult.schemaCheck.__typename); + } + const schemaCheckId = await checkResult.schemaCheck.schemaCheck?.id; + if (!schemaCheckId) { + throw new Error('Invalid result: ' + JSON.stringify(checkResult, null, 2)); + } + + // Setup Done: Create a failed schema check + + // Create a member with no access to project targets + const { member, createMemberRole, assignMemberRole, memberToken } = await inviteAndJoinMember(); + const memberRole = await createMemberRole(['schemaCheck:approve', 'project:describe']); + await assignMemberRole({ + roleId: memberRole.id, + userId: member.user.id, + resources: { + mode: ResourceAssignmentMode.Granular, + projects: [ + { + projectId: project.id, + targets: { + mode: ResourceAssignmentMode.Granular, + targets: [], + }, + }, + ], + }, + }); + + // Attempt approving the failed schema check + const errors = await execute({ + document: ApproveFailedSchemaCheckMutation, + variables: { + input: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + schemaCheckId, + }, + }, + authToken: memberToken, + }).then(r => r.expectGraphQLErrors()); + expect(errors).toHaveLength(1); + expect(errors.at(0)).toMatchObject({ + extensions: { + code: 'UNAUTHORISED', + }, + message: `No access (reason: "Missing permission for performing 'schemaCheck:approve' on resource")`, + path: ['approveFailedSchemaCheck'], + }); + }, +); + +test.concurrent( + 'can approve schema with sufficient permissions granted by member role (access to target)', + async () => { + const { createOrg } = await initSeed().createOwner(); + const { organization, createProject, inviteAndJoinMember } = await createOrg(); + + // Setup Start: Create a failed schema check + + const { project, createTargetAccessToken, target } = await createProject(ProjectType.Single); + + // Create a token with write rights + const writeToken = await createTargetAccessToken({}); + + // Publish schema with write rights + const publishResult = await writeToken + .publishSchema({ + sdl: /* GraphQL */ ` + type Query { + ping: String + } + `, + }) + .then(r => r.expectNoGraphQLErrors()); + + // Schema publish should be successful + expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); + + const checkResult = await writeToken + .checkSchema(/* GraphQL */ ` + type Query { + ping: Float + } + `) + .then(r => r.expectNoGraphQLErrors()); + + if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') { + throw new Error('Invalid result: ' + checkResult.schemaCheck.__typename); + } + const schemaCheckId = await checkResult.schemaCheck.schemaCheck?.id; + if (!schemaCheckId) { + throw new Error('Invalid result: ' + JSON.stringify(checkResult, null, 2)); + } + + // Setup Done: Create a failed schema check + + // Create a member with no access to projects + const { member, createMemberRole, assignMemberRole, memberToken } = await inviteAndJoinMember(); + const memberRole = await createMemberRole(['schemaCheck:approve', 'project:describe']); + await assignMemberRole({ + roleId: memberRole.id, + userId: member.user.id, + resources: { + mode: ResourceAssignmentMode.Granular, + projects: [ + { + projectId: project.id, + targets: { + mode: ResourceAssignmentMode.Granular, + targets: [ + { + targetId: target.id, + appDeployments: { mode: ResourceAssignmentMode.All }, + services: { mode: ResourceAssignmentMode.All }, + }, + ], + }, + }, + ], + }, + }); + + // Attempt approving the failed schema check + const result = await execute({ + document: ApproveFailedSchemaCheckMutation, + variables: { + input: { + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + schemaCheckId, + }, + }, + authToken: memberToken, + }).then(r => r.expectNoGraphQLErrors()); + expect(result.approveFailedSchemaCheck.ok).toMatchObject({ + schemaCheck: { + __typename: 'SuccessfulSchemaCheck', + isApproved: true, + }, + }); + }, +); + test.concurrent( 'subsequent schema check with shared contextId that contains new breaking changes that have not been approved fails', async ({ expect }) => { diff --git a/integration-tests/tests/api/target/crud.spec.ts b/integration-tests/tests/api/target/crud.spec.ts index de7eef843e..15fc5f1df7 100644 --- a/integration-tests/tests/api/target/crud.spec.ts +++ b/integration-tests/tests/api/target/crud.spec.ts @@ -140,8 +140,12 @@ test.concurrent( test.concurrent('organization member user can create a target', async ({ expect }) => { const { createOrg } = await initSeed().createOwner(); const { ownerEmail: orgMemberEmail, ownerToken: orgMemberToken } = await initSeed().createOwner(); - const { createProject, inviteMember, joinMemberUsingCode } = await createOrg(); - const inviteMemberResult = await inviteMember(orgMemberEmail); + const { createProject, inviteMember, joinMemberUsingCode, organization } = await createOrg(); + const inviteMemberResult = await inviteMember( + orgMemberEmail, + undefined, + organization.memberRoles?.find(role => role.name === 'Admin')?.id, + ); if (inviteMemberResult.ok == null) { throw new Error('Invite did not succeed' + JSON.stringify(inviteMemberResult)); 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..cf9941a0e3 --- /dev/null +++ b/packages/migrations/src/actions/2025.01.30T00-00-00.granular-member-role-permissions.ts @@ -0,0 +1,15 @@ +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[] + ; + + ALTER TABLE "organization_member" + ADD COLUMN "assigned_resources" JSONB + ; + `, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index 025d9781dd..1646ba07d5 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -157,5 +157,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri await import('./actions/2025.01.13T10-08-00.default-role'), await import('./actions/2025.01.17T10-08-00.drop-activities'), await import('./actions/2025.01.20T00-00-00.legacy-registry-model-removal'), + await import('./actions/2025.01.30T00-00-00.granular-member-role-permissions'), ], }); diff --git a/packages/services/api/src/index.ts b/packages/services/api/src/index.ts index 0d11764899..ca2d1b9c0c 100644 --- a/packages/services/api/src/index.ts +++ b/packages/services/api/src/index.ts @@ -27,13 +27,12 @@ export { HttpClient } from './modules/shared/providers/http-client'; export { OperationsManager } from './modules/operations/providers/operations-manager'; export { OperationsReader } from './modules/operations/providers/operations-reader'; export { ClickHouse, sql } from './modules/operations/providers/clickhouse-client'; -export { - organizationAdminScopes, - reservedOrganizationSlugs as reservedOrganizationNames, -} from './modules/organization/providers/organization-config'; +export { reservedOrganizationSlugs as reservedOrganizationNames } from './modules/organization/providers/organization-config'; export { CryptoProvider } from './modules/shared/providers/crypto'; export { OrganizationAccessScope, ProjectAccessScope, TargetAccessScope, } from './__generated__/types'; +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/app-deployments/providers/app-deployments-manager.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts index 73bca40f39..4b2cb08652 100644 --- a/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts +++ b/packages/services/api/src/modules/app-deployments/providers/app-deployments-manager.ts @@ -37,20 +37,6 @@ export class AppDeploymentsManager { version: appDeploymentInput.version, }); - if (!appDeployment) { - return null; - } - - await this.session.assertPerformAction({ - action: 'appDeployment:describe', - organizationId: target.orgId, - params: { - organizationId: target.orgId, - projectId: target.projectId, - targetId: target.id, - }, - }); - return appDeployment; } @@ -195,16 +181,6 @@ export class AppDeploymentsManager { target: Target, args: { cursor: string | null; first: number | null }, ) { - await this.session.assertPerformAction({ - action: 'appDeployment:describe', - organizationId: target.orgId, - params: { - organizationId: target.orgId, - projectId: target.projectId, - targetId: target.id, - }, - }); - return await this.appDeployments.getPaginatedAppDeployments({ targetId: target.id, cursor: args.cursor, diff --git a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts index d811d05884..b12960c1bb 100644 --- a/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts +++ b/packages/services/api/src/modules/app-deployments/providers/app-deployments.ts @@ -17,7 +17,7 @@ import { Storage } from '../../shared/providers/storage'; import { APP_DEPLOYMENTS_ENABLED } from './app-deployments-enabled-token'; import { PersistedDocumentScheduler } from './persisted-document-scheduler'; -const AppDeploymentNameModel = z +export const AppDeploymentNameModel = z .string() .min(1, 'Must be at least 1 character long') .max(64, 'Must be at most 64 characters long') diff --git a/packages/services/api/src/modules/app-deployments/resolvers/Target.ts b/packages/services/api/src/modules/app-deployments/resolvers/Target.ts index a320f93bd2..8b43a24a89 100644 --- a/packages/services/api/src/modules/app-deployments/resolvers/Target.ts +++ b/packages/services/api/src/modules/app-deployments/resolvers/Target.ts @@ -29,7 +29,7 @@ export const Target: Pick< first: args.first ?? null, }); }, - viewerCanViewAppDeployments: async (target, _arg, { injector, session }) => { + viewerCanViewAppDeployments: async (target, _arg, { injector }) => { const organization = await injector.get(OrganizationManager).getOrganization({ organizationId: target.orgId, }); @@ -40,15 +40,6 @@ export const Target: Pick< ) { return false; } - - return session.canPerformAction({ - action: 'appDeployment:describe', - organizationId: organization.id, - params: { - organizationId: organization.id, - projectId: target.projectId, - targetId: target.id, - }, - }); + return true; }, }; diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index cf762bbb23..5cf3ef5802 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -1,7 +1,9 @@ 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'; +import { objectEntries, objectFromEntries } from '../../../shared/helpers'; import { isUUID } from '../../../shared/is-uuid'; import { Logger } from '../../shared/providers/logger'; @@ -139,7 +141,12 @@ export abstract class Session { }): Promise { const permissions = await this._loadPolicyStatementsForOrganization(args.organizationId); + this.logger.debug('Resolved permission statements for viewer. (permissions=%o)', permissions); + const resourceIdsForAction = actionDefinitions[args.action](args.params as any); + + this.logger.debug('Resolved action resource IDs. (resourceIds=%o)', resourceIdsForAction); + let isAllowed = false; for (const permission of permissions) { @@ -315,59 +322,152 @@ function schemaCheckOrPublishIdentity( /** * 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 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, -} satisfies ActionDefinitionMap; +const permissionsByLevel = { + 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:modify'), + 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('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 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)), + target: z.set(z.union(permissionsByLevel.target)), + service: z.set(z.union(permissionsByLevel.service)), + appDeployment: z.set(z.union(permissionsByLevel.appDeployment)), +}); + +export type PermissionsPerResourceLevelAssignment = z.TypeOf< + typeof PermissionsPerResourceLevelAssignmentModel +>; + +export type ResourceLevel = keyof PermissionsPerResourceLevelAssignment; + +export const PermissionsModel = z.union([ + ...permissionsByLevel.organization, + ...permissionsByLevel.project, + ...permissionsByLevel.target, + ...permissionsByLevel.service, + ...permissionsByLevel.appDeployment, +]); + +export type Permission = z.TypeOf; + +const permissionResourceLevelLookupMap = new Map< + z.TypeOf, + ResourceLevel +>(); + +for (const [key, permissions] of objectEntries(permissionsByLevel)) { + for (const permission of permissions) { + permissionResourceLevelLookupMap.set(permission.value, key); + } +} + +/** Get the permission group for a specific permissions */ +export function getPermissionGroup(permission: Permission): ResourceLevel { + const group = permissionResourceLevelLookupMap.get(permission); + + if (group === undefined) { + throw new Error(`Could not find group for permission '${permission}'.`); + } + + 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(permissionsByLevel['organization'].map(t => [t.value, defaultOrgIdentity])), + ...objectFromEntries(permissionsByLevel['project'].map(t => [t.value, defaultProjectIdentity])), + ...objectFromEntries(permissionsByLevel['target'].map(t => [t.value, defaultTargetIdentity])), + ...objectFromEntries( + permissionsByLevel['service'].map(t => [t.value, schemaCheckOrPublishIdentity]), + ), + ...objectFromEntries( + permissionsByLevel['appDeployment'].map(t => [t.value, defaultAppDeploymentIdentity]), + ), +} satisfies ActionDefinitionMap; + type Actions = keyof typeof actionDefinitions; type ActionStrings = Actions | '*'; 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..fd2e80a927 100644 --- a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts @@ -5,25 +5,27 @@ import { captureException } from '@sentry/node'; import type { User } from '../../../shared/entities'; import { AccessError, HiveError } from '../../../shared/errors'; import { isUUID } from '../../../shared/is-uuid'; +import { + OrganizationMembers, + OrganizationMembershipRoleAssignment, + ResourceAssignment, +} from '../../organization/providers/organization-members'; import { Logger } from '../../shared/providers/logger'; import type { Storage } from '../../shared/providers/storage'; -import { - OrganizationAccessScope, - ProjectAccessScope, - TargetAccessScope, -} from '../providers/scopes'; 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 +34,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.isOwner) { + this.logger.debug( + 'User is organization owner, resolve admin access policy. (userId=%s, organizationId=%s)', + user.id, + organizationId, + ); + return [ { action: '*', @@ -52,11 +88,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.assignedRole, + ); + + return policyStatements; } public async getViewer(): Promise { @@ -74,15 +117,112 @@ export class SuperTokensCookieBasedSession extends Session { public isViewer() { 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}:target/${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}`; + } + + casesExhausted(resource); + } + + private translateAssignedRolesToAuthorizationPolicyStatements( + organizationId: string, + assignedRole: OrganizationMembershipRoleAssignment, + ): Array { + const policyStatements: Array = []; + + 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.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.appDeployment.size) { + policyStatements.push({ + action: Array.from(assignedRole.role.permissions.appDeployment), + effect: 'allow', + resource: this.toResourceIdentifier( + organizationId, + assignedRole.resolvedResources.appDeployment, + ), + }); + } + + 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 +313,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; +function casesExhausted(_value: never): never { + throw new Error('Not all cases were handled.'); } diff --git a/packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts b/packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts index a36532a3f0..946335160b 100644 --- a/packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts @@ -177,7 +177,6 @@ function transformAccessTokenLegacyScopes(args: { { effect: 'allow', action: [ - 'appDeployment:describe', '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..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,4 +1,8 @@ -import type { Member, User } from '../../shared/entities'; +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'; @@ -7,6 +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 239db6ba17..7efb608798 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 @@ -110,4 +96,28 @@ 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 + isReadOnly: Boolean! + warning: String + } + + type PermissionGroup { + id: ID! + title: String! + permissions: [Permission!]! + } `; diff --git a/packages/services/api/src/modules/auth/providers/auth-manager.ts b/packages/services/api/src/modules/auth/providers/auth-manager.ts index b6a040763a..6bc15c7970 100644 --- a/packages/services/api/src/modules/auth/providers/auth-manager.ts +++ b/packages/services/api/src/modules/auth/providers/auth-manager.ts @@ -1,7 +1,6 @@ import { Injectable, Scope } from 'graphql-modules'; import type { User } from '../../../shared/entities'; import { AccessError } from '../../../shared/errors'; -import { Storage } from '../../shared/providers/storage'; import { Session } from '../lib/authz'; import { TargetAccessTokenSession } from '../lib/target-access-token-strategy'; import { @@ -45,7 +44,6 @@ export class AuthManager { private projectAccess: ProjectAccess, private targetAccess: TargetAccess, private userManager: UserManager, - private storage: Storage, private session: Session, ) {} 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/Permission.ts b/packages/services/api/src/modules/auth/resolvers/Permission.ts new file mode 100644 index 0000000000..c20386cf17 --- /dev/null +++ b/packages/services/api/src/modules/auth/resolvers/Permission.ts @@ -0,0 +1,26 @@ +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 (permission, _arg, _ctx) => { + return permission.dependsOn ?? null; + }, + isReadOnly: (permission, _arg, _ctx) => { + return permission.isReadyOnly ?? false; + }, + level: async (permission, _arg, _ctx) => { + return getPermissionGroup(permission.id); + }, + warning: async (permission, _arg, _ctx) => { + return permission.warning ?? null; + }, +}; 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 */ +}; diff --git a/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts b/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts index e0533376e5..86d3d00530 100644 --- a/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts +++ b/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts @@ -382,18 +382,15 @@ export class OIDCIntegrationsProvider { } as const; } - const viewer = await this.session.getViewer(); - const [member, adminRole] = await Promise.all([ - this.storage.getOrganizationMember({ - organizationId: oidcIntegration.linkedOrganizationId, - userId: viewer.id, - }), - this.storage.getAdminOrganizationMemberRole({ + if ( + !(await this.session.canPerformAction({ + action: 'member:modify', organizationId: oidcIntegration.linkedOrganizationId, - }), - ]); - - if (member?.role.id !== adminRole.id) { + params: { + organizationId: oidcIntegration.linkedOrganizationId, + }, + })) + ) { return { type: 'error', message: 'You do not have permission to update the default member role.', diff --git a/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegration.ts b/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegration.ts index 0febf82623..931bbb2ad7 100644 --- a/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegration.ts +++ b/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegration.ts @@ -14,20 +14,28 @@ export const OIDCIntegration: OidcIntegrationResolvers = { * Fallbacks to Viewer if default member role is not set */ defaultMemberRole: async (oidcIntegration, _, { injector }) => { - if (!oidcIntegration.defaultMemberRoleId) { - return injector.get(OrganizationManager).getViewerMemberRole({ + if (oidcIntegration.defaultMemberRoleId) { + const role = await injector.get(OrganizationManager).getMemberRole({ organizationId: oidcIntegration.linkedOrganizationId, + roleId: oidcIntegration.defaultMemberRoleId, }); + + if (!role) { + throw new Error( + `Default role not found (role_id=${oidcIntegration.defaultMemberRoleId}, organization=${oidcIntegration.linkedOrganizationId})`, + ); + } + + return role; } - const role = await injector.get(OrganizationManager).getMemberRole({ + const role = await injector.get(OrganizationManager).getViewerMemberRole({ organizationId: oidcIntegration.linkedOrganizationId, - roleId: oidcIntegration.defaultMemberRoleId, }); if (!role) { throw new Error( - `Default role not found (role_id=${oidcIntegration.defaultMemberRoleId}, organization=${oidcIntegration.linkedOrganizationId})`, + `Viewer role not found (organization=${oidcIntegration.linkedOrganizationId})`, ); } diff --git a/packages/services/api/src/modules/organization/index.ts b/packages/services/api/src/modules/organization/index.ts index 0c5936e9ea..1b65c48c59 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: [OrganizationMemberRoles, OrganizationMembers, OrganizationManager], }); diff --git a/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts b/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts new file mode 100644 index 0000000000..92ce201bdb --- /dev/null +++ b/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts @@ -0,0 +1,311 @@ +import { allPermissions, Permission } from '../../auth/lib/authz'; + +export type PermissionRecord = { + id: Permission; + title: string; + description: string; + dependsOn?: Permission; + isReadyOnly?: true; + warning?: string; +}; + +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.', + isReadyOnly: 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:modify', + title: 'Manage members', + description: 'Member can invite users, update and assign roles.', + dependsOn: 'member:describe', + warning: + 'Granting a role the ability to manage members enables it to elevate its own permissions.', + }, + ], + }, + { + 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: '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: '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 permissionsToCheck = new Set(allPermissions); + + for (const item of excluded) { + permissionsToCheck.delete(item); + } + + for (const group of allPermissionGroups) { + for (const permission of group.permissions) { + permissionsToCheck.delete(permission.id); + } + } + + if (permissionsToCheck.size) { + throw new Error( + 'The following permissions are not assigned: \n' + Array.from(permissionsToCheck).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', +]); + +/** + * 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..8117f10a8e 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 { @@ -300,16 +304,6 @@ export default gql` message: String! } - extend type Member { - canLeaveOrganization: Boolean! - role: MemberRole! - isAdmin: Boolean! - """ - Whether the viewer can remove this member from the organization. - """ - viewerCanRemove: Boolean! - } - type MemberRole { id: ID! name: String! @@ -318,9 +312,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 +324,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 +371,7 @@ export default gql` roleId: ID! name: String! description: String! - organizationAccessScopes: [OrganizationAccessScope!]! - projectAccessScopes: [ProjectAccessScope!]! - targetAccessScopes: [TargetAccessScope!]! + selectedPermissions: [String!]! } type UpdateMemberRoleOk { @@ -430,6 +424,7 @@ export default gql` organizationSlug: String! userId: ID! roleId: ID! + resources: ResourceAssignmentInput! } type AssignMemberRoleOk { @@ -448,4 +443,123 @@ export default gql` ok: AssignMemberRoleOk error: AssignMemberRoleError } + + type Member { + id: ID! + user: User! + isOwner: Boolean! + canLeaveOrganization: Boolean! + role: MemberRole! + resourceAssignment: ResourceAssignment! + """ + Whether the viewer can remove this member from the organization. + """ + viewerCanRemove: Boolean! + } + + enum ResourceAssignmentMode { + all + granular + } + + type MemberConnection { + nodes: [Member!]! + total: Int! + } + + input AppDeploymentResourceAssignmentInput { + appDeployment: String! + } + + input TargetAppDeploymentsResourceAssignmentInput { + """ + Whether the permissions should apply for all app deployments within the target. + """ + mode: ResourceAssignmentMode! + """ + Specific app deployments within the target for which the permissions should be applied. + """ + appDeployments: [AppDeploymentResourceAssignmentInput!] + } + + input ServiceResourceAssignmentInput { + serviceName: String! + } + + input TargetServicesResourceAssignmentInput { + """ + Whether the permissions should apply for all services within the target or only selected ones. + """ + mode: ResourceAssignmentMode! + """ + Specific services within the target for which the permissions should be applied. + """ + services: [ServiceResourceAssignmentInput!] + } + + input TargetResourceAssignmentInput { + targetId: ID! + services: TargetServicesResourceAssignmentInput! + appDeployments: TargetAppDeploymentsResourceAssignmentInput! + } + + input ProjectTargetsResourceAssignmentInput { + """ + Whether the permissions should apply for all targets within the project or only selected ones. + """ + mode: ResourceAssignmentMode! + """ + Specific targets within the projects for which the permissions should be applied. + """ + targets: [TargetResourceAssignmentInput!] + } + + input ProjectResourceAssignmentInput { + projectId: ID! + targets: ProjectTargetsResourceAssignmentInput! + } + + input ResourceAssignmentInput { + """ + Whether the permissions should apply for all projects within the organization or only selected ones. + """ + mode: ResourceAssignmentMode! + """ + Specific projects within the organization for which the permissions should be applied. + """ + projects: [ProjectResourceAssignmentInput!] + } + + type TargetServicesResourceAssignment { + mode: ResourceAssignmentMode! + services: [String!] + } + + type TargetAppDeploymentsResourceAssignment { + mode: ResourceAssignmentMode! + appDeployments: [String!] + } + + type TargetResouceAssignment { + targetId: ID! + target: Target! + services: TargetServicesResourceAssignment! + appDeployments: TargetAppDeploymentsResourceAssignment! + } + + type ProjectTargetsResourceAssignment { + mode: ResourceAssignmentMode! + targets: [TargetResouceAssignment!] + } + + type ProjectResourceAssignment { + projectId: ID! + project: Project! + targets: ProjectTargetsResourceAssignment! + } + + type ResourceAssignment { + mode: ResourceAssignmentMode! + projects: [ProjectResourceAssignment!] + } `; diff --git a/packages/services/api/src/modules/organization/providers/organization-config.ts b/packages/services/api/src/modules/organization/providers/organization-config.ts index e2b83b10b8..44d91ca013 100644 --- a/packages/services/api/src/modules/organization/providers/organization-config.ts +++ b/packages/services/api/src/modules/organization/providers/organization-config.ts @@ -1,9 +1,3 @@ -import { - OrganizationAccessScope, - ProjectAccessScope, - TargetAccessScope, -} from '../../auth/providers/scopes'; - export const reservedOrganizationSlugs = [ 'registry', 'server', @@ -51,17 +45,3 @@ export const reservedOrganizationSlugs = [ 'new', 'org', ]; - -export const organizationAdminScopes = [ - ...Object.values(OrganizationAccessScope), - ...Object.values(ProjectAccessScope), - ...Object.values(TargetAccessScope), -]; - -export const organizationViewerScopes = [ - OrganizationAccessScope.READ, - ProjectAccessScope.READ, - ProjectAccessScope.OPERATIONS_STORE_READ, - TargetAccessScope.READ, - TargetAccessScope.REGISTRY_READ, -]; 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..ed1c503bc2 100644 --- a/packages/services/api/src/modules/organization/providers/organization-manager.ts +++ b/packages/services/api/src/modules/organization/providers/organization-manager.ts @@ -1,14 +1,12 @@ import { createHash } from 'node:crypto'; import { Inject, Injectable, Scope } from 'graphql-modules'; -import { Organization, OrganizationMemberRole } from '../../../shared/entities'; +import * as GraphQLSchema from '../../../__generated__/types'; +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'; import { Session } from '../../auth/lib/authz'; import { AuthManager } from '../../auth/providers/auth-manager'; -import { OrganizationAccessScope } from '../../auth/providers/organization-access'; -import { ProjectAccessScope } from '../../auth/providers/project-access'; -import { TargetAccessScope } from '../../auth/providers/target-access'; import { BillingProvider } from '../../billing/providers/billing.provider'; import { OIDCIntegrationsProvider } from '../../oidc-integrations/providers/oidc-integrations.provider'; import { Emails, mjml } from '../../shared/providers/emails'; @@ -18,34 +16,10 @@ 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 { - 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 { createOrUpdateMemberRoleInputSchema } from '../validation'; +import { reservedOrganizationSlugs } from './organization-config'; +import { OrganizationMemberRoles, type OrganizationMemberRole } from './organization-member-roles'; +import { OrganizationMembers } from './organization-members'; /** * Responsible for auth checks. @@ -68,6 +42,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, ) { @@ -261,17 +237,18 @@ export class OrganizationManager { return organization; } - @cache((selector: OrganizationSelector) => selector.organizationId) - async getOrganizationMembers(selector: OrganizationSelector) { - return this.storage.getOrganizationMembers(selector); - } - countOrganizationMembers(selector: OrganizationSelector) { return this.storage.countOrganizationMembers(selector); } 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'); @@ -283,7 +260,7 @@ export class OrganizationManager { @cache((selector: OrganizationSelector) => selector.organizationId) async getInvitations(selector: OrganizationSelector) { await this.session.assertPerformAction({ - action: 'member:manageInvites', + action: 'member:modify', organizationId: selector.organizationId, params: { organizationId: selector.organizationId, @@ -319,8 +296,6 @@ export class OrganizationManager { const result = await this.storage.createOrganization({ slug, userId: user.id, - adminScopes: organizationAdminScopes, - viewerScopes: organizationViewerScopes, reservedSlugs: reservedOrganizationSlugs, }); @@ -504,7 +479,7 @@ export class OrganizationManager { async deleteInvitation(input: { email: string; organizationId: string }) { await this.session.assertPerformAction({ - action: 'member:manageInvites', + action: 'member:modify', organizationId: input.organizationId, params: { organizationId: input.organizationId, @@ -515,7 +490,7 @@ export class OrganizationManager { async inviteByEmail(input: { email: string; organization: string; role?: string | null }) { await this.session.assertPerformAction({ - action: 'member:manageInvites', + action: 'member:modify', organizationId: input.organization, params: { organizationId: input.organization, @@ -533,11 +508,10 @@ export class OrganizationManager { organizationId: input.organization, }); - const [members, currentUserAccessScopes] = await Promise.all([ - this.getOrganizationMembers({ organizationId: input.organization }), - this.authManager.getCurrentUserAccessScopes(organization.id), - ]); - const existingMember = members.find(member => member.user.email === email); + const existingMember = await this.organizationMembers.findOrganizationMembershipByEmail( + organization, + email, + ); if (existingMember) { return { @@ -549,33 +523,13 @@ 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), - ); - - if (currentUserMissingScopes.length > 0) { - this.logger.debug(`Logged user scopes: %s`, currentUserAccessScopes.join(',')); - this.logger.debug(`Missing scopes: %s`, currentUserMissingScopes.join(',')); - return { - error: { - message: `Not enough access to invite a member with this role`, - inputErrors: {}, - }, - }; - } - // Delete existing invitation await this.storage.deleteOrganizationInvitationByEmail({ organizationId: organization.id, @@ -839,7 +793,7 @@ export class OrganizationManager { ): Promise { this.logger.info('Deleting a member from an organization (selector=%o)', selector); await this.session.assertPerformAction({ - action: 'member:removeMember', + action: 'member:modify', organizationId: selector.organizationId, params: { organizationId: selector.organizationId, @@ -874,17 +828,6 @@ export class OrganizationManager { throw new Error(`Logged user is not a member of the organization`); } - // Ensure current user has access to all scopes of the member. - // User with less access scopes cannot remove a member with more access scopes. - const currentUserMissingScopes = member.scopes.filter( - scope => !currentUserAsMember.scopes.includes(scope), - ); - - if (currentUserMissingScopes.length > 0) { - this.logger.debug(`Logged user scopes: %o`, currentUserAsMember.scopes); - throw new HiveError(`Not enough access to remove the member`); - } - await this.storage.deleteOrganizationMember({ userId: user, organizationId: organization, @@ -909,57 +852,47 @@ 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, + action: 'member:modify', + 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, }); - // 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 +905,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 +924,7 @@ export class OrganizationManager { return { ok: { updatedOrganization: await this.storage.getOrganization({ - organizationId: input.organizationId, + organizationId, }), createdRole: role, }, @@ -1000,17 +933,14 @@ export class OrganizationManager { async deleteMemberRole(input: { organizationId: string; roleId: string }) { await this.session.assertPerformAction({ - action: 'member:modifyRole', + action: 'member:modify', organizationId: input.organizationId, params: { organizationId: input.organizationId, }, }); - const role = await this.storage.getOrganizationMemberRole({ - organizationId: input.organizationId, - roleId: input.roleId, - }); + const role = await this.organizationMemberRoles.findMemberRoleById(input.roleId); if (!role) { return { @@ -1020,18 +950,10 @@ 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); - - if (!accessCheckResult.ok) { + if (role.membersCount > 0) { return { error: { - message: accessCheckResult.message, + message: `Cannot delete a role with members`, }, }; } @@ -1060,36 +982,37 @@ export class OrganizationManager { }; } - async assignMemberRole(input: { organizationId: string; userId: string; roleId: string }) { + async assignMemberRole(input: { + organizationSlug: string; + userId: string; + roleId: string; + resources: GraphQLSchema.ResourceAssignmentInput; + }) { + const organizationId = await this.idTranslator.translateOrganizationId(input); + await this.session.assertPerformAction({ - action: 'member:assignRole', - organizationId: input.organizationId, + action: 'member:modify', + 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,82 +1022,53 @@ 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`, - }, - }; - } - } + const resourceAssignmentGroup = + await this.organizationMembers.transformGraphQLMemberResourceAssignmentInputToResourceAssignmentGroup( + organization, + input.resources, + ); // Assign the role to the member - await this.storage.assignOrganizationMemberRole({ - organizationId: input.organizationId, + await this.organizationMembers.assignOrganizationMemberRole({ + organizationId, userId: input.userId, roleId: input.roleId, + resourceAssignmentGroup, }); // Access cache is stale by now 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 +1080,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, + action: 'member:modify', + 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 +1142,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 +1157,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,27 +1185,33 @@ export class OrganizationManager { }, }); - return this.storage.getOrganizationMemberRoles({ - organizationId: selector.organizationId, - }); + return this.organizationMemberRoles.getMemberRolesForOrganizationId(selector.organizationId); } - async getMemberRole(selector: { organizationId: string; roleId: string }) { + async getMemberRole(selector: { + organizationId: string; + roleId: string; + }): Promise { + 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 }) { + async getViewerMemberRole(selector: { + organizationId: string; + }): Promise { await this.session.assertPerformAction({ action: 'member:describe', organizationId: selector.organizationId, @@ -1366,156 +1220,6 @@ export class OrganizationManager { }, }); - return this.storage.getViewerOrganizationMemberRole({ - organizationId: selector.organizationId, - }); - } - - async canDeleteRole( - role: OrganizationMemberRole, - currentUserScopes: readonly ( - | OrganizationAccessScope - | ProjectAccessScope - | TargetAccessScope - )[], - ): Promise< - | { - ok: false; - message: string; - } - | { - ok: true; - } - > { - // Ensure role is not locked (can't be deleted) - if (role.locked) { - return { - ok: false, - message: `Cannot delete a built-in role`, - }; - } - // Ensure role has no members - let membersCount: number | undefined = role.membersCount; - - if (typeof membersCount !== 'number') { - const freshRole = await this.storage.getOrganizationMemberRole({ - organizationId: role.organizationId, - roleId: role.id, - }); - - if (!freshRole) { - throw new Error('Role not found'); - } - - membersCount = freshRole.membersCount; - } - - if (membersCount > 0) { - return { - ok: false, - message: `Cannot delete a role with members`, - }; - } - - // 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, - }; - } - - canUpdateRole( - role: OrganizationMemberRole, - currentUserScopes: readonly ( - | OrganizationAccessScope - | ProjectAccessScope - | TargetAccessScope - )[], - ): - | { - ok: false; - message: string; - } - | { - ok: true; - } { - // Ensure role is not locked (can't be updated) - if (role.locked) { - return { - ok: false, - message: `Cannot update a built-in role`, - }; - } - - // 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, - }; - } - - canInviteRole( - role: OrganizationMemberRole, - currentUserScopes: readonly ( - | OrganizationAccessScope - | ProjectAccessScope - | TargetAccessScope - )[], - ): - | { - ok: false; - message: string; - } - | { - ok: true; - } { - // 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, - }; - } - - isAdminRole(role: { name: string; locked: boolean } | null) { - return role?.name === 'Admin' && role.locked === true; + return this.organizationMemberRoles.findViewerRoleByOrganizationId(selector.organizationId); } } 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..918518a35a --- /dev/null +++ b/packages/services/api/src/modules/organization/providers/organization-member-roles.ts @@ -0,0 +1,344 @@ +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, + PermissionsPerResourceLevelAssignment, + 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 + .object({ + id: z.string(), + name: z.string(), + description: z.string(), + isLocked: z.boolean(), + organizationId: z.string(), + membersCount: z.number(), + legacyScopes: z + .array(z.string()) + .nullable() + .transform( + value => + value as Array | null, + ), + permissions: z.array(PermissionsModel).nullable(), + }) + .transform(record => { + let permissions: PermissionsPerResourceLevelAssignment; + + // Both "Viewer" and "Admin" have pre-defined permissions + if (record.name === 'Viewer') { + permissions = predefinedRolesPermissions.viewer; + } else if (record.name === 'Admin') { + permissions = predefinedRolesPermissions.admin; + } else if (record.permissions) { + permissions = permissionsToPermissionsPerResourceLevelAssignment([ + ...OrganizationMemberPermissions.permissions.default, + ...record.permissions, + ]); + } else { + permissions = transformOrganizationMemberLegacyScopesIntoPermissionGroup( + record.legacyScopes ?? [], + ); + } + + return { + ...omit(record, 'legacyScopes'), + permissions, + }; + }); + +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): Promise> { + 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, + ): Promise { + 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, + ): Promise { + 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; + }): Promise { + 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); + } +} + +function transformOrganizationMemberLegacyScopesIntoPermissionGroup( + scopes: Array, +): z.TypeOf { + const permissions = new Set(); + for (const scope of scopes) { + switch (scope) { + case OrganizationAccessScope.READ: { + permissions.add('support:manageTickets'); + permissions.add('project:describe'); + permissions.add('project:create'); + break; + } + case OrganizationAccessScope.SETTINGS: { + permissions.add('organization:modifySlug'); + permissions.add('schemaLinting:modifyOrganizationRules'); + permissions.add('billing:describe'); + permissions.add('billing:update'); + permissions.add('auditLog:export'); + break; + } + case OrganizationAccessScope.DELETE: { + permissions.add('organization:delete'); + break; + } + case OrganizationAccessScope.INTEGRATIONS: { + permissions.add('oidc:modify'); + permissions.add('gitHubIntegration:modify'); + permissions.add('slackIntegration:modify'); + break; + } + case OrganizationAccessScope.MEMBERS: { + // Note: We do not assign the following permission: + // - 'member:modify + // The reason for this is that we changed the behavior of checking the permissions for + // the logic and it is not safe to translate them to the new permission layer. + permissions.add('member:describe'); + break; + } + case ProjectAccessScope.ALERTS: { + permissions.add('alert:modify'); + break; + } + case ProjectAccessScope.READ: { + permissions.add('project:describe'); + break; + } + case ProjectAccessScope.DELETE: { + permissions.add('project:delete'); + break; + } + case ProjectAccessScope.SETTINGS: { + permissions.add('project:delete'); + permissions.add('project:modifySettings'); + permissions.add('schemaLinting:modifyProjectRules'); + break; + } + case TargetAccessScope.READ: { + permissions.add('target:create'); + permissions.add('laboratory:describe'); + break; + } + case TargetAccessScope.REGISTRY_WRITE: { + permissions.add('laboratory:modify'); + permissions.add('schemaCheck:approve'); + break; + } + case TargetAccessScope.TOKENS_WRITE: { + permissions.add('targetAccessToken:modify'); + permissions.add('cdnAccessToken:modify'); + break; + } + case TargetAccessScope.SETTINGS: { + permissions.add('target:modifySettings'); + permissions.add('laboratory:modifyPreflightScript'); + break; + } + case TargetAccessScope.DELETE: { + permissions.add('target:delete'); + break; + } + } + } + + return permissionsToPermissionsPerResourceLevelAssignment([ + ...OrganizationMemberPermissions.permissions.default, + ...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" +`; + +const predefinedRolesPermissions = { + /** + * Permissions the default admin role is assigned with (aka full access) + **/ + admin: permissionsToPermissionsPerResourceLevelAssignment([ + ...OrganizationMemberPermissions.permissions.default, + ...OrganizationMemberPermissions.permissions.assignable, + ]), + /** + * Permissions the viewer role is assigned with (computed from legacy scopes) + **/ + viewer: permissionsToPermissionsPerResourceLevelAssignment([ + ...OrganizationMemberPermissions.permissions.default, + 'support:manageTickets', + 'project:describe', + 'laboratory:describe', + ]), +}; diff --git a/packages/services/api/src/modules/organization/providers/organization-member.spec.ts b/packages/services/api/src/modules/organization/providers/organization-member.spec.ts new file mode 100644 index 0000000000..1fe26c5599 --- /dev/null +++ b/packages/services/api/src/modules/organization/providers/organization-member.spec.ts @@ -0,0 +1,541 @@ +import { resolveResourceAssignment } from './organization-members'; + +describe('resolveResourceAssignment', () => { + test('project wildcard: organization wide access to all resources', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: '*', + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: { + organizationId: 'aaa', + type: 'organization', + }, + target: { + organizationId: 'aaa', + type: 'organization', + }, + service: { + organizationId: 'aaa', + type: 'organization', + }, + appDeployment: { + organizationId: 'aaa', + type: 'organization', + }, + }); + }); + test('project granular: access to single project', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { mode: '*' }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + target: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + service: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + appDeployment: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + }); + }); + test('project granular: access to multiple projects', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { mode: '*' }, + type: 'project', + }, + { + id: 'ccc', + targets: { mode: '*' }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + { + projectId: 'ccc', + type: 'project', + }, + ], + target: [ + { + projectId: 'bbb', + type: 'project', + }, + { + projectId: 'ccc', + type: 'project', + }, + ], + service: [ + { + projectId: 'bbb', + type: 'project', + }, + { + projectId: 'ccc', + type: 'project', + }, + ], + appDeployment: [ + { + projectId: 'bbb', + type: 'project', + }, + { + projectId: 'ccc', + type: 'project', + }, + ], + }); + }); + test('target granular: access to single target', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { + mode: 'granular', + targets: [ + { + id: 'ccc', + type: 'target', + appDeployments: { mode: '*' }, + services: { mode: '*' }, + }, + ], + }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + target: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + service: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + appDeployment: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + }); + }); + test('target granular: access to multiple targets', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { + mode: 'granular', + targets: [ + { + id: 'ccc', + type: 'target', + appDeployments: { mode: '*' }, + services: { mode: '*' }, + }, + { + id: 'ddd', + type: 'target', + appDeployments: { mode: '*' }, + services: { mode: '*' }, + }, + ], + }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + target: [ + { + targetId: 'ccc', + type: 'target', + }, + { + targetId: 'ddd', + type: 'target', + }, + ], + service: [ + { + targetId: 'ccc', + type: 'target', + }, + { + targetId: 'ddd', + type: 'target', + }, + ], + appDeployment: [ + { + targetId: 'ccc', + type: 'target', + }, + { + targetId: 'ddd', + type: 'target', + }, + ], + }); + }); + test('service granular: access to single service', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { + mode: 'granular', + targets: [ + { + id: 'ccc', + type: 'target', + appDeployments: { mode: '*' }, + services: { + mode: 'granular', + services: [ + { + serviceName: 'my-service', + type: 'service', + }, + ], + }, + }, + ], + }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + target: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + service: [ + { + serviceName: 'my-service', + targetId: 'ccc', + type: 'service', + }, + ], + appDeployment: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + }); + }); + test('service granular: access to multiple services', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { + mode: 'granular', + targets: [ + { + id: 'ccc', + type: 'target', + appDeployments: { mode: '*' }, + services: { + mode: 'granular', + services: [ + { + serviceName: 'my-service', + type: 'service', + }, + { + serviceName: 'my-other-service', + type: 'service', + }, + ], + }, + }, + ], + }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + target: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + service: [ + { + serviceName: 'my-service', + targetId: 'ccc', + type: 'service', + }, + { + serviceName: 'my-other-service', + targetId: 'ccc', + type: 'service', + }, + ], + appDeployment: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + }); + }); + test('app deployment granular: access to single app deployment', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { + mode: 'granular', + targets: [ + { + id: 'ccc', + type: 'target', + appDeployments: { + mode: 'granular', + appDeployments: [{ appName: 'my-app', type: 'appDeployment' }], + }, + services: { + mode: 'granular', + services: [], + }, + }, + ], + }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + target: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + service: [], + appDeployment: [ + { + targetId: 'ccc', + appDeploymentName: 'my-app', + type: 'appDeployment', + }, + ], + }); + }); + test('app deployment granular: access to multiple app deployments', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { + mode: 'granular', + targets: [ + { + id: 'ccc', + type: 'target', + appDeployments: { + mode: 'granular', + appDeployments: [ + { appName: 'my-app', type: 'appDeployment' }, + { appName: 'my-other-app', type: 'appDeployment' }, + ], + }, + services: { + mode: 'granular', + services: [], + }, + }, + ], + }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + target: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + service: [], + appDeployment: [ + { + targetId: 'ccc', + appDeploymentName: 'my-app', + type: 'appDeployment', + }, + { + targetId: 'ccc', + appDeploymentName: 'my-other-app', + type: 'appDeployment', + }, + ], + }); + }); +}); 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..69e3937072 --- /dev/null +++ b/packages/services/api/src/modules/organization/providers/organization-members.ts @@ -0,0 +1,703 @@ +import { Inject, Injectable, Scope } from 'graphql-modules'; +import { sql, type DatabasePool } from 'slonik'; +import { z } from 'zod'; +import * as GraphQLSchema from '../../../__generated__/types'; +import { type Organization, type Project } from '../../../shared/entities'; +import { batchBy } from '../../../shared/helpers'; +import { isUUID } from '../../../shared/is-uuid'; +import { AppDeploymentNameModel } from '../../app-deployments/providers/app-deployments'; +import { Logger } from '../../shared/providers/logger'; +import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; +import { Storage } from '../../shared/providers/storage'; +import { OrganizationMemberRoles, type OrganizationMemberRole } from './organization-member-roles'; + +const WildcardAssignmentModeModel = z.literal('*'); +const GranularAssignmentModeModel = z.literal('granular'); + +const WildcardAssignmentMode = z.object({ + mode: WildcardAssignmentModeModel, +}); + +const AppDeploymentAssignmentModel = z.object({ + type: z.literal('appDeployment'), + appName: z.string(), +}); + +const ServiceAssignmentModel = z.object({ type: z.literal('service'), serviceName: z.string() }); + +const AssignedServicesModel = z.union([ + z.object({ + mode: GranularAssignmentModeModel, + services: z + .array(ServiceAssignmentModel) + .optional() + .nullable() + .transform(value => value ?? []), + }), + WildcardAssignmentMode, +]); + +const AssignedAppDeploymentsModel = z.union([ + z.object({ + mode: GranularAssignmentModeModel, + appDeployments: z.array(AppDeploymentAssignmentModel), + }), + WildcardAssignmentMode, +]); + +const TargetAssignmentModel = z.object({ + type: z.literal('target'), + id: z.string().uuid(), + services: AssignedServicesModel, + appDeployments: AssignedAppDeploymentsModel, +}); + +const AssignedTargetsModel = z.union([ + z.object({ + mode: GranularAssignmentModeModel, + targets: z.array(TargetAssignmentModel), + }), + WildcardAssignmentMode, +]); + +const ProjectAssignmentModel = z.object({ + type: z.literal('project'), + id: z.string().uuid(), + targets: AssignedTargetsModel, +}); + +const GranularAssignedProjectsModel = z.object({ + mode: GranularAssignmentModeModel, + projects: z.array(ProjectAssignmentModel), +}); + +/** + * Tree data structure that represents the resources assigned to an organization member. + * + * Together with the assigned member role, these are used to determine whether a user is allowed + * or not allowed to perform an action on a specific resource (project, target, service, or app deployment). + * + * If no resources are assigned to a member role, the permissions are granted on all the resources within the + * organization. + */ +const AssignedProjectsModel = z.union([GranularAssignedProjectsModel, WildcardAssignmentMode]); + +/** + * Resource assignments as stored within the database. + */ +type ResourceAssignmentGroup = z.TypeOf; +type GranularAssignedProjects = z.TypeOf; + +const RawOrganizationMembershipModel = z.object({ + userId: z.string(), + roleId: z.string(), + connectedToZendesk: z + .boolean() + .nullable() + .transform(value => value ?? false), + /** + * Resources that are assigned to the membership + * If no resources are defined the permissions of the role are applied to all resources within the organization. + */ + assignedResources: AssignedProjectsModel.nullable().transform( + value => value ?? { mode: '*' as const, projects: [] }, + ), +}); + +export type OrganizationMembershipRoleAssignment = { + role: OrganizationMemberRole; + /** + * Resource assignments as stored within the database. + * They are used for displaying the selection UI on the frontend. + */ + 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, + private storage: Storage, + logger: Logger, + ) { + this.logger = logger.child({ + source: 'OrganizationMembers', + }); + } + + private async findOrganizationMembers( + organizationId: string, + userIds: Array | null = null, + ) { + const query = sql` + SELECT + ${organizationMemberFields(sql`"om"`)} + 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.'); + } + + const resources: ResourceAssignmentGroup = record.assignedResources ?? { + mode: '*', + projects: [], + }; + + const resolvedResources = resolveResourceAssignment({ + organizationId: organization.id, + projects: resources, + }); + + organizationMembershipByUserId.set(record.userId, { + organizationId: organization.id, + userId: record.userId, + isOwner: organization.ownerId === record.userId, + connectedToZendesk: record.connectedToZendesk, + assignedRole: { + resources, + resolvedResources, + 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): Promise { + return this.findOrganizationMembership({ + organization, + userId: organization.ownerId, + }); + } + + async findOrganizationMembershipByEmail( + organization: Organization, + email: string, + ): Promise { + this.logger.debug( + 'Find organization membership by email. (organizationId=%s, email=%s)', + organization.id, + email, + ); + const query = sql` + SELECT + ${organizationMemberFields(sql`"om"`)} + FROM + "organization_member" AS "om" + INNER JOIN "users" AS "u" + ON "u"."id" = "om"."user_id" + WHERE + "om"."organization_id" = ${organization.id} + AND lower("u"."email") = lower(${email}) + LIMIT 1 + `; + + const result = await this.pool.maybeOne(query); + if (result === null) { + return null; + } + + const membership = RawOrganizationMembershipModel.parse(result); + const mapping = await this.resolveMemberships(organization, [membership]); + return mapping.get(membership.userId) ?? null; + } + + async assignOrganizationMemberRole(args: { + organizationId: string; + roleId: string; + userId: string; + resourceAssignmentGroup: ResourceAssignmentGroup; + }) { + await this.pool.query( + sql`/* assignOrganizationMemberRole */ + UPDATE + "organization_member" + SET + "role_id" = ${args.roleId} + , "assigned_resources" = ${JSON.stringify( + /** we parse it to avoid additional properties being stored within the database. */ + AssignedProjectsModel.parse(args.resourceAssignmentGroup), + )} + WHERE + "organization_id" = ${args.organizationId} + AND "user_id" = ${args.userId} + `, + ); + } + + /** + * This method translates the database stored member resource assignment to the GraphQL layer + * exposed resource assignment. + * + * Note: This currently by-passes access checks, granting the viewer read access to all resources + * within the organization. + */ + async resolveGraphQLMemberResourceAssignment( + member: OrganizationMembership, + ): Promise { + if (member.assignedRole.resources.mode === '*') { + return { mode: 'all' }; + } + const projects = await this.storage.findProjectsByIds({ + projectIds: member.assignedRole.resources.projects.map(project => project.id), + }); + + const filteredProjects = member.assignedRole.resources.projects.filter(row => + projects.get(row.id), + ); + + const targetAssignments = filteredProjects.flatMap(project => + project.targets.mode === 'granular' ? project.targets.targets : [], + ); + + const targets = await this.storage.findTargetsByIds({ + organizationId: member.organizationId, + targetIds: targetAssignments.map(target => target.id), + }); + + return { + mode: 'granular' as const, + projects: filteredProjects + .map(projectAssignment => { + const project = projects.get(projectAssignment.id); + if (!project || project.orgId !== member.organizationId) { + return null; + } + + return { + projectId: project.id, + project, + targets: + projectAssignment.targets.mode === '*' + ? { mode: 'all' as const } + : { + mode: 'granular' as const, + targets: projectAssignment.targets.targets + .map(targetAssignment => { + const target = targets.get(targetAssignment.id); + if (!target) return null; + + return { + targetId: target.id, + target, + services: + targetAssignment.services.mode === '*' + ? { mode: 'all' as const } + : { + mode: 'granular' as const, + services: targetAssignment.services.services.map( + service => service.serviceName, + ), + }, + appDeployments: + targetAssignment.appDeployments.mode === '*' + ? { mode: 'all' as const } + : { + mode: 'granular' as const, + appDeployments: + targetAssignment.appDeployments.appDeployments.map( + deployment => deployment.appName, + ), + }, + }; + }) + .filter(isSome), + }, + }; + }) + .filter(isSome), + }; + } + + /** + * Transforms and resolves a {GraphQL.MemberResourceAssignmentInput} to a {ResourceAssignmentGroup} + * that can be stored within our database + * + * - Projects and Targets that can not be found in our database are omitted from the resolved object. + * - Projects and Targets that do not follow the hierarchical structure are omitted from teh resolved object. + * + * These measures are done in order to prevent users to grant access to other organizations. + */ + async transformGraphQLMemberResourceAssignmentInputToResourceAssignmentGroup( + organization: Organization, + input: GraphQLSchema.ResourceAssignmentInput, + ): Promise { + if ( + !input.projects || + // No need to resolve the projects if mode "all" is used. + // We will not store the selection in the database. + input.mode === 'all' + ) { + return { + mode: '*', + }; + } + + /** Mutable array that we populate with the resolved data from the database */ + const resourceAssignmentGroup: GranularAssignedProjects = { + mode: 'granular', + projects: [], + }; + + const sanitizedProjects = input.projects.filter(project => isUUID(project.projectId)); + + const projects = await this.storage.findProjectsByIds({ + projectIds: sanitizedProjects.map(record => record.projectId), + }); + + // In case we are not assigning all targets to the project, + // we need to load all the targets/projects that would be assigned + // for verifying they belong to the organization and/or project. + // This prevents breaking permission boundaries through fault/sus input. + const targetLookupIds = new Set(); + const projectTargetAssignments: Array<{ + project: Project; + /** mutable array that is within "resourceAssignmentGroup" */ + projectTargets: Array>; + targets: readonly GraphQLSchema.TargetResourceAssignmentInput[]; + }> = []; + + for (const record of sanitizedProjects) { + const project = projects.get(record.projectId); + + // In case the project was not found or does not belogn the the organization, + // we omit it as it could grant an user permissions for a project within another organization. + if (!project || project.orgId !== organization.id) { + this.logger.debug('Omitted non-existing project.'); + continue; + } + + const projectTargets: Array> = []; + + resourceAssignmentGroup.projects.push({ + type: 'project', + id: project.id, + targets: { + mode: record.targets.mode === 'all' ? '*' : 'granular', + targets: projectTargets, + }, + }); + + // No need to resolve the projects if mode "a;ll" is used. + // We will not store the selection in the database. + if (record.targets.mode === 'all') { + continue; + } + + if (record.targets.targets) { + const sanitizedTargets = record.targets.targets.filter(target => isUUID(target.targetId)); + for (const target of sanitizedTargets) { + targetLookupIds.add(target.targetId); + } + projectTargetAssignments.push({ + projectTargets, + targets: sanitizedTargets, + project, + }); + } + } + + const targets = await this.storage.findTargetsByIds({ + organizationId: organization.id, + targetIds: Array.from(targetLookupIds), + }); + + for (const record of projectTargetAssignments) { + for (const targetRecord of record.targets) { + const target = targets.get(targetRecord.targetId); + + // In case the target was not found or does not belogn the the organization, + // we omit it as it could grant an user permissions for a target within another organization. + if (!target || target.projectId !== record.project.id) { + this.logger.debug('Omitted non-existing target.'); + continue; + } + + record.projectTargets.push({ + type: 'target', + id: target.id, + services: + // monolith schemas do not have services. + record.project.type === GraphQLSchema.ProjectType.SINGLE || + targetRecord.services.mode === 'all' + ? { mode: '*' } + : { + mode: 'granular', + services: + // TODO: it seems like we do not validate service names + targetRecord.services.services?.map(record => ({ + type: 'service', + serviceName: record?.serviceName, + })) ?? [], + }, + appDeployments: + targetRecord.appDeployments.mode === 'all' + ? { mode: '*' } + : { + mode: 'granular', + appDeployments: + targetRecord.appDeployments.appDeployments + ?.filter(name => AppDeploymentNameModel.safeParse(name).success) + .map(record => ({ + type: 'appDeployment', + appName: record.appDeployment, + })) ?? [], + }, + }); + } + } + + return resourceAssignmentGroup; + } +} + +function isSome(input: T | null): input is Exclude { + return input != null; +} + +const organizationMemberFields = (prefix = sql`"organization_member"`) => sql` + ${prefix}."user_id" AS "userId" + , ${prefix}."role_id" AS "roleId" + , ${prefix}."connected_to_zendesk" AS "connectedToZendesk" + , ${prefix}."assigned_resources" AS "assignedResources" +`; + +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; + service: OrganizationAssignment | Array; + appDeployment: + | OrganizationAssignment + | 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. + */ +export function resolveResourceAssignment(args: { + organizationId: string; + projects: ResourceAssignmentGroup; +}): ResolvedResourceAssignments { + const organizationAssignment: OrganizationAssignment = { + type: 'organization', + organizationId: args.organizationId, + }; + + if (args.projects.mode === '*') { + return { + organization: organizationAssignment, + project: organizationAssignment, + target: organizationAssignment, + appDeployment: organizationAssignment, + service: organizationAssignment, + }; + } + + const projectAssignments: ResolvedResourceAssignments['project'] = []; + const targetAssignments: ResolvedResourceAssignments['target'] = []; + const serviceAssignments: ResolvedResourceAssignments['service'] = []; + const appDeploymentAssignments: ResolvedResourceAssignments['appDeployment'] = []; + + for (const project of args.projects.projects) { + const projectAssignment: ProjectAssignment = { + type: 'project', + projectId: project.id, + }; + projectAssignments.push(projectAssignment); + + if (project.targets.mode === '*') { + // allow actions on all sub-resources of this project + targetAssignments.push(projectAssignment); + serviceAssignments.push(projectAssignment); + appDeploymentAssignments.push(projectAssignment); + continue; + } + + for (const target of project.targets.targets) { + const targetAssignment: TargetAssignment = { + type: 'target', + targetId: target.id, + }; + + targetAssignments.push(targetAssignment); + + // services + if (target.services.mode === '*') { + // allow actions on all services of this target + serviceAssignments.push(targetAssignment); + } else { + for (const service of target.services.services) { + serviceAssignments.push({ + type: 'service', + targetId: target.id, + serviceName: service.serviceName, + }); + } + } + + // app deployments + if (target.appDeployments.mode === '*') { + // allow actions on all app deployments of this target + appDeploymentAssignments.push(targetAssignment); + } else { + for (const appDeployment of target.appDeployments.appDeployments) { + appDeploymentAssignments.push({ + type: 'appDeployment', + targetId: target.id, + appDeploymentName: appDeployment.appName, + }); + } + } + } + } + + return { + organization: organizationAssignment, + project: projectAssignments, + target: targetAssignments, + service: serviceAssignments, + appDeployment: appDeploymentAssignments, + }; +} diff --git a/packages/services/api/src/modules/organization/resolvers/Member.ts b/packages/services/api/src/modules/organization/resolvers/Member.ts index 9e450fd00d..55d2b542e3 100644 --- a/packages/services/api/src/modules/organization/resolvers/Member.ts +++ b/packages/services/api/src/modules/organization/resolvers/Member.ts @@ -1,32 +1,41 @@ +import { Storage } from '../../shared/providers/storage'; import { OrganizationManager } from '../providers/organization-manager'; +import { OrganizationMembers } from '../providers/organization-members'; 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, - }); + const { result } = await injector.get(OrganizationManager).canLeaveOrganization(member); return result; }, - isAdmin: (member, _, { injector }) => { - return member.isOwner || injector.get(OrganizationManager).isAdminRole(member.role); - }, viewerCanRemove: async (member, _arg, { session }) => { if (member.isOwner) { return false; } return await session.canPerformAction({ - action: 'member:removeMember', - organizationId: member.organization, + action: 'member:modify', + 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; + }, + resourceAssignment: async (member, _arg, { injector }) => { + return injector.get(OrganizationMembers).resolveGraphQLMemberResourceAssignment(member); + }, }; 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..055581b280 100644 --- a/packages/services/api/src/modules/organization/resolvers/MemberRole.ts +++ b/packages/services/api/src/modules/organization/resolvers/MemberRole.ts @@ -1,77 +1,46 @@ -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 { Permission, Session } from '../../auth/lib/authz'; 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:modify', 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:modify', 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:modify', 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 Array.from(Object.values(role.permissions)).flatMap((set: Set) => + Array.from(set), + ); }, }; 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..40a1a07fb7 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,11 +6,10 @@ 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, + resources: input.resources, }); }; 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..71281a6395 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 }) => { @@ -139,7 +148,7 @@ export const Organization: Pick< viewerCanManageInvitations: (organization, _arg, { session }) => { return session.canPerformAction({ - action: 'member:manageInvites', + action: 'member:modify', organizationId: organization.id, params: { organizationId: organization.id, @@ -148,7 +157,7 @@ export const Organization: Pick< }, viewerCanAssignUserRoles: (organization, _arg, { session }) => { return session.canPerformAction({ - action: 'member:assignRole', + action: 'member:modify', organizationId: organization.id, params: { organizationId: organization.id, @@ -157,7 +166,7 @@ export const Organization: Pick< }, viewerCanManageRoles: (organization, _arg, { session }) => { return session.canPerformAction({ - action: 'member:modifyRole', + action: 'member:modify', organizationId: organization.id, params: { organizationId: organization.id, @@ -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/schema/providers/schema-version-helper.ts b/packages/services/api/src/modules/schema/providers/schema-version-helper.ts index 86bc1df38c..63ba25c676 100644 --- a/packages/services/api/src/modules/schema/providers/schema-version-helper.ts +++ b/packages/services/api/src/modules/schema/providers/schema-version-helper.ts @@ -11,7 +11,6 @@ import { import { ProjectType } from '../../../shared/entities'; import { cache } from '../../../shared/helpers'; import { parseGraphQLSource } from '../../../shared/schema'; -import { OrganizationManager } from '../../organization/providers/organization-manager'; import { ProjectManager } from '../../project/providers/project-manager'; import { Logger } from '../../shared/providers/logger'; import { Storage } from '../../shared/providers/storage'; @@ -33,7 +32,6 @@ export class SchemaVersionHelper { private schemaManager: SchemaManager, private schemaHelper: SchemaHelper, private projectManager: ProjectManager, - private organizationManager: OrganizationManager, private registryChecks: RegistryChecks, private storage: Storage, private logger: Logger, @@ -57,7 +55,7 @@ export class SchemaVersionHelper { organizationId: schemaVersion.organizationId, projectId: schemaVersion.projectId, }), - this.organizationManager.getOrganization({ + this.storage.getOrganization({ organizationId: schemaVersion.organizationId, }), ]); diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index c57f242e9c..3701c38227 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, @@ -95,8 +94,6 @@ export interface Storage { createOrganization( _: Pick & { userId: string; - adminScopes: ReadonlyArray; - viewerScopes: ReadonlyArray; reservedSlugs: string[]; }, ): Promise< @@ -198,40 +195,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; - userId: string; - }): Promise; deleteOrganizationMemberRole(_: { organizationId: string; roleId: string }): Promise; getProject(_: ProjectSelector): Promise; @@ -242,6 +205,8 @@ export interface Storage { getProjects(_: OrganizationSelector): Promise; + findProjectsByIds(args: { projectIds: Array }): Promise>; + createProject(_: Pick & { slug: string } & OrganizationSelector): Promise< | { ok: true; @@ -339,6 +304,11 @@ export interface Storage { getTargets(_: ProjectSelector): Promise; + findTargetsByIds(args: { + organizationId: string; + targetIds: Array; + }): Promise>; + getTargetIdsOfOrganization(_: OrganizationSelector): Promise; getTargetIdsOfProject(_: ProjectSelector): Promise; getTargetSettings(_: TargetSelector): Promise; 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..9fd8619f27 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,21 +72,6 @@ export class TokenManager { throw new HiveError('User is not a member of the organization'); } - const newScopes = [...input.organizationScopes, ...input.projectScopes, ...input.targetScopes]; - - // 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), - ); - - 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); pushIfMissing(scopes, OrganizationAccessScope.READ); diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index f61f85abda..0b4d31de8a 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; } @@ -201,7 +202,7 @@ export interface OrganizationInvitation { email: string; created_at: string; expires_at: string; - role: OrganizationMemberRole; + roleId: string; } export interface OrganizationBilling { @@ -443,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/api/src/shared/helpers.ts b/packages/services/api/src/shared/helpers.ts index ba11f10059..83d2312245 100644 --- a/packages/services/api/src/shared/helpers.ts +++ b/packages/services/api/src/shared/helpers.ts @@ -413,3 +413,16 @@ export function nsToMs(ns: number) { export function msToNs(ms: number) { return ms * NS_TO_MS; } + +/** Typed Object.fromEntries */ +export function objectFromEntries<$Key extends string, $Value>( + entries: Array<[$Key, $Value]>, +): Record<$Key, $Value> { + return Object.fromEntries(entries) as Record<$Key, $Value>; +} + +export function objectEntries<$Key extends string, $Value>( + object: Record<$Key, $Value>, +): Array<[$Key, $Value]> { + return Object.entries(object) as Array<[$Key, $Value]>; +} diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index b4e6627314..54d286069c 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -14,7 +14,15 @@ import { hostname } from 'os'; import { createPubSub } from 'graphql-yoga'; import { z } from 'zod'; import formDataPlugin from '@fastify/formbody'; -import { createRegistry, createTaskRunner, CryptoProvider, LogFn, Logger } from '@hive/api'; +import { + createRegistry, + createTaskRunner, + CryptoProvider, + LogFn, + Logger, + OrganizationMemberRoles, + 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'; @@ -401,6 +409,12 @@ export async function main() { new SuperTokensUserAuthNStrategy({ logger: server.log, storage, + organizationMembers: new OrganizationMembers( + storage.pool, + new OrganizationMemberRoles(storage.pool, server.log), + storage, + server.log, + ), }), new TargetAccessTokenStrategy({ logger: server.log, diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 439de62164..fc501ff46c 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -166,6 +166,7 @@ export interface organization_invitations { } export interface organization_member { + assigned_resources: any | null; connected_to_zendesk: boolean; organization_id: string; role: user_role; @@ -180,7 +181,8 @@ export interface organization_member_roles { locked: boolean; name: string; organization_id: string; - scopes: Array; + permissions: Array | null; + scopes: Array | null; } export interface organizations { diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 9c99228a07..0fa4d00c07 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -19,20 +19,20 @@ import type { Project, Schema, Storage, + Target, TargetSettings, } from '@hive/api'; 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, type SchemaLog, type SchemaPolicy, } from '../../api/src/shared/entities'; -import { batch } from '../../api/src/shared/helpers'; +import { batch, batchBy } from '../../api/src/shared/helpers'; import { alert_channels, alerts, @@ -227,7 +227,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, }; } @@ -506,7 +506,7 @@ export async function createStorage( ${args.userId}, ( COALESCE( - (SELECT default_role_id FROM oidc_integrations + (SELECT default_role_id FROM oidc_integrations WHERE id = ${args.oidcIntegrationId}), (SELECT id FROM organization_member_roles WHERE organization_id = ${linkedOrganizationId} AND name = 'Viewer') @@ -525,15 +525,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; }, }; @@ -699,20 +702,17 @@ export async function createStorage( organization_id, name, description, - scopes, locked ) VALUES ( ${org.id}, 'Admin', 'Full access to all organization resources', - ${sql.array(input.adminScopes, 'text')}, true ), ( ${org.id}, 'Viewer', 'Read-only access to all organization resources', - ${sql.array(input.viewerScopes, 'text')}, true ) RETURNING id, name @@ -965,72 +965,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< @@ -1077,7 +1011,7 @@ export async function createStorage( WHERE organization_id = ${organizationId} AND id = ${roleId} - AND locked = false + AND locked = false AND ( SELECT count(*) FROM organization_member @@ -1153,7 +1087,7 @@ export async function createStorage( }, async updateOrganizationRateLimits({ monthlyRateLimit, organizationId: organization }) { return transformOrganization( - await pool.one>(sql`/* updateOrganizationRateLimits */ + await pool.one>(sql`/* updateOrganizationRateLimits */ UPDATE organizations SET limit_operations_monthly = ${monthlyRateLimit.operations}, limit_retention_days = ${monthlyRateLimit.retentionInDays} WHERE id = ${organization} @@ -1305,7 +1239,7 @@ export async function createStorage( return; } - const adminRole = await shared.getOrganizationMemberRoleByName( + const adminRoleId = await shared.getOrganizationMemberRoleByName( { organizationId: organization, roleName: 'Admin', @@ -1316,7 +1250,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,43 +1275,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 */ - UPDATE organization_member - SET role_id = ${roleId} - WHERE organization_id = ${organizationId} AND user_id = ${userId} - `, - ); - }, async getProjectId({ projectSlug, organizationSlug }) { // Based on project's clean_id and organization's clean_id, resolve the actual uuid of the project const result = await pool.one>( @@ -1396,7 +1293,7 @@ export async function createStorage( SELECT t.id FROM targets as t LEFT JOIN projects AS p ON (p.id = t.project_id) LEFT JOIN organizations AS o ON (o.id = p.org_id) - WHERE + WHERE t.clean_id = ${selector.targetSlug} AND p.clean_id = ${selector.projectSlug} AND o.clean_id = ${selector.organizationSlug} AND @@ -1501,6 +1398,36 @@ export async function createStorage( return result.rows.map(transformProject); }, + findProjectsByIds: batch<{ projectIds: Array }, Map>( + async function FindProjectByIdsBatchHandler(args) { + const allProjectIds = args.flatMap(args => args.projectIds); + const allProjectsLookupMap = new Map(); + + if (allProjectIds.length === 0) { + return args.map(async () => allProjectsLookupMap); + } + + const result = await pool.query>( + sql`/* findProjectsByIds */ SELECT * FROM projects WHERE id = ANY(${sql.array(allProjectIds, 'uuid')}) AND type != 'CUSTOM'`, + ); + + result.rows.forEach(row => { + const project = transformProject(row); + allProjectsLookupMap.set(project.id, project); + }); + + return args.map(async arg => { + const map = new Map(); + for (const projectId of arg.projectIds) { + const project = allProjectsLookupMap.get(projectId); + if (!project) continue; + map.set(projectId, project); + } + + return map; + }); + }, + ), async updateProjectSlug({ slug, organizationId: organization, projectId: project }) { return pool.transaction(async t => { const projectSlugExists = await t.exists( @@ -1792,6 +1719,50 @@ export async function createStorage( orgId: organization, })); }, + findTargetsByIds: batchBy< + { + organizationId: string; + targetIds: Array; + }, + Map + >( + org => org.organizationId, + async function FindTargetsByIdsBatchHandler(args) { + const resultLookupMap = new Map(); + + const allTargetIds = args.flatMap(arg => arg.targetIds); + + if (allTargetIds.length === 0) { + return args.map(async () => resultLookupMap); + } + + const orgId = args[0].organizationId; + + const results = await pool.query(sql`/* getTargets */ + SELECT + ${targetSQLFields} + FROM + "targets" + WHERE + "id" = ANY(${sql.array(allTargetIds, 'uuid')}) + `); + + for (const row of results.rows) { + const target: Target = { ...TargetModel.parse(row), orgId }; + resultLookupMap.set(target.id, target); + } + + return args.map(async arg => { + const map = new Map(); + for (const targetId of arg.targetIds) { + const target = resultLookupMap.get(targetId); + if (!target) continue; + map.set(targetId, target); + } + return map; + }); + }, + ), async getTargetIdsOfOrganization({ organizationId: organization }) { const results = await pool.query>>( sql`/* getTargetIdsOfOrganization */ @@ -1951,7 +1922,7 @@ export async function createStorage( }>(sql`/* countPeriodSchemaVersionsOfProject */ SELECT COUNT(*) as total FROM schema_versions as sv LEFT JOIN targets as t ON (t.id = sv.target_id) - WHERE + WHERE t.project_id = ${project} AND sv.created_at >= ${period.from.toISOString()} AND sv.created_at < ${period.to.toISOString()} @@ -1973,7 +1944,7 @@ export async function createStorage( total: number; }>(sql`/* countPeriodSchemaVersionsOfTarget */ SELECT COUNT(*) as total FROM schema_versions - WHERE + WHERE target_id = ${target} AND created_at >= ${period.from.toISOString()} AND created_at < ${period.to.toISOString()} @@ -2293,7 +2264,7 @@ export async function createStorage( async getVersion({ projectId: project, targetId: target, versionId: version }) { const result = await pool.one(sql`/* getVersion */ - SELECT + SELECT ${schemaVersionSQLFields(sql`sv.`)} FROM schema_versions as sv LEFT JOIN schema_log as sl ON (sl.id = sv.action_id) @@ -2320,7 +2291,7 @@ export async function createStorage( } const query = sql`/* getPaginatedSchemaVersionsForTargetId */ - SELECT + SELECT ${schemaVersionSQLFields()} FROM "schema_versions" @@ -2904,7 +2875,7 @@ export async function createStorage( `); // get organizations data - const organizationsResult = pool.query>(sql`/* adminGetOrganizations */ + const organizationsResult = pool.query>(sql`/* adminGetOrganizations */ SELECT * FROM organizations `); @@ -2987,7 +2958,7 @@ export async function createStorage( }, async deleteOrganizationBilling(selector) { await pool.query>( - sql`/* deleteOrganizationBilling */ + sql`/* deleteOrganizationBilling */ DELETE FROM organizations_billing WHERE organization_id = ${selector.organizationId}`, ); @@ -3212,7 +3183,7 @@ export async function createStorage( return tracedTransaction('updateOIDCDefaultMemberRole', pool, async trx => { // Make sure the role exists and is associated with the organization const roleId = await trx.oneFirst(sql`/* checkRoleExists */ - SELECT id FROM "organization_member_roles" + SELECT id FROM "organization_member_roles" WHERE "id" = ${args.roleId} AND "organization_id" = ( @@ -3293,7 +3264,7 @@ export async function createStorage( async getCDNAccessTokenById(args) { const result = await pool.maybeOne(sql`/* getCDNAccessTokenById */ - SELECT + SELECT "id" , "target_id" , "s3_key" @@ -3414,7 +3385,7 @@ export async function createStorage( DO UPDATE SET "config" = ${sql.jsonb(input.policy)}, "allow_overriding" = ${input.allowOverrides}, - "updated_at" = now() + "updated_at" = now() RETURNING *; `); @@ -3429,7 +3400,7 @@ export async function createStorage( (resource_type, resource_id) DO UPDATE SET "config" = ${sql.jsonb(input.policy)}, - "updated_at" = now() + "updated_at" = now() RETURNING *; `); @@ -4074,7 +4045,7 @@ export async function createStorage( "id" = ${args.schemaCheckId} AND "is_success" = false AND "schema_composition_errors" IS NULL - RETURNING + RETURNING "id" `); } else if (didUpdateContractChecks) { @@ -4092,7 +4063,7 @@ export async function createStorage( "id" = ${args.schemaCheckId} AND "is_success" = false AND "schema_composition_errors" IS NULL - RETURNING + RETURNING "id" `); } @@ -5170,7 +5141,7 @@ export const userFields = ( , ${user}"supertoken_user_id" AS "superTokensUserId" , ${user}"is_admin" AS "isAdmin" , ${user}"oidc_integration_id" AS "oidcIntegrationId" - , ${user}"zendesk_user_id" AS "zendeskId" + , ${user}"zendesk_user_id" AS "zendeskId" , ${superTokensThirdParty}"third_party_id" AS "provider" `; 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/common.tsx b/packages/web/app/src/components/organization/members/common.tsx index 9ce146ac5a..871c861dd5 100644 --- a/packages/web/app/src/components/organization/members/common.tsx +++ b/packages/web/app/src/components/organization/members/common.tsx @@ -20,6 +20,7 @@ type Role = { } & T; export function RoleSelector(props: { + className?: string; roles: readonly Role[]; defaultRole?: Role; isRoleActive(role: Role): @@ -47,7 +48,7 @@ export function RoleSelector(props: { + + + + ); +} diff --git a/packages/web/app/src/components/organization/members/member-role-selector.tsx b/packages/web/app/src/components/organization/members/member-role-selector.tsx new file mode 100644 index 0000000000..3c1c863450 --- /dev/null +++ b/packages/web/app/src/components/organization/members/member-role-selector.tsx @@ -0,0 +1,75 @@ +import { FragmentType, graphql, useFragment } from '@/gql'; +import { RoleSelector } from './common'; + +const MemberRoleSelector_OrganizationFragment = graphql(` + fragment MemberRoleSelector_OrganizationFragment on Organization { + id + slug + viewerCanAssignUserRoles + owner { + id + } + memberRoles { + id + name + description + locked + } + } +`); + +const MemberRoleSelector_MemberFragment = graphql(` + fragment MemberRoleSelector_MemberFragment on Member { + id + role { + id + } + user { + id + } + } +`); + +export function MemberRoleSelector(props: { + organization: FragmentType; + member: FragmentType; + selectedRoleId: string; + onSelectRoleId: (roleId: string) => void; +}) { + const organization = useFragment(MemberRoleSelector_OrganizationFragment, props.organization); + const member = useFragment(MemberRoleSelector_MemberFragment, props.member); + const canAssignRole = organization.viewerCanAssignUserRoles; + const roles = organization.memberRoles ?? []; + + const memberRole = roles.find(role => role.id === props.selectedRoleId); + + if (!memberRole || !member) { + console.error('No role or member provided to MemberRoleSelector'); + return null; + } + + return ( + { + props.onSelectRoleId(role.id); + }} + defaultRole={memberRole} + disabled={!canAssignRole} + isRoleActive={role => { + const isCurrentRole = role.id === member.id; + if (isCurrentRole) { + return { + active: false, + reason: 'This is the current role', + }; + } + + return { + active: true, + }; + }} + /> + ); +} 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..841c5e7187 --- /dev/null +++ b/packages/web/app/src/components/organization/members/permission-selector.tsx @@ -0,0 +1,250 @@ +import { useMemo, useRef, useState } from 'react'; +import { InfoIcon, TriangleAlert } 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 + warning + } + } + } +`); + +type AvailableMembershipPermissions = ResultOf< + typeof PermissionSelector_OrganizationFragment +>['availableMemberPermissionGroups']; + +type MembershipPermissionGroup = AvailableMembershipPermissions[number]; + +export type PermissionSelectorProps = { + isReadOnly?: boolean; + organization: FragmentType; + selectedPermissionIds: ReadonlySet; + onSelectedPermissionsChange: (selectedPermissionIds: ReadonlySet) => void; +}; + +export function PermissionSelector(props: PermissionSelectorProps) { + const organization = useFragment(PermissionSelector_OrganizationFragment, props.organization); + const [groups, permissionToGroupTitleMapping, dependencyGraph] = useMemo(() => { + const filteredGroups: Array< + MembershipPermissionGroup & { + selectedPermissionCount: number; + } + > = []; + const permissionToGroupTitleMapping = new Map(); + const dependencyGraph = new Map>(); + + for (const group of organization.availableMemberPermissionGroups) { + let selectedPermissionCount = 0; + + for (const permission of group.permissions) { + 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({ + ...group, + selectedPermissionCount, + }); + } + + return [filteredGroups, permissionToGroupTitleMapping, dependencyGraph] 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)} + > + {groups.map(group => { + 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.warning && props.selectedPermissionIds.has(permission.id) ? ( +
+ + + + + + {permission.warning} + + +
+ ) : ( + !!permission.dependsOnId && + permissionToGroupTitleMapping.has(permission.dependsOnId) && ( +
+ + + + + + +

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

+
+
+
+
+ ) + )} + +
+ {focusedPermission === permission.id && ( +
+ )} +
+ ); + })} + + + ); + })} + + ); +} diff --git a/packages/web/app/src/components/organization/members/resource-selector.tsx b/packages/web/app/src/components/organization/members/resource-selector.tsx new file mode 100644 index 0000000000..260e187321 --- /dev/null +++ b/packages/web/app/src/components/organization/members/resource-selector.tsx @@ -0,0 +1,776 @@ +import { useMemo, useState } from 'react'; +import { produce } from 'immer'; +import { ChevronRightIcon, XIcon } from 'lucide-react'; +import { useQuery } from 'urql'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { graphql, useFragment, type FragmentType } from '@/gql'; +import * as GraphQLSchema from '@/gql/graphql'; +import { cn } from '@/lib/utils'; + +const ResourceSelector_OrganizationFragment = graphql(` + fragment ResourceSelector_OrganizationFragment on Organization { + id + slug + projects { + nodes { + id + slug + type + } + } + } +`); + +const ResourceSelector_OrganizationProjectTargestQuery = graphql(` + query ResourceSelector_OrganizationProjectTargestQuery( + $organizationSlug: String! + $projectSlug: String! + ) { + organization: organizationBySlug(organizationSlug: $organizationSlug) { + id + project: projectBySlug(projectSlug: $projectSlug) { + id + type + targets { + nodes { + id + slug + } + } + } + } + } +`); + +const ResourceSelector_OrganizationProjectTargetQuery = graphql(` + query ResourceSelector_OrganizationProjectTargetQuery( + $organizationSlug: String! + $projectSlug: String! + $targetSlug: String! + ) { + organization: organizationBySlug(organizationSlug: $organizationSlug) { + id + project: projectBySlug(projectSlug: $projectSlug) { + id + type + targets { + nodes { + id + slug + } + } + target: targetBySlug(targetSlug: $targetSlug) { + id + latestValidSchemaVersion { + id + schemas { + nodes { + ... on CompositeSchema { + id + service + } + } + } + } + } + } + } + } +`); + +export function ResourceSelector(props: { + organization: FragmentType; + selection: GraphQLSchema.ResourceAssignmentInput; + onSelectionChange: (selection: GraphQLSchema.ResourceAssignmentInput) => void; +}) { + const organization = useFragment(ResourceSelector_OrganizationFragment, props.organization); + const [breadcrumb, setBreadcrumb] = useState( + null as + | null + | { projectId: string; targetId?: undefined } + | { projectId: string; targetId: string }, + ); + + const projectState = useMemo(() => { + if (props.selection.mode === GraphQLSchema.ResourceAssignmentMode.All) { + return null; + } + + type SelectedItem = { + project: (typeof organization.projects.nodes)[number]; + projectSelection: GraphQLSchema.ProjectResourceAssignmentInput; + }; + + type NotSelectedItem = (typeof organization.projects.nodes)[number]; + + const selectedProjects: Array = []; + const notSelectedProjects: Array = []; + + let activeProject: null | SelectedItem = null; + + for (const project of organization.projects.nodes) { + const projectSelection = props.selection.projects?.find( + item => item.projectId === project.id, + ); + + if (projectSelection) { + selectedProjects.push({ project, projectSelection }); + + if (breadcrumb?.projectId === project.id) { + activeProject = { project, projectSelection }; + } + + continue; + } + + notSelectedProjects.push(project); + } + + return { + selected: selectedProjects, + notSelected: notSelectedProjects, + activeProject, + addProject(item: (typeof organization.projects.nodes)[number]) { + props.onSelectionChange( + produce(props.selection, state => { + state.projects?.push({ + projectId: item.id, + targets: { + mode: GraphQLSchema.ResourceAssignmentMode.Granular, + targets: [], + }, + }); + }), + ); + }, + removeProject(item: (typeof organization.projects.nodes)[number]) { + props.onSelectionChange( + produce(props.selection, state => { + state.projects = state.projects?.filter(project => project.projectId !== item.id); + }), + ); + setBreadcrumb(breadcrumb => { + if (breadcrumb?.projectId === item.id) { + return null; + } + return breadcrumb; + }); + }, + }; + }, [organization.projects.nodes, props.selection, breadcrumb?.projectId]); + + const [organizationProjectTargets] = useQuery({ + query: ResourceSelector_OrganizationProjectTargestQuery, + pause: !projectState?.activeProject, + variables: { + organizationSlug: organization.slug, + projectSlug: projectState?.activeProject?.project.slug ?? '', + }, + }); + + const targetState = useMemo(() => { + if ( + !organizationProjectTargets?.data?.organization?.project?.targets?.nodes || + !projectState?.activeProject + ) { + return null; + } + + const projectId = projectState.activeProject.project.id; + const projectType = projectState.activeProject.project.type; + + if ( + projectState.activeProject.projectSelection.targets.mode === + GraphQLSchema.ResourceAssignmentMode.All + ) { + return { + selection: '*', + setGranular() { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + project.targets.mode = GraphQLSchema.ResourceAssignmentMode.Granular; + }), + ); + }, + } as const; + } + + type SelectedItem = { + target: (typeof organizationProjectTargets.data.organization.project.targets.nodes)[number]; + targetSelection: Exclude< + typeof projectState.activeProject.projectSelection.targets.targets, + null | undefined + >[number]; + }; + + type NotSelectedItem = + (typeof organizationProjectTargets.data.organization.project.targets.nodes)[number]; + + const selected: Array = []; + const notSelected: Array = []; + + let activeTarget: null | { + targetSelection: Exclude< + typeof projectState.activeProject.projectSelection.targets.targets, + null | undefined + >[number]; + target: (typeof organizationProjectTargets.data.organization.project.targets.nodes)[number]; + } = null; + + for (const target of organizationProjectTargets.data.organization.project.targets.nodes) { + const targetSelection = projectState.activeProject.projectSelection.targets.targets?.find( + item => item.targetId === target.id, + ); + + if (targetSelection) { + selected.push({ target, targetSelection }); + + if (breadcrumb?.targetId === target.id) { + activeTarget = { + targetSelection, + target, + }; + } + continue; + } + + notSelected.push(target); + } + + return { + selection: { + selected, + notSelected, + }, + activeTarget, + activeProject: projectState.activeProject, + addTarget( + item: (typeof organizationProjectTargets.data.organization.project.targets.nodes)[number], + ) { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + project.targets.targets?.push({ + targetId: item.id, + appDeployments: { + mode: GraphQLSchema.ResourceAssignmentMode.Granular, + appDeployments: [], + }, + services: { + mode: + // for single projects we choose "All" by default as there is no granular selection available + projectType === GraphQLSchema.ProjectType.Single + ? GraphQLSchema.ResourceAssignmentMode.All + : GraphQLSchema.ResourceAssignmentMode.Granular, + services: [], + }, + }); + }), + ); + }, + removeTarget( + item: (typeof organizationProjectTargets.data.organization.project.targets.nodes)[number], + ) { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + project.targets.targets = project.targets.targets?.filter( + target => target.targetId !== item.id, + ); + }), + ); + setBreadcrumb(breadcrumb => { + if (breadcrumb?.targetId === item.id) { + return { + ...breadcrumb, + targetId: undefined, + }; + } + return breadcrumb; + }); + }, + setAll() { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + project.targets.mode = GraphQLSchema.ResourceAssignmentMode.All; + }), + ); + setBreadcrumb({ projectId }); + }, + }; + }, [ + projectState?.activeProject, + organizationProjectTargets?.data?.organization?.project?.targets?.nodes, + breadcrumb?.targetId, + ]); + + const [organizationProjectTarget] = useQuery({ + query: ResourceSelector_OrganizationProjectTargetQuery, + pause: !targetState?.activeTarget || !projectState?.activeProject, + variables: { + organizationSlug: organization.slug, + projectSlug: projectState?.activeProject?.project.slug ?? '', + targetSlug: targetState?.activeTarget?.target?.slug ?? '', + }, + }); + + const serviceState = useMemo(() => { + if ( + !projectState?.activeProject || + !targetState?.activeTarget || + !breadcrumb?.targetId || + !organizationProjectTarget.data?.organization?.project + ) { + return null; + } + + if ( + organizationProjectTarget.data.organization.project.type === GraphQLSchema.ProjectType.Single + ) { + return 'none' as const; + } + + const projectId = projectState.activeProject.projectSelection.projectId; + const targetId = targetState.activeTarget.targetSelection.targetId; + + if ( + targetState.activeTarget.targetSelection.services.mode === + GraphQLSchema.ResourceAssignmentMode.All + ) { + return { + selection: '*' as const, + setGranular() { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + const target = project.targets.targets?.find(target => target.targetId === targetId); + if (!target) return; + target.services.mode = GraphQLSchema.ResourceAssignmentMode.Granular; + }), + ); + }, + }; + } + + const selectedServices: GraphQLSchema.ServiceResourceAssignmentInput[] = [ + ...(targetState.activeTarget.targetSelection.services.services ?? []), + ]; + const notSelectedServices: Array = []; + + if ( + organizationProjectTarget.data.organization.project.target?.latestValidSchemaVersion?.schemas + ) { + for (const schema of organizationProjectTarget.data.organization.project.target + .latestValidSchemaVersion.schemas.nodes) { + if ( + schema.__typename === 'CompositeSchema' && + schema.service && + !selectedServices.find(service => service.serviceName === schema.service) + ) { + notSelectedServices.push(schema.service); + } + } + } + + return { + selection: { + selected: selectedServices, + notSelected: notSelectedServices, + }, + setAll() { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + const target = project.targets.targets?.find(target => target.targetId === targetId); + + if (!target) return; + target.services.mode = GraphQLSchema.ResourceAssignmentMode.All; + }), + ); + }, + addService(serviceName: string) { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + const target = project.targets.targets?.find(target => target.targetId === targetId); + if ( + !target || + target.services.services?.find(service => service.serviceName === serviceName) + ) { + return; + } + + target.services.services?.push({ + serviceName, + }); + }), + ); + }, + removeService(serviceName: string) { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + const target = project.targets.targets?.find(target => target.targetId === targetId); + if (!target) { + return; + } + target.services.services = target.services.services?.filter( + service => service.serviceName !== serviceName, + ); + }), + ); + }, + }; + }, [targetState?.activeTarget, breadcrumb, projectState?.activeProject, props.selection]); + + return ( + + + { + props.onSelectionChange({ + ...props.selection, + mode: GraphQLSchema.ResourceAssignmentMode.All, + }); + setBreadcrumb(null); + }} + > + Full Access + + { + props.onSelectionChange({ + ...props.selection, + mode: GraphQLSchema.ResourceAssignmentMode.Granular, + }); + }} + > + Granular Access + + + +

+ The permissions are granted on all projects, targets and services within the organization. +

+
+ + {projectState && ( + <> +

The permissions are granted on the specified resources.

+
+
+
+ Projects +
+
+
Targets
+ {targetState && ( +
+ + {' / '} + +
+ )} +
+
+ Services + {serviceState && serviceState !== 'none' && ( +
+ + {' / '} + +
+ )} +
+
+
+
+
+ access granted +
+ {projectState.selected.length ? ( + projectState.selected.map(selection => ( + { + setBreadcrumb({ projectId: selection.project.id }); + }} + onDelete={() => projectState.removeProject(selection.project)} + /> + )) + ) : ( +
None selected
+ )} +
+ not selected +
+ {projectState.notSelected.length ? ( + projectState.notSelected.map(project => ( + projectState.addProject(project)} + /> + )) + ) : ( +
All selected
+ )} +
+ +
+ {targetState === null ? ( +
+ Select a project for adjusting the target access. +
+ ) : ( + <> + {targetState.selection === '*' ? ( +
+ Access to all targets of project granted. +
+ ) : ( + <> +
+ access granted +
+ {targetState.selection.selected.length ? ( + targetState.selection.selected.map(selection => ( + { + setBreadcrumb({ + projectId: targetState.activeProject.project.id, + targetId: selection.target.id, + }); + }} + onDelete={() => { + targetState.removeTarget(selection.target); + }} + /> + )) + ) : ( +
None selected
+ )} +
+ Not selected +
+ {targetState.selection.notSelected.length ? ( + targetState.selection.notSelected.map(target => ( + targetState.addTarget(target)} + /> + )) + ) : ( +
All selected
+ )} + + )} + + )} +
+
+ {projectState.activeProject?.projectSelection.targets.mode === + GraphQLSchema.ResourceAssignmentMode.All ? ( +
+ Access to all services of projects targets granted. +
+ ) : serviceState === null ? ( +
+ Select a target for adjusting the service access. +
+ ) : ( + <> + {serviceState === 'none' ? ( +
+ Project is monolithic and has no services. +
+ ) : serviceState.selection === '*' ? ( +
+ Access to all services in target granted. +
+ ) : ( + <> +
+ access granted +
+ {serviceState.selection.selected.length ? ( + serviceState.selection.selected.map(service => ( + serviceState.removeService(service.serviceName)} + /> + )) + ) : ( +
None
+ )} +
+ Not selected +
+ {serviceState.selection.notSelected.map(serviceName => ( + serviceState.addService(serviceName)} + /> + ))} +
{ + ev.preventDefault(); + const input: HTMLInputElement = ev.currentTarget.serviceName; + const serviceName = input.value.trim().toLowerCase(); + + if (!serviceName) { + return; + } + + serviceState.addService(serviceName); + input.value = ''; + }} + > + +
+ + )} + + )} +
+
+
+
+ {projectState.activeProject && ( + <> + {' '} + {targetState?.activeTarget && ( + <> + {targetState.activeTarget.target.slug} + + )} + + )} +
+ + )} +
+
+ ); +} + +function RowItem(props: { + title: string; + isActive?: boolean; + onClick?: () => void; + onDelete?: () => void; +}) { + return ( +
+ + {props.title} {props.isActive && } + + + {props.onDelete && ( + + + + + + Remove + + + )} +
+ ); +} diff --git a/packages/web/app/src/components/organization/members/roles.tsx b/packages/web/app/src/components/organization/members/roles.tsx index f96fe41384..f9fa6d2d8d 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,100 +95,58 @@ 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, ); const { toast } = useToast(); - const isDisabled = props.mode === 'read-only' || updateMemberRoleState.fetching; + const isDisabled = updateMemberRoleState.fetching; const form = useForm({ resolver: zodResolver(roleFormSchema), mode: 'onChange', 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 +189,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.' - )} -
+ Member Role Editor + Adjust the permissions of this role.
@@ -293,74 +227,97 @@ 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} - /> - +
- {props.mode === 'read-only' ? null : ( - - - - - )} + + + + ); } +const OrganizationMemberRoleView_OrganizationFragment = graphql(` + fragment OrganizationMemberRoleView_OrganizationFragment on Organization { + id + ...SelectedPermissionOverview_OrganizationFragment + } +`); + +function OrganizationMemberRoleView(props: { + role: FragmentType; + organization: FragmentType; + close: VoidFunction; +}) { + const role = useFragment(OrganizationMemberRoleRow_MemberRoleFragment, props.role); + const organization = useFragment( + OrganizationMemberRoleView_OrganizationFragment, + props.organization, + ); + + const [showOnlyGrantedPermissions, setShowOnlyGrantedPermissions] = useState(true); + + return ( + + + Member Role: {role.name} + {role.description} + +
+
+
+ +
+
+
+ +
+ setShowOnlyGrantedPermissions(!!value)} + /> + +
+ +
+
+ ); +} + const OrganizationMemberRoleCreator_CreateMemberRoleMutation = graphql(` mutation OrganizationMemberRoleCreator_CreateMemberRoleMutation($input: CreateMemberRoleInput!) { createMemberRole(input: $input) { @@ -384,21 +341,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 +368,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 updateTargetScopes = useCallback( - (scopes: TargetAccessScope[]) => { - setTargetScopes(scopes); - form.setValue('targetScopes', [...scopes]); - }, - [targetScopes], - ); + const [selectedPermissions, setSelectedPermissions] = useState(() => new Set()); - const updateProjectScopes = useCallback( - (scopes: ProjectAccessScope[]) => { - setProjectScopes(scopes); - form.setValue('projectScopes', [...scopes]); - }, - [projectScopes], - ); + const onChangeSelectedPermissions = useCallback((permissions: ReadonlySet) => { + setSelectedPermissions(new Set(permissions)); + form.setValue('selectedPermissions', [...permissions]); + }, []); - 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 +435,7 @@ function OrganizationMemberRoleCreator(props: { return (
- + Member Role Creator @@ -512,89 +443,114 @@ function OrganizationMemberRoleCreator(props: { Create a new role that can be assigned to members of this organization. -
-
- ( - - Name - - - - - - )} - /> - ( - - Description - -