diff --git a/integration-tests/tests/api/oidc-integrations/assigned-resources.spec.ts b/integration-tests/tests/api/oidc-integrations/assigned-resources.spec.ts new file mode 100644 index 00000000000..64b78fc4169 --- /dev/null +++ b/integration-tests/tests/api/oidc-integrations/assigned-resources.spec.ts @@ -0,0 +1,266 @@ +import { graphql } from 'testkit/gql'; +import { ResourceAssignmentModeType } from 'testkit/gql/graphql'; +import { execute } from 'testkit/graphql'; +import { initSeed } from 'testkit/seed'; + +const AssignedResourcesSpec_CreateOIDCIntegrationMutation = graphql(` + mutation AssignedResourcesSpec_CreateOIDCIntegrationMutation( + $input: CreateOIDCIntegrationInput! + ) { + createOIDCIntegration(input: $input) { + ok { + createdOIDCIntegration { + id + defaultResourceAssignment { + mode + } + } + } + } + } +`); + +const AssignedResourcesSpec_ReadDefaultTest = graphql(` + query AssignedResourcesSpec_ReadDefaultTest($organizationSlug: String!) { + organization(reference: { bySelector: { organizationSlug: $organizationSlug } }) { + id + oidcIntegration { + defaultResourceAssignment { + mode + projects { + project { + id + slug + } + targets { + mode + targets { + target { + id + slug + } + services { + mode + services + } + appDeployments { + mode + appDeployments + } + } + } + } + } + } + } + } +`); + +const AssignedResourcesSpec_UpdateDefaultMutation = graphql(` + mutation AssignedResourcesSpec_UpdateDefaultMutation( + $input: UpdateOIDCDefaultResourceAssignmentInput! + ) { + updateOIDCDefaultResourceAssignment(input: $input) { + ok { + updatedOIDCIntegration { + id + defaultResourceAssignment { + mode + projects { + project { + id + slug + } + targets { + mode + targets { + target { + id + slug + } + services { + mode + services + } + appDeployments { + mode + appDeployments + } + } + } + } + } + } + } + error { + message + } + } + } +`); + +async function setup() { + const { ownerToken, createOrg } = await initSeed().createOwner(); + const { organization, createOrganizationAccessToken } = await createOrg(); + + const result = await execute({ + document: AssignedResourcesSpec_CreateOIDCIntegrationMutation, + variables: { + input: { + organizationId: organization.id, + clientId: 'foo', + clientSecret: 'foofoofoofoo', + tokenEndpoint: 'http://localhost:8888/oauth/token', + userinfoEndpoint: 'http://localhost:8888/oauth/userinfo', + authorizationEndpoint: 'http://localhost:8888/oauth/authorize', + }, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + // no default exists at creation + expect(result.createOIDCIntegration.ok?.createdOIDCIntegration.defaultResourceAssignment).toBe( + null, + ); + return { + organization, + ownerToken, + oidcIntegrationId: result.createOIDCIntegration.ok?.createdOIDCIntegration.id!, + createOrganizationAccessToken, + }; +} + +describe('read OIDC', () => { + describe('permissions="organization:integrations"', () => { + test.concurrent('success', async ({ expect }) => { + const { organization, ownerToken, oidcIntegrationId } = await setup(); + + await execute({ + document: AssignedResourcesSpec_UpdateDefaultMutation, + variables: { + input: { + oidcIntegrationId, + resources: { + mode: ResourceAssignmentModeType.All, + }, + }, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + const read = await execute({ + document: AssignedResourcesSpec_ReadDefaultTest, + variables: { + organizationSlug: organization.slug, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + expect(read).toEqual({ + organization: { + id: expect.stringMatching('.+'), + oidcIntegration: { + defaultResourceAssignment: { + mode: 'ALL', + projects: null, + }, + }, + }, + }); + }); + }); + + describe('permissions missing "organization:integrations"', () => { + test.concurrent('fail', async ({ expect }) => { + const { organization, ownerToken, oidcIntegrationId, createOrganizationAccessToken } = + await setup(); + const { privateAccessKey: readToken } = await createOrganizationAccessToken({ + permissions: ['organization:read'], + resources: { mode: ResourceAssignmentModeType.All }, + }); + + await execute({ + document: AssignedResourcesSpec_UpdateDefaultMutation, + variables: { + input: { + oidcIntegrationId, + resources: { + mode: ResourceAssignmentModeType.All, + }, + }, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + await execute({ + document: AssignedResourcesSpec_ReadDefaultTest, + variables: { + organizationSlug: organization.slug, + }, + authToken: readToken, + }).then(r => r.expectGraphQLErrors()); + }); + }); +}); + +describe('update OIDC default assigned resources', () => { + describe('permissions="oidc:modify"', () => { + test.concurrent('success', async ({ expect }) => { + const { organization, ownerToken, oidcIntegrationId } = await setup(); + + const update = await execute({ + document: AssignedResourcesSpec_UpdateDefaultMutation, + variables: { + input: { + oidcIntegrationId, + resources: { + mode: ResourceAssignmentModeType.All, + }, + }, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + expect(update).toEqual({ + updateOIDCDefaultResourceAssignment: { + error: null, + ok: { + updatedOIDCIntegration: { + defaultResourceAssignment: { + mode: 'ALL', + projects: null, + }, + id: expect.stringMatching('.+'), + }, + }, + }, + }); + }); + }); + + describe('permissions missing "oidc:modify"', () => { + test.concurrent('fails', async ({ expect }) => { + const { createOrganizationAccessToken, ownerToken, oidcIntegrationId } = await setup(); + + const { privateAccessKey: accessToken } = await createOrganizationAccessToken({ + permissions: ['organization:read'], + resources: { + mode: ResourceAssignmentModeType.All, + }, + }); + + const update = await execute({ + document: AssignedResourcesSpec_UpdateDefaultMutation, + variables: { + input: { + oidcIntegrationId, + resources: { + mode: ResourceAssignmentModeType.All, + }, + }, + }, + authToken: accessToken, + }).then(r => r.expectGraphQLErrors()); + }); + }); +}); diff --git a/packages/migrations/src/actions/2025.10.30T00-00-00.granular-oidc-role-permissions.ts b/packages/migrations/src/actions/2025.10.30T00-00-00.granular-oidc-role-permissions.ts new file mode 100644 index 00000000000..2f069d982e5 --- /dev/null +++ b/packages/migrations/src/actions/2025.10.30T00-00-00.granular-oidc-role-permissions.ts @@ -0,0 +1,10 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2025.10.30T00-00-00.granular-oidc-role-permissions.ts', + run: ({ sql }) => sql` + ALTER TABLE "oidc_integrations" + ADD COLUMN "default_assigned_resources" JSONB + ; + `, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index 3d456c9b18f..e2160e0d58c 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.30T00-00-00.granular-oidc-role-permissions'), ], }); 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..81a819d74f5 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 @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { ResourceAssignmentModel } from '../../organization/lib/resource-assignment-model'; +import { ResourceAssignmentModel } from '@hive/storage/resource-assignment-model'; export const AuditLogModel = z.union([ z.object({ diff --git a/packages/services/api/src/modules/oidc-integrations/index.ts b/packages/services/api/src/modules/oidc-integrations/index.ts index a2367f3abdd..81e45d8550c 100644 --- a/packages/services/api/src/modules/oidc-integrations/index.ts +++ b/packages/services/api/src/modules/oidc-integrations/index.ts @@ -1,4 +1,5 @@ import { createModule } from 'graphql-modules'; +import { ResourceAssignments } from '../organization/providers/resource-assignments'; import { OIDCIntegrationsProvider } from './providers/oidc-integrations.provider'; import { resolvers } from './resolvers.generated'; import typeDefs from './module.graphql'; @@ -8,5 +9,5 @@ export const oidcIntegrationsModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [OIDCIntegrationsProvider], + providers: [OIDCIntegrationsProvider, ResourceAssignments], }); diff --git a/packages/services/api/src/modules/oidc-integrations/module.graphql.ts b/packages/services/api/src/modules/oidc-integrations/module.graphql.ts index 5d0ad2e7a03..2a5c5d3203e 100644 --- a/packages/services/api/src/modules/oidc-integrations/module.graphql.ts +++ b/packages/services/api/src/modules/oidc-integrations/module.graphql.ts @@ -1,9 +1,24 @@ import { gql } from 'graphql-modules'; export default gql` + type ProjectForResourceSelector { + id: ID! + slug: String! + type: ProjectType! + targets: [TargetForResourceSelector!]! + } + + type TargetForResourceSelector { + id: ID! + slug: String! + services: [String!]! + appDeployments: [String!]! + } + extend type Organization { viewerCanManageOIDCIntegration: Boolean! oidcIntegration: OIDCIntegration + projectsForResourceSelector: [ProjectForResourceSelector] } extend type User { @@ -19,6 +34,7 @@ export default gql` authorizationEndpoint: String! oidcUserAccessOnly: Boolean! defaultMemberRole: MemberRole! + defaultResourceAssignment: ResourceAssignment } extend type Mutation { @@ -29,6 +45,30 @@ export default gql` updateOIDCDefaultMemberRole( input: UpdateOIDCDefaultMemberRoleInput! ): UpdateOIDCDefaultMemberRoleResult! + updateOIDCDefaultResourceAssignment( + input: UpdateOIDCDefaultResourceAssignmentInput! + ): UpdateOIDCDefaultResourceAssignmentResult! + } + + """ + @oneOf + """ + type UpdateOIDCDefaultResourceAssignmentResult { + ok: UpdateOIDCDefaultResourceAssignmentOk + error: UpdateOIDCDefaultResourceAssignmentError + } + + type UpdateOIDCDefaultResourceAssignmentOk { + updatedOIDCIntegration: OIDCIntegration! + } + + type UpdateOIDCDefaultResourceAssignmentError implements Error { + message: String! + } + + input UpdateOIDCDefaultResourceAssignmentInput { + oidcIntegrationId: ID! + resources: ResourceAssignmentInput! } extend type Subscription { diff --git a/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts b/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts index d4f83ade463..da06d4eed5d 100644 --- a/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts +++ b/packages/services/api/src/modules/oidc-integrations/providers/oidc-integrations.provider.ts @@ -1,10 +1,13 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; import zod from 'zod'; import { maskToken } from '@hive/service-common'; +import { ResourceAssignmentGroup } from '@hive/storage/resource-assignment-model'; +import * as GraphQLSchema from '../../../__generated__/types'; import { OIDCIntegration } from '../../../shared/entities'; import { HiveError } from '../../../shared/errors'; import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; +import { ResourceAssignments } from '../../organization/providers/resource-assignments'; import { CryptoProvider } from '../../shared/providers/crypto'; import { Logger } from '../../shared/providers/logger'; import { PUB_SUB_CONFIG, type HivePubSub } from '../../shared/providers/pub-sub'; @@ -26,6 +29,7 @@ export class OIDCIntegrationsProvider { @Inject(PUB_SUB_CONFIG) private pubSub: HivePubSub, @Inject(OIDC_INTEGRATIONS_ENABLED) private enabled: boolean, private session: Session, + private resourceAssignments: ResourceAssignments, ) { this.logger = logger.child({ source: 'OIDCIntegrationsProvider' }); } @@ -367,6 +371,51 @@ export class OIDCIntegrationsProvider { } as const; } + async updateOIDCDefaultAssignedResources(args: { + oidcIntegrationId: string; + assignedResources: GraphQLSchema.ResourceAssignmentInput; + }) { + if (this.isEnabled() === false) { + return { + type: 'error', + message: 'OIDC integrations are disabled.', + } as const; + } + + const oidcIntegration = await this.storage.getOIDCIntegrationById({ + oidcIntegrationId: args.oidcIntegrationId, + }); + + if (oidcIntegration === null) { + return { + type: 'error', + message: 'Integration not found.', + } as const; + } + + await this.session.assertPerformAction({ + organizationId: oidcIntegration.linkedOrganizationId, + action: 'oidc:modify', + params: { + organizationId: oidcIntegration.linkedOrganizationId, + }, + }); + + const assignedResources: ResourceAssignmentGroup = + await this.resourceAssignments.transformGraphQLResourceAssignmentInputToResourceAssignmentGroup( + oidcIntegration.linkedOrganizationId, + args.assignedResources, + ); + + return { + type: 'ok', + oidcIntegration: await this.storage.updateOIDCDefaultAssignedResources({ + oidcIntegrationId: args.oidcIntegrationId, + assignedResources, + }), + } as const; + } + async updateOIDCDefaultMemberRole(args: { oidcIntegrationId: string; roleId: string }) { if (this.isEnabled() === false) { return { @@ -459,6 +508,15 @@ export class OIDCIntegrationsProvider { return this.pubSub.subscribe('oidcIntegrationLogs', integration.id); } + + async getProjectsForResourceSelector(args: { organizationId: string }) { + const isAllowed = this.canViewerManageIntegrationForOrganization(args.organizationId); + if (!isAllowed) { + this.session.raise('oidc:modify'); + } + + return this.storage.getProjectsForResourceSelector({ organizationId: args.organizationId }); + } } const OIDCIntegrationClientIdModel = zod diff --git a/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/updateOIDCDefaultResourceAssignment.ts b/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/updateOIDCDefaultResourceAssignment.ts new file mode 100644 index 00000000000..b25500756e3 --- /dev/null +++ b/packages/services/api/src/modules/oidc-integrations/resolvers/Mutation/updateOIDCDefaultResourceAssignment.ts @@ -0,0 +1,25 @@ +import { OIDCIntegrationsProvider } from '../../providers/oidc-integrations.provider'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const updateOIDCDefaultResourceAssignment: NonNullable< + MutationResolvers['updateOIDCDefaultResourceAssignment'] +> = async (_parent, { input }, { injector }) => { + const result = await injector.get(OIDCIntegrationsProvider).updateOIDCDefaultAssignedResources({ + assignedResources: input.resources, + oidcIntegrationId: input.oidcIntegrationId, + }); + + if (result.type === 'error') { + return { + error: { + message: result.message, + }, + }; + } + + return { + ok: { + updatedOIDCIntegration: result.oidcIntegration, + }, + }; +}; diff --git a/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegration.ts b/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegration.ts index 617b3528f5c..597e47cb804 100644 --- a/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegration.ts +++ b/packages/services/api/src/modules/oidc-integrations/resolvers/OIDCIntegration.ts @@ -1,4 +1,5 @@ import { OrganizationMemberRoles } from '../../organization/providers/organization-member-roles'; +import { ResourceAssignments } from '../../organization/providers/resource-assignments'; import { OIDCIntegrationsProvider } from '../providers/oidc-integrations.provider'; import type { OidcIntegrationResolvers } from './../../../__generated__/types'; @@ -40,4 +41,14 @@ export const OIDCIntegration: OidcIntegrationResolvers = { return role; }, + defaultResourceAssignment: async (oidcIntegration, _, { injector }) => { + if (!oidcIntegration.defaultResourceAssignment) { + return null; + } + + return injector.get(ResourceAssignments).resolveGraphQLMemberResourceAssignment({ + organizationId: oidcIntegration.linkedOrganizationId, + resources: oidcIntegration.defaultResourceAssignment, + }); + }, }; diff --git a/packages/services/api/src/modules/oidc-integrations/resolvers/Organization.ts b/packages/services/api/src/modules/oidc-integrations/resolvers/Organization.ts index adf286c00d5..c31f6e6d512 100644 --- a/packages/services/api/src/modules/oidc-integrations/resolvers/Organization.ts +++ b/packages/services/api/src/modules/oidc-integrations/resolvers/Organization.ts @@ -3,7 +3,7 @@ import type { OrganizationResolvers } from './../../../__generated__/types'; export const Organization: Pick< OrganizationResolvers, - 'oidcIntegration' | 'viewerCanManageOIDCIntegration' + 'oidcIntegration' | 'projectsForResourceSelector' | 'viewerCanManageOIDCIntegration' > = { oidcIntegration: async (organization, _, { injector }) => { if (injector.get(OIDCIntegrationsProvider).isEnabled() === false) { @@ -19,4 +19,9 @@ export const Organization: Pick< .get(OIDCIntegrationsProvider) .canViewerManageIntegrationForOrganization(organization.id); }, + projectsForResourceSelector: async (organization, _, { injector }) => { + return await injector + .get(OIDCIntegrationsProvider) + .getProjectsForResourceSelector({ organizationId: organization.id }); + }, }; diff --git a/packages/services/api/src/modules/organization/providers/organization-access-tokens.ts b/packages/services/api/src/modules/organization/providers/organization-access-tokens.ts index af35060a8af..22580231436 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 @@ -5,6 +5,7 @@ import { decodeCreatedAtAndUUIDIdBasedCursor, encodeCreatedAtAndUUIDIdBasedCursor, } from '@hive/storage'; +import { ResourceAssignmentModel } from '@hive/storage/resource-assignment-model'; import * as GraphQLSchema from '../../../__generated__/types'; import { isUUID } from '../../../shared/is-uuid'; import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; @@ -21,7 +22,6 @@ 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 { OrganizationAccessTokensCache } from './organization-access-tokens-cache'; import { resolveResourceAssignment, 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..ef70a968e85 100644 --- a/packages/services/api/src/modules/organization/providers/organization-members.ts +++ b/packages/services/api/src/modules/organization/providers/organization-members.ts @@ -5,15 +5,15 @@ import { decodeCreatedAtAndUUIDIdBasedCursor, encodeCreatedAtAndUUIDIdBasedCursor, } from '@hive/storage'; +import { + ResourceAssignmentModel, + type ResourceAssignmentGroup, +} from '@hive/storage/resource-assignment-model'; import { type Organization } from '../../../shared/entities'; import { batchBy } from '../../../shared/helpers'; import { AuthorizationPolicyStatement } from '../../auth/lib/authz'; import { Logger } from '../../shared/providers/logger'; import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; -import { - ResourceAssignmentModel, - type ResourceAssignmentGroup, -} from '../lib/resource-assignment-model'; import { OrganizationMemberRoles, type OrganizationMemberRole } from './organization-member-roles'; import { resolveResourceAssignment, 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..4dea0fda52a 100644 --- a/packages/services/api/src/modules/organization/providers/resource-assignments.ts +++ b/packages/services/api/src/modules/organization/providers/resource-assignments.ts @@ -1,5 +1,10 @@ import { Injectable, Scope } from 'graphql-modules'; import { z } from 'zod'; +import { + GranularAssignedProjects, + TargetAssignmentModel, + type ResourceAssignmentGroup, +} from '@hive/storage/resource-assignment-model'; import * as GraphQLSchema from '../../../__generated__/types'; import type { Project } from '../../../shared/entities'; import { isUUID } from '../../../shared/is-uuid'; @@ -10,11 +15,6 @@ import { } from '../../auth/lib/authz'; import { Logger } from '../../shared/providers/logger'; import { Storage } from '../../shared/providers/storage'; -import { - GranularAssignedProjects, - TargetAssignmentModel, - type ResourceAssignmentGroup, -} from '../lib/resource-assignment-model'; @Injectable({ scope: Scope.Operation, diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index 25159a6c0ea..d0a68672b09 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -12,7 +12,8 @@ import type { SchemaVersion, TargetBreadcrumb, } from '@hive/storage'; -import type { SchemaChecksFilter } from '../../../__generated__/types'; +import type { ResourceAssignmentGroup } from '@hive/storage/resource-assignment-model'; +import type { ProjectType, SchemaChecksFilter } from '../../../__generated__/types'; import type { Alert, AlertChannel, @@ -55,6 +56,20 @@ export interface TargetSelector extends ProjectSelector { targetId: string; } +export type ProjectForResourceSelector = { + id: string; + slug: string; + type: ProjectType; + targets: TargetForResourceSelector[]; +}; + +export type TargetForResourceSelector = { + id: string; + slug: string; + services: string[]; + appDeployments: string[]; +}; + type CreateContractVersionInput = { contractId: string; contractName: string; @@ -390,6 +405,7 @@ export interface Storage { after: string | null; }>; getSchemasOfVersion(_: { versionId: string; includeMetadata?: boolean }): Promise; + getSchemaNamesOfVersion(_: { versionId: string }): Promise; getSchemaByNameOfVersion(_: { versionId: string; serviceName: string }): Promise; getServiceSchemaOfVersion(args: { schemaVersionId: string; @@ -623,6 +639,11 @@ export interface Storage { roleId: string; }): Promise; + updateOIDCDefaultAssignedResources(_: { + oidcIntegrationId: string; + assignedResources: ResourceAssignmentGroup; + }): Promise; + createCDNAccessToken(_: { id: string; targetId: string; @@ -822,6 +843,9 @@ export interface Storage { targetId: string; nativeComposition: boolean; }): Promise; + getProjectsForResourceSelector(_: { + organizationId: string; + }): Promise; } @Injectable() diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index 4acd0904171..6477bcf2009 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import type { AvailableRulesResponse, PolicyConfigurationObject } from '@hive/policy'; import type { CompositionFailureError } from '@hive/schema'; import type { schema_policy_resource } from '@hive/storage'; +import type { ResourceAssignmentGroup } from '@hive/storage/resource-assignment-model'; import type { AlertChannelType, AlertType, @@ -223,6 +224,7 @@ export interface OIDCIntegration { authorizationEndpoint: string; oidcUserAccessOnly: boolean; defaultMemberRoleId: string | null; + defaultResourceAssignment: ResourceAssignmentGroup | null; } export interface CDNAccessToken { diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 2d563d08efd..6fe72fada9f 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -146,6 +146,7 @@ export interface oidc_integrations { client_id: string; client_secret: string; created_at: Date; + default_assigned_resources: any | null; default_role_id: string | null; id: string; linked_organization_id: string; diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 5b3b6e83fd7..50309172077 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -51,6 +51,7 @@ import { tokens, users, } from './db'; +import { ResourceAssignmentModel } from './resource-assignment-model'; import { ConditionalBreakingChangeMetadata, ConditionalBreakingChangeMetadataModel, @@ -506,7 +507,7 @@ export async function createStorage( await connection.query( sql`/* addOrganizationMemberViaOIDCIntegrationId */ INSERT INTO organization_member - (organization_id, user_id, role_id, created_at) + (organization_id, user_id, role_id, assigned_resources, created_at) VALUES ( ${linkedOrganizationId}, @@ -519,6 +520,10 @@ export async function createStorage( WHERE organization_id = ${linkedOrganizationId} AND name = 'Viewer') ) ), + ( + SELECT default_assigned_resources FROM oidc_integrations + WHERE id = ${args.oidcIntegrationId} + ), now() ) ON CONFLICT DO NOTHING @@ -2198,6 +2203,27 @@ export async function createStorage( return result.rows.map(transformSchema); }, + async getSchemaNamesOfVersion({ versionId: version }) { + const result = await pool.query< + Pick, 'service_name'> + >( + sql`/* getSchemaNamesOfVersion */ + SELECT + lower(sl.service_name) as service_name + FROM schema_version_to_log AS svl + LEFT JOIN schema_log AS sl ON (sl.id = svl.action_id) + LEFT JOIN projects as p ON (p.id = sl.project_id) + WHERE + svl.version_id = ${version} + AND sl.action = 'PUSH' + AND p.type != 'CUSTOM' + ORDER BY + sl.created_at DESC + `, + ); + + return result.rows.map(s => s.service_name).filter(Boolean) as string[]; + }, async getServiceSchemaOfVersion(args) { const result = await pool.maybeOne< Pick< @@ -3021,6 +3047,7 @@ export async function createStorage( , "authorization_endpoint" , "oidc_user_access_only" , "default_role_id" + , "default_assigned_resources" FROM "oidc_integrations" WHERE @@ -3048,6 +3075,7 @@ export async function createStorage( , "authorization_endpoint" , "oidc_user_access_only" , "default_role_id" + , "default_assigned_resources" FROM "oidc_integrations" WHERE @@ -3111,6 +3139,7 @@ export async function createStorage( , "authorization_endpoint" , "oidc_user_access_only" , "default_role_id" + , "default_assigned_resources" `); return { @@ -3166,6 +3195,7 @@ export async function createStorage( , "authorization_endpoint" , "oidc_user_access_only" , "default_role_id" + , "default_assigned_resources" `); return decodeOktaIntegrationRecord(result); @@ -3189,11 +3219,38 @@ export async function createStorage( , "authorization_endpoint" , "oidc_user_access_only" , "default_role_id" + , "default_assigned_resources" `); return decodeOktaIntegrationRecord(result); }, + async updateOIDCDefaultAssignedResources(args) { + return tracedTransaction('updateOIDCDefaultAssignedResources', pool, async _ => { + const result = await pool.one(sql`/* updateOIDCDefaultAssignedResources */ + UPDATE "oidc_integrations" + SET + "default_assigned_resources" = ${sql.jsonb(args.assignedResources)} + WHERE + "id" = ${args.oidcIntegrationId} + RETURNING + "id" + , "linked_organization_id" + , "client_id" + , "client_secret" + , "oauth_api_url" + , "token_endpoint" + , "userinfo_endpoint" + , "authorization_endpoint" + , "oidc_user_access_only" + , "default_role_id" + , "default_assigned_resources" + `); + + return decodeOktaIntegrationRecord(result); + }); + }, + async updateOIDCDefaultMemberRole(args) { return tracedTransaction('updateOIDCDefaultMemberRole', pool, async trx => { // Make sure the role exists and is associated with the organization @@ -3227,6 +3284,7 @@ export async function createStorage( , "authorization_endpoint" , "oidc_user_access_only" , "default_role_id" + , "default_assigned_resources" `); return decodeOktaIntegrationRecord(result); @@ -4574,6 +4632,52 @@ export async function createStorage( organizationId: args.organizationId, }); }, + async getProjectsForResourceSelector(args) { + const projects = await this.getProjects({ organizationId: args.organizationId }); + + return await Promise.all( + projects.map(async p => { + const targets = await this.getTargets({ + organizationId: args.organizationId, + projectId: p.id, + }); + return { + id: p.id, + slug: p.slug, + type: p.type, + targets: await Promise.all( + targets.map(async t => { + const latest = await this.getMaybeLatestValidVersion({ targetId: t.id }); + let services: string[] | undefined; + if (latest) { + services = await storage.getSchemaNamesOfVersion({ + versionId: latest.id, + }); + } + + const apps = await this.pool.query<{ name: string }>( + sql` + SELECT DISTINCT ON ("name") + "name" + FROM + "app_deployments" + WHERE + "target_id" = ${t.id} + AND "retired_at" IS NULL + `, + ); + return { + id: t.id, + slug: t.slug, + services: services ?? [], + appDeployments: apps.rows.map(a => a.name), + }; + }), + ), + }; + }), + ); + }, pool, }; @@ -4635,6 +4739,7 @@ const OktaIntegrationBaseModel = zod.object({ client_secret: zod.string(), oidc_user_access_only: zod.boolean(), default_role_id: zod.string().nullable(), + default_assigned_resources: ResourceAssignmentModel.nullable(), }); const OktaIntegrationLegacyModel = zod.intersection( @@ -4670,6 +4775,7 @@ const decodeOktaIntegrationRecord = (result: unknown): OIDCIntegration => { authorizationEndpoint: `${rawRecord.oauth_api_url}/authorize`, oidcUserAccessOnly: rawRecord.oidc_user_access_only, defaultMemberRoleId: rawRecord.default_role_id, + defaultResourceAssignment: rawRecord.default_assigned_resources, }; } @@ -4683,6 +4789,7 @@ const decodeOktaIntegrationRecord = (result: unknown): OIDCIntegration => { authorizationEndpoint: rawRecord.authorization_endpoint, oidcUserAccessOnly: rawRecord.oidc_user_access_only, defaultMemberRoleId: rawRecord.default_role_id, + defaultResourceAssignment: rawRecord.default_assigned_resources, }; }; diff --git a/packages/services/api/src/modules/organization/lib/resource-assignment-model.ts b/packages/services/storage/src/resource-assignment-model.ts similarity index 100% rename from packages/services/api/src/modules/organization/lib/resource-assignment-model.ts rename to packages/services/storage/src/resource-assignment-model.ts diff --git a/packages/web/app/src/components/organization/members/common.tsx b/packages/web/app/src/components/organization/members/common.tsx index 871c861dd51..cb65bc45098 100644 --- a/packages/web/app/src/components/organization/members/common.tsx +++ b/packages/web/app/src/components/organization/members/common.tsx @@ -48,14 +48,19 @@ export function RoleSelector(props: { diff --git a/packages/web/app/src/components/organization/members/oidc-resource-selector.tsx b/packages/web/app/src/components/organization/members/oidc-resource-selector.tsx new file mode 100644 index 00000000000..5610b3fe818 --- /dev/null +++ b/packages/web/app/src/components/organization/members/oidc-resource-selector.tsx @@ -0,0 +1,1031 @@ +import { MouseEvent, useMemo, useState } from 'react'; +import { produce } from 'immer'; +import { ChevronRightIcon, XIcon } from 'lucide-react'; +import { ArrowDownIcon } from '@/components/ui/icon'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Checkbox } from '@/components/v2'; +import { graphql, useFragment, type FragmentType } from '@/gql'; +import * as GraphQLSchema from '@/gql/graphql'; +import { cn } from '@/lib/utils'; + +const OIDCResourceSelector_OrganizationFragment = graphql(` + fragment OIDCResourceSelector_OrganizationFragment on Organization { + id + slug + isAppDeploymentsEnabled + projects: projectsForResourceSelector { + id + slug + type + targets { + id + slug + services + appDeployments + } + } + } +`); + +/** + * This is the `GraphQLSchema.ResourceAssignmentInput` type, but with the slug values for projects and targets included. + */ +export type ResourceSelection = Omit & { + projects: Array< + Omit & { + projectSlug: string; + targets: Omit & { + targets: Array< + GraphQLSchema.TargetResourceAssignmentInput & { + targetSlug: string; + } + >; + }; + } + >; +}; + +/** + * Converts `ResourceSelection` to `GraphQLSchema.ResourceAssignmentInput` for sending to the GraphQL API. + * `ResourceSelection` contains fields such as `projectSlug` and `targetSlug`, which are not within the `GraphQLSchema.ResourceAssignmentInput` + * type, but TypeScript does not catch sending these properties to the API... + */ +export function resourceSlectionToGraphQLSchemaResourceAssignmentInput( + input: ResourceSelection, +): GraphQLSchema.ResourceAssignmentInput { + return { + mode: input.mode, + projects: input.projects.map(project => ({ + projectId: project.projectId, + targets: { + mode: project.targets.mode, + targets: project.targets.targets.map(target => ({ + targetId: target.targetId, + services: target.services, + appDeployments: target.appDeployments, + })), + }, + })), + }; +} + +const enum ServicesAppsState { + service, + apps, +} + +type Project = { + id: string; + slug: string; + type: GraphQLSchema.ProjectType; + targets: Array<{ + id: string; + slug: string; + services: Array; + appDeployments: Array; + }>; +}; + +export function OIDCResourceSelector(props: { + organization: FragmentType; + selection: ResourceSelection; + onSelectionChange: (selection: ResourceSelection) => void; +}) { + const organization = useFragment(OIDCResourceSelector_OrganizationFragment, props.organization); + const [breadcrumb, setBreadcrumb] = useState( + null as + | null + | { projectId: string; targetId?: undefined } + | { projectId: string; targetId: string }, + ); + // whether we show the service or apps in the last tab + const [serviceAppsState, setServiceAppsState] = useState(ServicesAppsState.service); + + const toggleServiceAppsState = (e: MouseEvent) => { + e.preventDefault(); + const state = + serviceAppsState === ServicesAppsState.apps + ? ServicesAppsState.service + : ServicesAppsState.apps; + setServiceAppsState(state); + }; + + /** + * Tracks internal state of the selected projects + * - activeProject is whatever is currently highlighted + * - selected is the list of projects that have been added to the list of resources + * - notSelected is the list of projects still yet to be added + * - add/removeProject add or remove the project from props.selected and call props.onSelectionChange + * + * This gets called as a memo so it can respond to the breadcrumb changing. + * The "mode" is used to be able to keep the selected state as well as toggle between + * granular and all. + * */ + const projectState = useMemo(() => { + if (props.selection.mode === GraphQLSchema.ResourceAssignmentModeType.All) { + return null; + } + + const selectedProjects: Array<{ + project: Project; + projectSelection: GraphQLSchema.ProjectResourceAssignmentInput; + }> = []; + const notSelectedProjects: Array = []; + + let activeProject: null | (typeof selectedProjects)[number] = null; + const projects = organization?.projects?.filter(p => !!p) ?? []; + + for (const project of projects) { + const projectSelection = props.selection.projects?.find( + item => item.projectId === project?.id, + ); + + if (projectSelection) { + const selection = { project, projectSelection }; + selectedProjects.push(selection); + + if (breadcrumb?.projectId === project?.id) { + activeProject = selection; + } + + continue; + } + + notSelectedProjects.push(project); + } + + return { + selected: selectedProjects, + notSelected: notSelectedProjects, + activeProject, + addProject(item: { id: string; slug: string }) { + props.onSelectionChange( + produce(props.selection, state => { + state.projects?.push({ + projectId: item.id, + projectSlug: item.slug, + targets: { + mode: GraphQLSchema.ResourceAssignmentModeType.Granular, + targets: [], + }, + }); + setBreadcrumb({ projectId: item.id }); + }), + ); + }, + removeProject(item: { id: string; slug: string }) { + props.onSelectionChange( + produce(props.selection, state => { + state.projects = state.projects?.filter(project => project.projectId !== item.id); + }), + ); + setBreadcrumb(breadcrumb => { + if (breadcrumb?.projectId === item.id) { + return null; + } + return breadcrumb; + }); + }, + }; + }, [organization?.projects, props.selection, breadcrumb?.projectId]); + + const targetState = useMemo(() => { + if (!organization?.projects || !projectState?.activeProject) { + return null; + } + + const projectId = projectState.activeProject.project.id; + const projectType = projectState.activeProject.project.type; + + if ( + projectState.activeProject.projectSelection.targets.mode === + GraphQLSchema.ResourceAssignmentModeType.All + ) { + return { + selection: '*', + setGranular() { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + project.targets.mode = GraphQLSchema.ResourceAssignmentModeType.Granular; + }), + ); + }, + } as const; + } + + type Target = Omit< + NonNullable[number]>['targets'][number], + '__typename' + >; + type SelectedItem = { + target: Target; + targetSelection: (typeof projectState.activeProject.projectSelection.targets.targets & {})[number]; + }; + + type NotSelectedItem = Target; + + const selected: Array = []; + const notSelected: Array = []; + + let activeTarget: null | { + targetSelection: (typeof projectState.activeProject.projectSelection.targets.targets & {})[number]; + target: Target; + } = null; + + for (const target of projectState.activeProject.project.targets) { + const targetSelection = projectState.activeProject.projectSelection.targets.targets?.find( + item => item.targetId === target.id, + ); + + if (targetSelection) { + selected.push({ target, targetSelection }); + + if (breadcrumb?.targetId === target.id) { + activeTarget = { + targetSelection, + target, + }; + } + continue; + } + + notSelected.push(target); + } + + return { + selection: { + selected, + notSelected, + }, + activeTarget, + activeProject: projectState.activeProject, + addTarget(item: Target) { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects.find(project => project.projectId === projectId); + if (!project) return; + project.targets.targets.push({ + targetId: item.id, + targetSlug: item.slug, + appDeployments: { + mode: GraphQLSchema.ResourceAssignmentModeType.All, + appDeployments: [], + }, + services: { + mode: + // for single projects we choose "All" by default as there is no granular selection available + projectType === GraphQLSchema.ProjectType.Single + ? GraphQLSchema.ResourceAssignmentModeType.All + : GraphQLSchema.ResourceAssignmentModeType.Granular, + services: [], + }, + }); + setBreadcrumb({ projectId: project.projectId, targetId: item.id }); + }), + ); + }, + removeTarget(item: Target) { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + project.targets.targets = project.targets.targets?.filter( + target => target.targetId !== item.id, + ); + }), + ); + setBreadcrumb(breadcrumb => { + if (breadcrumb?.targetId === item.id) { + return { + ...breadcrumb, + targetId: undefined, + }; + } + return breadcrumb; + }); + }, + setAll() { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + project.targets.mode = GraphQLSchema.ResourceAssignmentModeType.All; + }), + ); + setBreadcrumb({ projectId }); + }, + }; + }, [projectState?.activeProject, organization?.projects, breadcrumb?.targetId]); + + const serviceState = useMemo(() => { + if ( + !projectState?.activeProject || + !targetState?.activeTarget || + !breadcrumb?.targetId || + !organization?.projects + ) { + return null; + } + + if (projectState.activeProject.project.type === GraphQLSchema.ProjectType.Single) { + return 'none' as const; + } + + const projectId = projectState.activeProject.projectSelection.projectId; + const targetId = targetState.activeTarget.targetSelection.targetId; + + if ( + targetState.activeTarget.targetSelection.services.mode === + GraphQLSchema.ResourceAssignmentModeType.All + ) { + return { + selection: '*' as const, + setGranular() { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + const target = project.targets.targets?.find(target => target.targetId === targetId); + if (!target) return; + target.services.mode = GraphQLSchema.ResourceAssignmentModeType.Granular; + }), + ); + }, + }; + } + + const selectedServices: GraphQLSchema.ServiceResourceAssignmentInput[] = [ + ...(targetState.activeTarget.targetSelection.services.services ?? []), + ]; + const notSelectedServices: Array = []; + + for (const serviceName of targetState.activeTarget.target.services) { + if ( + // @todo is it critical to check composite schema here?? + // schema.__typename === 'CompositeSchema' && + !selectedServices.find(service => service.serviceName === serviceName) + ) { + notSelectedServices.push(serviceName); + } + } + + return { + selection: { + selected: selectedServices, + notSelected: notSelectedServices, + }, + setAll() { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + const target = project.targets.targets?.find(target => target.targetId === targetId); + + if (!target) return; + target.services.mode = GraphQLSchema.ResourceAssignmentModeType.All; + }), + ); + }, + addService(serviceName: string) { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + const target = project.targets.targets?.find(target => target.targetId === targetId); + if ( + !target || + target.services.services?.find(service => service.serviceName === serviceName) + ) { + return; + } + + target.services.services?.push({ + serviceName, + }); + }), + ); + }, + removeService(serviceName: string) { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + const target = project.targets.targets?.find(target => target.targetId === targetId); + if (!target) { + return; + } + target.services.services = target.services.services?.filter( + service => service.serviceName !== serviceName, + ); + }), + ); + }, + }; + }, [targetState?.activeTarget, breadcrumb, projectState?.activeProject, props.selection]); + + const appsState = useMemo(() => { + if ( + !projectState?.activeProject || + !targetState?.activeTarget || + !breadcrumb?.targetId || + !organization.isAppDeploymentsEnabled + ) { + return null; + } + + const projectId = projectState.activeProject.projectSelection.projectId; + const targetId = targetState.activeTarget.targetSelection.targetId; + + if ( + targetState.activeTarget.targetSelection.appDeployments.mode === + GraphQLSchema.ResourceAssignmentModeType.All + ) { + return { + selection: '*' as const, + setGranular() { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + const target = project.targets.targets?.find(target => target.targetId === targetId); + if (!target) return; + target.appDeployments.mode = GraphQLSchema.ResourceAssignmentModeType.Granular; + }), + ); + }, + }; + } + + const selectedApps: GraphQLSchema.AppDeploymentResourceAssignmentInput[] = [ + ...(targetState.activeTarget.targetSelection.appDeployments.appDeployments ?? []), + ]; + // TODO: populate this with service state + const notSelectedApps: Array = []; + + return { + selection: { + selected: selectedApps, + notSelected: notSelectedApps, + }, + setAll() { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + const target = project.targets.targets?.find(target => target.targetId === targetId); + + if (!target) return; + target.appDeployments.mode = GraphQLSchema.ResourceAssignmentModeType.All; + }), + ); + }, + addApp(appName: string) { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + const target = project.targets.targets?.find(target => target.targetId === targetId); + if ( + !target || + target.appDeployments.appDeployments?.find( + appDeployment => appDeployment.appDeployment === appName, + ) + ) { + return; + } + + target.appDeployments.appDeployments?.push({ + appDeployment: appName, + }); + }), + ); + }, + removeApp(appName: string) { + props.onSelectionChange( + produce(props.selection, state => { + const project = state.projects?.find(project => project.projectId === projectId); + if (!project) return; + const target = project.targets.targets?.find(target => target.targetId === targetId); + if (!target) { + return; + } + target.appDeployments.appDeployments = target.appDeployments.appDeployments?.filter( + appDeployment => appDeployment.appDeployment !== appName, + ); + }), + ); + }, + }; + }, [ + projectState?.activeProject, + targetState?.activeTarget, + breadcrumb?.targetId, + props.selection, + props.onSelectionChange, + ]); + + return ( + + + { + props.onSelectionChange({ + ...props.selection, + mode: GraphQLSchema.ResourceAssignmentModeType.All, + }); + setBreadcrumb(null); + }} + > + Full Access + + { + props.onSelectionChange({ + ...props.selection, + mode: GraphQLSchema.ResourceAssignmentModeType.Granular, + }); + }} + > + Granular Access + + + +

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

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

The permissions are granted on the specified resources.

+
+
+
+ Projects +
+
+
Targets
+ {targetState && ( +
+ All + { + const isChecked = targetState.selection === '*'; + if (isChecked) { + targetState.setGranular(); + } else { + targetState.setAll(); + } + }} + /> +
+ )} +
+
+
+ + {organization.isAppDeploymentsEnabled ? ( + <> + + + ) : ( + <>Services + )} + + {/** Service All / Granular Toggle */} + {serviceState && serviceState !== 'none' && ( +
+ All + { + const isChecked = serviceState.selection === '*'; + if (isChecked) { + serviceState.setGranular(); + } else { + serviceState.setAll(); + } + // expand services area on toggle + setServiceAppsState(ServicesAppsState.service); + }} + /> +
+ )} +
+
+
+
+ {/** 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 +
+ {projectState.notSelected.length ? ( + projectState.notSelected.map(project => ( + projectState.addProject(project)} + /> + )) + ) : ( +
All selected
+ )} +
+ + {/** Targets Content */} +
+ {targetState === null ? ( +
+ Select a project for adjusting the target access. +
+ ) : ( + <> + {targetState.selection === '*' ? ( +
+ Access to all targets of project granted. +
+ ) : ( + <> +
+ access granted +
+ {targetState.selection.selected.length ? ( + targetState.selection.selected.map(selection => ( + { + setBreadcrumb({ + projectId: targetState.activeProject.project.id, + targetId: selection.target.id, + }); + }} + onDelete={() => { + targetState.removeTarget(selection.target); + }} + /> + )) + ) : ( +
None selected
+ )} +
+ Not selected +
+ {targetState.selection.notSelected.length ? ( + targetState.selection.notSelected.map(target => ( + targetState.addTarget(target)} + /> + )) + ) : ( +
All selected
+ )} + + )} + + )} +
+ +
+ {/** Services Content */} + {serviceAppsState === ServicesAppsState.service && ( +
+ {projectState.activeProject?.projectSelection.targets.mode === + GraphQLSchema.ResourceAssignmentModeType.All ? ( +
+ Access to all services of projects targets granted. +
+ ) : serviceState === null ? ( +
+ Select a target for adjusting the service access. +
+ ) : ( + <> + {serviceState === 'none' ? ( +
+ Project is monolithic and has no services. +
+ ) : serviceState.selection === '*' ? ( +
+ Access to all services in target granted. +
+ ) : ( + <> +
+ access granted +
+ {serviceState.selection.selected.length ? ( + serviceState.selection.selected.map(service => ( + serviceState.removeService(service.serviceName)} + /> + )) + ) : ( +
None
+ )} +
+ Not selected +
+ {serviceState.selection.notSelected.map(serviceName => ( + serviceState.addService(serviceName)} + /> + ))} + { + if (ev.key !== 'Enter') { + return; + } + ev.preventDefault(); + const input: HTMLInputElement = ev.currentTarget; + const serviceName = input.value.trim().toLowerCase(); + + if (!serviceName) { + return; + } + + serviceState.addService(serviceName); + input.value = ''; + }} + /> + + )} + + )} +
+ )} + + {/** Apps Content */} + {organization.isAppDeploymentsEnabled ? ( +
+
+ + {/** Apps All / Granular Toggle */} + {appsState && ( +
+ All + { + const isChecked = appsState.selection === '*'; + if (isChecked) { + appsState.setGranular(); + } else { + appsState.setAll(); + } + // expand apps area on toggle + setServiceAppsState(ServicesAppsState.apps); + }} + /> +
+ )} +
+
+ ) : null} + {serviceAppsState === ServicesAppsState.apps && ( +
+ {projectState.activeProject?.projectSelection.targets.mode === + GraphQLSchema.ResourceAssignmentModeType.All ? ( +
+ Access to all apps of projects targets granted. +
+ ) : appsState === null ? ( +
+ Select a target for adjusting the apps access. +
+ ) : ( + <> + {appsState.selection === '*' ? ( +
+ Access to all apps in target granted. +
+ ) : ( + <> +
+ access granted +
+ {appsState.selection.selected.length ? ( + appsState.selection.selected.map(app => ( + appsState.removeApp(app.appDeployment)} + /> + )) + ) : ( +
None
+ )} +
+ Not selected +
+ {appsState.selection.notSelected.map(serviceName => ( + appsState.addApp(serviceName)} + /> + ))} + { + if (ev.key !== 'Enter') { + return; + } + ev.preventDefault(); + const input: HTMLInputElement = ev.currentTarget; + const appName = input.value.trim().toLowerCase(); + + if (!appName) { + return; + } + + appsState.addApp(appName); + input.value = ''; + }} + /> + + )} + + )} +
+ )} +
+
+
+
+ {projectState.activeProject && ( + <> + {' '} + {targetState?.activeTarget && ( + <> + {targetState.activeTarget.target.slug} + + )} + + )} +
+ + )} +
+
+ ); +} + +function RowItem(props: { + title: string; + isActive?: boolean; + onClick?: () => void; + onDelete?: () => void; +}) { + return ( +
+ + {props.title} {props.isActive && } + + + {props.onDelete && ( + + + + + + Remove + + + )} +
+ ); +} diff --git a/packages/web/app/src/components/organization/settings/oidc-integration-section.tsx b/packages/web/app/src/components/organization/settings/oidc-integration-section.tsx index b0cb94f6a30..408f517e7c8 100644 --- a/packages/web/app/src/components/organization/settings/oidc-integration-section.tsx +++ b/packages/web/app/src/components/organization/settings/oidc-integration-section.tsx @@ -1,9 +1,10 @@ -import { ReactElement, useEffect, useRef } from 'react'; +import { ReactElement, useCallback, useEffect, useRef, useState } from 'react'; import { format } from 'date-fns'; import { useFormik } from 'formik'; import { useForm } from 'react-hook-form'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { useClient, useMutation } from 'urql'; +import { useDebouncedCallback } from 'use-debounce'; import { z } from 'zod'; import { Button, buttonVariants } from '@/components/ui/button'; import { @@ -22,15 +23,17 @@ import { FormItem, FormMessage, } from '@/components/ui/form'; -import { AlertTriangleIcon, KeyIcon } from '@/components/ui/icon'; +import { AlertTriangleIcon, CheckIcon, KeyIcon, XIcon } from '@/components/ui/icon'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; +import { Spinner } from '@/components/ui/spinner'; import { Switch } from '@/components/ui/switch'; import { useToast } from '@/components/ui/use-toast'; import { Tag } from '@/components/v2'; import { env } from '@/env/frontend'; import { DocumentType, FragmentType, graphql, useFragment } from '@/gql'; +import * as GraphQLSchema from '@/gql/graphql'; import { useClipboard } from '@/lib/hooks'; import { useResetState } from '@/lib/hooks/use-reset-state'; import { cn } from '@/lib/utils'; @@ -38,6 +41,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation as useRQMutation } from '@tanstack/react-query'; import { Link, useRouter } from '@tanstack/react-router'; import { RoleSelector } from '../members/common'; +import { OIDCResourceSelector } from '../members/oidc-resource-selector'; +import { + ResourceSelection, + resourceSlectionToGraphQLSchemaResourceAssignmentInput, +} from '../members/resource-selector'; function CopyInput(props: { value: string; id?: string }) { const copy = useClipboard(); @@ -71,6 +79,12 @@ function FormError({ children }: { children: React.ReactNode }) { const OIDCIntegrationSection_OrganizationFragment = graphql(` fragment OIDCIntegrationSection_OrganizationFragment on Organization { id + slug + availableOrganizationAccessTokenPermissionGroups { + ...PermissionSelector_PermissionGroupsFragment + ...SelectedPermissionOverview_PermissionGroupFragment + } + ...OIDCResourceSelector_OrganizationFragment oidcIntegration { id ...UpdateOIDCIntegration_OIDCIntegrationFragment @@ -170,6 +184,7 @@ export function OIDCIntegrationSection(props: { key={organization.oidcIntegration?.id ?? 'noop'} isAdmin={isAdmin} organizationId={organization.id} + organization={organization} oidcIntegration={organization.oidcIntegration ?? null} memberRoles={organization.memberRoles?.edges.map(edge => edge.node) ?? null} isOpen={isUpdateOIDCIntegrationModalOpen} @@ -571,6 +586,7 @@ function ManageOIDCIntegrationModal(props: { openCreateModalHash: string; oidcIntegration: FragmentType | null; memberRoles: Array> | null; + organization: DocumentType; }) { const oidcIntegration = useFragment( UpdateOIDCIntegration_OIDCIntegrationFragment, @@ -612,8 +628,9 @@ function ManageOIDCIntegrationModal(props: { isOpen={props.isOpen} isAdmin={props.isAdmin} key={oidcIntegration.id} - oidcIntegration={oidcIntegration} memberRoles={props.memberRoles} + oidcIntegration={oidcIntegration} + organization={props.organization} /> ); } @@ -644,11 +661,32 @@ const OIDCDefaultRoleSelector_UpdateMutation = graphql(` } `); +const OIDCDefaultResourceSelector_UpdateMutation = graphql(` + mutation OIDCDefaultResourceSelector_UpdateMutation( + $input: UpdateOIDCDefaultResourceAssignmentInput! + ) { + updateOIDCDefaultResourceAssignment(input: $input) { + ok { + updatedOIDCIntegration { + id + defaultResourceAssignment { + ...OIDCDefaultResourceSelector_ResourceAssignmentFragment + } + } + } + error { + message + } + } + } +`); + function OIDCDefaultRoleSelector(props: { oidcIntegrationId: string; disabled: boolean; defaultRole: FragmentType; memberRoles: Array>; + className?: string; }) { const defaultRole = useFragment(OIDCDefaultRoleSelector_MemberRoleFragment, props.defaultRole); const memberRoles = useFragment(OIDCDefaultRoleSelector_MemberRoleFragment, props.memberRoles); @@ -657,6 +695,7 @@ function OIDCDefaultRoleSelector(props: { return ( ; + resourceAssignment: FragmentType; +}) { + const resourceAssignment = useFragment( + OIDCDefaultResourceSelector_ResourceAssignmentFragment, + props.resourceAssignment, + ); + const [_, mutate] = useMutation(OIDCDefaultResourceSelector_UpdateMutation); + + const [selection, setSelection] = useState(() => ({ + mode: resourceAssignment.mode ?? GraphQLSchema.ResourceAssignmentModeType.All, + projects: (resourceAssignment.projects ?? []).map(record => ({ + projectId: record.project.id, + projectSlug: record.project.slug, + targets: { + mode: record.targets.mode, + targets: (record.targets.targets ?? []).map(target => ({ + targetId: target.target.id, + targetSlug: target.target.slug, + services: { + mode: target.services.mode, + services: target.services.services?.map( + (service): GraphQLSchema.ServiceResourceAssignmentInput => ({ + serviceName: service, + }), + ), + }, + appDeployments: { + mode: target.appDeployments.mode, + appDeployments: target.appDeployments.appDeployments?.map( + (appDeploymentName): GraphQLSchema.AppDeploymentResourceAssignmentInput => ({ + appDeployment: appDeploymentName, + }), + ), + }, + })), + }, + })), + })); + + const [mutateState, setMutateState] = useState(null); + const debouncedMutate = useDebouncedCallback( + async (args: Parameters[0]) => { + setMutateState('loading'); + await mutate(args) + .then(data => { + if (data.error) { + setMutateState('error'); + } else { + setMutateState('success'); + } + return data; + }) + .catch((err: unknown) => { + console.error(err); + setMutateState('error'); + }); + }, + 1500, + { leading: false }, + ); + + const _setSelection = useCallback( + async (resources: ResourceSelection) => { + setSelection(resources); + await debouncedMutate({ + input: { + oidcIntegrationId: props.oidcIntegrationId, + resources: resourceSlectionToGraphQLSchemaResourceAssignmentInput(resources), + }, + }); + }, + [debouncedMutate, setSelection, props.oidcIntegrationId], + ); + + function MutateState() { + if (debouncedMutate.isPending() || mutateState === 'loading') { + return ; + } + if (mutateState === 'error') { + return ; + } + if (mutateState === 'success') { + return ; + } + return null; + } + + return ( +
+ + void 0 : _setSelection} + organization={props.organization} + /> +
+ ); +} + const UpdateOIDCIntegration_OIDCIntegrationFragment = graphql(` fragment UpdateOIDCIntegration_OIDCIntegrationFragment on OIDCIntegration { id @@ -715,6 +886,9 @@ const UpdateOIDCIntegration_OIDCIntegrationFragment = graphql(` id ...OIDCDefaultRoleSelector_MemberRoleFragment } + defaultResourceAssignment { + ...OIDCDefaultResourceSelector_ResourceAssignmentFragment + } } `); @@ -771,6 +945,7 @@ function UpdateOIDCIntegrationForm(props: { oidcIntegration: DocumentType; isAdmin: boolean; memberRoles: Array>; + organization: DocumentType; }): ReactElement { const [oidcUpdateMutation, oidcUpdateMutate] = useMutation( UpdateOIDCIntegrationForm_UpdateOIDCIntegrationMutation, @@ -857,10 +1032,10 @@ function UpdateOIDCIntegrationForm(props: { return ( - -
-
-
+ +
+
+
@@ -915,36 +1090,42 @@ function UpdateOIDCIntegrationForm(props: {
-
-

Default Member Role

-

- This role is assigned to new members who sign in via OIDC.{' '} - - Only members with the Admin role can modify it. - -

+

Default Member Role

+
+
+

+ This role is assigned to new members who sign in via OIDC.{' '} + + Only members with the Admin role can modify it. + +

+
+
+ +
-
- -
Properties
@@ -1032,7 +1213,7 @@ function UpdateOIDCIntegrationForm(props: { {oidcUpdateMutation.data?.updateOIDCIntegration.error?.details.clientSecret}
-
+
+
+

Default Resource Assignments

+

+ This permitted resources for new members who sign in via OIDC.{' '} + Only members with the Admin role can modify it. +

+
+
+ +
diff --git a/packages/web/app/src/lib/urql.ts b/packages/web/app/src/lib/urql.ts index 194f09a6aec..68559bef22a 100644 --- a/packages/web/app/src/lib/urql.ts +++ b/packages/web/app/src/lib/urql.ts @@ -82,6 +82,13 @@ export const urqlClient = createClient({ FilterStringOption: noKey, FilterBooleanOption: noKey, TracesFilterOptions: noKey, + ResourceAssignment: noKey, + TargetServicesResourceAssignment: noKey, + TargetAppDeploymentsResourceAssignment: noKey, + TargetResouceAssignment: noKey, + ProjectTargetsResourceAssignment: noKey, + ProjectResourceAssignment: noKey, + BillingConfiguration: noKey, }, globalIDs: ['SuccessfulSchemaCheck', 'FailedSchemaCheck'], }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b90987eea5b..fdaafbc332e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17607,8 +17607,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -17715,11 +17715,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0': + '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -17758,6 +17758,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -17891,11 +17892,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': + '@aws-sdk/client-sts@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -17934,7 +17935,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -18048,7 +18048,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -18167,7 +18167,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -18342,7 +18342,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12