From 8ca7ee2dc7b4afb39e671a814a7afe3b18486d8d Mon Sep 17 00:00:00 2001 From: bh0fer Date: Sat, 12 Sep 2026 14:53:00 +0000 Subject: [PATCH 1/3] fix: document permissions for shared documents --- .../migration.sql | 201 ++++++++++++++++++ .../view__all_document_user_permissions.sql | 7 +- src/tests/integration/users.test.ts | 41 ++++ 3 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260912143203_create_views__all_document_user_permissions/migration.sql diff --git a/prisma/migrations/20260912143203_create_views__all_document_user_permissions/migration.sql b/prisma/migrations/20260912143203_create_views__all_document_user_permissions/migration.sql new file mode 100644 index 0000000..dbbdcba --- /dev/null +++ b/prisma/migrations/20260912143203_create_views__all_document_user_permissions/migration.sql @@ -0,0 +1,201 @@ +-- NEVER MODIFY THIS FILE MANUALLY! IT IS AUTO-GENERATED USING prisma/view-migrations/create-view-migration.ts + +DROP VIEW IF EXISTS view__users_documents; + +DROP VIEW IF EXISTS view__document_user_permissions; + +DROP VIEW IF EXISTS view__all_document_user_permissions; + + +CREATE VIEW view__all_document_user_permissions AS + -- 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 + AND document_roots.shared_access != 'None_DocumentRoot' + ) + ) + ) + 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 + AND document_roots.shared_access != 'None_DocumentRoot' + ) + 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; + + + +CREATE VIEW view__document_user_permissions AS + -- 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; + + + +CREATE VIEW view__users_documents AS + -- 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; diff --git a/prisma/view-migrations/views/view__all_document_user_permissions.sql b/prisma/view-migrations/views/view__all_document_user_permissions.sql index fee2499..56ec95a 100644 --- a/prisma/view-migrations/views/view__all_document_user_permissions.sql +++ b/prisma/view-migrations/views/view__all_document_user_permissions.sql @@ -65,8 +65,10 @@ FROM ( document_roots.id = rup.document_root_id AND ( documents.author_id = rup.user_id - OR - rup.access >= document_roots.shared_access + OR ( + rup.access >= document_roots.shared_access + AND document_roots.shared_access != 'None_DocumentRoot' + ) ) ) WHERE rup.user_id IS NOT NULL @@ -110,6 +112,7 @@ FROM ( ON ( document_roots.id=rgp.document_root_id AND rgp.access >= document_roots.shared_access + AND document_roots.shared_access != 'None_DocumentRoot' ) INNER JOIN student_groups sg ON rgp.student_group_id=sg.id LEFT JOIN documents ON document_roots.id=documents.document_root_id diff --git a/src/tests/integration/users.test.ts b/src/tests/integration/users.test.ts index 01dffdc..a52032b 100644 --- a/src/tests/integration/users.test.ts +++ b/src/tests/integration/users.test.ts @@ -68,6 +68,47 @@ describe('Users (integration)', () => { expect(res.body[0].documents[0].data).toEqual({ owner: user.id }); }); + it('returns document roots for the requested user with explicite user permissions', async () => { + const user = await createTestUser(Role.STUDENT); + const otherUser = await createTestUser(Role.STUDENT); + const documentRootId = randomUUID(); + const agent = agentAs(user.id); + + const createRootRes = await agent.post(`${API_URL}/documentRoots/${documentRootId}`).send({ + access: Access.None_DocumentRoot, + sharedAccess: Access.None_DocumentRoot, + userPermissions: [{ userId: user.id, access: Access.RW_User }] + }); + expect(createRootRes.status).toBe(200); + + const ownDocumentRes = await agent.post(`${API_URL}/documents`).send({ + type: 'document', + documentRootId, + data: { owner: user.id } + }); + expect(ownDocumentRes.status).toBe(200); + + await prisma.document.create({ + data: { + type: 'document', + documentRootId, + authorId: otherUser.id, + data: { owner: otherUser.id } + } + }); + + const res = await agent + .post(`${API_URL}/users/${user.id}/documentRoots`) + .send({ documentRootIds: [documentRootId] }); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].id).toBe(documentRootId); + expect(res.body[0].documents).toHaveLength(1); + expect(res.body[0].documents[0].authorId).toBe(user.id); + expect(res.body[0].documents[0].data).toEqual({ owner: user.id }); + }); + it('does not allow a user to request another users document roots', async () => { const user = await createTestUser(Role.STUDENT); const otherUser = await createTestUser(Role.STUDENT); From b491b2b7ad4c7f8bd327943ea5fd0b3469c9b2e3 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Sat, 12 Sep 2026 15:03:08 +0000 Subject: [PATCH 2/3] add test for shared permission --- src/tests/integration/users.test.ts | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/tests/integration/users.test.ts b/src/tests/integration/users.test.ts index a52032b..82c9392 100644 --- a/src/tests/integration/users.test.ts +++ b/src/tests/integration/users.test.ts @@ -109,6 +109,55 @@ describe('Users (integration)', () => { expect(res.body[0].documents[0].data).toEqual({ owner: user.id }); }); + it('returns shared documents for the requested user with explicite user permissions', async () => { + const user = await createTestUser(Role.STUDENT); + const otherUser = await createTestUser(Role.STUDENT); + const documentRootId = randomUUID(); + const agent = agentAs(user.id); + const otherAgent = agentAs(otherUser.id); + + const createRootRes = await agent.post(`${API_URL}/documentRoots/${documentRootId}`).send({ + access: Access.None_DocumentRoot, + sharedAccess: Access.RW_DocumentRoot, + userPermissions: [{ userId: user.id, access: Access.RW_User }] + }); + expect(createRootRes.status).toBe(200); + + await prisma.document.create({ + data: { + type: 'document', + documentRootId, + authorId: otherUser.id, + data: { owner: otherUser.id } + } + }); + + const res = await agent + .post(`${API_URL}/users/${user.id}/documentRoots`) + .send({ documentRootIds: [documentRootId] }); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].id).toBe(documentRootId); + expect(res.body[0].access).toBe('None_DocumentRoot'); + expect(res.body[0].sharedAccess).toBe('RW_DocumentRoot'); + expect(res.body[0].documents).toHaveLength(1); + expect(res.body[0].userPermissions).toHaveLength(1); + expect(res.body[0].userPermissions[0].userId).toBe(user.id); + expect(res.body[0].documents[0].authorId).toBe(otherUser.id); + expect(res.body[0].documents[0].data).toEqual({ owner: otherUser.id }); + + const resOtherUser = await otherAgent + .post(`${API_URL}/users/${otherUser.id}/documentRoots`) + .send({ documentRootIds: [documentRootId] }); + expect(resOtherUser.status).toBe(200); + expect(resOtherUser.body.length).toBe(1); + expect(resOtherUser.body[0].id).toBe(documentRootId); + expect(resOtherUser.body[0].documents).toHaveLength(1); + expect(resOtherUser.body[0].documents[0].authorId).toBe(otherUser.id); + expect(resOtherUser.body[0].documents[0].data).toEqual(null); + }); + it('does not allow a user to request another users document roots', async () => { const user = await createTestUser(Role.STUDENT); const otherUser = await createTestUser(Role.STUDENT); From 9717809f559eca92dd5011763073e69e55aab946 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Sat, 12 Sep 2026 15:06:51 +0000 Subject: [PATCH 3/3] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0b797c9..887366d 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ The access policies and users documents are implemented as database views. To ke 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). +3. Run `yarn db:migrate-view` 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.