diff --git a/app/lib/rbac.server.ts b/app/lib/rbac.server.ts index 04116b5..ca8c4d7 100644 --- a/app/lib/rbac.server.ts +++ b/app/lib/rbac.server.ts @@ -257,6 +257,101 @@ export const {can} = canCant<'guest' | 'reader' | 'writer' | 'admin'>({ false ) + return result + } + }, + 'document:list', + 'document:add', + { + name: 'document:view', + when: async ({ + user, + documentId + }: { + user: SessionUser + documentId: string + }) => { + const prisma = getPrisma() + + const document = await prisma.document.findFirstOrThrow({ + where: {id: documentId}, + include: {acl: {include: {entries: true}}} + }) + + const result = document.acl.entries.reduce( + (r, {target, type, read}) => { + if (r) return true + + if (type === 'user' && target !== user.id) return false + if (type === 'role' && target !== user.role) return false + + return read + }, + false + ) + + return result + } + }, + { + name: 'document:write', + when: async ({ + user, + documentId + }: { + user: SessionUser + documentId: string + }) => { + const prisma = getPrisma() + + const document = await prisma.document.findFirstOrThrow({ + where: {id: documentId}, + include: {acl: {include: {entries: true}}} + }) + + const result = document.acl.entries.reduce( + (r, {target, type, write}) => { + if (r) return true + + if (type === 'user' && target !== user.id) return false + if (type === 'role' && target !== user.role) return false + + return write + }, + false + ) + + return result + } + }, + { + name: 'document:delete', + when: async ({ + user, + documentId + }: { + user: SessionUser + documentId: string + }) => { + const prisma = getPrisma() + + const document = await prisma.document.findFirstOrThrow({ + where: {id: documentId}, + include: {acl: {include: {entries: true}}} + }) + + const result = document.acl.entries.reduce( + (r, {target, type, delete: del}) => { + if (r) return true + + if (type === 'user' && target !== user.id) return false + if (type === 'role' && target !== user.role) return false + + return del + }, + false + ) + return result } } @@ -276,7 +371,6 @@ export const {can} = canCant<'guest' | 'reader' | 'writer' | 'admin'>({ 'dashboard', 'search', 'logout', - 'document:*', 'user:*', 'dashboard:*', 'process:*', diff --git a/app/routes/app.documents.$document.edit.tsx b/app/routes/app.documents.$document.edit.tsx index 032c0d3..638bdd6 100644 --- a/app/routes/app.documents.$document.edit.tsx +++ b/app/routes/app.documents.$document.edit.tsx @@ -15,7 +15,7 @@ import {Label, Input, HelperText, TextArea} from '~/lib/components/input' import {pageTitle} from '~/lib/utils/page-title' export const loader = async ({request, params}: LoaderFunctionArgs) => { - const user = await ensureUser(request, 'document:edit', { + const user = await ensureUser(request, 'document:write', { documentId: params.document }) @@ -29,7 +29,7 @@ export const loader = async ({request, params}: LoaderFunctionArgs) => { } export const action = async ({request, params}: ActionFunctionArgs) => { - const user = await ensureUser(request, 'document:edit', { + const user = await ensureUser(request, 'document:write', { documentId: params.document }) @@ -39,9 +39,11 @@ export const action = async ({request, params}: ActionFunctionArgs) => { const title = formData.get('title') as string | undefined const body = formData.get('body') as string | undefined + const acl = formData.get('acl') as string | undefined invariant(title) invariant(body) + invariant(acl) const document = await prisma.document.findFirstOrThrow({ where: {id: params.document} @@ -58,7 +60,7 @@ export const action = async ({request, params}: ActionFunctionArgs) => { const updatedDocument = await prisma.document.update({ where: {id: params.document}, - data: {title, body} + data: {title, body, aclId: acl} }) return redirect(`/app/documents/${updatedDocument.id}`) diff --git a/app/routes/app.documents._index.tsx b/app/routes/app.documents._index.tsx index f212219..859cf04 100644 --- a/app/routes/app.documents._index.tsx +++ b/app/routes/app.documents._index.tsx @@ -11,7 +11,23 @@ export const loader = async ({request}: LoaderFunctionArgs) => { const prisma = getPrisma() - const documents = await prisma.document.findMany({orderBy: {title: 'asc'}}) + //const documents = await prisma.document.findMany({orderBy: {title: 'asc'}}) + const documents = await prisma.$queryRaw< + Array<{id: string; title: string; updatedAt: string}> + >`SELECT +Document.id, Document.title, Document.updatedAt +FROM +Document +WHERE +aclId IN (SELECT aclId FROM ACLEntry + WHERE read = true AND ( + (type = "role" AND target = ${user.role}) + OR + (type = "user" AND target = ${user.id}) + ) + ) +ORDER BY +Document.title ASC` return json({user, documents}) } diff --git a/app/routes/app.documents.add.tsx b/app/routes/app.documents.add.tsx index d129295..2dab488 100644 --- a/app/routes/app.documents.add.tsx +++ b/app/routes/app.documents.add.tsx @@ -28,11 +28,15 @@ export const action = async ({request}: ActionFunctionArgs) => { const title = formData.get('title') as string | undefined const body = formData.get('body') as string | undefined + const acl = formData.get('acl') as string | undefined invariant(title) invariant(body) + invariant(acl) - const document = await prisma.document.create({data: {title, body}}) + const document = await prisma.document.create({ + data: {title, body, aclId: acl} + }) return redirect(`/app/documents/${document.id}`) } diff --git a/prisma/migrations/20240814125708_add_acl_to_document/migration.sql b/prisma/migrations/20240814125708_add_acl_to_document/migration.sql new file mode 100644 index 0000000..c8aba68 --- /dev/null +++ b/prisma/migrations/20240814125708_add_acl_to_document/migration.sql @@ -0,0 +1,17 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Document" ( + "id" TEXT NOT NULL PRIMARY KEY, + "body" TEXT NOT NULL, + "title" TEXT NOT NULL, + "aclId" TEXT NOT NULL DEFAULT '', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Document_aclId_fkey" FOREIGN KEY ("aclId") REFERENCES "ACL" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Document" ("body", "createdAt", "id", "title", "updatedAt") SELECT "body", "createdAt", "id", "title", "updatedAt" FROM "Document"; +DROP TABLE "Document"; +ALTER TABLE "new_Document" RENAME TO "Document"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 63b24ad..2f072fa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -131,6 +131,9 @@ model Document { history DocumentHistory[] + acl ACL @relation(fields: [aclId], references: [id]) + aclId String @default("") + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -227,6 +230,7 @@ model ACL { entries ACLEntry[] passwords Password[] + documents Document[] assets Asset[] assetEntries Entry[] diff --git a/prisma/seed.js b/prisma/seed.js index e35c2e6..305146e 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -80,6 +80,22 @@ const main = async () => { data: {aclId: defaultAcl.id} }) } + + const documentsWithNoACLCount = await prisma.document.count({ + where: {aclId: ''} + }) + + if (documentsWithNoACLCount !== 0) { + console.log('Documents need ACLs') + const defaultAcl = await prisma.aCL.findFirstOrThrow({ + where: {name: 'Default'} + }) + + await prisma.document.updateMany({ + where: {aclId: ''}, + data: {aclId: defaultAcl.id} + }) + } } main() diff --git a/tests/unit/documents.spec.ts b/tests/unit/documents.spec.ts new file mode 100644 index 0000000..e9f932b --- /dev/null +++ b/tests/unit/documents.spec.ts @@ -0,0 +1,193 @@ +import {describe, expect, test} from 'vitest' +import {faker} from '@faker-js/faker' + +import {appRequest, userForTest, postBody, createACL} from 'tests/unit-utils' + +import {getPrisma} from '~/lib/prisma.server' + +import {action as addAction} from '~/routes/app.documents.add' +import {loader as listLoader} from '~/routes/app.documents._index' +import {loader as readLoader} from '~/routes/app.documents.$document._index' +import {action as editAction} from '~/routes/app.documents.$document.edit' + +describe('Documents', () => { + test('Should create documents', async () => { + const prisma = getPrisma() + + const {sessionHeader, dispose} = await userForTest({role: 'admin'}) + + const headers = await sessionHeader() + headers.append('Content-Type', 'application/x-www-form-urlencoded') + + const testACL = await createACL('Test', { + 'role/admin': {read: true, write: true, delete: true} + }) + + const title = faker.lorem.sentence() + + const addResponse = await addAction({ + request: appRequest('/app/documents/add', { + method: 'POST', + headers, + body: postBody({ + title, + body: faker.lorem.paragraphs(), + acl: testACL.id + }) + }), + context: {}, + params: {} + }) + + expect(addResponse.status).toBe(302) + + const newId = addResponse.headers.get('Location')!.split('/')[3] + + const document = await prisma.document.findFirstOrThrow({ + where: {id: newId} + }) + + expect(document.title).toBe(title) + + await prisma.document.delete({where: {id: newId}}) + + await dispose() + }) + + test('Should restrict access', async () => { + const prisma = getPrisma() + + const admin = await userForTest({role: 'admin'}) + const reader = await userForTest({role: 'reader'}) + + const adminACL = await createACL('adminOnly', { + 'role/admin': {read: true, write: true, delete: true} + }) + + const headers = await admin.sessionHeader() + headers.append('Content-Type', 'application/x-www-form-urlencoded') + + const addResponse = await addAction({ + request: appRequest('/app/documents/add', { + method: 'POST', + headers, + body: postBody({ + title: faker.lorem.sentence(), + body: faker.lorem.paragraphs(), + acl: adminACL.id + }) + }), + context: {}, + params: {} + }) + + expect(addResponse.status).toBe(302) + + const newId = addResponse.headers.get('Location')!.split('/')[3] + + const adminListLoaderResponse = await listLoader({ + request: appRequest('/app/documents', { + headers: await admin.sessionHeader() + }), + context: {}, + params: {} + }) + + expect(adminListLoaderResponse.status).toBe(200) + + const adminListLoaderData = await adminListLoaderResponse.json() + + const adminCanSeeDocument = adminListLoaderData.documents.reduce( + (check, value) => { + if (check) return check + + return value.id === newId + }, + false + ) + + expect(adminCanSeeDocument).toBeTruthy() + + const readerListLoaderResponse = await listLoader({ + request: appRequest('/app/documents', { + headers: await reader.sessionHeader() + }), + context: {}, + params: {} + }) + + expect(readerListLoaderResponse.status).toBe(200) + + const readerListLoaderData = await readerListLoaderResponse.json() + + const readerCanSeeDocument = readerListLoaderData.documents.reduce( + (check, value) => { + if (check) return check + + return value.id === newId + }, + false + ) + + expect(readerCanSeeDocument).toBeFalsy() + + const adminViewRequest = await readLoader({ + request: appRequest(`/app/documents/${newId}`, { + headers: await admin.sessionHeader() + }), + context: {}, + params: {document: newId} + }) + + expect(adminViewRequest.status).toBe(200) + + await expect(async () => { + await readLoader({ + request: appRequest(`/app/documents/${newId}`, { + headers: await reader.sessionHeader() + }), + context: {}, + params: {document: newId} + }) + }).rejects.toThrowError() + + const adminEditResponse = await editAction({ + request: appRequest(`/app/documents/${newId}`, { + method: 'POST', + headers, + body: postBody({ + title: faker.lorem.sentence(), + body: faker.lorem.paragraphs(), + acl: adminACL.id + }) + }), + context: {}, + params: {document: newId} + }) + + expect(adminEditResponse.status).toBe(302) + + await expect(async () => { + const readerHeaders = await reader.sessionHeader() + readerHeaders.append('Content-Type', 'application/x-www-form-urlencoded') + + await editAction({ + request: appRequest(`/app/documents/${newId}`, { + headers: readerHeaders, + body: postBody({ + title: faker.lorem.sentence(), + body: faker.lorem.paragraphs(), + acl: adminACL.id + }) + }), + context: {}, + params: {document: newId} + }) + }).rejects.toThrowError() + + await prisma.documentHistory.deleteMany({}) + + await admin.dispose() + await reader.dispose() + }) +})