diff --git a/apps/backend/src/migrations/0027-add-projects-policies.ts b/apps/backend/src/migrations/0027-add-projects-policies.ts index 9c471315..27f98975 100644 --- a/apps/backend/src/migrations/0027-add-projects-policies.ts +++ b/apps/backend/src/migrations/0027-add-projects-policies.ts @@ -18,9 +18,56 @@ export async function up(db: Kysely): Promise { .addColumn('user_id', 'integer', col => col.references('users.id').onDelete('restrict')) .addColumn('group_id', 'integer', col => col.references('users_groups.id').onDelete('restrict')) .addColumn('level', sql`project_access_level`, col => col.notNull()) + .addCheckConstraint( + 'user_xor_group_check', + sql`COALESCE((user_id IS NOT NULL)::int, 0) + COALESCE((group_id IS NOT NULL)::int, 0) = 1`, + ) .addUniqueConstraint('project_id_user_id_group_id_unique', ['project_id', 'user_id', 'group_id']) .execute(); + // Create validation triggers + await sql` + CREATE OR REPLACE FUNCTION projects_policies_check_user_organization() RETURNS TRIGGER AS $$ + BEGIN + IF NEW.user_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 + FROM organizations_users ou + JOIN projects p ON p.id = NEW.project_id + WHERE ou.user_id = NEW.user_id + AND ou.organization_id = p.organization_id + ) THEN + RAISE EXCEPTION 'User must belong to the project organization'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER projects_policies_user_organization_check + BEFORE INSERT OR UPDATE ON projects_policies + FOR EACH ROW + EXECUTE FUNCTION projects_policies_check_user_organization(); + + CREATE OR REPLACE FUNCTION projects_policies_check_group_organization() RETURNS TRIGGER AS $$ + BEGIN + IF NEW.group_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 + FROM users_groups ug + JOIN projects p ON p.id = NEW.project_id + WHERE ug.id = NEW.group_id + AND ug.organization_id = p.organization_id + ) THEN + RAISE EXCEPTION 'Group must belong to the project organization'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER projects_policies_group_organization_check + BEFORE INSERT OR UPDATE ON projects_policies + FOR EACH ROW + EXECUTE FUNCTION projects_policies_check_group_organization(); + `.execute(db); + // Alter projects table to add creator_user_id column await db.schema .alterTable('projects') @@ -45,6 +92,15 @@ export async function up(db: Kysely): Promise { } export async function down(db: Kysely): Promise { + await sql` + DROP TRIGGER IF EXISTS projects_policies_user_organization_check ON projects_policies; + DROP FUNCTION IF EXISTS projects_policies_check_user_organization(); + + DROP TRIGGER IF EXISTS projects_policies_group_organization_check ON projects_policies; + DROP FUNCTION IF EXISTS projects_policies_check_group_organization(); + `.execute(db); + await db.schema.alterTable('projects').dropColumn('creator_user_id').execute(); await db.schema.dropTable('projects_policies').execute(); + await db.schema.dropType('project_access_level').execute(); } diff --git a/apps/backend/src/modules/elasticsearch/boot/elasticsearch-registry.boot.ts b/apps/backend/src/modules/elasticsearch/boot/elasticsearch-registry.boot.ts index e0b6cf59..89a6960f 100644 --- a/apps/backend/src/modules/elasticsearch/boot/elasticsearch-registry.boot.ts +++ b/apps/backend/src/modules/elasticsearch/boot/elasticsearch-registry.boot.ts @@ -13,6 +13,7 @@ import { OrganizationsEsIndexRepo } from '~/modules/organizations/elasticsearch' import { OrganizationsS3BucketsEsIndexRepo } from '~/modules/organizations/s3-buckets/elasticsearch/organizations-s3-buckets-es-index.repo'; import { ProjectsEmbeddingsEsIndexRepo } from '~/modules/projects-embeddings/elasticsearch/projects-embeddings-es-index.repo'; import { ProjectsFilesEsIndexRepo } from '~/modules/projects-files/elasticsearch/projects-files-es-index.repo'; +import { ProjectsPoliciesEsIndexRepo } from '~/modules/projects-policies/elasticsearch/projects-policies-es-index.repo'; import { ProjectsEsIndexRepo } from '~/modules/projects/elasticsearch/projects-es-index.repo'; import { UsersEsIndexRepo } from '~/modules/users/elasticsearch/users-es-index.repo'; @@ -35,6 +36,7 @@ export class ElasticsearchRegistryBootService { @inject(AppsCategoriesEsIndexRepo) private readonly appsCategoriesEsIndexRepo: AppsCategoriesEsIndexRepo, @inject(ProjectsFilesEsIndexRepo) private readonly projectsFilesEsIndexRepo: ProjectsFilesEsIndexRepo, @inject(ProjectsEmbeddingsEsIndexRepo) private readonly projectsEmbeddingsEsIndexRepo: ProjectsEmbeddingsEsIndexRepo, + @inject(ProjectsPoliciesEsIndexRepo) private readonly projectsPoliciesEsIndexRepo: ProjectsPoliciesEsIndexRepo, ) {} register = TE.fromIO(() => { @@ -50,6 +52,7 @@ export class ElasticsearchRegistryBootService { this.appsCategoriesEsIndexRepo, this.projectsFilesEsIndexRepo, this.projectsEmbeddingsEsIndexRepo, + this.projectsPoliciesEsIndexRepo, ]); this.logger.info('Registered elasticsearch repos!'); diff --git a/apps/backend/src/modules/projects-policies/elasticsearch/index.ts b/apps/backend/src/modules/projects-policies/elasticsearch/index.ts new file mode 100644 index 00000000..5230a316 --- /dev/null +++ b/apps/backend/src/modules/projects-policies/elasticsearch/index.ts @@ -0,0 +1,2 @@ +export * from './projects-policies-es-index.repo'; +export * from './projects-policies-es-search.repo'; diff --git a/apps/backend/src/modules/projects-policies/elasticsearch/projects-policies-es-index.repo.ts b/apps/backend/src/modules/projects-policies/elasticsearch/projects-policies-es-index.repo.ts new file mode 100644 index 00000000..a24f5114 --- /dev/null +++ b/apps/backend/src/modules/projects-policies/elasticsearch/projects-policies-es-index.repo.ts @@ -0,0 +1,72 @@ +import { array as A, taskEither as TE } from 'fp-ts'; +import { pipe } from 'fp-ts/lib/function'; +import snakecaseKeys from 'snakecase-keys'; +import { inject, injectable } from 'tsyringe'; + +import { tryOrThrowTE } from '@llm/commons'; +import { + createBaseDatedRecordMappings, + createElasticsearchIndexRepo, + createIdObjectMapping, + ElasticsearchRepo, + type EsDocument, +} from '~/modules/elasticsearch'; + +import type { ProjectPolicyTableRowWithRelations } from '../projects-policies.tables'; + +import { ProjectsPoliciesRepo } from '../projects-policies.repo'; + +const ProjectsPoliciesAbstractEsIndexRepo = createElasticsearchIndexRepo({ + indexName: 'dashboard-projects-policies', + schema: { + mappings: { + dynamic: false, + properties: { + ...createBaseDatedRecordMappings(), + project: createIdObjectMapping(), + user: createIdObjectMapping(), + group: createIdObjectMapping({ + users: { + type: 'nested', + ...createIdObjectMapping({ + email: { type: 'keyword' }, + }), + }, + }), + }, + }, + settings: { + 'index.number_of_replicas': 1, + }, + }, +}); + +export type ProjectsPoliciesEsDocument = EsDocument; + +@injectable() +export class ProjectsPoliciesEsIndexRepo extends ProjectsPoliciesAbstractEsIndexRepo { + constructor( + @inject(ElasticsearchRepo) elasticsearchRepo: ElasticsearchRepo, + @inject(ProjectsPoliciesRepo) private readonly policiesRepo: ProjectsPoliciesRepo, + ) { + super(elasticsearchRepo); + } + + protected async findEntities(ids: number[]): Promise { + return pipe( + this.policiesRepo.findWithRelationsByIds({ ids }), + TE.map( + A.map(entity => ({ + ...snakecaseKeys(entity, { deep: true }), + _id: String(entity.id), + })), + ), + tryOrThrowTE, + )(); + } + + protected createAllEntitiesIdsIterator = () => + this.policiesRepo.createIdsIterator({ + chunkSize: 100, + }); +} diff --git a/apps/backend/src/modules/projects-policies/elasticsearch/projects-policies-es-search.repo.ts b/apps/backend/src/modules/projects-policies/elasticsearch/projects-policies-es-search.repo.ts new file mode 100644 index 00000000..5d40329a --- /dev/null +++ b/apps/backend/src/modules/projects-policies/elasticsearch/projects-policies-es-search.repo.ts @@ -0,0 +1,59 @@ +import esb from 'elastic-builder'; +import { taskEither as TE } from 'fp-ts'; +import { pipe } from 'fp-ts/lib/function'; +import { inject, injectable } from 'tsyringe'; + +import type { SdkProjectPolicyT } from '@llm/sdk'; +import type { TableId } from '~/modules/database'; + +import { tryGetFirstRawResponseHitOrNotExists } from '~/modules/elasticsearch/helpers'; + +import { + type ProjectsPoliciesEsDocument, + ProjectsPoliciesEsIndexRepo, +} from './projects-policies-es-index.repo'; + +@injectable() +export class ProjectsPoliciesEsSearchRepo { + constructor( + @inject(ProjectsPoliciesEsIndexRepo) private readonly indexRepo: ProjectsPoliciesEsIndexRepo, + ) {} + + getByProjectId = (projectId: TableId) => pipe( + this.indexRepo.search( + esb + .requestBodySearch() + .query( + esb + .boolQuery() + .must(esb.termQuery('project.id', projectId)), + ) + .toJSON(), + ), + tryGetFirstRawResponseHitOrNotExists, + TE.map(doc => ProjectsPoliciesEsSearchRepo.mapOutputHit(doc._source as ProjectsPoliciesEsDocument)), + ); + + private static mapOutputHit = (source: ProjectsPoliciesEsDocument): SdkProjectPolicyT => { + const record = { + id: source.id, + createdAt: source.created_at, + updatedAt: source.updated_at, + project: source.project, + user: null, + group: null, + }; + + if (source.user) { + return { + ...record, + user: source.user, + }; + } + + return { + ...record, + group: source.group!, + }; + }; +} diff --git a/apps/backend/src/modules/projects-policies/index.ts b/apps/backend/src/modules/projects-policies/index.ts index c86b9930..840e6032 100644 --- a/apps/backend/src/modules/projects-policies/index.ts +++ b/apps/backend/src/modules/projects-policies/index.ts @@ -1 +1,4 @@ +export * from './elasticsearch'; +export * from './projects-policies.repo'; +export * from './projects-policies.service'; export * from './projects-policies.tables'; diff --git a/apps/backend/src/modules/projects-policies/projects-policies.repo.ts b/apps/backend/src/modules/projects-policies/projects-policies.repo.ts new file mode 100644 index 00000000..d3f8dbd6 --- /dev/null +++ b/apps/backend/src/modules/projects-policies/projects-policies.repo.ts @@ -0,0 +1,90 @@ +import { array as A, option as O, taskEither as TE } from 'fp-ts'; +import { pipe } from 'fp-ts/lib/function'; +import { injectable } from 'tsyringe'; + +import { + createDatabaseRepo, + DatabaseError, + type TableId, + type TransactionalAttrs, + tryReuseTransactionOrSkip, +} from '../database'; +import { ProjectPolicyTableRowWithRelations } from './projects-policies.tables'; + +@injectable() +export class ProjectsPoliciesRepo extends createDatabaseRepo('projects_policies') { + findWithRelationsByIds = ({ forwardTransaction, ids }: TransactionalAttrs<{ ids: TableId[]; }>) => { + const transaction = tryReuseTransactionOrSkip({ db: this.db, forwardTransaction }); + + return pipe( + transaction( + async qb => + qb + .selectFrom(this.table) + .where('projects_policies.id', 'in', ids) + .innerJoin('projects', 'projects.id', 'project_id') + + .leftJoin('users', 'users.id', 'user_id') + .leftJoin('users_groups', 'users_groups.id', 'group_id') + + .selectAll('projects_policies') + .select([ + 'user_id as user_id', + 'users.email as user_email', + + 'group_id as group_id', + 'users_groups.name as group_name', + + 'projects.id as project_id', + 'projects.name as project_name', + ]) + .limit(ids.length) + .execute(), + ), + DatabaseError.tryTask, + TE.map( + A.filterMap(({ + project_id: projectId, + project_name: projectName, + + user_id: userId, + user_email: userEmail, + + group_id: groupId, + group_name: groupName, + + ...item + }): O.Option => { + const record = { + id: item.id, + createdAt: item.created_at, + updatedAt: item.updated_at, + accessLevel: item.access_level, + group: null, + user: null, + project: { + id: projectId, + name: projectName, + }, + }; + + if (groupId) { + return O.some({ + ...record, + group: { id: groupId, name: groupName!, users: [] }, + }); + } + + if (userId) { + return O.some({ + ...record, + user: { id: userId, email: userEmail! }, + }); + } + + return O.none; + }), + ), + ); + }; +} diff --git a/apps/backend/src/modules/projects-policies/projects-policies.service.ts b/apps/backend/src/modules/projects-policies/projects-policies.service.ts new file mode 100644 index 00000000..e50632e4 --- /dev/null +++ b/apps/backend/src/modules/projects-policies/projects-policies.service.ts @@ -0,0 +1,12 @@ +import { inject, injectable } from 'tsyringe'; + +import { ProjectsPoliciesEsSearchRepo } from './elasticsearch'; + +@injectable() +export class ProjectsPoliciesService { + constructor( + @inject(ProjectsPoliciesEsSearchRepo) private readonly esSearchRepo: ProjectsPoliciesEsSearchRepo, + ) {} + + getByProjectId = this.esSearchRepo.getByProjectId; +} diff --git a/apps/backend/src/modules/projects-policies/projects-policies.tables.ts b/apps/backend/src/modules/projects-policies/projects-policies.tables.ts index bcbeb868..57511904 100644 --- a/apps/backend/src/modules/projects-policies/projects-policies.tables.ts +++ b/apps/backend/src/modules/projects-policies/projects-policies.tables.ts @@ -2,7 +2,13 @@ import type { ColumnType } from 'kysely'; import type { SdkProjectAccessLevelT } from '@llm/sdk'; -import type { TableId, TableWithDefaultColumns } from '../database'; +import type { + NormalizeSelectTableRow, + TableId, + TableRowWithIdName, + TableWithDefaultColumns, +} from '../database'; +import type { UserTableRowBaseRelation } from '../users'; export type ProjectsPoliciesTable = & TableWithDefaultColumns @@ -12,3 +18,27 @@ export type ProjectsPoliciesTable = group_id: ColumnType; access_level: SdkProjectAccessLevelT; }; + +export type ProjectPolicyTableRow = NormalizeSelectTableRow; + +export type ProjectPolicyGroupRelationTableRow = + & TableRowWithIdName + & { + users: UserTableRowBaseRelation[]; + }; + +export type ProjectPolicyTableRowWithRelations = + & Omit + & { + project: TableRowWithIdName; + } + & ( + { + user: null; + group: ProjectPolicyGroupRelationTableRow; + } + | { + user: UserTableRowBaseRelation; + group: null; + } + ); diff --git a/apps/backend/src/modules/users-groups/index.ts b/apps/backend/src/modules/users-groups/index.ts index c8a6da77..8493d778 100644 --- a/apps/backend/src/modules/users-groups/index.ts +++ b/apps/backend/src/modules/users-groups/index.ts @@ -1 +1,2 @@ +export * from './users-groups.repo'; export * from './users-groups.tables'; diff --git a/apps/backend/src/modules/users-groups/users-groups.repo.ts b/apps/backend/src/modules/users-groups/users-groups.repo.ts new file mode 100644 index 00000000..1c84e3d4 --- /dev/null +++ b/apps/backend/src/modules/users-groups/users-groups.repo.ts @@ -0,0 +1,6 @@ +import { injectable } from 'tsyringe'; + +import { createDatabaseRepo } from '../database'; + +@injectable() +export class UsersGroupsRepo extends createDatabaseRepo('users_groups') {} diff --git a/packages/sdk/src/modules/dashboard/projects-policies/dto/index.ts b/packages/sdk/src/modules/dashboard/projects-policies/dto/index.ts index 589c8a83..b1cb4d27 100644 --- a/packages/sdk/src/modules/dashboard/projects-policies/dto/index.ts +++ b/packages/sdk/src/modules/dashboard/projects-policies/dto/index.ts @@ -1 +1,2 @@ export * from './sdk-project-access-level.dto'; +export * from './sdk-project-policy.dto'; diff --git a/packages/sdk/src/modules/dashboard/projects-policies/dto/sdk-project-policy.dto.ts b/packages/sdk/src/modules/dashboard/projects-policies/dto/sdk-project-policy.dto.ts new file mode 100644 index 00000000..4f23b723 --- /dev/null +++ b/packages/sdk/src/modules/dashboard/projects-policies/dto/sdk-project-policy.dto.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import { + SdkTableRowWithDatesV, + SdkTableRowWithIdNameV, + SdkTableRowWithIdV, +} from '~/shared'; + +import { SdkUserListItemV } from '../../users/dto'; + +export const SdkProjectPolicyV = z + .object({ + project: SdkTableRowWithIdNameV, + }) + .merge(SdkTableRowWithIdV) + .merge(SdkTableRowWithDatesV) + .and( + z.union([ + z.object({ + user: SdkUserListItemV, + group: z.null(), + }), + z.object({ + group: SdkTableRowWithIdNameV, + user: z.null(), + }), + ]), + ); + +export type SdkProjectPolicyT = z.infer;