feat(api): organization access tokens (#6493)

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Laurin Quast 2025-02-25 10:01:32 +08:00 committed by GitHub
parent aca2fc0719
commit 3043d4ea3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 2351 additions and 620 deletions

View file

@ -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,
},
});
});

View file

@ -132,7 +132,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"
}
}
}

View file

@ -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;

View file

@ -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'),
],
});

View file

@ -13,6 +13,7 @@
"devDependencies": {
"@aws-sdk/client-s3": "3.723.0",
"@aws-sdk/s3-request-presigner": "3.723.0",
"@bentocache/plugin-prometheus": "0.2.0",
"@date-fns/utc": "2.1.0",
"@graphql-hive/core": "workspace:*",
"@graphql-inspector/core": "5.1.0-alpha-20231208113249-34700c8a",
@ -44,6 +45,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",

View file

@ -56,6 +56,7 @@ import {
import { Logger } from './modules/shared/providers/logger';
import { Mutex } from './modules/shared/providers/mutex';
import { PG_POOL_CONFIG } from './modules/shared/providers/pg-pool';
import { PrometheusConfig } from './modules/shared/providers/prometheus-config';
import { HivePubSub, PUB_SUB_CONFIG } from './modules/shared/providers/pub-sub';
import { REDIS_INSTANCE } from './modules/shared/providers/redis';
import { S3_CONFIG, type S3Config } from './modules/shared/providers/s3-config';
@ -112,6 +113,7 @@ export function createRegistry({
organizationOIDC,
pubSub,
appDeploymentsEnabled,
prometheus,
}: {
logger: Logger;
storage: Storage;
@ -155,6 +157,7 @@ export function createRegistry({
organizationOIDC: boolean;
pubSub: HivePubSub;
appDeploymentsEnabled: boolean;
prometheus: null | Record<string, unknown>;
}) {
const s3Config: S3Config = [
{
@ -303,6 +306,12 @@ export function createRegistry({
scope: Scope.Operation,
deps: [CONTEXT],
},
{
provide: PrometheusConfig,
useFactory() {
return new PrometheusConfig(!!prometheus);
},
},
];
if (emailsEndpoint) {

View file

@ -1,4 +1,5 @@
import { z } from 'zod';
import { ResourceAssignmentModel } from '../../organization/lib/resource-assignment-model';
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<typeof AuditLogModel>;

View file

@ -2,6 +2,7 @@ import { createModule } from 'graphql-modules';
import { AuditLogManager } from '../audit-logs/providers/audit-logs-manager';
import { AuthManager } from './providers/auth-manager';
import { OrganizationAccess } from './providers/organization-access';
import { OrganizationAccessTokenValidationCache } from './providers/organization-access-token-validation-cache';
import { ProjectAccess } from './providers/project-access';
import { TargetAccess } from './providers/target-access';
import { UserManager } from './providers/user-manager';
@ -20,5 +21,6 @@ export const authModule = createModule({
ProjectAccess,
TargetAccess,
AuditLogManager,
OrganizationAccessTokenValidationCache,
],
});

View file

@ -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: [

View file

@ -0,0 +1,116 @@
import * as crypto from 'node:crypto';
import { type FastifyReply, type FastifyRequest } from '@hive/service-common';
import * as OrganizationAccessKey from '../../organization/lib/organization-access-key';
import { OrganizationAccessTokensCache } from '../../organization/providers/organization-access-tokens-cache';
import { Logger } from '../../shared/providers/logger';
import { OrganizationAccessTokenValidationCache } from '../providers/organization-access-token-validation-cache';
import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz';
function hashToken(token: string) {
return crypto.createHash('sha256').update(token).digest('hex');
}
export class OrganizationAccessTokenSession extends Session {
public readonly organizationId: string;
private policies: Array<AuthorizationPolicyStatement>;
constructor(
args: {
organizationId: string;
policies: Array<AuthorizationPolicyStatement>;
},
deps: {
logger: Logger;
},
) {
super({ logger: deps.logger });
this.organizationId = args.organizationId;
this.policies = args.policies;
}
protected loadPolicyStatementsForOrganization(
_: string,
): Promise<Array<AuthorizationPolicyStatement>> | Array<AuthorizationPolicyStatement> {
return this.policies;
}
}
export class OrganizationAccessTokenStrategy extends AuthNStrategy<OrganizationAccessTokenSession> {
private logger: Logger;
private organizationAccessTokenCache: OrganizationAccessTokensCache;
private organizationAccessTokenValidationCache: OrganizationAccessTokenValidationCache;
constructor(deps: {
logger: Logger;
organizationAccessTokensCache: OrganizationAccessTokensCache;
organizationAccessTokenValidationCache: OrganizationAccessTokenValidationCache;
}) {
super();
this.logger = deps.logger.child({ module: 'OrganizationAccessTokenStrategy' });
this.organizationAccessTokenCache = deps.organizationAccessTokensCache;
this.organizationAccessTokenValidationCache = deps.organizationAccessTokenValidationCache;
}
async parse(args: {
req: FastifyRequest;
reply: FastifyReply;
}): Promise<OrganizationAccessTokenSession | null> {
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.organizationAccessTokenCache.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 this.organizationAccessTokenValidationCache.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,
},
);
}
}

View file

@ -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<User> {
@ -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<ResourceAssignment>,
): Array<string>;
private toResourceIdentifier(
organizationId: string,
resource: ResourceAssignment | Array<ResourceAssignment>,
): string | Array<string> {
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<AuthorizationPolicyStatement> {
const policyStatements: Array<AuthorizationPolicyStatement> = [];
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<SuperTokensCookieBasedSession> {
@ -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.');
}

View file

@ -110,11 +110,6 @@ export class TargetAccessTokenStrategy extends AuthNStrategy<TargetAccessTokenSe
return null;
}
// if (accessToken.length !== 32) {
// this.logger.debug('Invalid access token length.');
// return null;
// }
const tokens = new TokenStorage(this.logger, this.tokensConfig, {
requestId: args.req.headers['x-request-id'] as string,
} as any);
@ -177,7 +172,6 @@ function transformAccessTokenLegacyScopes(args: {
'appDeployment:retire',
'schemaVersion:publish',
'schemaVersion:deleteService',
'schema:loadFromRegistry',
'schemaVersion:publish',
],
resource: [`hrn:${args.organizationId}:target/${args.targetId}`],

View file

@ -1,8 +1,5 @@
import type { User } from '../../shared/entities';
import {
PermissionGroup,
PermissionRecord,
} from '../organization/lib/organization-member-permissions';
import { PermissionGroup, PermissionRecord } from '../organization/lib/permissions';
import type { OrganizationAccessScope } from './providers/organization-access';
import type { ProjectAccessScope } from './providers/project-access';
import type { TargetAccessScope } from './providers/target-access';

View file

@ -0,0 +1,40 @@
import { BentoCache, bentostore } from 'bentocache';
import { memoryDriver } from 'bentocache/build/src/drivers/memory';
import { Injectable, Scope } from 'graphql-modules';
import { prometheusPlugin } from '@bentocache/plugin-prometheus';
import { PrometheusConfig } from '../../shared/providers/prometheus-config';
/**
* Cache for performant OrganizationAccessToken lookups.
*/
@Injectable({
scope: Scope.Singleton,
global: true,
})
export class OrganizationAccessTokenValidationCache {
private cache: BentoCache<{ store: ReturnType<typeof bentostore> }>;
constructor(prometheusConfig: PrometheusConfig) {
this.cache = new BentoCache({
default: 'organizationAccessTokenValidation',
plugins: prometheusConfig.isEnabled
? [
prometheusPlugin({
prefix: 'bentocache_organization_access_token_validation',
}),
]
: undefined,
stores: {
organizationAccessTokenValidation: bentostore().useL1Layer(
memoryDriver({
maxItems: 10_000,
prefix: 'bentocache:organization-access-token-validation',
}),
),
},
});
}
getOrSetForever: typeof this.cache.getOrSetForever = (...args) =>
this.cache.getOrSetForever(...args);
}

View file

@ -15,7 +15,7 @@ export const Permission: PermissionResolvers = {
return permission.dependsOn ?? null;
},
isReadOnly: (permission, _arg, _ctx) => {
return permission.isReadyOnly ?? false;
return permission.isReadOnly ?? false;
},
level: async (permission, _arg, _ctx) => {
return getPermissionGroup(permission.id);

View file

@ -1,7 +1,10 @@
import { createModule } from 'graphql-modules';
import { OrganizationAccessTokens } from './providers/organization-access-tokens';
import { OrganizationAccessTokensCache } from './providers/organization-access-tokens-cache';
import { OrganizationManager } from './providers/organization-manager';
import { OrganizationMemberRoles } from './providers/organization-member-roles';
import { OrganizationMembers } from './providers/organization-members';
import { ResourceAssignments } from './providers/resource-assignments';
import { resolvers } from './resolvers.generated';
import typeDefs from './module.graphql';
@ -10,5 +13,12 @@ export const organizationModule = createModule({
dirname: __dirname,
typeDefs,
resolvers,
providers: [OrganizationMemberRoles, OrganizationMembers, OrganizationManager],
providers: [
OrganizationMemberRoles,
OrganizationMembers,
OrganizationManager,
OrganizationAccessTokens,
ResourceAssignments,
OrganizationAccessTokensCache,
],
});

View file

@ -0,0 +1,96 @@
import * as Crypto from 'crypto';
import bcrypt from 'bcryptjs';
/**
* @module OrganizationAccessKey
* Contains functions for generating an organization acces key.
*/
/**
* Payload within the access token.
*/
type DecodedAccessKey = {
/** UUID as stored within the database ("organization_access_tokens"."id") */
id: string;
/** string to compare against the hash within the database ("organization_access_tokens"."hash") */
privateKey: string;
};
/**
* Prefix for the organization access key.
* We use this prefix so we can quickly identify whether an organization access token.
*
* **hv** -> 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);
}

View file

@ -0,0 +1,67 @@
import { type PermissionGroup } from './permissions';
export const permissionGroups: Array<PermissionGroup> = [
{
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)),
);

View file

@ -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<PermissionRecord>;
};
export const allPermissionGroups: Array<PermissionGroup> = [
export const permissionGroups: Array<PermissionGroup> = [
{
id: 'organization',
title: 'Organization',
@ -24,7 +10,7 @@ export const allPermissionGroups: Array<PermissionGroup> = [
id: 'organization:describe',
title: 'View organization',
description: 'Member can see the organization. Permission can not be modified.',
isReadyOnly: true,
isReadOnly: true,
},
{
id: 'support:manageTickets',
@ -253,7 +239,7 @@ function assertAllRulesAreAssigned(excluded: Array<Permission>) {
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<Permission>) {
*/
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,9 +275,9 @@ assertAllRulesAreAssigned([
export const permissions = (() => {
const assignable = new Set<Permission>();
const readOnly = new Set<Permission>();
for (const group of allPermissionGroups) {
for (const group of permissionGroups) {
for (const permission of group.permissions) {
if (permission.isReadyOnly === true) {
if (permission.isReadOnly === true) {
readOnly.add(permission.id);
continue;
}

View file

@ -0,0 +1,16 @@
import type { Permission } from '../../auth/lib/authz';
export type PermissionRecord = {
id: Permission;
title: string;
description: string;
dependsOn?: Permission;
isReadOnly?: true;
warning?: string;
};
export type PermissionGroup = {
id: string;
title: string;
permissions: Array<PermissionRecord>;
};

View file

@ -0,0 +1,81 @@
import { z } from 'zod';
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,
]);
export 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<typeof ResourceAssignmentModel>;
export type GranularAssignedProjects = z.TypeOf<typeof GranularAssignedProjectsModel>;

View file

@ -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;

View file

@ -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 {

View file

@ -0,0 +1,79 @@
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 { prometheusPlugin } from '@bentocache/plugin-prometheus';
import { Logger } from '../../shared/providers/logger';
import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool';
import { PrometheusConfig } from '../../shared/providers/prometheus-config';
import { REDIS_INSTANCE } from '../../shared/providers/redis';
import { findById, type OrganizationAccessToken } from './organization-access-tokens';
/**
* Cache for performant OrganizationAccessToken lookups.
*/
@Injectable({
scope: Scope.Singleton,
global: true,
})
export class OrganizationAccessTokensCache {
private findById: ReturnType<typeof findById>;
private cache: BentoCache<{ store: ReturnType<typeof bentostore> }>;
constructor(
@Inject(REDIS_INSTANCE) redis: Redis,
@Inject(PG_POOL_CONFIG) pool: DatabasePool,
logger: Logger,
prometheusConfig: PrometheusConfig,
) {
this.findById = findById({ pool, logger });
this.cache = new BentoCache({
default: 'organizationAccessTokens',
plugins: prometheusConfig.isEnabled
? [
prometheusPlugin({
prefix: 'bentocache_organization_access_tokens',
}),
]
: undefined,
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,
});
}
}

View file

@ -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 { ResourceAssignmentModel } from '../lib/resource-assignment-model';
import { OrganizationAccessTokensCache } from './organization-access-tokens-cache';
import {
resolveResourceAssignment,
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<typeof OrganizationAccessTokenModel>;
@Injectable({
scope: Scope.Operation,
})
export class OrganizationAccessTokens {
logger: Logger;
private findById: ReturnType<typeof findById>;
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<string>;
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<unknown>(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<unknown>(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<unknown>(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"
`;

View file

@ -19,6 +19,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.
@ -43,6 +44,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,
) {
@ -962,8 +964,8 @@ export class OrganizationManager {
}
const resourceAssignmentGroup =
await this.organizationMembers.transformGraphQLMemberResourceAssignmentInputToResourceAssignmentGroup(
organization,
await this.resourceAssignments.transformGraphQLResourceAssignmentInputToResourceAssignmentGroup(
organization.id,
input.resources,
);

View file

@ -1,92 +1,20 @@
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 {
ResourceAssignmentModel,
type ResourceAssignmentGroup,
} from '../lib/resource-assignment-model';
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<typeof AssignedProjectsModel>;
type GranularAssignedProjects = z.TypeOf<typeof GranularAssignedProjectsModel>;
import {
resolveResourceAssignment,
translateResolvedResourcesToAuthorizationPolicyStatements,
} from './resource-assignments';
const RawOrganizationMembershipModel = z.object({
userId: z.string(),
@ -99,7 +27,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 +40,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 +63,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 +125,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 +249,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 +257,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<GraphQLSchema.ResolversTypes['ResourceAssignment']> {
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<ResourceAssignmentGroup> {
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<string>();
const projectTargetAssignments: Array<{
project: Project;
/** mutable array that is within "resourceAssignmentGroup" */
projectTargets: Array<z.TypeOf<typeof TargetAssignmentModel>>;
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<z.TypeOf<typeof TargetAssignmentModel>> = [];
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<T>(input: T | null): input is Exclude<T, null> {
return input != null;
}
const organizationMemberFields = (prefix = sql`"organization_member"`) => sql`
@ -558,147 +265,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<ProjectAssignment>;
target: OrganizationAssignment | Array<ProjectAssignment | TargetAssignment>;
service: OrganizationAssignment | Array<ProjectAssignment | TargetAssignment | ServiceAssignment>;
appDeployment:
| OrganizationAssignment
| Array<ProjectAssignment | TargetAssignment | AppDeploymentAssignment>;
};
/**
* 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,
};
}

View file

@ -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', () => {

View file

@ -0,0 +1,488 @@
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';
import {
GranularAssignedProjects,
TargetAssignmentModel,
type ResourceAssignmentGroup,
} from '../lib/resource-assignment-model';
@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<GraphQLSchema.ResolversTypes['ResourceAssignment']> {
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<ResourceAssignmentGroup> {
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<string>();
const projectTargetAssignments: Array<{
project: Project;
/** mutable array that is within "resourceAssignmentGroup" */
projectTargets: Array<z.TypeOf<typeof TargetAssignmentModel>>;
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<z.TypeOf<typeof TargetAssignmentModel>> = [];
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<T>(input: T | null): input is Exclude<T, null> {
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<ProjectAssignment>;
target: OrganizationAssignment | Array<ProjectAssignment | TargetAssignment>;
service: OrganizationAssignment | Array<ProjectAssignment | TargetAssignment | ServiceAssignment>;
appDeployment:
| OrganizationAssignment
| Array<ProjectAssignment | TargetAssignment | AppDeploymentAssignment>;
};
/**
* 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<ResourceAssignment>,
): Array<string>;
export function toResourceIdentifier(
organizationId: string,
resource: ResourceAssignment | Array<ResourceAssignment>,
): string | Array<string> {
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<AuthorizationPolicyStatement> = [];
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;
}

View file

@ -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,
});
},
};

View file

@ -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,
},
};
};

View file

@ -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,
},
};
};

View file

@ -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,
});
},
};

View file

@ -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,
});
},
};

View file

@ -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,
},
});

View file

@ -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(

View file

@ -0,0 +1,12 @@
import { Injectable, Scope } from 'graphql-modules';
@Injectable({
scope: Scope.Singleton,
})
export class PrometheusConfig {
constructor(private _isEnabled = false) {}
get isEnabled() {
return this._isEnabled;
}
}

View file

@ -55,8 +55,11 @@ 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 { OrganizationAccessTokenValidationCache } from '../../api/src/modules/auth/providers/organization-access-token-validation-cache';
import { OrganizationAccessTokensCache } from '../../api/src/modules/organization/providers/organization-access-tokens-cache';
import { internalApiRouter } from './api';
import { asyncStorage } from './async-storage';
import { env } from './environment';
@ -396,6 +399,7 @@ export async function main() {
supportConfig: env.zendeskSupport,
pubSub,
appDeploymentsEnabled: env.featureFlags.appDeploymentsEnabled,
prometheus: env.prometheus,
});
const authN = new AuthN({
@ -407,10 +411,17 @@ export async function main() {
organizationMembers: new OrganizationMembers(
storage.pool,
new OrganizationMemberRoles(storage.pool, logger),
storage,
logger,
),
}),
(logger: Logger) =>
new OrganizationAccessTokenStrategy({
logger,
organizationAccessTokensCache: registry.injector.get(OrganizationAccessTokensCache),
organizationAccessTokenValidationCache: registry.injector.get(
OrganizationAccessTokenValidationCache,
),
}),
(logger: Logger) =>
new TargetAccessTokenStrategy({
logger,

View file

@ -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<string>;
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;

13
patches/bentocache.patch Normal file
View file

@ -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",

View file

@ -37,6 +37,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
@ -678,6 +681,9 @@ importers:
'@aws-sdk/s3-request-presigner':
specifier: 3.723.0
version: 3.723.0
'@bentocache/plugin-prometheus':
specifier: 0.2.0
version: 0.2.0(bentocache@1.1.0(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.4.2))(prom-client@15.1.3)
'@date-fns/utc':
specifier: 2.1.0
version: 2.1.0
@ -771,6 +777,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
@ -2891,6 +2900,21 @@ packages:
'@balena/dockerignore@1.0.2':
resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==}
'@bentocache/plugin-prometheus@0.2.0':
resolution: {integrity: sha512-ZaWtexpwDf6cSy2dZaRl36BAZi1eSM8QDnGeJQ0qN7rJ6TEvrP3v0egH70Gxc5mdHY7xhh0Zppf+kAoTgJZx3A==}
peerDependencies:
bentocache: ^1.0.0
prom-client: ^15.0.0
'@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==}
@ -4377,6 +4401,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==}
@ -4642,6 +4669,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'}
@ -5354,6 +5385,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'}
@ -5458,6 +5492,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==}
@ -7525,6 +7575,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==}
@ -7712,6 +7765,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==}
@ -8241,6 +8297,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==}
@ -8328,6 +8387,26 @@ packages:
before-after-hook@3.0.2:
resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==}
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'}
@ -8551,6 +8630,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==}
@ -10098,6 +10181,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'}
@ -10687,6 +10774,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==}
@ -14082,6 +14173,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==}
@ -14110,6 +14205,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==}
@ -14283,6 +14381,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'}
@ -14824,6 +14926,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'}
@ -17380,6 +17485,19 @@ snapshots:
'@balena/dockerignore@1.0.2': {}
'@bentocache/plugin-prometheus@0.2.0(bentocache@1.1.0(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.4.2))(prom-client@15.1.3)':
dependencies:
bentocache: 1.1.0(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.4.2)
prom-client: 15.1.3
'@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':
@ -19587,6 +19705,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)':
@ -19837,6 +19960,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
@ -20966,6 +21091,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
@ -21058,6 +21187,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': {}
@ -23846,6 +23999,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
@ -24058,6 +24213,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': {}
@ -24654,6 +24811,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
@ -24764,6 +24925,18 @@ snapshots:
before-after-hook@3.0.2: {}
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
@ -25053,6 +25226,8 @@ snapshots:
ansicolors: 0.3.2
redeyed: 2.1.1
case-anything@3.1.0: {}
caseless@0.12.0: {}
ccount@2.0.1: {}
@ -26962,6 +27137,8 @@ snapshots:
flatted@3.2.7: {}
flattie@1.1.1: {}
fn-name@3.0.0: {}
follow-redirects@1.15.6(debug@4.3.7):
@ -27761,6 +27938,8 @@ snapshots:
help-me@5.0.0: {}
hexoid@2.0.0: {}
highlight-words-core@1.2.2: {}
hoist-non-react-statics@3.3.2:
@ -31865,6 +32044,8 @@ snapshots:
safe-stable-stringify@2.4.2: {}
safe-stable-stringify@2.5.0: {}
safer-buffer@2.1.2: {}
sax@1.4.1:
@ -31891,6 +32072,8 @@ snapshots:
secure-json-parse@2.7.0: {}
secure-json-parse@3.0.2: {}
semver-compare@1.0.0: {}
semver@5.7.2: {}
@ -32130,6 +32313,8 @@ snapshots:
transitivePeerDependencies:
- pg-native
slugify@1.6.6: {}
smart-buffer@4.2.0: {}
snake-case@3.0.4:
@ -32723,6 +32908,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