diff --git a/.changeset/five-hoops-punch.md b/.changeset/five-hoops-punch.md new file mode 100644 index 00000000000..0ac4f3238e2 --- /dev/null +++ b/.changeset/five-hoops-punch.md @@ -0,0 +1,5 @@ +--- +'@graphql-hive/cli': minor +--- + +Improve output of the `hive whoami` command. It now also handles the new access token format. diff --git a/.changeset/long-impalas-wash.md b/.changeset/long-impalas-wash.md new file mode 100644 index 00000000000..5e95213146c --- /dev/null +++ b/.changeset/long-impalas-wash.md @@ -0,0 +1,5 @@ +--- +'hive': minor +--- + +Introduce personal access tokens (PAT) and project scoped access tokens. diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index 42ae122d4e2..e8611759e82 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -60,7 +60,7 @@ import { import { execute } from './graphql'; import { UpdateSchemaPolicyForOrganization, UpdateSchemaPolicyForProject } from './schema-policy'; import { collect, CollectedOperation, legacyCollect } from './usage'; -import { generateUnique } from './utils'; +import { generateUnique, getServiceHost } from './utils'; export function initSeed() { function createConnectionPool() { @@ -89,6 +89,15 @@ export function initSeed() { }, authenticate, generateEmail: () => userEmail(generateUnique()), + async purgeOrganizationAccessTokenById(id: string) { + const registryAddress = await getServiceHost('server', 8082); + await fetch( + 'http://' + registryAddress + '/cache/organization-access-token-cache/delete/' + id, + { + method: 'POST', + }, + ).then(res => res.json()); + }, async createOwner() { const ownerEmail = userEmail(generateUnique()); const auth = await authenticate(ownerEmail); diff --git a/integration-tests/testkit/utils.ts b/integration-tests/testkit/utils.ts index bb59d9d9637..1861ae6f852 100644 --- a/integration-tests/testkit/utils.ts +++ b/integration-tests/testkit/utils.ts @@ -111,3 +111,12 @@ export function assertNonNull( throw new Error(message); } } + +export function assertNonNullish( + value: T | null | undefined, + message = 'Expected non-null value.', +): asserts value is T { + if (value === null) { + throw new Error(message); + } +} diff --git a/integration-tests/tests/api/organization-access-tokens.spec.ts b/integration-tests/tests/api/access-tokens/organization.spec.ts similarity index 60% rename from integration-tests/tests/api/organization-access-tokens.spec.ts rename to integration-tests/tests/api/access-tokens/organization.spec.ts index 7603686c169..59234160da4 100644 --- a/integration-tests/tests/api/organization-access-tokens.spec.ts +++ b/integration-tests/tests/api/access-tokens/organization.spec.ts @@ -1,31 +1,8 @@ -import { graphql } from '../../testkit/gql'; -import * as GraphQLSchema from '../../testkit/gql/graphql'; -import { execute } from '../../testkit/graphql'; -import { initSeed } from '../../testkit/seed'; - -const CreateOrganizationAccessTokenMutation = graphql(` - mutation CreateOrganizationAccessToken($input: CreateOrganizationAccessTokenInput!) { - createOrganizationAccessToken(input: $input) { - ok { - privateAccessKey - createdOrganizationAccessToken { - id - title - description - permissions - createdAt - } - } - error { - message - details { - title - description - } - } - } - } -`); +import { createOrganizationAccessToken } from 'testkit/flow'; +import { graphql } from '../../../testkit/gql'; +import * as GraphQLSchema from '../../../testkit/gql/graphql'; +import { execute } from '../../../testkit/graphql'; +import { initSeed } from '../../../testkit/seed'; const OrganizationProjectTargetQuery = graphql(` query OrganizationProjectTargetQuery( @@ -60,7 +37,6 @@ const PaginatedAccessTokensQuery = graphql(` id title description - permissions createdAt } } @@ -77,21 +53,19 @@ test.concurrent('create: success', async () => { const { createOrg, ownerToken } = await initSeed().createOwner(); const org = await createOrg(); - const result = await execute({ - document: CreateOrganizationAccessTokenMutation, - variables: { - input: { - organization: { - byId: org.organization.id, - }, - title: 'a access token', - description: 'Some description', - resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, - permissions: [], + const result = await createOrganizationAccessToken( + { + organization: { + byId: org.organization.id, }, + title: 'a access token', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], }, - authToken: ownerToken, - }).then(e => e.expectNoGraphQLErrors()); + ownerToken, + ).then(e => e.expectNoGraphQLErrors()); + expect(result.createOrganizationAccessToken.error).toEqual(null); expect(result.createOrganizationAccessToken.ok).toEqual({ privateAccessKey: expect.any(String), @@ -109,21 +83,18 @@ test.concurrent('create: failure invalid title', async ({ expect }) => { const { createOrg, ownerToken } = await initSeed().createOwner(); const org = await createOrg(); - const result = await execute({ - document: CreateOrganizationAccessTokenMutation, - variables: { - input: { - organization: { - byId: org.organization.id, - }, - title: ' ', - description: 'Some description', - resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, - permissions: [], + const result = await createOrganizationAccessToken( + { + organization: { + byId: org.organization.id, }, + title: ' ', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], }, - authToken: ownerToken, - }).then(e => e.expectNoGraphQLErrors()); + ownerToken, + ).then(e => e.expectNoGraphQLErrors()); expect(result.createOrganizationAccessToken.ok).toEqual(null); expect(result.createOrganizationAccessToken.error).toMatchInlineSnapshot(` { @@ -140,21 +111,18 @@ test.concurrent('create: failure invalid description', async ({ expect }) => { const { createOrg, ownerToken } = await initSeed().createOwner(); const org = await createOrg(); - const result = await execute({ - document: CreateOrganizationAccessTokenMutation, - variables: { - input: { - organization: { - byId: org.organization.id, - }, - title: 'a access token', - description: new Array(300).fill('A').join(''), - resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, - permissions: [], + const result = await createOrganizationAccessToken( + { + organization: { + byId: org.organization.id, }, + title: 'a access token', + description: new Array(300).fill('A').join(''), + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], }, - authToken: ownerToken, - }).then(e => e.expectNoGraphQLErrors()); + ownerToken, + ).then(e => e.expectNoGraphQLErrors()); expect(result.createOrganizationAccessToken.ok).toEqual(null); expect(result.createOrganizationAccessToken.error).toMatchInlineSnapshot(` { @@ -172,21 +140,18 @@ test.concurrent('create: failure because no access to organization', async ({ ex const actor2 = await initSeed().createOwner(); const org = await actor1.createOrg(); - const errors = await execute({ - document: CreateOrganizationAccessTokenMutation, - variables: { - input: { - organization: { - byId: org.organization.id, - }, - title: 'a access token', - description: 'Some description', - resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, - permissions: [], + const errors = await createOrganizationAccessToken( + { + organization: { + byId: org.organization.id, }, + title: 'a access token', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], }, - authToken: actor2.ownerToken, - }).then(e => e.expectGraphQLErrors()); + actor2.ownerToken, + ).then(e => e.expectGraphQLErrors()); expect(errors).toMatchObject([ { extensions: { @@ -204,21 +169,18 @@ test.concurrent('query GraphQL API on resources with access', async ({ expect }) const org = await createOrg(); const project = await org.createProject(GraphQLSchema.ProjectType.Federation); - const result = await execute({ - document: CreateOrganizationAccessTokenMutation, - variables: { - input: { - organization: { - byId: org.organization.id, - }, - title: 'a access token', - description: 'a description', - resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, - permissions: ['organization:describe', 'project:describe'], + const result = await createOrganizationAccessToken( + { + organization: { + byId: org.organization.id, }, + title: 'a access token', + description: 'a description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: ['organization:describe', 'project:describe'], }, - authToken: ownerToken, - }).then(e => e.expectNoGraphQLErrors()); + ownerToken, + ).then(e => e.expectNoGraphQLErrors()); expect(result.createOrganizationAccessToken.error).toEqual(null); const organizationAccessToken = result.createOrganizationAccessToken.ok!.privateAccessKey; @@ -253,29 +215,26 @@ test.concurrent('query GraphQL API on resources without access', async ({ expect const project1 = await org.createProject(GraphQLSchema.ProjectType.Federation); const project2 = await org.createProject(GraphQLSchema.ProjectType.Federation); - const result = await execute({ - document: CreateOrganizationAccessTokenMutation, - variables: { - input: { - organization: { - byId: org.organization.id, - }, - title: 'a access token', - description: 'a description', - resources: { - mode: GraphQLSchema.ResourceAssignmentModeType.Granular, - projects: [ - { - projectId: project1.project.id, - targets: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, - }, - ], - }, - permissions: ['organization:describe', 'project:describe'], + const result = await createOrganizationAccessToken( + { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: 'a description', + resources: { + mode: GraphQLSchema.ResourceAssignmentModeType.Granular, + projects: [ + { + projectId: project1.project.id, + targets: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + }, + ], }, + permissions: ['organization:describe', 'project:describe'], }, - authToken: ownerToken, - }).then(e => e.expectNoGraphQLErrors()); + ownerToken, + ).then(e => e.expectNoGraphQLErrors()); expect(result.createOrganizationAccessToken.error).toEqual(null); const organizationAccessToken = result.createOrganizationAccessToken.ok!.privateAccessKey; @@ -317,41 +276,35 @@ test.concurrent('pagination', async ({ expect }) => { }, }); - await execute({ - document: CreateOrganizationAccessTokenMutation, - variables: { - input: { - organization: { - byId: org.organization.id, - }, - title: 'first access token', - description: 'a description', - resources: { - mode: GraphQLSchema.ResourceAssignmentModeType.All, - }, - permissions: ['organization:describe'], + await createOrganizationAccessToken( + { + organization: { + byId: org.organization.id, }, + title: 'first access token', + description: 'a description', + resources: { + mode: GraphQLSchema.ResourceAssignmentModeType.All, + }, + permissions: ['organization:describe'], }, - authToken: ownerToken, - }).then(e => e.expectNoGraphQLErrors()); + ownerToken, + ).then(e => e.expectNoGraphQLErrors()); - await execute({ - document: CreateOrganizationAccessTokenMutation, - variables: { - input: { - organization: { - byId: org.organization.id, - }, - title: 'second access token', - description: 'a description', - resources: { - mode: GraphQLSchema.ResourceAssignmentModeType.All, - }, - permissions: ['organization:describe'], + await createOrganizationAccessToken( + { + organization: { + byId: org.organization.id, + }, + title: 'second access token', + description: 'a description', + resources: { + mode: GraphQLSchema.ResourceAssignmentModeType.All, }, + permissions: ['organization:describe'], }, - authToken: ownerToken, - }).then(e => e.expectNoGraphQLErrors()); + ownerToken, + ).then(e => e.expectNoGraphQLErrors()); paginatedResult = await execute({ document: PaginatedAccessTokensQuery, @@ -370,7 +323,6 @@ test.concurrent('pagination', async ({ expect }) => { createdAt: expect.any(String), description: 'a description', id: expect.any(String), - permissions: ['organization:describe'], title: 'second access token', }, }, @@ -400,7 +352,6 @@ test.concurrent('pagination', async ({ expect }) => { createdAt: expect.any(String), description: 'a description', id: expect.any(String), - permissions: ['organization:describe'], title: 'first access token', }, }, diff --git a/integration-tests/tests/api/access-tokens/personal.spec.ts b/integration-tests/tests/api/access-tokens/personal.spec.ts new file mode 100644 index 00000000000..1a5516663fa --- /dev/null +++ b/integration-tests/tests/api/access-tokens/personal.spec.ts @@ -0,0 +1,746 @@ +import { assertNonNullish } from 'testkit/utils'; +import { graphql } from '../../../testkit/gql'; +import * as GraphQLSchema from '../../../testkit/gql/graphql'; +import { execute } from '../../../testkit/graphql'; +import { initSeed } from '../../../testkit/seed'; +import { deleteAccessToken, fetchPermissions } from './shared'; + +const CreatePersonalAccessTokenMutation = graphql(` + mutation CreatePersonalAccessTokenMutation($input: CreatePersonalAccessTokenInput!) { + createPersonalAccessToken(input: $input) { + ok { + privateAccessKey + createdPersonalAccessToken { + id + title + description + createdAt + } + } + error { + message + details { + title + description + } + } + } + } +`); + +const OrganizationProjectTargetQuery1 = graphql(` + query OrganizationProjectTargetQuery1( + $organizationSlug: String! + $projectSlug: String! + $targetSlug: String! + ) { + organization: organizationBySlug(organizationSlug: $organizationSlug) { + id + slug + project: projectBySlug(projectSlug: $projectSlug) { + id + slug + targetBySlug(targetSlug: $targetSlug) { + id + slug + } + } + } + } +`); + +test.concurrent('create: success with admin supertokens session', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + + const result = await execute({ + document: CreatePersonalAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(result.createPersonalAccessToken.error).toEqual(null); + expect(result.createPersonalAccessToken.ok).toEqual({ + privateAccessKey: expect.any(String), + createdPersonalAccessToken: { + id: expect.any(String), + title: 'a access token', + description: 'Some description', + createdAt: expect.any(String), + }, + }); +}); + +test.concurrent('create: failure invalid title', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + + const result = await execute({ + document: CreatePersonalAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: ' ', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(result.createPersonalAccessToken.ok).toEqual(null); + expect(result.createPersonalAccessToken.error).toMatchInlineSnapshot(` + { + details: { + description: null, + title: Can only contain letters, numbers, " ", "_", and "-"., + }, + message: Invalid input provided., + } + `); +}); + +test.concurrent('create: failure invalid description', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + + const result = await execute({ + document: CreatePersonalAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: new Array(300).fill('A').join(''), + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(result.createPersonalAccessToken.ok).toEqual(null); + expect(result.createPersonalAccessToken.error).toMatchInlineSnapshot(` + { + details: { + description: Maximum length is 248 characters., + title: null, + }, + message: Invalid input provided., + } + `); +}); + +test.concurrent('create: failure because no access to organization', async ({ expect }) => { + const actor1 = await initSeed().createOwner(); + const actor2 = await initSeed().createOwner(); + const org = await actor1.createOrg(); + + const errors = await execute({ + document: CreatePersonalAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], + }, + }, + authToken: actor2.ownerToken, + }).then(e => e.expectGraphQLErrors()); + expect(errors).toMatchObject([ + { + extensions: { + code: 'UNAUTHORISED', + }, + + message: `No access (reason: "Missing permission for performing 'personalAccessToken:modify' on resource")`, + path: ['createPersonalAccessToken'], + }, + ]); +}); + +test.concurrent('delete: successfuly delete own access token', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + + const createResult = await execute({ + document: CreatePersonalAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(createResult.createPersonalAccessToken.error).toEqual(null); + assertNonNullish(createResult.createPersonalAccessToken.ok); + + const accessToken = createResult.createPersonalAccessToken.ok.createdPersonalAccessToken; + + const deleteResult = await deleteAccessToken(accessToken.id, ownerToken).then(e => + e.expectNoGraphQLErrors(), + ); + expect(deleteResult.deleteAccessToken.error).toEqual(null); + expect(deleteResult.deleteAccessToken.ok).toEqual({ + deletedAccessTokenId: accessToken.id, + }); +}); + +test.concurrent('delete: fail delete access token of another user', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + + const user1 = await org.inviteAndJoinMember(); + const { createMemberRole, assignMemberRole } = user1; + + const memberRole = await createMemberRole([ + 'organization:describe', + 'project:describe', + 'personalAccessToken:modify', + ]); + + const user2 = await org.inviteAndJoinMember(); + + await assignMemberRole({ + roleId: memberRole.id, + userId: user1.member.id, + }); + + await assignMemberRole({ + roleId: memberRole.id, + userId: user2.member.id, + }); + + const createResult = await execute({ + document: CreatePersonalAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], + }, + }, + authToken: user1.memberToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(createResult.createPersonalAccessToken.error).toEqual(null); + assertNonNullish(createResult.createPersonalAccessToken.ok); + const accessToken = createResult.createPersonalAccessToken.ok.createdPersonalAccessToken; + + const deleteResult = await deleteAccessToken(accessToken.id, user2.memberToken).then(e => + e.expectGraphQLErrors(), + ); + expect(deleteResult).toMatchObject([ + { + extensions: { + code: 'UNAUTHORISED', + }, + message: `No access (reason: "Missing permission for performing 'personalAccessToken:modify' on resource")`, + path: ['deleteAccessToken'], + }, + ]); +}); + +test.concurrent('query GraphQL API on resources with access', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + const project = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const result = await execute({ + document: CreatePersonalAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: 'a description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: ['organization:describe', 'project:describe'], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(result.createPersonalAccessToken.error).toEqual(null); + assertNonNullish(result.createPersonalAccessToken.ok); + const personalAccessToken = result.createPersonalAccessToken.ok.privateAccessKey; + + const projectQuery = await execute({ + document: OrganizationProjectTargetQuery1, + variables: { + organizationSlug: org.organization.slug, + projectSlug: project.project.slug, + targetSlug: project.target.slug, + }, + authToken: personalAccessToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(projectQuery).toEqual({ + organization: { + id: expect.any(String), + slug: org.organization.slug, + project: { + id: expect.any(String), + slug: project.project.slug, + targetBySlug: { + id: expect.any(String), + slug: project.target.slug, + }, + }, + }, + }); +}); + +test.concurrent('query GraphQL API on resources without access', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + const project1 = await org.createProject(GraphQLSchema.ProjectType.Federation); + const project2 = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const result = await execute({ + document: CreatePersonalAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: 'a description', + resources: { + mode: GraphQLSchema.ResourceAssignmentModeType.Granular, + projects: [ + { + projectId: project1.project.id, + targets: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + }, + ], + }, + permissions: ['organization:describe', 'project:describe'], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(result.createPersonalAccessToken.error).toEqual(null); + assertNonNullish(result.createPersonalAccessToken.ok); + const personalAccessToken = result.createPersonalAccessToken.ok.privateAccessKey; + + const projectQuery = await execute({ + document: OrganizationProjectTargetQuery1, + variables: { + organizationSlug: org.organization.slug, + projectSlug: project2.project.slug, + targetSlug: project2.target.slug, + }, + authToken: personalAccessToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(projectQuery).toEqual({ + organization: { + id: expect.any(String), + project: null, + slug: org.organization.slug, + }, + }); +}); + +test.concurrent( + 'query GraphQL API after membership resources have been downgraded', + async ({ expect }) => { + const seed = await initSeed(); + const { createOrg } = await seed.createOwner(); + const org = await createOrg(); + const project = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const { member, memberToken, assignMemberRole, createMemberRole } = + await org.inviteAndJoinMember(); + + const newRole = await createMemberRole([ + 'organization:describe', + 'project:describe', + 'personalAccessToken:modify', + ]); + + // make user also an admin + await assignMemberRole({ + userId: member.id, + roleId: newRole.id, + }); + + const result = await execute({ + document: CreatePersonalAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: 'a description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: ['organization:describe', 'project:describe'], + }, + }, + authToken: memberToken, + }).then(e => e.expectNoGraphQLErrors()); + + expect(result.createPersonalAccessToken.error).toEqual(null); + assertNonNullish(result.createPersonalAccessToken.ok); + + const personalAccessToken = result.createPersonalAccessToken.ok.privateAccessKey; + + let projectQuery = await execute({ + document: OrganizationProjectTargetQuery1, + variables: { + organizationSlug: org.organization.slug, + projectSlug: project.project.slug, + targetSlug: project.target.slug, + }, + authToken: personalAccessToken, + }).then(e => e.expectNoGraphQLErrors()); + + expect(projectQuery).toEqual({ + organization: { + id: org.organization.id, + project: { + id: project.project.id, + slug: project.project.slug, + targetBySlug: { + id: project.target.id, + slug: project.target.slug, + }, + }, + slug: org.organization.slug, + }, + }); + + // Update member role assignment so it looses access to describe project/target on the resources + await assignMemberRole({ + userId: member.id, + roleId: newRole.id, + resources: { + mode: GraphQLSchema.ResourceAssignmentModeType.Granular, + projects: [], + }, + }); + + // simulate 5 minutes passing by... + await seed.purgeOrganizationAccessTokenById( + result.createPersonalAccessToken.ok.createdPersonalAccessToken.id, + ); + + projectQuery = await execute({ + document: OrganizationProjectTargetQuery1, + variables: { + organizationSlug: org.organization.slug, + projectSlug: project.project.slug, + targetSlug: project.target.slug, + }, + authToken: personalAccessToken, + }).then(e => e.expectNoGraphQLErrors()); + + expect(projectQuery).toEqual({ + organization: { + id: org.organization.id, + project: null, + slug: org.organization.slug, + }, + }); + }, +); + +test.concurrent( + 'query GraphQL API after membership permissions have been downgraded', + async ({ expect }) => { + const seed = await initSeed(); + const { createOrg } = await seed.createOwner(); + const org = await createOrg(); + const project = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const { member, memberToken, assignMemberRole, createMemberRole, updateMemberRole } = + await org.inviteAndJoinMember(); + + const memberRole = await createMemberRole([ + 'personalAccessToken:modify', + 'organization:describe', + 'project:describe', + ]); + + // make user also an admin + await assignMemberRole({ + userId: member.id, + roleId: memberRole.id, + }); + + const result = await execute({ + document: CreatePersonalAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: 'a description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: ['organization:describe', 'project:describe'], + }, + }, + authToken: memberToken, + }).then(e => e.expectNoGraphQLErrors()); + + expect(result.createPersonalAccessToken.error).toEqual(null); + assertNonNullish(result.createPersonalAccessToken.ok); + + const personalAccessToken = result.createPersonalAccessToken.ok.privateAccessKey; + + expect(await fetchPermissions(personalAccessToken)).toEqual([ + { + level: 'ORGANIZATION', + resolvedPermissionGroups: [ + { + permissions: [ + { + permission: { + id: 'organization:describe', + }, + }, + ], + }, + ], + resolvedResourceIds: [org.organization.slug], + }, + { + level: 'PROJECT', + resolvedPermissionGroups: [ + { + permissions: [ + { + permission: { + id: 'project:describe', + }, + }, + ], + }, + ], + resolvedResourceIds: [org.organization.slug + '/*'], + }, + ]); + + let projectQuery = await execute({ + document: OrganizationProjectTargetQuery1, + variables: { + organizationSlug: org.organization.slug, + projectSlug: project.project.slug, + targetSlug: project.target.slug, + }, + authToken: personalAccessToken, + }).then(e => e.expectNoGraphQLErrors()); + + expect(projectQuery).toEqual({ + organization: { + id: org.organization.id, + project: { + id: project.project.id, + slug: project.project.slug, + targetBySlug: { + id: project.target.id, + slug: project.target.slug, + }, + }, + slug: org.organization.slug, + }, + }); + + // Update member role to no longer allow describing projects + await updateMemberRole(memberRole, ['personalAccessToken:modify', 'organization:describe']); + + // simulate 5 minutes passing by... + await seed.purgeOrganizationAccessTokenById( + result.createPersonalAccessToken.ok.createdPersonalAccessToken.id, + ); + + expect(await fetchPermissions(personalAccessToken)).toEqual([ + { + level: 'ORGANIZATION', + resolvedPermissionGroups: [ + { + permissions: [ + { + permission: { + id: 'organization:describe', + }, + }, + ], + }, + ], + resolvedResourceIds: [org.organization.slug], + }, + ]); + + projectQuery = await execute({ + document: OrganizationProjectTargetQuery1, + variables: { + organizationSlug: org.organization.slug, + projectSlug: project.project.slug, + targetSlug: project.target.slug, + }, + authToken: personalAccessToken, + }).then(e => e.expectNoGraphQLErrors()); + + expect(projectQuery).toEqual({ + organization: { + id: org.organization.id, + project: null, + slug: org.organization.slug, + }, + }); + }, +); + +test.concurrent( + 'query GraphQL API after membership was revoked using access tokens', + async ({ expect }) => { + const seed = await initSeed(); + const { createOrg } = await seed.createOwner(); + const org = await createOrg(); + const project = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const { member, memberToken, assignMemberRole, createMemberRole, updateMemberRole } = + await org.inviteAndJoinMember(); + + const memberRole = await createMemberRole([ + 'personalAccessToken:modify', + 'organization:describe', + 'project:describe', + ]); + + // make user also an admin + await assignMemberRole({ + userId: member.id, + roleId: memberRole.id, + }); + + const result = await execute({ + document: CreatePersonalAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: 'a description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: ['organization:describe', 'project:describe'], + }, + }, + authToken: memberToken, + }).then(e => e.expectNoGraphQLErrors()); + + expect(result.createPersonalAccessToken.error).toEqual(null); + assertNonNullish(result.createPersonalAccessToken.ok); + + const personalAccessToken = result.createPersonalAccessToken.ok.privateAccessKey; + + expect(await fetchPermissions(personalAccessToken)).toEqual([ + { + level: 'ORGANIZATION', + resolvedPermissionGroups: [ + { + permissions: [ + { + permission: { + id: 'organization:describe', + }, + }, + ], + }, + ], + resolvedResourceIds: [org.organization.slug], + }, + { + level: 'PROJECT', + resolvedPermissionGroups: [ + { + permissions: [ + { + permission: { + id: 'project:describe', + }, + }, + ], + }, + ], + resolvedResourceIds: [org.organization.slug + '/*'], + }, + ]); + + let projectQuery = await execute({ + document: OrganizationProjectTargetQuery1, + variables: { + organizationSlug: org.organization.slug, + projectSlug: project.project.slug, + targetSlug: project.target.slug, + }, + authToken: personalAccessToken, + }).then(e => e.expectNoGraphQLErrors()); + + expect(projectQuery).toEqual({ + organization: { + id: org.organization.id, + project: { + id: project.project.id, + slug: project.project.slug, + targetBySlug: { + id: project.target.id, + slug: project.target.slug, + }, + }, + slug: org.organization.slug, + }, + }); + + // Remove personalAccessToken:modify + await updateMemberRole(memberRole, ['organization:describe', 'project:describe']); + + // simulate 5 minutes passing by... + await seed.purgeOrganizationAccessTokenById( + result.createPersonalAccessToken.ok.createdPersonalAccessToken.id, + ); + + expect(await fetchPermissions(personalAccessToken)).toEqual([]); + + projectQuery = await execute({ + document: OrganizationProjectTargetQuery1, + variables: { + organizationSlug: org.organization.slug, + projectSlug: project.project.slug, + targetSlug: project.target.slug, + }, + authToken: personalAccessToken, + }).then(e => e.expectNoGraphQLErrors()); + + expect(projectQuery).toEqual({ + organization: null, + }); + }, +); diff --git a/integration-tests/tests/api/access-tokens/project.spec.ts b/integration-tests/tests/api/access-tokens/project.spec.ts new file mode 100644 index 00000000000..ce202535af0 --- /dev/null +++ b/integration-tests/tests/api/access-tokens/project.spec.ts @@ -0,0 +1,487 @@ +import { graphql } from '../../../testkit/gql'; +import * as GraphQLSchema from '../../../testkit/gql/graphql'; +import { execute } from '../../../testkit/graphql'; +import { initSeed } from '../../../testkit/seed'; +import { deleteAccessToken, fetchPermissions } from './shared'; + +const CreateProjectAccessTokenMutation = graphql(` + mutation CreateProjectAccessTokenMutation($input: CreateProjectAccessTokenInput!) { + createProjectAccessToken(input: $input) { + ok { + privateAccessKey + createdProjectAccessToken { + id + title + description + createdAt + } + } + error { + message + details { + title + description + } + } + } + } +`); + +const SimpleProjectQuery = graphql(` + query ProjectAccessTokensProjectTestQuery($projectId: ID!) { + project(reference: { byId: $projectId }) { + id + slug + } + } +`); + +test.concurrent('create: success with admin session', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + const { project } = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const result = await execute({ + document: CreateProjectAccessTokenMutation, + variables: { + input: { + project: { + byId: project.id, + }, + title: 'a access token', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(result.createProjectAccessToken.error).toEqual(null); + expect(result.createProjectAccessToken.ok).toEqual({ + privateAccessKey: expect.any(String), + createdProjectAccessToken: { + id: expect.any(String), + title: 'a access token', + description: 'Some description', + createdAt: expect.any(String), + }, + }); + + // no permissions :D + expect(await fetchPermissions(result.createProjectAccessToken.ok!.privateAccessKey)).toEqual([]); +}); + +test.concurrent('create: failure invalid title', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + const { project } = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const result = await execute({ + document: CreateProjectAccessTokenMutation, + variables: { + input: { + project: { + byId: project.id, + }, + title: ' ', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(result.createProjectAccessToken.ok).toEqual(null); + expect(result.createProjectAccessToken.error).toMatchInlineSnapshot(` + { + details: { + description: null, + title: Can only contain letters, numbers, " ", "_", and "-"., + }, + message: Invalid input provided., + } + `); +}); + +test.concurrent('create: failure invalid description', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + const { project } = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const result = await execute({ + document: CreateProjectAccessTokenMutation, + variables: { + input: { + project: { + byId: project.id, + }, + title: 'a access token', + description: new Array(300).fill('A').join(''), + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(result.createProjectAccessToken.ok).toEqual(null); + expect(result.createProjectAccessToken.error).toMatchInlineSnapshot(` + { + details: { + description: Maximum length is 248 characters., + title: null, + }, + message: Invalid input provided., + } + `); +}); + +test.concurrent('create: failure because no access to project', async ({ expect }) => { + const actor1 = await initSeed().createOwner(); + const actor2 = await initSeed().createOwner(); + const org = await actor1.createOrg(); + const { project } = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const errors = await execute({ + document: CreateProjectAccessTokenMutation, + variables: { + input: { + project: { + byId: project.id, + }, + title: 'a access token', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], + }, + }, + authToken: actor2.ownerToken, + }).then(e => e.expectGraphQLErrors()); + expect(errors).toMatchObject([ + { + extensions: { + code: 'UNAUTHORISED', + }, + + message: `No access (reason: "Missing permission for performing 'projectAccessToken:modify' on resource")`, + path: ['createProjectAccessToken'], + }, + ]); +}); + +test.concurrent( + 'create: fail because of missing "projectAccessToken:modify" permission.', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const org = await createOrg(); + const { member, createMemberRole, assignMemberRole, memberToken } = + await org.inviteAndJoinMember(); + const memberRole = await createMemberRole(['organization:describe', 'project:describe']); + await assignMemberRole({ + roleId: memberRole.id, + userId: member.id, + }); + + const { project } = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const errors = await execute({ + document: CreateProjectAccessTokenMutation, + variables: { + input: { + project: { + byId: project.id, + }, + title: 'a access token', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: [], + }, + }, + authToken: memberToken, + }).then(e => e.expectGraphQLErrors()); + + expect(errors).toMatchObject([ + { + extensions: { + code: 'UNAUTHORISED', + }, + message: `No access (reason: "Missing permission for performing 'projectAccessToken:modify' on resource")`, + path: ['createProjectAccessToken'], + }, + ]); + }, +); + +test.concurrent('query GraphQL API on resources with access', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const org = await createOrg(); + const { + member, + createMemberRole, + assignMemberRole, + memberToken: authToken, + } = await org.inviteAndJoinMember(); + const memberRole = await createMemberRole([ + 'organization:describe', + 'project:describe', + 'projectAccessToken:modify', + ]); + await assignMemberRole({ + roleId: memberRole.id, + userId: member.id, + }); + + const { project } = await org.createProject(GraphQLSchema.ProjectType.Federation); + const { project: project1 } = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const result = await execute({ + document: CreateProjectAccessTokenMutation, + variables: { + input: { + project: { + byId: project.id, + }, + title: 'a access token', + description: 'a description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: ['project:describe'], + }, + }, + authToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(result.createProjectAccessToken.error).toEqual(null); + const organizationAccessToken = result.createProjectAccessToken.ok!.privateAccessKey; + + expect(await fetchPermissions(organizationAccessToken)).toEqual([ + { + level: 'PROJECT', + resolvedPermissionGroups: [ + { + permissions: [ + { + permission: { + id: 'project:describe', + }, + }, + ], + }, + ], + resolvedResourceIds: [`${org.organization.slug}/${project.slug}`], + }, + ]); + + const projectQuery = await execute({ + document: SimpleProjectQuery, + variables: { + projectId: project.id, + }, + authToken: organizationAccessToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(projectQuery).toEqual({ + project: { + id: expect.any(String), + slug: project.slug, + }, + }); + + const errors = await execute({ + document: SimpleProjectQuery, + variables: { + projectId: project1.id, + }, + authToken: organizationAccessToken, + }).then(e => e.expectGraphQLErrors()); + expect(errors).toMatchObject([ + { + extensions: { + code: 'UNAUTHORISED', + }, + message: `No access (reason: "Missing permission for performing 'project:describe' on resource")`, + path: ['project'], + }, + ]); +}); + +test.concurrent('delete access token revokes access', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const org = await createOrg(); + const { + member, + createMemberRole, + assignMemberRole, + memberToken: authToken, + } = await org.inviteAndJoinMember(); + const memberRole = await createMemberRole([ + 'organization:describe', + 'project:describe', + 'projectAccessToken:modify', + ]); + await assignMemberRole({ + roleId: memberRole.id, + userId: member.id, + }); + + const { project } = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const createProjectAccessTokenResult = await execute({ + document: CreateProjectAccessTokenMutation, + variables: { + input: { + project: { + byId: project.id, + }, + title: 'a access token', + description: 'a description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: ['project:describe'], + }, + }, + authToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(createProjectAccessTokenResult.createProjectAccessToken.error).toEqual(null); + const accessToken = createProjectAccessTokenResult.createProjectAccessToken.ok!; + + expect(await fetchPermissions(accessToken.privateAccessKey)).toEqual([ + { + level: 'PROJECT', + resolvedPermissionGroups: [ + { + permissions: [ + { + permission: { + id: 'project:describe', + }, + }, + ], + }, + ], + resolvedResourceIds: [`${org.organization.slug}/${project.slug}`], + }, + ]); + + const deleteAccessTokenResult = await deleteAccessToken( + accessToken.createdProjectAccessToken.id, + authToken, + ).then(res => res.expectNoGraphQLErrors()); + expect(deleteAccessTokenResult).toEqual({ + deleteAccessToken: { + error: null, + ok: { + deletedAccessTokenId: accessToken.createdProjectAccessToken.id, + }, + }, + }); + + const errors = await execute({ + document: SimpleProjectQuery, + variables: { + projectId: project.id, + }, + authToken: accessToken.privateAccessKey, + }).then(e => e.expectGraphQLErrors()); + expect(errors).toMatchObject([ + { + message: 'Invalid token provided', + }, + ]); +}); + +test.concurrent('cannot delete access token without sufficient permissions', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + const { member, createMemberRole, assignMemberRole, memberToken } = + await org.inviteAndJoinMember(); + const memberRole = await createMemberRole(['organization:describe', 'project:describe']); + await assignMemberRole({ + roleId: memberRole.id, + userId: member.id, + }); + + const { project } = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const createProjectAccessTokenResult = await execute({ + document: CreateProjectAccessTokenMutation, + variables: { + input: { + project: { + byId: project.id, + }, + title: 'a access token', + description: 'a description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: ['project:describe'], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + + expect(createProjectAccessTokenResult.createProjectAccessToken.error).toEqual(null); + const accessToken = createProjectAccessTokenResult.createProjectAccessToken.ok!; + + const errors = await deleteAccessToken( + accessToken.createdProjectAccessToken.id, + memberToken, + ).then(res => res.expectGraphQLErrors()); + expect(errors).toMatchObject([ + { + extensions: { + code: 'UNAUTHORISED', + }, + + message: `No access (reason: "Missing permission for performing 'projectAccessToken:modify' on resource")`, + path: ['deleteAccessToken'], + }, + ]); +}); + +test.concurrent( + 'can delete access token without "accessToken:modify" permissions', + async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + const { member, createMemberRole, assignMemberRole, memberToken } = + await org.inviteAndJoinMember(); + const memberRole = await createMemberRole([ + 'organization:describe', + 'project:describe', + 'accessToken:modify', + ]); + await assignMemberRole({ + roleId: memberRole.id, + userId: member.id, + }); + + const { project } = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const createProjectAccessTokenResult = await execute({ + document: CreateProjectAccessTokenMutation, + variables: { + input: { + project: { + byId: project.id, + }, + title: 'a access token', + description: 'a description', + resources: { mode: GraphQLSchema.ResourceAssignmentModeType.All }, + permissions: ['project:describe'], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + + expect(createProjectAccessTokenResult.createProjectAccessToken.error).toEqual(null); + const accessToken = createProjectAccessTokenResult.createProjectAccessToken.ok!; + + const deleteAccessTokenResult = await deleteAccessToken( + accessToken.createdProjectAccessToken.id, + memberToken, + ).then(res => res.expectNoGraphQLErrors()); + expect(deleteAccessTokenResult).toEqual({ + deleteAccessToken: { + error: null, + ok: { + deletedAccessTokenId: accessToken.createdProjectAccessToken.id, + }, + }, + }); + }, +); diff --git a/integration-tests/tests/api/access-tokens/shared.ts b/integration-tests/tests/api/access-tokens/shared.ts new file mode 100644 index 00000000000..646f864b1d9 --- /dev/null +++ b/integration-tests/tests/api/access-tokens/shared.ts @@ -0,0 +1,55 @@ +import { graphql } from '../../../testkit/gql'; +import { execute } from '../../../testkit/graphql'; + +const WhoAmI = graphql(` + query projectBySlug { + whoAmI { + resolvedPermissions { + level + resolvedPermissionGroups { + permissions { + permission { + id + } + } + } + resolvedResourceIds + } + } + } +`); + +/** + * Get a object representation of all the permissions issued to an access token. + */ +export function fetchPermissions(accessToken: string) { + return execute({ + document: WhoAmI, + authToken: accessToken, + }) + .then(e => e.expectNoGraphQLErrors()) + .then(res => res.whoAmI?.resolvedPermissions); +} + +const DeleteAccessTokenMutation = graphql(` + mutation DeleteAccessTokenMutation($accessTokenId: ID!) { + deleteAccessToken(input: { accessToken: { byId: $accessTokenId } }) { + error { + message + } + ok { + deletedAccessTokenId + } + } + } +`); + +export function deleteAccessToken(accessTokenId: string, authToken: string) { + return execute({ + document: DeleteAccessTokenMutation, + variables: { + accessTokenId, + }, + authToken, + }); +} diff --git a/integration-tests/tests/api/organization/members.spec.ts b/integration-tests/tests/api/organization/members.spec.ts index 1b5508efcfe..c0eccd54e79 100644 --- a/integration-tests/tests/api/organization/members.spec.ts +++ b/integration-tests/tests/api/organization/members.spec.ts @@ -24,9 +24,11 @@ test.concurrent('owner of an organization should have all scopes', async ({ expe slackIntegration:modify, project:create, schemaLinting:modifyOrganizationRules, + personalAccessToken:modify, project:describe, project:delete, project:modifySettings, + projectAccessToken:modify, schemaLinting:modifyProjectRules, target:create, alert:modify, @@ -37,7 +39,16 @@ test.concurrent('owner of an organization should have all scopes', async ({ expe laboratory:describe, laboratory:modify, laboratory:modifyPreflightScript, + schema:compose, + usage:report, + traces:report, schemaCheck:approve, + schemaCheck:create, + schemaVersion:publish, + schemaVersion:deleteService, + appDeployment:create, + appDeployment:publish, + appDeployment:retire, ] `); }); diff --git a/packages/libraries/cli/package.json b/packages/libraries/cli/package.json index c19ae3a9d19..70a8e5a4d23 100644 --- a/packages/libraries/cli/package.json +++ b/packages/libraries/cli/package.json @@ -61,6 +61,7 @@ "@oclif/plugin-help": "6.0.22", "@oclif/plugin-update": "4.2.13", "@theguild/federation-composition": "0.20.2", + "cli-table3": "0.6.5", "colors": "1.4.0", "env-ci": "7.3.0", "graphql": "^16.8.1", diff --git a/packages/libraries/cli/src/commands/whoami.ts b/packages/libraries/cli/src/commands/whoami.ts index ac13e44b931..d1888a70220 100644 --- a/packages/libraries/cli/src/commands/whoami.ts +++ b/packages/libraries/cli/src/commands/whoami.ts @@ -1,3 +1,4 @@ +import Table from 'cli-table3'; import { Flags } from '@oclif/core'; import Command from '../base-command'; import { graphql } from '../gql'; @@ -6,33 +7,27 @@ import { InvalidRegistryTokenError, MissingEndpointError, MissingRegistryTokenError, - UnexpectedError, } from '../helpers/errors'; -import { Texture } from '../helpers/texture/texture'; const myTokenInfoQuery = graphql(/* GraphQL */ ` - query myTokenInfo { - tokenInfo { - __typename - ... on TokenInfo { - token { - name + query myTokenInfo($showAll: Boolean!) { + whoAmI { + title + resolvedPermissions(includeAll: $showAll) { + level + resolvedResourceIds + title + resolvedPermissionGroups { + title + permissions { + isGranted + permission { + id + title + description + } + } } - organization { - slug - } - project { - type - slug - } - target { - slug - } - canPublishSchema: hasTargetScope(scope: REGISTRY_WRITE) - canCheckSchema: hasTargetScope(scope: REGISTRY_READ) - } - ... on TokenNotFoundError { - message } } } @@ -63,6 +58,10 @@ export default class WhoAmI extends Command { version: '0.21.0', }, }), + all: Flags.boolean({ + description: 'Also show non-granted permissions.', + default: false, + }), }; async run() { @@ -95,62 +94,44 @@ export default class WhoAmI extends Command { const result = await this.registryApi(registry, token).request({ operation: myTokenInfoQuery, + variables: { + showAll: flags.all, + }, }); - if (result.tokenInfo.__typename === 'TokenInfo') { - const { tokenInfo } = result; - const { organization, project, target } = tokenInfo; - - const organizationUrl = `https://app.graphql-hive.com/${organization.slug}`; - const projectUrl = `${organizationUrl}/${project.slug}`; - const targetUrl = `${projectUrl}/${target.slug}`; - - const access = { - yes: Texture.colors.green('Yes'), - not: Texture.colors.red('No access'), - }; - - const print = createPrinter({ - 'Token name:': [Texture.colors.bold(tokenInfo.token.name)], - ' ': [''], - 'Organization:': [ - Texture.colors.bold(organization.slug), - Texture.colors.dim(organizationUrl), - ], - 'Project:': [Texture.colors.bold(project.slug), Texture.colors.dim(projectUrl)], - 'Target:': [Texture.colors.bold(target.slug), Texture.colors.dim(targetUrl)], - ' ': [''], - 'Access to schema:publish': [tokenInfo.canPublishSchema ? access.yes : access.not], - 'Access to schema:check': [tokenInfo.canCheckSchema ? access.yes : access.not], - }); - - this.log(print()); - } else if (result.tokenInfo.__typename === 'TokenNotFoundError') { - this.debug(result.tokenInfo.message); + if (result.whoAmI == null) { throw new InvalidRegistryTokenError(); - } else { - throw new UnexpectedError( - `Token response got an unsupported type: ${(result.tokenInfo as any).__typename}`, - ); } - } -} -function createPrinter(records: { [label: string]: [value: string, extra?: string] }) { - const labels = Object.keys(records); - const values = Object.values(records).map(v => v[0]); - const maxLabelsLen = Math.max(...labels.map(v => v.length)) + 4; - const maxValuesLen = Math.max(...values.map(v => v.length)) + 4; + const data = result.whoAmI; - return () => { - const lines: string[] = []; + // Print header + this.log(`\n=== ${data.title} ===\n`); - for (const label in records) { - const [value, extra] = records[label]; + // Iterate and display each permission group + for (const permLevel of data.resolvedPermissions) { + this.log(`Level: ${permLevel.level}`); + this.log(`Resources: ${permLevel.resolvedResourceIds?.join(', ') ?? ''}`); - lines.push(label.padEnd(maxLabelsLen, ' ') + value.padEnd(maxValuesLen, ' ') + (extra || '')); - } + const table = new Table({ + head: ['Group', 'Permission ID', 'Title', 'Granted', 'Description'], + wordWrap: true, + style: { head: ['cyan'] }, + }); - return lines.join('\n'); - }; + for (const group of permLevel.resolvedPermissionGroups) { + for (const perm of group.permissions) { + table.push([ + group.title, + perm.permission.id, + perm.permission.title, + perm.isGranted ? '✓' : '✗', + perm.permission.description, + ]); + } + } + + this.log(table.toString()); + } + } } diff --git a/packages/migrations/src/actions/2025.10.17T00-00-00.project-access-tokens.ts b/packages/migrations/src/actions/2025.10.17T00-00-00.project-access-tokens.ts new file mode 100644 index 00000000000..95257a40393 --- /dev/null +++ b/packages/migrations/src/actions/2025.10.17T00-00-00.project-access-tokens.ts @@ -0,0 +1,37 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2025.10.17T00-00-00.organization-access-tokens-project-scope.ts', + noTransaction: true, + run: ({ sql }) => [ + { + name: 'add new columns to "organization_access_tokens"', + query: sql` + ALTER TABLE "organization_access_tokens" + ADD COLUMN IF NOT EXISTS "project_id" UUID REFERENCES "projects" ("id") ON DELETE CASCADE + , ADD COLUMN IF NOT EXISTS "user_id" UUID REFERENCES "users" ("id") ON DELETE CASCADE + , ALTER COLUMN "permissions" DROP NOT NULL; + `, + }, + { + name: 'add index "organization_access_tokens_pagination_project"', + query: sql` + CREATE INDEX CONCURRENTLY IF NOT EXISTS "organization_access_tokens_pagination_project" ON "organization_access_tokens" ( + "project_id" + , "created_at" DESC + , "id" DESC + ) + `, + }, + { + name: 'add index "organization_access_tokens_pagination_user"', + query: sql` + CREATE INDEX CONCURRENTLY IF NOT EXISTS "organization_access_tokens_pagination_user" ON "organization_access_tokens" ( + "user_id" + , "created_at" DESC + , "id" DESC + ) + `, + }, + ], +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index 3d456c9b18f..a801866aa31 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -168,5 +168,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri await import('./actions/2025.05.15T00-00-01.organization-member-pagination'), await import('./actions/2025.05.28T00-00-00.schema-log-by-ids'), await import('./actions/2025.10.16T00-00-00.schema-log-by-commit-ordered'), + await import('./actions/2025.10.17T00-00-00.project-access-tokens'), ], }); diff --git a/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts b/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts index e9dab091d64..31106c08470 100644 --- a/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts +++ b/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts @@ -332,8 +332,20 @@ export const AuditLogModel = z.union([ eventType: z.literal('ORGANIZATION_ACCESS_TOKEN_CREATED'), metadata: z.object({ organizationAccessTokenId: z.string().uuid(), - permissions: z.array(z.string()), + permissions: z.array(z.string()).nullable(), assignedResources: ResourceAssignmentModel, + userId: z + .string() + // optional, because it was introduced later on. + .optional() + .nullable() + .transform(value => value ?? null), + projectId: z + .string() + // optional, because it was introduced later on. + .optional() + .nullable() + .transform(value => value ?? null), }), }), z.object({ diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index f4661b84573..6963ef2162e 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -5,7 +5,7 @@ import type { User } from '../../../shared/entities'; import { AccessError } from '../../../shared/errors'; import { objectEntries, objectFromEntries } from '../../../shared/helpers'; import { isUUID } from '../../../shared/is-uuid'; -import type { OrganizationAccessToken } from '../../organization/providers/organization-access-tokens'; +import { CachedAccessToken } from '../../organization/providers/organization-access-tokens-cache'; import { Logger } from '../../shared/providers/logger'; export type AuthorizationPolicyStatement = { @@ -55,7 +55,7 @@ export type UserActor = { export type OrganizationAccessTokenActor = { type: 'organizationAccessToken'; - organizationAccessToken: OrganizationAccessToken; + organizationAccessToken: CachedAccessToken; }; type Actor = UserActor | OrganizationAccessTokenActor; @@ -393,6 +393,7 @@ const permissionsByLevel = { z.literal('schemaLinting:modifyOrganizationRules'), z.literal('auditLog:export'), z.literal('accessToken:modify'), + z.literal('personalAccessToken:modify'), ], project: [ z.literal('project:describe'), @@ -401,6 +402,7 @@ const permissionsByLevel = { z.literal('alert:modify'), z.literal('schemaLinting:modifyProjectRules'), z.literal('target:create'), + z.literal('projectAccessToken:modify'), ], target: [ z.literal('targetAccessToken:modify'), @@ -524,7 +526,7 @@ type Actions = keyof typeof actionDefinitions; type ActionStrings = Actions | '*' | '*:describe'; /** Unauthenticated session that is returned by default. */ -class UnauthenticatedSession extends Session { +export class UnauthenticatedSession extends Session { protected loadPolicyStatementsForOrganization( _: string, ): Promise> | Array { diff --git a/packages/services/api/src/modules/auth/lib/organization-access-token-strategy.ts b/packages/services/api/src/modules/auth/lib/organization-access-token-strategy.ts index e8ce1dd1010..e1ba6a8d1b3 100644 --- a/packages/services/api/src/modules/auth/lib/organization-access-token-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/organization-access-token-strategy.ts @@ -1,8 +1,10 @@ import * as crypto from 'node:crypto'; import { type FastifyReply, type FastifyRequest } from '@hive/service-common'; import * as OrganizationAccessKey from '../../organization/lib/organization-access-key'; -import type { OrganizationAccessToken } from '../../organization/providers/organization-access-tokens'; -import { OrganizationAccessTokensCache } from '../../organization/providers/organization-access-tokens-cache'; +import { + CachedAccessToken, + OrganizationAccessTokensCache, +} from '../../organization/providers/organization-access-tokens-cache'; import { Logger } from '../../shared/providers/logger'; import { OrganizationAccessTokenValidationCache } from '../providers/organization-access-token-validation-cache'; import { @@ -19,7 +21,7 @@ function hashToken(token: string) { export class OrganizationAccessTokenSession extends Session { public readonly organizationId: string; private policies: Array; - private organizationAccessToken: OrganizationAccessToken; + private organizationAccessToken: CachedAccessToken; readonly id: string; constructor( @@ -27,7 +29,7 @@ export class OrganizationAccessTokenSession extends Session { id: string; organizationId: string; policies: Array; - organizationAccessToken: OrganizationAccessToken; + organizationAccessToken: CachedAccessToken; }, deps: { logger: Logger; 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 176cbf38f4a..210947f99cd 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 @@ -8,7 +8,7 @@ import { ProjectAccessScope, TargetAccessScope, } from '../providers/scopes'; -import { AuthNStrategy, Session, type AuthorizationPolicyStatement } from './authz'; +import { AuthNStrategy, Permission, Session, type AuthorizationPolicyStatement } from './authz'; export class TargetAccessTokenSession extends Session { public readonly organizationId: string; @@ -57,6 +57,11 @@ export class TargetAccessTokenSession extends Session { }; } + get allowedPermissions(): Array { + // Since the list is static and computed below, we can safely hard-cast it and treat all policy statements as "allow" + return this.policies.map(policy => policy.action) as Array; + } + public async getActor(): Promise { throw new AccessError('Authorization token is missing', 'UNAUTHENTICATED'); } diff --git a/packages/services/api/src/modules/auth/module.graphql.ts b/packages/services/api/src/modules/auth/module.graphql.ts index feac2c4ec49..e20fd694239 100644 --- a/packages/services/api/src/modules/auth/module.graphql.ts +++ b/packages/services/api/src/modules/auth/module.graphql.ts @@ -109,6 +109,10 @@ export default gql` dependsOnId: ID @tag(name: "public") isReadOnly: Boolean! warning: String + """ + Whether this permission is assignable by the current viewer. + """ + isAssignableByViewer: Boolean! } type PermissionGroup { diff --git a/packages/services/api/src/modules/auth/resolvers/Permission.ts b/packages/services/api/src/modules/auth/resolvers/Permission.ts index 1103b8a8520..bec64c19829 100644 --- a/packages/services/api/src/modules/auth/resolvers/Permission.ts +++ b/packages/services/api/src/modules/auth/resolvers/Permission.ts @@ -23,9 +23,12 @@ export const Permission: PermissionResolvers = { warning: async (permission, _arg, _ctx) => { return permission.warning ?? null; }, + isAssignableByViewer(permission) { + return permission.isAssignableByViewer ?? true; + }, }; -function resourceLevelToResourceLevelType(resourceLevel: ResourceLevel) { +export function resourceLevelToResourceLevelType(resourceLevel: ResourceLevel) { switch (resourceLevel) { case 'target': return 'TARGET' as const; @@ -39,3 +42,18 @@ function resourceLevelToResourceLevelType(resourceLevel: ResourceLevel) { return 'APP_DEPLOYMENT' as const; } } + +export function resourceLevelToHumanReadableName(resourceLevel: ResourceLevel) { + switch (resourceLevel) { + case 'target': + return 'Target' as const; + case 'service': + return 'Service' as const; + case 'project': + return 'Project' as const; + case 'organization': + return 'Organization' as const; + case 'appDeployment': + return 'App Deployment' as const; + } +} diff --git a/packages/services/api/src/modules/organization/lib/organization-access-token-permissions.ts b/packages/services/api/src/modules/organization/lib/organization-access-token-permissions.ts index 0a352e00757..eedc6144864 100644 --- a/packages/services/api/src/modules/organization/lib/organization-access-token-permissions.ts +++ b/packages/services/api/src/modules/organization/lib/organization-access-token-permissions.ts @@ -116,10 +116,15 @@ export const permissionGroups: Array = [ id: 'services', title: 'Schema Registry', permissions: [ + { + id: 'schema:compose', + title: 'Compose schema', + description: 'Allow using "hive dev" command for local composition.', + }, { id: 'schemaCheck:create', title: 'Check schema/service/subgraph', - description: 'Grant access to publish services/schemas.', + description: 'Grant access to run checks for services/schemas.', }, { id: 'schemaVersion:publish', @@ -147,6 +152,11 @@ export const permissionGroups: Array = [ title: 'Publish app deployment', description: 'Grant access to publishing app deployments.', }, + { + id: 'appDeployment:retire', + title: 'Retire app deployment', + description: 'Grant access to retring app deployments.', + }, ], }, ]; 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 index 3663909811a..b3bc1494cc4 100644 --- a/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts +++ b/packages/services/api/src/modules/organization/lib/organization-member-permissions.ts @@ -17,6 +17,7 @@ export const permissionGroups: Array = [ title: 'Access support tickets', description: 'Member can access, create and reply to support tickets.', }, + { id: 'accessToken:modify', title: 'Manage organization access tokens', @@ -135,10 +136,16 @@ export const permissionGroups: Array = [ }, { id: 'project:modifySettings', - title: 'Modify Settings', + title: 'Modify project settings', description: 'Member can access the specified projects.', dependsOn: 'project:describe', }, + { + id: 'projectAccessToken:modify', + title: 'Manage project access token', + description: 'Create access tokens for performing actions within the project.', + dependsOn: 'project:describe', + }, ], }, { @@ -237,6 +244,71 @@ export const permissionGroups: Array = [ }, ], }, + { + id: 'cli-actions', + title: 'CLI/API Actions', + permissions: [ + { + id: 'personalAccessToken:modify', + title: 'Manage personal access tokens', + description: 'Member can create and use personal access tokens.', + }, + { + id: 'schema:compose', + title: 'Compose schema', + description: 'Allow using "hive dev" command for local composition.', + dependsOn: 'personalAccessToken:modify', + }, + { + id: 'schemaCheck:create', + title: 'Check schema/service/subgraph', + description: 'Grant access to run checks for services/schemas.', + dependsOn: 'personalAccessToken:modify', + }, + { + id: 'schemaVersion:publish', + title: 'Publish schema/service/subgraph', + description: 'Grant access to publish services/schemas.', + dependsOn: 'personalAccessToken:modify', + }, + { + id: 'schemaVersion:deleteService', + title: 'Delete service', + description: 'Deletes a service from the schema registry.', + dependsOn: 'personalAccessToken:modify', + }, + { + id: 'appDeployment:create', + title: 'Create app deployment', + description: 'Grant access to creating app deployments.', + dependsOn: 'personalAccessToken:modify', + }, + { + id: 'appDeployment:publish', + title: 'Publish app deployment', + description: 'Grant access to publishing app deployments.', + dependsOn: 'personalAccessToken:modify', + }, + { + id: 'appDeployment:retire', + title: 'Retire app deployment', + description: 'Grant access to retring app deployments.', + dependsOn: 'personalAccessToken:modify', + }, + { + id: 'usage:report', + title: 'Report usage data', + description: 'Grant access to report usage data.', + dependsOn: 'personalAccessToken:modify', + }, + { + id: 'traces:report', + title: 'Report OTEL traces', + description: 'Grant access to reporting traces.', + dependsOn: 'personalAccessToken:modify', + }, + ], + }, ] as const; function assertAllRulesAreAssigned(excluded: Array) { diff --git a/packages/services/api/src/modules/organization/lib/permissions.ts b/packages/services/api/src/modules/organization/lib/permissions.ts index 2f5d003fd6a..5d041d2122b 100644 --- a/packages/services/api/src/modules/organization/lib/permissions.ts +++ b/packages/services/api/src/modules/organization/lib/permissions.ts @@ -7,6 +7,7 @@ export type PermissionRecord = { dependsOn?: Permission; isReadOnly?: true; warning?: string; + isAssignableByViewer?: boolean; }; export type PermissionGroup = { @@ -14,3 +15,30 @@ export type PermissionGroup = { title: string; permissions: Array; }; + +/** + * Utility to verify that all the permissions in one permission group are also available in the other permission group. + */ +export function assertPermissionGroupsIsSubset( + sourceGroups: Array, + subsetGroups: Array, +) { + const permissionsInSource = new Set( + sourceGroups.flatMap(group => group.permissions.map(permission => permission.id)), + ); + const missing = new Array(); + + subsetGroups.forEach(group => + group.permissions.forEach(permission => { + if (!permissionsInSource.has(permission.id)) { + missing.push(permission.id); + } + }), + ); + + if (missing.length) { + throw new Error( + 'The following permissions are missing in the main group.\n- ' + missing.join('\n- '), + ); + } +} diff --git a/packages/services/api/src/modules/organization/lib/resource-assignment-model.ts b/packages/services/api/src/modules/organization/lib/resource-assignment-model.ts index 7c9f8d09a87..95a70d77f78 100644 --- a/packages/services/api/src/modules/organization/lib/resource-assignment-model.ts +++ b/packages/services/api/src/modules/organization/lib/resource-assignment-model.ts @@ -26,6 +26,8 @@ const AssignedServicesModel = z.union([ WildcardAssignmentMode, ]); +type AssignedServices = z.TypeOf; + const AssignedAppDeploymentsModel = z.union([ z.object({ mode: GranularAssignmentModeModel, @@ -34,6 +36,8 @@ const AssignedAppDeploymentsModel = z.union([ WildcardAssignmentMode, ]); +type AssignedAppDeployments = z.TypeOf; + export const TargetAssignmentModel = z.object({ type: z.literal('target'), id: z.string().uuid(), @@ -41,6 +45,8 @@ export const TargetAssignmentModel = z.object({ appDeployments: AssignedAppDeploymentsModel, }); +export type AssignedTarget = z.TypeOf; + const AssignedTargetsModel = z.union([ z.object({ mode: GranularAssignmentModeModel, @@ -49,6 +55,8 @@ const AssignedTargetsModel = z.union([ WildcardAssignmentMode, ]); +type AssignedTargets = z.TypeOf; + const ProjectAssignmentModel = z.object({ type: z.literal('project'), id: z.string().uuid(), @@ -79,3 +87,111 @@ export const ResourceAssignmentModel = z.union([ */ export type ResourceAssignmentGroup = z.TypeOf; export type GranularAssignedProjects = z.TypeOf; + +/** + * Get the intersection of two resource assignments + */ +export function intersectResourceAssignments( + a: ResourceAssignmentGroup, + b: ResourceAssignmentGroup, +): ResourceAssignmentGroup { + if (a.mode === '*' && b.mode === '*') { + return { mode: '*' }; + } + + if (a.mode === '*') { + return b; + } + + if (b.mode === '*') { + return a; + } + + return { + mode: 'granular', + projects: a.projects + .map(projectA => { + const projectB = b.projects.find(p => p.id === projectA.id); + if (!projectB) { + return null; + } + + const intersectedTargets = intersectTargets(projectA.targets, projectB.targets); + + return { + ...projectA, + targets: intersectedTargets, + }; + }) + .filter((p): p is NonNullable => p !== null), + }; +} + +function intersectTargets(a: AssignedTargets, b: AssignedTargets): AssignedTargets { + if (a.mode === '*' && b.mode === '*') { + return { mode: '*' }; + } + + if (a.mode === '*') { + return b; + } + + if (b.mode === '*') { + return a; + } + + const targets = a.targets + .map(targetA => { + const targetB = b.targets.find(t => t.id === targetA.id); + if (!targetB) return null; + + return { + ...targetA, + services: intersectServices(targetA.services, targetB.services), + appDeployments: intersectAppDeployments(targetA.appDeployments, targetB.appDeployments), + }; + }) + .filter(t => t !== null); + + return { mode: 'granular', targets }; +} + +function intersectServices(a: AssignedServices, b: AssignedServices): AssignedServices { + if (a.mode === '*' && b.mode === '*') { + return { mode: '*' }; + } + if (a.mode === '*') { + return b; + } + if (b.mode === '*') { + return a; + } + + // Both granular + const services = a.services.filter(s => b.services.some(sb => sb.serviceName === s.serviceName)); + return { mode: 'granular', services }; +} + +function intersectAppDeployments( + a: AssignedAppDeployments, + b: AssignedAppDeployments, +): AssignedAppDeployments { + if (a.mode === '*' && b.mode === '*') { + return { mode: '*' }; + } + + if (a.mode === '*') { + return b; + } + + if (b.mode === '*') { + return a; + } + + // Both granular + const appDeployments = a.appDeployments.filter(ad => + b.appDeployments.some(bd => bd.type === ad.type && bd.appName === ad.appName), + ); + + return { mode: 'granular', appDeployments }; +} 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 5c71b316463..c66a4a566ff 100644 --- a/packages/services/api/src/modules/organization/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/organization/module.graphql.mappers.ts @@ -3,9 +3,12 @@ import type { OrganizationGetStarted, OrganizationInvitation, } from '../../shared/entities'; -import { OrganizationAccessToken } from './providers/organization-access-tokens'; -import { OrganizationMemberRole } from './providers/organization-member-roles'; -import { OrganizationMembership } from './providers/organization-members'; +import type { + GraphQLResolvedResourcePermissionGroupOutput, + OrganizationAccessToken, +} from './providers/organization-access-tokens'; +import type { OrganizationMemberRole } from './providers/organization-member-roles'; +import type { OrganizationMembership } from './providers/organization-members'; export type OrganizationConnectionMapper = readonly Organization[]; export type OrganizationMapper = Organization; @@ -14,3 +17,12 @@ export type OrganizationGetStartedMapper = OrganizationGetStarted; export type OrganizationInvitationMapper = OrganizationInvitation; export type MemberMapper = OrganizationMembership; export type OrganizationAccessTokenMapper = OrganizationAccessToken; +export type PersonalAccessTokenMapper = OrganizationAccessToken; +export type ProjectAccessTokenMapper = OrganizationAccessToken; + +export type WhoAmIMapper = { + title: string; + resolvedPermissions: ( + showAll: boolean, + ) => Promise>; +}; diff --git a/packages/services/api/src/modules/organization/module.graphql.ts b/packages/services/api/src/modules/organization/module.graphql.ts index 661904223a1..499c581a728 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -50,9 +50,147 @@ export default gql` createOrganizationAccessToken( input: CreateOrganizationAccessTokenInput! @tag(name: "public") ): CreateOrganizationAccessTokenResult! @tag(name: "public") + createProjectAccessToken(input: CreateProjectAccessTokenInput!): CreateProjectAccessTokenResult! + createPersonalAccessToken( + input: CreatePersonalAccessTokenInput! + ): CreatePersonalAccessTokenResult! deleteOrganizationAccessToken( input: DeleteOrganizationAccessTokenInput! @tag(name: "public") - ): DeleteOrganizationAccessTokenResult! @tag(name: "public") + ): DeleteOrganizationAccessTokenResult! + @tag(name: "public") + @deprecated(reason: "Please use 'Mutation.deleteAccessToken' instead.") + deleteAccessToken( + input: DeleteAccessTokenInput! @tag(name: "public") + ): DeleteAccessTokenResult! @tag(name: "public") + } + + input DeleteOrganizationAccessTokenInput { + """ + The access token that should be deleted. + """ + organizationAccessToken: OrganizationAccessTokenReference! @tag(name: "public") + } + + input OrganizationAccessTokenReference @oneOf @tag(name: "public") { + byId: ID @tag(name: "public") + } + + type DeleteOrganizationAccessTokenResult { + ok: DeleteOrganizationAccessTokenResultOk @tag(name: "public") + error: DeleteOrganizationAccessTokenResultError @tag(name: "public") + } + + type DeleteOrganizationAccessTokenResultOk { + deletedOrganizationAccessTokenId: ID! @tag(name: "public") + } + + type DeleteOrganizationAccessTokenResultError { + message: String! @tag(name: "public") + } + + input CreatePersonalAccessTokenInput { + """ + Organization in which the access token should be created. + """ + organization: OrganizationReferenceInput! + """ + Title of the access token. + """ + title: String! + """ + Additional description containing information about the purpose of the access token. + """ + description: String + """ + List of permissions that are assigned to the access token. + A list of available permissions can be retrieved via the 'Member.availablePersonalAccessTokenPermissionGroups' field. + """ + permissions: [String!] + """ + Resources on which the permissions should be granted (project, target, service, and app deployments). + Permissions are inherited by sub-resources. + """ + resources: ResourceAssignmentInput + } + + type CreatePersonalAccessTokenResultOk { + createdPersonalAccessToken: PersonalAccessToken! + privateAccessKey: String! + } + + type CreatePersonalAccessTokenResultError { + message: String! + details: CreatePersonalAccessTokenResultErrorDetails + } + + type CreatePersonalAccessTokenResultErrorDetails { + """ + Error message for the input title. + """ + title: String + """ + Error message for the input description. + """ + description: String + } + + type CreatePersonalAccessTokenResult { + ok: CreatePersonalAccessTokenResultOk + error: CreatePersonalAccessTokenResultError + } + + input CreateProjectAccessTokenInput { + """ + Project in which the access token should be created. + """ + project: ProjectReferenceInput! + """ + Title of the access token. + """ + title: String! + """ + Additional description containing information about the purpose of the access token. + """ + description: String + """ + List of permissions that are assigned to the access token. + A list of available permissions can be retrieved via the 'Organization.availableOrganizationAccessTokenPermissionGroups' field. + """ + permissions: [String!]! + """ + Resources on which the permissions should be granted (project, target, service, and app deployments). + Permissions are inherited by sub-resources. + """ + resources: ProjectTargetsResourceAssignmentInput! + } + + type CreateProjectAccessTokenResultOk { + createdProjectAccessToken: ProjectAccessToken! + privateAccessKey: String! + } + + type CreateProjectAccessTokenResultErrorDetails { + """ + Error message for the input title. + """ + title: String + """ + Error message for the input description. + """ + description: String + } + + type CreateProjectAccessTokenResultError { + message: String + details: CreateProjectAccessTokenResultErrorDetails + } + + """ + @oneOf + """ + type CreateProjectAccessTokenResult { + ok: CreateProjectAccessTokenResultOk + error: CreateProjectAccessTokenResultError } input OrganizationReferenceInput @oneOf { @@ -111,7 +249,34 @@ export default gql` description: String @tag(name: "public") } - type OrganizationAccessToken { + interface AccessToken { + id: ID! + title: String! + description: String + firstCharacters: String! + createdAt: DateTime! + """ + A list of the resource levels, the assigned resources and the granted permissions on each of those resources. + """ + resolvedResourcePermissionGroups( + """ + Whether the result should contain all permissions and resource groups, or only granted permissions/resources. + """ + includeAll: Boolean! = false + ): [ResolvedResourcePermissionGroup!]! + } + + type AccessTokenEdge { + node: AccessToken! + cursor: String! + } + + type AccessTokenConnection { + pageInfo: PageInfo! + edges: [AccessTokenEdge!]! + } + + type OrganizationAccessToken implements AccessToken { id: ID! @tag(name: "public") title: String! @tag(name: "public") description: String @tag(name: "public") @@ -119,29 +284,92 @@ export default gql` resources: ResourceAssignment! @tag(name: "public") firstCharacters: String! @tag(name: "public") createdAt: DateTime! @tag(name: "public") + """ + A list of the resource levels, the assigned resources and the granted permissions on each of those resources. + """ + resolvedResourcePermissionGroups( + """ + Whether the result should contain all permissions and resource groups, or only granted permissions/resources. + """ + includeAll: Boolean! = false + ): [ResolvedResourcePermissionGroup!]! } - input DeleteOrganizationAccessTokenInput { + type ProjectAccessTokenEdge { + node: ProjectAccessToken! + cursor: String! + } + + type PersonalAccessTokenConnection { + pageInfo: PageInfo! + edges: [PersonalAccessTokenEdge!]! + } + + type PersonalAccessTokenEdge { + node: PersonalAccessToken! + cursor: String! + } + + type ProjectAccessTokenConnection { + pageInfo: PageInfo! + edges: [ProjectAccessTokenEdge!]! + } + + type ProjectAccessToken implements AccessToken { + id: ID! @tag(name: "public") + title: String! @tag(name: "public") + description: String @tag(name: "public") + firstCharacters: String! @tag(name: "public") + createdAt: DateTime! @tag(name: "public") + """ + A list of the resource levels, the assigned resources and the granted permissions on each of those resources. + """ + resolvedResourcePermissionGroups( + """ + Whether the result should contain all permissions and resource groups, or only granted permissions/resources. + """ + includeAll: Boolean! = false + ): [ResolvedResourcePermissionGroup!]! + } + + type PersonalAccessToken implements AccessToken { + id: ID! @tag(name: "public") + title: String! @tag(name: "public") + description: String @tag(name: "public") + firstCharacters: String! @tag(name: "public") + createdAt: DateTime! @tag(name: "public") + """ + A list of the resource levels, the assigned resources and the granted permissions on each of those resources. + """ + resolvedResourcePermissionGroups( + """ + Whether the result should contain all permissions and resource groups, or only granted permissions/resources. + """ + includeAll: Boolean! = false + ): [ResolvedResourcePermissionGroup!]! + } + + input DeleteAccessTokenInput { """ The access token that should be deleted. """ - organizationAccessToken: OrganizationAccessTokenReference! @tag(name: "public") + accessToken: AccessTokenReference! @tag(name: "public") } - input OrganizationAccessTokenReference @oneOf @tag(name: "public") { + input AccessTokenReference @oneOf @tag(name: "public") { byId: ID @tag(name: "public") } - type DeleteOrganizationAccessTokenResult { - ok: DeleteOrganizationAccessTokenResultOk @tag(name: "public") - error: DeleteOrganizationAccessTokenResultError @tag(name: "public") + type DeleteAccessTokenResult { + ok: DeleteAccessTokenResultOk @tag(name: "public") + error: DeleteAccessTokenResultError @tag(name: "public") } - type DeleteOrganizationAccessTokenResultOk { - deletedOrganizationAccessTokenId: ID! @tag(name: "public") + type DeleteAccessTokenResultOk { + deletedAccessTokenId: ID! @tag(name: "public") } - type DeleteOrganizationAccessTokenResultError { + type DeleteAccessTokenResultError { message: String! @tag(name: "public") } @@ -388,16 +616,30 @@ export default gql` """ viewerCanManageAccessTokens: Boolean! """ - Paginated organization access tokens. + Whether the viewer can manage personal access tokens. + """ + viewerCanManagePersonalAccessTokens: Boolean! + """ + Paginated list of all organization scoped access tokens. """ accessTokens( first: Int @tag(name: "public") after: String @tag(name: "public") ): OrganizationAccessTokenConnection! @tag(name: "public") """ - Get organization access token by id. + Retrieve a organization scoped access token by it's id. """ accessToken(id: ID! @tag(name: "public")): OrganizationAccessToken @tag(name: "public") + + """ + Retrieve a list of all access tokens within the organization. + This includes organization, project and personal scoped access tokens. + """ + allAccessTokens(first: Int, after: String): AccessTokenConnection! + """ + Retrieve a access token within the organization by its ID. + """ + accessTokenById(id: ID!): AccessToken } type OrganizationAccessTokenEdge { @@ -769,4 +1011,81 @@ export default gql` mode: ResourceAssignmentModeType! @tag(name: "public") projects: [ProjectResourceAssignment!] @tag(name: "public") } + + extend type Project { + """ + Paginated list of access tokens issued for the project. + """ + accessTokens(first: Int, after: String): ProjectAccessTokenConnection! + """ + Access token for project. + """ + accessToken(id: ID!): ProjectAccessToken + """ + Permissions that the viewer can assign to project access tokens. + """ + availableProjectAccessTokenPermissionGroups: [PermissionGroup!]! + """ + Whether the user can manage the access tokens in this project. + """ + viewerCanManageProjectAccessTokens: Boolean! + } + + extend type Member { + availablePersonalAccessTokenPermissionGroups: [PermissionGroup!]! + """ + Paginated list of access tokens issued for the project. + """ + accessTokens(first: Int, after: String): PersonalAccessTokenConnection! + """ + Access token for project. + """ + accessToken(id: ID!): PersonalAccessToken + } + + type ResolvedPermission { + """ + The permission + """ + permission: Permission! + """ + Whether this permission is granted. + """ + isGranted: Boolean! + } + + type ResolvedPermissionsGroup { + title: String! + permissions: [ResolvedPermission!]! + } + + type ResolvedResourcePermissionGroup { + """ + On what resoucre level this permission applies. + """ + level: PermissionLevelType! + """ + Resource ids that are currently valid for this permission group. + """ + resolvedResourceIds: [String!] + """ + Title + """ + title: String! + resolvedPermissionGroups: [ResolvedPermissionsGroup!]! + } + + type WhoAmI { + title: String! + resolvedPermissions( + """ + Whether an overview of all permissions should be included in the output, even if not granted. + """ + includeAll: Boolean = false + ): [ResolvedResourcePermissionGroup!]! + } + + extend type Query { + whoAmI: WhoAmI + } `; diff --git a/packages/services/api/src/modules/organization/providers/organization-access-tokens-cache.ts b/packages/services/api/src/modules/organization/providers/organization-access-tokens-cache.ts index 17f25bb27d4..b8f23a0833c 100644 --- a/packages/services/api/src/modules/organization/providers/organization-access-tokens-cache.ts +++ b/packages/services/api/src/modules/organization/providers/organization-access-tokens-cache.ts @@ -5,11 +5,29 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; import type Redis from 'ioredis'; import type { DatabasePool } from 'slonik'; import { prometheusPlugin } from '@bentocache/plugin-prometheus'; +import { AuthorizationPolicyStatement } from '../../auth/lib/authz'; import { Logger } from '../../shared/providers/logger'; import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; import { PrometheusConfig } from '../../shared/providers/prometheus-config'; import { REDIS_INSTANCE } from '../../shared/providers/redis'; -import { findById, type OrganizationAccessToken } from './organization-access-tokens'; +import { + findById, + OrganizationAccessTokens, + type OrganizationAccessToken, +} from './organization-access-tokens'; +import { OrganizationMembers } from './organization-members'; + +export type CachedAccessToken = { + id: string; + organizationId: string; + /** undefined, since older cache records might not contain this property. */ + projectId?: string | null; + /** undefined, since older cache records might not contain this property. */ + userId?: string | null; + authorizationPolicyStatements: Array; + hash: string; + firstCharacters: string; +}; /** * Cache for performant OrganizationAccessToken lookups. @@ -50,6 +68,46 @@ export class OrganizationAccessTokensCache { }); } + private async makeCacheRecord( + logger: Logger, + accessToken: OrganizationAccessToken, + ): Promise { + logger.debug('create cache record for access token'); + let authorizationPolicyStatements: Array | null = null; + + // in this case, we need to load the membership and filter down permissions based on the viewer. + if (accessToken.userId) { + logger.debug('personal access token detected'); + + const membership = await OrganizationMembers.findOrganizationMembership({ + logger, + pool: this.pool, + })(accessToken.organizationId, accessToken.userId); + + // No membership? No access token! + if (!membership) { + logger.debug('could not find membership'); + return null; + } + + authorizationPolicyStatements = OrganizationAccessTokens.computeAuthorizationStatements( + accessToken, + membership, + ); + } + + return { + id: accessToken.id, + organizationId: accessToken.organizationId, + projectId: accessToken.projectId, + userId: accessToken.userId, + authorizationPolicyStatements: + authorizationPolicyStatements ?? accessToken.authorizationPolicyStatements, + hash: accessToken.hash, + firstCharacters: accessToken.firstCharacters, + }; + } + get( id: string, /** Request scoped logger so we associate the request-id with any logs occuring during the SQL lookup. */ @@ -57,22 +115,29 @@ export class OrganizationAccessTokensCache { ) { return this.cache.getOrSet({ key: id, - factory: () => findById({ logger, pool: this.pool })(id), + factory: async () => { + const record = await findById({ logger, pool: this.pool })(id); + if (!record) { + return null; + } + + return this.makeCacheRecord(logger, record); + }, ttl: '5min', grace: '24h', }); } - add(token: OrganizationAccessToken) { + async add(logger: Logger, record: OrganizationAccessToken) { return this.cache.set({ - key: token.id, - value: token, + key: record.id, + value: await this.makeCacheRecord(logger, record), ttl: '5min', grace: '24h', }); } - purge(token: OrganizationAccessToken) { + purge(token: Pick) { return this.cache.delete({ key: token.id, }); diff --git a/packages/services/api/src/modules/organization/providers/organization-access-tokens.ts b/packages/services/api/src/modules/organization/providers/organization-access-tokens.ts index af35060a8af..aaa8b1d2702 100644 --- a/packages/services/api/src/modules/organization/providers/organization-access-tokens.ts +++ b/packages/services/api/src/modules/organization/providers/organization-access-tokens.ts @@ -6,23 +6,36 @@ import { encodeCreatedAtAndUUIDIdBasedCursor, } from '@hive/storage'; import * as GraphQLSchema from '../../../__generated__/types'; +import { Organization, Project } from '../../../shared/entities'; import { isUUID } from '../../../shared/is-uuid'; import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { + getPermissionGroup, InsufficientPermissionError, Permission, PermissionsModel, permissionsToPermissionsPerResourceLevelAssignment, + ResourceLevel, Session, } from '../../auth/lib/authz'; +import { + resourceLevelToHumanReadableName, + resourceLevelToResourceLevelType, +} from '../../auth/resolvers/Permission'; import { IdTranslator } from '../../shared/providers/id-translator'; import { Logger } from '../../shared/providers/logger'; import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; import { Storage } from '../../shared/providers/storage'; import * as OrganizationAccessKey from '../lib/organization-access-key'; -import { assignablePermissions } from '../lib/organization-access-token-permissions'; -import { ResourceAssignmentModel } from '../lib/resource-assignment-model'; +import * as OrganizationAccessTokensPermissions from '../lib/organization-access-token-permissions'; +import { PermissionGroup, PermissionRecord } from '../lib/permissions'; +import { + intersectResourceAssignments, + ResourceAssignmentGroup, + ResourceAssignmentModel, +} from '../lib/resource-assignment-model'; import { OrganizationAccessTokensCache } from './organization-access-tokens-cache'; +import { OrganizationMembers, OrganizationMembership } from './organization-members'; import { resolveResourceAssignment, ResourceAssignments, @@ -46,10 +59,13 @@ const OrganizationAccessTokenModel = z .object({ id: z.string().uuid(), organizationId: z.string().uuid(), + projectId: z.string().uuid().nullable(), + userId: z.string().uuid().nullable(), createdAt: z.string(), title: z.string(), description: z.string(), - permissions: z.array(PermissionsModel), + /** Note: permissions is only supposed to be nullable if "userId" is non-null */ + permissions: z.array(PermissionsModel).nullable(), assignedResources: ResourceAssignmentModel.nullable().transform( value => value ?? { mode: '*' as const, projects: [] }, ), @@ -58,14 +74,47 @@ const OrganizationAccessTokenModel = z }) .transform(record => ({ ...record, + get __typename() { + return record.userId + ? ('OrganizationAccessToken' as const) + : record.projectId + ? ('PersonalAccessToken' as const) + : ('ProjectAccessToken' as const); + }, + scope: record.userId + ? ('PERSONAL' as const) + : record.projectId + ? ('PROJECT' as const) + : ('ORGANIZATION' as const), // We have these as a getter statement as they are // only used in the context of authorization, we do not need // to compute when querying a list of organization access tokens via the GraphQL API. get authorizationPolicyStatements() { - const permissions = permissionsToPermissionsPerResourceLevelAssignment(record.permissions); + if (record.permissions === null && record.userId === null) { + throw new Error( + 'I am very sorry, but this property only works for access tokens on the organization and project scope.' + + '\nFor user access tokens we need to look at the organization memebrship in order to compute the authorization policy statements.', + ); + } + const permissions = permissionsToPermissionsPerResourceLevelAssignment( + record.permissions ?? [], + ); + let assignedResources = record.assignedResources; + + // Filter down permissions based on project - just to be sure :) + if (record.projectId) { + assignedResources = { + mode: 'granular', + projects: + assignedResources.mode === 'granular' + ? assignedResources.projects.filter(project => project.id === record.projectId) + : [], + }; + } + const resolvedResources = resolveResourceAssignment({ organizationId: record.organizationId, - projects: record.assignedResources, + projects: assignedResources, }); return translateResolvedResourcesToAuthorizationPolicyStatements( @@ -78,6 +127,13 @@ const OrganizationAccessTokenModel = z export type OrganizationAccessToken = z.TypeOf; +const validProjectResourceLevels: ReadonlySet = new Set([ + 'project', + 'target', + 'service', + 'appDeployment', +]); + @Injectable({ scope: Scope.Operation, }) @@ -94,6 +150,7 @@ export class OrganizationAccessTokens { private session: Session, private auditLogs: AuditLogRecorder, private storage: Storage, + private members: OrganizationMembers, logger: Logger, ) { this.logger = logger.child({ @@ -102,13 +159,7 @@ export class OrganizationAccessTokens { this.findById = findById({ logger: this.logger, pool }); } - async create(args: { - organization: GraphQLSchema.OrganizationReferenceInput; - title: string; - description: string | null; - permissions: Array; - assignedResources: GraphQLSchema.ResourceAssignmentInput | null; - }) { + private _validateCreateInputError(args: { title: string; description: string | null }) { const titleResult = TitleInputModel.safeParse(args.title.trim()); const descriptionResult = DescriptionInputModel.safeParse(args.description); @@ -122,6 +173,86 @@ export class OrganizationAccessTokens { }, }; } + } + + /** + * Create an access token that is on the project level. + */ + async createForProject(args: { + project: GraphQLSchema.ProjectReferenceInput; + title: string; + description: string | null; + permissions: Array; + assignedResources: GraphQLSchema.ProjectTargetsResourceAssignmentInput | null; + }) { + this.logger.debug('create access token for project (project=%o)', args.project); + + const error = this._validateCreateInputError(args); + + if (error) { + return error; + } + + const selector = await this.idTranslator.resolveProjectReference({ + reference: args.project, + }); + + if (!selector) { + this.session.raise('projectAccessToken:modify'); + } + + const { projectId, organizationId } = selector; + + await this.session.assertPerformAction({ + organizationId, + params: { organizationId, projectId }, + action: 'projectAccessToken:modify', + }); + + const permissions = args.permissions.filter(permission => + validProjectResourceLevels.has(getPermissionGroup(permission as Permission)), + ); + + const assignedResources = + await this.resourceAssignments.transformGraphQLResourceAssignmentInputToResourceAssignmentGroup( + organizationId, + { + mode: 'GRANULAR', + projects: [ + { + projectId, + targets: args.assignedResources ?? { mode: 'ALL' }, + }, + ], + }, + ); + + return this._create({ + organizationId, + projectId, + userId: null, + title: args.title, + description: args.description, + assignedResources, + permissions, + }); + } + + /** + * Create an access token that is on the organization level. + */ + async createForOrganization(args: { + organization: GraphQLSchema.OrganizationReferenceInput; + title: string; + description: string | null; + permissions: Array; + assignedResources: GraphQLSchema.ResourceAssignmentInput; + }) { + const error = this._validateCreateInputError(args); + + if (error) { + return error; + } const { organizationId } = await this.idTranslator.resolveOrganizationReference({ reference: args.organization, @@ -130,11 +261,7 @@ export class OrganizationAccessTokens { }, }); - await this.session.assertPerformAction({ - organizationId, - params: { organizationId }, - action: 'accessToken:modify', - }); + const organization = await this.storage.getOrganization({ organizationId }); const assignedResources = await this.resourceAssignments.transformGraphQLResourceAssignmentInputToResourceAssignmentGroup( @@ -142,19 +269,139 @@ export class OrganizationAccessTokens { args.assignedResources ?? { mode: 'GRANULAR' }, ); - const organization = await this.storage.getOrganization({ organizationId }); + await this.session.assertPerformAction({ + organizationId, + params: { organizationId }, + action: 'accessToken:modify', + }); + + // Permissions assigned to this access token must be valid organization access token permissions + const assignablePermissionFilter = (permission: Permission) => + OrganizationAccessTokensPermissions.assignablePermissions.has(permission); + + // Permissions assigned to this access token must be valid based on the organziations feature flags + const featurePermissionFlagFilter = this.createFeatureFlagPermissionFilter(organization); + + const permissionFilter = (permission: Permission) => + featurePermissionFlagFilter(permission) && assignablePermissionFilter(permission); const permissions = Array.from( - new Set( - args.permissions.filter( - permission => - assignablePermissions.has(permission as Permission) && - // can only assign traces report permission if otel tracing feature flag is enabled in organization - (permission === 'traces:report' ? organization.featureFlags.otelTracing : true), - ), + new Set(args.permissions.filter(permission => permissionFilter(permission as Permission))), + ); + + return this._create({ + organizationId, + projectId: null, + userId: null, + title: args.title, + description: args.description, + assignedResources, + permissions, + }); + } + + /** Create an access token on the user scope. */ + async createPersonalAccessTokenForViewer(args: { + organization: GraphQLSchema.OrganizationReferenceInput; + title: string; + description: string | null; + permissions: ReadonlyArray | null; + assignedResources: GraphQLSchema.ResourceAssignmentInput | null; + }) { + const error = this._validateCreateInputError(args); + + if (error) { + return error; + } + + const viewer = await this.session.getViewer(); + + const { organizationId } = await this.idTranslator.resolveOrganizationReference({ + reference: args.organization, + onError() { + throw new InsufficientPermissionError('personalAccessToken:modify'); + }, + }); + + const organization = await this.storage.getOrganization({ organizationId }); + + await this.session.assertPerformAction({ + organizationId, + params: { organizationId }, + action: 'personalAccessToken:modify', + }); + + const membership = await this.members.findOrganizationMembership({ + organization, + userId: viewer.id, + }); + + if (!membership) { + this.session.raise('personalAccessToken:modify'); + } + + // Handle permission assignment + // + // Must be intersection with the members permissions + + const assignedResources = intersectResourceAssignments( + await this.resourceAssignments.transformGraphQLResourceAssignmentInputToResourceAssignmentGroup( + organizationId, + args.assignedResources ?? { mode: 'ALL' }, ), + membership.assignedRole.resources, ); + // Handle permission assignment + // + // permissions -> null : The access tokens permissions equal members permission. + // permissions -> non-null: The access tokens permissions equal a subset of the members permissions. + + let permissions = args.permissions; + + if (permissions !== null) { + const membershipPermissions = membership.assignedRole.role.allPermissions; + + const membershipHasPermissionFilter = (permission: Permission) => + membershipPermissions.has(permission); + + // Permissions assigned to this access token must be valid organization access token permissions + const assignablePermissionFilter = (permission: Permission) => + OrganizationAccessTokensPermissions.assignablePermissions.has(permission); + + // Permissions assigned to this access token must be valid based on the organziations feature flags + const featurePermissionFlagFilter = this.createFeatureFlagPermissionFilter(organization); + + const permissionFilter = (permission: Permission) => + featurePermissionFlagFilter(permission) && + assignablePermissionFilter(permission) && + membershipHasPermissionFilter(permission); + + permissions = Array.from( + new Set(permissions.filter(permission => permissionFilter(permission as Permission))), + ); + } + + return this._create({ + organizationId, + projectId: null, + userId: viewer.id, + title: args.title, + description: args.description, + assignedResources, + permissions, + }); + } + + private async _create(args: { + organizationId: string; + projectId: string | null; + userId: string | null; + title: string; + description: string | null; + permissions: ReadonlyArray | null; + assignedResources: ResourceAssignmentGroup; + }) { const id = crypto.randomUUID(); const accessKey = await OrganizationAccessKey.create(id); @@ -162,6 +409,8 @@ export class OrganizationAccessTokens { INSERT INTO "organization_access_tokens" ( "id" , "organization_id" + , "project_id" + , "user_id" , "title" , "description" , "permissions" @@ -171,11 +420,13 @@ export class OrganizationAccessTokens { ) VALUES ( ${id} - , ${organizationId} - , ${titleResult.data} - , ${descriptionResult.data} - , ${sql.array(permissions, 'text')} - , ${sql.jsonb(assignedResources)} + , ${args.organizationId} + , ${args.projectId} + , ${args.userId} + , ${args.title} + , ${args.description} + , ${args.permissions !== null ? sql.array(args.permissions, 'text') : null} + , ${sql.jsonb(args.assignedResources)} , ${accessKey.hash} , ${accessKey.firstCharacters} ) @@ -183,45 +434,83 @@ export class OrganizationAccessTokens { ${organizationAccessTokenFields} `); - const organizationAccessToken = OrganizationAccessTokenModel.parse(result); + const accessToken = OrganizationAccessTokenModel.parse(result); - await this.cache.add(organizationAccessToken); + await this.cache.add(this.logger, accessToken); await this.auditLogs.record({ - organizationId, + organizationId: args.organizationId, eventType: 'ORGANIZATION_ACCESS_TOKEN_CREATED', metadata: { - organizationAccessTokenId: organizationAccessToken.id, - permissions: organizationAccessToken.permissions, - assignedResources: organizationAccessToken.assignedResources, + organizationAccessTokenId: accessToken.id, + permissions: accessToken.permissions, + assignedResources: accessToken.assignedResources, + projectId: accessToken.projectId, + userId: accessToken.userId, }, }); + this.logger.debug('Access tokens was created successfully. (accessTokenId=%s)', accessToken.id); + return { type: 'success' as const, - organizationAccessToken, + accessToken, privateAccessKey: accessKey.privateAccessToken, }; } - async delete(args: { organizationAccessTokenId: string }) { - const record = await this.findById(args.organizationAccessTokenId); - if (record === null) { - throw new InsufficientPermissionError('accessToken:modify'); + async delete(args: { accessTokenId: string; onlyOrganizationScoped?: true }) { + this.logger.debug('Delete access token. (accessTokenId=%s)', args.accessTokenId); + + const record = await this.findById(args.accessTokenId); + if (record === null || (args.onlyOrganizationScoped && (record.projectId || record.userId))) { + this.logger.debug('Delete failed. Token not found. (accessTokenId=%s)', args.accessTokenId); + + return { + type: 'error' as const, + message: 'The access token does not exist.', + }; } - await this.session.assertPerformAction({ + const canOrganizationAccessTokens = await this.session.canPerformAction({ action: 'accessToken:modify', organizationId: record.organizationId, params: { organizationId: record.organizationId }, }); + if (record.projectId) { + if (!canOrganizationAccessTokens) { + await this.session.assertPerformAction({ + action: 'projectAccessToken:modify', + organizationId: record.organizationId, + params: { organizationId: record.organizationId, projectId: record.projectId }, + }); + } + } else if (record.userId) { + if (!canOrganizationAccessTokens) { + await this.session.assertPerformAction({ + action: 'personalAccessToken:modify', + organizationId: record.organizationId, + params: { organizationId: record.organizationId }, + }); + + const viewer = await this.session.getViewer(); + if (viewer.id !== record.userId) { + this.session.raise('personalAccessToken:modify'); + } + } + } else { + if (!canOrganizationAccessTokens) { + this.session.raise('accessToken:modify'); + } + } + await this.pool.query(sql` DELETE FROM "organization_access_tokens" WHERE - "id" = ${args.organizationAccessTokenId} + "id" = ${record.id} `); await this.cache.purge(record); @@ -234,16 +523,29 @@ export class OrganizationAccessTokens { }, }); + this.logger.debug( + 'Access tokens was deleted successfully. (accessTokenId=%s)', + args.accessTokenId, + ); + return { type: 'success' as const, - organizationAccessTokenId: args.organizationAccessTokenId, + organizationAccessTokenId: record.id, }; } - async getPaginated(args: { organizationId: string; first: number | null; after: string | null }) { + async getPaginatedForOrganization( + organization: Organization, + args: { + first: number | null; + after: string | null; + /** Whether only access tokens on the organization scope should be included in the result. */ + includeOnlyOrganizationScoped?: true; + }, + ) { await this.session.assertPerformAction({ - organizationId: args.organizationId, - params: { organizationId: args.organizationId }, + organizationId: organization.id, + params: { organizationId: organization.id }, action: 'accessToken:modify', }); @@ -264,7 +566,7 @@ export class OrganizationAccessTokens { FROM "organization_access_tokens" WHERE - "organization_id" = ${args.organizationId} + "organization_id" = ${organization.id} ${ cursor ? sql` @@ -278,6 +580,14 @@ export class OrganizationAccessTokens { ` : sql`` } + ${ + args.includeOnlyOrganizationScoped + ? sql` + AND "project_id" IS NULL + AND "user_id" IS NULL + ` + : sql`` + } ORDER BY "organization_id" ASC , "created_at" DESC @@ -315,31 +625,541 @@ export class OrganizationAccessTokens { }; } - async get(args: { organizationId: string; id: string }) { + async getPaginatedForProject( + project: Project, + args: { + first: number | null; + after: string | null; + }, + ) { await this.session.assertPerformAction({ - organizationId: args.organizationId, - params: { organizationId: args.organizationId }, - action: 'accessToken:modify', + organizationId: project.orgId, + params: { organizationId: project.orgId, projectId: project.id }, + action: 'projectAccessToken:modify', + }); + + let cursor: null | { + createdAt: string; + id: string; + } = null; + + const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; + + if (args.after) { + cursor = decodeCreatedAtAndUUIDIdBasedCursor(args.after); + } + + const result = await this.pool.any(sql` /* OrganizationAccessTokens.getPaginated */ + SELECT + ${organizationAccessTokenFields} + FROM + "organization_access_tokens" + WHERE + "project_id" = ${project.id} + ${ + cursor + ? sql` + AND ( + ( + "created_at" = ${cursor.createdAt} + AND "id" < ${cursor.id} + ) + OR "created_at" < ${cursor.createdAt} + ) + ` + : sql`` + } + ORDER BY + "project_id" ASC + , "created_at" DESC + , "id" DESC + LIMIT ${limit + 1} + `); + + let edges = result.map(row => { + const node = OrganizationAccessTokenModel.parse(row); + + return { + node, + get cursor() { + return encodeCreatedAtAndUUIDIdBasedCursor(node); + }, + }; }); - const row = await this.pool.maybeOne(sql` /* OrganizationAccessTokens.getPaginated */ + const hasNextPage = edges.length > limit; + + edges = edges.slice(0, limit); + + return { + edges, + pageInfo: { + hasNextPage, + hasPreviousPage: cursor !== null, + get endCursor() { + return edges[edges.length - 1]?.cursor ?? ''; + }, + get startCursor() { + return edges[0]?.cursor ?? ''; + }, + }, + }; + } + + async getPaginatedForMembership( + member: OrganizationMembership, + args: { + first: number | null; + after: string | null; + }, + ) { + let cursor: null | { + createdAt: string; + id: string; + } = null; + + const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; + + if (args.after) { + cursor = decodeCreatedAtAndUUIDIdBasedCursor(args.after); + } + + const result = await this.pool + .any(sql` /* OrganizationAccessTokens.getPaginatedForMembership */ SELECT ${organizationAccessTokenFields} FROM "organization_access_tokens" WHERE - "id" = ${args.id} - AND "organization_id" = ${args.organizationId} + "user_id" = ${member.userId} + ${ + cursor + ? sql` + AND ( + ( + "created_at" = ${cursor.createdAt} + AND "id" < ${cursor.id} + ) + OR "created_at" < ${cursor.createdAt} + ) + ` + : sql`` + } + ORDER BY + "user_id" ASC + , "created_at" DESC + , "id" DESC + LIMIT ${limit + 1} `); - if (row === null) { + let edges = result.map(row => { + const node = OrganizationAccessTokenModel.parse(row); + + return { + node, + get cursor() { + return encodeCreatedAtAndUUIDIdBasedCursor(node); + }, + }; + }); + + const hasNextPage = edges.length > limit; + + edges = edges.slice(0, limit); + + return { + edges, + pageInfo: { + hasNextPage, + hasPreviousPage: cursor !== null, + get endCursor() { + return edges[edges.length - 1]?.cursor ?? ''; + }, + get startCursor() { + return edges[0]?.cursor ?? ''; + }, + }, + }; + } + + /** Get a access token by it's ID without performing any permission checks. */ + async getById(id: string) { + return await findById({ + logger: this.logger, + pool: this.pool, + })(id); + } + + async getForOrganization( + organization: Organization, + id: string, + includeOnlyOrganizationScoped?: true, + ) { + await this.session.assertPerformAction({ + organizationId: organization.id, + params: { organizationId: organization.id }, + action: 'accessToken:modify', + }); + + const accessToken = await this.getById(id); + + if (!accessToken || accessToken?.organizationId !== organization.id) { return null; } - return OrganizationAccessTokenModel.parse(row); + if (includeOnlyOrganizationScoped && (accessToken.projectId || accessToken.userId)) { + return null; + } + + return accessToken; + } + + async getForMembership(membership: OrganizationMembership, id: string) { + const accessToken = await this.getById(id); + + if ( + !accessToken || + !accessToken.userId || + accessToken.userId !== membership.userId || + accessToken.organizationId !== membership.organizationId + ) { + return null; + } + + return accessToken; + } + + async getForProject(project: Project, id: string) { + await this.session.assertPerformAction({ + organizationId: project.orgId, + params: { organizationId: project.orgId, projectId: project.id }, + action: 'projectAccessToken:modify', + }); + + const accessToken = await this.getById(id); + + if (!accessToken || accessToken?.projectId !== project.id) { + return null; + } + + return accessToken; + } + + async getAvailablePermissionsGroupsForProject(project: Project): Promise> { + await this.session.assertPerformAction({ + organizationId: project.orgId, + params: { organizationId: project.orgId, projectId: project.id }, + action: 'projectAccessToken:modify', + }); + + const organization = await this.storage.getOrganization({ organizationId: project.orgId }); + const groups = await this.getAvailablePermissionGroupsForOrganization(organization); + + return groups + .map(group => ({ + ...group, + permissions: group.permissions.filter(permission => + validProjectResourceLevels.has(getPermissionGroup(permission.id)), + ), + })) + .filter(group => group.permissions.length !== 0); + } + + private createFeatureFlagPermissionFilter(organization: Organization) { + const isAppDeplymentsEnabled = organization.featureFlags.appDeployments; + const isOTELTracingEnabled = organization.featureFlags.otelTracing; + + return (id: Permission) => + (!isAppDeplymentsEnabled && id.startsWith('appDeployment:')) || + (!isOTELTracingEnabled && id.startsWith('traces:')) + ? false + : true; + } + + async getAvailablePermissionGroupsForOrganization( + organization: Organization, + ): Promise> { + const filter = this.createFeatureFlagPermissionFilter(organization); + + return OrganizationAccessTokensPermissions.permissionGroups + .map(group => ({ + ...group, + permissions: group.permissions + .filter(permission => filter(permission.id)) + .map(permission => ({ + ...permission, + isAssignableByViewer: true, + })), + })) + .filter(group => group.permissions.length !== 0); + } + + async getAvailablePermissionGroupsForMembership(membership: OrganizationMembership) { + const organization = await this.storage.getOrganization({ + organizationId: membership.organizationId, + }); + const groups = await this.getAvailablePermissionGroupsForOrganization(organization); + const memberPermissions = membership.assignedRole.role.allPermissions; + + return groups + .map(group => ({ + ...group, + permissions: group.permissions.map(permission => ({ + ...permission, + isAssignableByViewer: memberPermissions.has(permission.id), + })), + })) + .filter(group => group.permissions.length !== 0); + } + + async getPermissionsForAccessToken(accessToken: OrganizationAccessToken) { + if (!accessToken.userId) { + return accessToken.permissions ?? []; + } + const organization = await this.storage.getOrganization({ + organizationId: accessToken.organizationId, + }); + const membership = await this.members.findOrganizationMembership({ + organization, + userId: accessToken.userId, + }); + if (!membership) { + return []; + } + + const allRolePermissions = membership.assignedRole.role.allPermissions; + + if (!accessToken.permissions) { + return Array.from(membership.assignedRole.role.allPermissions); + } + + return accessToken.permissions.filter(permission => allRolePermissions.has(permission)); + } + + async getResourceAssignmentsForAccessToken(accessToken: OrganizationAccessToken) { + if (accessToken.userId === null) { + return this.resourceAssignments.resolveGraphQLMemberResourceAssignment({ + organizationId: accessToken.organizationId, + resources: accessToken.assignedResources, + }); + } + + const organization = await this.storage.getOrganization({ + organizationId: accessToken.organizationId, + }); + const membership = await this.members.findOrganizationMembership({ + organization, + userId: accessToken.userId, + }); + + if (!membership) { + return { + mode: 'GRANULAR', + } satisfies GraphQLSchema.ResolversTypes['ResourceAssignment']; + } + + return this.resourceAssignments.resolveGraphQLMemberResourceAssignment({ + organizationId: accessToken.organizationId, + resources: intersectResourceAssignments( + accessToken.assignedResources, + membership?.assignedRole.resources, + ), + }); + } + + getGraphQLResolvedResourcePermissionGroupForAccessToken = + ( + accessToken: Pick< + OrganizationAccessToken, + 'organizationId' | 'projectId' | 'assignedResources' | 'permissions' | 'userId' + >, + ) => + async ( + /** Whether to include all or only the granted permissions. */ + includeAll: boolean = false, + ): Promise> => { + let grantedPermissions: Set; + let grantedResources: ResourceAssignmentGroup; + + if (accessToken.userId) { + const organization = await this.storage.getOrganization({ + organizationId: accessToken.organizationId, + }); + const membership = await this.members.findOrganizationMembership({ + organization, + userId: accessToken.userId, + }); + + if ( + !membership || + !membership.assignedRole.role.permissions.organization.has('personalAccessToken:modify') + ) { + return []; + } + + grantedResources = intersectResourceAssignments( + accessToken.assignedResources, + membership.assignedRole.resources, + ); + const membershipPermissions = membership.assignedRole.role.allPermissions; + grantedPermissions = new Set( + accessToken.permissions?.filter(permission => membershipPermissions.has(permission)) ?? + [], + ); + } else { + grantedPermissions = new Set(accessToken.permissions ?? []); + grantedResources = accessToken.assignedResources; + } + + const resourceIds = await this.resourceAssignments.resourceAssignmentToResourceIds( + accessToken.organizationId, + grantedResources, + ); + + type PMap = { + level: ResourceLevel; + permissionGroups: Map< + /* group name */ string, + { + title: string; + permissions: Array<{ + permission: PermissionRecord; + isGranted: boolean; + }>; + } + >; + resourceIds: Array; + }; + + const resourceLevelGroups = new Map( + ( + [ + 'organization', + 'project', + 'target', + 'service', + 'appDeployment', + ] satisfies Array + ).map((value): [ResourceLevel, PMap] => [ + value, + { + level: value, + permissionGroups: new Map(), + resourceIds: resourceIds[value] ?? [], + }, + ]), + ); + + if (accessToken.projectId) { + resourceLevelGroups.delete('organization'); + } + + for (const pgroup of OrganizationAccessTokensPermissions.permissionGroups) { + for (const permission of pgroup.permissions) { + const resourceLevel = getPermissionGroup(permission.id); + const resourceGroup = resourceLevelGroups.get(resourceLevel); + + if (resourceGroup === undefined) { + continue; + } + + let group = resourceGroup.permissionGroups.get(pgroup.title); + + if (group === undefined) { + group = { + title: pgroup.title, + permissions: [], + }; + resourceGroup.permissionGroups.set(pgroup.title, group); + } + + const isGranted = grantedPermissions.has(permission.id); + + if (includeAll || isGranted) { + group.permissions.push({ + isGranted: grantedPermissions.has(permission.id), + permission, + }); + } + } + } + + return Array.from(resourceLevelGroups.values()) + .map( + resourceGroup => + ({ + level: resourceLevelToResourceLevelType(resourceGroup.level), + title: resourceLevelToHumanReadableName(resourceGroup.level), + resolvedPermissionGroups: Array.from(resourceGroup.permissionGroups.values()) + .map(group => ({ + title: group.title, + permissions: group.permissions.map(permission => ({ + isGranted: permission.isGranted, + permission: permission.permission, + })), + })) + .filter(group => (includeAll ? true : group.permissions.length !== 0)), + resolvedResourceIds: resourceGroup.resourceIds.length + ? resourceGroup.resourceIds + : null, + }) satisfies GraphQLResolvedResourcePermissionGroupOutput, + ) + .filter(group => + includeAll + ? true + : group.resolvedPermissionGroups.length !== 0 && group.resolvedResourceIds?.length, + ); + }; + + static computeAuthorizationStatements( + accessToken: OrganizationAccessToken, + membership: OrganizationMembership, + ) { + let permissions = accessToken.permissions; + const allMembershipPermissions = membership.assignedRole.role.allPermissions; + + // if the user does not have this, the user cannot have personal access tokens. + if (!allMembershipPermissions.has('personalAccessToken:modify')) { + return []; + } + + if (permissions === null) { + permissions = Array.from(membership.assignedRole.role.allPermissions); + } else { + const allMembershipPermissions = membership.assignedRole.role.allPermissions; + permissions = permissions.filter(permission => allMembershipPermissions.has(permission)); + } + + let resources = accessToken.assignedResources; + + if (resources === null) { + resources = membership.assignedRole.resources; + } else { + const membershipResources = membership.assignedRole.resources; + resources = intersectResourceAssignments(membershipResources, resources); + } + + const permissionsPerLevel = permissionsToPermissionsPerResourceLevelAssignment(permissions); + const resolvedResources = resolveResourceAssignment({ + organizationId: accessToken.organizationId, + projects: resources, + }); + + return translateResolvedResourcesToAuthorizationPolicyStatements( + accessToken.organizationId, + permissionsPerLevel, + resolvedResources, + ); } } +type ResolveType = + TResolverType extends GraphQLSchema.ResolverTypeWrapper ? T : never; + +export type GraphQLResolvedResourcePermissionGroupOutput = ResolveType< + GraphQLSchema.ResolversTypes['ResolvedResourcePermissionGroup'] +>; + /** * Implementation for finding a organization access token from the PG database. * It is a function, so we can use it for the organization access tokens cache. @@ -359,7 +1179,7 @@ export function findById(deps: { pool: CommonQueryMethods; logger: Logger }) { return null; } - const data = await deps.pool.maybeOne(sql` + const data = await deps.pool.maybeOne(sql` /* OrganizationAccessTokens.findById */ SELECT ${organizationAccessTokenFields} FROM @@ -380,8 +1200,9 @@ export function findById(deps: { pool: CommonQueryMethods; logger: Logger }) { const result = OrganizationAccessTokenModel.parse(data); deps.logger.debug( - 'Organization access token found. (organizationAccessTokenId=%s)', + 'Organization access token found. (organizationAccessTokenId=%s, scope=%s)', organizationAccessTokenId, + result.scope, ); return result; @@ -391,6 +1212,8 @@ export function findById(deps: { pool: CommonQueryMethods; logger: Logger }) { const organizationAccessTokenFields = sql` "id" , "organization_id" AS "organizationId" + , "project_id" AS "projectId" + , "user_id" AS "userId" , to_json("created_at") AS "createdAt" , "title" , "description" 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 index cd44d6a80c0..5fc0eec2d39 100644 --- a/packages/services/api/src/modules/organization/providers/organization-member-roles.ts +++ b/packages/services/api/src/modules/organization/providers/organization-member-roles.ts @@ -1,5 +1,5 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; -import { sql, type DatabasePool } from 'slonik'; +import { CommonQueryMethods, sql, type DatabasePool } from 'slonik'; import { z } from 'zod'; import { decodeCreatedAtAndUUIDIdBasedCursor, @@ -67,6 +67,15 @@ const MemberRoleModel = z return { ...omit(record, 'legacyScopes'), permissions, + get allPermissions(): ReadonlySet { + const allPermissions = new Set(); + Object.values(permissions).forEach(set => { + set.forEach(permission => { + allPermissions.add(permission); + }); + }); + return allPermissions; + }, }; }); @@ -278,6 +287,34 @@ export class OrganizationMemberRoles { return MemberRoleModel.parse(role); } + + static findMemberRoleById(deps: { logger: Logger; pool: CommonQueryMethods }) { + return async function findMemberRoleById( + roleId: string, + ): Promise { + deps.logger.debug('Find organization membership role by id. (roleId=%s)', roleId); + + const query = sql` + SELECT + ${organizationMemberRoleFields} + FROM + "organization_member_roles" + WHERE + "id" = ${roleId} + `; + + const result = await deps.pool.maybeOne(query); + + if (result == null) { + deps.logger.debug('Organization membership role not found. (roleId=%s)', roleId); + return null; + } + + deps.logger.debug('Organization membership found. (roleId=%s)', roleId); + + return MemberRoleModel.parse(result); + }; + } } function transformOrganizationMemberLegacyScopesIntoPermissionGroup( diff --git a/packages/services/api/src/modules/organization/providers/organization-members.ts b/packages/services/api/src/modules/organization/providers/organization-members.ts index cf88a2ac4aa..214a34e0ff0 100644 --- a/packages/services/api/src/modules/organization/providers/organization-members.ts +++ b/packages/services/api/src/modules/organization/providers/organization-members.ts @@ -1,5 +1,5 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; -import { sql, type DatabasePool } from 'slonik'; +import { CommonQueryMethods, sql, type DatabasePool } from 'slonik'; import { z } from 'zod'; import { decodeCreatedAtAndUUIDIdBasedCursor, @@ -21,6 +21,7 @@ import { } from './resource-assignments'; const RawOrganizationMembershipModel = z.object({ + organizationId: z.string(), userId: z.string(), roleId: z.string(), connectedToZendesk: z @@ -126,37 +127,14 @@ export class OrganizationMembers { throw new Error('Could not resolve role.'); } - const resources: ResourceAssignmentGroup = record.assignedResources ?? { - mode: '*', - projects: [], - }; - - organizationMembershipByUserId.set(record.userId, { - organizationId: organization.id, - userId: record.userId, - isOwner: organization.ownerId === record.userId, - connectedToZendesk: record.connectedToZendesk, - assignedRole: { - role: membershipRole, - resources, - // We have these as a getter statement as they are - // only used in the context of authorization, we do not need - // to compute when querying a list of organization mambers via the GraphQL API. - get authorizationPolicyStatements() { - const resolvedResources = resolveResourceAssignment({ - organizationId: organization.id, - projects: resources, - }); - - return translateResolvedResourcesToAuthorizationPolicyStatements( - organization.id, - membershipRole.permissions, - resolvedResources, - ); - }, - }, - createdAt: record.createdAt, - }); + organizationMembershipByUserId.set( + record.userId, + OrganizationMembers.buildOrganizationMembership( + record, + membershipRole, + organization.ownerId, + ), + ); } } @@ -324,10 +302,106 @@ export class OrganizationMembers { `, ); } + + static buildOrganizationMembership( + rawMembership: z.TypeOf, + membershipRole: OrganizationMemberRole, + organizationOwnerId: string, + ): OrganizationMembership { + const resources: ResourceAssignmentGroup = rawMembership.assignedResources ?? { + mode: '*', + projects: [], + }; + + return { + organizationId: rawMembership.organizationId, + userId: rawMembership.userId, + isOwner: organizationOwnerId === rawMembership.userId, + connectedToZendesk: rawMembership.connectedToZendesk, + assignedRole: { + role: membershipRole, + resources, + // We have these as a getter statement as they are + // only used in the context of authorization, we do not need + // to compute when querying a list of organization mambers via the GraphQL API. + get authorizationPolicyStatements() { + const resolvedResources = resolveResourceAssignment({ + organizationId: rawMembership.organizationId, + projects: resources, + }); + + return translateResolvedResourcesToAuthorizationPolicyStatements( + rawMembership.organizationId, + membershipRole.permissions, + resolvedResources, + ); + }, + }, + createdAt: rawMembership.createdAt, + }; + } + + static findOrganizationMembership(deps: { logger: Logger; pool: CommonQueryMethods }) { + return async function findOrganizationMembership( + organizationId: string, + userId: string, + ): Promise { + deps.logger.debug( + 'Find organization membership by organizationId and userId. (organizationId=%s, userId=%s)', + organizationId, + userId, + ); + + const query = sql` + SELECT + ${organizationMemberFields(sql`"om"`)} + FROM + "organization_member" AS "om" + WHERE + "om"."organization_id" = ${organizationId} + AND "om"."user_id" = ${userId} + `; + + const result = await deps.pool.maybeOne(query); + + if (result == null) { + deps.logger.debug( + 'Could not find organization membership by organizationId and userId. (organizationId=%s, userId=%s)', + organizationId, + userId, + ); + + return null; + } + + const rawMembership = RawOrganizationMembershipModel.parse(result); + + const memberRole = await OrganizationMemberRoles.findMemberRoleById(deps)( + rawMembership.roleId, + ); + + if (!memberRole) { + deps.logger.debug( + 'Could not resolve role. (organizationId=%s, userId=%s)', + organizationId, + userId, + ); + + return null; + } + + return OrganizationMembers.buildOrganizationMembership( + rawMembership, + memberRole, + /** In this context whether the user is the organization owner does not matter. So we can simply noop it. */ 'noop', + ); + }; + } } const organizationMemberFields = (prefix = sql`"organization_member"`) => sql` - ${prefix}."user_id" AS "userId" + ${prefix}."organization_id" AS "organizationId" + , ${prefix}."user_id" AS "userId" , ${prefix}."role_id" AS "roleId" , ${prefix}."connected_to_zendesk" AS "connectedToZendesk" , ${prefix}."assigned_resources" AS "assignedResources" diff --git a/packages/services/api/src/modules/organization/providers/resource-assignments.ts b/packages/services/api/src/modules/organization/providers/resource-assignments.ts index 03176f86ded..b45d219ee44 100644 --- a/packages/services/api/src/modules/organization/providers/resource-assignments.ts +++ b/packages/services/api/src/modules/organization/providers/resource-assignments.ts @@ -7,10 +7,12 @@ import { AppDeploymentNameModel } from '../../app-deployments/providers/app-depl import { AuthorizationPolicyStatement, PermissionsPerResourceLevelAssignment, + ResourceLevel, } from '../../auth/lib/authz'; import { Logger } from '../../shared/providers/logger'; import { Storage } from '../../shared/providers/storage'; import { + AssignedTarget, GranularAssignedProjects, TargetAssignmentModel, type ResourceAssignmentGroup, @@ -112,7 +114,7 @@ export class ResourceAssignments { * 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. + * - Projects and Targets that do not follow the hierarchical structure are omitted from the resolved object. * * These measures are done in order to prevent users to grant access to other organizations. */ @@ -249,6 +251,106 @@ export class ResourceAssignments { return resourceAssignmentGroup; } + + /** + * Translate a resource assignment into resource ids grouped by resource level. + */ + async resourceAssignmentToResourceIds( + organizationId: string, + resourceAssignment: ResourceAssignmentGroup, + ): Promise>> { + const organization = await this.storage.getOrganization({ organizationId }); + const orgBaseId = `${organization.slug}`; + + const ids: Record> = { + organization: [orgBaseId], + project: [], + target: [], + service: [], + appDeployment: [], + }; + + if (resourceAssignment.mode === '*') { + ids.project.push(`${orgBaseId}/*`); + ids.target.push(`${orgBaseId}/*/*`); + ids.service.push(`${orgBaseId}/*/*/service/*`); + ids.appDeployment.push(`${orgBaseId}/*/*/appDeployment/*`); + return ids; + } + + const projectIds = resourceAssignment.projects.map(project => project.id); + const projects = await this.storage.findProjectsByIds({ projectIds }); + + const targetLookupIds = new Set(); + const projectTargetAssignments: Array<{ + projectBaseId: string; + project: Project; + target: AssignedTarget; + }> = []; + + for (const projectAssignment of resourceAssignment.projects) { + const project = projects.get(projectAssignment.id); + if (!project || project.orgId !== organization.id) { + continue; + } + + const projectBaseId = `${orgBaseId}/${project.slug}`; + + ids.project.push(projectBaseId); + + if (projectAssignment.targets.mode === '*') { + ids.target.push(`${projectBaseId}/*`); + ids.service.push(`${projectBaseId}/service/*`); + ids.appDeployment.push(`${projectBaseId}/appDeployment/*`); + continue; + } + + for (const target of projectAssignment.targets.targets) { + targetLookupIds.add(target.id); + projectTargetAssignments.push({ + projectBaseId, + project, + target, + }); + } + } + + const targets = await this.storage.findTargetsByIds({ + organizationId, + targetIds: Array.from(targetLookupIds), + }); + + for (const projectTargetAssignment of projectTargetAssignments) { + const target = targets.get(projectTargetAssignment.target.id); + if (!target || target.projectId !== projectTargetAssignment.project.id) { + continue; + } + const targetBaseId = projectTargetAssignment.projectBaseId + '/' + target.slug; + ids.target.push(targetBaseId); + + if (projectTargetAssignment.target.appDeployments.mode === '*') { + ids.appDeployment.push(targetBaseId + '/appDeployment/*'); + } else { + ids.appDeployment.push( + ...projectTargetAssignment.target.appDeployments.appDeployments.map( + appDeployment => targetBaseId + '/appDeployment/' + appDeployment.appName, + ), + ); + } + + if (projectTargetAssignment.target.services.mode === '*') { + ids.service.push(targetBaseId + '/services/*'); + } else { + ids.service.push( + ...projectTargetAssignment.target.services.services.map( + service => targetBaseId + '/services/' + service.serviceName, + ), + ); + } + } + + return ids; + } } function isSome(input: T | null): input is Exclude { diff --git a/packages/services/api/src/modules/organization/resolvers/Member.ts b/packages/services/api/src/modules/organization/resolvers/Member.ts index 3659b4c9869..ac9792fa207 100644 --- a/packages/services/api/src/modules/organization/resolvers/Member.ts +++ b/packages/services/api/src/modules/organization/resolvers/Member.ts @@ -1,4 +1,5 @@ import { Storage } from '../../shared/providers/storage'; +import { OrganizationAccessTokens } from '../providers/organization-access-tokens'; import { OrganizationManager } from '../providers/organization-manager'; import { ResourceAssignments } from '../providers/resource-assignments'; import type { MemberResolvers } from './../../../__generated__/types'; @@ -41,4 +42,16 @@ export const Member: MemberResolvers = { resources: member.assignedRole.resources, }); }, + availablePersonalAccessTokenPermissionGroups(member, _arg, { injector }) { + return injector.get(OrganizationAccessTokens).getAvailablePermissionGroupsForMembership(member); + }, + accessToken(member, args, { injector }) { + return injector.get(OrganizationAccessTokens).getForMembership(member, args.id); + }, + accessTokens(member, args, { injector }) { + return injector.get(OrganizationAccessTokens).getPaginatedForMembership(member, { + first: args.first ?? null, + after: args.after ?? null, + }); + }, }; diff --git a/packages/services/api/src/modules/organization/resolvers/Mutation/createOrganizationAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/createOrganizationAccessToken.ts index 220a27c11bf..606dabe0d4d 100644 --- a/packages/services/api/src/modules/organization/resolvers/Mutation/createOrganizationAccessToken.ts +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/createOrganizationAccessToken.ts @@ -4,7 +4,7 @@ import type { MutationResolvers } from './../../../../__generated__/types'; export const createOrganizationAccessToken: NonNullable< MutationResolvers['createOrganizationAccessToken'] > = async (_, args, { injector }) => { - const result = await injector.get(OrganizationAccessTokens).create({ + const result = await injector.get(OrganizationAccessTokens).createForOrganization({ organization: args.input.organization, title: args.input.title, description: args.input.description ?? null, @@ -16,7 +16,7 @@ export const createOrganizationAccessToken: NonNullable< return { ok: { __typename: 'CreateOrganizationAccessTokenResultOk', - createdOrganizationAccessToken: result.organizationAccessToken, + createdOrganizationAccessToken: result.accessToken, privateAccessKey: result.privateAccessKey, }, }; diff --git a/packages/services/api/src/modules/organization/resolvers/Mutation/createPersonalAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/createPersonalAccessToken.ts new file mode 100644 index 00000000000..ae2080c58ed --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/createPersonalAccessToken.ts @@ -0,0 +1,32 @@ +import { OrganizationAccessTokens } from '../../providers/organization-access-tokens'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const createPersonalAccessToken: NonNullable< + MutationResolvers['createPersonalAccessToken'] +> = async (_, args, { injector }) => { + const result = await injector.get(OrganizationAccessTokens).createPersonalAccessTokenForViewer({ + organization: args.input.organization, + title: args.input.title, + description: args.input.description ?? null, + permissions: args.input.permissions ?? null, + assignedResources: args.input.resources ?? null, + }); + + if (result.type === 'success') { + return { + ok: { + __typename: 'CreatePersonalAccessTokenResultOk', + createdPersonalAccessToken: result.accessToken, + privateAccessKey: result.privateAccessKey, + }, + }; + } + + return { + error: { + __typename: 'CreatePersonalAccessTokenResultError', + message: result.message, + details: result.details, + }, + }; +}; diff --git a/packages/services/api/src/modules/organization/resolvers/Mutation/createProjectAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/createProjectAccessToken.ts new file mode 100644 index 00000000000..868670453f8 --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/createProjectAccessToken.ts @@ -0,0 +1,30 @@ +import { OrganizationAccessTokens } from '../../providers/organization-access-tokens'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const createProjectAccessToken: NonNullable< + MutationResolvers['createProjectAccessToken'] +> = async (_, args, { injector }) => { + const result = await injector.get(OrganizationAccessTokens).createForProject({ + project: args.input.project, + title: args.input.title, + description: args.input.description ?? null, + permissions: [...args.input.permissions], + assignedResources: args.input.resources, + }); + + if (result.type === 'success') { + return { + ok: { + createdProjectAccessToken: result.accessToken, + privateAccessKey: result.privateAccessKey, + }, + }; + } + + return { + error: { + message: result.message, + details: result.details, + }, + }; +}; diff --git a/packages/services/api/src/modules/organization/resolvers/Mutation/deleteAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/deleteAccessToken.ts new file mode 100644 index 00000000000..6022bcb0599 --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/deleteAccessToken.ts @@ -0,0 +1,26 @@ +import { OrganizationAccessTokens } from '../../providers/organization-access-tokens'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const deleteAccessToken: NonNullable = async ( + _parent, + args, + { injector }, +) => { + const result = await injector.get(OrganizationAccessTokens).delete({ + accessTokenId: args.input.accessToken.byId, + }); + + if (result.type === 'error') { + return { + error: { + message: result.message, + }, + }; + } + + return { + ok: { + deletedAccessTokenId: result.organizationAccessTokenId, + }, + }; +}; diff --git a/packages/services/api/src/modules/organization/resolvers/Mutation/deleteOrganizationAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/deleteOrganizationAccessToken.ts index 65954f10ba0..52a5264deeb 100644 --- a/packages/services/api/src/modules/organization/resolvers/Mutation/deleteOrganizationAccessToken.ts +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/deleteOrganizationAccessToken.ts @@ -5,12 +5,20 @@ export const deleteOrganizationAccessToken: NonNullable< MutationResolvers['deleteOrganizationAccessToken'] > = async (_parent, args, { injector }) => { const result = await injector.get(OrganizationAccessTokens).delete({ - organizationAccessTokenId: args.input.organizationAccessToken.byId, + accessTokenId: args.input.organizationAccessToken.byId, + onlyOrganizationScoped: true, }); + if (result.type === 'error') { + return { + error: { + message: result.message, + }, + }; + } + return { ok: { - __typename: 'DeleteOrganizationAccessTokenResultOk', deletedOrganizationAccessTokenId: result.organizationAccessTokenId, }, }; diff --git a/packages/services/api/src/modules/organization/resolvers/Organization.ts b/packages/services/api/src/modules/organization/resolvers/Organization.ts index b44febcf461..08ccfa45cc8 100644 --- a/packages/services/api/src/modules/organization/resolvers/Organization.ts +++ b/packages/services/api/src/modules/organization/resolvers/Organization.ts @@ -1,6 +1,4 @@ -import { APP_DEPLOYMENTS_ENABLED } from '../../app-deployments/providers/app-deployments-enabled-token'; import { Session } from '../../auth/lib/authz'; -import * as OrganizationAccessTokensPermissions from '../lib/organization-access-token-permissions'; import * as OrganizationMemberPermissions from '../lib/organization-member-permissions'; import { OrganizationAccessTokens } from '../providers/organization-access-tokens'; import { OrganizationManager } from '../providers/organization-manager'; @@ -11,7 +9,9 @@ import type { OrganizationResolvers } from './../../../__generated__/types'; export const Organization: Pick< OrganizationResolvers, | 'accessToken' + | 'accessTokenById' | 'accessTokens' + | 'allAccessTokens' | 'availableMemberPermissionGroups' | 'availableOrganizationAccessTokenPermissionGroups' | 'cleanId' @@ -30,6 +30,7 @@ export const Organization: Pick< | 'viewerCanExportAuditLogs' | 'viewerCanManageAccessTokens' | 'viewerCanManageInvitations' + | 'viewerCanManagePersonalAccessTokens' | 'viewerCanManageRoles' | 'viewerCanModifySlug' | 'viewerCanSeeMembers' @@ -205,29 +206,15 @@ export const Organization: Pick< return OrganizationMemberPermissions.permissionGroups; }, availableOrganizationAccessTokenPermissionGroups: async (organization, _, { injector }) => { - let permissionGroups = OrganizationAccessTokensPermissions.permissionGroups; - - const isAppDeploymentsEnabled = - injector.get(APP_DEPLOYMENTS_ENABLED) || organization.featureFlags.appDeployments; - - if (!isAppDeploymentsEnabled) { - permissionGroups = permissionGroups.filter(p => p.id !== 'app-deployments'); - } - - if (!organization.featureFlags.otelTracing) { - permissionGroups = permissionGroups.map(group => ({ - ...group, - permissions: group.permissions.filter(p => p.id !== 'traces:report'), - })); - } - - return permissionGroups; + return injector + .get(OrganizationAccessTokens) + .getAvailablePermissionGroupsForOrganization(organization); }, accessTokens: async (organization, args, { injector }) => { - return injector.get(OrganizationAccessTokens).getPaginated({ - organizationId: organization.id, + return injector.get(OrganizationAccessTokens).getPaginatedForOrganization(organization, { first: args.first ?? null, after: args.after ?? null, + includeOnlyOrganizationScoped: true, }); }, viewerCanManageAccessTokens: async (organization, _arg, { session }) => { @@ -240,9 +227,24 @@ export const Organization: Pick< }); }, accessToken: async (organization, args, { injector }) => { - return injector.get(OrganizationAccessTokens).get({ + return injector.get(OrganizationAccessTokens).getForOrganization(organization, args.id, true); + }, + viewerCanManagePersonalAccessTokens: async (organization, _arg, { session }) => { + return session.canPerformAction({ organizationId: organization.id, - id: args.id, + action: 'personalAccessToken:modify', + params: { + organizationId: organization.id, + }, + }); + }, + accessTokenById: async (organization, args, { injector }) => { + return injector.get(OrganizationAccessTokens).getForOrganization(organization, args.id); + }, + async allAccessTokens(organization, args, { injector }) { + return injector.get(OrganizationAccessTokens).getPaginatedForOrganization(organization, { + first: args.first ?? null, + after: args.after ?? null, }); }, }; diff --git a/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts index caf34657381..ba1de9ea1ea 100644 --- a/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts +++ b/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts @@ -1,4 +1,4 @@ -import { ResourceAssignments } from '../providers/resource-assignments'; +import { OrganizationAccessTokens } from '../providers/organization-access-tokens'; import type { OrganizationAccessTokenResolvers } from './../../../__generated__/types'; /* @@ -11,10 +11,17 @@ import type { OrganizationAccessTokenResolvers } from './../../../__generated__/ * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. */ export const OrganizationAccessToken: OrganizationAccessTokenResolvers = { - resources: async (accessToken, _arg, { injector }) => { - return injector.get(ResourceAssignments).resolveGraphQLMemberResourceAssignment({ - organizationId: accessToken.organizationId, - resources: accessToken.assignedResources, - }); + resources(accessToken, _arg, { injector }) { + return injector.get(OrganizationAccessTokens).getResourceAssignmentsForAccessToken(accessToken); + }, + permissions(accessToken, _arg, { injector }) { + return injector.get(OrganizationAccessTokens).getPermissionsForAccessToken(accessToken); + }, + resolvedResourcePermissionGroups(accessToken, args, { injector }) { + return injector + .get(OrganizationAccessTokens) + .getGraphQLResolvedResourcePermissionGroupForAccessToken(accessToken)( + args.includeAll ?? false, + ); }, }; diff --git a/packages/services/api/src/modules/organization/resolvers/PersonalAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/PersonalAccessToken.ts new file mode 100644 index 00000000000..754eff31004 --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/PersonalAccessToken.ts @@ -0,0 +1,21 @@ +import { OrganizationAccessTokens } from '../providers/organization-access-tokens'; +import type { PersonalAccessTokenResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "PersonalAccessTokenMapper" 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 PersonalAccessToken: PersonalAccessTokenResolvers = { + resolvedResourcePermissionGroups(accessToken, args, { injector }) { + return injector + .get(OrganizationAccessTokens) + .getGraphQLResolvedResourcePermissionGroupForAccessToken(accessToken)( + args.includeAll ?? false, + ); + }, +}; diff --git a/packages/services/api/src/modules/organization/resolvers/Project.ts b/packages/services/api/src/modules/organization/resolvers/Project.ts new file mode 100644 index 00000000000..d3bbc8c5cc0 --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/Project.ts @@ -0,0 +1,42 @@ +import { OrganizationAccessTokens } from '../providers/organization-access-tokens'; +import type { ProjectResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "ProjectMapper" 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 Project: Pick< + ProjectResolvers, + | 'accessToken' + | 'accessTokens' + | 'availableProjectAccessTokenPermissionGroups' + | 'viewerCanManageProjectAccessTokens' +> = { + availableProjectAccessTokenPermissionGroups(project, _, { injector }) { + return injector.get(OrganizationAccessTokens).getAvailablePermissionsGroupsForProject(project); + }, + accessToken(project, args, { injector }) { + return injector.get(OrganizationAccessTokens).getForProject(project, args.id); + }, + accessTokens(project, args, { injector }) { + return injector.get(OrganizationAccessTokens).getPaginatedForProject(project, { + first: args.first ?? null, + after: args.after ?? null, + }); + }, + viewerCanManageProjectAccessTokens(project, _arg, { session }) { + return session.canPerformAction({ + organizationId: project.orgId, + action: 'projectAccessToken:modify', + params: { + organizationId: project.orgId, + projectId: project.id, + }, + }); + }, +}; diff --git a/packages/services/api/src/modules/organization/resolvers/ProjectAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/ProjectAccessToken.ts new file mode 100644 index 00000000000..80a4898eed7 --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/ProjectAccessToken.ts @@ -0,0 +1,21 @@ +import { OrganizationAccessTokens } from '../providers/organization-access-tokens'; +import type { ProjectAccessTokenResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "ProjectAccessTokenMapper" 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 ProjectAccessToken: ProjectAccessTokenResolvers = { + resolvedResourcePermissionGroups(accessToken, args, { injector }) { + return injector + .get(OrganizationAccessTokens) + .getGraphQLResolvedResourcePermissionGroupForAccessToken(accessToken)( + args.includeAll ?? false, + ); + }, +}; diff --git a/packages/services/api/src/modules/organization/resolvers/Query/whoAmI.ts b/packages/services/api/src/modules/organization/resolvers/Query/whoAmI.ts new file mode 100644 index 00000000000..cec20d3385c --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/Query/whoAmI.ts @@ -0,0 +1,63 @@ +import { HiveError } from '../../../../shared/errors'; +import { UnauthenticatedSession } from '../../../auth/lib/authz'; +import { OrganizationAccessTokenSession } from '../../../auth/lib/organization-access-token-strategy'; +import { TargetAccessTokenSession } from '../../../auth/lib/target-access-token-strategy'; +import { OrganizationAccessTokens } from '../../providers/organization-access-tokens'; +import type { QueryResolvers } from './../../../../__generated__/types'; + +export const whoAmI: NonNullable = async ( + _, + __, + { session, injector }, +) => { + const accessTokens = injector.get(OrganizationAccessTokens); + + if (session instanceof OrganizationAccessTokenSession) { + const accessToken = await accessTokens.getById(session.id); + + if (!accessToken) { + throw new Error('This one is invalid :D'); + } + + const resolvedPermissions = + accessTokens.getGraphQLResolvedResourcePermissionGroupForAccessToken(accessToken); + + return { + title: `Access Token - ${accessToken.title}`, + resolvedPermissions, + }; + } + + if (session instanceof TargetAccessTokenSession) { + const resolvedPermissions = + accessTokens.getGraphQLResolvedResourcePermissionGroupForAccessToken({ + projectId: session.projectId, + userId: null, + organizationId: session.organizationId, + assignedResources: { + mode: 'granular', + projects: [ + { + id: session.projectId, + targets: { + mode: '*', + }, + type: 'project', + }, + ], + }, + permissions: session.allowedPermissions, + }); + + return { + title: `Legacy Target Access Token - ${session.id}`, + resolvedPermissions, + }; + } + + if (session instanceof UnauthenticatedSession) { + throw new HiveError('Not authenticated.'); + } + + throw new HiveError('WhoAmI only supports access tokens.'); +}; diff --git a/packages/services/api/src/modules/organization/resolvers/WhoAmI.ts b/packages/services/api/src/modules/organization/resolvers/WhoAmI.ts new file mode 100644 index 00000000000..aae3a1b7c86 --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/WhoAmI.ts @@ -0,0 +1,16 @@ +import type { WhoAmIResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "WhoAmIMapper" 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 WhoAmI: WhoAmIResolvers = { + resolvedPermissions: (whoAmI, args) => { + return whoAmI.resolvedPermissions(args.includeAll); + }, +}; diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index cc673faf4fa..f14bc03ecb1 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -567,6 +567,7 @@ export async function main() { required_error: 'Slug is required', }), }); + server.post('/auth-api/oidc-id-lookup', async (req, res) => { const inputResult = oidcIdLookupSchema.safeParse(req.body); @@ -604,6 +605,15 @@ export async function main() { targetsByIdCache: registry.injector.get(TargetsByIdCache), }); + server.post('/cache/organization-access-token-cache/delete/:id', async (req, res) => { + void res.status(200).send({ + deleted: await registry.injector + .get(OrganizationAccessTokensCache) + .purge({ id: (req.params as any).id }), + }); + return; + }); + if (env.cdn.providers.api !== null) { const s3 = { client: new AwsClient({ diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 2d563d08efd..a471d1fc6c1 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -164,8 +164,10 @@ export interface organization_access_tokens { hash: string; id: string; organization_id: string; - permissions: Array; + permissions: Array | null; + project_id: string | null; title: string; + user_id: string | null; } export interface organization_invitations { diff --git a/packages/web/app/src/components/organization/members/permission-selector.tsx b/packages/web/app/src/components/organization/members/permission-selector.tsx index 12b0eadf019..b6cb0d2a9a9 100644 --- a/packages/web/app/src/components/organization/members/permission-selector.tsx +++ b/packages/web/app/src/components/organization/members/permission-selector.tsx @@ -33,6 +33,7 @@ export const PermissionSelector_PermissionGroupsFragment = graphql(` title isReadOnly warning + isAssignableByViewer } } `); @@ -139,11 +140,29 @@ export function PermissionSelector(props: PermissionSelectorProps) { } }} > -
+
{permission.title}
{permission.description}
- {permission.warning && props.selectedPermissionIds.has(permission.id) ? ( + {permission.isAssignableByViewer === false ? ( +
+ + + + + + + Your membership has insufficient authority for assigning this + permission. + + + +
+ ) : permission.warning && props.selectedPermissionIds.has(permission.id) ? (
@@ -205,7 +224,12 @@ export function PermissionSelector(props: PermissionSelectorProps) { ) )} + + + Name of the access token. + + + + )} + /> +
+ +
+ ( + + Description + +