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.
| ba9e143 | 1 | /** |
| 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 | ||
| 11 | import { Hono } from "hono"; | |
| 12 | import { and, count, desc, eq, gte, sql } from "drizzle-orm"; | |
| 13 | import { db } from "../db"; | |
| 14 | import { | |
| 15 | auditLog, | |
| 16 | loginAttempts, | |
| 17 | sessions, | |
| 18 | userTotp, | |
| 19 | users, | |
| 20 | } from "../db/schema"; | |
| 21 | import { isSiteAdmin } from "../lib/admin"; | |
| 22 | import { softAuth } from "../middleware/auth"; | |
| 23 | import type { AuthEnv } from "../middleware/auth"; | |
| 24 | import { Layout } from "../views/layout"; | |
| 25 | ||
| 26 | const adminSecurity = new Hono<AuthEnv>(); | |
| 27 | adminSecurity.use("*", softAuth); | |
| 28 | ||
| 29 | // ── Auth guard ─────────────────────────────────────────────────────────────── | |
| 30 | adminSecurity.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 | }); | |
| 37 | adminSecurity.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 ─────────────────────────────────────────────────────────────── | |
| 46 | const 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 ────────────────────────────────────────────────────── | |
| 133 | adminSecurity.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 ─────────────────────────────────────────────────────────── | |
| 448 | adminSecurity.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 | ||
| 695 | export default adminSecurity; |