Harden authentication, tenant isolation and security boundaries

This commit is contained in:
t.indorf
2026-10-09 22:49:55 +02:00
parent ec3f4f933d
commit 656ada80bd
71 changed files with 3107 additions and 1200 deletions
+23
View File
@@ -0,0 +1,23 @@
import { stat } from "node:fs/promises";
import { PrismaClient } from "@prisma/client";
import { attachmentPath } from "../src/lib/attachments";
const prisma = new PrismaClient();
async function main() {
let count = 0, missing = 0;
while (true) {
const batch = await prisma.attachment.findMany({ where: { sizeBytes: null }, select: { id: true }, take: 100 });
if (!batch.length) break;
for (const item of batch) {
let sizeBytes = 0;
try { sizeBytes = (await stat(attachmentPath(item.id))).size; }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
missing++;
}
await prisma.attachment.update({ where: { id: item.id }, data: { sizeBytes } });
count++;
}
}
console.log(`Attachment sizes recorded: ${count}; missing files: ${missing}.`);
}
main().finally(() => prisma.$disconnect()).catch(() => { console.error("Attachment size backfill failed."); process.exitCode = 1; });
+62
View File
@@ -0,0 +1,62 @@
-- Read-only preflight for both legacy and upgraded schemas. Empty result means no violations.
SELECT * FROM (
SELECT 'users.familyId' AS relation, count(*) AS invalid_rows FROM "users" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'children.familyId' AS relation, count(*) AS invalid_rows FROM "children" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'parent_duty_assignments.dutyId' AS relation, count(*) AS invalid_rows FROM "parent_duty_assignments" c JOIN "parent_duties" p ON c."dutyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'parent_duty_assignments.userId' AS relation, count(*) AS invalid_rows FROM "parent_duty_assignments" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'duty_assignments.familyId' AS relation, count(*) AS invalid_rows FROM "duty_assignments" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'duty_assignments.dutyTypeId' AS relation, count(*) AS invalid_rows FROM "duty_assignments" c JOIN "duty_types" p ON c."dutyTypeId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'absences.childId' AS relation, count(*) AS invalid_rows FROM "absences" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'announcements.authorId' AS relation, count(*) AS invalid_rows FROM "announcements" c JOIN "users" p ON c."authorId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'invitations.invitedById' AS relation, count(*) AS invalid_rows FROM "invitations" c JOIN "users" p ON c."invitedById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'termine.createdById' AS relation, count(*) AS invalid_rows FROM "termine" c JOIN "users" p ON c."createdById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'termine.approvedById' AS relation, count(*) AS invalid_rows FROM "termine" c JOIN "users" p ON c."approvedById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'event_participations.eventId' AS relation, count(*) AS invalid_rows FROM "event_participations" c JOIN "termine" p ON c."eventId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'event_participations.childId' AS relation, count(*) AS invalid_rows FROM "event_participations" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'mitbringsel_items.terminId' AS relation, count(*) AS invalid_rows FROM "mitbringsel_items" c JOIN "termine" p ON c."terminId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'mitbringsel_items.userId' AS relation, count(*) AS invalid_rows FROM "mitbringsel_items" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_plans.createdById' AS relation, count(*) AS invalid_rows FROM "notdienst_plans" c JOIN "users" p ON c."createdById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_availabilities.childId' AS relation, count(*) AS invalid_rows FROM "notdienst_availabilities" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_availabilities.userId' AS relation, count(*) AS invalid_rows FROM "notdienst_availabilities" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_assignments.planId' AS relation, count(*) AS invalid_rows FROM "notdienst_assignments" c JOIN "notdienst_plans" p ON c."planId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_assignments.childId' AS relation, count(*) AS invalid_rows FROM "notdienst_assignments" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_swap_requests.requesterUserId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "users" p ON c."requesterUserId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_swap_requests.requesterFamilyId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "families" p ON c."requesterFamilyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_swap_requests.targetAssignmentId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "notdienst_assignments" p ON c."targetAssignmentId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_swap_requests.offeredAssignmentId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "notdienst_assignments" p ON c."offeredAssignmentId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_swap_requests.respondedById' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "users" p ON c."respondedById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_alerts.assignmentId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "notdienst_assignments" p ON c."assignmentId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_alerts.parentUserId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "users" p ON c."parentUserId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_alerts.triggeredById' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "users" p ON c."triggeredById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'notdienst_alerts.educatorId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "educators" p ON c."educatorId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId"
UNION ALL
SELECT 'announcement_reads.userId' AS relation, count(*) AS invalid_rows FROM announcement_reads r JOIN announcements a ON r."announcementId" = a.id JOIN users u ON r."userId" = u.id WHERE u."kitaId" IS DISTINCT FROM a."kitaId"
) violations WHERE invalid_rows > 0;
+21
View File
@@ -0,0 +1,21 @@
import { readdir, stat, rm } from "node:fs/promises";
import { PrismaClient } from "@prisma/client";
import { attachmentDirectory, attachmentPath } from "../src/lib/attachments";
// Dry run by default. A 24-hour grace period protects concurrent uploads.
const prisma = new PrismaClient();
async function main() {
const remove = process.argv.includes("--delete");
let candidates = 0;
for (const entry of await readdir(attachmentDirectory(), { withFileTypes: true })) {
if (!entry.isFile()) continue;
let file: string;
try { file = attachmentPath(entry.name); } catch { continue; }
if ((await stat(file)).mtimeMs > Date.now() - 24 * 60 * 60_000) continue;
if (await prisma.attachment.findUnique({ where: { id: entry.name }, select: { id: true } })) continue;
candidates++;
if (remove) await rm(file, { force: true });
}
console.log(`${candidates} orphan attachments ${remove ? "removed" : "found (dry run)"}`);
}
main().finally(() => prisma.$disconnect()).catch(error => { console.error(error); process.exitCode = 1; });
+3
View File
@@ -0,0 +1,3 @@
import { cpSync } from 'node:fs';
cpSync('.next/static', '.next/standalone/.next/static', { recursive: true });
cpSync('public', '.next/standalone/public', { recursive: true });
+34
View File
@@ -0,0 +1,34 @@
import { PrismaClient } from '@prisma/client';
const password = process.env.APP_DATABASE_PASSWORD;
if (!password || password.length < 24 || password.length > 256) {
throw new Error('Set APP_DATABASE_PASSWORD to a dedicated password of 24–256 characters.');
}
const prisma = new PrismaClient();
async function main() {
const existing = await prisma.$queryRaw`SELECT 1 FROM pg_roles WHERE rolname = 'kita_runtime'`;
if (!existing.length) await prisma.$executeRawUnsafe('CREATE ROLE kita_runtime LOGIN');
const owned = await prisma.$queryRaw`SELECT 1 FROM pg_class c JOIN pg_roles r ON c.relowner = r.oid WHERE r.rolname = 'kita_runtime'
UNION ALL SELECT 1 FROM pg_namespace n JOIN pg_roles r ON n.nspowner = r.oid WHERE r.rolname = 'kita_runtime'`;
if (owned.length) throw new Error('Runtime role must not own tables or schemas.');
const memberships = await prisma.$queryRaw`SELECT quote_ident(r.rolname) AS role FROM pg_auth_members m JOIN pg_roles r ON m.roleid = r.oid
JOIN pg_roles member ON m.member = member.oid WHERE member.rolname = 'kita_runtime'`;
for (const membership of memberships) await prisma.$executeRawUnsafe(`REVOKE ${membership.role} FROM kita_runtime`);
// PostgreSQL quotes the secret. Only this server-produced SQL is executed.
const [quoted] = await prisma.$queryRaw`SELECT format('ALTER ROLE kita_runtime PASSWORD %L LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS NOREPLICATION', ${password}) AS statement`;
await prisma.$executeRawUnsafe(quoted.statement);
for (const statement of [
'REVOKE CREATE ON SCHEMA public FROM PUBLIC',
'GRANT USAGE ON SCHEMA public TO kita_runtime',
'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO kita_runtime',
'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO kita_runtime',
'REVOKE ALL ON TABLE _prisma_migrations FROM kita_runtime',
'REVOKE UPDATE, DELETE, TRUNCATE ON TABLE audit_logs FROM kita_runtime',
]) await prisma.$executeRawUnsafe(statement);
console.log('Runtime database grants configured. Re-run after future migrations.');
}
main().finally(() => prisma.$disconnect()).catch(error => {
// Avoid putting a statement containing credentials into deployment logs.
console.error('Runtime role provisioning failed:', error?.code ?? error?.name ?? 'unknown');
process.exitCode = 1;
});
+2 -6
View File
@@ -1,8 +1,4 @@
#!/bin/sh
set -eu
echo "Applying Prisma schema..."
npx prisma db push --skip-generate
echo "Starting Kita-Planer..."
exec npm run start:prod
# Apply reviewed migrations separately before starting the runtime.
exec node server.js