fix(projects): stop the cockpit offering email controls the API rejects (stable) (#977)

Closes #969 on stable. Backport of #976.

The cockpit's email feed rendered preview/resend/cancel/retry/send-now for every mail regardless of role or permission, producing 404s (CRM document mail has no event_id; project ownership does not imply event ownership) and 403s (preview needs events.view, the write actions need email.send).

getProjectOverview now stamps each email with an authoritative canAct, mirroring filterOwnedEventIds; created_by is selected only for that check and stripped before the response. A missing canAct reads as false.
This commit is contained in:
Paul Nothaft
2026-08-03 14:49:04 +02:00
committed by GitHub
parent cc49f6997a
commit 2d0e6ab2dc
6 changed files with 379 additions and 9 deletions
@@ -0,0 +1,149 @@
/**
* getProjectOverview stamps each email with `canAct` — whether the queued-mail
* routes (requireOwnedQueuedEmail) would actually accept an action on it.
*
* The cockpit used to derive this client-side from `event_id != null`, which is
* weaker than the backend rule in a way that still produced dead controls:
* requireOwnedQueuedEmail ALSO requires ownership of that event, while
* getProjectOverview lists the project's events by project_id alone. Project
* ownership does not imply event ownership — ownedProjectsSubquery's
* `projects.created_by = admin.id` branch places no constraint on the linked
* events' owners, so a super_admin can attach admin B's event to admin A's
* project. See #969 / codex review round 1.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-canact-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'canact-test-secret';
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('getProjectOverview email canAct (#969)', () => {
let db; let cleanup; let projectService;
let adminA; let adminB; let superAdmin;
let projectId; let ownEventId; let foreignEventId; let ownerlessEventId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username, email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkEvent = async (slug, createdBy, project) => {
const r = await db('events').insert({
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
host_email: '[email protected]', admin_email: '[email protected]', password_hash: 'x',
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
created_by: createdBy, project_id: project,
expires_at: new Date(Date.now() + 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkMail = async (eventId, type) => {
const r = await db('email_queue').insert({
recipient_email: '[email protected]', email_type: type, status: 'sent',
event_id: eventId,
created_at: new Date().toISOString(), sent_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
projectService = require('../../src/services/projectService');
adminA = await mkAdmin('canact-a', 'editor');
adminB = await mkAdmin('canact-b', 'editor');
superAdmin = await mkAdmin('canact-root', 'super_admin');
const p = await db('projects').insert({
name: 'Cockpit canAct', status: 'active', created_by: adminA,
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}).returning('id');
projectId = p[0]?.id ?? p[0];
// All three hang off adminA's project. Only the first is adminA's; the
// third is an ownerless legacy row, which filterOwnedEventIds treats as
// owned by whoever asks — but only once we know who is asking.
ownEventId = await mkEvent('canact-own', adminA, projectId);
foreignEventId = await mkEvent('canact-foreign', adminB, projectId);
ownerlessEventId = await mkEvent('canact-legacy', null, projectId);
await mkMail(ownEventId, 'gallery_ready');
await mkMail(foreignEventId, 'gallery_ready');
await mkMail(ownerlessEventId, 'gallery_ready');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
const byEvent = (overview) => {
const m = new Map();
for (const e of overview.emails) m.set(e.eventId, e);
return m;
};
it('clears mail on an event the caller owns', async () => {
const overview = await projectService.getProjectOverview(
projectId, {}, { id: adminA, roleName: 'editor' },
);
expect(byEvent(overview).get(ownEventId).canAct).toBe(true);
});
it('denies mail on a foreign admin\'s event inside the caller\'s own project', async () => {
const overview = await projectService.getProjectOverview(
projectId, {}, { id: adminA, roleName: 'editor' },
);
// event_id is non-null here — the old client-side rule would have offered
// controls, and requireOwnedQueuedEmail would have 404'd them.
const row = byEvent(overview).get(foreignEventId);
expect(row.eventId).not.toBeNull();
expect(row.canAct).toBe(false);
});
it('clears everything for a super_admin', async () => {
const overview = await projectService.getProjectOverview(
projectId, {}, { id: superAdmin, roleName: 'super_admin' },
);
expect(overview.emails.every((e) => e.canAct === true)).toBe(true);
});
it('clears mail on an ownerless legacy event for an identified caller', async () => {
// Parity with filterOwnedEventIds, which allows created_by IS NULL.
const overview = await projectService.getProjectOverview(
projectId, {}, { id: adminA, roleName: 'editor' },
);
expect(byEvent(overview).get(ownerlessEventId).canAct).toBe(true);
});
it('denies everything when no admin context is supplied', async () => {
// Including the ownerless event: `created_by == null` must not read as
// "owned" when we do not know who is asking (codex review round 2).
const overview = await projectService.getProjectOverview(projectId, {});
expect(overview.emails.length).toBe(3);
expect(overview.emails.every((e) => e.canAct === false)).toBe(true);
});
it('does not leak event ownership to the client', async () => {
const overview = await projectService.getProjectOverview(
projectId, {}, { id: adminA, roleName: 'editor' },
);
expect(overview.events.length).toBe(3);
for (const e of overview.events) expect(e).not.toHaveProperty('created_by');
});
});
+1 -1
View File
@@ -183,7 +183,7 @@ router.get('/:id/overview', requirePermission('events.view'), requireProjectOwne
quotes: await userHasAnyPermission(req.admin.id, ['quotes.view']),
contracts: await userHasAnyPermission(req.admin.id, ['contracts.view']),
};
const overview = await projectService.getProjectOverview(parseInt(req.params.id, 10), perms);
const overview = await projectService.getProjectOverview(parseInt(req.params.id, 10), perms, req.admin);
return successResponse(res, overview);
}));
+40 -3
View File
@@ -454,16 +454,48 @@ function computeValuation(invoices = [], quotes = []) {
* Full overview aggregation for the cockpit. Returns the project, its events,
* and the rolled-up emails / quotes / contracts / invoices / hours + a
* timeline of milestones. `perms` gates which doc types are included.
*
* `admin` (optional) is used only to stamp each email with `canAct` — whether
* the queued-mail routes would actually accept an action on it. See below.
*/
async function getProjectOverview(id, perms = {}) {
async function getProjectOverview(id, perms = {}, admin = null) {
const project = await getProjectById(id);
if (!project) throw new AppError('Project not found', 404);
const events = await db('events')
// `created_by` is selected for the ownership check below and stripped again
// before the response — the cockpit has no business learning who owns a
// sibling event.
const eventRows = await db('events')
.where({ project_id: id })
.select('id', 'event_name', 'event_date', 'slug', 'is_active', 'is_draft', 'expires_at', 'is_archived');
.select('id', 'event_name', 'event_date', 'slug', 'is_active', 'is_draft', 'expires_at', 'is_archived', 'created_by');
const events = eventRows.map(({ created_by: _ignored, ...e }) => e);
const eventIds = events.map((e) => e.id);
// Which of this project's events would filterOwnedEventIds() let `admin`
// act on. Mirrors that predicate exactly (ownership.js): super_admin gets
// everything, otherwise created_by IS NULL OR created_by = admin.id.
//
// Project ownership does NOT imply event ownership — ownedProjectsSubquery's
// `projects.created_by = admin.id` branch places no constraint on who owns
// the linked events, so a super_admin can attach admin B's event to admin
// A's project. Deriving actionability from `event_id != null` alone (as the
// UI first did) would then still render controls that requireOwnedQueuedEmail
// rejects with a 404.
// No admin context → nothing is actionable. Without this, an ownerless
// (legacy/system) event would satisfy `created_by == null` and be marked
// actionable for a caller we know nothing about.
const isSuperAdmin = admin?.roleName === 'super_admin';
let actionableEventIds = new Set();
if (isSuperAdmin) {
actionableEventIds = new Set(eventIds);
} else if (admin?.id != null) {
actionableEventIds = new Set(
eventRows
.filter((e) => e.created_by == null || Number(e.created_by) === Number(admin.id))
.map((e) => e.id),
);
}
const out = { project, events, emails: [], quotes: [], contracts: [], invoices: [], hours: { entries: [], totalMinutes: 0 } };
// Invoices (by event) incl. storno.
@@ -518,6 +550,11 @@ async function getProjectOverview(id, perms = {}) {
queuedAt: e.created_at, sentAt: e.sent_at, error: e.error_message, eventId: e.event_id,
// false → the cockpit preview will re-render from the current template.
stored: !!Number(e.has_rendered),
// Would requireOwnedQueuedEmail accept preview/resend/cancel/retry/send-now
// on this row? Authoritative here because the client cannot derive it: CRM
// document mail has no event to own, and event mail additionally requires
// ownership of THAT event, which the response deliberately does not expose.
canAct: isSuperAdmin || (e.event_id != null && actionableEventIds.has(e.event_id)),
});
const emailRows = [];