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