Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion server/src/repositories/auditLogRepository.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
// Repository for the AuditLog aggregate.

import { pick } from './pick.js';

// Client-writable fields (see prisma/schema.prisma AuditLog model). Excludes
// id and the previousHash/hash integrity fields, which must never be
// settable by the caller.
const WRITABLE_FIELDS = [
'userId',
'userEmail',
'eventType',
'action',
'resourceType',
'resourceId',
'metadata',
'ipAddress',
'userAgent',
'sessionId',
'timestamp',
];

export async function createAuditLog(prisma, data) {
return prisma.auditLog.create({
data: {
...pick(data, WRITABLE_FIELDS),
timestamp: data.timestamp ?? new Date(),
...data,
},
});
}
Expand Down
19 changes: 19 additions & 0 deletions server/src/repositories/auditLogRepository.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,23 @@ describe('auditLogRepository', () => {
const logs = await listAuditLogs(prisma, { limit: 1 });
expect(logs).toHaveLength(1);
});

it('does not allow hash/previousHash to be set via create (integrity field mass assignment)', async () => {
const entry = await createAuditLog(prisma, {
userId: 'user-1',
eventType: 'AUTH_LOGIN',
action: 'login',
resourceId: 'draft-1',
metadata: { foo: 'bar' },
hash: 'forged-hash',
previousHash: 'forged-previous-hash',
});

expect(entry.eventType).toBe('AUTH_LOGIN');
expect(entry.action).toBe('login');
expect(entry.resourceId).toBe('draft-1');
expect(entry.metadata).toEqual({ foo: 'bar' });
expect(entry.hash).toBeUndefined();
expect(entry.previousHash).toBeUndefined();
});
});
19 changes: 18 additions & 1 deletion server/src/repositories/collaboratorRepository.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
// Repository for the DocumentCollaborator aggregate (document sharing with
// role-based access, mirroring src/utils/collaborationService.ts).

import { pick } from './pick.js';

// Client-writable fields (see prisma/schema.prisma DocumentCollaborator
// model). Excludes id, collaboratorUserId/status/acceptedAt (set only by
// acceptCollaboratorInvite), and revokeCollaborator's status transition.
const WRITABLE_FIELDS = [
'documentId',
'documentOwnerId',
'collaboratorEmail',
'role',
'invitedBy',
'invitedByName',
'invitedAt',
'inviteToken',
'inviteExpiresAt',
];

export async function inviteCollaborator(prisma, data) {
return prisma.documentCollaborator.create({
data: {
...data,
...pick(data, WRITABLE_FIELDS),
collaboratorEmail: data.collaboratorEmail.toLowerCase(),
status: 'pending',
invitedAt: data.invitedAt ?? new Date(),
Expand Down
17 changes: 17 additions & 0 deletions server/src/repositories/collaboratorRepository.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,21 @@ describe('collaboratorRepository', () => {
const updated = await updateCollaboratorRole(prisma, invite.id, 'admin');
expect(updated.role).toBe('admin');
});

it('does not allow collaboratorUserId to be set via invite (mass assignment)', async () => {
const invite = await inviteCollaborator(prisma, {
documentId: 'doc-1',
documentOwnerId: 'owner-1',
collaboratorEmail: 'friend@example.com',
role: 'editor',
invitedBy: 'owner-1',
inviteToken: 'token-abc',
collaboratorUserId: 'attacker-id',
});

expect(invite.collaboratorEmail).toBe('friend@example.com');
expect(invite.role).toBe('editor');
expect(invite.inviteToken).toBe('token-abc');
expect(invite.collaboratorUserId).toBeFalsy();
});
});
19 changes: 18 additions & 1 deletion server/src/repositories/shareLinkRepository.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
// Repository for the ShareLink aggregate (passcode-protected document
// share links, mirroring src/utils/collaborationService.ts).

import { pick } from './pick.js';

// Client-writable fields (see prisma/schema.prisma ShareLink model).
// Excludes id, lastAccessedAt/lastAccessedBy (set only by
// incrementShareLinkAccess), and revokedAt/revokedBy (set only by
// revokeShareLink).
const WRITABLE_FIELDS = [
'documentId',
'documentOwnerId',
'token',
'passcode',
'accessLevel',
'expiresAt',
'accessCount',
'isActive',
];

export async function createShareLink(prisma, data) {
return prisma.shareLink.create({
data: {
...data,
...pick(data, WRITABLE_FIELDS),
accessCount: data.accessCount ?? 0,
isActive: data.isActive ?? true,
},
Expand Down
19 changes: 19 additions & 0 deletions server/src/repositories/shareLinkRepository.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,23 @@ describe('shareLinkRepository', () => {
expect(revoked.isActive).toBe(false);
expect(revoked.revokedBy).toBe('owner-1');
});

it('does not allow revokedBy/revokedAt to be set via create (mass assignment)', async () => {
const link = await createShareLink(prisma, {
documentId: 'doc-1',
documentOwnerId: 'owner-1',
token: 'tok-1',
passcode: 'ABC123',
accessLevel: 'view',
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
revokedBy: 'attacker',
revokedAt: new Date('2020-01-01'),
});

expect(link.documentId).toBe('doc-1');
expect(link.token).toBe('tok-1');
expect(link.accessLevel).toBe('view');
expect(link.revokedBy).toBeFalsy();
expect(link.revokedAt).toBeFalsy();
});
});
Loading