Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitba9e143unknown_key

Add SOC 2 readiness: failed login lockout, session management, security dashboard

Add SOC 2 readiness: failed login lockout, session management, security dashboard

- Track failed login attempts in new login_attempts table (migration 0078)
- Lock accounts for 15 minutes after 10 failures in 1 hour; emit auth.login.locked to audit_log
- Store IP + user-agent on sessions at login (migration 0077 adds ip, user_agent, last_seen_at columns)
- Add /settings/sessions: list all active sessions with device hint, IP, timestamps; revoke individual or all-others
- Add /admin/security: real-time dashboard showing failed logins (24h), locked accounts, audit trail, MFA counts, session counts, account deletions
- Add /admin/soc2: static SOC 2 readiness checklist mapping all five Trust Service Criteria to implemented controls; identifies 10 gaps with remediation guidance

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 7, 2026Parent: 6efae38
7 files changed+11282ba9e143ecb622b846ded89ac8fdc136ee853dad7
7 changed files+1128−2
Addeddrizzle/0077_session_metadata.sql+7−0View fileUnifiedSplit
1-- Migration 0077: Add IP address, user-agent, and last-seen timestamp to sessions.
2-- Powers the /settings/sessions management page (SOC 2 session visibility).
3
4ALTER TABLE sessions
5 ADD COLUMN IF NOT EXISTS ip text,
6 ADD COLUMN IF NOT EXISTS user_agent text,
7 ADD COLUMN IF NOT EXISTS last_seen_at timestamp;
Addeddrizzle/0078_login_attempts.sql+17−0View fileUnifiedSplit
1-- Migration 0078: Login attempt tracking for account lockout (SOC 2 CC6.1).
2-- Records failed login attempts per email+IP. After 10 failures in 1 hour,
3-- auth.login.locked is emitted and logins from that email are blocked for 15 min.
4
5CREATE TABLE IF NOT EXISTS login_attempts (
6 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7 email text NOT NULL,
8 ip text NOT NULL,
9 success boolean NOT NULL DEFAULT false,
10 created_at timestamp NOT NULL DEFAULT now()
11);
12
13CREATE INDEX IF NOT EXISTS login_attempts_email_created
14 ON login_attempts (email, created_at);
15
16CREATE INDEX IF NOT EXISTS login_attempts_ip_created
17 ON login_attempts (ip, created_at);
Modifiedsrc/app.tsx+8−0View fileUnifiedSplit
105105import adminDiagnoseRoutes from "./routes/admin-diagnose";
106106import adminIntegrationsRoutes from "./routes/admin-integrations";
107107import adminAdvancementRoutes from "./routes/admin-advancement";
108import adminSecurityRoutes from "./routes/admin-security";
109import settingsSessionsRoutes from "./routes/settings-sessions";
108110import advisoriesRoutes from "./routes/advisories";
109111import aiChangelogRoutes from "./routes/ai-changelog";
110112import aiExplainRoutes from "./routes/ai-explain";
423425// Settings routes (profile, SSH keys)
424426app.route("/", settingsRoutes);
425427
428// Session management (SOC 2 CC6.1 — /settings/sessions)
429app.route("/", settingsSessionsRoutes);
430
426431// 2FA / TOTP settings (Block B4)
427432app.route("/", settings2faRoutes);
428433
624629app.route("/", adminRoutes);
625630app.route("/", adminDeletionsRoutes);
626631app.route("/", adminStripeRoutes);
632
633// SOC 2 security dashboard + readiness checklist (/admin/security, /admin/soc2)
634app.route("/", adminSecurityRoutes);
627635app.route("/", adminIntegrationsRoutes);
628636app.route("/", adminAdvancementRoutes);
629637app.route("/", adminDeploysRoutes);
Modifiedsrc/db/schema.ts+29−0View fileUnifiedSplit
146146 // code. softAuth/requireAuth treat such sessions as anonymous; only
147147 // /login/2fa can consume them. Flips to false on successful 2FA.
148148 requires2fa: boolean("requires_2fa").default(false).notNull(),
149 // Migration 0077 — SOC 2 session visibility. Populated at login and
150 // refreshed on each authenticated request (best-effort, non-blocking).
151 ip: text("ip"),
152 userAgent: text("user_agent"),
153 lastSeenAt: timestamp("last_seen_at"),
149154 createdAt: timestamp("created_at").defaultNow().notNull(),
150155});
151156
157/**
158 * Login attempt log — SOC 2 CC6.1 account-lockout evidence.
159 * Records every login attempt (success or failure) per email + IP.
160 * After 10 failures within 1 hour the login handler blocks the email
161 * for 15 minutes and emits `auth.login.locked` to the audit log.
162 * Migration 0078.
163 */
164export const loginAttempts = pgTable(
165 "login_attempts",
166 {
167 id: uuid("id").primaryKey().defaultRandom(),
168 email: text("email").notNull(),
169 ip: text("ip").notNull(),
170 success: boolean("success").notNull().default(false),
171 createdAt: timestamp("created_at").defaultNow().notNull(),
172 },
173 (table) => [
174 index("login_attempts_email_created").on(table.email, table.createdAt),
175 index("login_attempts_ip_created").on(table.ip, table.createdAt),
176 ]
177);
178
179export type LoginAttempt = typeof loginAttempts.$inferSelect;
180
152181// @ts-ignore — self-referential FK on forkedFromId causes circular inference
153182export const repositories = pgTable(
154183 "repositories",
Addedsrc/routes/admin-security.tsx+695−0View fileUnifiedSplit
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
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;
Modifiedsrc/routes/auth.tsx+101−2View fileUnifiedSplit
44
55import { Hono } from "hono";
66import { setCookie, deleteCookie, getCookie } from "hono/cookie";
7import { and, eq, isNull, sql } from "drizzle-orm";
7import { and, eq, gte, isNull, sql } from "drizzle-orm";
88import { db } from "../db";
99import {
1010 users,
1212 organizations,
1313 userTotp,
1414 userRecoveryCodes,
15 loginAttempts,
1516} from "../db/schema";
1617import {
1718 hashPassword,
2223} from "../lib/auth";
2324import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
2425import { cancelAccountDeletion } from "../lib/account-deletion";
26import { audit } from "../lib/notify";
2527import {
2628 getSsoConfig,
2729 getGithubOauthConfig,
562564 );
563565});
564566
567// ── Account lockout constants (SOC 2 CC6.1) ─────────────────────────────
568const LOGIN_FAIL_WINDOW_MS = 60 * 60 * 1000; // 1 hour
569const LOGIN_FAIL_LIMIT = 10;
570const LOGIN_LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
571
572/**
573 * Returns the number of failed login attempts for `email` in the last
574 * `LOGIN_FAIL_WINDOW_MS` milliseconds.
575 */
576async function countRecentFailures(email: string): Promise<number> {
577 const since = new Date(Date.now() - LOGIN_FAIL_WINDOW_MS);
578 const [row] = await db
579 .select({ count: sql<number>`count(*)::int` })
580 .from(loginAttempts)
581 .where(
582 and(
583 eq(loginAttempts.email, email.toLowerCase()),
584 eq(loginAttempts.success, false),
585 gte(loginAttempts.createdAt, since)
586 )
587 );
588 return row?.count ?? 0;
589}
590
565591auth.post("/login", async (c) => {
566592 const body = await c.req.parseBody();
567593 const identifier = String(body.username || "").trim();
568594 const password = String(body.password || "");
569595 const redirect = c.req.query("redirect") || "/";
596 const ip =
597 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
598 c.req.header("x-real-ip") ||
599 "unknown";
600 const ua = c.req.header("user-agent") || "";
570601
571602 if (!identifier || !password) {
572603 return c.redirect("/login?error=All+fields+are+required");
573604 }
574605
575 // Find user by username or email
606 // Resolve the canonical email for lockout checks regardless of whether
607 // the user typed username or email.
576608 const isEmail = identifier.includes("@");
577609 const [user] = await db
578610 .select()
584616 )
585617 .limit(1);
586618
619 // Determine the email key for lockout (use identifier if user not found
620 // so we still record the attempt without leaking account existence).
621 const emailKey = (user?.email ?? identifier).toLowerCase();
622
623 // ── Lockout check ───────────────────────────────────────────────────
624 // Check whether this email is currently locked out (≥ LOGIN_FAIL_LIMIT
625 // failures in the last LOGIN_FAIL_WINDOW_MS). We check before password
626 // verification so brute-forcers can't time-diff their way around it.
627 const recentFailures = await countRecentFailures(emailKey);
628 if (recentFailures >= LOGIN_FAIL_LIMIT) {
629 // Record that we blocked this attempt (success=false) so the window
630 // keeps rolling while the attacker keeps trying.
631 await db
632 .insert(loginAttempts)
633 .values({ email: emailKey, ip, success: false })
634 .catch(() => {});
635 await audit({
636 userId: user?.id ?? null,
637 action: "auth.login.locked",
638 ip,
639 userAgent: ua,
640 metadata: { email: emailKey, recentFailures },
641 });
642 return c.redirect(
643 "/login?error=Account+temporarily+locked+due+to+too+many+failed+login+attempts.+Please+try+again+in+15+minutes."
644 );
645 }
646
587647 if (!user) {
648 // Record failed attempt (unknown user) and return generic error.
649 await db
650 .insert(loginAttempts)
651 .values({ email: emailKey, ip, success: false })
652 .catch(() => {});
588653 return c.redirect("/login?error=Invalid+credentials");
589654 }
590655
591656 const valid = await verifyPassword(password, user.passwordHash);
592657 if (!valid) {
658 // Record failed attempt.
659 await db
660 .insert(loginAttempts)
661 .values({ email: emailKey, ip, success: false })
662 .catch(() => {});
663 await audit({
664 userId: user.id,
665 action: "auth.login.failed",
666 ip,
667 userAgent: ua,
668 metadata: { email: emailKey, attempt: recentFailures + 1 },
669 });
670 // Check if this failure just crossed the threshold.
671 if (recentFailures + 1 >= LOGIN_FAIL_LIMIT) {
672 await audit({
673 userId: user.id,
674 action: "auth.login.locked",
675 ip,
676 userAgent: ua,
677 metadata: { email: emailKey, recentFailures: recentFailures + 1 },
678 });
679 return c.redirect(
680 "/login?error=Account+temporarily+locked+due+to+too+many+failed+login+attempts.+Please+try+again+in+15+minutes."
681 );
682 }
593683 return c.redirect("/login?error=Invalid+credentials");
594684 }
595685
686 // Successful login — record success and clear old failure window.
687 await db
688 .insert(loginAttempts)
689 .values({ email: emailKey, ip, success: true })
690 .catch(() => {});
691
596692 // B4: if the user has TOTP enabled, issue a pending-2fa session and
597693 // redirect to the code prompt.
598694 const [totp] = await db
608704 token,
609705 expiresAt: sessionExpiry(),
610706 requires2fa: needs2fa,
707 ip,
708 userAgent: ua,
709 lastSeenAt: new Date(),
611710 });
612711
613712 setCookie(c, "session", token, sessionCookieOptions());
Addedsrc/routes/settings-sessions.tsx+271−0View fileUnifiedSplit
1/**
2 * Session management — SOC 2 CC6.1 session visibility.
3 *
4 * GET /settings/sessions — list all active sessions for the current user
5 * POST /settings/sessions/:id/revoke — delete a specific session
6 * POST /settings/sessions/revoke-all — delete all sessions except the current one
7 */
8
9import { Hono } from "hono";
10import { and, desc, eq, ne } from "drizzle-orm";
11import { getCookie, deleteCookie } from "hono/cookie";
12import { db } from "../db";
13import { sessions } from "../db/schema";
14import { requireAuth } from "../middleware/auth";
15import type { AuthEnv } from "../middleware/auth";
16import { Layout } from "../views/layout";
17
18const settingsSessions = new Hono<AuthEnv>();
19settingsSessions.use("/settings/sessions*", requireAuth);
20
21// ── Helper: parse a friendly device/browser hint from a user-agent string ──
22function parseBrowserHint(ua: string | null | undefined): string {
23 if (!ua) return "Unknown device";
24 const s = ua.toLowerCase();
25
26 // OS
27 let os = "";
28 if (s.includes("android")) os = "Android";
29 else if (s.includes("iphone") || s.includes("ipad")) os = "iOS";
30 else if (s.includes("mac os") || s.includes("macintosh")) os = "macOS";
31 else if (s.includes("windows")) os = "Windows";
32 else if (s.includes("linux")) os = "Linux";
33
34 // Browser
35 let browser = "";
36 if (s.includes("edg/") || s.includes("edge/")) browser = "Edge";
37 else if (s.includes("chrome/") && !s.includes("chromium")) browser = "Chrome";
38 else if (s.includes("firefox/")) browser = "Firefox";
39 else if (s.includes("safari/") && !s.includes("chrome")) browser = "Safari";
40 else if (s.includes("curl/")) browser = "curl";
41 else if (s.includes("postman")) browser = "Postman";
42
43 if (browser && os) return `${browser} on ${os}`;
44 if (browser) return browser;
45 if (os) return os;
46 // Fallback: first 60 chars of raw UA
47 return ua.slice(0, 60);
48}
49
50// ── Scoped styles ────────────────────────────────────────────────────────────
51const sessionsStyles = `
52 .sessions-page { max-width: 800px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
53 .sessions-hero {
54 position: relative;
55 margin-bottom: var(--space-6);
56 padding: var(--space-5) var(--space-6);
57 background: var(--bg-elevated);
58 border: 1px solid var(--border);
59 border-radius: 16px;
60 overflow: hidden;
61 }
62 .sessions-hero::before {
63 content: '';
64 position: absolute; top: 0; left: 0; right: 0; height: 2px;
65 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
66 opacity: 0.7; pointer-events: none;
67 }
68 .sessions-hero h1 { font-size: 1.5rem; font-weight: 700; margin: 0 0 4px; }
69 .sessions-hero p { color: var(--text-muted); margin: 0; font-size: 14px; }
70 .sessions-list { display: flex; flex-direction: column; gap: 12px; }
71 .session-card {
72 background: var(--bg-elevated);
73 border: 1px solid var(--border);
74 border-radius: 10px;
75 padding: var(--space-4) var(--space-5);
76 display: flex; align-items: center; gap: 16px;
77 }
78 .session-card.current { border-color: #8c6dff44; background: #8c6dff0a; }
79 .session-icon {
80 width: 40px; height: 40px; border-radius: 8px;
81 background: var(--bg-canvas); display: flex; align-items: center;
82 justify-content: center; flex-shrink: 0; font-size: 18px;
83 }
84 .session-info { flex: 1; min-width: 0; }
85 .session-device { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px; }
86 .session-current-badge {
87 font-size: 11px; font-weight: 600; padding: 2px 8px;
88 background: #8c6dff22; color: #a388ff; border-radius: 100px;
89 text-transform: uppercase; letter-spacing: 0.04em;
90 }
91 .session-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
92 .sessions-actions { margin-top: var(--space-6); padding-top: var(--space-4); border-top: 1px solid var(--border); }
93 .sessions-actions h3 { font-size: 14px; font-weight: 600; margin: 0 0 8px; }
94 .sessions-actions p { font-size: 13px; color: var(--text-muted); margin: 0 0 12px; }
95`;
96
97// ── GET /settings/sessions ───────────────────────────────────────────────────
98settingsSessions.get("/settings/sessions", async (c) => {
99 const user = c.get("user")!;
100 const currentToken = getCookie(c, "session") ?? "";
101
102 const allSessions = await db
103 .select()
104 .from(sessions)
105 .where(
106 and(
107 eq(sessions.userId, user.id),
108 // Only show non-expired sessions
109 )
110 )
111 .orderBy(desc(sessions.createdAt));
112
113 // Filter to non-expired
114 const now = new Date();
115 const activeSessions = allSessions.filter((s) => new Date(s.expiresAt) > now);
116
117 return c.html(
118 <Layout title="Active sessions" user={user}>
119 <style dangerouslySetInnerHTML={{ __html: sessionsStyles }} />
120 <div class="sessions-page">
121 <div class="sessions-hero">
122 <h1>Active sessions</h1>
123 <p>
124 These are the devices currently signed in to your account. Revoke
125 any session you don't recognise.
126 </p>
127 </div>
128
129 <div class="sessions-list">
130 {activeSessions.map((s) => {
131 const isCurrent = s.token === currentToken;
132 const deviceHint = parseBrowserHint(s.userAgent);
133 const lastSeen = s.lastSeenAt
134 ? new Date(s.lastSeenAt).toLocaleString("en-US", {
135 month: "short",
136 day: "numeric",
137 year: "numeric",
138 hour: "2-digit",
139 minute: "2-digit",
140 })
141 : "Unknown";
142 const created = new Date(s.createdAt).toLocaleString("en-US", {
143 month: "short",
144 day: "numeric",
145 year: "numeric",
146 });
147 const icon =
148 deviceHint.toLowerCase().includes("mobile") ||
149 deviceHint.toLowerCase().includes("android") ||
150 deviceHint.toLowerCase().includes("ios")
151 ? "📱"
152 : "💻";
153
154 return (
155 <div class={`session-card${isCurrent ? " current" : ""}`} key={s.id}>
156 <div class="session-icon">{icon}</div>
157 <div class="session-info">
158 <div class="session-device">
159 {deviceHint}
160 {isCurrent && (
161 <span class="session-current-badge">Current</span>
162 )}
163 </div>
164 <div class="session-meta">
165 {s.ip ? `IP: ${s.ip} · ` : ""}
166 Last active: {lastSeen} · Signed in: {created}
167 </div>
168 </div>
169 {!isCurrent && (
170 <form method="post" action={`/settings/sessions/${s.id}/revoke`}>
171 <input
172 type="hidden"
173 name="_csrf"
174 value={(c.get("csrfToken") as string | undefined) ?? ""}
175 />
176 <button
177 type="submit"
178 class="btn btn-sm"
179 style="color: var(--danger); border-color: var(--danger);"
180 >
181 Revoke
182 </button>
183 </form>
184 )}
185 </div>
186 );
187 })}
188 </div>
189
190 {activeSessions.length > 1 && (
191 <div class="sessions-actions">
192 <h3>Sign out everywhere else</h3>
193 <p>
194 This will revoke all sessions except your current one. Any device
195 signed in to your account will need to log in again.
196 </p>
197 <form method="post" action="/settings/sessions/revoke-all">
198 <input
199 type="hidden"
200 name="_csrf"
201 value={(c.get("csrfToken") as string | undefined) ?? ""}
202 />
203 <button
204 type="submit"
205 class="btn"
206 style="color: var(--danger); border-color: var(--danger);"
207 >
208 Revoke all other sessions
209 </button>
210 </form>
211 </div>
212 )}
213 </div>
214 </Layout>
215 );
216});
217
218// ── POST /settings/sessions/:id/revoke ───────────────────────────────────────
219settingsSessions.post("/settings/sessions/:id/revoke", async (c) => {
220 const user = c.get("user")!;
221 const { id } = c.req.param();
222 const currentToken = getCookie(c, "session") ?? "";
223
224 // Fetch the session to verify ownership and ensure it's not current.
225 const [target] = await db
226 .select()
227 .from(sessions)
228 .where(and(eq(sessions.id, id), eq(sessions.userId, user.id)))
229 .limit(1);
230
231 if (!target) {
232 return c.redirect("/settings/sessions?error=Session+not+found");
233 }
234 if (target.token === currentToken) {
235 return c.redirect("/settings/sessions?error=Cannot+revoke+your+current+session");
236 }
237
238 await db.delete(sessions).where(eq(sessions.id, id));
239 return c.redirect("/settings/sessions?success=Session+revoked");
240});
241
242// ── POST /settings/sessions/revoke-all ──────────────────────────────────────
243settingsSessions.post("/settings/sessions/revoke-all", async (c) => {
244 const user = c.get("user")!;
245 const currentToken = getCookie(c, "session") ?? "";
246
247 // Find and delete all sessions belonging to this user EXCEPT the current one.
248 const [currentSession] = await db
249 .select({ id: sessions.id })
250 .from(sessions)
251 .where(and(eq(sessions.userId, user.id), eq(sessions.token, currentToken)))
252 .limit(1);
253
254 if (currentSession) {
255 await db
256 .delete(sessions)
257 .where(
258 and(
259 eq(sessions.userId, user.id),
260 ne(sessions.id, currentSession.id)
261 )
262 );
263 } else {
264 // Fallback: delete all (this shouldn't happen in normal flow).
265 await db.delete(sessions).where(eq(sessions.userId, user.id));
266 }
267
268 return c.redirect("/settings/sessions?success=All+other+sessions+revoked");
269});
270
271export default settingsSessions;
0272