Harden authentication, tenant isolation and security boundaries
This commit is contained in:
@@ -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; });
|
||||
@@ -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;
|
||||
@@ -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; });
|
||||
@@ -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 });
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user