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..51d32316c7 --- /dev/null +++ b/integration-tests/tests/api/organization-access-tokens.spec.ts @@ -0,0 +1,413 @@ +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 + } + } + } + } +`); + +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(); + + 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('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(); + 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, + }, + }); +}); + +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/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/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 new file mode 100644 index 0000000000..3a72f4358a --- /dev/null +++ b/packages/migrations/src/actions/2025.02.20T00-00-00.organization-access-tokens.ts @@ -0,0 +1,24 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + 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() + , "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 NOT NULL + , "first_characters" text NOT NULL + ); + + CREATE INDEX IF NOT EXISTS "organization_access_tokens_organization_id" ON "organization_access_tokens" ( + "organization_id" + , "created_at" DESC + , "id" DESC + ); + `, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index 1646ba07d5..0eb3dbd60e 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.02.20T00-00-00.organization-access-tokens'), ], }); diff --git a/packages/services/api/package.json b/packages/services/api/package.json index 1f38d9dfa0..90da6d4171 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -44,6 +44,7 @@ "@types/object-hash": "3.0.6", "agentkeepalive": "4.6.0", "bcryptjs": "2.4.3", + "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/audit-logs/providers/audit-logs-types.ts b/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts index 968f3bd057..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,4 +1,5 @@ import { z } from 'zod'; +import { ResourceAssignmentModel } from '../../organization/providers/resource-assignments'; export const AuditLogModel = z.union([ z.object({ @@ -327,6 +328,20 @@ export const AuditLogModel = z.union([ scriptContents: z.string(), }), }), + z.object({ + eventType: z.literal('ORGANIZATION_ACCESS_TOKEN_CREATED'), + metadata: z.object({ + organizationAccessTokenId: z.string().uuid(), + permissions: z.array(z.string()), + assignedResources: ResourceAssignmentModel, + }), + }), + 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/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index 50427a7c94..e95bd776f2 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'), @@ -366,7 +367,6 @@ const permissionsByLevel = { z.literal('laboratory:describe'), z.literal('laboratory:modify'), z.literal('laboratory:modifyPreflightScript'), - z.literal('schema:loadFromRegistry'), z.literal('schema:compose'), ], service: [ 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..f2ea40ecb8 --- /dev/null +++ b/packages/services/api/src/modules/auth/lib/organization-access-token-strategy.ts @@ -0,0 +1,120 @@ +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; + + 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 cache: OrganizationAccessTokensCache; + + constructor(deps: { logger: Logger; cache: OrganizationAccessTokensCache }) { + super(); + this.logger = deps.logger.child({ module: 'OrganizationAccessTokenStrategy' }); + this.cache = deps.cache; + } + + 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 === 'error') { + this.logger.debug(result.reason); + return null; + } + + const organizationAccessToken = await this.cache.get(result.accessKey.id); + if (!organizationAccessToken) { + return null; + } + + // let's hash it so we do not store the plain private key in memory + const key = hashToken(accessToken); + const isHashMatch = await cache.getOrSetForever({ + factory: () => + OrganizationAccessKey.verify(result.accessKey.privateKey, organizationAccessToken.hash), + key, + }); + + if (!isHashMatch) { + this.logger.debug('Provided private key does not match hash.'); + return null; + } + + return new OrganizationAccessTokenSession( + { + organizationId: organizationAccessToken.organizationId, + policies: organizationAccessToken.authorizationPolicyStatements, + }, + { + logger: args.req.log, + }, + ); + } +} 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..461d6ecc21 100644 --- a/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts +++ b/packages/services/api/src/modules/auth/lib/supertokens-strategy.ts @@ -5,11 +5,7 @@ import { captureException } from '@sentry/node'; import type { User } from '../../../shared/entities'; import { AccessError, HiveError } from '../../../shared/errors'; import { isUUID } from '../../../shared/is-uuid'; -import { - OrganizationMembers, - OrganizationMembershipRoleAssignment, - ResourceAssignment, -} from '../../organization/providers/organization-members'; +import { OrganizationMembers } from '../../organization/providers/organization-members'; import { Logger } from '../../shared/providers/logger'; import type { Storage } from '../../shared/providers/storage'; import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz'; @@ -94,12 +90,7 @@ export class SuperTokensCookieBasedSession extends Session { organizationId, ); - const policyStatements = this.translateAssignedRolesToAuthorizationPolicyStatements( - organizationId, - organizationMembership.assignedRole, - ); - - return policyStatements; + return organizationMembership.assignedRole.authorizationPolicyStatements; } public async getViewer(): Promise { @@ -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/auth/lib/target-access-token-strategy.ts b/packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts index b011779c32..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 Hive + * **o** -> Organization + * **1** -> Version 1 + */ +const keyPrefix = 'hvo1/'; +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); +} + +/** + * 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 } { + 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 id = parts.at(0); + const privateKey = parts.at(1); + + if (id && privateKey) { + return { type: 'ok', accessKey: { id, privateKey } } as const; + } + + 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()) + .digest('hex'); + + const hash = await bcrypt.hash(secret, await bcrypt.genSalt()); + const privateAccessToken = encode(id, secret); + const firstCharacters = privateAccessToken.substr(0, 10); + + return { + privateAccessToken, + hash, + firstCharacters, + }; +} + +/** + * 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); +} 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..bd8296dbb3 --- /dev/null +++ b/packages/services/api/src/modules/organization/lib/organization-access-token-permissions.ts @@ -0,0 +1,67 @@ +import { type PermissionGroup } from './permissions'; + +export const permissionGroups: Array = [ + { + id: 'organization', + title: 'Organization', + permissions: [ + { + id: 'organization:describe', + title: 'Describe organization', + description: 'Fetch information about the specified organization.', + }, + ], + }, + { + id: 'project', + title: 'Project', + permissions: [ + { + id: 'project:describe', + title: 'View project', + description: 'Fetch information about the specified projects.', + }, + ], + }, + { + id: 'services', + title: 'Schema Registry', + 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: 'schemaVersion:deleteService', + title: 'Delete service', + description: 'Deletes a service from the schema registry.', + }, + ], + }, + { + 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.', + }, + ], + }, +]; + +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 92ce201bdb..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 @@ -1,21 +1,7 @@ import { allPermissions, Permission } from '../../auth/lib/authz'; +import { PermissionGroup } from './permissions'; -export type PermissionRecord = { - id: Permission; - title: string; - description: string; - dependsOn?: Permission; - isReadyOnly?: true; - warning?: string; -}; - -export type PermissionGroup = { - id: string; - title: string; - permissions: Array; -}; - -export const allPermissionGroups: Array = [ +export const permissionGroups: Array = [ { id: 'organization', title: 'Organization', @@ -253,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); } @@ -272,7 +258,6 @@ function assertAllRulesAreAssigned(excluded: Array) { */ assertAllRulesAreAssigned([ /** These are CLI only actions for now. */ - 'schema:loadFromRegistry', 'schema:compose', 'schemaCheck:create', 'schemaVersion:publish', @@ -280,6 +265,8 @@ assertAllRulesAreAssigned([ 'appDeployment:create', 'appDeployment:publish', 'appDeployment:retire', + + 'accessToken:modify', ]); /** @@ -288,7 +275,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/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.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 8117f10a8e..cc810f15d1 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -35,6 +35,77 @@ export default gql` updateMemberRole(input: UpdateMemberRoleInput!): UpdateMemberRoleResult! deleteMemberRole(input: DeleteMemberRoleInput!): DeleteMemberRoleResult! assignMemberRole(input: AssignMemberRoleInput!): AssignMemberRoleResult! + createOrganizationAccessToken( + input: CreateOrganizationAccessTokenInput! + ): CreateOrganizationAccessTokenResult! + deleteOrganizationAccessToken( + input: DeleteOrganizationAccessTokenInput! + ): DeleteOrganizationAccessTokenResult! + } + + input OrganizationReferenceInput @oneOf { + bySelector: OrganizationSelectorInput + byId: ID + } + + input CreateOrganizationAccessTokenInput { + organization: OrganizationReferenceInput! + title: String! + description: String + permissions: [String!]! + resources: ResourceAssignmentInput! + } + + type CreateOrganizationAccessTokenResult { + ok: CreateOrganizationAccessTokenResultOk + error: CreateOrganizationAccessTokenResultError + } + + 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 { + id: ID! + title: String! + description: String + permissions: [String!]! + resources: ResourceAssignment! + createdAt: DateTime! + } + + input DeleteOrganizationAccessTokenInput { + organizationAccessTokenId: ID! + } + + type DeleteOrganizationAccessTokenResult { + ok: DeleteOrganizationAccessTokenResultOk + error: DeleteOrganizationAccessTokenResultError + } + + type DeleteOrganizationAccessTokenResultOk { + deletedOrganizationAccessTokenId: ID! + } + + type DeleteOrganizationAccessTokenResultError implements Error { + message: String! } type UpdateOrganizationSlugResult { @@ -244,6 +315,24 @@ 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!]! + """ + 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-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 new file mode 100644 index 0000000000..1bf84bed9d --- /dev/null +++ b/packages/services/api/src/modules/organization/providers/organization-access-tokens.ts @@ -0,0 +1,368 @@ +import { Inject, Injectable, Scope } from 'graphql-modules'; +import { sql, type CommonQueryMethods, 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 { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; +import { + InsufficientPermissionError, + Permission, + 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 { assignablePermissions } from '../lib/organization-access-token-permissions'; +import { OrganizationAccessTokensCache } from './organization-access-tokens-cache'; +import { + resolveResourceAssignment, + ResourceAssignmentModel, + ResourceAssignments, + translateResolvedResourcesToAuthorizationPolicyStatements, +} from './resource-assignments'; + +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.'); + +const DescriptionInputModel = z + .string() + .trim() + .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: ResourceAssignmentModel.nullable().transform( + value => value ?? { mode: '*' as const, projects: [] }, + ), + firstCharacters: z.string(), + hash: z.string(), + }) + .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({ + organizationId: record.organizationId, + projects: record.assignedResources, + }); + + return translateResolvedResourcesToAuthorizationPolicyStatements( + record.organizationId, + permissions, + resolvedResources, + ); + }, + })); + +export type OrganizationAccessToken = z.TypeOf; + +@Injectable({ + scope: Scope.Operation, +}) +export class OrganizationAccessTokens { + logger: Logger; + + private findById: ReturnType; + + constructor( + @Inject(PG_POOL_CONFIG) private pool: DatabasePool, + private cache: OrganizationAccessTokensCache, + private resourceAssignments: ResourceAssignments, + private idTranslator: IdTranslator, + private session: Session, + private auditLogs: AuditLogRecorder, + logger: Logger, + ) { + this.logger = logger.child({ + source: 'OrganizationAccessTokens', + }); + this.findById = findById({ logger: this.logger, pool }); + } + + async create(args: { + organization: GraphQLSchema.OrganizationReferenceInput; + title: string; + description: string | null; + permissions: Array; + assignedResources: GraphQLSchema.ResourceAssignmentInput | null; + }) { + const titleResult = TitleInputModel.safeParse(args.title.trim()); + const descriptionResult = DescriptionInputModel.safeParse(args.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, + }, + }; + } + + 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 permissions = Array.from( + new Set( + args.permissions.filter(permission => assignablePermissions.has(permission as Permission)), + ), + ); + + 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} + , ${organizationId} + , ${titleResult.data} + , ${descriptionResult.data} + , ${sql.array(permissions, 'text')} + , ${sql.jsonb(assignedResources)} + , ${accessKey.hash} + , ${accessKey.firstCharacters} + ) + RETURNING + ${organizationAccessTokenFields} + `); + + const organizationAccessToken = OrganizationAccessTokenModel.parse(result); + + await this.cache.add(organizationAccessToken); + + await this.auditLogs.record({ + organizationId, + eventType: 'ORGANIZATION_ACCESS_TOKEN_CREATED', + metadata: { + organizationAccessTokenId: organizationAccessToken.id, + permissions: organizationAccessToken.permissions, + assignedResources: organizationAccessToken.assignedResources, + }, + }); + + return { + type: 'success' as const, + organizationAccessToken, + privateAccessKey: accessKey.privateAccessToken, + }; + } + + async delete(args: { organizationAccessTokenId: string }) { + 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 }, + }); + + await this.pool.query(sql` + DELETE + FROM + "organization_access_tokens" + WHERE + "id" = ${args.organizationAccessTokenId} + `); + + 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, + }; + } + + 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 ?? ''; + }, + }, + }; + } +} + +/** + * 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: CommonQueryMethods; logger: Logger }) { + return async function findByIdImplementation(organizationAccessTokenId: string) { + deps.logger.debug( + 'Resolve organization access token by id. (organizationAccessTokenId=%s)', + organizationAccessTokenId, + ); + + if (isUUID(organizationAccessTokenId) === false) { + deps.logger.debug( + 'Invalid UUID provided. (organizationAccessTokenId=%s)', + organizationAccessTokenId, + ); + return null; + } + + const data = await deps.pool.maybeOne(sql` + SELECT + ${organizationAccessTokenFields} + FROM + "organization_access_tokens" + WHERE + "id" = ${organizationAccessTokenId} + LIMIT 1 + `); + + if (data === null) { + deps.logger.debug( + 'Organization access token not found. (organizationAccessTokenId=%s)', + organizationAccessTokenId, + ); + return null; + } + + const result = OrganizationAccessTokenModel.parse(data); + + deps.logger.debug( + 'Organization access token found. (organizationAccessTokenId=%s)', + organizationAccessTokenId, + ); + + return result; + }; +} + +const organizationAccessTokenFields = sql` + "id" + , "organization_id" AS "organizationId" + , to_json("created_at") AS "createdAt" + , "title" + , "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 40271ecd8e..04e7ba158f 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,8 +1004,8 @@ export class OrganizationManager { } const resourceAssignmentGroup = - await this.organizationMembers.transformGraphQLMemberResourceAssignmentInputToResourceAssignmentGroup( - organization, + await this.resourceAssignments.transformGraphQLResourceAssignmentInputToResourceAssignmentGroup( + organization.id, 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..81a2113e64 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,18 @@ 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 { type Organization } from '../../../shared/entities'; import { batchBy } from '../../../shared/helpers'; -import { isUUID } from '../../../shared/is-uuid'; -import { AppDeploymentNameModel } from '../../app-deployments/providers/app-deployments'; +import { AuthorizationPolicyStatement } from '../../auth/lib/authz'; 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 { + resolveResourceAssignment, + ResourceAssignmentGroup, + ResourceAssignmentModel, + translateResolvedResourcesToAuthorizationPolicyStatements, +} from './resource-assignments'; const RawOrganizationMembershipModel = z.object({ userId: z.string(), @@ -99,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: [] }, ), }); @@ -112,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 = { @@ -135,7 +61,6 @@ export class OrganizationMembers { constructor( @Inject(PG_POOL_CONFIG) private pool: DatabasePool, private organizationMemberRoles: OrganizationMemberRoles, - private storage: Storage, logger: Logger, ) { this.logger = logger.child({ @@ -198,20 +123,29 @@ 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, + // We have these as a getter statement as they are + // only used in the context of authorization, we do not need + // to compute when querying a list of organization mambers via the GraphQL API. + get authorizationPolicyStatements() { + const resolvedResources = resolveResourceAssignment({ + organizationId: organization.id, + projects: resources, + }); + + return translateResolvedResourcesToAuthorizationPolicyStatements( + organization.id, + membershipRole.permissions, + resolvedResources, + ); + }, }, }); } @@ -313,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} @@ -321,235 +255,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 +263,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/organization-member.spec.ts b/packages/services/api/src/modules/organization/providers/resource-assignments.spec.ts similarity index 99% rename from packages/services/api/src/modules/organization/providers/organization-member.spec.ts rename to packages/services/api/src/modules/organization/providers/resource-assignments.spec.ts index 1fe26c5599..46ea6f5d90 100644 --- a/packages/services/api/src/modules/organization/providers/organization-member.spec.ts +++ b/packages/services/api/src/modules/organization/providers/resource-assignments.spec.ts @@ -1,4 +1,4 @@ -import { resolveResourceAssignment } from './organization-members'; +import { resolveResourceAssignment } from './resource-assignments'; describe('resolveResourceAssignment', () => { test('project wildcard: organization wide access to all 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 new file mode 100644 index 0000000000..e7ad13e385 --- /dev/null +++ b/packages/services/api/src/modules/organization/providers/resource-assignments.ts @@ -0,0 +1,563 @@ +import { Injectable, Scope } from 'graphql-modules'; +import { z } from 'zod'; +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('*'); +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 ResourceAssignmentModel = z.union([ + GranularAssignedProjectsModel, + WildcardAssignmentMode, +]); + +/** + * Resource assignments as stored within the database. + */ +export type ResourceAssignmentGroup = z.TypeOf; +type GranularAssignedProjects = z.TypeOf; + +@Injectable({ + scope: Scope.Operation, +}) +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( + organizationId: string, + 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 !== organizationId) { + 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, + 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, + }; +} + +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/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..220a27c11b --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/createOrganizationAccessToken.ts @@ -0,0 +1,32 @@ +import { OrganizationAccessTokens } from '../../providers/organization-access-tokens'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const createOrganizationAccessToken: NonNullable< + MutationResolvers['createOrganizationAccessToken'] +> = 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/deleteOrganizationAccessToken.ts b/packages/services/api/src/modules/organization/resolvers/Mutation/deleteOrganizationAccessToken.ts new file mode 100644 index 0000000000..83acc5518c --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/Mutation/deleteOrganizationAccessToken.ts @@ -0,0 +1,17 @@ +import { OrganizationAccessTokens } from '../../providers/organization-access-tokens'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const deleteOrganizationAccessToken: NonNullable< + MutationResolvers['deleteOrganizationAccessToken'] +> = 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/Organization.ts b/packages/services/api/src/modules/organization/resolvers/Organization.ts index 71281a6395..1bfc1b2548 100644 --- a/packages/services/api/src/modules/organization/resolvers/Organization.ts +++ b/packages/services/api/src/modules/organization/resolvers/Organization.ts @@ -1,5 +1,7 @@ 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 { 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'; @@ -7,7 +9,9 @@ import type { OrganizationResolvers } from './../../../__generated__/types'; export const Organization: Pick< OrganizationResolvers, + | 'accessTokens' | 'availableMemberPermissionGroups' + | 'availableOrganizationPermissionGroups' | 'cleanId' | 'getStarted' | 'id' @@ -183,6 +187,16 @@ export const Organization: Pick< }); }, availableMemberPermissionGroups: () => { - return allPermissionGroups; + return OrganizationMemberPermissions.permissionGroups; + }, + 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, + }); }, }; 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..caf3465738 --- /dev/null +++ b/packages/services/api/src/modules/organization/resolvers/OrganizationAccessToken.ts @@ -0,0 +1,20 @@ +import { ResourceAssignments } from '../providers/resource-assignments'; +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 = { + resources: async (accessToken, _arg, { injector }) => { + return injector.get(ResourceAssignments).resolveGraphQLMemberResourceAssignment({ + organizationId: accessToken.organizationId, + resources: accessToken.assignedResources, + }); + }, +}; 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..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'); }, }); @@ -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, }, }); 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( diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index fbd0e3eb4d..19d6d4fc67 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -55,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'; @@ -413,10 +415,14 @@ export async function main() { organizationMembers: new OrganizationMembers( storage.pool, new OrganizationMemberRoles(storage.pool, logger), - storage, logger, ), }), + (logger: Logger) => + new OrganizationAccessTokenStrategy({ + logger, + cache: registry.injector.get(OrganizationAccessTokensCache), + }), (logger: Logger) => new TargetAccessTokenStrategy({ logger, diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index fc501ff46c..83f0c3a39c 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; + hash: string; + 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; 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