Add a Privacy page: what is stored, where it goes, and your own data
A page every signed-in user can open. It states what the installation stores (accounts, sign-in sessions, audit and diagnostic logs, server reports, the secrets tracker, credentials, inventory) and for how long, where data goes (Authentik, integrations, DNS providers, notification channels, domain registries, certificate checks, port scans), what lives in the browser, who can see what, and how to limit or remove data. Written from what the code actually does, including the uncomfortable parts: the session file keeps the user's Authentik ID token plus the IP and browser from sign-in; audit entries keep a name snapshot after an account is gone; the app has no delete-account function; cron commands in agent reports can contain sensitive text. It also says what isn't there -- no telemetry, update checks, third-party scripts, fonts or tracking cookies -- which was checked against the web build and the server's outbound calls before being asserted. Live values rather than boilerplate: log retention (and whether it's on), which integration and DNS provider types are enabled, how many domains, certificate checks and reporting servers, and which notification channels are on. Channel addresses are shown to admins only, and only the host -- never a path, query string or token -- since a webhook URL can embed a key. Each user also sees their own account and active sign-ins, and can "Download my data": their account, their sign-ins and the audit-log entries made under their account, as JSON. Only their own -- never another user's -- and without session ids or ID tokens. The export is itself audit-logged, so a later export shows it. Verified with 23 backend checks (own-vs-others isolation for audit counts, sessions and export; no session ids, ID tokens or channel secrets in any response; admin-vs-viewer channel visibility; live counts; audit of the export; auth) using an isolated session directory so real sessions are never read, and in a browser against the real router, including the download. Real dev database and session files untouched. Not legal text: this is a transparency page for the people using the app, not a privacy policy or a GDPR compliance document. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
07d20bd2b7
commit
72f8c85406
7 files changed
+466
No files matched your search
@@ -26,6 +26,7 @@ import { sessionsRouter } from "./routes/sessions.js";
|
||||
import { maintenanceRouter } from "./routes/maintenance.js";
|
||||
import { domainsRouter } from "./routes/domains.js";
|
||||
import { consistencyRouter } from "./routes/consistency.js";
|
||||
import { privacyRouter } from "./routes/privacy.js";
|
||||
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
|
||||
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
|
||||
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
|
||||
@@ -96,6 +97,7 @@ app.use("/api/sessions", sessionsRouter);
|
||||
app.use("/api/maintenance", maintenanceRouter);
|
||||
app.use("/api/domains", domainsRouter);
|
||||
app.use("/api/consistency", consistencyRouter);
|
||||
app.use("/api/privacy", privacyRouter);
|
||||
|
||||
if (existsSync(webDist)) {
|
||||
app.use(express.static(webDist));
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Router } from "express";
|
||||
import { count, eq, isNotNull } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { auditLog, dnsProviders, domains, integrations, secrets, servers, users } from "../db/schema.js";
|
||||
import { requireAuth } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
import { getSettings } from "../services/settingsStore.js";
|
||||
import { listSessions } from "../services/sessionStore.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
export const privacyRouter = Router();
|
||||
privacyRouter.use(requireAuth);
|
||||
|
||||
function hostOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).host || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The signed-in user's own sessions. The session id and the stored ID token are deliberately not passed on. */
|
||||
async function ownSessions(sub: string, currentSessionId: string) {
|
||||
return (await listSessions())
|
||||
.filter((s) => s.sub === sub)
|
||||
.map((s) => ({ ip: s.ip, userAgent: s.userAgent, lastAccess: s.lastAccess, expiresAt: s.expiresAt, current: s.id === currentSessionId }));
|
||||
}
|
||||
|
||||
privacyRouter.get("/", asyncHandler(async (req, res) => {
|
||||
const me = req.currentUser!;
|
||||
const isAdmin = me.role === "admin";
|
||||
const settings = await getSettings();
|
||||
|
||||
const [{ n: auditEntries }] = await db.select({ n: count() }).from(auditLog).where(eq(auditLog.actorUserId, me.id));
|
||||
const integrationRows = await db.select({ type: integrations.type }).from(integrations).where(eq(integrations.enabled, true));
|
||||
const integrationTypes = [...new Set(integrationRows.map((r) => r.type))].sort();
|
||||
const dnsRows = await db.select({ type: dnsProviders.providerType }).from(dnsProviders).where(eq(dnsProviders.enabled, true));
|
||||
const dnsTypes = [...new Set(dnsRows.map((r) => r.type))].sort();
|
||||
const [{ n: domainCount }] = await db.select({ n: count() }).from(domains);
|
||||
const [{ n: tlsChecks }] = await db.select({ n: count() }).from(secrets).where(isNotNull(secrets.checkHost));
|
||||
const [{ n: serverCount }] = await db.select({ n: count() }).from(servers);
|
||||
const [{ n: agentCount }] = await db.select({ n: count() }).from(servers).where(isNotNull(servers.lastSeenAt));
|
||||
|
||||
// Where a notification goes is infrastructure detail — every signed-in user is told a channel is on, only admins see the address.
|
||||
const channel = (enabled: boolean, host: string | null) => ({ enabled, host: isAdmin ? host : null });
|
||||
|
||||
res.json({
|
||||
me: {
|
||||
user: { email: me.email, name: me.name, role: me.role, subject: me.oidcSub, createdAt: me.createdAt, lastLoginAt: me.lastLoginAt },
|
||||
auditEntries,
|
||||
sessions: await ownSessions(me.oidcSub, req.sessionID),
|
||||
},
|
||||
retention: settings.logRetention,
|
||||
outbound: {
|
||||
integrationTypes,
|
||||
dnsProviderTypes: dnsTypes,
|
||||
domainsTracked: domainCount,
|
||||
tlsCertificateChecks: tlsChecks,
|
||||
servers: serverCount,
|
||||
serversWithAgent: agentCount,
|
||||
channels: {
|
||||
gotify: channel(settings.gotify.enabled, hostOf(settings.gotify.url)),
|
||||
ntfy: channel(settings.ntfy.enabled, hostOf(settings.ntfy.url)),
|
||||
smtp: channel(settings.smtp.enabled, settings.smtp.host || null),
|
||||
webhook: channel(settings.webhook.enabled, hostOf(settings.webhook.url)),
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
// Everything the app holds that is specifically about the signed-in user, as a file. Only their own — never another user's.
|
||||
privacyRouter.get("/export", asyncHandler(async (req, res) => {
|
||||
const me = req.currentUser!;
|
||||
const [row] = await db.select().from(users).where(eq(users.id, me.id)).limit(1);
|
||||
const entries = await db
|
||||
.select({ createdAt: auditLog.createdAt, category: auditLog.category, action: auditLog.action, targetType: auditLog.targetType, targetId: auditLog.targetId, detail: auditLog.detail })
|
||||
.from(auditLog)
|
||||
.where(eq(auditLog.actorUserId, me.id))
|
||||
.orderBy(auditLog.id);
|
||||
|
||||
await recordAudit({ actor: me, category: "privacy", action: "export_own_data", targetType: "user", targetId: me.id });
|
||||
|
||||
const body = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
note: "Everything Homelab Manager holds that is specifically about you. Other people's data, server data and shared inventory are not included.",
|
||||
account: { email: row.email, name: row.name, role: row.role, subject: row.oidcSub, createdAt: row.createdAt, lastLoginAt: row.lastLoginAt },
|
||||
sessions: await ownSessions(me.oidcSub, req.sessionID),
|
||||
auditLog: entries.map((e) => ({ ...e, detail: e.detail ? safeJson(e.detail) : null })),
|
||||
};
|
||||
res.setHeader("Content-Disposition", 'attachment; filename="homelab-manager-my-data.json"');
|
||||
res.json(body);
|
||||
}));
|
||||
|
||||
function safeJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user