mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
feat(api): organization access tokens (#6493)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
parent
aca2fc0719
commit
3043d4ea3e
41 changed files with 2351 additions and 620 deletions
413
integration-tests/tests/api/organization-access-tokens.spec.ts
Normal file
413
integration-tests/tests/api/organization-access-tokens.spec.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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'),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`],
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)),
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
`;
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
13
patches/bentocache.patch
Normal 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",
|
||||
187
pnpm-lock.yaml
187
pnpm-lock.yaml
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue