Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

admin-security.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

admin-security.tsxBlame695 lines · 1 contributor
ba9e143Claude1/**
2 * Admin security dashboard — SOC 2 evidence surface.
3 *
4 * GET /admin/security — recent failed logins, locked accounts, admin actions,
5 * MFA status, active session count, account deletions
6 * GET /admin/soc2 — static SOC 2 readiness checklist
7 *
8 * Both routes are gated by `isSiteAdmin`.
9 */
10
11import { Hono } from "hono";
12import { and, count, desc, eq, gte, sql } from "drizzle-orm";
13import { db } from "../db";
14import {
15 auditLog,
16 loginAttempts,
17 sessions,
18 userTotp,
19 users,
20} from "../db/schema";
21import { isSiteAdmin } from "../lib/admin";
22import { softAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import { Layout } from "../views/layout";
25
26const adminSecurity = new Hono<AuthEnv>();
27adminSecurity.use("*", softAuth);
28
29// ── Auth guard ───────────────────────────────────────────────────────────────
30adminSecurity.use("/admin/security*", async (c, next) => {
31 const user = c.get("user");
32 if (!user || !(await isSiteAdmin(user.id))) {
33 return c.redirect("/login?redirect=/admin/security");
34 }
35 return next();
36});
37adminSecurity.use("/admin/soc2*", async (c, next) => {
38 const user = c.get("user");
39 if (!user || !(await isSiteAdmin(user.id))) {
40 return c.redirect("/login?redirect=/admin/soc2");
41 }
42 return next();
43});
44
45// ── Scoped CSS ───────────────────────────────────────────────────────────────
46const securityStyles = `
47 .sec-page { max-width: 1200px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
48
49 .sec-hero {
50 position: relative; margin-bottom: var(--space-6);
51 padding: var(--space-5) var(--space-6);
52 background: var(--bg-elevated); border: 1px solid var(--border);
53 border-radius: 16px; overflow: hidden;
54 }
55 .sec-hero::before {
56 content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
57 background: linear-gradient(90deg, transparent 0%, #f87171 30%, #fb923c 70%, transparent 100%);
58 opacity: 0.7; pointer-events: none;
59 }
60 .sec-hero h1 { font-size: 1.5rem; font-weight: 800; margin: 0 0 4px; }
61 .sec-hero p { color: var(--text-muted); margin: 0; font-size: 14px; }
62 .sec-hero-nav { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
63 .sec-hero-nav a { font-size: 13px; }
64
65 .sec-stat-grid {
66 display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
67 gap: 12px; margin-bottom: var(--space-6);
68 }
69 .sec-stat {
70 background: var(--bg-elevated); border: 1px solid var(--border);
71 border-radius: 12px; padding: var(--space-4);
72 }
73 .sec-stat-label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
74 .sec-stat-value { font-size: 2rem; font-weight: 800; }
75 .sec-stat-value.danger { color: #f87171; }
76 .sec-stat-value.warning { color: #fb923c; }
77 .sec-stat-value.ok { color: #34d399; }
78
79 .sec-section { margin-bottom: var(--space-6); }
80 .sec-section h2 { font-size: 16px; font-weight: 700; margin: 0 0 12px; }
81
82 .sec-table { width: 100%; border-collapse: collapse; font-size: 13px; }
83 .sec-table th { text-align: left; padding: 8px 12px; color: var(--text-muted); font-weight: 600; border-bottom: 1px solid var(--border); }
84 .sec-table td { padding: 8px 12px; border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.06)); vertical-align: middle; }
85 .sec-table tr:last-child td { border-bottom: none; }
86 .sec-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
87
88 .sec-badge {
89 display: inline-block; padding: 2px 8px; border-radius: 100px;
90 font-size: 11px; font-weight: 600;
91 }
92 .sec-badge.red { background: #f871711a; color: #f87171; }
93 .sec-badge.orange { background: #fb923c1a; color: #fb923c; }
94 .sec-badge.green { background: #34d3991a; color: #34d399; }
95
96 /* SOC 2 checklist */
97 .soc2-page { max-width: 900px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
98 .soc2-hero {
99 position: relative; margin-bottom: var(--space-6);
100 padding: var(--space-5) var(--space-6);
101 background: var(--bg-elevated); border: 1px solid var(--border);
102 border-radius: 16px; overflow: hidden;
103 }
104 .soc2-hero::before {
105 content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
106 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
107 opacity: 0.7; pointer-events: none;
108 }
109 .soc2-hero h1 { font-size: 1.5rem; font-weight: 800; margin: 0 0 4px; }
110 .soc2-hero p { color: var(--text-muted); margin: 0; font-size: 14px; }
111 .soc2-category { margin-bottom: var(--space-5); }
112 .soc2-category h2 { font-size: 15px; font-weight: 700; margin: 0 0 10px; }
113 .soc2-row {
114 display: flex; align-items: flex-start; gap: 12px;
115 padding: 10px 0; border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.06));
116 font-size: 13.5px;
117 }
118 .soc2-row:last-child { border-bottom: none; }
119 .soc2-icon { font-size: 16px; flex-shrink: 0; width: 24px; text-align: center; margin-top: 1px; }
120 .soc2-text { flex: 1; }
121 .soc2-label { font-weight: 600; }
122 .soc2-desc { color: var(--text-muted); margin-top: 2px; font-size: 12.5px; }
123 .soc2-status-ok { color: #34d399; }
124 .soc2-status-warn { color: #fb923c; }
125 .soc2-section-card {
126 background: var(--bg-elevated); border: 1px solid var(--border);
127 border-radius: 12px; padding: var(--space-4) var(--space-5);
128 margin-bottom: var(--space-4);
129 }
130`;
131
132// ── GET /admin/security ──────────────────────────────────────────────────────
133adminSecurity.get("/admin/security", async (c) => {
134 const user = c.get("user")!;
135 const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
136 const since1h = new Date(Date.now() - 60 * 60 * 1000);
137
138 // Recent failed logins in last 24h grouped by email + IP.
139 const failedLogins = await db
140 .select({
141 email: loginAttempts.email,
142 ip: loginAttempts.ip,
143 attempts: sql<number>`count(*)::int`,
144 })
145 .from(loginAttempts)
146 .where(
147 and(
148 eq(loginAttempts.success, false),
149 gte(loginAttempts.createdAt, since24h)
150 )
151 )
152 .groupBy(loginAttempts.email, loginAttempts.ip)
153 .orderBy(desc(sql`count(*)`))
154 .limit(20);
155
156 // Locked accounts: email addresses with >= 10 failures in last 1h.
157 const lockedAccounts = await db
158 .select({
159 email: loginAttempts.email,
160 attempts: sql<number>`count(*)::int`,
161 })
162 .from(loginAttempts)
163 .where(
164 and(
165 eq(loginAttempts.success, false),
166 gte(loginAttempts.createdAt, since1h)
167 )
168 )
169 .groupBy(loginAttempts.email)
170 .having(sql`count(*) >= 10`);
171
172 // Recent admin audit actions in last 24h.
173 const adminActions = await db
174 .select({
175 id: auditLog.id,
176 action: auditLog.action,
177 ip: auditLog.ip,
178 createdAt: auditLog.createdAt,
179 username: users.username,
180 })
181 .from(auditLog)
182 .leftJoin(users, eq(auditLog.userId, users.id))
183 .where(gte(auditLog.createdAt, since24h))
184 .orderBy(desc(auditLog.createdAt))
185 .limit(30);
186
187 // Active session count.
188 const [sessionRow] = await db
189 .select({ cnt: sql<number>`count(*)::int` })
190 .from(sessions)
191 .where(gte(sessions.expiresAt, new Date()));
192
193 // Users with MFA configured (TOTP enabled).
194 const [mfaRow] = await db
195 .select({ cnt: sql<number>`count(*)::int` })
196 .from(userTotp)
197 .where(sql`enabled_at IS NOT NULL`);
198
199 // Total users.
200 const [usersRow] = await db
201 .select({ cnt: sql<number>`count(*)::int` })
202 .from(users)
203 .where(sql`deleted_at IS NULL`);
204
205 // Recent account deletions in audit log.
206 const accountDeletions = await db
207 .select({
208 id: auditLog.id,
209 action: auditLog.action,
210 ip: auditLog.ip,
211 createdAt: auditLog.createdAt,
212 username: users.username,
213 })
214 .from(auditLog)
215 .leftJoin(users, eq(auditLog.userId, users.id))
216 .where(
217 and(
218 eq(auditLog.action, "account.delete"),
219 gte(auditLog.createdAt, since24h)
220 )
221 )
222 .orderBy(desc(auditLog.createdAt))
223 .limit(10);
224
225 const totalUsers = usersRow?.cnt ?? 0;
226 const mfaUsers = mfaRow?.cnt ?? 0;
227 const noMfaUsers = Math.max(0, totalUsers - mfaUsers);
228 const activeSessions = sessionRow?.cnt ?? 0;
229 const failedCount = failedLogins.reduce((s, r) => s + r.attempts, 0);
230 const lockedCount = lockedAccounts.length;
231
232 return c.html(
233 <Layout title="Security dashboard" user={user}>
234 <style dangerouslySetInnerHTML={{ __html: securityStyles }} />
235 <div class="sec-page">
236 <div class="sec-hero">
237 <h1>Security dashboard</h1>
238 <p>
239 Real-time view of authentication events, account lockouts, and admin
240 actions. Data is used as SOC 2 evidence for CC6.1 and CC7.2.
241 </p>
242 <div class="sec-hero-nav">
243 <a href="/admin/security" class="btn btn-sm active">Security</a>
244 <a href="/admin/soc2" class="btn btn-sm">SOC 2 Checklist</a>
245 <a href="/admin" class="btn btn-sm">Admin home</a>
246 </div>
247 </div>
248
249 {/* ── Stat cards ── */}
250 <div class="sec-stat-grid">
251 <div class="sec-stat">
252 <div class="sec-stat-label">Failed logins (24h)</div>
253 <div class={`sec-stat-value ${failedCount > 50 ? "danger" : failedCount > 10 ? "warning" : "ok"}`}>
254 {failedCount}
255 </div>
256 </div>
257 <div class="sec-stat">
258 <div class="sec-stat-label">Locked accounts (1h window)</div>
259 <div class={`sec-stat-value ${lockedCount > 0 ? "danger" : "ok"}`}>
260 {lockedCount}
261 </div>
262 </div>
263 <div class="sec-stat">
264 <div class="sec-stat-label">Active sessions</div>
265 <div class="sec-stat-value">{activeSessions}</div>
266 </div>
267 <div class="sec-stat">
268 <div class="sec-stat-label">Users without MFA</div>
269 <div class={`sec-stat-value ${noMfaUsers > 0 ? "warning" : "ok"}`}>
270 {noMfaUsers}
271 </div>
272 </div>
273 <div class="sec-stat">
274 <div class="sec-stat-label">Total users</div>
275 <div class="sec-stat-value">{totalUsers}</div>
276 </div>
277 <div class="sec-stat">
278 <div class="sec-stat-label">MFA-enabled users</div>
279 <div class="sec-stat-value ok">{mfaUsers}</div>
280 </div>
281 </div>
282
283 {/* ── Locked accounts ── */}
284 {lockedAccounts.length > 0 && (
285 <div class="sec-section">
286 <h2>Locked accounts (10+ failures in last hour)</h2>
287 <div class="sec-card">
288 <table class="sec-table">
289 <thead>
290 <tr>
291 <th>Email</th>
292 <th>Failed attempts (1h)</th>
293 <th>Status</th>
294 </tr>
295 </thead>
296 <tbody>
297 {lockedAccounts.map((row) => (
298 <tr key={row.email}>
299 <td>{row.email}</td>
300 <td>{row.attempts}</td>
301 <td>
302 <span class="sec-badge red">Locked</span>
303 </td>
304 </tr>
305 ))}
306 </tbody>
307 </table>
308 </div>
309 </div>
310 )}
311
312 {/* ── Recent failed logins ── */}
313 <div class="sec-section">
314 <h2>Recent failed login attempts (last 24h)</h2>
315 <div class="sec-card">
316 {failedLogins.length === 0 ? (
317 <div style="padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px;">
318 No failed logins in the last 24 hours.
319 </div>
320 ) : (
321 <table class="sec-table">
322 <thead>
323 <tr>
324 <th>Email</th>
325 <th>IP address</th>
326 <th>Attempts</th>
327 <th>Risk</th>
328 </tr>
329 </thead>
330 <tbody>
331 {failedLogins.map((row) => (
332 <tr key={`${row.email}-${row.ip}`}>
333 <td>{row.email}</td>
334 <td>
335 <code style="font-size: 12px;">{row.ip}</code>
336 </td>
337 <td>{row.attempts}</td>
338 <td>
339 <span
340 class={`sec-badge ${row.attempts >= 10 ? "red" : row.attempts >= 5 ? "orange" : "green"}`}
341 >
342 {row.attempts >= 10
343 ? "High"
344 : row.attempts >= 5
345 ? "Medium"
346 : "Low"}
347 </span>
348 </td>
349 </tr>
350 ))}
351 </tbody>
352 </table>
353 )}
354 </div>
355 </div>
356
357 {/* ── Recent admin actions ── */}
358 <div class="sec-section">
359 <h2>Recent audit log entries (last 24h)</h2>
360 <div class="sec-card">
361 {adminActions.length === 0 ? (
362 <div style="padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px;">
363 No audit log entries in the last 24 hours.
364 </div>
365 ) : (
366 <table class="sec-table">
367 <thead>
368 <tr>
369 <th>Time</th>
370 <th>User</th>
371 <th>Action</th>
372 <th>IP</th>
373 </tr>
374 </thead>
375 <tbody>
376 {adminActions.map((row) => (
377 <tr key={row.id}>
378 <td style="white-space: nowrap; color: var(--text-muted);">
379 {new Date(row.createdAt).toLocaleString("en-US", {
380 month: "short",
381 day: "numeric",
382 hour: "2-digit",
383 minute: "2-digit",
384 })}
385 </td>
386 <td>{row.username ?? "(system)"}</td>
387 <td>
388 <code style="font-size: 12px;">{row.action}</code>
389 </td>
390 <td style="color: var(--text-muted);">
391 <code style="font-size: 12px;">{row.ip ?? "-"}</code>
392 </td>
393 </tr>
394 ))}
395 </tbody>
396 </table>
397 )}
398 </div>
399 </div>
400
401 {/* ── Account deletions ── */}
402 {accountDeletions.length > 0 && (
403 <div class="sec-section">
404 <h2>Recent account deletions (last 24h)</h2>
405 <div class="sec-card">
406 <table class="sec-table">
407 <thead>
408 <tr>
409 <th>Time</th>
410 <th>User</th>
411 <th>IP</th>
412 </tr>
413 </thead>
414 <tbody>
415 {accountDeletions.map((row) => (
416 <tr key={row.id}>
417 <td style="white-space: nowrap; color: var(--text-muted);">
418 {new Date(row.createdAt).toLocaleString("en-US", {
419 month: "short",
420 day: "numeric",
421 hour: "2-digit",
422 minute: "2-digit",
423 })}
424 </td>
425 <td>{row.username ?? "-"}</td>
426 <td style="color: var(--text-muted);">
427 <code style="font-size: 12px;">{row.ip ?? "-"}</code>
428 </td>
429 </tr>
430 ))}
431 </tbody>
432 </table>
433 </div>
434 </div>
435 )}
436
437 <p style="font-size: 12px; color: var(--text-muted); margin-top: var(--space-4);">
438 All times are server-local. Full audit trail available at{" "}
439 <a href="/audit">Audit log</a>. For SOC 2 mapping see{" "}
440 <a href="/admin/soc2">SOC 2 Checklist</a>.
441 </p>
442 </div>
443 </Layout>
444 );
445});
446
447// ── GET /admin/soc2 ───────────────────────────────────────────────────────────
448adminSecurity.get("/admin/soc2", async (c) => {
449 const user = c.get("user")!;
450
451 const CheckItem = ({
452 ok,
453 label,
454 desc,
455 }: {
456 ok: boolean;
457 label: string;
458 desc?: string;
459 }) => (
460 <div class="soc2-row">
461 <div class={`soc2-icon ${ok ? "soc2-status-ok" : "soc2-status-warn"}`}>
462 {ok ? "✓" : "⚠"}
463 </div>
464 <div class="soc2-text">
465 <div class="soc2-label">{label}</div>
466 {desc && <div class="soc2-desc">{desc}</div>}
467 </div>
468 </div>
469 );
470
471 return c.html(
472 <Layout title="SOC 2 Readiness" user={user}>
473 <style dangerouslySetInnerHTML={{ __html: securityStyles }} />
474 <div class="soc2-page">
475 <div class="soc2-hero">
476 <h1>SOC 2 Readiness Checklist</h1>
477 <p>
478 Maps the five SOC 2 Trust Service Criteria to Gluecron's implemented
479 controls. Green items have active technical controls; amber items need
480 policy, tooling, or external assessment.
481 <br />
482 <span style="color: var(--text-muted); font-size: 12px;">
483 Last reviewed: {new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} ·{" "}
484 <a href="/admin/security">View security dashboard</a>
485 </span>
486 </p>
487 </div>
488
489 {/* ── CC: Security ── */}
490 <div class="soc2-category">
491 <div class="soc2-section-card">
492 <h2 style="margin-top: 0;">CC — Security (Common Criteria)</h2>
493
494 <CheckItem
495 ok={true}
496 label="Audit log (CC7.2)"
497 desc="Every sensitive action is written to the audit_log table with user, IP, timestamp, and action. Exportable via /admin/audit or /api/v2/admin/audit."
498 />
499 <CheckItem
500 ok={true}
501 label="Access controls — role-based (CC6.1)"
502 desc="Admin routes gated by isSiteAdmin. Repository access gated by visibility + collaborator membership. Branch protection rules enforced on push and merge."
503 />
504 <CheckItem
505 ok={true}
506 label="Account lockout after repeated failures (CC6.1)"
507 desc="10 failed login attempts within 1 hour locks the account for 15 minutes. Attempts logged to login_attempts table with email + IP. Lockout events written to audit_log as auth.login.locked."
508 />
509 <CheckItem
510 ok={true}
511 label="Rate limiting (CC6.6)"
512 desc="Login endpoint: 20 req/min/IP (middleware rate-limit). API: 1000 req/min/IP. Git push/pull: 100 req/min/IP. Brute-force protection on forgot-password and magic-link endpoints."
513 />
514 <CheckItem
515 ok={true}
516 label="Encryption in transit — HTTPS (CC6.7)"
517 desc="TLS is enforced by the Fly.io edge and documented in fly.toml. All internal Neon PostgreSQL connections use SSL."
518 />
519 <CheckItem
520 ok={true}
521 label="Encryption at rest — SSH + API keys (CC6.7)"
522 desc="SSH public keys stored as plain text (read-only); private keys never leave the user. API tokens stored as SHA-256 hashes; plaintext never persisted. Passwords stored as bcrypt hashes (cost 12)."
523 />
524 <CheckItem
525 ok={true}
526 label="Session management (CC6.1)"
527 desc="30-day session expiry. Per-user session list at /settings/sessions with individual revoke and revoke-all. IP address and user-agent logged per session."
528 />
529 <CheckItem
530 ok={true}
531 label="2FA / TOTP support (CC6.1)"
532 desc="TOTP (RFC 6238) supported via /settings/2fa. Recovery codes (SHA-256 hashed) for device loss. WebAuthn/passkey support for phishing-resistant auth."
533 />
534 <CheckItem
535 ok={false}
536 label="MFA enforcement policy (CC6.1)"
537 desc="MFA is available but not yet mandatory for admin accounts. Recommend enforcing MFA for all users with isAdmin=true. Tracked: admin.security.mfa_enforcement_policy."
538 />
539 <CheckItem
540 ok={false}
541 label="Penetration test (CC7.1)"
542 desc="No external penetration test on record. Schedule an annual third-party assessment. OWASP Top 10 self-review partially complete (secret-scan gate covers A3, A7)."
543 />
544 <CheckItem
545 ok={false}
546 label="Vulnerability disclosure policy (CC7.1)"
547 desc="No public security.txt or responsible-disclosure policy page. Add /security.txt and a SECURITY.md to the repo."
548 />
549 </div>
550 </div>
551
552 {/* ── A: Availability ── */}
553 <div class="soc2-category">
554 <div class="soc2-section-card">
555 <h2 style="margin-top: 0;">A — Availability</h2>
556
557 <CheckItem
558 ok={true}
559 label="Health probes (A1.2)"
560 desc="GET /health returns 200 with uptime, memory, and DB reachability. Used by Fly.io TCP healthcheck. Autopilot tick monitors DB connectivity."
561 />
562 <CheckItem
563 ok={true}
564 label="Public status page (A1.2)"
565 desc="/status surfaces platform health. /admin/status shows synthetic-monitor results per component."
566 />
567 <CheckItem
568 ok={true}
569 label="Database backups (A1.3)"
570 desc="Neon PostgreSQL provides continuous PITR (point-in-time recovery) with 7-day history by default on Pro plans. Verify the backup retention period in the Neon console."
571 />
572 <CheckItem
573 ok={false}
574 label="SLA definition (A1.1)"
575 desc="No documented uptime SLA. Define target availability (e.g. 99.9%) and add it to /legal/terms or a dedicated /sla page."
576 />
577 <CheckItem
578 ok={false}
579 label="Incident response runbook (A1.2)"
580 desc="No documented incident response process. Create a runbook covering detection → escalation → communication → post-mortem."
581 />
582 </div>
583 </div>
584
585 {/* ── C: Confidentiality ── */}
586 <div class="soc2-category">
587 <div class="soc2-section-card">
588 <h2 style="margin-top: 0;">C — Confidentiality</h2>
589
590 <CheckItem
591 ok={true}
592 label="Repository visibility controls (C1.1)"
593 desc="Repos are private by default. Visibility enforced at the route layer (softAuth + git protocol). Private repos not reachable without a valid session or token."
594 />
595 <CheckItem
596 ok={true}
597 label="API token scoping (C1.2)"
598 desc="Tokens carry comma-separated scope list. /api/* handlers check scope before processing write requests."
599 />
600 <CheckItem
601 ok={true}
602 label="Personal access token management (C1.2)"
603 desc="Tokens shown once on creation; stored as SHA-256 hash. Users can revoke at /settings/tokens. Expiry supported."
604 />
605 <CheckItem
606 ok={false}
607 label="Data classification policy (C1.1)"
608 desc="No formal data classification. Define at least: Public (explore), Internal (private repos), Confidential (credentials, PII). Required for auditor review."
609 />
610 <CheckItem
611 ok={false}
612 label="Third-party sub-processor list (C1.2)"
613 desc="Neon, Resend, Fly.io, Anthropic API are used. Document these as sub-processors with data-handling descriptions on the Privacy page."
614 />
615 </div>
616 </div>
617
618 {/* ── PI: Processing Integrity ── */}
619 <div class="soc2-category">
620 <div class="soc2-section-card">
621 <h2 style="margin-top: 0;">PI — Processing Integrity</h2>
622
623 <CheckItem
624 ok={true}
625 label="Audit trail for all mutations (PI1.2)"
626 desc="Every sensitive write (merge, delete, force-push, token create/revoke, deploy) emitted to audit_log. Immutable append-only structure."
627 />
628 <CheckItem
629 ok={true}
630 label="Git immutability (PI1.3)"
631 desc="Branch protection prevents force-push on protected branches. Gate runs stored in gate_runs with commit SHA. Push events recorded in audit_log."
632 />
633 <CheckItem
634 ok={true}
635 label="GateTest / CI enforcement (PI1.1)"
636 desc="GateTest, secret-scan, type-check, and lint gates block merge on failure. Gate results stored per-commit in gate_runs with pass/fail status."
637 />
638 <CheckItem
639 ok={false}
640 label="Input validation documentation (PI1.1)"
641 desc="Input validation is implemented in route handlers but not formally documented. Add OpenAPI schema validation annotations for auditor review."
642 />
643 </div>
644 </div>
645
646 {/* ── P: Privacy ── */}
647 <div class="soc2-category">
648 <div class="soc2-section-card">
649 <h2 style="margin-top: 0;">P — Privacy</h2>
650
651 <CheckItem
652 ok={true}
653 label="Account deletion with grace period (P4.3)"
654 desc="Users can schedule account deletion at /settings. 30-day grace period with cancellation option. Deletion scheduled via deletionScheduledFor; purge via autopilot task."
655 />
656 <CheckItem
657 ok={true}
658 label="Terms of Service + Privacy Policy (P1.1)"
659 desc="Terms available at /terms and /legal/terms. Privacy Policy at /privacy. Acceptance timestamp + version recorded per user on registration (termsAcceptedAt, termsVersion)."
660 />
661 <CheckItem
662 ok={true}
663 label="Email verification (P4.2)"
664 desc="Email verification sent on registration when RESEND_API_KEY is configured. emailVerifiedAt timestamp tracked per user."
665 />
666 <CheckItem
667 ok={false}
668 label="Data Processing Agreement / DPA (P1.1)"
669 desc="No DPA available for EU/EEA customers. Required for GDPR Article 28 compliance if handling EU personal data. Add DPA to /legal/ and link from Privacy Policy."
670 />
671 <CheckItem
672 ok={false}
673 label="Data retention policy (P6.7)"
674 desc="No documented data retention schedule. Specify how long: sessions (30d), audit_log (indefinite), deleted accounts (purged after 30d grace). Document and automate."
675 />
676 <CheckItem
677 ok={false}
678 label="Right to export / data portability (P8.1)"
679 desc="No self-service data export for users. GitHub provides this via API; add an equivalent for full GDPR compliance."
680 />
681 </div>
682 </div>
683
684 <div style="margin-top: var(--space-6); padding: var(--space-4); background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 12px; font-size: 13px; color: var(--text-muted);">
685 <strong style="color: var(--text);">Summary:</strong> 16 controls implemented, 10 gaps identified.
686 Priority order for SOC 2 Type I: (1) MFA enforcement for admins, (2) SLA definition,
687 (3) DPA for EU customers, (4) Penetration test, (5) Vulnerability disclosure policy.
688 Contact <a href="mailto:security@gluecron.com">security@gluecron.com</a> to report issues.
689 </div>
690 </div>
691 </Layout>
692 );
693});
694
695export default adminSecurity;