From 8d86c9e84d13c856b80f926ce1eb7f42794a3238 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 3 Nov 2025 11:36:07 +0100 Subject: [PATCH 01/36] feat(api): project and personal scoped access tokens --- packages/libraries/cli/package.json | 1 + packages/libraries/cli/src/commands/whoami.ts | 111 +-- ...25.10.17T00-00-00.project-access-tokens.ts | 37 + packages/migrations/src/run-pg-migrations.ts | 1 + .../audit-logs/providers/audit-logs-types.ts | 2 +- .../api/src/modules/auth/lib/authz.ts | 13 +- .../lib/organization-access-token-strategy.ts | 10 +- .../auth/lib/target-access-token-strategy.ts | 7 +- .../src/modules/auth/resolvers/Permission.ts | 2 +- .../organization-access-token-permissions.ts | 10 + .../lib/organization-member-permissions.ts | 11 + .../modules/organization/lib/permissions.ts | 27 + .../lib/resource-assignment-model.ts | 116 +++ .../organization/module.graphql.mappers.ts | 15 +- .../modules/organization/module.graphql.ts | 170 ++++ .../organization-access-tokens-cache.ts | 73 +- .../providers/organization-access-tokens.ts | 774 ++++++++++++++++-- .../providers/organization-member-roles.ts | 39 +- .../providers/organization-members.ts | 140 +++- .../providers/resource-assignments.ts | 104 ++- .../modules/organization/resolvers/Member.ts | 3 + .../Mutation/createOrganizationAccessToken.ts | 4 +- .../Mutation/createPersonalAccessToken.ts | 32 + .../Mutation/createProjectAccessToken.ts | 32 + .../organization/resolvers/Organization.ts | 27 +- .../resolvers/OrganizationAccessToken.ts | 20 +- .../modules/organization/resolvers/Project.ts | 29 + .../organization/resolvers/Query/whoAmI.ts | 58 ++ .../modules/organization/resolvers/WhoAmI.ts | 16 + pnpm-lock.yaml | 15 +- 30 files changed, 1712 insertions(+), 187 deletions(-) create mode 100644 packages/migrations/src/actions/2025.10.17T00-00-00.project-access-tokens.ts create mode 100644 packages/services/api/src/modules/organization/resolvers/Mutation/createPersonalAccessToken.ts create mode 100644 packages/services/api/src/modules/organization/resolvers/Mutation/createProjectAccessToken.ts create mode 100644 packages/services/api/src/modules/organization/resolvers/Project.ts create mode 100644 packages/services/api/src/modules/organization/resolvers/Query/whoAmI.ts create mode 100644 packages/services/api/src/modules/organization/resolvers/WhoAmI.ts 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..5dc96642e9e 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,28 @@ 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 + groups { + 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 +59,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,43 +95,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], + if (result.whoAmI == null) { + throw new InvalidRegistryTokenError(); + } + + const data = result.whoAmI; + + // Print header + this.log(`\n=== ${data.title} ===\n`); + + // Iterate and display each permission group + for (const permLevel of data.resolvedPermissions) { + this.log(`Level: ${permLevel.level}`); + this.log(`Resources: ${permLevel.resolvedResourceIds?.join(', ') ?? ''}`); + + const table = new Table({ + head: ['Group', 'Permission ID', 'Title', 'Granted', 'Description'], + wordWrap: true, + style: { head: ['cyan'] }, }); - this.log(print()); - } else if (result.tokenInfo.__typename === 'TokenNotFoundError') { - this.debug(result.tokenInfo.message); - throw new InvalidRegistryTokenError(); - } else { - throw new UnexpectedError( - `Token response got an unsupported type: ${(result.tokenInfo as any).__typename}`, - ); + for (const group of permLevel.groups) { + 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..53f201c18ff --- /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_target"', + query: sql` + CREATE INDEX CONCURRENTLY IF NOT EXISTS "organization_access_tokens_pagination_target" 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..d90dfe83e28 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,7 +332,7 @@ 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, }), }), diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index f4661b84573..d4b7b85e617 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -5,8 +5,9 @@ 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'; +import { TargetAccessTokenStrategy } from './target-access-token-strategy'; export type AuthorizationPolicyStatement = { effect: 'allow' | 'deny'; @@ -55,10 +56,14 @@ export type UserActor = { export type OrganizationAccessTokenActor = { type: 'organizationAccessToken'; - organizationAccessToken: OrganizationAccessToken; + organizationAccessToken: CachedAccessToken; }; -type Actor = UserActor | OrganizationAccessTokenActor; +export type LegacyTargetAccessTokenActor = { + type: 'legacyTargetAccessToken'; +}; + +type Actor = UserActor | OrganizationAccessTokenActor | LegacyTargetAccessTokenActor; /** * Abstract session class that is implemented by various ways to identify a session. @@ -393,6 +398,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 +407,7 @@ const permissionsByLevel = { z.literal('alert:modify'), z.literal('schemaLinting:modifyProjectRules'), z.literal('target:create'), + z.literal('projectAccessToken:modify'), ], target: [ z.literal('targetAccessToken:modify'), 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/resolvers/Permission.ts b/packages/services/api/src/modules/auth/resolvers/Permission.ts index 1103b8a8520..8909c46284f 100644 --- a/packages/services/api/src/modules/auth/resolvers/Permission.ts +++ b/packages/services/api/src/modules/auth/resolvers/Permission.ts @@ -25,7 +25,7 @@ export const Permission: PermissionResolvers = { }, }; -function resourceLevelToResourceLevelType(resourceLevel: ResourceLevel) { +export function resourceLevelToResourceLevelType(resourceLevel: ResourceLevel) { switch (resourceLevel) { case 'target': return 'TARGET' 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..b9fae466220 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,6 +116,11 @@ 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', @@ -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..fe1f17b19c8 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,11 @@ export const permissionGroups: Array = [ title: 'Access support tickets', description: 'Member can access, create and reply to support tickets.', }, + { + id: 'personalAccessToken:modify', + title: 'Create personal access tokens', + description: 'Member can create and use personal access tokens.', + }, { id: 'accessToken:modify', title: 'Manage organization access tokens', @@ -139,6 +144,12 @@ export const permissionGroups: Array = [ description: 'Member can access the specified projects.', dependsOn: 'project:describe', }, + { + id: 'projectAccessToken:modify', + title: 'Create Project scoped access tokens', + description: 'Create access tokens for performing actions within the project.', + dependsOn: 'project:describe', + }, ], }, { diff --git a/packages/services/api/src/modules/organization/lib/permissions.ts b/packages/services/api/src/modules/organization/lib/permissions.ts index 2f5d003fd6a..ef38d047f90 100644 --- a/packages/services/api/src/modules/organization/lib/permissions.ts +++ b/packages/services/api/src/modules/organization/lib/permissions.ts @@ -14,3 +14,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..50dbacd6235 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,9 @@ export type OrganizationGetStartedMapper = OrganizationGetStarted; export type OrganizationInvitationMapper = OrganizationInvitation; export type MemberMapper = OrganizationMembership; export type OrganizationAccessTokenMapper = 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..8465a416524 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -53,6 +53,103 @@ export default gql` deleteOrganizationAccessToken( input: DeleteOrganizationAccessTokenInput! @tag(name: "public") ): DeleteOrganizationAccessTokenResult! @tag(name: "public") + createProjectAccessToken(input: CreateProjectAccessTokenInput!): CreateProjectAccessTokenResult! + createPersonalAccessToken( + input: CreatePersonalAccessTokenInput! + ): CreatePersonalAccessTokenResult! + } + + 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: OrganizationAccessToken! + 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: OrganizationAccessToken! + privateAccessKey: String! + } + + type CreateProjectAccessTokenResultError { + message: String + } + + """ + @oneOf + """ + type CreateProjectAccessTokenResult { + ok: CreateProjectAccessTokenResultOk + error: CreateProjectAccessTokenResultError } input OrganizationReferenceInput @oneOf { @@ -111,6 +208,12 @@ export default gql` description: String @tag(name: "public") } + enum OrganizationAccessTokenScopeType { + ORGANIZATION + PROJECT + PERSONAL + } + type OrganizationAccessToken { id: ID! @tag(name: "public") title: String! @tag(name: "public") @@ -119,6 +222,8 @@ export default gql` resources: ResourceAssignment! @tag(name: "public") firstCharacters: String! @tag(name: "public") createdAt: DateTime! @tag(name: "public") + scope: OrganizationAccessTokenScopeType! + resolvedPermissions: [ResolvedResourcePermissionGroup!]! } input DeleteOrganizationAccessTokenInput { @@ -769,4 +874,69 @@ 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): OrganizationAccessTokenConnection + """ + Access token for project. + """ + accessToken(id: ID): OrganizationAccessToken + """ + Permissions that the viewer can assign to project access tokens. + """ + availableProjectAccessTokenPermissionGroups: [PermissionGroup!]! + } + + extend type Member { + availablePersonalAccessTokenPermissionGroups: [PermissionGroup!]! + } + + 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! + groups: [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..9cf00f0ef03 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,44 @@ 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) { + 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,16 +113,23 @@ 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) { + add(logger: Logger, record: OrganizationAccessToken) { return this.cache.set({ - key: token.id, - value: token, + key: record.id, + value: this.makeCacheRecord(logger, record), ttl: '5min', grace: '24h', }); 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..377a365de1b 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 @@ -1,6 +1,7 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; import { sql, type CommonQueryMethods, type DatabasePool } from 'slonik'; import { z } from 'zod'; +import { Organization, Project } from '@hive/api'; import { decodeCreatedAtAndUUIDIdBasedCursor, encodeCreatedAtAndUUIDIdBasedCursor, @@ -9,20 +10,29 @@ import * as GraphQLSchema from '../../../__generated__/types'; 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 { 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 +56,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: [] }, ), @@ -62,10 +75,31 @@ const OrganizationAccessTokenModel = z // 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 +112,13 @@ const OrganizationAccessTokenModel = z export type OrganizationAccessToken = z.TypeOf; +const validProjectResourceLevels: ReadonlySet = new Set([ + 'project', + 'target', + 'service', + 'appDeployment', +]); + @Injectable({ scope: Scope.Operation, }) @@ -94,6 +135,7 @@ export class OrganizationAccessTokens { private session: Session, private auditLogs: AuditLogRecorder, private storage: Storage, + private members: OrganizationMembers, logger: Logger, ) { this.logger = logger.child({ @@ -102,13 +144,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 +158,95 @@ 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; + + const canModifyProjectAccessTokens = await this.session.canPerformAction({ + organizationId, + params: { organizationId, projectId }, + action: 'projectAccessToken:modify', + }); + const canModifyOrganizationAccessTokens = await this.session.canPerformAction({ + organizationId, + params: { organizationId }, + action: 'accessToken:modify', + }); + + if (!canModifyProjectAccessTokens && !canModifyOrganizationAccessTokens) { + this.session.raise('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 +255,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 +263,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 +403,8 @@ export class OrganizationAccessTokens { INSERT INTO "organization_access_tokens" ( "id" , "organization_id" + , "project_id" + , "user_id" , "title" , "description" , "permissions" @@ -171,11 +414,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,23 +428,23 @@ 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, }, }); return { type: 'success' as const, - organizationAccessToken, + accessToken, privateAccessKey: accessKey.privateAccessToken, }; } @@ -240,7 +485,11 @@ export class OrganizationAccessTokens { }; } - async getPaginated(args: { organizationId: string; first: number | null; after: string | null }) { + async getPaginatedForOrganization(args: { + organizationId: string; + first: number | null; + after: string | null; + }) { await this.session.assertPerformAction({ organizationId: args.organizationId, params: { organizationId: args.organizationId }, @@ -315,31 +564,458 @@ 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', }); - const row = await this.pool.maybeOne(sql` /* OrganizationAccessTokens.getPaginated */ + 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 - "id" = ${args.id} - AND "organization_id" = ${args.organizationId} + "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} `); - 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 ?? ''; + }, + }, + }; + } + + 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 + "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} + `); + + 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 ?? ''; + }, + }, + }; + } + + async getById(id: string) { + return await findById({ + logger: this.logger, + pool: this.pool, + })(id); + } + + async getForOrganization(organization: Organization, id: string) { + 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); + 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)), + })) + .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, + }); + + // TODO: we could handle this different TBH + 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' + >, + ) => + async ( + /** Whether to include all or only the granted permissions. */ + includeAll: boolean = false, + ): Promise> => { + const grantedPermissions = new Set(accessToken.permissions ?? []); + + const resourceIds = await this.resourceAssignments.resourceAssignmentToResourceIds( + accessToken.organizationId, + accessToken.assignedResources, + ); + + type PMap = { + title: string; + 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, + { + title: 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: resourceGroup.title, + groups: 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.groups.length !== 0 && group.resolvedResourceIds?.length, + ); + }; + + static computeAuthorizationStatements( + accessToken: OrganizationAccessToken, + membership: OrganizationMembership, + ) { + let permissions = accessToken.permissions; + + 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 +1035,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 @@ -391,6 +1067,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..a335b6c00cc 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() { + 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..45316b460c9 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.id !== 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..0cfb2e11bbb 100644 --- a/packages/services/api/src/modules/organization/resolvers/Member.ts +++ b/packages/services/api/src/modules/organization/resolvers/Member.ts @@ -41,4 +41,7 @@ export const Member: MemberResolvers = { resources: member.assignedRole.resources, }); }, + availablePersonalAccessTokenPermissionGroups: async (_parent, _arg, _ctx) => { + /* Member.availablePersonalAccessTokenPermissionGroups resolver is required because Member.availablePersonalAccessTokenPermissionGroups exists but MemberMapper.availablePersonalAccessTokenPermissionGroups does not */ + }, }; 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..fd100914f40 --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/createProjectAccessToken.ts @@ -0,0 +1,32 @@ +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: { + __typename: 'CreateProjectAccessTokenResultOk', + createdProjectAccessToken: result.accessToken, + privateAccessKey: result.privateAccessKey, + }, + }; + } + + return { + error: { + __typename: 'CreateProjectAccessTokenResultError', + message: result.message, + details: result.details, + }, + }; +}; diff --git a/packages/services/api/src/modules/organization/resolvers/Organization.ts b/packages/services/api/src/modules/organization/resolvers/Organization.ts index b44febcf461..9220ab0084b 100644 --- a/packages/services/api/src/modules/organization/resolvers/Organization.ts +++ b/packages/services/api/src/modules/organization/resolvers/Organization.ts @@ -205,26 +205,12 @@ 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({ + return injector.get(OrganizationAccessTokens).getPaginatedForOrganization({ organizationId: organization.id, first: args.first ?? null, after: args.after ?? null, @@ -240,9 +226,6 @@ export const Organization: Pick< }); }, accessToken: async (organization, args, { injector }) => { - return injector.get(OrganizationAccessTokens).get({ - organizationId: organization.id, - id: args.id, - }); + return injector.get(OrganizationAccessTokens).getForOrganization(organization, args.id); }, }; diff --git a/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts index caf34657381..e5f08f79638 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,18 @@ 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); + }, + scope(accessToken, _arg, _ctx) { + return accessToken.userId ? 'PERSONAL' : accessToken.projectId ? 'PROJECT' : 'ORGANIZATION'; + }, + permissions(accessToken, _arg, { injector }) { + return injector.get(OrganizationAccessTokens).getPermissionsForAccessToken(accessToken); + }, + resolvedPermissions(accessToken, _arg, { injector }) { + return injector + .get(OrganizationAccessTokens) + .getGraphQLResolvedResourcePermissionGroupForAccessToken(accessToken)(); }, }; 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..9fd4764a8ee --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/Project.ts @@ -0,0 +1,29 @@ +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' +> = { + accessTokens(project, args, { injector }) { + return injector.get(OrganizationAccessTokens).getPaginatedForProject(project, { + first: args.first ?? null, + after: args.after ?? null, + }); + }, + availableProjectAccessTokenPermissionGroups(project, _, { injector }) { + return injector.get(OrganizationAccessTokens).getAvailablePermissionsGroupsForProject(project); + }, + accessToken(project, args, { injector }) { + return injector.get(OrganizationAccessTokens).getForProject(project, args.id); + }, +}; 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..ac55cf5abb7 --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/Query/whoAmI.ts @@ -0,0 +1,58 @@ +import { HiveError } from '@hive/api/shared/errors'; +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 ( + _, + args, + { session, injector }, + info, +) => { + 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, + 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, + }; + } + + throw new HiveError('Currently not supported.'); +}; 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/pnpm-lock.yaml b/pnpm-lock.yaml index 8ff25a1cb09..8439b577f93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -467,6 +467,9 @@ importers: '@theguild/federation-composition': specifier: 0.20.2 version: 0.20.2(graphql@16.9.0) + cli-table3: + specifier: 0.6.5 + version: 0.6.5 colors: specifier: 1.4.0 version: 1.4.0 @@ -9711,6 +9714,10 @@ packages: resolution: {integrity: sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==} engines: {node: 10.* || >= 12.*} + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -27639,6 +27646,12 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + cli-truncate@2.1.0: dependencies: slice-ansi: 3.0.0 @@ -27961,7 +27974,7 @@ snapshots: check-more-types: 2.24.0 ci-info: 4.0.0 cli-cursor: 3.1.0 - cli-table3: 0.6.3 + cli-table3: 0.6.5 commander: 6.2.1 common-tags: 1.8.2 dayjs: 1.11.13 From e854b3a139109fb8b4694d559980f482030320c5 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 3 Nov 2025 11:42:20 +0100 Subject: [PATCH 02/36] typo --- .../organization/lib/organization-access-token-permissions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b9fae466220..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 @@ -124,7 +124,7 @@ export const permissionGroups: Array = [ { 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', From 9cc1d76817afc591630fc92b86c0f6d3d19e42ce Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 3 Nov 2025 12:32:15 +0100 Subject: [PATCH 03/36] feat: project access token ui --- packages/libraries/cli/src/commands/whoami.ts | 2 +- .../src/modules/auth/resolvers/Permission.ts | 15 + .../modules/organization/module.graphql.ts | 6 +- .../providers/organization-access-tokens.ts | 15 +- .../resolvers/OrganizationAccessToken.ts | 6 +- .../organization/resolvers/Query/whoAmI.ts | 3 +- .../members/resource-selector.tsx | 175 ++++-- .../members/selected-permission-overview.tsx | 19 +- .../access-token-detail-view-sheet.tsx | 2 +- .../access-tokens/access-tokens-sub-page.tsx | 2 +- .../access-tokens/access-tokens-table.tsx | 6 + .../create-access-token-sheet-content.tsx | 4 +- ...ate-project-access-token-sheet-content.tsx | 504 ++++++++++++++++++ ...project-access-token-detail-view-sheet.tsx | 185 +++++++ .../project-access-tokens-sub-page.tsx | 142 +++++ .../project-access-tokens-table.tsx | 264 +++++++++ .../web/app/src/pages/project-settings.tsx | 20 +- 17 files changed, 1292 insertions(+), 78 deletions(-) create mode 100644 packages/web/app/src/components/project/settings/access-tokens/create-project-access-token-sheet-content.tsx create mode 100644 packages/web/app/src/components/project/settings/access-tokens/project-access-token-detail-view-sheet.tsx create mode 100644 packages/web/app/src/components/project/settings/access-tokens/project-access-tokens-sub-page.tsx create mode 100644 packages/web/app/src/components/project/settings/access-tokens/project-access-tokens-table.tsx diff --git a/packages/libraries/cli/src/commands/whoami.ts b/packages/libraries/cli/src/commands/whoami.ts index 5dc96642e9e..f7d270a1c05 100644 --- a/packages/libraries/cli/src/commands/whoami.ts +++ b/packages/libraries/cli/src/commands/whoami.ts @@ -18,7 +18,7 @@ const myTokenInfoQuery = graphql(/* GraphQL */ ` level resolvedResourceIds title - groups { + resolvedPermissionGroups { title permissions { isGranted diff --git a/packages/services/api/src/modules/auth/resolvers/Permission.ts b/packages/services/api/src/modules/auth/resolvers/Permission.ts index 8909c46284f..e44c28a0a0f 100644 --- a/packages/services/api/src/modules/auth/resolvers/Permission.ts +++ b/packages/services/api/src/modules/auth/resolvers/Permission.ts @@ -39,3 +39,18 @@ export 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/module.graphql.ts b/packages/services/api/src/modules/organization/module.graphql.ts index 8465a416524..db9d8fd7de2 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -223,7 +223,9 @@ export default gql` firstCharacters: String! @tag(name: "public") createdAt: DateTime! @tag(name: "public") scope: OrganizationAccessTokenScopeType! - resolvedPermissions: [ResolvedResourcePermissionGroup!]! + resolvedResourcePermissionGroups( + includeAll: Boolean = false + ): [ResolvedResourcePermissionGroup!]! } input DeleteOrganizationAccessTokenInput { @@ -923,7 +925,7 @@ export default gql` Title """ title: String! - groups: [ResolvedPermissionsGroup!]! + resolvedPermissionGroups: [ResolvedPermissionsGroup!]! } type WhoAmI { 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 377a365de1b..9e0d8efa4d9 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 @@ -18,7 +18,10 @@ import { ResourceLevel, Session, } from '../../auth/lib/authz'; -import { resourceLevelToResourceLevelType } from '../../auth/resolvers/Permission'; +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'; @@ -879,7 +882,6 @@ export class OrganizationAccessTokens { ); type PMap = { - title: string; level: ResourceLevel; permissionGroups: Map< /* group name */ string, @@ -906,7 +908,6 @@ export class OrganizationAccessTokens { ).map((value): [ResourceLevel, PMap] => [ value, { - title: value, level: value, permissionGroups: new Map(), resourceIds: resourceIds[value] ?? [], @@ -953,8 +954,8 @@ export class OrganizationAccessTokens { resourceGroup => ({ level: resourceLevelToResourceLevelType(resourceGroup.level), - title: resourceGroup.title, - groups: Array.from(resourceGroup.permissionGroups.values()) + title: resourceLevelToHumanReadableName(resourceGroup.level), + resolvedPermissionGroups: Array.from(resourceGroup.permissionGroups.values()) .map(group => ({ title: group.title, permissions: group.permissions.map(permission => ({ @@ -969,7 +970,9 @@ export class OrganizationAccessTokens { }) satisfies GraphQLResolvedResourcePermissionGroupOutput, ) .filter(group => - includeAll ? true : group.groups.length !== 0 && group.resolvedResourceIds?.length, + includeAll + ? true + : group.resolvedPermissionGroups.length !== 0 && group.resolvedResourceIds?.length, ); }; diff --git a/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts index e5f08f79638..a9fe9a6748a 100644 --- a/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts +++ b/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts @@ -20,9 +20,11 @@ export const OrganizationAccessToken: OrganizationAccessTokenResolvers = { permissions(accessToken, _arg, { injector }) { return injector.get(OrganizationAccessTokens).getPermissionsForAccessToken(accessToken); }, - resolvedPermissions(accessToken, _arg, { injector }) { + resolvedResourcePermissionGroups(accessToken, args, { injector }) { return injector .get(OrganizationAccessTokens) - .getGraphQLResolvedResourcePermissionGroupForAccessToken(accessToken)(); + .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 index ac55cf5abb7..3212769b8cc 100644 --- a/packages/services/api/src/modules/organization/resolvers/Query/whoAmI.ts +++ b/packages/services/api/src/modules/organization/resolvers/Query/whoAmI.ts @@ -6,9 +6,8 @@ import type { QueryResolvers } from './../../../../__generated__/types'; export const whoAmI: NonNullable = async ( _, - args, + __, { session, injector }, - info, ) => { const accessTokens = injector.get(OrganizationAccessTokens); diff --git a/packages/web/app/src/components/organization/members/resource-selector.tsx b/packages/web/app/src/components/organization/members/resource-selector.tsx index 6f7359de148..f9c50db9ae9 100644 --- a/packages/web/app/src/components/organization/members/resource-selector.tsx +++ b/packages/web/app/src/components/organization/members/resource-selector.tsx @@ -141,14 +141,16 @@ export function ResourceSelector(props: { organization: FragmentType; selection: ResourceSelection; onSelectionChange: (selection: ResourceSelection) => void; + /** + * Scope the resource selector to a specific project. + * If this property is provided, please make sure that the `selection` property contains the project. + * */ + forProjectId?: string; }) { const organization = useFragment(ResourceSelector_OrganizationFragment, props.organization); - const [breadcrumb, setBreadcrumb] = useState( - null as - | null - | { projectId: string; targetId?: undefined } - | { projectId: string; targetId: string }, - ); + const [breadcrumb, setBreadcrumb] = useState< + null | { projectId: string; targetId?: undefined } | { projectId: string; targetId: string } + >(props.forProjectId ? { projectId: props.forProjectId } : null); // whether we show the service or apps in the last tab const [serviceAppsState, setServiceAppsState] = useState(ServicesAppsState.service); @@ -608,11 +610,33 @@ export function ResourceSelector(props: { props.onSelectionChange, ]); + const forIdProject = useMemo(() => { + if (!props.forProjectId) { + return null; + } + + const project = props.selection.projects.find( + project => project.projectId === props.forProjectId, + ); + + if (!project) { + // Something is wrong + return null; + } + + return project; + }, [props.forProjectId, props.selection.projects]); + + const showProjectsTab = !props.forProjectId; + return ( @@ -620,11 +644,29 @@ export function ResourceSelector(props: { variant="content" value="full" onClick={() => { + if (!forIdProject) { + props.onSelectionChange({ + ...props.selection, + mode: GraphQLSchema.ResourceAssignmentModeType.All, + }); + setBreadcrumb(null); + return; + } + props.onSelectionChange({ ...props.selection, - mode: GraphQLSchema.ResourceAssignmentModeType.All, + mode: GraphQLSchema.ResourceAssignmentModeType.Granular, + projects: [ + { + ...forIdProject, + targets: { + ...forIdProject.targets, + mode: GraphQLSchema.ResourceAssignmentModeType.All, + }, + }, + ], }); - setBreadcrumb(null); + setBreadcrumb({ projectId: forIdProject.projectId }); }} > Full Access @@ -633,9 +675,25 @@ export function ResourceSelector(props: { variant="content" value="granular" onClick={() => { + if (!forIdProject) { + props.onSelectionChange({ + ...props.selection, + mode: GraphQLSchema.ResourceAssignmentModeType.Granular, + }); + return; + } props.onSelectionChange({ ...props.selection, mode: GraphQLSchema.ResourceAssignmentModeType.Granular, + projects: [ + { + ...forIdProject, + targets: { + ...forIdProject.targets, + mode: GraphQLSchema.ResourceAssignmentModeType.Granular, + }, + }, + ], }); }} > @@ -653,12 +711,14 @@ export function ResourceSelector(props: {

The permissions are granted on the specified resources.

-
- Projects -
+ {showProjectsTab && ( +
+ Projects +
+ )}
Targets
- {targetState && ( + {targetState && showProjectsTab && (
All
{/** Projects Content */} -
-
- access granted -
- {projectState.selected.length ? ( - projectState.selected.map(selection => ( - { - setBreadcrumb({ projectId: selection.project.id }); - }} - onDelete={() => projectState.removeProject(selection.project)} - /> - )) - ) : ( -
None selected
- )} -
- not selected + {showProjectsTab && ( +
+
+ access granted +
+ {projectState.selected.length ? ( + projectState.selected.map(selection => ( + { + setBreadcrumb({ projectId: selection.project.id }); + }} + onDelete={() => projectState.removeProject(selection.project)} + /> + )) + ) : ( +
None selected
+ )} +
+ not selected +
+ {projectState.notSelected.length ? ( + projectState.notSelected.map(project => ( + projectState.addProject(project)} + /> + )) + ) : ( +
All selected
+ )}
- {projectState.notSelected.length ? ( - projectState.notSelected.map(project => ( - projectState.addProject(project)} - /> - )) - ) : ( -
All selected
- )} -
+ )} {/** Targets Content */} -
+
{targetState === null ? (
Select a project for adjusting the target access. diff --git a/packages/web/app/src/components/organization/members/selected-permission-overview.tsx b/packages/web/app/src/components/organization/members/selected-permission-overview.tsx index 3b5f572e056..5a47c54579e 100644 --- a/packages/web/app/src/components/organization/members/selected-permission-overview.tsx +++ b/packages/web/app/src/components/organization/members/selected-permission-overview.tsx @@ -35,6 +35,8 @@ export type SelectedPermissionOverviewProps = { isExpanded?: boolean; /** option for injecting additional content within a permission group. */ additionalGroupContent?: (group: { level: PermissionLevelType }) => React.ReactNode; + /** Exclude organization for project settings. */ + excludeOrganization?: boolean; }; export function SelectedPermissionOverview(props: SelectedPermissionOverviewProps) { @@ -47,11 +49,7 @@ export function SelectedPermissionOverview(props: SelectedPermissionOverviewProp [props.activePermissionIds], ); - return [ - { - level: PermissionLevelType.Organization, - title: 'Organization', - }, + const items = [ { level: PermissionLevelType.Project, title: 'Project', @@ -68,7 +66,16 @@ export function SelectedPermissionOverview(props: SelectedPermissionOverviewProp level: PermissionLevelType.AppDeployment, title: 'App Deployment', }, - ].map(group => ( + ]; + + if (!props.excludeOrganization) { + items.unshift({ + level: PermissionLevelType.Organization, + title: 'Organization', + }); + } + + return items.map(group => ( Title Private Key + Scope Created At @@ -112,6 +115,9 @@ export function AccessTokensTable(props: AccessTokensTable) { {edge.node.firstCharacters + privateKeyFiller} + + {edge.node.scope.toLowerCase()} + created diff --git a/packages/web/app/src/components/organization/settings/access-tokens/create-access-token-sheet-content.tsx b/packages/web/app/src/components/organization/settings/access-tokens/create-access-token-sheet-content.tsx index 423209272b8..1d55630bfef 100644 --- a/packages/web/app/src/components/organization/settings/access-tokens/create-access-token-sheet-content.tsx +++ b/packages/web/app/src/components/organization/settings/access-tokens/create-access-token-sheet-content.tsx @@ -28,7 +28,7 @@ import { SelectedPermissionOverview } from '../../members/selected-permission-ov import { permissionLevelToResourceName, resolveResources } from './shared-helpers'; /** @soure packages/services/api/src/modules/organization/providers/organization-access-tokens.ts */ -const TitleInputModel = z +export const TitleInputModel = z .string() .trim() .regex(/^[ a-zA-Z0-9_-]+$/, 'Can only contain letters, numbers, " ", "_", and "-".') @@ -36,7 +36,7 @@ const TitleInputModel = z .max(100, 'Maximum length is 100 characters.'); /** @soure packages/services/api/src/modules/organization/providers/organization-access-tokens.ts */ -const DescriptionInputModel = z +export const DescriptionInputModel = z .string() .trim() .max(248, 'Maximum length is 248 characters.') diff --git a/packages/web/app/src/components/project/settings/access-tokens/create-project-access-token-sheet-content.tsx b/packages/web/app/src/components/project/settings/access-tokens/create-project-access-token-sheet-content.tsx new file mode 100644 index 00000000000..8cccd2f16df --- /dev/null +++ b/packages/web/app/src/components/project/settings/access-tokens/create-project-access-token-sheet-content.tsx @@ -0,0 +1,504 @@ +import { useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useMutation } from 'urql'; +import { z } from 'zod'; +import * as AlertDialog from '@/components/ui/alert-dialog'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import * as Form from '@/components/ui/form'; +import { Heading } from '@/components/ui/heading'; +import { Input } from '@/components/ui/input'; +import { InputCopy } from '@/components/ui/input-copy'; +import * as Sheet from '@/components/ui/sheet'; +import { defineStepper } from '@/components/ui/stepper'; +import { Textarea } from '@/components/ui/textarea'; +import { useToast } from '@/components/ui/use-toast'; +import { Tag } from '@/components/v2'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import * as GraphQLSchema from '@/gql/graphql'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { PermissionSelector } from '../../../organization/members/permission-selector'; +import { + ResourceSelector, + resourceSlectionToGraphQLSchemaResourceAssignmentInput, + type ResourceSelection, +} from '../../../organization/members/resource-selector'; +import { SelectedPermissionOverview } from '../../../organization/members/selected-permission-overview'; +import { + DescriptionInputModel, + TitleInputModel, +} from '../../../organization/settings/access-tokens/create-access-token-sheet-content'; +import { + permissionLevelToResourceName, + resolveResources, +} from '../../../organization/settings/access-tokens/shared-helpers'; + +const CreateAccessTokenFormModel = z.object({ + title: TitleInputModel, + description: DescriptionInputModel, + permissions: z.array(z.string()).min(1, 'Please select at least one permission.'), +}); + +const CreateProjectAccessTokenSheetContent_OrganizationFragment = graphql(` + fragment CreateProjectAccessTokenSheetContent_OrganizationFragment on Organization { + id + slug + ...ResourceSelector_OrganizationFragment + } +`); + +const CreateProjectAccessTokenSheetContent_ProjectFragment = graphql(` + fragment CreateProjectAccessTokenSheetContent_ProjectFragment on Project { + id + slug + availableProjectAccessTokenPermissionGroups { + ...PermissionSelector_PermissionGroupsFragment + ...SelectedPermissionOverview_PermissionGroupFragment + } + } +`); + +type CreateProjectAccessTokenSheetContentProps = { + onSuccess: () => void; + organization: FragmentType; + project: FragmentType; +}; + +const CreateProjectAccessTokenSheetContent_CreateOrganizationAccessTokenMutation = graphql(` + mutation CreateProjectAccessTokenSheetContent_CreateOrganizationAccessTokenMutation( + $input: CreateProjectAccessTokenInput! + ) { + createProjectAccessToken(input: $input) { + ok { + privateAccessKey + createdProjectAccessToken { + id + } + } + error { + message + # details { + # title + # description + # } + } + } + } +`); + +export function CreateProjectAccessTokenSheetContent( + props: CreateProjectAccessTokenSheetContentProps, +): React.ReactNode { + // eslint-disable-next-line react/hook-use-state + const [Stepper] = useState(() => + defineStepper( + { + id: 'step-1-general', + title: 'General', + }, + { + id: 'step-2-permissions', + title: 'Permissions', + }, + { + id: 'step-3-resources', + title: 'Resources', + }, + { + id: 'step-4-confirmation', + title: 'Confirm', + }, + ), + ); + const organization = useFragment( + CreateProjectAccessTokenSheetContent_OrganizationFragment, + props.organization, + ); + const project = useFragment(CreateProjectAccessTokenSheetContent_ProjectFragment, props.project); + const [resourceSelection, setResourceSelection] = useState(() => ({ + mode: GraphQLSchema.ResourceAssignmentModeType.Granular, + projects: [ + { + projectId: project.id, + projectSlug: project.slug, + targets: { + mode: GraphQLSchema.ResourceAssignmentModeType.All, + targets: [], + }, + }, + ], + })); + + const form = useForm>({ + mode: 'onChange', + resolver: zodResolver(CreateAccessTokenFormModel), + defaultValues: { + title: '', + description: '', + permissions: [], + }, + }); + + const [createOrganizationAccessTokenState, createOrganizationAccessToken] = useMutation( + CreateProjectAccessTokenSheetContent_CreateOrganizationAccessTokenMutation, + ); + + const resolvedResources = useMemo( + () => resolveResources(organization.slug, resourceSelection), + [resourceSelection], + ); + + const { toast } = useToast(); + async function createAccessToken() { + const formValues = form.getValues(); + const result = await createOrganizationAccessToken({ + input: { + project: { + byId: project.id, + }, + title: formValues.title ?? '', + description: formValues.description ?? '', + permissions: formValues.permissions, + resources: + resourceSlectionToGraphQLSchemaResourceAssignmentInput(resourceSelection).projects?.at(0)! + .targets!, + }, + }); + + if (result.data?.createProjectAccessToken.error) { + const { error } = result.data.createProjectAccessToken; + // if (error.details?.title) { + // form.setError('title', { message: error.details.title }); + // } + // if (error.details?.description) { + // form.setError('description', { message: error.details.description }); + // } + if (error.message) { + toast({ + variant: 'destructive', + title: 'An error occured', + description: error.message, + }); + } + return; + } + if (result.error) { + toast({ + variant: 'destructive', + title: 'An error occured', + description: 'Something went wrong. Try again later.', + }); + return; + } + } + + return ( + + + Create Access Token + + Create a new access token with specified permissions and optionally assigned resources. + + + + {({ stepper }) => ( + <> + +
{})}> + <> + + {stepper.all.map(step => ( + + {step.title} + + ))} + + {stepper.switch({ + 'step-1-general': () => ( + <> + General +
+ ( + + Name + + + + + Name of the access token. + + + + )} + /> +
+ +
+ ( + + Description + +