From 35cfca6982488e002a4f96d7594dfd5e8fcb4745 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 10 Feb 2025 11:42:45 +0100 Subject: [PATCH 01/33] feat: organization access tokens --- ....30T00-02-03.organization-access-tokens.ts | 23 + packages/migrations/src/run-pg-migrations.ts | 1 + .../api/src/modules/auth/lib/authz.ts | 2 +- .../auth/lib/target-access-token-strategy.ts | 1 - .../lib/organization-access-key.ts | 68 +++ .../organization-access-token-permissions.ts | 71 +++ .../lib/organization-member-permissions.ts | 17 +- .../modules/organization/lib/permissions.ts | 16 + .../modules/organization/module.graphql.ts | 82 ++++ .../providers/organization-access-tokens.ts | 165 +++++++ .../providers/organization-manager.ts | 4 +- .../providers/organization-members.ts | 461 +---------------- .../providers/resource-assignments.ts | 464 ++++++++++++++++++ .../modules/organization/resolvers/Member.ts | 7 +- .../Mutation/createOrganizationAccessToken.ts | 7 + .../Mutation/deleteOrganizationAccessToken.ts | 7 + .../Mutation/updateOrganizationAccessToken.ts | 7 + .../schema/providers/schema-manager.ts | 3 +- 18 files changed, 928 insertions(+), 478 deletions(-) create mode 100644 packages/migrations/src/actions/2025.01.30T00-02-03.organization-access-tokens.ts create mode 100644 packages/services/api/src/modules/organization/lib/organization-access-key.ts create mode 100644 packages/services/api/src/modules/organization/lib/organization-access-token-permissions.ts create mode 100644 packages/services/api/src/modules/organization/lib/permissions.ts create mode 100644 packages/services/api/src/modules/organization/providers/organization-access-tokens.ts create mode 100644 packages/services/api/src/modules/organization/providers/resource-assignments.ts create mode 100644 packages/services/api/src/modules/organization/resolvers/Mutation/createOrganizationAccessToken.ts create mode 100644 packages/services/api/src/modules/organization/resolvers/Mutation/deleteOrganizationAccessToken.ts create mode 100644 packages/services/api/src/modules/organization/resolvers/Mutation/updateOrganizationAccessToken.ts diff --git a/packages/migrations/src/actions/2025.01.30T00-02-03.organization-access-tokens.ts b/packages/migrations/src/actions/2025.01.30T00-02-03.organization-access-tokens.ts new file mode 100644 index 0000000000..5ae253fce6 --- /dev/null +++ b/packages/migrations/src/actions/2025.01.30T00-02-03.organization-access-tokens.ts @@ -0,0 +1,23 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2025.01.30T00-02-03.organization-access-tokens.ts', + run: ({ sql }) => sql` + CREATE TABLE IF NOT EXISTS "organization_access_tokens" ( + "id" UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4() + , "organization_id" UUID NOT NULL REFERENCES "organizations" ("id") ON DELETE CASCADE + , "created_at" timestamptz NOT NULL DEFAULT now() + , "title" text NOT NULL + , "description" text NOT NULL + , "permissions" text[] NOT NULL + , "assigned_resources" jsonb + , "hash" text + , "first_characters" text + ); + + CREATE INDEX IF NOT EXISTS "organization_access_tokens_organization_id" ON "organization_access_tokens" ( + "organization_id" + , "created_at" DESC + ); + `, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index 1646ba07d5..4d16e87f7c 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -158,5 +158,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri await import('./actions/2025.01.17T10-08-00.drop-activities'), await import('./actions/2025.01.20T00-00-00.legacy-registry-model-removal'), await import('./actions/2025.01.30T00-00-00.granular-member-role-permissions'), + await import('./actions/2025.01.30T00-02-03.organization-access-tokens'), ], }); diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index 50427a7c94..771250ab3c 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -366,8 +366,8 @@ const permissionsByLevel = { z.literal('laboratory:describe'), z.literal('laboratory:modify'), z.literal('laboratory:modifyPreflightScript'), - z.literal('schema:loadFromRegistry'), z.literal('schema:compose'), + z.literal('usage:report'), ], service: [ z.literal('schemaCheck:create'), 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 b011779c32..1941418da2 100644 --- a/packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts @@ -177,7 +177,6 @@ function transformAccessTokenLegacyScopes(args: { 'appDeployment:retire', 'schemaVersion:publish', 'schemaVersion:deleteService', - 'schema:loadFromRegistry', 'schemaVersion:publish', ], resource: [`hrn:${args.organizationId}:target/${args.targetId}`], diff --git a/packages/services/api/src/modules/organization/lib/organization-access-key.ts b/packages/services/api/src/modules/organization/lib/organization-access-key.ts new file mode 100644 index 0000000000..83e51bfd24 --- /dev/null +++ b/packages/services/api/src/modules/organization/lib/organization-access-key.ts @@ -0,0 +1,68 @@ +import * as Crypto from 'crypto'; +import bcrypt from 'bcryptjs'; + +/** + * @module OrganizationAccessKey + * Contains functions for generating an organization acces key. + */ + +/** + * Prefix for the access key + * **hv** -> Hive + * **o** -> Organization + * **1** -> Version 1 + */ +const keyPrefix = 'hvo1/'; +const decodeError = { type: 'failure', reason: 'Invalid access token.' } as const; + +function encode(recordId: string, secret: string) { + const keyContents = [recordId, secret].join(':'); + return keyPrefix + btoa(keyContents); +} + +export function decode(accessToken: string) { + if (!accessToken.startsWith(keyPrefix)) { + return decodeError; + } + + accessToken = accessToken.slice(keyPrefix.length); + + let str: string; + + try { + str = globalThis.atob(accessToken); + } catch (error) { + return decodeError; + } + + const parts = str.split(':'); + + if (parts.length > 2) { + return decodeError; + } + + const accessTokenRecordId = parts.at(0); + const privateKey = parts.at(1); + + if (accessTokenRecordId && privateKey) { + return { type: 'success', token: { accessTokenRecordId, privateKey } } as const; + } + + return decodeError; +} + +export async function create(recordId: string) { + const secret = Crypto.createHash('sha256') + .update(Crypto.randomBytes(20).toString()) + .digest('hex'); + + const hash = await bcrypt.hash(secret, await bcrypt.genSalt()); + const privateAccessToken = encode(recordId, secret); + const firstCharacters = privateAccessToken.substr(0, 10); + + return { + privateAccessToken, + hash, + firstCharacters, + }; +} 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 new file mode 100644 index 0000000000..ff10d1df63 --- /dev/null +++ b/packages/services/api/src/modules/organization/lib/organization-access-token-permissions.ts @@ -0,0 +1,71 @@ +import { allPermissions, type Permission } from '../../auth/lib/authz'; +import { type PermissionGroup } from './permissions'; + +export const allPermissionGroups: Array = [ + { + id: 'organization', + title: 'Organization', + permissions: [ + { + id: 'organization:describe', + title: 'View organization', + description: 'Member can see the organization. Permission can not be modified.', + isReadyOnly: true, + }, + ], + }, + { + id: 'organization', + title: 'Organization', + permissions: [ + { + id: 'project:describe', + title: 'View project', + description: 'Member can access the specified projects.', + }, + ], + }, + { + id: 'schema-checks', + title: 'Schema Checks', + permissions: [ + { + id: 'schemaCheck:create', + title: 'Create schema checks', + description: 'Grant access to performing schema checks.', + }, + ], + }, + { + id: 'services', + title: 'Schema Registry', + permissions: [ + { + id: 'schemaCheck:create', + title: 'Publish schema/service/subgraph', + description: 'Grant access to publish services/schemas.', + }, + { + id: 'schemaCheck:create', + title: 'Delete service', + description: 'Grant access to deleting services.', + }, + ], + }, + { + id: 'app-deployments', + title: 'App Deployments', + permissions: [ + { + id: 'appDeployment:create', + title: 'Create app deployment', + description: 'Grant access to creating app deployments.', + }, + { + id: 'appDeployment:publish', + title: 'Publish app deployment', + description: 'Grant access to publishing 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 92ce201bdb..8cd89bfbc7 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 @@ -1,19 +1,5 @@ import { allPermissions, Permission } from '../../auth/lib/authz'; - -export type PermissionRecord = { - id: Permission; - title: string; - description: string; - dependsOn?: Permission; - isReadyOnly?: true; - warning?: string; -}; - -export type PermissionGroup = { - id: string; - title: string; - permissions: Array; -}; +import { PermissionGroup } from './permissions'; export const allPermissionGroups: Array = [ { @@ -272,7 +258,6 @@ function assertAllRulesAreAssigned(excluded: Array) { */ assertAllRulesAreAssigned([ /** These are CLI only actions for now. */ - 'schema:loadFromRegistry', 'schema:compose', 'schemaCheck:create', 'schemaVersion:publish', diff --git a/packages/services/api/src/modules/organization/lib/permissions.ts b/packages/services/api/src/modules/organization/lib/permissions.ts new file mode 100644 index 0000000000..0062cdfbd1 --- /dev/null +++ b/packages/services/api/src/modules/organization/lib/permissions.ts @@ -0,0 +1,16 @@ +import type { Permission } from '../../auth/lib/authz'; + +export type PermissionRecord = { + id: Permission; + title: string; + description: string; + dependsOn?: Permission; + isReadyOnly?: true; + warning?: string; +}; + +export type PermissionGroup = { + id: string; + title: string; + permissions: Array; +}; diff --git a/packages/services/api/src/modules/organization/module.graphql.ts b/packages/services/api/src/modules/organization/module.graphql.ts index 8117f10a8e..ce94661e84 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -35,6 +35,88 @@ export default gql` updateMemberRole(input: UpdateMemberRoleInput!): UpdateMemberRoleResult! deleteMemberRole(input: DeleteMemberRoleInput!): DeleteMemberRoleResult! assignMemberRole(input: AssignMemberRoleInput!): AssignMemberRoleResult! + createOrganizationAccessToken( + input: CreateOrganizationAccessTokenInput! + ): CreateOrganizationAccessTokenResult! + updateOrganizationAccessToken( + input: CreateOrganizationAccessTokenInput! + ): CreateOrganizationAccessTokenResult! + deleteOrganizationAccessToken( + input: DeleteOrganizationAccessTokenInput! + ): DeleteOrganizationAccessTokenResult! + } + + input OrganizationReferenceInput @oneOf { + byOrganizationSlug: String + byOrganizationId: ID + } + + input CreateOrganizationAccessTokenInput { + organization: OrganizationReferenceInput! + title: String! + description: String + permissions: [String!]! + resources: ResourceAssignmentInput! + } + + type CreateOrganizationAccessTokenResult { + ok: CreateOrganizationAccessTokenResultOk + error: CreateOrganizationAccessTokenResultError + } + + type CreateOrganizationAccessTokenResultOk { + createdOrganizationAccessToken: OrganizationAccessToken! + } + + type CreateOrganizationAccessTokenResultError implements Error { + message: String! + } + + type OrganizationAccessToken { + id: ID! + title: String! + description: String + permissions: [String!]! + resources: ResourceAssignment! + createdAt: Date! + } + + input UpdateOrganizationAccessTokenInput { + organizationAccessTokenId: ID! + title: String + description: String + permissions: [String!] + resources: ResourceAssignmentInput + } + + type UpdateOrganizationAccessTokenResult { + ok: UpdateOrganizationAccessTokenResultOk + error: UpdateOrganizationAccessTokenResultError + } + + type UpdateOrganizationAccessTokenResultOk { + updatedOrganizationAccessToken: OrganizationAccessToken! + } + + type UpdateOrganizationAccessTokenResultError implements Error { + message: String! + } + + input DeleteOrganizationAccessTokenInput { + organizationAccessTokenId: ID! + } + + type DeleteOrganizationAccessTokenResult { + ok: DeleteOrganizationAccessTokenResultOk + error: DeleteOrganizationAccessTokenResultError + } + + type DeleteOrganizationAccessTokenResultOk { + deletedOrganizationAccessTokenId: ID! + } + + type DeleteOrganizationAccessTokenResultError implements Error { + message: String! } type UpdateOrganizationSlugResult { 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 new file mode 100644 index 0000000000..496383cc88 --- /dev/null +++ b/packages/services/api/src/modules/organization/providers/organization-access-tokens.ts @@ -0,0 +1,165 @@ +import { Inject, Injectable, Scope } from 'graphql-modules'; +import { DatabasePool, sql } from 'slonik'; +import { z } from 'zod'; +import { Organization } from '@hive/api'; +import { PermissionsModel } from '../../auth/lib/authz'; +import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; +import * as OrganizationAccessKey from '../lib/organization-access-key'; +import { AssignedProjectsModel, ResourceAssignmentGroup } from './resource-assignments'; + +// TODO: specify characters +const TitleInputModel = z + .string() + .min(2, 'Minimum length is 2 characters.') + .max(100, 'Maximum length is 100 characters.'); + +// TODO: specify characters +const DescriptionInputModel = z + .string() + .min(2, 'Minimum length is 2 characters.') + .max(100, 'Maximum length is 100 characters.') + .nullable(); + +const OrganizationAccessTokenModel = z.object({ + id: z.string().uuid(), + organizationId: z.string().uuid(), + createdAt: z.string(), + title: z.string(), + description: z.string(), + permissions: z.array(PermissionsModel).nullable(), + assignedResources: AssignedProjectsModel.nullable().transform( + value => value ?? { mode: '*' as const, projects: [] }, + ), +}); + +type OrganizationAccessKeyRecord = z.TypeOf; + +export class OrganizationAccessTokens { + constructor(@Inject(PG_POOL_CONFIG) private pool: DatabasePool) {} + + async create(args: { + organizationId: string; + title: string; + description: string; + permissions: Array; + assignedResources: ResourceAssignmentGroup; + }) { + const titleResult = TitleInputModel.safeParse(args.title.trim()); + const descriptionResult = DescriptionInputModel.safeParse(args.description.trim()); + + if (titleResult.error || descriptionResult.error) { + return { + type: 'error' as const, + message: 'Invalid input provided.', + details: { + title: titleResult.error?.issues.at(0)?.message ?? null, + description: descriptionResult.error?.issues.at(0)?.message ?? null, + }, + }; + } + + // TODO: validate permissions + // TODO: validate assigned resources + + const id = crypto.randomUUID(); + const accessKey = await OrganizationAccessKey.create(id); + + const result = await this.pool.maybeOne(sql` + INSERT INTO "organization_access_tokens" ( + "id" + , "organization_id" + , "title" + , "description" + , "permissions" + , "assigned_resources" + , "hash" + , "first_characters" + ) + VALUES ( + ${id} + , ${args.organizationId} + , ${titleResult.data} + , ${descriptionResult.data} + , ${sql.array(args.permissions, 'text')}, + , ${sql.jsonb(args.assignedResources)} + , ${accessKey.hash} + , ${accessKey.firstCharacters} + ) + RETURNING + ${organizationAccessTokenFields} + `); + + const organizationAccessToken = OrganizationAccessTokenModel.parse(result); + + return { + type: 'success' as const, + organizationAccessToken, + privateAccessKey: accessKey.privateAccessToken, + }; + } + + async update(args: { + organizationAccessTokenId: string; + title: string | null; + permissions: Array | null; + assignedResources: ResourceAssignmentGroup | null; + }) { + const titleResult = TitleInputModel.nullable().safeParse(args.title?.trim()); + const descriptionResult = DescriptionInputModel.nullable().safeParse(args.description?.trim()); + + if (titleResult.error || descriptionResult.error) { + return { + type: 'error' as const, + message: 'Invalid input provided.', + details: { + title: titleResult.error?.issues.at(0)?.message ?? null, + description: descriptionResult.error?.issues.at(0)?.message ?? null, + }, + }; + } + + const result = await this.pool.maybeOne(sql` + UPDATE + "organization_access_tokens" + SET + "title" = COALESCE(${titleResult.data}, "title") + , "description" = COALESCE(${descriptionResult.data}, "description") + , "permissions" = COALESCE(${args.permissions}, "permissions") + , "assigned_resources" = COALESCE(${sql.jsonb(args.assignedResources)}, "permissions") + ) + WHERE + "id" = ${args.organizationAccessTokenId} + RETURNING + ${organizationAccessTokenFields} + `); + + return { + type: 'success', + organizationAccessToken: OrganizationAccessTokenModel.parse(result), + }; + } + + async delete(args: { organizationAccessTokenId: string }) { + await this.pool.query(sql` + DELETE + FROM + "organization_access_tokens" + WHERE + "id" = ${args.organizationAccessTokenId} + `); + + return { + type: 'success' as const, + }; + } +} + +const organizationAccessTokenFields = sql` + "id" + , "organization_id" AS "organizationId" + , to_json("created_at") AS "createdAt" + , "title" + , "description" + , "permissions" + , "assigned_resources" AS "assignedResources" +`; diff --git a/packages/services/api/src/modules/organization/providers/organization-manager.ts b/packages/services/api/src/modules/organization/providers/organization-manager.ts index 40271ecd8e..e6d82c4180 100644 --- a/packages/services/api/src/modules/organization/providers/organization-manager.ts +++ b/packages/services/api/src/modules/organization/providers/organization-manager.ts @@ -20,6 +20,7 @@ import { createOrUpdateMemberRoleInputSchema } from '../validation'; import { reservedOrganizationSlugs } from './organization-config'; import { OrganizationMemberRoles, type OrganizationMemberRole } from './organization-member-roles'; import { OrganizationMembers } from './organization-members'; +import { ResourceAssignments } from './resource-assignments'; /** * Responsible for auth checks. @@ -44,6 +45,7 @@ export class OrganizationManager { private emails: Emails, private organizationMemberRoles: OrganizationMemberRoles, private organizationMembers: OrganizationMembers, + private resourceAssignments: ResourceAssignments, @Inject(WEB_APP_URL) private appBaseUrl: string, private idTranslator: IdTranslator, ) { @@ -1002,7 +1004,7 @@ export class OrganizationManager { } const resourceAssignmentGroup = - await this.organizationMembers.transformGraphQLMemberResourceAssignmentInputToResourceAssignmentGroup( + await this.resourceAssignments.transformGraphQLResourceAssignmentInputToResourceAssignmentGroup( organization, input.resources, ); 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 85e7abac85..1971b33591 100644 --- a/packages/services/api/src/modules/organization/providers/organization-members.ts +++ b/packages/services/api/src/modules/organization/providers/organization-members.ts @@ -1,92 +1,17 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; import { sql, type DatabasePool } from 'slonik'; import { z } from 'zod'; -import * as GraphQLSchema from '../../../__generated__/types'; import { type Organization, type Project } from '../../../shared/entities'; import { batchBy } from '../../../shared/helpers'; -import { isUUID } from '../../../shared/is-uuid'; -import { AppDeploymentNameModel } from '../../app-deployments/providers/app-deployments'; import { Logger } from '../../shared/providers/logger'; import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; -import { Storage } from '../../shared/providers/storage'; import { OrganizationMemberRoles, type OrganizationMemberRole } from './organization-member-roles'; - -const WildcardAssignmentModeModel = z.literal('*'); -const GranularAssignmentModeModel = z.literal('granular'); - -const WildcardAssignmentMode = z.object({ - mode: WildcardAssignmentModeModel, -}); - -const AppDeploymentAssignmentModel = z.object({ - type: z.literal('appDeployment'), - appName: z.string(), -}); - -const ServiceAssignmentModel = z.object({ type: z.literal('service'), serviceName: z.string() }); - -const AssignedServicesModel = z.union([ - z.object({ - mode: GranularAssignmentModeModel, - services: z - .array(ServiceAssignmentModel) - .optional() - .nullable() - .transform(value => value ?? []), - }), - WildcardAssignmentMode, -]); - -const AssignedAppDeploymentsModel = z.union([ - z.object({ - mode: GranularAssignmentModeModel, - appDeployments: z.array(AppDeploymentAssignmentModel), - }), - WildcardAssignmentMode, -]); - -const TargetAssignmentModel = z.object({ - type: z.literal('target'), - id: z.string().uuid(), - services: AssignedServicesModel, - appDeployments: AssignedAppDeploymentsModel, -}); - -const AssignedTargetsModel = z.union([ - z.object({ - mode: GranularAssignmentModeModel, - targets: z.array(TargetAssignmentModel), - }), - WildcardAssignmentMode, -]); - -const ProjectAssignmentModel = z.object({ - type: z.literal('project'), - id: z.string().uuid(), - targets: AssignedTargetsModel, -}); - -const GranularAssignedProjectsModel = z.object({ - mode: GranularAssignmentModeModel, - projects: z.array(ProjectAssignmentModel), -}); - -/** - * Tree data structure that represents the resources assigned to an organization member. - * - * Together with the assigned member role, these are used to determine whether a user is allowed - * or not allowed to perform an action on a specific resource (project, target, service, or app deployment). - * - * If no resources are assigned to a member role, the permissions are granted on all the resources within the - * organization. - */ -const AssignedProjectsModel = z.union([GranularAssignedProjectsModel, WildcardAssignmentMode]); - -/** - * Resource assignments as stored within the database. - */ -type ResourceAssignmentGroup = z.TypeOf; -type GranularAssignedProjects = z.TypeOf; +import { + AssignedProjectsModel, + ResolvedResourceAssignments, + resolveResourceAssignment, + ResourceAssignmentGroup, +} from './resource-assignments'; const RawOrganizationMembershipModel = z.object({ userId: z.string(), @@ -135,7 +60,6 @@ export class OrganizationMembers { constructor( @Inject(PG_POOL_CONFIG) private pool: DatabasePool, private organizationMemberRoles: OrganizationMemberRoles, - private storage: Storage, logger: Logger, ) { this.logger = logger.child({ @@ -321,235 +245,6 @@ export class OrganizationMembers { `, ); } - - /** - * This method translates the database stored member resource assignment to the GraphQL layer - * exposed resource assignment. - * - * Note: This currently by-passes access checks, granting the viewer read access to all resources - * within the organization. - */ - async resolveGraphQLMemberResourceAssignment( - member: OrganizationMembership, - ): Promise { - if (member.assignedRole.resources.mode === '*') { - return { mode: 'all' }; - } - const projects = await this.storage.findProjectsByIds({ - projectIds: member.assignedRole.resources.projects.map(project => project.id), - }); - - const filteredProjects = member.assignedRole.resources.projects.filter(row => - projects.get(row.id), - ); - - const targetAssignments = filteredProjects.flatMap(project => - project.targets.mode === 'granular' ? project.targets.targets : [], - ); - - const targets = await this.storage.findTargetsByIds({ - organizationId: member.organizationId, - targetIds: targetAssignments.map(target => target.id), - }); - - return { - mode: 'granular' as const, - projects: filteredProjects - .map(projectAssignment => { - const project = projects.get(projectAssignment.id); - if (!project || project.orgId !== member.organizationId) { - return null; - } - - return { - projectId: project.id, - project, - targets: - projectAssignment.targets.mode === '*' - ? { mode: 'all' as const } - : { - mode: 'granular' as const, - targets: projectAssignment.targets.targets - .map(targetAssignment => { - const target = targets.get(targetAssignment.id); - if (!target) return null; - - return { - targetId: target.id, - target, - services: - targetAssignment.services.mode === '*' - ? { mode: 'all' as const } - : { - mode: 'granular' as const, - services: targetAssignment.services.services.map( - service => service.serviceName, - ), - }, - appDeployments: - targetAssignment.appDeployments.mode === '*' - ? { mode: 'all' as const } - : { - mode: 'granular' as const, - appDeployments: - targetAssignment.appDeployments.appDeployments.map( - deployment => deployment.appName, - ), - }, - }; - }) - .filter(isSome), - }, - }; - }) - .filter(isSome), - }; - } - - /** - * Transforms and resolves a {GraphQL.MemberResourceAssignmentInput} to a {ResourceAssignmentGroup} - * that can be stored within our database - * - * - Projects and Targets that can not be found in our database are omitted from the resolved object. - * - Projects and Targets that do not follow the hierarchical structure are omitted from teh resolved object. - * - * These measures are done in order to prevent users to grant access to other organizations. - */ - async transformGraphQLMemberResourceAssignmentInputToResourceAssignmentGroup( - organization: Organization, - input: GraphQLSchema.ResourceAssignmentInput, - ): Promise { - if ( - !input.projects || - // No need to resolve the projects if mode "all" is used. - // We will not store the selection in the database. - input.mode === 'all' - ) { - return { - mode: '*', - }; - } - - /** Mutable array that we populate with the resolved data from the database */ - const resourceAssignmentGroup: GranularAssignedProjects = { - mode: 'granular', - projects: [], - }; - - const sanitizedProjects = input.projects.filter(project => isUUID(project.projectId)); - - const projects = await this.storage.findProjectsByIds({ - projectIds: sanitizedProjects.map(record => record.projectId), - }); - - // In case we are not assigning all targets to the project, - // we need to load all the targets/projects that would be assigned - // for verifying they belong to the organization and/or project. - // This prevents breaking permission boundaries through fault/sus input. - const targetLookupIds = new Set(); - const projectTargetAssignments: Array<{ - project: Project; - /** mutable array that is within "resourceAssignmentGroup" */ - projectTargets: Array>; - targets: readonly GraphQLSchema.TargetResourceAssignmentInput[]; - }> = []; - - for (const record of sanitizedProjects) { - const project = projects.get(record.projectId); - - // In case the project was not found or does not belogn the the organization, - // we omit it as it could grant an user permissions for a project within another organization. - if (!project || project.orgId !== organization.id) { - this.logger.debug('Omitted non-existing project.'); - continue; - } - - const projectTargets: Array> = []; - - resourceAssignmentGroup.projects.push({ - type: 'project', - id: project.id, - targets: { - mode: record.targets.mode === 'all' ? '*' : 'granular', - targets: projectTargets, - }, - }); - - // No need to resolve the projects if mode "a;ll" is used. - // We will not store the selection in the database. - if (record.targets.mode === 'all') { - continue; - } - - if (record.targets.targets) { - const sanitizedTargets = record.targets.targets.filter(target => isUUID(target.targetId)); - for (const target of sanitizedTargets) { - targetLookupIds.add(target.targetId); - } - projectTargetAssignments.push({ - projectTargets, - targets: sanitizedTargets, - project, - }); - } - } - - const targets = await this.storage.findTargetsByIds({ - organizationId: organization.id, - targetIds: Array.from(targetLookupIds), - }); - - for (const record of projectTargetAssignments) { - for (const targetRecord of record.targets) { - const target = targets.get(targetRecord.targetId); - - // In case the target was not found or does not belogn the the organization, - // we omit it as it could grant an user permissions for a target within another organization. - if (!target || target.projectId !== record.project.id) { - this.logger.debug('Omitted non-existing target.'); - continue; - } - - record.projectTargets.push({ - type: 'target', - id: target.id, - services: - // monolith schemas do not have services. - record.project.type === GraphQLSchema.ProjectType.SINGLE || - targetRecord.services.mode === 'all' - ? { mode: '*' } - : { - mode: 'granular', - services: - // TODO: it seems like we do not validate service names - targetRecord.services.services?.map(record => ({ - type: 'service', - serviceName: record?.serviceName, - })) ?? [], - }, - appDeployments: - targetRecord.appDeployments.mode === 'all' - ? { mode: '*' } - : { - mode: 'granular', - appDeployments: - targetRecord.appDeployments.appDeployments - ?.filter(name => AppDeploymentNameModel.safeParse(name).success) - .map(record => ({ - type: 'appDeployment', - appName: record.appDeployment, - })) ?? [], - }, - }); - } - } - - return resourceAssignmentGroup; - } -} - -function isSome(input: T | null): input is Exclude { - return input != null; } const organizationMemberFields = (prefix = sql`"organization_member"`) => sql` @@ -558,147 +253,3 @@ const organizationMemberFields = (prefix = sql`"organization_member"`) => sql` , ${prefix}."connected_to_zendesk" AS "connectedToZendesk" , ${prefix}."assigned_resources" AS "assignedResources" `; - -type OrganizationAssignment = { - type: 'organization'; - organizationId: string; -}; - -type ProjectAssignment = { - type: 'project'; - projectId: string; -}; - -type TargetAssignment = { - type: 'target'; - targetId: string; -}; - -type ServiceAssignment = { - type: 'service'; - targetId: string; - serviceName: string; -}; - -type AppDeploymentAssignment = { - type: 'appDeployment'; - targetId: string; - appDeploymentName: string; -}; - -export type ResourceAssignment = - | OrganizationAssignment - | ProjectAssignment - | TargetAssignment - | ServiceAssignment - | AppDeploymentAssignment; - -type ResolvedResourceAssignments = { - organization: OrganizationAssignment; - project: OrganizationAssignment | Array; - target: OrganizationAssignment | Array; - service: OrganizationAssignment | Array; - appDeployment: - | OrganizationAssignment - | Array; -}; - -/** - * This function resolves the "stored-in-database", user configuration to the actual resolved structure - * Currently, we have the following hierarchy - * - * organization - * v - * project - * v - * target - * v v - * app deployment service - * - * If one level specifies "*", it needs to inherit the resources defined on the next upper level. - */ -export function resolveResourceAssignment(args: { - organizationId: string; - projects: ResourceAssignmentGroup; -}): ResolvedResourceAssignments { - const organizationAssignment: OrganizationAssignment = { - type: 'organization', - organizationId: args.organizationId, - }; - - if (args.projects.mode === '*') { - return { - organization: organizationAssignment, - project: organizationAssignment, - target: organizationAssignment, - appDeployment: organizationAssignment, - service: organizationAssignment, - }; - } - - const projectAssignments: ResolvedResourceAssignments['project'] = []; - const targetAssignments: ResolvedResourceAssignments['target'] = []; - const serviceAssignments: ResolvedResourceAssignments['service'] = []; - const appDeploymentAssignments: ResolvedResourceAssignments['appDeployment'] = []; - - for (const project of args.projects.projects) { - const projectAssignment: ProjectAssignment = { - type: 'project', - projectId: project.id, - }; - projectAssignments.push(projectAssignment); - - if (project.targets.mode === '*') { - // allow actions on all sub-resources of this project - targetAssignments.push(projectAssignment); - serviceAssignments.push(projectAssignment); - appDeploymentAssignments.push(projectAssignment); - continue; - } - - for (const target of project.targets.targets) { - const targetAssignment: TargetAssignment = { - type: 'target', - targetId: target.id, - }; - - targetAssignments.push(targetAssignment); - - // services - if (target.services.mode === '*') { - // allow actions on all services of this target - serviceAssignments.push(targetAssignment); - } else { - for (const service of target.services.services) { - serviceAssignments.push({ - type: 'service', - targetId: target.id, - serviceName: service.serviceName, - }); - } - } - - // app deployments - if (target.appDeployments.mode === '*') { - // allow actions on all app deployments of this target - appDeploymentAssignments.push(targetAssignment); - } else { - for (const appDeployment of target.appDeployments.appDeployments) { - appDeploymentAssignments.push({ - type: 'appDeployment', - targetId: target.id, - appDeploymentName: appDeployment.appName, - }); - } - } - } - } - - return { - organization: organizationAssignment, - project: projectAssignments, - target: targetAssignments, - service: serviceAssignments, - appDeployment: appDeploymentAssignments, - }; -} diff --git a/packages/services/api/src/modules/organization/providers/resource-assignments.ts b/packages/services/api/src/modules/organization/providers/resource-assignments.ts new file mode 100644 index 0000000000..8687d67097 --- /dev/null +++ b/packages/services/api/src/modules/organization/providers/resource-assignments.ts @@ -0,0 +1,464 @@ +import { z } from 'zod'; +import { Logger, Organization, Project } from '@hive/api'; +import * as GraphQLSchema from '../../../__generated__/types'; +import { isUUID } from '../../../shared/is-uuid'; +import { AppDeploymentNameModel } from '../../app-deployments/providers/app-deployments'; +import { Storage } from '../../shared/providers/storage'; + +const WildcardAssignmentModeModel = z.literal('*'); +const GranularAssignmentModeModel = z.literal('granular'); + +const WildcardAssignmentMode = z.object({ + mode: WildcardAssignmentModeModel, +}); + +const AppDeploymentAssignmentModel = z.object({ + type: z.literal('appDeployment'), + appName: z.string(), +}); + +const ServiceAssignmentModel = z.object({ type: z.literal('service'), serviceName: z.string() }); + +const AssignedServicesModel = z.union([ + z.object({ + mode: GranularAssignmentModeModel, + services: z + .array(ServiceAssignmentModel) + .optional() + .nullable() + .transform(value => value ?? []), + }), + WildcardAssignmentMode, +]); + +const AssignedAppDeploymentsModel = z.union([ + z.object({ + mode: GranularAssignmentModeModel, + appDeployments: z.array(AppDeploymentAssignmentModel), + }), + WildcardAssignmentMode, +]); + +const TargetAssignmentModel = z.object({ + type: z.literal('target'), + id: z.string().uuid(), + services: AssignedServicesModel, + appDeployments: AssignedAppDeploymentsModel, +}); + +const AssignedTargetsModel = z.union([ + z.object({ + mode: GranularAssignmentModeModel, + targets: z.array(TargetAssignmentModel), + }), + WildcardAssignmentMode, +]); + +const ProjectAssignmentModel = z.object({ + type: z.literal('project'), + id: z.string().uuid(), + targets: AssignedTargetsModel, +}); + +const GranularAssignedProjectsModel = z.object({ + mode: GranularAssignmentModeModel, + projects: z.array(ProjectAssignmentModel), +}); + +/** + * Tree data structure that represents the resources assigned to an organization member. + * + * Together with the assigned member role, these are used to determine whether a user is allowed + * or not allowed to perform an action on a specific resource (project, target, service, or app deployment). + * + * If no resources are assigned to a member role, the permissions are granted on all the resources within the + * organization. + */ +export const AssignedProjectsModel = z.union([ + GranularAssignedProjectsModel, + WildcardAssignmentMode, +]); + +/** + * Resource assignments as stored within the database. + */ +export type ResourceAssignmentGroup = z.TypeOf; +type GranularAssignedProjects = z.TypeOf; + +export class ResourceAssignments { + private logger: Logger; + + constructor( + private storage: Storage, + logger: Logger, + ) { + this.logger = logger.child({ + source: 'ResourceAssignments', + }); + } + + async resolveGraphQLMemberResourceAssignment(args: { + organizationId: string; + resources: ResourceAssignmentGroup; + }): Promise { + if (args.resources.mode === '*') { + return { mode: 'all' }; + } + const projects = await this.storage.findProjectsByIds({ + projectIds: args.resources.projects.map(project => project.id), + }); + + const filteredProjects = args.resources.projects.filter(row => projects.get(row.id)); + + const targetAssignments = filteredProjects.flatMap(project => + project.targets.mode === 'granular' ? project.targets.targets : [], + ); + + const targets = await this.storage.findTargetsByIds({ + organizationId: args.organizationId, + targetIds: targetAssignments.map(target => target.id), + }); + + return { + mode: 'granular' as const, + projects: filteredProjects + .map(projectAssignment => { + const project = projects.get(projectAssignment.id); + if (!project || project.orgId !== args.organizationId) { + return null; + } + + return { + projectId: project.id, + project, + targets: + projectAssignment.targets.mode === '*' + ? { mode: 'all' as const } + : { + mode: 'granular' as const, + targets: projectAssignment.targets.targets + .map(targetAssignment => { + const target = targets.get(targetAssignment.id); + if (!target) return null; + + return { + targetId: target.id, + target, + services: + targetAssignment.services.mode === '*' + ? { mode: 'all' as const } + : { + mode: 'granular' as const, + services: targetAssignment.services.services.map( + service => service.serviceName, + ), + }, + appDeployments: + targetAssignment.appDeployments.mode === '*' + ? { mode: 'all' as const } + : { + mode: 'granular' as const, + appDeployments: + targetAssignment.appDeployments.appDeployments.map( + deployment => deployment.appName, + ), + }, + }; + }) + .filter(isSome), + }, + }; + }) + .filter(isSome), + }; + } + + /** + * Transforms and resolves a {GraphQL.ResourceAssignmentInput} to a {ResourceAssignmentGroup} + * that can be stored within our database + * + * - Projects and Targets that can not be found in our database are omitted from the resolved object. + * - Projects and Targets that do not follow the hierarchical structure are omitted from teh resolved object. + * + * These measures are done in order to prevent users to grant access to other organizations. + */ + async transformGraphQLResourceAssignmentInputToResourceAssignmentGroup( + organization: Organization, + input: GraphQLSchema.ResourceAssignmentInput, + ): Promise { + if ( + !input.projects || + // No need to resolve the projects if mode "all" is used. + // We will not store the selection in the database. + input.mode === 'all' + ) { + return { + mode: '*', + }; + } + + /** Mutable array that we populate with the resolved data from the database */ + const resourceAssignmentGroup: GranularAssignedProjects = { + mode: 'granular', + projects: [], + }; + + const sanitizedProjects = input.projects.filter(project => isUUID(project.projectId)); + + const projects = await this.storage.findProjectsByIds({ + projectIds: sanitizedProjects.map(record => record.projectId), + }); + + // In case we are not assigning all targets to the project, + // we need to load all the targets/projects that would be assigned + // for verifying they belong to the organization and/or project. + // This prevents breaking permission boundaries through fault/sus input. + const targetLookupIds = new Set(); + const projectTargetAssignments: Array<{ + project: Project; + /** mutable array that is within "resourceAssignmentGroup" */ + projectTargets: Array>; + targets: readonly GraphQLSchema.TargetResourceAssignmentInput[]; + }> = []; + + for (const record of sanitizedProjects) { + const project = projects.get(record.projectId); + + // In case the project was not found or does not belogn the the organization, + // we omit it as it could grant an user permissions for a project within another organization. + if (!project || project.orgId !== organization.id) { + this.logger.debug('Omitted non-existing project.'); + continue; + } + + const projectTargets: Array> = []; + + resourceAssignmentGroup.projects.push({ + type: 'project', + id: project.id, + targets: { + mode: record.targets.mode === 'all' ? '*' : 'granular', + targets: projectTargets, + }, + }); + + // No need to resolve the projects if mode "a;ll" is used. + // We will not store the selection in the database. + if (record.targets.mode === 'all') { + continue; + } + + if (record.targets.targets) { + const sanitizedTargets = record.targets.targets.filter(target => isUUID(target.targetId)); + for (const target of sanitizedTargets) { + targetLookupIds.add(target.targetId); + } + projectTargetAssignments.push({ + projectTargets, + targets: sanitizedTargets, + project, + }); + } + } + + const targets = await this.storage.findTargetsByIds({ + organizationId: organization.id, + targetIds: Array.from(targetLookupIds), + }); + + for (const record of projectTargetAssignments) { + for (const targetRecord of record.targets) { + const target = targets.get(targetRecord.targetId); + + // In case the target was not found or does not belogn the the organization, + // we omit it as it could grant an user permissions for a target within another organization. + if (!target || target.projectId !== record.project.id) { + this.logger.debug('Omitted non-existing target.'); + continue; + } + + record.projectTargets.push({ + type: 'target', + id: target.id, + services: + // monolith schemas do not have services. + record.project.type === GraphQLSchema.ProjectType.SINGLE || + targetRecord.services.mode === 'all' + ? { mode: '*' } + : { + mode: 'granular', + services: + // TODO: it seems like we do not validate service names + targetRecord.services.services?.map(record => ({ + type: 'service', + serviceName: record?.serviceName, + })) ?? [], + }, + appDeployments: + targetRecord.appDeployments.mode === 'all' + ? { mode: '*' } + : { + mode: 'granular', + appDeployments: + targetRecord.appDeployments.appDeployments + ?.filter(name => AppDeploymentNameModel.safeParse(name).success) + .map(record => ({ + type: 'appDeployment', + appName: record.appDeployment, + })) ?? [], + }, + }); + } + } + + return resourceAssignmentGroup; + } +} + +function isSome(input: T | null): input is Exclude { + return input != null; +} + +type OrganizationAssignment = { + type: 'organization'; + organizationId: string; +}; + +type ProjectAssignment = { + type: 'project'; + projectId: string; +}; + +type TargetAssignment = { + type: 'target'; + targetId: string; +}; + +type ServiceAssignment = { + type: 'service'; + targetId: string; + serviceName: string; +}; + +type AppDeploymentAssignment = { + type: 'appDeployment'; + targetId: string; + appDeploymentName: string; +}; + +export type ResourceAssignment = + | OrganizationAssignment + | ProjectAssignment + | TargetAssignment + | ServiceAssignment + | AppDeploymentAssignment; + +export type ResolvedResourceAssignments = { + organization: OrganizationAssignment; + project: OrganizationAssignment | Array; + target: OrganizationAssignment | Array; + service: OrganizationAssignment | Array; + appDeployment: + | OrganizationAssignment + | Array; +}; + +/** + * This function resolves the "stored-in-database", user configuration to the actual resolved structure + * Currently, we have the following hierarchy + * + * organization + * v + * project + * v + * target + * v v + * app deployment service + * + * If one level specifies "*", it needs to inherit the resources defined on the next upper level. + */ +export function resolveResourceAssignment(args: { + organizationId: string; + projects: ResourceAssignmentGroup; +}): ResolvedResourceAssignments { + const organizationAssignment: OrganizationAssignment = { + type: 'organization', + organizationId: args.organizationId, + }; + + if (args.projects.mode === '*') { + return { + organization: organizationAssignment, + project: organizationAssignment, + target: organizationAssignment, + appDeployment: organizationAssignment, + service: organizationAssignment, + }; + } + + const projectAssignments: ResolvedResourceAssignments['project'] = []; + const targetAssignments: ResolvedResourceAssignments['target'] = []; + const serviceAssignments: ResolvedResourceAssignments['service'] = []; + const appDeploymentAssignments: ResolvedResourceAssignments['appDeployment'] = []; + + for (const project of args.projects.projects) { + const projectAssignment: ProjectAssignment = { + type: 'project', + projectId: project.id, + }; + projectAssignments.push(projectAssignment); + + if (project.targets.mode === '*') { + // allow actions on all sub-resources of this project + targetAssignments.push(projectAssignment); + serviceAssignments.push(projectAssignment); + appDeploymentAssignments.push(projectAssignment); + continue; + } + + for (const target of project.targets.targets) { + const targetAssignment: TargetAssignment = { + type: 'target', + targetId: target.id, + }; + + targetAssignments.push(targetAssignment); + + // services + if (target.services.mode === '*') { + // allow actions on all services of this target + serviceAssignments.push(targetAssignment); + } else { + for (const service of target.services.services) { + serviceAssignments.push({ + type: 'service', + targetId: target.id, + serviceName: service.serviceName, + }); + } + } + + // app deployments + if (target.appDeployments.mode === '*') { + // allow actions on all app deployments of this target + appDeploymentAssignments.push(targetAssignment); + } else { + for (const appDeployment of target.appDeployments.appDeployments) { + appDeploymentAssignments.push({ + type: 'appDeployment', + targetId: target.id, + appDeploymentName: appDeployment.appName, + }); + } + } + } + } + + return { + organization: organizationAssignment, + project: projectAssignments, + target: targetAssignments, + service: serviceAssignments, + appDeployment: appDeploymentAssignments, + }; +} diff --git a/packages/services/api/src/modules/organization/resolvers/Member.ts b/packages/services/api/src/modules/organization/resolvers/Member.ts index 55d2b542e3..3659b4c986 100644 --- a/packages/services/api/src/modules/organization/resolvers/Member.ts +++ b/packages/services/api/src/modules/organization/resolvers/Member.ts @@ -1,6 +1,6 @@ import { Storage } from '../../shared/providers/storage'; import { OrganizationManager } from '../providers/organization-manager'; -import { OrganizationMembers } from '../providers/organization-members'; +import { ResourceAssignments } from '../providers/resource-assignments'; import type { MemberResolvers } from './../../../__generated__/types'; export const Member: MemberResolvers = { @@ -36,6 +36,9 @@ export const Member: MemberResolvers = { return user; }, resourceAssignment: async (member, _arg, { injector }) => { - return injector.get(OrganizationMembers).resolveGraphQLMemberResourceAssignment(member); + return injector.get(ResourceAssignments).resolveGraphQLMemberResourceAssignment({ + organizationId: member.organizationId, + resources: member.assignedRole.resources, + }); }, }; diff --git a/packages/services/api/src/modules/organization/resolvers/Mutation/createOrganizationAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/createOrganizationAccessToken.ts new file mode 100644 index 0000000000..fe98f29312 --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/createOrganizationAccessToken.ts @@ -0,0 +1,7 @@ +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const createOrganizationAccessToken: NonNullable< + MutationResolvers['createOrganizationAccessToken'] +> = async (_parent, _arg, _ctx) => { + /* Implement Mutation.createOrganizationAccessToken resolver logic here */ +}; diff --git a/packages/services/api/src/modules/organization/resolvers/Mutation/deleteOrganizationAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/deleteOrganizationAccessToken.ts new file mode 100644 index 0000000000..f70ed794cc --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/deleteOrganizationAccessToken.ts @@ -0,0 +1,7 @@ +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const deleteOrganizationAccessToken: NonNullable< + MutationResolvers['deleteOrganizationAccessToken'] +> = async (_parent, _arg, _ctx) => { + /* Implement Mutation.deleteOrganizationAccessToken resolver logic here */ +}; diff --git a/packages/services/api/src/modules/organization/resolvers/Mutation/updateOrganizationAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/updateOrganizationAccessToken.ts new file mode 100644 index 0000000000..b79deaef6d --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/updateOrganizationAccessToken.ts @@ -0,0 +1,7 @@ +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const updateOrganizationAccessToken: NonNullable< + MutationResolvers['updateOrganizationAccessToken'] +> = async (_parent, _arg, _ctx) => { + /* Implement Mutation.updateOrganizationAccessToken resolver logic here */ +}; diff --git a/packages/services/api/src/modules/schema/providers/schema-manager.ts b/packages/services/api/src/modules/schema/providers/schema-manager.ts index cb33d28a1a..b2fdc4c1e4 100644 --- a/packages/services/api/src/modules/schema/providers/schema-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-manager.ts @@ -1007,12 +1007,11 @@ export class SchemaManager { }); await this.session.assertPerformAction({ - action: 'schema:loadFromRegistry', + action: 'project:describe', organizationId: selector.organizationId, params: { organizationId: selector.organizationId, projectId: selector.projectId, - targetId: selector.targetId, }, }); From 83daaeb37599f91cf03f92c6e3edefa8caa4ddf8 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 10 Feb 2025 13:06:58 +0100 Subject: [PATCH 02/33] more wip --- .../api/src/modules/auth/lib/authz.ts | 1 + .../modules/auth/module.graphql.mappers.ts | 5 +- .../organization/module.graphql.mappers.ts | 2 + .../modules/organization/module.graphql.ts | 33 +++- .../providers/organization-access-tokens.ts | 175 +++++++++++++++--- .../providers/organization-manager.ts | 2 +- .../providers/resource-assignments.ts | 8 +- .../Mutation/createOrganizationAccessToken.ts | 29 ++- .../Mutation/updateOrganizationAccessToken.ts | 30 ++- .../resolvers/OrganizationAccessToken.ts | 21 +++ .../schema/providers/schema-manager.ts | 2 +- .../modules/shared/providers/id-translator.ts | 54 +++++- 12 files changed, 318 insertions(+), 44 deletions(-) create mode 100644 packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index 771250ab3c..a5376759a0 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -349,6 +349,7 @@ const permissionsByLevel = { z.literal('project:create'), z.literal('schemaLinting:modifyOrganizationRules'), z.literal('auditLog:export'), + z.literal('accessToken:modify'), ], project: [ z.literal('project:describe'), diff --git a/packages/services/api/src/modules/auth/module.graphql.mappers.ts b/packages/services/api/src/modules/auth/module.graphql.mappers.ts index 9374cdb7dc..1d50421820 100644 --- a/packages/services/api/src/modules/auth/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/auth/module.graphql.mappers.ts @@ -1,8 +1,5 @@ import type { User } from '../../shared/entities'; -import { - PermissionGroup, - PermissionRecord, -} from '../organization/lib/organization-member-permissions'; +import { PermissionGroup, PermissionRecord } from '../organization/lib/permissions'; import type { OrganizationAccessScope } from './providers/organization-access'; import type { ProjectAccessScope } from './providers/project-access'; import type { TargetAccessScope } from './providers/target-access'; 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 4701b0b37a..e47a6d2587 100644 --- a/packages/services/api/src/modules/organization/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/organization/module.graphql.mappers.ts @@ -3,6 +3,7 @@ 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'; @@ -13,3 +14,4 @@ export type OrganizationGetStartedMapper = OrganizationGetStarted; export type OrganizationInvitationMapper = OrganizationInvitation; export type MemberConnectionMapper = readonly OrganizationMembership[]; export type MemberMapper = OrganizationMembership; +export type OrganizationAccessTokenMapper = OrganizationAccessToken; diff --git a/packages/services/api/src/modules/organization/module.graphql.ts b/packages/services/api/src/modules/organization/module.graphql.ts index ce94661e84..cf0927666a 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -39,16 +39,16 @@ export default gql` input: CreateOrganizationAccessTokenInput! ): CreateOrganizationAccessTokenResult! updateOrganizationAccessToken( - input: CreateOrganizationAccessTokenInput! - ): CreateOrganizationAccessTokenResult! + input: UpdateOrganizationAccessTokenInput! + ): UpdateOrganizationAccessTokenResult! deleteOrganizationAccessToken( input: DeleteOrganizationAccessTokenInput! ): DeleteOrganizationAccessTokenResult! } input OrganizationReferenceInput @oneOf { - byOrganizationSlug: String - byOrganizationId: ID + bySelector: OrganizationSelectorInput + byId: ID } input CreateOrganizationAccessTokenInput { @@ -66,10 +66,23 @@ export default gql` type CreateOrganizationAccessTokenResultOk { createdOrganizationAccessToken: OrganizationAccessToken! + privateAccessKey: String! } type CreateOrganizationAccessTokenResultError implements Error { message: String! + details: CreateOrganizationAccessTokenResultErrorDetails + } + + type CreateOrganizationAccessTokenResultErrorDetails { + """ + Error message for the input title. + """ + title: String + """ + Error message for the input description. + """ + description: String } type OrganizationAccessToken { @@ -100,6 +113,18 @@ export default gql` type UpdateOrganizationAccessTokenResultError implements Error { message: String! + details: UpdateOrganizationAccessTokenResultErrorDetails + } + + type UpdateOrganizationAccessTokenResultErrorDetails { + """ + Error message for the input title. + """ + title: String + """ + Error message for the input description. + """ + description: String } input DeleteOrganizationAccessTokenInput { 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 496383cc88..55d78b6d02 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,23 +1,30 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; import { DatabasePool, sql } from 'slonik'; import { z } from 'zod'; -import { Organization } from '@hive/api'; -import { PermissionsModel } from '../../auth/lib/authz'; +import * as GraphQLSchema from '../../../__generated__/types'; +import { isUUID } from '../../../shared/is-uuid'; +import { InsufficientPermissionError, PermissionsModel, Session } from '../../auth/lib/authz'; +import { IdTranslator } from '../../shared/providers/id-translator'; +import { Logger } from '../../shared/providers/logger'; import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; import * as OrganizationAccessKey from '../lib/organization-access-key'; -import { AssignedProjectsModel, ResourceAssignmentGroup } from './resource-assignments'; +import { + AssignedProjectsModel, + ResourceAssignmentGroup, + ResourceAssignments, +} from './resource-assignments'; -// TODO: specify characters const TitleInputModel = z .string() + .trim() + .regex(/^[ a-zA-Z0-9_-]+$/, `Can only contain letters, numbers, " ", '_', and '-'.`) .min(2, 'Minimum length is 2 characters.') .max(100, 'Maximum length is 100 characters.'); -// TODO: specify characters const DescriptionInputModel = z .string() - .min(2, 'Minimum length is 2 characters.') - .max(100, 'Maximum length is 100 characters.') + .trim() + .max(248, 'Maximum length is 248 characters.') .nullable(); const OrganizationAccessTokenModel = z.object({ @@ -30,22 +37,39 @@ const OrganizationAccessTokenModel = z.object({ assignedResources: AssignedProjectsModel.nullable().transform( value => value ?? { mode: '*' as const, projects: [] }, ), + firstCharacters: z.string(), + hash: z.string(), }); -type OrganizationAccessKeyRecord = z.TypeOf; +export type OrganizationAccessToken = z.TypeOf; +@Injectable({ + scope: Scope.Operation, +}) export class OrganizationAccessTokens { - constructor(@Inject(PG_POOL_CONFIG) private pool: DatabasePool) {} + logger: Logger; + + constructor( + @Inject(PG_POOL_CONFIG) private pool: DatabasePool, + private resourceAssignments: ResourceAssignments, + private idTranslator: IdTranslator, + private session: Session, + logger: Logger, + ) { + this.logger = logger.child({ + source: 'OrganizationAccessTokens', + }); + } async create(args: { - organizationId: string; + organization: GraphQLSchema.OrganizationReferenceInput; title: string; - description: string; + description: string | null; permissions: Array; - assignedResources: ResourceAssignmentGroup; + assignedResources: GraphQLSchema.ResourceAssignmentInput | null; }) { const titleResult = TitleInputModel.safeParse(args.title.trim()); - const descriptionResult = DescriptionInputModel.safeParse(args.description.trim()); + const descriptionResult = DescriptionInputModel.safeParse(args.description); if (titleResult.error || descriptionResult.error) { return { @@ -58,8 +82,24 @@ export class OrganizationAccessTokens { }; } - // TODO: validate permissions - // TODO: validate assigned resources + const { organizationId } = await this.idTranslator.resolveOrganizationReference({ + reference: args.organization, + onError() { + throw new InsufficientPermissionError('accessToken:modify'); + }, + }); + + await this.session.assertPerformAction({ + organizationId, + params: { organizationId }, + action: 'accessToken:modify', + }); + + const assignedResources = + await this.resourceAssignments.transformGraphQLResourceAssignmentInputToResourceAssignmentGroup( + organizationId, + args.assignedResources ?? { mode: 'granular' }, + ); const id = crypto.randomUUID(); const accessKey = await OrganizationAccessKey.create(id); @@ -77,11 +117,11 @@ export class OrganizationAccessTokens { ) VALUES ( ${id} - , ${args.organizationId} + , ${organizationId} , ${titleResult.data} , ${descriptionResult.data} , ${sql.array(args.permissions, 'text')}, - , ${sql.jsonb(args.assignedResources)} + , ${sql.jsonb(assignedResources)} , ${accessKey.hash} , ${accessKey.firstCharacters} ) @@ -100,12 +140,26 @@ export class OrganizationAccessTokens { async update(args: { organizationAccessTokenId: string; - title: string | null; - permissions: Array | null; - assignedResources: ResourceAssignmentGroup | null; + data: { + title: string | null; + description: string | null; + permissions: Array | null; + assignedResources: GraphQLSchema.ResourceAssignmentInput | null; + }; }) { - const titleResult = TitleInputModel.nullable().safeParse(args.title?.trim()); - const descriptionResult = DescriptionInputModel.nullable().safeParse(args.description?.trim()); + const record = await this.findOrganizationAccessTokenById(args.organizationAccessTokenId); + if (record === null) { + throw new InsufficientPermissionError('accessToken:modify'); + } + + await this.session.assertPerformAction({ + action: 'accessToken:modify', + organizationId: record.organizationId, + params: { organizationId: record.organizationId }, + }); + + const titleResult = TitleInputModel.nullable().safeParse(args.data.title); + const descriptionResult = DescriptionInputModel.nullable().safeParse(args.data.description); if (titleResult.error || descriptionResult.error) { return { @@ -118,14 +172,30 @@ export class OrganizationAccessTokens { }; } + let assignedResources: ResourceAssignmentGroup | null = null; + + if (args.data.assignedResources) { + assignedResources = + await this.resourceAssignments.transformGraphQLResourceAssignmentInputToResourceAssignmentGroup( + record.organizationId, + args.data.assignedResources, + ); + } + + let permissions: Array | null = null; + if (args.data.permissions) { + // TODO: validate permissions + permissions = args.data.permissions; + } + const result = await this.pool.maybeOne(sql` UPDATE "organization_access_tokens" SET "title" = COALESCE(${titleResult.data}, "title") , "description" = COALESCE(${descriptionResult.data}, "description") - , "permissions" = COALESCE(${args.permissions}, "permissions") - , "assigned_resources" = COALESCE(${sql.jsonb(args.assignedResources)}, "permissions") + , "permissions" = COALESCE(${permissions}, "permissions") + , "assigned_resources" = COALESCE(${sql.jsonb(assignedResources)}, "permissions") ) WHERE "id" = ${args.organizationAccessTokenId} @@ -134,12 +204,23 @@ export class OrganizationAccessTokens { `); return { - type: 'success', + type: 'success' as const, organizationAccessToken: OrganizationAccessTokenModel.parse(result), }; } async delete(args: { organizationAccessTokenId: string }) { + const record = await this.findOrganizationAccessTokenById(args.organizationAccessTokenId); + if (record === null) { + throw new InsufficientPermissionError('accessToken:modify'); + } + + await this.session.assertPerformAction({ + action: 'accessToken:modify', + organizationId: record.organizationId, + params: { organizationId: record.organizationId }, + }); + await this.pool.query(sql` DELETE FROM @@ -152,6 +233,48 @@ export class OrganizationAccessTokens { type: 'success' as const, }; } + + private async findOrganizationAccessTokenById(organizationAccessTokenId: string) { + this.logger.debug( + 'Resolve organization access token by id. (organizationAccessTokenId=%s)', + organizationAccessTokenId, + ); + + if (isUUID(organizationAccessTokenId) === false) { + this.logger.debug( + 'Invalid UUID provided. (organizationAccessTokenId=%s)', + organizationAccessTokenId, + ); + return null; + } + + const data = await this.pool.maybeOne(sql` + SELECT + ${organizationAccessTokenFields} + FROM + "organization_access_tokens" + WHERE + "id" = ${organizationAccessTokenId} + LIMIT 1 + `); + + if (data === null) { + this.logger.debug( + 'Organization access token not found. (organizationAccessTokenId=%s)', + organizationAccessTokenId, + ); + return null; + } + + const result = OrganizationAccessTokenModel.parse(data); + + this.logger.debug( + 'Organization access token found. (organizationAccessTokenId=%s)', + organizationAccessTokenId, + ); + + return result; + } } const organizationAccessTokenFields = sql` @@ -162,4 +285,6 @@ const organizationAccessTokenFields = sql` , "description" , "permissions" , "assigned_resources" AS "assignedResources" + , "first_characters" AS "firstCharacters" + , "hash" `; diff --git a/packages/services/api/src/modules/organization/providers/organization-manager.ts b/packages/services/api/src/modules/organization/providers/organization-manager.ts index e6d82c4180..04e7ba158f 100644 --- a/packages/services/api/src/modules/organization/providers/organization-manager.ts +++ b/packages/services/api/src/modules/organization/providers/organization-manager.ts @@ -1005,7 +1005,7 @@ export class OrganizationManager { const resourceAssignmentGroup = await this.resourceAssignments.transformGraphQLResourceAssignmentInputToResourceAssignmentGroup( - organization, + organization.id, input.resources, ); 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 8687d67097..a4d053de70 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,5 @@ import { z } from 'zod'; -import { Logger, Organization, Project } from '@hive/api'; +import { Logger, Project } from '@hive/api'; import * as GraphQLSchema from '../../../__generated__/types'; import { isUUID } from '../../../shared/is-uuid'; import { AppDeploymentNameModel } from '../../app-deployments/providers/app-deployments'; @@ -183,7 +183,7 @@ export class ResourceAssignments { * These measures are done in order to prevent users to grant access to other organizations. */ async transformGraphQLResourceAssignmentInputToResourceAssignmentGroup( - organization: Organization, + organizationId: string, input: GraphQLSchema.ResourceAssignmentInput, ): Promise { if ( @@ -226,7 +226,7 @@ export class ResourceAssignments { // In case the project was not found or does not belogn the the organization, // we omit it as it could grant an user permissions for a project within another organization. - if (!project || project.orgId !== organization.id) { + if (!project || project.orgId !== organizationId) { this.logger.debug('Omitted non-existing project.'); continue; } @@ -262,7 +262,7 @@ export class ResourceAssignments { } const targets = await this.storage.findTargetsByIds({ - organizationId: organization.id, + organizationId, targetIds: Array.from(targetLookupIds), }); 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 fe98f29312..220a27c11b 100644 --- a/packages/services/api/src/modules/organization/resolvers/Mutation/createOrganizationAccessToken.ts +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/createOrganizationAccessToken.ts @@ -1,7 +1,32 @@ +import { OrganizationAccessTokens } from '../../providers/organization-access-tokens'; import type { MutationResolvers } from './../../../../__generated__/types'; export const createOrganizationAccessToken: NonNullable< MutationResolvers['createOrganizationAccessToken'] -> = async (_parent, _arg, _ctx) => { - /* Implement Mutation.createOrganizationAccessToken resolver logic here */ +> = async (_, args, { injector }) => { + const result = await injector.get(OrganizationAccessTokens).create({ + organization: args.input.organization, + title: args.input.title, + description: args.input.description ?? null, + permissions: [...args.input.permissions], + assignedResources: args.input.resources, + }); + + if (result.type === 'success') { + return { + ok: { + __typename: 'CreateOrganizationAccessTokenResultOk', + createdOrganizationAccessToken: result.organizationAccessToken, + privateAccessKey: result.privateAccessKey, + }, + }; + } + + return { + error: { + __typename: 'CreateOrganizationAccessTokenResultError', + message: result.message, + details: result.details, + }, + }; }; diff --git a/packages/services/api/src/modules/organization/resolvers/Mutation/updateOrganizationAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/updateOrganizationAccessToken.ts index b79deaef6d..8cc30ffc36 100644 --- a/packages/services/api/src/modules/organization/resolvers/Mutation/updateOrganizationAccessToken.ts +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/updateOrganizationAccessToken.ts @@ -1,7 +1,33 @@ +import { OrganizationAccessTokens } from '../../providers/organization-access-tokens'; import type { MutationResolvers } from './../../../../__generated__/types'; export const updateOrganizationAccessToken: NonNullable< MutationResolvers['updateOrganizationAccessToken'] -> = async (_parent, _arg, _ctx) => { - /* Implement Mutation.updateOrganizationAccessToken resolver logic here */ +> = async (_, args, { injector }) => { + const result = await injector.get(OrganizationAccessTokens).update({ + organizationAccessTokenId: args.input.organizationAccessTokenId, + data: { + title: args.input.title ?? null, + description: args.input.description ?? null, + permissions: [...(args.input.permissions ?? [])], + assignedResources: args.input.resources ?? null, + }, + }); + + if (result.type === 'success') { + return { + ok: { + __typename: 'UpdateOrganizationAccessTokenResultOk', + updatedOrganizationAccessToken: result.organizationAccessToken, + }, + }; + } + + return { + error: { + __typename: 'UpdateOrganizationAccessTokenResultError', + message: result.message, + details: result.details, + }, + }; }; diff --git a/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts new file mode 100644 index 0000000000..e7fe750dc1 --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts @@ -0,0 +1,21 @@ +import type { OrganizationAccessTokenResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "OrganizationAccessTokenMapper" 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 OrganizationAccessToken: OrganizationAccessTokenResolvers = { + /* Implement OrganizationAccessToken resolver logic here */ + permissions: ({ permissions }, _arg, _ctx) => { + /* OrganizationAccessToken.permissions resolver is required because OrganizationAccessToken.permissions and OrganizationAccessTokenMapper.permissions are not compatible */ + return permissions; + }, + resources: async (_parent, _arg, _ctx) => { + /* OrganizationAccessToken.resources resolver is required because OrganizationAccessToken.resources exists but OrganizationAccessTokenMapper.resources does not */ + }, +}; diff --git a/packages/services/api/src/modules/schema/providers/schema-manager.ts b/packages/services/api/src/modules/schema/providers/schema-manager.ts index b2fdc4c1e4..061d1b35e7 100644 --- a/packages/services/api/src/modules/schema/providers/schema-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-manager.ts @@ -996,7 +996,7 @@ export class SchemaManager { const selector = await this.idTranslator.resolveTargetReference({ reference: args.target, onError() { - throw new InsufficientPermissionError('schema:loadFromRegistry'); + throw new InsufficientPermissionError('project:describe'); }, }); diff --git a/packages/services/api/src/modules/shared/providers/id-translator.ts b/packages/services/api/src/modules/shared/providers/id-translator.ts index f14a197e9d..06339e8c8b 100644 --- a/packages/services/api/src/modules/shared/providers/id-translator.ts +++ b/packages/services/api/src/modules/shared/providers/id-translator.ts @@ -92,7 +92,7 @@ export class IdTranslator { /** Resolve a GraphQLSchema.TargetReferenceInput */ async resolveTargetReference(args: { reference: GraphQLSchema.TargetReferenceInput | null; - onError: () => never; + onError(): never; }): Promise<{ organizationId: string; projectId: string; targetId: string }> { this.logger.debug('Resolve target reference. (reference=%o)', args.reference); @@ -156,6 +156,58 @@ export class IdTranslator { return selector; } + + /** Resolve a GraphQLSchema.OrganizationReferenceInput */ + async resolveOrganizationReference(args: { + reference: GraphQLSchema.OrganizationReferenceInput; + onError(): never; + }): Promise<{ organizationId: string }> { + let selector: { + organizationId: string; + }; + + if (args.reference.bySelector) { + const organizationId = await this.translateOrganizationId(args.reference.bySelector).catch( + error => { + this.logger.debug(error); + this.logger.debug('Failed to resolve input slug to ids (reference=%o)', args.reference); + args.onError(); + }, + ); + + this.logger.debug('Organization selector resolved. (organizationId=%s)', organizationId); + + selector = { + organizationId, + }; + } else { + if (!isUUID(args.reference.byId)) { + this.logger.debug('Invalid uuid provided. (targetId=%s)', args.reference.byId); + args.onError(); + } + + const organization = await this.storage + .getOrganization({ + organizationId: args.reference.byId, + }) + .catch(error => { + this.logger.debug(error); + this.logger.debug( + 'Failed to resolve id to organization (reference=%o)', + args.reference.byId, + ); + args.onError(); + }); + + selector = { + organizationId: organization.id, + }; + } + + this.logger.debug('Target selector resolved. (organizationId=%s)', selector.organizationId); + + return selector; + } } function filterSelector( From cfb17b79baebd688cf36bf93803ac7dda65601bf Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 10 Feb 2025 13:28:50 +0100 Subject: [PATCH 03/33] w --- .../modules/auth/lib/supertokens-strategy.ts | 2 +- .../providers/organization-access-tokens.ts | 3 +- .../providers/organization-member.spec.ts | 541 ------------------ .../Mutation/deleteOrganizationAccessToken.ts | 14 +- .../resolvers/OrganizationAccessToken.ts | 13 +- packages/services/server/src/index.ts | 1 - 6 files changed, 21 insertions(+), 553 deletions(-) delete mode 100644 packages/services/api/src/modules/organization/providers/organization-member.spec.ts diff --git a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts index 499f675b62..7286397af6 100644 --- a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts @@ -8,8 +8,8 @@ import { isUUID } from '../../../shared/is-uuid'; import { OrganizationMembers, OrganizationMembershipRoleAssignment, - ResourceAssignment, } from '../../organization/providers/organization-members'; +import { ResourceAssignment } from '../../organization/providers/resource-assignments'; import { Logger } from '../../shared/providers/logger'; import type { Storage } from '../../shared/providers/storage'; import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz'; 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 55d78b6d02..0bca235714 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 @@ -33,7 +33,7 @@ const OrganizationAccessTokenModel = z.object({ createdAt: z.string(), title: z.string(), description: z.string(), - permissions: z.array(PermissionsModel).nullable(), + permissions: z.array(PermissionsModel), assignedResources: AssignedProjectsModel.nullable().transform( value => value ?? { mode: '*' as const, projects: [] }, ), @@ -231,6 +231,7 @@ export class OrganizationAccessTokens { return { type: 'success' as const, + organizationAccessTokenId: args.organizationAccessTokenId, }; } diff --git a/packages/services/api/src/modules/organization/providers/organization-member.spec.ts b/packages/services/api/src/modules/organization/providers/organization-member.spec.ts deleted file mode 100644 index 1fe26c5599..0000000000 --- a/packages/services/api/src/modules/organization/providers/organization-member.spec.ts +++ /dev/null @@ -1,541 +0,0 @@ -import { resolveResourceAssignment } from './organization-members'; - -describe('resolveResourceAssignment', () => { - test('project wildcard: organization wide access to all resources', () => { - expect( - resolveResourceAssignment({ - organizationId: 'aaa', - projects: { - mode: '*', - }, - }), - ).toEqual({ - organization: { - organizationId: 'aaa', - type: 'organization', - }, - project: { - organizationId: 'aaa', - type: 'organization', - }, - target: { - organizationId: 'aaa', - type: 'organization', - }, - service: { - organizationId: 'aaa', - type: 'organization', - }, - appDeployment: { - organizationId: 'aaa', - type: 'organization', - }, - }); - }); - test('project granular: access to single project', () => { - expect( - resolveResourceAssignment({ - organizationId: 'aaa', - projects: { - mode: 'granular', - projects: [ - { - id: 'bbb', - targets: { mode: '*' }, - type: 'project', - }, - ], - }, - }), - ).toEqual({ - organization: { - organizationId: 'aaa', - type: 'organization', - }, - project: [ - { - projectId: 'bbb', - type: 'project', - }, - ], - target: [ - { - projectId: 'bbb', - type: 'project', - }, - ], - service: [ - { - projectId: 'bbb', - type: 'project', - }, - ], - appDeployment: [ - { - projectId: 'bbb', - type: 'project', - }, - ], - }); - }); - test('project granular: access to multiple projects', () => { - expect( - resolveResourceAssignment({ - organizationId: 'aaa', - projects: { - mode: 'granular', - projects: [ - { - id: 'bbb', - targets: { mode: '*' }, - type: 'project', - }, - { - id: 'ccc', - targets: { mode: '*' }, - type: 'project', - }, - ], - }, - }), - ).toEqual({ - organization: { - organizationId: 'aaa', - type: 'organization', - }, - project: [ - { - projectId: 'bbb', - type: 'project', - }, - { - projectId: 'ccc', - type: 'project', - }, - ], - target: [ - { - projectId: 'bbb', - type: 'project', - }, - { - projectId: 'ccc', - type: 'project', - }, - ], - service: [ - { - projectId: 'bbb', - type: 'project', - }, - { - projectId: 'ccc', - type: 'project', - }, - ], - appDeployment: [ - { - projectId: 'bbb', - type: 'project', - }, - { - projectId: 'ccc', - type: 'project', - }, - ], - }); - }); - test('target granular: access to single target', () => { - expect( - resolveResourceAssignment({ - organizationId: 'aaa', - projects: { - mode: 'granular', - projects: [ - { - id: 'bbb', - targets: { - mode: 'granular', - targets: [ - { - id: 'ccc', - type: 'target', - appDeployments: { mode: '*' }, - services: { mode: '*' }, - }, - ], - }, - type: 'project', - }, - ], - }, - }), - ).toEqual({ - organization: { - organizationId: 'aaa', - type: 'organization', - }, - project: [ - { - projectId: 'bbb', - type: 'project', - }, - ], - target: [ - { - targetId: 'ccc', - type: 'target', - }, - ], - service: [ - { - targetId: 'ccc', - type: 'target', - }, - ], - appDeployment: [ - { - targetId: 'ccc', - type: 'target', - }, - ], - }); - }); - test('target granular: access to multiple targets', () => { - expect( - resolveResourceAssignment({ - organizationId: 'aaa', - projects: { - mode: 'granular', - projects: [ - { - id: 'bbb', - targets: { - mode: 'granular', - targets: [ - { - id: 'ccc', - type: 'target', - appDeployments: { mode: '*' }, - services: { mode: '*' }, - }, - { - id: 'ddd', - type: 'target', - appDeployments: { mode: '*' }, - services: { mode: '*' }, - }, - ], - }, - type: 'project', - }, - ], - }, - }), - ).toEqual({ - organization: { - organizationId: 'aaa', - type: 'organization', - }, - project: [ - { - projectId: 'bbb', - type: 'project', - }, - ], - target: [ - { - targetId: 'ccc', - type: 'target', - }, - { - targetId: 'ddd', - type: 'target', - }, - ], - service: [ - { - targetId: 'ccc', - type: 'target', - }, - { - targetId: 'ddd', - type: 'target', - }, - ], - appDeployment: [ - { - targetId: 'ccc', - type: 'target', - }, - { - targetId: 'ddd', - type: 'target', - }, - ], - }); - }); - test('service granular: access to single service', () => { - expect( - resolveResourceAssignment({ - organizationId: 'aaa', - projects: { - mode: 'granular', - projects: [ - { - id: 'bbb', - targets: { - mode: 'granular', - targets: [ - { - id: 'ccc', - type: 'target', - appDeployments: { mode: '*' }, - services: { - mode: 'granular', - services: [ - { - serviceName: 'my-service', - type: 'service', - }, - ], - }, - }, - ], - }, - type: 'project', - }, - ], - }, - }), - ).toEqual({ - organization: { - organizationId: 'aaa', - type: 'organization', - }, - project: [ - { - projectId: 'bbb', - type: 'project', - }, - ], - target: [ - { - targetId: 'ccc', - type: 'target', - }, - ], - service: [ - { - serviceName: 'my-service', - targetId: 'ccc', - type: 'service', - }, - ], - appDeployment: [ - { - targetId: 'ccc', - type: 'target', - }, - ], - }); - }); - test('service granular: access to multiple services', () => { - expect( - resolveResourceAssignment({ - organizationId: 'aaa', - projects: { - mode: 'granular', - projects: [ - { - id: 'bbb', - targets: { - mode: 'granular', - targets: [ - { - id: 'ccc', - type: 'target', - appDeployments: { mode: '*' }, - services: { - mode: 'granular', - services: [ - { - serviceName: 'my-service', - type: 'service', - }, - { - serviceName: 'my-other-service', - type: 'service', - }, - ], - }, - }, - ], - }, - type: 'project', - }, - ], - }, - }), - ).toEqual({ - organization: { - organizationId: 'aaa', - type: 'organization', - }, - project: [ - { - projectId: 'bbb', - type: 'project', - }, - ], - target: [ - { - targetId: 'ccc', - type: 'target', - }, - ], - service: [ - { - serviceName: 'my-service', - targetId: 'ccc', - type: 'service', - }, - { - serviceName: 'my-other-service', - targetId: 'ccc', - type: 'service', - }, - ], - appDeployment: [ - { - targetId: 'ccc', - type: 'target', - }, - ], - }); - }); - test('app deployment granular: access to single app deployment', () => { - expect( - resolveResourceAssignment({ - organizationId: 'aaa', - projects: { - mode: 'granular', - projects: [ - { - id: 'bbb', - targets: { - mode: 'granular', - targets: [ - { - id: 'ccc', - type: 'target', - appDeployments: { - mode: 'granular', - appDeployments: [{ appName: 'my-app', type: 'appDeployment' }], - }, - services: { - mode: 'granular', - services: [], - }, - }, - ], - }, - type: 'project', - }, - ], - }, - }), - ).toEqual({ - organization: { - organizationId: 'aaa', - type: 'organization', - }, - project: [ - { - projectId: 'bbb', - type: 'project', - }, - ], - target: [ - { - targetId: 'ccc', - type: 'target', - }, - ], - service: [], - appDeployment: [ - { - targetId: 'ccc', - appDeploymentName: 'my-app', - type: 'appDeployment', - }, - ], - }); - }); - test('app deployment granular: access to multiple app deployments', () => { - expect( - resolveResourceAssignment({ - organizationId: 'aaa', - projects: { - mode: 'granular', - projects: [ - { - id: 'bbb', - targets: { - mode: 'granular', - targets: [ - { - id: 'ccc', - type: 'target', - appDeployments: { - mode: 'granular', - appDeployments: [ - { appName: 'my-app', type: 'appDeployment' }, - { appName: 'my-other-app', type: 'appDeployment' }, - ], - }, - services: { - mode: 'granular', - services: [], - }, - }, - ], - }, - type: 'project', - }, - ], - }, - }), - ).toEqual({ - organization: { - organizationId: 'aaa', - type: 'organization', - }, - project: [ - { - projectId: 'bbb', - type: 'project', - }, - ], - target: [ - { - targetId: 'ccc', - type: 'target', - }, - ], - service: [], - appDeployment: [ - { - targetId: 'ccc', - appDeploymentName: 'my-app', - type: 'appDeployment', - }, - { - targetId: 'ccc', - appDeploymentName: 'my-other-app', - type: 'appDeployment', - }, - ], - }); - }); -}); diff --git a/packages/services/api/src/modules/organization/resolvers/Mutation/deleteOrganizationAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/deleteOrganizationAccessToken.ts index f70ed794cc..83acc5518c 100644 --- a/packages/services/api/src/modules/organization/resolvers/Mutation/deleteOrganizationAccessToken.ts +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/deleteOrganizationAccessToken.ts @@ -1,7 +1,17 @@ +import { OrganizationAccessTokens } from '../../providers/organization-access-tokens'; import type { MutationResolvers } from './../../../../__generated__/types'; export const deleteOrganizationAccessToken: NonNullable< MutationResolvers['deleteOrganizationAccessToken'] -> = async (_parent, _arg, _ctx) => { - /* Implement Mutation.deleteOrganizationAccessToken resolver logic here */ +> = async (_parent, args, { injector }) => { + const result = await injector.get(OrganizationAccessTokens).delete({ + organizationAccessTokenId: args.input.organizationAccessTokenId, + }); + + return { + ok: { + __typename: 'DeleteOrganizationAccessTokenResultOk', + deletedOrganizationAccessTokenId: result.organizationAccessTokenId, + }, + }; }; diff --git a/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts index e7fe750dc1..caf3465738 100644 --- a/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts +++ b/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts @@ -1,3 +1,4 @@ +import { ResourceAssignments } from '../providers/resource-assignments'; import type { OrganizationAccessTokenResolvers } from './../../../__generated__/types'; /* @@ -10,12 +11,10 @@ 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 = { - /* Implement OrganizationAccessToken resolver logic here */ - permissions: ({ permissions }, _arg, _ctx) => { - /* OrganizationAccessToken.permissions resolver is required because OrganizationAccessToken.permissions and OrganizationAccessTokenMapper.permissions are not compatible */ - return permissions; - }, - resources: async (_parent, _arg, _ctx) => { - /* OrganizationAccessToken.resources resolver is required because OrganizationAccessToken.resources exists but OrganizationAccessTokenMapper.resources does not */ + resources: async (accessToken, _arg, { injector }) => { + return injector.get(ResourceAssignments).resolveGraphQLMemberResourceAssignment({ + organizationId: accessToken.organizationId, + resources: accessToken.assignedResources, + }); }, }; diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index fbd0e3eb4d..94cddba363 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -413,7 +413,6 @@ export async function main() { organizationMembers: new OrganizationMembers( storage.pool, new OrganizationMemberRoles(storage.pool, logger), - storage, logger, ), }), From d68fd714a706e8a76367a8103b5797f72d5c826f Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 10 Feb 2025 13:30:03 +0100 Subject: [PATCH 04/33] missing key in index --- .../actions/2025.01.30T00-02-03.organization-access-tokens.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/migrations/src/actions/2025.01.30T00-02-03.organization-access-tokens.ts b/packages/migrations/src/actions/2025.01.30T00-02-03.organization-access-tokens.ts index 5ae253fce6..940b4292d0 100644 --- a/packages/migrations/src/actions/2025.01.30T00-02-03.organization-access-tokens.ts +++ b/packages/migrations/src/actions/2025.01.30T00-02-03.organization-access-tokens.ts @@ -18,6 +18,7 @@ export default { CREATE INDEX IF NOT EXISTS "organization_access_tokens_organization_id" ON "organization_access_tokens" ( "organization_id" , "created_at" DESC + , "id" DESC ); `, } satisfies MigrationExecutor; From 1e734738736c4dc580bce4cffb85bae080bf1c32 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 10 Feb 2025 13:30:30 +0100 Subject: [PATCH 05/33] missing tests --- .../providers/resource-assignments.spec.ts | 541 ++++++++++++++++++ 1 file changed, 541 insertions(+) create mode 100644 packages/services/api/src/modules/organization/providers/resource-assignments.spec.ts diff --git a/packages/services/api/src/modules/organization/providers/resource-assignments.spec.ts b/packages/services/api/src/modules/organization/providers/resource-assignments.spec.ts new file mode 100644 index 0000000000..46ea6f5d90 --- /dev/null +++ b/packages/services/api/src/modules/organization/providers/resource-assignments.spec.ts @@ -0,0 +1,541 @@ +import { resolveResourceAssignment } from './resource-assignments'; + +describe('resolveResourceAssignment', () => { + test('project wildcard: organization wide access to all resources', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: '*', + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: { + organizationId: 'aaa', + type: 'organization', + }, + target: { + organizationId: 'aaa', + type: 'organization', + }, + service: { + organizationId: 'aaa', + type: 'organization', + }, + appDeployment: { + organizationId: 'aaa', + type: 'organization', + }, + }); + }); + test('project granular: access to single project', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { mode: '*' }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + target: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + service: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + appDeployment: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + }); + }); + test('project granular: access to multiple projects', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { mode: '*' }, + type: 'project', + }, + { + id: 'ccc', + targets: { mode: '*' }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + { + projectId: 'ccc', + type: 'project', + }, + ], + target: [ + { + projectId: 'bbb', + type: 'project', + }, + { + projectId: 'ccc', + type: 'project', + }, + ], + service: [ + { + projectId: 'bbb', + type: 'project', + }, + { + projectId: 'ccc', + type: 'project', + }, + ], + appDeployment: [ + { + projectId: 'bbb', + type: 'project', + }, + { + projectId: 'ccc', + type: 'project', + }, + ], + }); + }); + test('target granular: access to single target', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { + mode: 'granular', + targets: [ + { + id: 'ccc', + type: 'target', + appDeployments: { mode: '*' }, + services: { mode: '*' }, + }, + ], + }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + target: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + service: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + appDeployment: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + }); + }); + test('target granular: access to multiple targets', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { + mode: 'granular', + targets: [ + { + id: 'ccc', + type: 'target', + appDeployments: { mode: '*' }, + services: { mode: '*' }, + }, + { + id: 'ddd', + type: 'target', + appDeployments: { mode: '*' }, + services: { mode: '*' }, + }, + ], + }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + target: [ + { + targetId: 'ccc', + type: 'target', + }, + { + targetId: 'ddd', + type: 'target', + }, + ], + service: [ + { + targetId: 'ccc', + type: 'target', + }, + { + targetId: 'ddd', + type: 'target', + }, + ], + appDeployment: [ + { + targetId: 'ccc', + type: 'target', + }, + { + targetId: 'ddd', + type: 'target', + }, + ], + }); + }); + test('service granular: access to single service', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { + mode: 'granular', + targets: [ + { + id: 'ccc', + type: 'target', + appDeployments: { mode: '*' }, + services: { + mode: 'granular', + services: [ + { + serviceName: 'my-service', + type: 'service', + }, + ], + }, + }, + ], + }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + target: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + service: [ + { + serviceName: 'my-service', + targetId: 'ccc', + type: 'service', + }, + ], + appDeployment: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + }); + }); + test('service granular: access to multiple services', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { + mode: 'granular', + targets: [ + { + id: 'ccc', + type: 'target', + appDeployments: { mode: '*' }, + services: { + mode: 'granular', + services: [ + { + serviceName: 'my-service', + type: 'service', + }, + { + serviceName: 'my-other-service', + type: 'service', + }, + ], + }, + }, + ], + }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + target: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + service: [ + { + serviceName: 'my-service', + targetId: 'ccc', + type: 'service', + }, + { + serviceName: 'my-other-service', + targetId: 'ccc', + type: 'service', + }, + ], + appDeployment: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + }); + }); + test('app deployment granular: access to single app deployment', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { + mode: 'granular', + targets: [ + { + id: 'ccc', + type: 'target', + appDeployments: { + mode: 'granular', + appDeployments: [{ appName: 'my-app', type: 'appDeployment' }], + }, + services: { + mode: 'granular', + services: [], + }, + }, + ], + }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + target: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + service: [], + appDeployment: [ + { + targetId: 'ccc', + appDeploymentName: 'my-app', + type: 'appDeployment', + }, + ], + }); + }); + test('app deployment granular: access to multiple app deployments', () => { + expect( + resolveResourceAssignment({ + organizationId: 'aaa', + projects: { + mode: 'granular', + projects: [ + { + id: 'bbb', + targets: { + mode: 'granular', + targets: [ + { + id: 'ccc', + type: 'target', + appDeployments: { + mode: 'granular', + appDeployments: [ + { appName: 'my-app', type: 'appDeployment' }, + { appName: 'my-other-app', type: 'appDeployment' }, + ], + }, + services: { + mode: 'granular', + services: [], + }, + }, + ], + }, + type: 'project', + }, + ], + }, + }), + ).toEqual({ + organization: { + organizationId: 'aaa', + type: 'organization', + }, + project: [ + { + projectId: 'bbb', + type: 'project', + }, + ], + target: [ + { + targetId: 'ccc', + type: 'target', + }, + ], + service: [], + appDeployment: [ + { + targetId: 'ccc', + appDeploymentName: 'my-app', + type: 'appDeployment', + }, + { + targetId: 'ccc', + appDeploymentName: 'my-other-app', + type: 'appDeployment', + }, + ], + }); + }); +}); From 563c69bb236621d1c78959931a6ca6bb1229da8c Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 10 Feb 2025 13:57:00 +0100 Subject: [PATCH 06/33] fixtures --- packages/services/storage/src/db/types.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index fc501ff46c..b0816afe5e 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -156,6 +156,18 @@ export interface oidc_integrations { userinfo_endpoint: string | null; } +export interface organization_access_tokens { + assigned_resources: any | null; + created_at: Date; + description: string; + first_characters: string | null; + hash: string | null; + id: string; + organization_id: string; + permissions: Array; + title: string; +} + export interface organization_invitations { code: string; created_at: Date; @@ -422,6 +434,7 @@ export interface DBTables { document_preflight_scripts: document_preflight_scripts; migration: migration; oidc_integrations: oidc_integrations; + organization_access_tokens: organization_access_tokens; organization_invitations: organization_invitations; organization_member: organization_member; organization_member_roles: organization_member_roles; From 0adf26518d872f3d767778c80ab54423dd765e1b Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 10 Feb 2025 14:05:49 +0100 Subject: [PATCH 07/33] access token strategy --- .../lib/organization-access-token-strategy.ts | 104 ++++++++++++++++++ .../auth/lib/target-access-token-strategy.ts | 5 - .../api/src/modules/organization/index.ts | 8 +- .../lib/organization-access-key.ts | 4 + .../providers/organization-access-tokens.ts | 23 ++-- packages/services/server/src/index.ts | 7 ++ 6 files changed, 136 insertions(+), 15 deletions(-) create mode 100644 packages/services/api/src/modules/auth/lib/organization-access-token-strategy.ts 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 new file mode 100644 index 0000000000..dcaffee23c --- /dev/null +++ b/packages/services/api/src/modules/auth/lib/organization-access-token-strategy.ts @@ -0,0 +1,104 @@ +import { DatabasePool } from 'slonik'; +import { type FastifyReply, type FastifyRequest } from '@hive/service-common'; +import * as OrganizationAccessKey from '../../organization/lib/organization-access-key'; +import { findById } from '../../organization/providers/organization-access-tokens'; +import { Logger } from '../../shared/providers/logger'; +import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz'; + +export class OrganizationAccessTokenSession extends Session { + public readonly organizationId: string; + private policies: Array; + + constructor( + args: { + organizationId: string; + policies: Array; + }, + deps: { + logger: Logger; + }, + ) { + super({ logger: deps.logger }); + this.organizationId = args.organizationId; + this.policies = args.policies; + } + + protected loadPolicyStatementsForOrganization( + _: string, + ): Promise> | Array { + return this.policies; + } +} + +export class OrganizationAccessTokenStrategy extends AuthNStrategy { + private logger: Logger; + + private findOrganizationAccessTokenById: ReturnType; + + constructor(deps: { logger: Logger; pool: DatabasePool }) { + super(); + this.logger = deps.logger.child({ module: 'OrganizationAccessTokenStrategy' }); + this.findOrganizationAccessTokenById = findById(deps); + } + + async parse(args: { + req: FastifyRequest; + reply: FastifyReply; + }): Promise { + this.logger.debug('Attempt to resolve an API token from headers'); + let value: string | null = null; + for (const headerName in args.req.headers) { + if (headerName.toLowerCase() !== 'authorization') { + continue; + } + const values = args.req.headers[headerName]; + value = (Array.isArray(values) ? values.at(0) : values) ?? null; + } + + if (!value) { + this.logger.debug('No access token header found.'); + return null; + } + + if (!value.startsWith('Bearer ')) { + this.logger.debug('Access token does not start with "Bearer ".'); + return null; + } + + const accessToken = value.replace('Bearer ', ''); + const result = OrganizationAccessKey.decode(accessToken); + if (result.type === 'failure') { + this.logger.debug(result.reason); + return null; + } + + const organizationAccessToken = await this.findOrganizationAccessTokenById( + result.token.accessTokenRecordId, + ); + + if (!organizationAccessToken) { + return null; + } + + const isHashMatch = await OrganizationAccessKey.verify( + result.token.privateKey, + organizationAccessToken.hash, + ); + + if (!isHashMatch) { + this.logger.debug('Provided private key does not match hash.'); + return null; + } + + return new OrganizationAccessTokenSession( + { + organizationId: organizationAccessToken.organizationId, + // TODO: translate access tokens stuff to policies + policies: [], + }, + { + logger: args.req.log, + }, + ); + } +} 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 1941418da2..a2846addea 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 @@ -110,11 +110,6 @@ export class TargetAccessTokenStrategy extends AuthNStrategy; + constructor( @Inject(PG_POOL_CONFIG) private pool: DatabasePool, private resourceAssignments: ResourceAssignments, @@ -59,6 +61,7 @@ export class OrganizationAccessTokens { this.logger = logger.child({ source: 'OrganizationAccessTokens', }); + this.findById = findById({ logger: this.logger, pool }); } async create(args: { @@ -147,7 +150,7 @@ export class OrganizationAccessTokens { assignedResources: GraphQLSchema.ResourceAssignmentInput | null; }; }) { - const record = await this.findOrganizationAccessTokenById(args.organizationAccessTokenId); + const record = await this.findById(args.organizationAccessTokenId); if (record === null) { throw new InsufficientPermissionError('accessToken:modify'); } @@ -210,7 +213,7 @@ export class OrganizationAccessTokens { } async delete(args: { organizationAccessTokenId: string }) { - const record = await this.findOrganizationAccessTokenById(args.organizationAccessTokenId); + const record = await this.findById(args.organizationAccessTokenId); if (record === null) { throw new InsufficientPermissionError('accessToken:modify'); } @@ -234,22 +237,24 @@ export class OrganizationAccessTokens { organizationAccessTokenId: args.organizationAccessTokenId, }; } +} - private async findOrganizationAccessTokenById(organizationAccessTokenId: string) { - this.logger.debug( +export function findById(deps: { pool: DatabasePool; logger: Logger }) { + return async function findByIdImplementation(organizationAccessTokenId: string) { + deps.logger.debug( 'Resolve organization access token by id. (organizationAccessTokenId=%s)', organizationAccessTokenId, ); if (isUUID(organizationAccessTokenId) === false) { - this.logger.debug( + deps.logger.debug( 'Invalid UUID provided. (organizationAccessTokenId=%s)', organizationAccessTokenId, ); return null; } - const data = await this.pool.maybeOne(sql` + const data = await deps.pool.maybeOne(sql` SELECT ${organizationAccessTokenFields} FROM @@ -260,7 +265,7 @@ export class OrganizationAccessTokens { `); if (data === null) { - this.logger.debug( + deps.logger.debug( 'Organization access token not found. (organizationAccessTokenId=%s)', organizationAccessTokenId, ); @@ -269,13 +274,13 @@ export class OrganizationAccessTokens { const result = OrganizationAccessTokenModel.parse(data); - this.logger.debug( + deps.logger.debug( 'Organization access token found. (organizationAccessTokenId=%s)', organizationAccessTokenId, ); return result; - } + }; } const organizationAccessTokenFields = sql` diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index 94cddba363..d3909e094c 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -12,6 +12,8 @@ import { createRedisEventTarget } from '@graphql-yoga/redis-event-target'; import 'reflect-metadata'; import { hostname } from 'os'; import { createPubSub } from 'graphql-yoga'; +import { OrganizationAccessTokenStrategy } from 'packages/services/api/src/modules/auth/lib/organization-access-token-strategy'; +import { OrganizationAccessTokens } from 'packages/services/api/src/modules/organization/providers/organization-access-tokens'; import { z } from 'zod'; import formDataPlugin from '@fastify/formbody'; import { @@ -416,6 +418,11 @@ export async function main() { logger, ), }), + (logger: Logger) => + new OrganizationAccessTokenStrategy({ + logger, + pool: storage.pool, + }), (logger: Logger) => new TargetAccessTokenStrategy({ logger, From 5ad7426f2f8be300f3133013c2afc08f928b348f Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 11 Feb 2025 16:19:44 +0100 Subject: [PATCH 08/33] more? --- package.json | 3 +- packages/services/api/package.json | 2 + .../lib/organization-access-token-strategy.ts | 22 +-- .../modules/auth/lib/supertokens-strategy.ts | 108 +---------- .../api/src/modules/organization/index.ts | 4 + .../lib/organization-access-key.ts | 21 ++- .../organization-access-token-permissions.ts | 1 - .../lib/organization-member-permissions.ts | 3 + .../modules/organization/module.graphql.ts | 36 ---- .../organization-access-tokens-cache.ts | 69 +++++++ .../providers/organization-access-tokens.ts | 138 +++++--------- .../providers/organization-members.ts | 29 +-- .../providers/resource-assignments.ts | 101 +++++++++- .../Mutation/updateOrganizationAccessToken.ts | 33 ---- packages/services/server/src/index.ts | 6 +- patches/bentocache.patch | 13 ++ pnpm-lock.yaml | 173 ++++++++++++++++++ 17 files changed, 462 insertions(+), 300 deletions(-) create mode 100644 packages/services/api/src/modules/organization/providers/organization-access-tokens-cache.ts delete mode 100644 packages/services/api/src/modules/organization/resolvers/Mutation/updateOrganizationAccessToken.ts create mode 100644 patches/bentocache.patch diff --git a/package.json b/package.json index 3773cbd104..d9ca34f3d1 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,8 @@ "countup.js": "patches/countup.js.patch", "@oclif/core@4.0.6": "patches/@oclif__core@4.0.6.patch", "@fastify/vite": "patches/@fastify__vite.patch", - "p-cancelable@4.0.1": "patches/p-cancelable@4.0.1.patch" + "p-cancelable@4.0.1": "patches/p-cancelable@4.0.1.patch", + "bentocache": "patches/bentocache.patch" } } } diff --git a/packages/services/api/package.json b/packages/services/api/package.json index 1f38d9dfa0..e19818b117 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -44,6 +44,8 @@ "@types/object-hash": "3.0.6", "agentkeepalive": "4.6.0", "bcryptjs": "2.4.3", + "bentocache": "1.1.0", + "bentocache": "1.1.0", "csv-stringify": "6.5.2", "dataloader": "2.2.3", "date-fns": "4.1.0", 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 dcaffee23c..ada36c7f02 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,7 +1,6 @@ -import { DatabasePool } from 'slonik'; import { type FastifyReply, type FastifyRequest } from '@hive/service-common'; import * as OrganizationAccessKey from '../../organization/lib/organization-access-key'; -import { findById } from '../../organization/providers/organization-access-tokens'; +import { OrganizationAccessTokensCache } from '../../organization/providers/organization-access-tokens-cache'; import { Logger } from '../../shared/providers/logger'; import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz'; @@ -33,12 +32,12 @@ export class OrganizationAccessTokenSession extends Session { export class OrganizationAccessTokenStrategy extends AuthNStrategy { private logger: Logger; - private findOrganizationAccessTokenById: ReturnType; + private cache: OrganizationAccessTokensCache; - constructor(deps: { logger: Logger; pool: DatabasePool }) { + constructor(deps: { logger: Logger; cache: OrganizationAccessTokensCache }) { super(); this.logger = deps.logger.child({ module: 'OrganizationAccessTokenStrategy' }); - this.findOrganizationAccessTokenById = findById(deps); + this.cache = deps.cache; } async parse(args: { @@ -67,21 +66,19 @@ export class OrganizationAccessTokenStrategy extends AuthNStrategy { @@ -117,97 +108,6 @@ export class SuperTokensCookieBasedSession extends Session { public isViewer() { return true; } - - private toResourceIdentifier(organizationId: string, resource: ResourceAssignment): string; - private toResourceIdentifier( - organizationId: string, - resource: ResourceAssignment | Array, - ): Array; - private toResourceIdentifier( - organizationId: string, - resource: ResourceAssignment | Array, - ): string | Array { - if (Array.isArray(resource)) { - return resource.map(resource => this.toResourceIdentifier(organizationId, resource)); - } - - if (resource.type === 'organization') { - return `hrn:${organizationId}:organization/${resource.organizationId}`; - } - - if (resource.type === 'project') { - return `hrn:${organizationId}:project/${resource.projectId}`; - } - - if (resource.type === 'target') { - return `hrn:${organizationId}:target/${resource.targetId}`; - } - - if (resource.type === 'service') { - return `hrn:${organizationId}:target/${resource.targetId}/service/${resource.serviceName}`; - } - - if (resource.type === 'appDeployment') { - return `hrn:${organizationId}:target/${resource.targetId}/appDeployment/${resource.appDeploymentName}`; - } - - casesExhausted(resource); - } - - private translateAssignedRolesToAuthorizationPolicyStatements( - organizationId: string, - assignedRole: OrganizationMembershipRoleAssignment, - ): Array { - const policyStatements: Array = []; - - if (assignedRole.role.permissions.organization.size) { - policyStatements.push({ - action: Array.from(assignedRole.role.permissions.organization), - effect: 'allow', - resource: this.toResourceIdentifier( - organizationId, - assignedRole.resolvedResources.organization, - ), - }); - } - - if (assignedRole.role.permissions.project.size) { - policyStatements.push({ - action: Array.from(assignedRole.role.permissions.project), - effect: 'allow', - resource: this.toResourceIdentifier(organizationId, assignedRole.resolvedResources.project), - }); - } - - if (assignedRole.role.permissions.target.size) { - policyStatements.push({ - action: Array.from(assignedRole.role.permissions.target), - effect: 'allow', - resource: this.toResourceIdentifier(organizationId, assignedRole.resolvedResources.target), - }); - } - - if (assignedRole.role.permissions.service.size) { - policyStatements.push({ - action: Array.from(assignedRole.role.permissions.service), - effect: 'allow', - resource: this.toResourceIdentifier(organizationId, assignedRole.resolvedResources.service), - }); - } - - if (assignedRole.role.permissions.appDeployment.size) { - policyStatements.push({ - action: Array.from(assignedRole.role.permissions.appDeployment), - effect: 'allow', - resource: this.toResourceIdentifier( - organizationId, - assignedRole.resolvedResources.appDeployment, - ), - }); - } - - return policyStatements; - } } export class SuperTokensUserAuthNStrategy extends AuthNStrategy { @@ -325,7 +225,3 @@ const SuperTokenAccessTokenModel = zod.object({ superTokensUserId: zod.string(), email: zod.string(), }); - -function casesExhausted(_value: never): never { - throw new Error('Not all cases were handled.'); -} diff --git a/packages/services/api/src/modules/organization/index.ts b/packages/services/api/src/modules/organization/index.ts index 84ca1a8418..d4812c3c48 100644 --- a/packages/services/api/src/modules/organization/index.ts +++ b/packages/services/api/src/modules/organization/index.ts @@ -1,8 +1,10 @@ import { createModule } from 'graphql-modules'; import { OrganizationAccessTokens } from './providers/organization-access-tokens'; +import { OrganizationAccessTokensCache } from './providers/organization-access-tokens-cache'; import { OrganizationManager } from './providers/organization-manager'; import { OrganizationMemberRoles } from './providers/organization-member-roles'; import { OrganizationMembers } from './providers/organization-members'; +import { ResourceAssignments } from './providers/resource-assignments'; import { resolvers } from './resolvers.generated'; import typeDefs from './module.graphql'; @@ -16,5 +18,7 @@ export const organizationModule = createModule({ OrganizationMembers, OrganizationManager, OrganizationAccessTokens, + ResourceAssignments, + OrganizationAccessTokensCache, ], }); diff --git a/packages/services/api/src/modules/organization/lib/organization-access-key.ts b/packages/services/api/src/modules/organization/lib/organization-access-key.ts index c28c9472ba..012fc1dbfb 100644 --- a/packages/services/api/src/modules/organization/lib/organization-access-key.ts +++ b/packages/services/api/src/modules/organization/lib/organization-access-key.ts @@ -6,6 +6,11 @@ import bcrypt from 'bcryptjs'; * Contains functions for generating an organization acces key. */ +type DecodedAccessKey = { + id: string; + privateKey: string; +}; + /** * Prefix for the access key * **hv** -> Hive @@ -13,14 +18,16 @@ import bcrypt from 'bcryptjs'; * **1** -> Version 1 */ const keyPrefix = 'hvo1/'; -const decodeError = { type: 'failure', reason: 'Invalid access token.' } as const; +const decodeError = { type: 'error' as const, reason: 'Invalid access token.' }; function encode(recordId: string, secret: string) { const keyContents = [recordId, secret].join(':'); return keyPrefix + btoa(keyContents); } -export function decode(accessToken: string) { +export function decode( + accessToken: string, +): { type: 'error'; reason: string } | { type: 'ok'; accessKey: DecodedAccessKey } { if (!accessToken.startsWith(keyPrefix)) { return decodeError; } @@ -41,23 +48,23 @@ export function decode(accessToken: string) { return decodeError; } - const accessTokenRecordId = parts.at(0); + const id = parts.at(0); const privateKey = parts.at(1); - if (accessTokenRecordId && privateKey) { - return { type: 'success', token: { accessTokenRecordId, privateKey } } as const; + if (id && privateKey) { + return { type: 'ok', accessKey: { id, privateKey } } as const; } return decodeError; } -export async function create(recordId: string) { +export async function create(id: string) { const secret = Crypto.createHash('sha256') .update(Crypto.randomBytes(20).toString()) .digest('hex'); const hash = await bcrypt.hash(secret, await bcrypt.genSalt()); - const privateAccessToken = encode(recordId, secret); + const privateAccessToken = encode(id, secret); const firstCharacters = privateAccessToken.substr(0, 10); return { 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 ff10d1df63..a3c5b37a9d 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 @@ -1,4 +1,3 @@ -import { allPermissions, type Permission } from '../../auth/lib/authz'; import { type PermissionGroup } from './permissions'; export const allPermissionGroups: Array = [ 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 8cd89bfbc7..84cc00fea9 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 @@ -265,6 +265,9 @@ assertAllRulesAreAssigned([ 'appDeployment:create', 'appDeployment:publish', 'appDeployment:retire', + + 'accessToken:modify', + 'usage:report', ]); /** diff --git a/packages/services/api/src/modules/organization/module.graphql.ts b/packages/services/api/src/modules/organization/module.graphql.ts index cf0927666a..ff50c1e11a 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -38,9 +38,6 @@ export default gql` createOrganizationAccessToken( input: CreateOrganizationAccessTokenInput! ): CreateOrganizationAccessTokenResult! - updateOrganizationAccessToken( - input: UpdateOrganizationAccessTokenInput! - ): UpdateOrganizationAccessTokenResult! deleteOrganizationAccessToken( input: DeleteOrganizationAccessTokenInput! ): DeleteOrganizationAccessTokenResult! @@ -94,39 +91,6 @@ export default gql` createdAt: Date! } - input UpdateOrganizationAccessTokenInput { - organizationAccessTokenId: ID! - title: String - description: String - permissions: [String!] - resources: ResourceAssignmentInput - } - - type UpdateOrganizationAccessTokenResult { - ok: UpdateOrganizationAccessTokenResultOk - error: UpdateOrganizationAccessTokenResultError - } - - type UpdateOrganizationAccessTokenResultOk { - updatedOrganizationAccessToken: OrganizationAccessToken! - } - - type UpdateOrganizationAccessTokenResultError implements Error { - message: String! - details: UpdateOrganizationAccessTokenResultErrorDetails - } - - type UpdateOrganizationAccessTokenResultErrorDetails { - """ - Error message for the input title. - """ - title: String - """ - Error message for the input description. - """ - description: String - } - input DeleteOrganizationAccessTokenInput { organizationAccessTokenId: ID! } 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 new file mode 100644 index 0000000000..e0d2cee953 --- /dev/null +++ b/packages/services/api/src/modules/organization/providers/organization-access-tokens-cache.ts @@ -0,0 +1,69 @@ +import { BentoCache, bentostore } from 'bentocache'; +import { memoryDriver } from 'bentocache/build/src/drivers/memory'; +import { redisDriver } from 'bentocache/build/src/drivers/redis'; +import { Inject, Injectable, Scope } from 'graphql-modules'; +import Redis from 'ioredis'; +import type { DatabasePool } from 'slonik'; +import { Logger } from '../../shared/providers/logger'; +import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; +import { REDIS_INSTANCE } from '../../shared/providers/redis'; +import { findById, type OrganizationAccessToken } from './organization-access-tokens'; + +/** + * Cache for performant OrganizationAccessToken lookups. + */ +@Injectable({ + scope: Scope.Singleton, + global: true, +}) +export class OrganizationAccessTokensCache { + private findById: ReturnType; + private cache: BentoCache<{ store: ReturnType }>; + + constructor( + @Inject(REDIS_INSTANCE) redis: Redis, + @Inject(PG_POOL_CONFIG) pool: DatabasePool, + logger: Logger, + ) { + this.findById = findById({ pool, logger }); + this.cache = new BentoCache({ + default: 'organizationAccessTokens', + stores: { + organizationAccessTokens: bentostore() + .useL1Layer( + memoryDriver({ + maxItems: 10_000, + prefix: 'bentocache:organization-access-tokens', + }), + ) + .useL2Layer( + redisDriver({ connection: redis, prefix: 'bentocache:organization-access-tokens' }), + ), + }, + }); + } + + get(id: string) { + return this.cache.getOrSet({ + key: id, + factory: () => this.findById(id), + ttl: '5min', + grace: '24h', + }); + } + + add(token: OrganizationAccessToken) { + return this.cache.set({ + key: token.id, + value: token, + ttl: '5min', + grace: '24h', + }); + } + + purge(token: OrganizationAccessToken) { + return this.cache.delete({ + key: token.id, + }); + } +} diff --git a/packages/services/api/src/modules/organization/providers/organization-access-tokens.ts b/packages/services/api/src/modules/organization/providers/organization-access-tokens.ts index be143e9bce..2ead8ac6dd 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,17 +1,24 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; -import { DatabasePool, sql } from 'slonik'; +import { sql, type DatabasePool } from 'slonik'; import { z } from 'zod'; import * as GraphQLSchema from '../../../__generated__/types'; import { isUUID } from '../../../shared/is-uuid'; -import { InsufficientPermissionError, PermissionsModel, Session } from '../../auth/lib/authz'; +import { + InsufficientPermissionError, + PermissionsModel, + permissionsToPermissionsPerResourceLevelAssignment, + Session, +} from '../../auth/lib/authz'; import { IdTranslator } from '../../shared/providers/id-translator'; import { Logger } from '../../shared/providers/logger'; import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; import * as OrganizationAccessKey from '../lib/organization-access-key'; +import { OrganizationAccessTokensCache } from './organization-access-tokens-cache'; import { AssignedProjectsModel, - ResourceAssignmentGroup, + resolveResourceAssignment, ResourceAssignments, + translateResolvedResourcesToAuthorizationPolicyStatements, } from './resource-assignments'; const TitleInputModel = z @@ -27,19 +34,36 @@ const DescriptionInputModel = z .max(248, 'Maximum length is 248 characters.') .nullable(); -const OrganizationAccessTokenModel = z.object({ - id: z.string().uuid(), - organizationId: z.string().uuid(), - createdAt: z.string(), - title: z.string(), - description: z.string(), - permissions: z.array(PermissionsModel), - assignedResources: AssignedProjectsModel.nullable().transform( - value => value ?? { mode: '*' as const, projects: [] }, - ), - firstCharacters: z.string(), - hash: z.string(), -}); +const OrganizationAccessTokenModel = z + .object({ + id: z.string().uuid(), + organizationId: z.string().uuid(), + createdAt: z.string(), + title: z.string(), + description: z.string(), + permissions: z.array(PermissionsModel), + assignedResources: AssignedProjectsModel.nullable().transform( + value => value ?? { mode: '*' as const, projects: [] }, + ), + firstCharacters: z.string(), + hash: z.string(), + }) + .transform(record => ({ + ...record, + get authorizationPolicyStatements() { + const permissions = permissionsToPermissionsPerResourceLevelAssignment(record.permissions); + const resolvedResources = resolveResourceAssignment({ + organizationId: record.organizationId, + projects: record.assignedResources, + }); + + return translateResolvedResourcesToAuthorizationPolicyStatements( + record.organizationId, + permissions, + resolvedResources, + ); + }, + })); export type OrganizationAccessToken = z.TypeOf; @@ -53,6 +77,7 @@ export class OrganizationAccessTokens { constructor( @Inject(PG_POOL_CONFIG) private pool: DatabasePool, + private cache: OrganizationAccessTokensCache, private resourceAssignments: ResourceAssignments, private idTranslator: IdTranslator, private session: Session, @@ -123,7 +148,7 @@ export class OrganizationAccessTokens { , ${organizationId} , ${titleResult.data} , ${descriptionResult.data} - , ${sql.array(args.permissions, 'text')}, + , ${sql.array(args.permissions, 'text')} , ${sql.jsonb(assignedResources)} , ${accessKey.hash} , ${accessKey.firstCharacters} @@ -134,6 +159,8 @@ export class OrganizationAccessTokens { const organizationAccessToken = OrganizationAccessTokenModel.parse(result); + await this.cache.add(organizationAccessToken); + return { type: 'success' as const, organizationAccessToken, @@ -141,77 +168,6 @@ export class OrganizationAccessTokens { }; } - async update(args: { - organizationAccessTokenId: string; - data: { - title: string | null; - description: string | null; - permissions: Array | null; - assignedResources: GraphQLSchema.ResourceAssignmentInput | null; - }; - }) { - const record = await this.findById(args.organizationAccessTokenId); - if (record === null) { - throw new InsufficientPermissionError('accessToken:modify'); - } - - await this.session.assertPerformAction({ - action: 'accessToken:modify', - organizationId: record.organizationId, - params: { organizationId: record.organizationId }, - }); - - const titleResult = TitleInputModel.nullable().safeParse(args.data.title); - const descriptionResult = DescriptionInputModel.nullable().safeParse(args.data.description); - - if (titleResult.error || descriptionResult.error) { - return { - type: 'error' as const, - message: 'Invalid input provided.', - details: { - title: titleResult.error?.issues.at(0)?.message ?? null, - description: descriptionResult.error?.issues.at(0)?.message ?? null, - }, - }; - } - - let assignedResources: ResourceAssignmentGroup | null = null; - - if (args.data.assignedResources) { - assignedResources = - await this.resourceAssignments.transformGraphQLResourceAssignmentInputToResourceAssignmentGroup( - record.organizationId, - args.data.assignedResources, - ); - } - - let permissions: Array | null = null; - if (args.data.permissions) { - // TODO: validate permissions - permissions = args.data.permissions; - } - - const result = await this.pool.maybeOne(sql` - UPDATE - "organization_access_tokens" - SET - "title" = COALESCE(${titleResult.data}, "title") - , "description" = COALESCE(${descriptionResult.data}, "description") - , "permissions" = COALESCE(${permissions}, "permissions") - , "assigned_resources" = COALESCE(${sql.jsonb(assignedResources)}, "permissions") - ) - WHERE - "id" = ${args.organizationAccessTokenId} - RETURNING - ${organizationAccessTokenFields} - `); - - return { - type: 'success' as const, - organizationAccessToken: OrganizationAccessTokenModel.parse(result), - }; - } - async delete(args: { organizationAccessTokenId: string }) { const record = await this.findById(args.organizationAccessTokenId); if (record === null) { @@ -232,6 +188,8 @@ export class OrganizationAccessTokens { "id" = ${args.organizationAccessTokenId} `); + await this.cache.purge(record); + return { type: 'success' as const, organizationAccessTokenId: args.organizationAccessTokenId, @@ -239,6 +197,10 @@ export class OrganizationAccessTokens { } } +/** + * 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. + */ export function findById(deps: { pool: DatabasePool; logger: Logger }) { return async function findByIdImplementation(organizationAccessTokenId: string) { deps.logger.debug( 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 1971b33591..b8b5496483 100644 --- a/packages/services/api/src/modules/organization/providers/organization-members.ts +++ b/packages/services/api/src/modules/organization/providers/organization-members.ts @@ -1,16 +1,17 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; import { sql, type DatabasePool } from 'slonik'; import { z } from 'zod'; -import { type Organization, type Project } from '../../../shared/entities'; +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 { OrganizationMemberRoles, type OrganizationMemberRole } from './organization-member-roles'; import { AssignedProjectsModel, - ResolvedResourceAssignments, resolveResourceAssignment, ResourceAssignmentGroup, + translateResolvedResourcesToAuthorizationPolicyStatements, } from './resource-assignments'; const RawOrganizationMembershipModel = z.object({ @@ -37,9 +38,9 @@ export type OrganizationMembershipRoleAssignment = { */ resources: ResourceAssignmentGroup; /** - * Resolved resource groups, used for runtime permission checks. + * Resolved policy statements */ - resolvedResources: ResolvedResourceAssignments; + authorizationPolicyStatements: AuthorizationPolicyStatement[]; }; export type OrganizationMembership = { @@ -122,20 +123,26 @@ export class OrganizationMembers { projects: [], }; - const resolvedResources = resolveResourceAssignment({ - organizationId: organization.id, - projects: resources, - }); - organizationMembershipByUserId.set(record.userId, { organizationId: organization.id, userId: record.userId, isOwner: organization.ownerId === record.userId, connectedToZendesk: record.connectedToZendesk, assignedRole: { - resources, - resolvedResources, role: membershipRole, + resources, + get authorizationPolicyStatements() { + const resolvedResources = resolveResourceAssignment({ + organizationId: organization.id, + projects: resources, + }); + + return translateResolvedResourcesToAuthorizationPolicyStatements( + organization.id, + membershipRole.permissions, + resolvedResources, + ); + }, }, }); } 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 a4d053de70..4daabb5f4f 100644 --- a/packages/services/api/src/modules/organization/providers/resource-assignments.ts +++ b/packages/services/api/src/modules/organization/providers/resource-assignments.ts @@ -1,8 +1,14 @@ +import { Injectable, Scope } from 'graphql-modules'; import { z } from 'zod'; -import { Logger, Project } from '@hive/api'; import * as GraphQLSchema from '../../../__generated__/types'; +import type { Project } from '../../../shared/entities'; import { isUUID } from '../../../shared/is-uuid'; import { AppDeploymentNameModel } from '../../app-deployments/providers/app-deployments'; +import { + AuthorizationPolicyStatement, + PermissionsPerResourceLevelAssignment, +} from '../../auth/lib/authz'; +import { Logger } from '../../shared/providers/logger'; import { Storage } from '../../shared/providers/storage'; const WildcardAssignmentModeModel = z.literal('*'); @@ -85,6 +91,9 @@ export const AssignedProjectsModel = z.union([ export type ResourceAssignmentGroup = z.TypeOf; type GranularAssignedProjects = z.TypeOf; +@Injectable({ + scope: Scope.Operation, +}) export class ResourceAssignments { private logger: Logger; @@ -462,3 +471,93 @@ export function resolveResourceAssignment(args: { appDeployment: appDeploymentAssignments, }; } + +function casesExhausted(_value: never): never { + throw new Error('Not all cases were handled.'); +} + +export function toResourceIdentifier(organizationId: string, resource: ResourceAssignment): string; +export function toResourceIdentifier( + organizationId: string, + resource: ResourceAssignment | Array, +): Array; +export function toResourceIdentifier( + organizationId: string, + resource: ResourceAssignment | Array, +): string | Array { + if (Array.isArray(resource)) { + return resource.map(resource => toResourceIdentifier(organizationId, resource)); + } + + if (resource.type === 'organization') { + return `hrn:${organizationId}:organization/${resource.organizationId}`; + } + + if (resource.type === 'project') { + return `hrn:${organizationId}:project/${resource.projectId}`; + } + + if (resource.type === 'target') { + return `hrn:${organizationId}:target/${resource.targetId}`; + } + + if (resource.type === 'service') { + return `hrn:${organizationId}:target/${resource.targetId}/service/${resource.serviceName}`; + } + + if (resource.type === 'appDeployment') { + return `hrn:${organizationId}:target/${resource.targetId}/appDeployment/${resource.appDeploymentName}`; + } + + casesExhausted(resource); +} + +export function translateResolvedResourcesToAuthorizationPolicyStatements( + organizationId: string, + permissions: PermissionsPerResourceLevelAssignment, + resourceAssignments: ResolvedResourceAssignments, +) { + const policyStatements: Array = []; + + if (permissions.organization.size) { + policyStatements.push({ + action: Array.from(permissions.organization), + effect: 'allow', + resource: toResourceIdentifier(organizationId, resourceAssignments.organization), + }); + } + + if (permissions.project.size) { + policyStatements.push({ + action: Array.from(permissions.project), + effect: 'allow', + resource: toResourceIdentifier(organizationId, resourceAssignments.project), + }); + } + + if (permissions.target.size) { + policyStatements.push({ + action: Array.from(permissions.target), + effect: 'allow', + resource: toResourceIdentifier(organizationId, resourceAssignments.target), + }); + } + + if (permissions.service.size) { + policyStatements.push({ + action: Array.from(permissions.service), + effect: 'allow', + resource: toResourceIdentifier(organizationId, resourceAssignments.service), + }); + } + + if (permissions.appDeployment.size) { + policyStatements.push({ + action: Array.from(permissions.appDeployment), + effect: 'allow', + resource: toResourceIdentifier(organizationId, resourceAssignments.appDeployment), + }); + } + + return policyStatements; +} diff --git a/packages/services/api/src/modules/organization/resolvers/Mutation/updateOrganizationAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/updateOrganizationAccessToken.ts deleted file mode 100644 index 8cc30ffc36..0000000000 --- a/packages/services/api/src/modules/organization/resolvers/Mutation/updateOrganizationAccessToken.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { OrganizationAccessTokens } from '../../providers/organization-access-tokens'; -import type { MutationResolvers } from './../../../../__generated__/types'; - -export const updateOrganizationAccessToken: NonNullable< - MutationResolvers['updateOrganizationAccessToken'] -> = async (_, args, { injector }) => { - const result = await injector.get(OrganizationAccessTokens).update({ - organizationAccessTokenId: args.input.organizationAccessTokenId, - data: { - title: args.input.title ?? null, - description: args.input.description ?? null, - permissions: [...(args.input.permissions ?? [])], - assignedResources: args.input.resources ?? null, - }, - }); - - if (result.type === 'success') { - return { - ok: { - __typename: 'UpdateOrganizationAccessTokenResultOk', - updatedOrganizationAccessToken: result.organizationAccessToken, - }, - }; - } - - return { - error: { - __typename: 'UpdateOrganizationAccessTokenResultError', - message: result.message, - details: result.details, - }, - }; -}; diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index d3909e094c..19d6d4fc67 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -12,8 +12,6 @@ import { createRedisEventTarget } from '@graphql-yoga/redis-event-target'; import 'reflect-metadata'; import { hostname } from 'os'; import { createPubSub } from 'graphql-yoga'; -import { OrganizationAccessTokenStrategy } from 'packages/services/api/src/modules/auth/lib/organization-access-token-strategy'; -import { OrganizationAccessTokens } from 'packages/services/api/src/modules/organization/providers/organization-access-tokens'; import { z } from 'zod'; import formDataPlugin from '@fastify/formbody'; import { @@ -57,8 +55,10 @@ import { } from '@sentry/node'; import { createServerAdapter } from '@whatwg-node/server'; import { AuthN } from '../../api/src/modules/auth/lib/authz'; +import { OrganizationAccessTokenStrategy } from '../../api/src/modules/auth/lib/organization-access-token-strategy'; import { SuperTokensUserAuthNStrategy } from '../../api/src/modules/auth/lib/supertokens-strategy'; import { TargetAccessTokenStrategy } from '../../api/src/modules/auth/lib/target-access-token-strategy'; +import { OrganizationAccessTokensCache } from '../../api/src/modules/organization/providers/organization-access-tokens-cache'; import { createContext, internalApiRouter } from './api'; import { asyncStorage } from './async-storage'; import { env } from './environment'; @@ -421,7 +421,7 @@ export async function main() { (logger: Logger) => new OrganizationAccessTokenStrategy({ logger, - pool: storage.pool, + cache: registry.injector.get(OrganizationAccessTokensCache), }), (logger: Logger) => new TargetAccessTokenStrategy({ diff --git a/patches/bentocache.patch b/patches/bentocache.patch new file mode 100644 index 0000000000..1143f87364 --- /dev/null +++ b/patches/bentocache.patch @@ -0,0 +1,13 @@ +diff --git a/package.json b/package.json +index 26e97ac450795f4595d06b7b4b9d1d0f6b16700e..cabc9439217790a325c042f2931fe8c3761ccaa3 100644 +--- a/package.json ++++ b/package.json +@@ -15,6 +15,8 @@ + ], + "exports": { + ".": "./build/index.js", ++ "./build/src/drivers/redis": "./build/src/drivers/redis.js", ++ "./build/src/drivers/memory": "./build/src/drivers/memory.js", + "./drivers/redis": "./build/src/drivers/redis.js", + "./drivers/memory": "./build/src/drivers/memory.js", + "./drivers/file": "./build/src/drivers/file/file.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c5b69ffb9..fc4e7e39cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ patchedDependencies: '@theguild/editor@1.2.5': hash: a401455daa519af0fe686b4f970a02582f9e406c520aad19273a8eeef8f4adf7 path: patches/@theguild__editor@1.2.5.patch + bentocache: + hash: 98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af + path: patches/bentocache.patch countup.js: hash: 664547d4d5412a2891bfdfb34790bb773535102f8e26075dfafbd831d79f4410 path: patches/countup.js.patch @@ -757,6 +760,9 @@ importers: bcryptjs: specifier: 2.4.3 version: 2.4.3 + bentocache: + specifier: 1.1.0 + version: 1.1.0(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.4.2) csv-stringify: specifier: 6.5.2 version: 6.5.2 @@ -2937,6 +2943,15 @@ packages: '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@boringnode/bus@0.7.0': + resolution: {integrity: sha512-bOL0B22ukDG2wkd8WGGhTHp2I3YhcaphFXvt8oFwJ8/T+ERVECTG6WJBgH0h4B5l/8pKjbjNxmhIXniQ5RwI8g==} + engines: {node: '>=20.11.1'} + peerDependencies: + ioredis: ^5.0.0 + peerDependenciesMeta: + ioredis: + optional: true + '@braintree/sanitize-url@7.1.0': resolution: {integrity: sha512-o+UlMLt49RvtCASlOMW0AkHnabN9wR9rwCCherxO0yG4Npy34GkvrAqdXQvrhNs+jh+gkK8gB8Lf05qL/O7KWg==} @@ -4837,6 +4852,9 @@ packages: '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@julr/utils@1.7.0': + resolution: {integrity: sha512-9L0slidilvgiD46oqWhXE/KG20dkSEuxBFE6eH+w5BPWoMug9gQSFDZuijFmYcjlW+3vjjALCJZzXtOgHfZpjg==} + '@kamilkisiela/fast-url-parser@1.1.4': resolution: {integrity: sha512-gbkePEBupNydxCelHCESvFSFM8XPh1Zs/OAVRW/rKpEqPAl5PbOM90Si8mv9bvnR53uPD2s/FiRxdvSejpRJew==} @@ -5102,6 +5120,10 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@1.7.1': + resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -5827,6 +5849,9 @@ packages: cpu: [x64] os: [win32] + '@paralleldrive/cuid2@2.2.2': + resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} + '@parcel/watcher-android-arm64@2.5.0': resolution: {integrity: sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==} engines: {node: '>= 10.0.0'} @@ -5931,6 +5956,22 @@ packages: '@polka/url@1.0.0-next.25': resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==} + '@poppinss/exception@1.2.0': + resolution: {integrity: sha512-WLneXKQYNClhaMXccO111VQmZahSrcSRDaHRbV6KL5R4pTvK87fMn/MXLUcvOjk0X5dTHDPKF61tM7j826wrjQ==} + engines: {node: '>=20.6.0'} + + '@poppinss/object-builder@1.1.0': + resolution: {integrity: sha512-FOrOq52l7u8goR5yncX14+k+Ewi5djnrt1JwXeS/FvnwAPOiveFhiczCDuvXdssAwamtrV2hp5Rw9v+n2T7hQg==} + engines: {node: '>=20.6.0'} + + '@poppinss/string@1.2.0': + resolution: {integrity: sha512-1z78zjqhfjqsvWr+pQzCpRNcZpIM+5vNY5SFOvz28GrL/LRanwtmOku5tBX7jE8/ng3oXaOVrB59lnnXFtvkug==} + engines: {node: '>=20.6.0'} + + '@poppinss/utils@6.9.2': + resolution: {integrity: sha512-ypVszZxhwiehhklM5so2BI+nClQJwp7mBUSJh/R1GepeUH1vvD5GtxMz8Lp9dO9oAbKyDmq1jc4g/4E0dv8r2g==} + engines: {node: '>=18.16.0'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -7910,6 +7951,9 @@ packages: '@types/bunyan@1.8.9': resolution: {integrity: sha512-ZqS9JGpBxVOvsawzmVt30sP++gSQMTejCkIAQ3VdadOcRE8izTyW66hufvwLeH+YEGP6Js2AW7Gz+RMyvrEbmw==} + '@types/bytes@3.1.5': + resolution: {integrity: sha512-VgZkrJckypj85YxEsEavcMmmSOIzkUHqWmM4CCyia5dc54YwsXzJ5uT4fYxBQNEXx+oF1krlhgCbvfubXqZYsQ==} + '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -8100,6 +8144,9 @@ packages: '@types/pg@8.6.1': resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==} + '@types/pluralize@0.0.33': + resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} + '@types/prop-types@15.7.5': resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} @@ -8629,6 +8676,9 @@ packages: resolution: {integrity: sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==} hasBin: true + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} @@ -8716,6 +8766,26 @@ packages: before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + bentocache@1.1.0: + resolution: {integrity: sha512-KuZN619eDkk9iDNwHLyerVi3iRr+uLSG0OWackN1z41+ari/4Ff2UQyo464s1cutRvvago6HzZ28MzUlo5qfrA==} + peerDependencies: + '@aws-sdk/client-dynamodb': ^3.438.0 + ioredis: ^5.3.2 + knex: ^3.0.1 + kysely: ^0.27.3 + orchid-orm: ^1.24.0 + peerDependenciesMeta: + '@aws-sdk/client-dynamodb': + optional: true + ioredis: + optional: true + knex: + optional: true + kysely: + optional: true + orchid-orm: + optional: true + better-opn@3.0.2: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} @@ -8942,6 +9012,10 @@ packages: resolution: {integrity: sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==} hasBin: true + case-anything@3.1.0: + resolution: {integrity: sha512-rRYnn5Elur8RuNHKoJ2b0tgn+pjYxL7BzWom+JZ7NKKn1lt/yGV/tUNwOovxYa9l9VL5hnXQdMc+mENbhJzosQ==} + engines: {node: '>=18'} + caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} @@ -10504,6 +10578,10 @@ packages: flatted@3.2.7: resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + fn-name@3.0.0: resolution: {integrity: sha512-eNMNr5exLoavuAMhIUVsOKF79SWd/zG104ef6sxBTSw+cZc6BXdQXDvYcGvp0VbxVVSp1XDUNoz7mg1xMtSznA==} engines: {node: '>=8'} @@ -11093,6 +11171,10 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hexoid@2.0.0: + resolution: {integrity: sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==} + engines: {node: '>=8'} + highlight-words-core@1.2.2: resolution: {integrity: sha512-BXUKIkUuh6cmmxzi5OIbUJxrG8OAk2MqoL1DtO3Wo9D2faJg2ph5ntyuQeLqaHJmzER6H5tllCDA9ZnNe9BVGg==} @@ -14479,6 +14561,10 @@ packages: resolution: {integrity: sha512-gMxvPJYhP0O9n2pvcfYfIuYgbledAOJFcqRThtPRmjscaipiwcwPPKLytpVzMkG2HAN87Qmo2d4PtGiri1dSLA==} engines: {node: '>=10'} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -14507,6 +14593,9 @@ packages: secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + secure-json-parse@3.0.2: + resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==} + semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -14680,6 +14769,10 @@ packages: resolution: {integrity: sha512-5Z1QJhRCDyq0J+0yiUN6COETKtvrYdmukeQn5RZUSt7EvzYo4oTm7D4j2ZV4DN0DMHsaQBSnW/tIgX+UHRJYmQ==} engines: {node: '>=10.0'} + slugify@1.6.6: + resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} + engines: {node: '>=8.0.0'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -15221,6 +15314,9 @@ packages: trough@2.1.0: resolution: {integrity: sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==} + truncatise@0.0.8: + resolution: {integrity: sha512-cXzueh9pzBCsLzhToB4X4gZCb3KYkrsAcBAX97JnazE74HOl3cpBJYEV7nabHeG/6/WXCU5Yujlde/WPBUwnsg==} + ts-api-utils@1.3.0: resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} engines: {node: '>=16'} @@ -17737,6 +17833,14 @@ snapshots: '@balena/dockerignore@1.0.2': {} + '@boringnode/bus@0.7.0(ioredis@5.4.2)': + dependencies: + '@paralleldrive/cuid2': 2.2.2 + '@poppinss/utils': 6.9.2 + object-hash: 3.0.0 + optionalDependencies: + ioredis: 5.4.2 + '@braintree/sanitize-url@7.1.0': {} '@changesets/apply-release-plan@7.0.7': @@ -20152,6 +20256,11 @@ snapshots: '@jsdevtools/ono@7.1.3': {} + '@julr/utils@1.7.0': + dependencies: + '@lukeed/ms': 2.0.2 + bytes: 3.1.2 + '@kamilkisiela/fast-url-parser@1.1.4': {} '@lbrlabs/pulumi-grafana@0.1.0(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3))(typescript@5.7.3)': @@ -20402,6 +20511,8 @@ snapshots: '@next/swc-win32-x64-msvc@15.1.6': optional: true + '@noble/hashes@1.7.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -21550,6 +21661,10 @@ snapshots: '@pagefind/windows-x64@1.3.0': optional: true + '@paralleldrive/cuid2@2.2.2': + dependencies: + '@noble/hashes': 1.7.1 + '@parcel/watcher-android-arm64@2.5.0': optional: true @@ -21642,6 +21757,30 @@ snapshots: '@polka/url@1.0.0-next.25': {} + '@poppinss/exception@1.2.0': {} + + '@poppinss/object-builder@1.1.0': {} + + '@poppinss/string@1.2.0': + dependencies: + '@lukeed/ms': 2.0.2 + '@types/bytes': 3.1.5 + '@types/pluralize': 0.0.33 + bytes: 3.1.2 + case-anything: 3.1.0 + pluralize: 8.0.0 + slugify: 1.6.6 + truncatise: 0.0.8 + + '@poppinss/utils@6.9.2': + dependencies: + '@poppinss/exception': 1.2.0 + '@poppinss/object-builder': 1.1.0 + '@poppinss/string': 1.2.0 + flattie: 1.1.1 + safe-stable-stringify: 2.5.0 + secure-json-parse: 3.0.2 + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -24382,6 +24521,8 @@ snapshots: dependencies: '@types/node': 22.10.5 + '@types/bytes@3.1.5': {} + '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.0.4 @@ -24598,6 +24739,8 @@ snapshots: pg-protocol: 1.7.0 pg-types: 2.2.0 + '@types/pluralize@0.0.33': {} + '@types/prop-types@15.7.5': {} '@types/qs@6.9.7': {} @@ -25194,6 +25337,10 @@ snapshots: astring@1.8.6: {} + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + async-retry@1.3.3: dependencies: retry: 0.13.1 @@ -25304,6 +25451,18 @@ snapshots: before-after-hook@2.2.3: {} + bentocache@1.1.0(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.4.2): + dependencies: + '@boringnode/bus': 0.7.0(ioredis@5.4.2) + '@julr/utils': 1.7.0 + '@poppinss/utils': 6.9.2 + async-mutex: 0.5.0 + hexoid: 2.0.0 + lru-cache: 11.0.2 + p-timeout: 6.1.4 + optionalDependencies: + ioredis: 5.4.2 + better-opn@3.0.2: dependencies: open: 8.4.2 @@ -25595,6 +25754,8 @@ snapshots: ansicolors: 0.3.2 redeyed: 2.1.1 + case-anything@3.1.0: {} + caseless@0.12.0: {} ccount@2.0.1: {} @@ -27582,6 +27743,8 @@ snapshots: flatted@3.2.7: {} + flattie@1.1.1: {} + fn-name@3.0.0: {} follow-redirects@1.15.6(debug@4.3.7): @@ -28381,6 +28544,8 @@ snapshots: help-me@5.0.0: {} + hexoid@2.0.0: {} + highlight-words-core@1.2.2: {} hoist-non-react-statics@3.3.2: @@ -32454,6 +32619,8 @@ snapshots: safe-stable-stringify@2.4.2: {} + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} sax@1.4.1: @@ -32480,6 +32647,8 @@ snapshots: secure-json-parse@2.7.0: {} + secure-json-parse@3.0.2: {} + semver-compare@1.0.0: {} semver@5.7.2: {} @@ -32719,6 +32888,8 @@ snapshots: transitivePeerDependencies: - pg-native + slugify@1.6.6: {} + smart-buffer@4.2.0: {} snake-case@3.0.4: @@ -33313,6 +33484,8 @@ snapshots: trough@2.1.0: {} + truncatise@0.0.8: {} + ts-api-utils@1.3.0(typescript@5.7.3): dependencies: typescript: 5.7.3 From 3deed761e9f532ad1b3038510c75594ce134efe2 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 11 Feb 2025 17:21:41 +0100 Subject: [PATCH 09/33] permission validation --- .../lib/organization-access-token-permissions.ts | 7 +++++-- .../organization/lib/organization-member-permissions.ts | 6 +++--- .../api/src/modules/organization/module.graphql.ts | 4 ++++ .../organization/providers/organization-access-tokens.ts | 7 ++++++- .../src/modules/organization/resolvers/Organization.ts | 9 +++++++-- 5 files changed, 25 insertions(+), 8 deletions(-) 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 a3c5b37a9d..c23ec4aff1 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 @@ -1,6 +1,6 @@ import { type PermissionGroup } from './permissions'; -export const allPermissionGroups: Array = [ +export const permissionGroups: Array = [ { id: 'organization', title: 'Organization', @@ -9,7 +9,6 @@ export const allPermissionGroups: Array = [ id: 'organization:describe', title: 'View organization', description: 'Member can see the organization. Permission can not be modified.', - isReadyOnly: true, }, ], }, @@ -68,3 +67,7 @@ export const allPermissionGroups: Array = [ ], }, ]; + +export const assignablePermissions = new Set( + permissionGroups.flatMap(group => group.permissions.map(permission => permission.id)), +); 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 84cc00fea9..4848d8b365 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 @@ -1,7 +1,7 @@ import { allPermissions, Permission } from '../../auth/lib/authz'; import { PermissionGroup } from './permissions'; -export const allPermissionGroups: Array = [ +export const permissionGroups: Array = [ { id: 'organization', title: 'Organization', @@ -239,7 +239,7 @@ function assertAllRulesAreAssigned(excluded: Array) { permissionsToCheck.delete(item); } - for (const group of allPermissionGroups) { + for (const group of permissionGroups) { for (const permission of group.permissions) { permissionsToCheck.delete(permission.id); } @@ -276,7 +276,7 @@ assertAllRulesAreAssigned([ export const permissions = (() => { const assignable = new Set(); const readOnly = new Set(); - for (const group of allPermissionGroups) { + for (const group of permissionGroups) { for (const permission of group.permissions) { if (permission.isReadyOnly === true) { readOnly.add(permission.id); diff --git a/packages/services/api/src/modules/organization/module.graphql.ts b/packages/services/api/src/modules/organization/module.graphql.ts index ff50c1e11a..5568cefb69 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -315,6 +315,10 @@ export default gql` List of available permission groups that can be assigned to users. """ availableMemberPermissionGroups: [PermissionGroup!]! + """ + List of available permission groups that can be assigned to organization access tokens. + """ + availableOrganizationPermissionGroups: [PermissionGroup!]! } type OrganizationConnection { 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 2ead8ac6dd..fff86c41c3 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 @@ -13,6 +13,7 @@ import { IdTranslator } from '../../shared/providers/id-translator'; import { Logger } from '../../shared/providers/logger'; import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; import * as OrganizationAccessKey from '../lib/organization-access-key'; +import { assignablePermissions } from '../lib/organization-access-token-permissions'; import { OrganizationAccessTokensCache } from './organization-access-tokens-cache'; import { AssignedProjectsModel, @@ -129,6 +130,10 @@ export class OrganizationAccessTokens { args.assignedResources ?? { mode: 'granular' }, ); + const permissions = Array.from( + new Set(args.permissions.filter(permission => assignablePermissions.has(permission as any))), + ); + const id = crypto.randomUUID(); const accessKey = await OrganizationAccessKey.create(id); @@ -148,7 +153,7 @@ export class OrganizationAccessTokens { , ${organizationId} , ${titleResult.data} , ${descriptionResult.data} - , ${sql.array(args.permissions, 'text')} + , ${sql.array(permissions, 'text')} , ${sql.jsonb(assignedResources)} , ${accessKey.hash} , ${accessKey.firstCharacters} diff --git a/packages/services/api/src/modules/organization/resolvers/Organization.ts b/packages/services/api/src/modules/organization/resolvers/Organization.ts index 71281a6395..9953545edf 100644 --- a/packages/services/api/src/modules/organization/resolvers/Organization.ts +++ b/packages/services/api/src/modules/organization/resolvers/Organization.ts @@ -1,5 +1,6 @@ import { Session } from '../../auth/lib/authz'; -import { allPermissionGroups } from '../lib/organization-member-permissions'; +import * as OrganizationAccessTokensPermissions from '../lib/organization-access-token-permissions'; +import * as OrganizationMemberPermissions from '../lib/organization-member-permissions'; import { OrganizationManager } from '../providers/organization-manager'; import { OrganizationMemberRoles } from '../providers/organization-member-roles'; import { OrganizationMembers } from '../providers/organization-members'; @@ -8,6 +9,7 @@ import type { OrganizationResolvers } from './../../../__generated__/types'; export const Organization: Pick< OrganizationResolvers, | 'availableMemberPermissionGroups' + | 'availableOrganizationPermissionGroups' | 'cleanId' | 'getStarted' | 'id' @@ -183,6 +185,9 @@ export const Organization: Pick< }); }, availableMemberPermissionGroups: () => { - return allPermissionGroups; + return OrganizationMemberPermissions.permissionGroups; + }, + availableOrganizationPermissionGroups: () => { + return OrganizationAccessTokensPermissions.permissionGroups; }, }; From 66165f32a3c5a61bbe328677a016db86c5d516a6 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Feb 2025 11:39:15 +0100 Subject: [PATCH 10/33] change permissions --- .../organization-access-token-permissions.ts | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) 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 c23ec4aff1..9b3c64cd2d 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 @@ -7,8 +7,8 @@ export const permissionGroups: Array = [ permissions: [ { id: 'organization:describe', - title: 'View organization', - description: 'Member can see the organization. Permission can not be modified.', + title: 'Describe organization', + description: 'Fetch information about the specified organization.', }, ], }, @@ -19,18 +19,7 @@ export const permissionGroups: Array = [ { id: 'project:describe', title: 'View project', - description: 'Member can access the specified projects.', - }, - ], - }, - { - id: 'schema-checks', - title: 'Schema Checks', - permissions: [ - { - id: 'schemaCheck:create', - title: 'Create schema checks', - description: 'Grant access to performing schema checks.', + description: 'Fetch information about the specified projects.', }, ], }, @@ -40,13 +29,18 @@ export const permissionGroups: Array = [ permissions: [ { id: 'schemaCheck:create', + title: 'Check schema/service/subgraph', + description: 'Grant access to publish services/schemas.', + }, + { + id: 'schemaVersion:publish', title: 'Publish schema/service/subgraph', description: 'Grant access to publish services/schemas.', }, { - id: 'schemaCheck:create', + id: 'schemaVersion:deleteService', title: 'Delete service', - description: 'Grant access to deleting services.', + description: 'Deletes a service from the schema registry.', }, ], }, From faa94529178069e92d6d2bdb7c11177fc5c6d59f Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Feb 2025 11:40:31 +0100 Subject: [PATCH 11/33] dup --- packages/services/api/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/services/api/package.json b/packages/services/api/package.json index e19818b117..90da6d4171 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -45,7 +45,6 @@ "agentkeepalive": "4.6.0", "bcryptjs": "2.4.3", "bentocache": "1.1.0", - "bentocache": "1.1.0", "csv-stringify": "6.5.2", "dataloader": "2.2.3", "date-fns": "4.1.0", From 68cf46e234e95ce38cc7d651509809d3b0ab8e83 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Feb 2025 11:49:42 +0100 Subject: [PATCH 12/33] oops --- .../src/modules/auth/lib/organization-access-token-strategy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ada36c7f02..35e25720f7 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 @@ -78,7 +78,7 @@ export class OrganizationAccessTokenStrategy extends AuthNStrategy Date: Wed, 12 Feb 2025 12:41:28 +0100 Subject: [PATCH 13/33] fix type --- .../services/api/src/modules/organization/module.graphql.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/api/src/modules/organization/module.graphql.ts b/packages/services/api/src/modules/organization/module.graphql.ts index 5568cefb69..6cce4cd5f2 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -88,7 +88,7 @@ export default gql` description: String permissions: [String!]! resources: ResourceAssignment! - createdAt: Date! + createdAt: DateTime! } input DeleteOrganizationAccessTokenInput { From 1587b4c7e2f83bec891e9c68bcfea1f25cf433ef Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Feb 2025 12:41:50 +0100 Subject: [PATCH 14/33] first integration tests --- .../api/organization-access-tokens.spec.ts | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 integration-tests/tests/api/organization-access-tokens.spec.ts diff --git a/integration-tests/tests/api/organization-access-tokens.spec.ts b/integration-tests/tests/api/organization-access-tokens.spec.ts new file mode 100644 index 0000000000..af4d66709a --- /dev/null +++ b/integration-tests/tests/api/organization-access-tokens.spec.ts @@ -0,0 +1,242 @@ +import { createProject } from 'packages/services/api/src/modules/project/resolvers/Mutation/createProject'; +import { graphql } from '../../testkit/gql'; +import * as GraphQLSchema from '../../testkit/gql/graphql'; +import { execute } from '../../testkit/graphql'; +import { initSeed } from '../../testkit/seed'; + +const CreateOrganizationAccessTokenMutation = graphql(` + mutation CreateOrganizationAccessToken($input: CreateOrganizationAccessTokenInput!) { + createOrganizationAccessToken(input: $input) { + ok { + privateAccessKey + createdOrganizationAccessToken { + id + title + description + permissions + createdAt + } + } + error { + message + details { + title + description + } + } + } + } +`); + +const OrganizationProjectTargetQuery = graphql(` + query OrganizationProjectTargetQuery( + $organizationSlug: String! + $projectSlug: String! + $targetSlug: String! + ) { + organization: organizationBySlug(organizationSlug: $organizationSlug) { + id + slug + project: projectBySlug(projectSlug: $projectSlug) { + id + slug + targetBySlug(targetSlug: $targetSlug) { + id + slug + } + } + } + } +`); + +test.concurrent('create: success', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + + const result = await execute({ + document: CreateOrganizationAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentMode.All }, + permissions: [], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(result.createOrganizationAccessToken.error).toEqual(null); + expect(result.createOrganizationAccessToken.ok).toEqual({ + privateAccessKey: expect.any(String), + createdOrganizationAccessToken: { + id: expect.any(String), + title: 'a access token', + description: 'Some description', + permissions: [], + createdAt: expect.any(String), + }, + }); +}); + +test.concurrent('create: failure invalid title', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + + const result = await execute({ + document: CreateOrganizationAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: ' ', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentMode.All }, + permissions: [], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(result.createOrganizationAccessToken.ok).toEqual(null); + expect(result.createOrganizationAccessToken.error).toMatchInlineSnapshot(` + { + details: { + description: null, + title: Can only contain letters, numbers, " ", '_', and '-'., + }, + message: Invalid input provided., + } + `); +}); + +test.concurrent('create: failure invalid description', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + + const result = await execute({ + document: CreateOrganizationAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: new Array(300).fill('A').join(''), + resources: { mode: GraphQLSchema.ResourceAssignmentMode.All }, + permissions: [], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(result.createOrganizationAccessToken.ok).toEqual(null); + expect(result.createOrganizationAccessToken.error).toMatchInlineSnapshot(` + { + details: { + description: Maximum length is 248 characters., + title: null, + }, + message: Invalid input provided., + } + `); +}); + +test.concurrent('query GraphQL API on resources with access', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + const project = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const result = await execute({ + document: CreateOrganizationAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: 'a description', + resources: { mode: GraphQLSchema.ResourceAssignmentMode.All }, + permissions: ['organization:describe', 'project:describe'], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(result.createOrganizationAccessToken.error).toEqual(null); + const organizationAccessToken = result.createOrganizationAccessToken.ok!.privateAccessKey; + + const projectQuery = await execute({ + document: OrganizationProjectTargetQuery, + variables: { + organizationSlug: org.organization.slug, + projectSlug: project.project.slug, + targetSlug: project.target.slug, + }, + authToken: organizationAccessToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(projectQuery).toEqual({ + organization: { + id: expect.any(String), + slug: org.organization.slug, + project: { + id: expect.any(String), + slug: project.project.slug, + targetBySlug: { + id: expect.any(String), + slug: project.target.slug, + }, + }, + }, + }); +}); + +test.concurrent('query GraphQL API on resources without access', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + const project1 = await org.createProject(GraphQLSchema.ProjectType.Federation); + const project2 = await org.createProject(GraphQLSchema.ProjectType.Federation); + + const result = await execute({ + document: CreateOrganizationAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: 'a description', + resources: { + mode: GraphQLSchema.ResourceAssignmentMode.Granular, + projects: [ + { + projectId: project1.project.id, + targets: { mode: GraphQLSchema.ResourceAssignmentMode.All }, + }, + ], + }, + permissions: ['organization:describe', 'project:describe'], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(result.createOrganizationAccessToken.error).toEqual(null); + const organizationAccessToken = result.createOrganizationAccessToken.ok!.privateAccessKey; + + const projectQuery = await execute({ + document: OrganizationProjectTargetQuery, + variables: { + organizationSlug: org.organization.slug, + projectSlug: project2.project.slug, + targetSlug: project2.target.slug, + }, + authToken: organizationAccessToken, + }).then(e => e.expectNoGraphQLErrors()); + expect(projectQuery).toEqual({ + organization: { + id: expect.any(String), + project: null, + slug: org.organization.slug, + }, + }); +}); From 58fc5bf31bd4552745b1eaf983d04fbbe28a88dd Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Feb 2025 12:45:48 +0100 Subject: [PATCH 15/33] integration tests --- .../api/organization-access-tokens.spec.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/integration-tests/tests/api/organization-access-tokens.spec.ts b/integration-tests/tests/api/organization-access-tokens.spec.ts index af4d66709a..a95a373467 100644 --- a/integration-tests/tests/api/organization-access-tokens.spec.ts +++ b/integration-tests/tests/api/organization-access-tokens.spec.ts @@ -1,4 +1,3 @@ -import { createProject } from 'packages/services/api/src/modules/project/resolvers/Mutation/createProject'; import { graphql } from '../../testkit/gql'; import * as GraphQLSchema from '../../testkit/gql/graphql'; import { execute } from '../../testkit/graphql'; @@ -143,6 +142,38 @@ test.concurrent('create: failure invalid description', async ({ expect }) => { `); }); +test.concurrent('create: failure because no access to organization', async ({ expect }) => { + const actor1 = await initSeed().createOwner(); + const actor2 = await initSeed().createOwner(); + const org = await actor1.createOrg(); + + const errors = await execute({ + document: CreateOrganizationAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'a access token', + description: 'Some description', + resources: { mode: GraphQLSchema.ResourceAssignmentMode.All }, + permissions: [], + }, + }, + authToken: actor2.ownerToken, + }).then(e => e.expectGraphQLErrors()); + expect(errors).toMatchObject([ + { + extensions: { + code: 'UNAUTHORISED', + }, + + message: `No access (reason: "Missing permission for performing 'accessToken:modify' on resource")`, + path: ['createOrganizationAccessToken'], + }, + ]); +}); + test.concurrent('query GraphQL API on resources with access', async ({ expect }) => { const { createOrg, ownerToken } = await initSeed().createOwner(); const org = await createOrg(); From 1ff10816d44ee474c802b10c555435fe75f98016 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 12 Feb 2025 13:25:23 +0100 Subject: [PATCH 16/33] fix --- .../organization/lib/organization-access-token-permissions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 9b3c64cd2d..bd8296dbb3 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 @@ -13,8 +13,8 @@ export const permissionGroups: Array = [ ], }, { - id: 'organization', - title: 'Organization', + id: 'project', + title: 'Project', permissions: [ { id: 'project:describe', From 60ab85be733e58fc0ce94a03a8f6898d19243c58 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 11:48:08 +0100 Subject: [PATCH 17/33] pagination --- .../api/organization-access-tokens.spec.ts | 140 ++++++++++++++++++ .../modules/organization/module.graphql.ts | 14 ++ .../providers/organization-access-tokens.ts | 79 ++++++++++ .../organization/resolvers/Organization.ts | 9 ++ 4 files changed, 242 insertions(+) diff --git a/integration-tests/tests/api/organization-access-tokens.spec.ts b/integration-tests/tests/api/organization-access-tokens.spec.ts index a95a373467..51d32316c7 100644 --- a/integration-tests/tests/api/organization-access-tokens.spec.ts +++ b/integration-tests/tests/api/organization-access-tokens.spec.ts @@ -48,6 +48,31 @@ const OrganizationProjectTargetQuery = graphql(` } `); +const PaginatedAccessTokensQuery = graphql(` + query PaginatedAccessTokensQuery($organizationSlug: String!, $first: Int, $after: String) { + organization: organizationBySlug(organizationSlug: $organizationSlug) { + id + slug + accessTokens(first: $first, after: $after) { + edges { + cursor + node { + id + title + description + permissions + createdAt + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + } +`); + test.concurrent('create: success', async () => { const { createOrg, ownerToken } = await initSeed().createOwner(); const org = await createOrg(); @@ -271,3 +296,118 @@ test.concurrent('query GraphQL API on resources without access', async ({ expect }, }); }); + +test.concurrent('pagination', async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const org = await createOrg(); + + let paginatedResult = await execute({ + document: PaginatedAccessTokensQuery, + variables: { + organizationSlug: org.organization.slug, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + + expect(paginatedResult.organization?.accessTokens).toEqual({ + edges: [], + pageInfo: { + endCursor: '', + hasNextPage: false, + }, + }); + + await execute({ + document: CreateOrganizationAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'first access token', + description: 'a description', + resources: { + mode: GraphQLSchema.ResourceAssignmentMode.All, + }, + permissions: ['organization:describe'], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + + await execute({ + document: CreateOrganizationAccessTokenMutation, + variables: { + input: { + organization: { + byId: org.organization.id, + }, + title: 'second access token', + description: 'a description', + resources: { + mode: GraphQLSchema.ResourceAssignmentMode.All, + }, + permissions: ['organization:describe'], + }, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + + paginatedResult = await execute({ + document: PaginatedAccessTokensQuery, + variables: { + organizationSlug: org.organization.slug, + first: 1, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + + expect(paginatedResult.organization?.accessTokens).toEqual({ + edges: [ + { + cursor: expect.any(String), + node: { + createdAt: expect.any(String), + description: 'a description', + id: expect.any(String), + permissions: ['organization:describe'], + title: 'second access token', + }, + }, + ], + pageInfo: { + endCursor: expect.any(String), + hasNextPage: true, + }, + }); + + const endCursor = paginatedResult.organization!.accessTokens!.pageInfo.endCursor; + + paginatedResult = await execute({ + document: PaginatedAccessTokensQuery, + variables: { + organizationSlug: org.organization.slug, + after: endCursor, + }, + authToken: ownerToken, + }).then(e => e.expectNoGraphQLErrors()); + + expect(paginatedResult.organization?.accessTokens).toEqual({ + edges: [ + { + cursor: expect.any(String), + node: { + createdAt: expect.any(String), + description: 'a description', + id: expect.any(String), + permissions: ['organization:describe'], + title: 'first access token', + }, + }, + ], + pageInfo: { + endCursor: expect.any(String), + hasNextPage: false, + }, + }); +}); diff --git a/packages/services/api/src/modules/organization/module.graphql.ts b/packages/services/api/src/modules/organization/module.graphql.ts index 6cce4cd5f2..cc810f15d1 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -319,6 +319,20 @@ export default gql` List of available permission groups that can be assigned to organization access tokens. """ availableOrganizationPermissionGroups: [PermissionGroup!]! + """ + Paginated organization access tokens. + """ + accessTokens(first: Int, after: String): OrganizationAccessTokenConnection! + } + + type OrganizationAccessTokenEdge { + node: OrganizationAccessToken! + cursor: String! + } + + type OrganizationAccessTokenConnection { + pageInfo: PageInfo! + edges: [OrganizationAccessTokenEdge!]! } type OrganizationConnection { 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 fff86c41c3..4b5bb109b6 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,10 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; import { sql, type DatabasePool } from 'slonik'; import { z } from 'zod'; +import { + decodeCreatedAtAndUUIDIdBasedCursor, + encodeCreatedAtAndUUIDIdBasedCursor, +} from '@hive/storage'; import * as GraphQLSchema from '../../../__generated__/types'; import { isUUID } from '../../../shared/is-uuid'; import { @@ -200,6 +204,81 @@ export class OrganizationAccessTokens { organizationAccessTokenId: args.organizationAccessTokenId, }; } + + async getPaginated(args: { organizationId: string; first: number | null; after: string | null }) { + await this.session.assertPerformAction({ + organizationId: args.organizationId, + params: { organizationId: args.organizationId }, + action: 'accessToken:modify', + }); + + let cursor: null | { + createdAt: string; + id: string; + } = null; + + const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; + + if (args.after) { + cursor = decodeCreatedAtAndUUIDIdBasedCursor(args.after); + } + + const result = await this.pool.any(sql` /* OrganizationAccessTokens.getPaginated */ + SELECT + ${organizationAccessTokenFields} + FROM + "organization_access_tokens" + WHERE + "organization_id" = ${args.organizationId} + ${ + cursor + ? sql` + AND ( + ( + "created_at" = ${cursor.createdAt} + AND "id" < ${cursor.id} + ) + OR "created_at" < ${cursor.createdAt} + ) + ` + : sql`` + } + ORDER BY + "organization_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 ?? ''; + }, + }, + }; + } } /** diff --git a/packages/services/api/src/modules/organization/resolvers/Organization.ts b/packages/services/api/src/modules/organization/resolvers/Organization.ts index 9953545edf..1bfc1b2548 100644 --- a/packages/services/api/src/modules/organization/resolvers/Organization.ts +++ b/packages/services/api/src/modules/organization/resolvers/Organization.ts @@ -1,6 +1,7 @@ import { Session } from '../../auth/lib/authz'; import * as OrganizationAccessTokensPermissions from '../lib/organization-access-token-permissions'; import * as OrganizationMemberPermissions from '../lib/organization-member-permissions'; +import { OrganizationAccessTokens } from '../providers/organization-access-tokens'; import { OrganizationManager } from '../providers/organization-manager'; import { OrganizationMemberRoles } from '../providers/organization-member-roles'; import { OrganizationMembers } from '../providers/organization-members'; @@ -8,6 +9,7 @@ import type { OrganizationResolvers } from './../../../__generated__/types'; export const Organization: Pick< OrganizationResolvers, + | 'accessTokens' | 'availableMemberPermissionGroups' | 'availableOrganizationPermissionGroups' | 'cleanId' @@ -190,4 +192,11 @@ export const Organization: Pick< availableOrganizationPermissionGroups: () => { return OrganizationAccessTokensPermissions.permissionGroups; }, + accessTokens: async (organization, args, { injector }) => { + return injector.get(OrganizationAccessTokens).getPaginated({ + organizationId: organization.id, + first: args.first ?? null, + after: args.after ?? null, + }); + }, }; From acb9e301bc2ea794245d8047144c63b4194535a1 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 12:15:49 +0100 Subject: [PATCH 18/33] not yet --- packages/services/api/src/modules/auth/lib/authz.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index a5376759a0..e95bd776f2 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -368,7 +368,6 @@ const permissionsByLevel = { z.literal('laboratory:modify'), z.literal('laboratory:modifyPreflightScript'), z.literal('schema:compose'), - z.literal('usage:report'), ], service: [ z.literal('schemaCheck:create'), From 90c68ca2e976fbe14b5530c5ff3a6d5d727d40cb Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 12:19:41 +0100 Subject: [PATCH 19/33] no any --- .../organization/providers/organization-access-tokens.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 4b5bb109b6..6c4f01294b 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 @@ -9,6 +9,7 @@ import * as GraphQLSchema from '../../../__generated__/types'; import { isUUID } from '../../../shared/is-uuid'; import { InsufficientPermissionError, + Permission, PermissionsModel, permissionsToPermissionsPerResourceLevelAssignment, Session, @@ -135,7 +136,9 @@ export class OrganizationAccessTokens { ); const permissions = Array.from( - new Set(args.permissions.filter(permission => assignablePermissions.has(permission as any))), + new Set( + args.permissions.filter(permission => assignablePermissions.has(permission as Permission)), + ), ); const id = crypto.randomUUID(); From fa7e965f6a9c7c53aac3867671b42076bf47d4d7 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 12:24:12 +0100 Subject: [PATCH 20/33] ooops --- .../modules/organization/lib/organization-member-permissions.ts | 1 - 1 file changed, 1 deletion(-) 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 4848d8b365..009cbd5cd6 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 @@ -267,7 +267,6 @@ assertAllRulesAreAssigned([ 'appDeployment:retire', 'accessToken:modify', - 'usage:report', ]); /** From 90434ede6864c9d71e3c84ed34b26c18fa4ccac6 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 12:45:12 +0100 Subject: [PATCH 21/33] types --- .../organization/providers/organization-access-tokens.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6c4f01294b..03de4fdf92 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,5 +1,5 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; -import { sql, type DatabasePool } from 'slonik'; +import { sql, type CommonQueryMethods, type DatabasePool } from 'slonik'; import { z } from 'zod'; import { decodeCreatedAtAndUUIDIdBasedCursor, @@ -288,7 +288,7 @@ export class OrganizationAccessTokens { * 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. */ -export function findById(deps: { pool: DatabasePool; logger: Logger }) { +export function findById(deps: { pool: CommonQueryMethods; logger: Logger }) { return async function findByIdImplementation(organizationAccessTokenId: string) { deps.logger.debug( 'Resolve organization access token by id. (organizationAccessTokenId=%s)', From fae1421d8e4940c2690ab263c33b70a2c8a88afa Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 14:45:58 +0100 Subject: [PATCH 22/33] update migration date --- ...ens.ts => 2025.02.20T00-00-00.organization-access-tokens.ts} | 2 +- packages/migrations/src/run-pg-migrations.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename packages/migrations/src/actions/{2025.01.30T00-02-03.organization-access-tokens.ts => 2025.02.20T00-00-00.organization-access-tokens.ts} (92%) diff --git a/packages/migrations/src/actions/2025.01.30T00-02-03.organization-access-tokens.ts b/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts similarity index 92% rename from packages/migrations/src/actions/2025.01.30T00-02-03.organization-access-tokens.ts rename to packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts index 940b4292d0..84096f14d2 100644 --- a/packages/migrations/src/actions/2025.01.30T00-02-03.organization-access-tokens.ts +++ b/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts @@ -1,7 +1,7 @@ import { type MigrationExecutor } from '../pg-migrator'; export default { - name: '2025.01.30T00-02-03.organization-access-tokens.ts', + name: '2025.02.20T00-00-00.organization-access-tokens.ts', run: ({ sql }) => sql` CREATE TABLE IF NOT EXISTS "organization_access_tokens" ( "id" UUID PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4() diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index 4d16e87f7c..0eb3dbd60e 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -158,6 +158,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri await import('./actions/2025.01.17T10-08-00.drop-activities'), await import('./actions/2025.01.20T00-00-00.legacy-registry-model-removal'), await import('./actions/2025.01.30T00-00-00.granular-member-role-permissions'), - await import('./actions/2025.01.30T00-02-03.organization-access-tokens'), + await import('./actions/2025.02.20T00-00-00.organization-access-tokens'), ], }); From 1b17d26a6bc86d502b418e7afc453dca9b227f22 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 15:21:34 +0100 Subject: [PATCH 23/33] Update packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../actions/2025.02.20T00-00-00.organization-access-tokens.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts b/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts index 84096f14d2..931ec18980 100644 --- a/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts +++ b/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts @@ -8,7 +8,7 @@ export default { , "organization_id" UUID NOT NULL REFERENCES "organizations" ("id") ON DELETE CASCADE , "created_at" timestamptz NOT NULL DEFAULT now() , "title" text NOT NULL - , "description" text NOT NULL + , "description" text , "permissions" text[] NOT NULL , "assigned_resources" jsonb , "hash" text From 8f6096a2aac2616197bb350cc94ff09d1950d88f Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 15:22:35 +0100 Subject: [PATCH 24/33] nullabilityy --- .../actions/2025.02.20T00-00-00.organization-access-tokens.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts b/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts index 931ec18980..698d9834e8 100644 --- a/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts +++ b/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts @@ -11,8 +11,8 @@ export default { , "description" text , "permissions" text[] NOT NULL , "assigned_resources" jsonb - , "hash" text - , "first_characters" text + , "hash" text NOT NULL + , "first_characters" text NOT NULL ); CREATE INDEX IF NOT EXISTS "organization_access_tokens_organization_id" ON "organization_access_tokens" ( From 222491bfb25361a582c864e89b11441168632059 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 15:29:18 +0100 Subject: [PATCH 25/33] fixtures --- packages/services/storage/src/db/types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index b0816afe5e..997d810e18 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -159,9 +159,9 @@ export interface oidc_integrations { export interface organization_access_tokens { assigned_resources: any | null; created_at: Date; - description: string; - first_characters: string | null; - hash: string | null; + description: string | null; + first_characters: string; + hash: string; id: string; organization_id: string; permissions: Array; From f3ee9f3c15db1bc8873c0139f402dc4f22d3730b Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 15:34:33 +0100 Subject: [PATCH 26/33] audit log --- .../audit-logs/providers/audit-logs-types.ts | 12 ++++++++++++ .../providers/organization-access-tokens.ts | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) 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 968f3bd057..f6c4502ba4 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 @@ -327,6 +327,18 @@ export const AuditLogModel = z.union([ scriptContents: z.string(), }), }), + z.object({ + eventType: z.literal('ORGANIZATION_ACCESS_TOKEN_CREATED'), + metadata: z.object({ + organizationAccessTokenId: z.string().uuid(), + }), + }), + z.object({ + eventType: z.literal('ORGANIZATION_ACCESS_TOKEN_DELETED'), + metadata: z.object({ + organizationAccessTokenId: z.string().uuid(), + }), + }), ]); export type AuditLogSchemaEvent = z.infer; 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 03de4fdf92..0b1e98257a 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 @@ -7,6 +7,7 @@ import { } from '@hive/storage'; import * as GraphQLSchema from '../../../__generated__/types'; import { isUUID } from '../../../shared/is-uuid'; +import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { InsufficientPermissionError, Permission, @@ -87,6 +88,7 @@ export class OrganizationAccessTokens { private resourceAssignments: ResourceAssignments, private idTranslator: IdTranslator, private session: Session, + private auditLogs: AuditLogRecorder, logger: Logger, ) { this.logger = logger.child({ @@ -173,6 +175,14 @@ export class OrganizationAccessTokens { await this.cache.add(organizationAccessToken); + await this.auditLogs.record({ + organizationId, + eventType: 'ORGANIZATION_ACCESS_TOKEN_CREATED', + metadata: { + organizationAccessTokenId: organizationAccessToken.id, + }, + }); + return { type: 'success' as const, organizationAccessToken, @@ -202,6 +212,14 @@ export class OrganizationAccessTokens { await this.cache.purge(record); + await this.auditLogs.record({ + organizationId: record.organizationId, + eventType: 'ORGANIZATION_ACCESS_TOKEN_DELETED', + metadata: { + organizationAccessTokenId: record.id, + }, + }); + return { type: 'success' as const, organizationAccessTokenId: args.organizationAccessTokenId, From 67da178ce76edd911605bbdad9b65f2467ba8328 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 15:36:57 +0100 Subject: [PATCH 27/33] coderabbit trolled me --- .../actions/2025.02.20T00-00-00.organization-access-tokens.ts | 2 +- packages/services/storage/src/db/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts b/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts index 698d9834e8..3a72f4358a 100644 --- a/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts +++ b/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts @@ -8,7 +8,7 @@ export default { , "organization_id" UUID NOT NULL REFERENCES "organizations" ("id") ON DELETE CASCADE , "created_at" timestamptz NOT NULL DEFAULT now() , "title" text NOT NULL - , "description" text + , "description" text NOT NULL , "permissions" text[] NOT NULL , "assigned_resources" jsonb , "hash" text NOT NULL diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 997d810e18..83f0c3a39c 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -159,7 +159,7 @@ export interface oidc_integrations { export interface organization_access_tokens { assigned_resources: any | null; created_at: Date; - description: string | null; + description: string; first_characters: string; hash: string; id: string; From 766102ff126ab60bbe10d52c7c57c2d520cb71ef Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 15:51:53 +0100 Subject: [PATCH 28/33] a bit more comments --- .../lib/organization-access-key.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/services/api/src/modules/organization/lib/organization-access-key.ts b/packages/services/api/src/modules/organization/lib/organization-access-key.ts index 012fc1dbfb..1393ee8895 100644 --- a/packages/services/api/src/modules/organization/lib/organization-access-key.ts +++ b/packages/services/api/src/modules/organization/lib/organization-access-key.ts @@ -6,13 +6,20 @@ import bcrypt from 'bcryptjs'; * Contains functions for generating an organization acces key. */ +/** + * Payload within the access token. + */ type DecodedAccessKey = { + /** UUID as stored within the database ("organization_access_tokens"."id") */ id: string; + /** string to compare against the hash within the database ("organization_access_tokens"."hash") */ privateKey: string; }; /** - * Prefix for the access key + * Prefix for the organization access key. + * We use this prefix so we can quickly identify whether an organization access token. + * * **hv** -> Hive * **o** -> Organization * **1** -> Version 1 @@ -25,6 +32,9 @@ function encode(recordId: string, secret: string) { return keyPrefix + btoa(keyContents); } +/** + * Attempt to decode a user provided access token string into the embedded id and private key. + */ export function decode( accessToken: string, ): { type: 'error'; reason: string } | { type: 'ok'; accessKey: DecodedAccessKey } { @@ -58,6 +68,9 @@ export function decode( return decodeError; } +/** + * Creates a new organization access key/token for a provided UUID. + */ export async function create(id: string) { const secret = Crypto.createHash('sha256') .update(Crypto.randomBytes(20).toString()) @@ -74,6 +87,10 @@ export async function create(id: string) { }; } +/** + * Verify whether a organization access key private key matches the + * hash stored within the "organization_access_tokens"."hash" table. + */ export async function verify(secret: string, hash: string) { return await bcrypt.compare(secret, hash); } From 6109e97525929c82ed4c7b5f7e3dd38cc95b9679 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 15:54:41 +0100 Subject: [PATCH 29/33] a bit more context --- .../organization/providers/organization-access-tokens.ts | 3 +++ .../src/modules/organization/providers/organization-members.ts | 3 +++ 2 files changed, 6 insertions(+) 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 0b1e98257a..3d880e0ba9 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 @@ -57,6 +57,9 @@ const OrganizationAccessTokenModel = z }) .transform(record => ({ ...record, + // We have these as a getter statement as they are + // only used in the context of authorization, we do not need + // to compute when querying a list of organization access tokens via the GraphQL API. get authorizationPolicyStatements() { const permissions = permissionsToPermissionsPerResourceLevelAssignment(record.permissions); const resolvedResources = 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 b8b5496483..113e41f5e5 100644 --- a/packages/services/api/src/modules/organization/providers/organization-members.ts +++ b/packages/services/api/src/modules/organization/providers/organization-members.ts @@ -131,6 +131,9 @@ export class OrganizationMembers { 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, From cc7c340e6b2c53297acaddf3045dc63204bdcf99 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 16:07:46 +0100 Subject: [PATCH 30/33] cache access key validation --- .../lib/organization-access-token-strategy.ts | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) 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 35e25720f7..f2ea40ecb8 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,9 +1,27 @@ +import * as crypto from 'node:crypto'; +import { BentoCache, bentostore } from 'bentocache'; +import { memoryDriver } from 'bentocache/build/src/drivers/memory'; import { type FastifyReply, type FastifyRequest } from '@hive/service-common'; import * as OrganizationAccessKey from '../../organization/lib/organization-access-key'; import { OrganizationAccessTokensCache } from '../../organization/providers/organization-access-tokens-cache'; import { Logger } from '../../shared/providers/logger'; import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz'; +const cache = new BentoCache({ + default: 'organizationAccessTokenValidation', + stores: { + organizationAccessTokenValidation: bentostore().useL1Layer( + memoryDriver({ + maxItems: 10_000, + }), + ), + }, +}); + +function hashToken(token: string) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + export class OrganizationAccessTokenSession extends Session { public readonly organizationId: string; private policies: Array; @@ -76,11 +94,13 @@ export class OrganizationAccessTokenStrategy extends AuthNStrategy + OrganizationAccessKey.verify(result.accessKey.privateKey, organizationAccessToken.hash), + key, + }); if (!isHashMatch) { this.logger.debug('Provided private key does not match hash.'); From 8c38327a078b12d24c6a73e91478a982d2c575a2 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 16:25:42 +0100 Subject: [PATCH 31/33] store resource assignments and permissions in audit log --- .../src/modules/audit-logs/providers/audit-logs-types.ts | 6 ++++++ .../organization/providers/organization-access-tokens.ts | 6 ++++-- .../modules/organization/providers/organization-members.ts | 6 +++--- .../modules/organization/providers/resource-assignments.ts | 4 ++-- 4 files changed, 15 insertions(+), 7 deletions(-) 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 f6c4502ba4..950f6e0492 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,4 +1,8 @@ import { z } from 'zod'; +import { + ResourceAssignmentModel, + ResourceAssignments, +} from '../../organization/providers/resource-assignments'; export const AuditLogModel = z.union([ z.object({ @@ -331,6 +335,8 @@ export const AuditLogModel = z.union([ eventType: z.literal('ORGANIZATION_ACCESS_TOKEN_CREATED'), metadata: z.object({ organizationAccessTokenId: z.string().uuid(), + permissions: z.array(z.string()), + assignedResources: ResourceAssignmentModel, }), }), z.object({ 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 3d880e0ba9..1bf84bed9d 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 @@ -22,8 +22,8 @@ import * as OrganizationAccessKey from '../lib/organization-access-key'; import { assignablePermissions } from '../lib/organization-access-token-permissions'; import { OrganizationAccessTokensCache } from './organization-access-tokens-cache'; import { - AssignedProjectsModel, resolveResourceAssignment, + ResourceAssignmentModel, ResourceAssignments, translateResolvedResourcesToAuthorizationPolicyStatements, } from './resource-assignments'; @@ -49,7 +49,7 @@ const OrganizationAccessTokenModel = z title: z.string(), description: z.string(), permissions: z.array(PermissionsModel), - assignedResources: AssignedProjectsModel.nullable().transform( + assignedResources: ResourceAssignmentModel.nullable().transform( value => value ?? { mode: '*' as const, projects: [] }, ), firstCharacters: z.string(), @@ -183,6 +183,8 @@ export class OrganizationAccessTokens { eventType: 'ORGANIZATION_ACCESS_TOKEN_CREATED', metadata: { organizationAccessTokenId: organizationAccessToken.id, + permissions: organizationAccessToken.permissions, + assignedResources: organizationAccessToken.assignedResources, }, }); 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 113e41f5e5..81a2113e64 100644 --- a/packages/services/api/src/modules/organization/providers/organization-members.ts +++ b/packages/services/api/src/modules/organization/providers/organization-members.ts @@ -8,9 +8,9 @@ import { Logger } from '../../shared/providers/logger'; import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; import { OrganizationMemberRoles, type OrganizationMemberRole } from './organization-member-roles'; import { - AssignedProjectsModel, resolveResourceAssignment, ResourceAssignmentGroup, + ResourceAssignmentModel, translateResolvedResourcesToAuthorizationPolicyStatements, } from './resource-assignments'; @@ -25,7 +25,7 @@ const RawOrganizationMembershipModel = z.object({ * Resources that are assigned to the membership * If no resources are defined the permissions of the role are applied to all resources within the organization. */ - assignedResources: AssignedProjectsModel.nullable().transform( + assignedResources: ResourceAssignmentModel.nullable().transform( value => value ?? { mode: '*' as const, projects: [] }, ), }); @@ -247,7 +247,7 @@ export class OrganizationMembers { "role_id" = ${args.roleId} , "assigned_resources" = ${JSON.stringify( /** we parse it to avoid additional properties being stored within the database. */ - AssignedProjectsModel.parse(args.resourceAssignmentGroup), + ResourceAssignmentModel.parse(args.resourceAssignmentGroup), )} WHERE "organization_id" = ${args.organizationId} 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 4daabb5f4f..e7ad13e385 100644 --- a/packages/services/api/src/modules/organization/providers/resource-assignments.ts +++ b/packages/services/api/src/modules/organization/providers/resource-assignments.ts @@ -80,7 +80,7 @@ const GranularAssignedProjectsModel = z.object({ * If no resources are assigned to a member role, the permissions are granted on all the resources within the * organization. */ -export const AssignedProjectsModel = z.union([ +export const ResourceAssignmentModel = z.union([ GranularAssignedProjectsModel, WildcardAssignmentMode, ]); @@ -88,7 +88,7 @@ export const AssignedProjectsModel = z.union([ /** * Resource assignments as stored within the database. */ -export type ResourceAssignmentGroup = z.TypeOf; +export type ResourceAssignmentGroup = z.TypeOf; type GranularAssignedProjects = z.TypeOf; @Injectable({ From b98959d6d523f541758a172336475f88ff2b0f66 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 17:01:38 +0100 Subject: [PATCH 32/33] unused import --- .../actions/2025.02.20T00-00-00.organization-access-tokens.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts b/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts index 3a72f4358a..b14e838b4d 100644 --- a/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts +++ b/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts @@ -13,12 +13,13 @@ export default { , "assigned_resources" jsonb , "hash" text NOT NULL , "first_characters" text NOT NULL + , "deleted_at" timestamptz DEFAULT NULL ); CREATE INDEX IF NOT EXISTS "organization_access_tokens_organization_id" ON "organization_access_tokens" ( "organization_id" , "created_at" DESC , "id" DESC - ); + ) WHERE "deleted_at" IS NULL; `, } satisfies MigrationExecutor; From 96abc144f4816f3d52da181bbf041a0d4a503d6a Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 13 Feb 2025 17:30:47 +0100 Subject: [PATCH 33/33] too early --- .../2025.02.20T00-00-00.organization-access-tokens.ts | 3 +-- .../api/src/modules/audit-logs/providers/audit-logs-types.ts | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts b/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts index b14e838b4d..3a72f4358a 100644 --- a/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts +++ b/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts @@ -13,13 +13,12 @@ export default { , "assigned_resources" jsonb , "hash" text NOT NULL , "first_characters" text NOT NULL - , "deleted_at" timestamptz DEFAULT NULL ); CREATE INDEX IF NOT EXISTS "organization_access_tokens_organization_id" ON "organization_access_tokens" ( "organization_id" , "created_at" DESC , "id" DESC - ) WHERE "deleted_at" IS NULL; + ); `, } satisfies MigrationExecutor; 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 950f6e0492..0c237854de 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,8 +1,5 @@ import { z } from 'zod'; -import { - ResourceAssignmentModel, - ResourceAssignments, -} from '../../organization/providers/resource-assignments'; +import { ResourceAssignmentModel } from '../../organization/providers/resource-assignments'; export const AuditLogModel = z.union([ z.object({