Skip to content

Commit

Permalink
feat(backend): add DB guards to projects policies migration
Browse files Browse the repository at this point in the history
  • Loading branch information
Mati365 committed Dec 28, 2024
1 parent 185a148 commit 8c88cbd
Show file tree
Hide file tree
Showing 13 changed files with 366 additions and 1 deletion.
56 changes: 56 additions & 0 deletions apps/backend/src/migrations/0027-add-projects-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,56 @@ export async function up(db: Kysely<any>): Promise<void> {
.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')
Expand All @@ -45,6 +92,15 @@ export async function up(db: Kysely<any>): Promise<void> {
}

export async function down(db: Kysely<any>): Promise<void> {
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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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(() => {
Expand All @@ -50,6 +52,7 @@ export class ElasticsearchRegistryBootService {
this.appsCategoriesEsIndexRepo,
this.projectsFilesEsIndexRepo,
this.projectsEmbeddingsEsIndexRepo,
this.projectsPoliciesEsIndexRepo,
]);

this.logger.info('Registered elasticsearch repos!');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './projects-policies-es-index.repo';
export * from './projects-policies-es-search.repo';
Original file line number Diff line number Diff line change
@@ -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<ProjectPolicyTableRowWithRelations>;

@injectable()
export class ProjectsPoliciesEsIndexRepo extends ProjectsPoliciesAbstractEsIndexRepo<ProjectsPoliciesEsDocument> {
constructor(
@inject(ElasticsearchRepo) elasticsearchRepo: ElasticsearchRepo,
@inject(ProjectsPoliciesRepo) private readonly policiesRepo: ProjectsPoliciesRepo,
) {
super(elasticsearchRepo);
}

protected async findEntities(ids: number[]): Promise<ProjectsPoliciesEsDocument[]> {
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,
});
}
Original file line number Diff line number Diff line change
@@ -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!,
};
};
}
3 changes: 3 additions & 0 deletions apps/backend/src/modules/projects-policies/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
export * from './elasticsearch';
export * from './projects-policies.repo';
export * from './projects-policies.service';
export * from './projects-policies.tables';
Original file line number Diff line number Diff line change
@@ -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<ProjectPolicyTableRowWithRelations> => {
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;
}),
),
);
};
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -12,3 +18,27 @@ export type ProjectsPoliciesTable =
group_id: ColumnType<TableId | null, TableId | null, never>;
access_level: SdkProjectAccessLevelT;
};

export type ProjectPolicyTableRow = NormalizeSelectTableRow<ProjectsPoliciesTable>;

export type ProjectPolicyGroupRelationTableRow =
& TableRowWithIdName
& {
users: UserTableRowBaseRelation[];
};

export type ProjectPolicyTableRowWithRelations =
& Omit<ProjectPolicyTableRow, 'projectId' | 'userId' | 'groupId'>
& {
project: TableRowWithIdName;
}
& (
{
user: null;
group: ProjectPolicyGroupRelationTableRow;
}
| {
user: UserTableRowBaseRelation;
group: null;
}
);
1 change: 1 addition & 0 deletions apps/backend/src/modules/users-groups/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './users-groups.repo';
export * from './users-groups.tables';
6 changes: 6 additions & 0 deletions apps/backend/src/modules/users-groups/users-groups.repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { injectable } from 'tsyringe';

import { createDatabaseRepo } from '../database';

@injectable()
export class UsersGroupsRepo extends createDatabaseRepo('users_groups') {}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './sdk-project-access-level.dto';
export * from './sdk-project-policy.dto';
Loading

0 comments on commit 8c88cbd

Please sign in to comment.