diff --git a/README.md b/README.md index efd3666..990df4f 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,21 @@ cp .example.env .env ## Dev Services ## Database + +### Database Views + +The access policies and users documents are implemented as database views. To keep track of views and changes, make sure to use `yarn db:migrate-views` when changing views: + +1. Edit or create a new view file in `prisma/view-migrations/views/`. +2. Make sure the dependencies are correct in [migrate.config.yml](prisma/view-migrations/migrate.config.yml). +3. Run `yarn db:migrate-views` to create a new migration for the changed views (this won't run `prisma migrate:dev`, it only creates the migration files). +4. Eventually change the [schema.prisma](prisma/schema.prisma) file to reflect changes in the views (e.g. new fields). +5. Run `yarn run prisma migrate:dev` to create a new migration for the schema changes. + +> [!WARNING] +> Never edit views directly in a prisma migration file (under `prisma/migrations/`), as these files are auto-generated and will be overwritten the next time `yarn db:migrate-views` is run. + + ### Docker Compose Run `scripts/purge_dev_services.sh` or the `purge_dev_services` run config to remove all containers **and volumes** associated with the dev services. diff --git a/package.json b/package.json index f5be2e5..3c26470 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "format:check": "prettier --check ./**/*.{ts,json}", "db:migrate": "yarn prisma migrate deploy", "db:migrate:dev": "yarn prisma migrate dev", + "db:migrate-view": "ts-node -r dotenv/config ./prisma/view-migrations/create-view-migration.ts", "db:seed": "yarn prisma db seed", "db:reset": "dotenv -- ts-node prisma/reset.ts", "db:recreate": "yarn run db:reset && yarn run db:migrate && yarn run db:seed", @@ -40,6 +41,7 @@ "@mermaid-js/mermaid-cli": "^10.9.1", "@types/cors": "^2.8.17", "@types/express": "^5.0.3", + "@types/js-yaml": "^4.0.9", "@types/morgan": "^1.9.9", "@types/node": "^20.14.6", "@typescript-eslint/eslint-plugin": "^8.44.1", @@ -48,6 +50,8 @@ "eslint": "^9.36.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", + "js-yaml": "^4.1.1", + "minimist": "^1.2.8", "nodemon": "^3.1.10", "prettier": "^3.6.2", "prisma": "^6.17.1", diff --git a/prisma/view-migrations/create-view-migration.ts b/prisma/view-migrations/create-view-migration.ts new file mode 100644 index 0000000..f69fe12 --- /dev/null +++ b/prisma/view-migrations/create-view-migration.ts @@ -0,0 +1,105 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import { default as parseArgs } from 'minimist'; +import { exit } from 'process'; + +const currentDir = __dirname; +const CONFIG_FILENAME = 'migrate.config.yml' as const; +interface Config { + name: string; + depends_on: string[]; +} +const config = yaml.load(fs.readFileSync(path.resolve(currentDir, CONFIG_FILENAME), 'utf8')) as Config[]; +const HELP_TEXT = ` +yarn run db:migrate-view [view-name [view-name ...]] + +Example: + yarn run db:migrate-view view__users_documents view__document_user_permissions + +available views (configured in ${CONFIG_FILENAME}): +${config.map((c) => ` - ${c.name}`).join('\n')} +`; + +const argv = parseArgs(process.argv.slice(2)); + +if (argv.help) { + console.log(HELP_TEXT); + exit(0); +} + +const viewNames = argv._.filter(Boolean); +if (viewNames.length === 0) { + console.error('Error: No view name provided.'); + console.log(HELP_TEXT); + exit(1); +} +if (viewNames.some((viewName) => !config.find((c) => c.name === viewName))) { + console.error( + 'Error: Invalid view name provided. Unknown views:\n', + viewNames + .filter((viewName) => !config.find((c) => c.name === viewName)) + .map((n) => `- ${n}`) + .join(`\n`), + `\nCheck ${CONFIG_FILENAME} to configure additional views.` + ); + console.log(HELP_TEXT); + exit(1); +} + +async function createViewMigration(viewNames: string[]) { + const migrationsFor: string[] = []; + const gatherDependencies = (viewName: string) => { + const viewConfig = config.find((c) => c.name === viewName); + if (!viewConfig) { + throw new Error(`View configuration for "${viewName}" not found.`); + } + const idx = migrationsFor.findIndex((name) => name === viewName); + if (idx >= 0) { + return; + } + const dependents = config.filter((dep) => dep.depends_on.includes(viewName)).map((d) => d.name); + for (const dep of dependents) { + gatherDependencies(dep); + } + if (!migrationsFor.includes(viewName)) { + migrationsFor.push(viewName); + } + }; + viewNames.forEach(gatherDependencies); + console.log(migrationsFor.join(' -> ')); + + const commands: string[] = []; + commands.push( + `-- NEVER MODIFY THIS FILE MANUALLY! IT IS AUTO-GENERATED USING prisma/view-migrations/create-view-migration.ts` + ); + migrationsFor.forEach((viewName) => { + commands.push(`DROP VIEW IF EXISTS ${viewName};`); + }); + for (const viewName of migrationsFor.toReversed()) { + const viewSqlPath = path.resolve(currentDir, 'views', `${viewName}.sql`); + const viewSql = await fs.promises.readFile(viewSqlPath, 'utf8'); + commands.push(` +CREATE VIEW ${viewName} AS +${viewSql + .replace(/;+\s*$/, '') + .trim() + .split('\n') + .map((line) => ` ${line}`) + .join('\n')}; +`); + } + const migrationContent = commands.join('\n\n'); + const timestamp = new Date() + .toISOString() + .replace(/[-:TZ.]/g, '') + .slice(0, 14); + const migrationFilename = `${timestamp}_create_views__${viewNames.map((name) => name.replace(/^view__/, '')).join('__')}`; + const migrationsDir = path.resolve(currentDir, '..', 'migrations', migrationFilename); + await fs.promises.mkdir(migrationsDir, { recursive: true }); + const migrationFilePath = path.resolve(migrationsDir, 'migration.sql'); + await fs.promises.writeFile(migrationFilePath, migrationContent, 'utf8'); + console.log(`✅ Created view migration at: ${migrationFilePath}`); +} + +createViewMigration(viewNames); diff --git a/prisma/view-migrations/migrate.config.yml b/prisma/view-migrations/migrate.config.yml new file mode 100644 index 0000000..e750216 --- /dev/null +++ b/prisma/view-migrations/migrate.config.yml @@ -0,0 +1,8 @@ +- name: view__users_documents + depends_on: + - view__document_user_permissions +- name: view__document_user_permissions + depends_on: + - view__all_document_user_permissions +- name: view__all_document_user_permissions + depends_on: [] diff --git a/prisma/view-migrations/tsconfig.json b/prisma/view-migrations/tsconfig.json new file mode 100644 index 0000000..e4f14ff --- /dev/null +++ b/prisma/view-migrations/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "strict": true, + "lib": ["esnext", "dom"], + "target": "esnext", + "module": "commonjs", + "outDir": "../../dist/view-migrations", + "rootDir": "./", + "esModuleInterop": true, + "skipLibCheck": true, + "baseUrl": "../../", + "paths": { + "*": ["node_modules/*"] + } + } +} \ No newline at end of file diff --git a/prisma/view-migrations/views/view__all_document_user_permissions.sql b/prisma/view-migrations/views/view__all_document_user_permissions.sql new file mode 100644 index 0000000..fee2499 --- /dev/null +++ b/prisma/view-migrations/views/view__all_document_user_permissions.sql @@ -0,0 +1,122 @@ +-- view: view__all_document_user_permissions + +-- assumption: all child documents of a document share the same document_root_id +SELECT + document_root_id, + user_id, + access, + document_id, + root_user_permission_id, + root_group_permission_id, + group_id, + ROW_NUMBER() OVER (PARTITION BY document_root_id, user_id, document_id ORDER BY access DESC) AS access_rank +FROM ( + -- get all documents where the user **is the author** + SELECT + document_roots.id AS document_root_id, + documents.author_id AS user_id, + document_roots.access AS access, + documents.id AS document_id, + NULL::uuid AS root_user_permission_id, + NULL::uuid AS root_group_permission_id, + NULL::uuid AS group_id + FROM + document_roots + INNER JOIN documents ON document_roots.id = documents.document_root_id + UNION ALL + -- get all documents where the user **is not the author** but has shared access + SELECT + document_roots.id AS document_root_id, + all_users.id AS user_id, + CASE + WHEN document_roots.shared_access <= document_roots.access THEN document_roots.shared_access + ELSE document_roots.access + END AS access, + documents.id AS document_id, + NULL::uuid AS root_user_permission_id, + NULL::uuid AS root_group_permission_id, + NULL::uuid AS group_id + FROM + document_roots + INNER JOIN documents ON document_roots.id = documents.document_root_id + CROSS JOIN users all_users + WHERE documents.author_id != all_users.id + AND ( + document_roots.shared_access='RO_DocumentRoot' + OR + document_roots.shared_access='RW_DocumentRoot' + ) + UNION ALL + -- get all documents where the user has been granted shared access + -- or the access has been extended by user permissions + SELECT + document_roots.id AS document_root_id, + rup.user_id AS user_id, + rup.access AS access, + documents.id AS document_id, + rup.id AS root_user_permission_id, + NULL::uuid AS root_group_permission_id, + NULL::uuid AS group_id + FROM + document_roots + LEFT JOIN documents ON document_roots.id=documents.document_root_id + LEFT JOIN root_user_permissions rup + ON ( + document_roots.id = rup.document_root_id + AND ( + documents.author_id = rup.user_id + OR + rup.access >= document_roots.shared_access + ) + ) + WHERE rup.user_id IS NOT NULL + UNION ALL + -- all group-based permissions for the documents author + SELECT + document_roots.id AS document_root_id, + user_to_sg.user_id AS user_id, + rgp.access AS access, + documents.id AS document_id, + NULL::uuid AS root_user_permission_id, + rgp.id AS root_group_permission_id, + sg.id AS group_id + FROM + document_roots + INNER JOIN root_group_permissions rgp ON document_roots.id=rgp.document_root_id + INNER JOIN student_groups sg ON rgp.student_group_id=sg.id + LEFT JOIN documents ON document_roots.id=documents.document_root_id + LEFT JOIN user_student_groups user_to_sg + ON ( + user_to_sg.student_group_id=sg.id + AND ( + user_to_sg.user_id=documents.author_id + OR documents.author_id is null + ) + ) + WHERE user_to_sg.user_id IS NOT NULL + UNION ALL + -- all group based permissions for the user, which is not the author + SELECT + document_roots.id AS document_root_id, + user_to_sg.user_id AS user_id, + rgp.access AS access, + documents.id AS document_id, + NULL::uuid AS root_user_permission_id, + rgp.id AS root_group_permission_id, + sg.id AS group_id + FROM + document_roots + INNER JOIN root_group_permissions rgp + ON ( + document_roots.id=rgp.document_root_id + AND rgp.access >= document_roots.shared_access + ) + INNER JOIN student_groups sg ON rgp.student_group_id=sg.id + LEFT JOIN documents ON document_roots.id=documents.document_root_id + LEFT JOIN user_student_groups user_to_sg + ON ( + user_to_sg.student_group_id=sg.id + AND user_to_sg.user_id!=documents.author_id + ) + WHERE user_to_sg.user_id IS NOT NULL +) as doc_user_permissions \ No newline at end of file diff --git a/prisma/view-migrations/views/view__document_user_permissions.sql b/prisma/view-migrations/views/view__document_user_permissions.sql new file mode 100644 index 0000000..c40aa33 --- /dev/null +++ b/prisma/view-migrations/views/view__document_user_permissions.sql @@ -0,0 +1,12 @@ +-- view: view__document_user_permissions + +SELECT + document_root_id, + user_id, + access, + document_id, + root_user_permission_id, + root_group_permission_id, + group_id +FROM view__all_document_user_permissions +WHERE access_rank = 1 \ No newline at end of file diff --git a/prisma/view-migrations/views/view__users_documents.sql b/prisma/view-migrations/views/view__users_documents.sql new file mode 100644 index 0000000..e4aacbb --- /dev/null +++ b/prisma/view-migrations/views/view__users_documents.sql @@ -0,0 +1,46 @@ +-- view: view__users_documents + +SELECT + view__document_user_permissions.user_id AS user_id, + document_roots.*, + COALESCE( + JSONB_AGG( + DISTINCT JSONB_BUILD_OBJECT( + 'id', view__document_user_permissions.root_group_permission_id, + 'access', view__document_user_permissions.access, + 'groupId', view__document_user_permissions.group_id + ) + ) FILTER (WHERE view__document_user_permissions.root_group_permission_id IS NOT NULL), + '[]'::jsonb + ) AS "groupPermissions", + COALESCE( + JSONB_AGG( + DISTINCT JSONB_BUILD_OBJECT( + 'id', view__document_user_permissions.root_user_permission_id, + 'access', view__document_user_permissions.access, + 'userId', view__document_user_permissions.user_id + ) + ) FILTER (WHERE view__document_user_permissions.root_user_permission_id IS NOT NULL), + '[]'::jsonb + ) AS "userPermissions", + COALESCE( + JSONB_AGG( + JSONB_BUILD_OBJECT( + 'id', d.id, + 'authorId', d.author_id, + 'type', d.type, + 'data', CASE WHEN (view__document_user_permissions.access='None_DocumentRoot' OR view__document_user_permissions.access='None_StudentGroup' OR view__document_user_permissions.access='None_User') THEN NULL ELSE d.data END, + 'parentId', d.parent_id, + 'documentRootId', d.document_root_id, + 'createdAt', d.created_at, + 'updatedAt', d.updated_at + ) + ) FILTER (WHERE d.id IS NOT NULL), + '[]'::jsonb + ) AS documents +FROM + document_roots + LEFT JOIN view__document_user_permissions ON document_roots.id=view__document_user_permissions.document_root_id + LEFT JOIN documents d ON document_roots.id=d.document_root_id AND view__document_user_permissions.document_id=d.id +WHERE view__document_user_permissions.user_id IS NOT NULL +GROUP BY document_roots.id, view__document_user_permissions.user_id \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 5e069be..31fd67c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1753,6 +1753,11 @@ resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.5.tgz#5b749ab2b16ba113423feb1a64a95dcd30398472" integrity sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg== +"@types/js-yaml@^4.0.9": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" + integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== + "@types/json-schema@^7.0.15": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" @@ -4461,6 +4466,13 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +js-yaml@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + dependencies: + argparse "^2.0.1" + json-buffer@3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" @@ -5122,7 +5134,7 @@ minimist-options@4.1.0: is-plain-obj "^1.1.0" kind-of "^6.0.3" -minimist@^1.2.6: +minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==