Skip to content

Commit 140b266

Browse files
authored
Merge pull request #8 from evangauer/feat/backup-grade-hardening
Phase 1: backup-grade hardening (viewer role, audit log, seeding, dry-run import, scheduled backup, ops alerts)
2 parents 120dd48 + 0cadd0e commit 140b266

25 files changed

Lines changed: 769 additions & 34 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ STRIPE_WEBHOOK_SECRET=
2727
# Cron job authentication
2828
CRON_SECRET=
2929

30+
# Ops alerting — failures in background jobs (reminders, backups, webhook
31+
# delivery) post here (Slack-style incoming webhook). Optional; logs if unset.
32+
OPS_ALERT_WEBHOOK_URL=
33+
3034
# Lab Integrations
3135
IDEXX_API_KEY=
3236
ANTECH_API_KEY=

apps/web/app/(dashboard)/settings/page.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,12 @@ function StaffTab() {
297297
const [editingId, setEditingId] = useState<string | null>(null);
298298
const [editForm, setEditForm] = useState({
299299
name: "",
300-
role: "front_desk" as "admin" | "veterinarian" | "technician" | "front_desk",
300+
role: "front_desk" as
301+
| "admin"
302+
| "veterinarian"
303+
| "technician"
304+
| "front_desk"
305+
| "viewer",
301306
phone: "",
302307
licenseNumber: "",
303308
});
@@ -374,6 +379,7 @@ function StaffTab() {
374379
}
375380
>
376381
<option value="front_desk">Front Desk</option>
382+
<option value="viewer">Viewer (read-only)</option>
377383
<option value="technician">Technician</option>
378384
<option value="veterinarian">Veterinarian</option>
379385
<option value="admin">Admin</option>
@@ -472,6 +478,7 @@ function StaffTab() {
472478
}
473479
>
474480
<option value="front_desk">Front Desk</option>
481+
<option value="viewer">Viewer (read-only)</option>
475482
<option value="technician">Technician</option>
476483
<option value="veterinarian">Veterinarian</option>
477484
<option value="admin">Admin</option>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { NextResponse } from "next/server";
2+
import { isNull } from "drizzle-orm";
3+
import { db } from "@openpims/db/client";
4+
import { practices } from "@openpims/db";
5+
import { exportPracticeData, backupKey } from "@/lib/backup/export";
6+
import { uploadFile } from "@/lib/s3";
7+
import { alertOps } from "@/lib/alerts";
8+
9+
export const dynamic = "force-dynamic";
10+
export const maxDuration = 300;
11+
12+
// Scheduled per-practice backup → object storage. A clinic gets a daily,
13+
// restorable JSON snapshot of its data, independent of the live DB.
14+
export async function GET(request: Request) {
15+
const cronSecret = request.headers.get("x-cron-secret");
16+
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
17+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
18+
}
19+
20+
const today = new Date().toISOString().slice(0, 10);
21+
let ok = 0;
22+
let failed = 0;
23+
24+
try {
25+
const allPractices = await db
26+
.select({ id: practices.id })
27+
.from(practices)
28+
.where(isNull(practices.deletedAt));
29+
30+
for (const p of allPractices) {
31+
try {
32+
const data = await exportPracticeData(db, p.id, new Date().toISOString());
33+
const key = backupKey(p.id, today);
34+
await uploadFile(key, Buffer.from(JSON.stringify(data)), "application/json");
35+
ok++;
36+
} catch (err) {
37+
failed++;
38+
void alertOps(
39+
"Practice backup failed",
40+
`practice ${p.id}: ${err instanceof Error ? err.message : String(err)}`,
41+
);
42+
}
43+
}
44+
45+
if (failed > 0) {
46+
void alertOps(
47+
"Scheduled backup had failures",
48+
`${failed} of ${allPractices.length} practice backups failed for ${today}.`,
49+
);
50+
}
51+
52+
return NextResponse.json({ date: today, practices: allPractices.length, ok, failed });
53+
} catch (error) {
54+
void alertOps(
55+
"Backup cron job crashed",
56+
error instanceof Error ? error.message : String(error),
57+
);
58+
console.error("Cron backup job failed:", error);
59+
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
60+
}
61+
}

apps/web/app/api/cron/reminders/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
communications,
1010
} from "@openpims/db";
1111
import { sendAppointmentReminder } from "@/lib/email";
12+
import { alertOps } from "@/lib/alerts";
1213

1314
export async function GET(request: Request) {
1415
// Validate the cron secret to prevent unauthorized access
@@ -101,8 +102,19 @@ export async function GET(request: Request) {
101102
`Cron reminders completed: ${sent} sent, ${failed} failed out of ${upcomingAppointments.length} total`,
102103
);
103104

105+
if (failed > 0) {
106+
void alertOps(
107+
"Appointment reminders had failures",
108+
`${failed} of ${upcomingAppointments.length} reminders failed to send (${sent} sent).`,
109+
);
110+
}
111+
104112
return NextResponse.json({ sent, failed });
105113
} catch (error) {
114+
void alertOps(
115+
"Reminder cron job crashed",
116+
error instanceof Error ? error.message : String(error),
117+
);
106118
console.error("Cron reminder job failed:", error);
107119
return NextResponse.json(
108120
{ error: "Internal server error" },

apps/web/components/layout/sidebar.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,15 @@ function PawMark({ className }: { className?: string }) {
4242
);
4343
}
4444

45-
type UserRole = "admin" | "veterinarian" | "technician" | "front_desk";
45+
type UserRole = "admin" | "veterinarian" | "technician" | "front_desk" | "viewer";
4646

47-
const allRoles: UserRole[] = ["admin", "veterinarian", "technician", "front_desk"];
47+
const allRoles: UserRole[] = [
48+
"admin",
49+
"veterinarian",
50+
"technician",
51+
"front_desk",
52+
"viewer",
53+
];
4854

4955
const navItems: {
5056
href: string;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, it, expect } from "vitest";
2+
import { formatOpsAlert, alertOps } from "../alerts";
3+
4+
describe("formatOpsAlert", () => {
5+
it("includes the subject and detail in a Slack-style text payload", () => {
6+
const p = formatOpsAlert("Backup failed", "practice abc: timeout");
7+
expect(p.text).toContain("Backup failed");
8+
expect(p.text).toContain("practice abc: timeout");
9+
expect(p.text).toContain("OpenVPM");
10+
});
11+
});
12+
13+
describe("alertOps", () => {
14+
it("never throws when no webhook is configured", async () => {
15+
const prev = process.env.OPS_ALERT_WEBHOOK_URL;
16+
delete process.env.OPS_ALERT_WEBHOOK_URL;
17+
await expect(alertOps("x", "y")).resolves.toBeUndefined();
18+
if (prev !== undefined) process.env.OPS_ALERT_WEBHOOK_URL = prev;
19+
});
20+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, it, expect } from "vitest";
2+
import { parseAuditPath, redactSecrets, extractEntityId } from "../audit";
3+
4+
const UUID = "11111111-1111-1111-1111-111111111111";
5+
6+
describe("parseAuditPath", () => {
7+
it("splits entity and action on the first dot", () => {
8+
expect(parseAuditPath("clients.create")).toEqual({ entityType: "clients", action: "create" });
9+
expect(parseAuditPath("treatmentPlans.updateItemStatus")).toEqual({
10+
entityType: "treatmentPlans",
11+
action: "updateItemStatus",
12+
});
13+
});
14+
it("handles a path with no dot", () => {
15+
expect(parseAuditPath("health")).toEqual({ entityType: "health", action: "" });
16+
});
17+
});
18+
19+
describe("redactSecrets", () => {
20+
it("redacts secret-ish keys, keeps the rest", () => {
21+
const out = redactSecrets({ name: "Rex", password: "hunter2", apiKey: "x", note: "ok" });
22+
expect(out).toEqual({ name: "Rex", password: "[redacted]", apiKey: "[redacted]", note: "ok" });
23+
});
24+
it("redacts keyHash/secret/token variants", () => {
25+
const out = redactSecrets({ keyHash: "a", secret: "b", authToken: "c" })!;
26+
expect(out.keyHash).toBe("[redacted]");
27+
expect(out.secret).toBe("[redacted]");
28+
expect(out.authToken).toBe("[redacted]");
29+
});
30+
it("returns null for null/undefined", () => {
31+
expect(redactSecrets(null)).toBeNull();
32+
expect(redactSecrets(undefined)).toBeNull();
33+
});
34+
});
35+
36+
describe("extractEntityId", () => {
37+
it("prefers the result row id", () => {
38+
expect(extractEntityId({ id: "ignored" }, { id: UUID })).toBe(UUID);
39+
});
40+
it("falls back to a uuid input id", () => {
41+
expect(extractEntityId({ id: UUID }, { ok: true })).toBe(UUID);
42+
});
43+
it("returns null when no uuid is present", () => {
44+
expect(extractEntityId({ id: "not-a-uuid" }, { success: true })).toBeNull();
45+
expect(extractEntityId(null, null)).toBeNull();
46+
});
47+
});

apps/web/lib/alerts.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Ops alerting for background jobs (cron, webhook dispatch). These run with no
3+
* user watching, so a silent failure means a clinic never knows a reminder
4+
* didn't send or a backup didn't run. Posts to OPS_ALERT_WEBHOOK_URL (Slack-
5+
* style) if configured; always logs. Never throws into the caller.
6+
*/
7+
8+
export function formatOpsAlert(subject: string, detail: string): { text: string } {
9+
return { text: `🚨 OpenVPM ops alert — ${subject}\n${detail}` };
10+
}
11+
12+
export async function alertOps(subject: string, detail: string): Promise<void> {
13+
console.error(`[ops-alert] ${subject}: ${detail}`);
14+
const url = process.env.OPS_ALERT_WEBHOOK_URL;
15+
if (!url) return;
16+
try {
17+
await fetch(url, {
18+
method: "POST",
19+
headers: { "Content-Type": "application/json" },
20+
body: JSON.stringify(formatOpsAlert(subject, detail)),
21+
});
22+
} catch (err) {
23+
console.error("[ops-alert] failed to deliver alert:", err);
24+
}
25+
}

apps/web/lib/audit.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { Database } from "@openpims/db/client";
2+
import { auditLog } from "@openpims/db";
3+
4+
/**
5+
* Audit logging for mutations. The pure helpers (path parsing, secret
6+
* redaction, entity-id extraction) are unit-tested; recordAuditLog performs the
7+
* best-effort insert and must never throw into the request path.
8+
*/
9+
10+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11+
const SECRET_KEY_RE = /pass(word)?|secret|token|keyhash|^key$|apikey/i;
12+
13+
/** "clients.create" -> { entityType: "clients", action: "create" }. */
14+
export function parseAuditPath(path: string): { entityType: string; action: string } {
15+
const dot = path.indexOf(".");
16+
const entityType = (dot === -1 ? path : path.slice(0, dot)).slice(0, 64);
17+
const action = (dot === -1 ? "" : path.slice(dot + 1)).slice(0, 64);
18+
return { entityType, action };
19+
}
20+
21+
/** Shallow-redact secret-ish fields so they never land in the audit trail. */
22+
export function redactSecrets(input: unknown): Record<string, unknown> | null {
23+
if (!input || typeof input !== "object" || Array.isArray(input)) {
24+
return input == null ? null : { value: "[redacted-nonobject]" };
25+
}
26+
const out: Record<string, unknown> = {};
27+
for (const [k, v] of Object.entries(input as Record<string, unknown>)) {
28+
out[k] = SECRET_KEY_RE.test(k) ? "[redacted]" : v;
29+
}
30+
return out;
31+
}
32+
33+
/** Best-effort entity id: prefer the created/updated row's id, else input.id. */
34+
export function extractEntityId(rawInput: unknown, resultData: unknown): string | null {
35+
const fromResult = (resultData as { id?: unknown } | null)?.id;
36+
if (typeof fromResult === "string" && UUID_RE.test(fromResult)) return fromResult;
37+
const fromInput = (rawInput as { id?: unknown } | null)?.id;
38+
if (typeof fromInput === "string" && UUID_RE.test(fromInput)) return fromInput;
39+
return null;
40+
}
41+
42+
export async function recordAuditLog(
43+
db: Database,
44+
opts: {
45+
practiceId: string;
46+
userId: string;
47+
ip?: string | null;
48+
path: string;
49+
rawInput: unknown;
50+
resultData: unknown;
51+
}
52+
): Promise<void> {
53+
try {
54+
const { entityType, action } = parseAuditPath(opts.path);
55+
await db.insert(auditLog).values({
56+
practiceId: opts.practiceId,
57+
userId: opts.userId,
58+
action,
59+
entityType,
60+
entityId: extractEntityId(opts.rawInput, opts.resultData),
61+
changes: redactSecrets(opts.rawInput),
62+
ipAddress: opts.ip ?? null,
63+
});
64+
} catch (err) {
65+
// Auditing must never break the request it's recording.
66+
console.error("[audit] failed to record:", err);
67+
}
68+
}

apps/web/lib/auth.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,23 @@ declare module "next-auth" {
1111
id: string;
1212
email: string;
1313
name: string;
14-
role: "admin" | "veterinarian" | "technician" | "front_desk";
14+
role: "admin" | "veterinarian" | "technician" | "front_desk" | "viewer";
1515
practiceId: string;
1616
};
1717
}
1818
interface User {
1919
id: string;
2020
email: string;
2121
name: string;
22-
role: "admin" | "veterinarian" | "technician" | "front_desk";
22+
role: "admin" | "veterinarian" | "technician" | "front_desk" | "viewer";
2323
practiceId: string;
2424
}
2525
}
2626

2727
declare module "next-auth/jwt" {
2828
interface JWT {
2929
id: string;
30-
role: "admin" | "veterinarian" | "technician" | "front_desk";
30+
role: "admin" | "veterinarian" | "technician" | "front_desk" | "viewer";
3131
practiceId: string;
3232
}
3333
}

0 commit comments

Comments
 (0)