From 1581dccd97613d40b2fdb3c4011f271f4b2e283e Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Wed, 6 May 2026 07:31:30 +0000 Subject: [PATCH 1/7] feat(auth): add access store for user authorization state SQLite-backed store managing user_access table with status (approved/pending/revoked) and source tracking (domain/env/admin/request). Supports request, approve, deny, revoke, and auto-approve workflows. Part of #118 --- src/services/auth/access-store.ts | 117 ++++++++++++++++++++++++++++++ src/services/auth/index.ts | 9 ++- test/access-store.test.ts | 105 +++++++++++++++++++++++++++ 3 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 src/services/auth/access-store.ts create mode 100644 test/access-store.test.ts diff --git a/src/services/auth/access-store.ts b/src/services/auth/access-store.ts new file mode 100644 index 000000000..10b644009 --- /dev/null +++ b/src/services/auth/access-store.ts @@ -0,0 +1,117 @@ +import { Database } from 'bun:sqlite' + +export type AccessStatus = 'approved' | 'pending' | 'revoked' +export type AccessSource = 'domain' | 'env' | 'admin' | 'request' + +export interface AccessEntry { + login: string + status: AccessStatus + source: AccessSource + requestedAt: number | null + decidedAt: number | null + decidedBy: string | null +} + +export interface AccessStore { + get(login: string): AccessEntry | null + requestAccess(login: string): void + approve(login: string, decidedBy: string): void + deny(login: string, decidedBy: string): void + revoke(login: string, decidedBy: string): void + setApproved(login: string, source: AccessSource): void + listByStatus(status: AccessStatus): AccessEntry[] +} + +export function createAccessStore(dbPath: string): AccessStore { + const db = new Database(dbPath) + db.run('PRAGMA journal_mode = WAL') + db.run(` + CREATE TABLE IF NOT EXISTS user_access ( + login TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK (status IN ('approved', 'pending', 'revoked')), + source TEXT NOT NULL CHECK (source IN ('domain', 'env', 'admin', 'request')), + requested_at INTEGER, + decided_at INTEGER, + decided_by TEXT + ) + `) + + function rowToEntry(row: Record): AccessEntry { + return { + login: row.login as string, + status: row.status as AccessStatus, + source: row.source as AccessSource, + requestedAt: (row.requested_at as number) ?? null, + decidedAt: (row.decided_at as number) ?? null, + decidedBy: (row.decided_by as string) ?? null, + } + } + + return { + get(login: string): AccessEntry | null { + const row = db + .query( + 'SELECT login, status, source, requested_at, decided_at, decided_by FROM user_access WHERE login = ?', + ) + .get(login) as Record | null + if (!row) return null + return rowToEntry(row) + }, + + requestAccess(login: string): void { + const now = Math.floor(Date.now() / 1000) + db.run( + `INSERT INTO user_access (login, status, source, requested_at) + VALUES (?, 'pending', 'request', ?) + ON CONFLICT(login) DO NOTHING`, + [login, now], + ) + }, + + approve(login: string, decidedBy: string): void { + const now = Math.floor(Date.now() / 1000) + db.run( + `UPDATE user_access SET status = 'approved', decided_at = ?, decided_by = ? WHERE login = ?`, + [now, decidedBy, login], + ) + }, + + deny(login: string, decidedBy: string): void { + const now = Math.floor(Date.now() / 1000) + db.run( + `UPDATE user_access SET status = 'revoked', decided_at = ?, decided_by = ? WHERE login = ?`, + [now, decidedBy, login], + ) + }, + + revoke(login: string, decidedBy: string): void { + const now = Math.floor(Date.now() / 1000) + db.run( + `UPDATE user_access SET status = 'revoked', decided_at = ?, decided_by = ? WHERE login = ?`, + [now, decidedBy, login], + ) + }, + + setApproved(login: string, source: AccessSource): void { + const now = Math.floor(Date.now() / 1000) + db.run( + `INSERT INTO user_access (login, status, source, decided_at) + VALUES (?, 'approved', ?, ?) + ON CONFLICT(login) DO UPDATE SET + status = 'approved', + source = CASE WHEN user_access.status = 'approved' THEN user_access.source ELSE excluded.source END, + decided_at = CASE WHEN user_access.status = 'approved' THEN user_access.decided_at ELSE excluded.decided_at END`, + [login, source, now], + ) + }, + + listByStatus(status: AccessStatus): AccessEntry[] { + const rows = db + .query( + 'SELECT login, status, source, requested_at, decided_at, decided_by FROM user_access WHERE status = ? ORDER BY requested_at DESC, decided_at DESC', + ) + .all(status) as Record[] + return rows.map(rowToEntry) + }, + } +} diff --git a/src/services/auth/index.ts b/src/services/auth/index.ts index f82243e9e..b3ede8e5a 100644 --- a/src/services/auth/index.ts +++ b/src/services/auth/index.ts @@ -2,6 +2,13 @@ // External imports (other services, entrypoints, design-system) MUST come // through this file. Enforced by test/architecture/dependency-rule.test.ts. +export type { + AccessEntry, + AccessSource, + AccessStatus, + AccessStore, +} from './access-store' +export { createAccessStore } from './access-store' export { checkOrgMembership, checkRepoPermission, @@ -12,14 +19,12 @@ export { type GitHubUser, hasAllowedEmailDomain, } from './github-oauth' - export { COOKIE_MAX_AGE, COOKIE_NAME, decryptSession, encryptSession, } from './session' - export type { SessionUser } from './types' export type { UserStore } from './user-store' export { createUserStore } from './user-store' diff --git a/test/access-store.test.ts b/test/access-store.test.ts new file mode 100644 index 000000000..334453c67 --- /dev/null +++ b/test/access-store.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { unlinkSync } from 'node:fs' +import { createAccessStore } from '../src/services/auth' + +const TEST_DB = '/tmp/test-access-store.sqlite' + +function cleanup() { + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(`${TEST_DB}${suffix}`) + } catch { + /* ignore */ + } + } +} + +describe('AccessStore', () => { + afterEach(cleanup) + + it('returns null for unknown user', () => { + const store = createAccessStore(TEST_DB) + expect(store.get('unknown')).toBeNull() + }) + + it('sets a user to pending via requestAccess', () => { + const store = createAccessStore(TEST_DB) + store.requestAccess('octocat') + const entry = store.get('octocat') + expect(entry).not.toBeNull() + expect(entry?.status).toBe('pending') + expect(entry?.source).toBe('request') + expect(entry?.requestedAt).toBeGreaterThan(0) + expect(entry?.decidedAt).toBeNull() + expect(entry?.decidedBy).toBeNull() + }) + + it('approves a pending user', () => { + const store = createAccessStore(TEST_DB) + store.requestAccess('octocat') + store.approve('octocat', 'admin1') + const entry = store.get('octocat') + expect(entry?.status).toBe('approved') + expect(entry?.source).toBe('request') + expect(entry?.decidedBy).toBe('admin1') + expect(entry?.decidedAt).toBeGreaterThan(0) + }) + + it('revokes an approved user', () => { + const store = createAccessStore(TEST_DB) + store.setApproved('octocat', 'admin') + store.revoke('octocat', 'admin1') + const entry = store.get('octocat') + expect(entry?.status).toBe('revoked') + expect(entry?.decidedBy).toBe('admin1') + }) + + it('denies a pending user', () => { + const store = createAccessStore(TEST_DB) + store.requestAccess('octocat') + store.deny('octocat', 'admin1') + const entry = store.get('octocat') + expect(entry?.status).toBe('revoked') + expect(entry?.decidedBy).toBe('admin1') + }) + + it('sets a user as approved with a given source', () => { + const store = createAccessStore(TEST_DB) + store.setApproved('octocat', 'domain') + const entry = store.get('octocat') + expect(entry?.status).toBe('approved') + expect(entry?.source).toBe('domain') + }) + + it('does not downgrade an approved user on setApproved with same source', () => { + const store = createAccessStore(TEST_DB) + store.setApproved('octocat', 'admin') + store.setApproved('octocat', 'domain') + const entry = store.get('octocat') + expect(entry?.status).toBe('approved') + expect(entry?.source).toBe('admin') + }) + + it('lists users by status', () => { + const store = createAccessStore(TEST_DB) + store.requestAccess('pending1') + store.requestAccess('pending2') + store.setApproved('approved1', 'domain') + store.setApproved('approved2', 'admin') + + const pending = store.listByStatus('pending') + expect(pending).toHaveLength(2) + + const approved = store.listByStatus('approved') + expect(approved).toHaveLength(2) + }) + + it('requestAccess is idempotent for pending users', () => { + const store = createAccessStore(TEST_DB) + store.requestAccess('octocat') + const first = store.get('octocat') + store.requestAccess('octocat') + const second = store.get('octocat') + expect(second?.requestedAt).toBe(first?.requestedAt) + }) +}) From 00192383173dcc8602f454a0b4cfb74799b387cb Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Wed, 6 May 2026 07:38:20 +0000 Subject: [PATCH 2/7] feat(auth): add requireAdmin middleware and access check in requireAuth requireAdmin gates routes to ADMIN_USERS env var (defaults to danielnaab). requireAuth now optionally accepts an AccessStore and rejects revoked users by clearing their session cookie. Part of #118 --- src/entrypoints/app/middleware/auth.ts | 35 ++++- test/auth-middleware.test.ts | 202 ++++++++++++++++++++++++- 2 files changed, 233 insertions(+), 4 deletions(-) diff --git a/src/entrypoints/app/middleware/auth.ts b/src/entrypoints/app/middleware/auth.ts index babe40c73..a584fcd12 100644 --- a/src/entrypoints/app/middleware/auth.ts +++ b/src/entrypoints/app/middleware/auth.ts @@ -1,6 +1,7 @@ -import { getCookie } from 'hono/cookie' +import { deleteCookie, getCookie } from 'hono/cookie' import { createMiddleware } from 'hono/factory' import { + type AccessStore, COOKIE_NAME, decryptSession, type SessionUser, @@ -35,7 +36,7 @@ export function sessionReader() { }) } -export function requireAuth() { +export function requireAuth(accessStore?: AccessStore) { return createMiddleware(async (c, next) => { const user = c.get('user') if (!user) { @@ -45,6 +46,36 @@ export function requireAuth() { const returnTo = encodeURIComponent(fullPath) return c.redirect(`${resolveUrl('/auth/signin')}?returnTo=${returnTo}`) } + + if (accessStore) { + const entry = accessStore.get(user.login) + if (entry && entry.status !== 'approved') { + deleteCookie(c, COOKIE_NAME) + const returnTo = encodeURIComponent(c.req.path) + return c.redirect(`${resolveUrl('/auth/signin')}?returnTo=${returnTo}`) + } + } + + await next() + }) +} + +export function requireAdmin() { + return createMiddleware(async (c, next) => { + const user = c.get('user') + if (!user) { + return c.text('Forbidden', 403) + } + + const adminUsers = (process.env.ADMIN_USERS ?? 'danielnaab') + .split(',') + .map((u) => u.trim()) + .filter(Boolean) + + if (!adminUsers.includes(user.login)) { + return c.text('Forbidden', 403) + } + await next() }) } diff --git a/test/auth-middleware.test.ts b/test/auth-middleware.test.ts index 96318586c..1cb29f1f5 100644 --- a/test/auth-middleware.test.ts +++ b/test/auth-middleware.test.ts @@ -1,10 +1,26 @@ -import { describe, expect, it } from 'bun:test' +import { afterEach, describe, expect, it } from 'bun:test' +import { unlinkSync } from 'node:fs' import { Hono } from 'hono' import { + requireAdmin, requireAuth, sessionReader, } from '../src/entrypoints/app/middleware/auth' -import { COOKIE_NAME, encryptSession } from '../src/services/auth' +import { + COOKIE_NAME, + createAccessStore, + encryptSession, +} from '../src/services/auth' + +const TEST_DB = '/tmp/test-auth-middleware-access.sqlite' + +afterEach(() => { + try { + unlinkSync(TEST_DB) + } catch { + // file may not exist, ignore + } +}) describe('Auth Middleware', () => { describe('sessionReader', () => { @@ -142,4 +158,186 @@ describe('Auth Middleware', () => { ) }) }) + + describe('requireAuth with access store', () => { + it('clears session and redirects when user access is revoked', async () => { + const app = new Hono() + const secret = 'test-secret-key-32-bytes-long!' + process.env.SESSION_SECRET = secret + + const store = createAccessStore(TEST_DB) + store.requestAccess('revokeduser') + store.approve('revokeduser', 'admin') + store.revoke('revokeduser', 'admin') + + app.use('*', sessionReader()) + app.use('/protected', requireAuth(store)) + app.get('/protected', (c) => c.text('success')) + + const sessionCookie = await encryptSession( + { + login: 'revokeduser', + name: 'Revoked User', + avatarUrl: 'https://example.com/avatar.png', + }, + secret, + ) + + const res = await app.request('/protected', { + headers: { + Cookie: `${COOKIE_NAME}=${sessionCookie}`, + }, + }) + + expect(res.status).toBe(302) + expect(res.headers.get('Location')).toContain('/auth/signin') + }) + + it('allows access when user is approved', async () => { + const app = new Hono() + const secret = 'test-secret-key-32-bytes-long!' + process.env.SESSION_SECRET = secret + + const store = createAccessStore(TEST_DB) + store.setApproved('approveduser', 'domain') + + app.use('*', sessionReader()) + app.use('/protected', requireAuth(store)) + app.get('/protected', (c) => c.text('success')) + + const sessionCookie = await encryptSession( + { + login: 'approveduser', + name: 'Approved User', + avatarUrl: 'https://example.com/avatar.png', + }, + secret, + ) + + const res = await app.request('/protected', { + headers: { + Cookie: `${COOKIE_NAME}=${sessionCookie}`, + }, + }) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('success') + }) + + it('still works without access store (backward compat)', async () => { + const app = new Hono() + const secret = 'test-secret-key-32-bytes-long!' + process.env.SESSION_SECRET = secret + + app.use('*', sessionReader()) + app.use('/protected', requireAuth()) + app.get('/protected', (c) => c.text('success')) + + const sessionCookie = await encryptSession( + { + login: 'anyuser', + name: 'Any User', + avatarUrl: 'https://example.com/avatar.png', + }, + secret, + ) + + const res = await app.request('/protected', { + headers: { + Cookie: `${COOKIE_NAME}=${sessionCookie}`, + }, + }) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('success') + }) + }) + + describe('requireAdmin', () => { + it('allows access for admin users', async () => { + const app = new Hono() + const secret = 'test-secret-key-32-bytes-long!' + process.env.SESSION_SECRET = secret + process.env.ADMIN_USERS = 'adminuser' + + app.use('*', sessionReader()) + app.use('/admin', requireAdmin()) + app.get('/admin', (c) => c.text('admin access')) + + const sessionCookie = await encryptSession( + { + login: 'adminuser', + name: 'Admin User', + avatarUrl: 'https://example.com/avatar.png', + }, + secret, + ) + + const res = await app.request('/admin', { + headers: { + Cookie: `${COOKIE_NAME}=${sessionCookie}`, + }, + }) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('admin access') + }) + + it('returns 403 for non-admin users', async () => { + const app = new Hono() + const secret = 'test-secret-key-32-bytes-long!' + process.env.SESSION_SECRET = secret + process.env.ADMIN_USERS = 'adminuser' + + app.use('*', sessionReader()) + app.use('/admin', requireAdmin()) + app.get('/admin', (c) => c.text('admin access')) + + const sessionCookie = await encryptSession( + { + login: 'regularuser', + name: 'Regular User', + avatarUrl: 'https://example.com/avatar.png', + }, + secret, + ) + + const res = await app.request('/admin', { + headers: { + Cookie: `${COOKIE_NAME}=${sessionCookie}`, + }, + }) + + expect(res.status).toBe(403) + }) + + it('defaults ADMIN_USERS to danielnaab', async () => { + const app = new Hono() + const secret = 'test-secret-key-32-bytes-long!' + process.env.SESSION_SECRET = secret + delete process.env.ADMIN_USERS + + app.use('*', sessionReader()) + app.use('/admin', requireAdmin()) + app.get('/admin', (c) => c.text('admin access')) + + const sessionCookie = await encryptSession( + { + login: 'danielnaab', + name: 'Daniel Naab', + avatarUrl: 'https://example.com/avatar.png', + }, + secret, + ) + + const res = await app.request('/admin', { + headers: { + Cookie: `${COOKIE_NAME}=${sessionCookie}`, + }, + }) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('admin access') + }) + }) }) From cd1e7c80519d6593060facc57e69827db76e2564 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Wed, 6 May 2026 07:45:30 +0000 Subject: [PATCH 3/7] feat(auth): three-layer authorization with request/approval workflow OAuth callback now checks: (1) ALLOWED_USERS env var, (2) flexion.us domain bypass (hardcoded), (3) user_access database table. Unknown external users are redirected to request access. Pending and revoked users see appropriate status pages. Part of #118 --- src/entrypoints/app/routes/auth/index.ts | 172 +++++++++++++++----- src/entrypoints/app/server.tsx | 5 +- test/access-control.test.ts | 191 +++++++++++++++++++++++ test/auth-routes.test.ts | 23 ++- 4 files changed, 347 insertions(+), 44 deletions(-) create mode 100644 test/access-control.test.ts diff --git a/src/entrypoints/app/routes/auth/index.ts b/src/entrypoints/app/routes/auth/index.ts index 323ffd7fc..60e2dc153 100644 --- a/src/entrypoints/app/routes/auth/index.ts +++ b/src/entrypoints/app/routes/auth/index.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono' import { deleteCookie, setCookie } from 'hono/cookie' -import type { UserStore } from '../../../../services/auth' +import type { AccessStore, UserStore } from '../../../../services/auth' import { COOKIE_MAX_AGE, COOKIE_NAME, @@ -12,7 +12,10 @@ import { } from '../../../../services/auth' import { resolveUrl } from '../../../../shared/base-path' -export function createAuthRoutes(userStore: UserStore): Hono { +export function createAuthRoutes( + userStore: UserStore, + accessStore: AccessStore, +): Hono { const auth = new Hono() /** @@ -103,51 +106,77 @@ export function createAuthRoutes(userStore: UserStore): Hono { // Fetch user profile const ghUser = await fetchUserProfile(token) - // Authorization: user passes if their GitHub login is on the - // ALLOWED_USERS list OR any of their verified emails matches a - // domain on ALLOWED_EMAIL_DOMAINS. Either mechanism alone is - // sufficient; both are evaluated so a personal-login dev can - // still get in on an instance with a strict corporate domain. + // --- Three-layer authorization --- + // Layer 1: env var bypass const allowedUsers = (process.env.ALLOWED_USERS ?? 'danielnaab') .split(',') .map((u) => u.trim()) .filter(Boolean) - const allowedDomains = (process.env.ALLOWED_EMAIL_DOMAINS ?? '') - .split(',') - .map((d) => d.trim()) - .filter(Boolean) - let authorised = allowedUsers.includes(ghUser.login) - let emailMatchDomain: string | null = null + if (allowedUsers.includes(ghUser.login)) { + // Env-var user: auto-approve and record + accessStore.setApproved(ghUser.login, 'env') + } else { + // Layer 2: domain bypass + const allowedDomains = ( + process.env.ALLOWED_EMAIL_DOMAINS ?? 'flexion.us' + ) + .split(',') + .map((d) => d.trim()) + .filter(Boolean) + // Always include flexion.us as a hardcoded domain bypass + if ( + !allowedDomains.map((d) => d.toLowerCase()).includes('flexion.us') + ) { + allowedDomains.push('flexion.us') + } - if (!authorised && allowedDomains.length > 0) { const emails = await fetchUserEmails(token) if (hasAllowedEmailDomain(emails, allowedDomains)) { - authorised = true - emailMatchDomain = - emails.find((e) => { - const at = e.email.lastIndexOf('@') - if (at === -1 || !e.verified) return false - const domain = e.email.slice(at + 1).toLowerCase() - return allowedDomains.map((d) => d.toLowerCase()).includes(domain) - })?.email ?? null - } - } + accessStore.setApproved(ghUser.login, 'domain') + } else { + // Layer 3: database lookup + const entry = accessStore.get(ghUser.login) - if (!authorised) { - console.log( - `Authorization failed for user: ${ghUser.login} (allowlist=${allowedUsers.length} users, ${allowedDomains.length} domains)`, - ) - return c.redirect(resolveUrl('/?error=unauthorized')) - } + if (!entry) { + // New external user — persist profile, set session so + // request-access page knows who they are, redirect + userStore.upsert({ + login: ghUser.login, + name: ghUser.name ?? ghUser.login, + avatarUrl: ghUser.avatar_url, + }) + const sessionData = { + login: ghUser.login, + name: ghUser.name ?? ghUser.login, + avatarUrl: ghUser.avatar_url, + } + const encryptedSession = await encryptSession( + sessionData, + sessionSecret, + ) + setCookie(c, COOKIE_NAME, encryptedSession, { + httpOnly: true, + sameSite: 'Lax', + maxAge: COOKIE_MAX_AGE, + path: '/', + }) + return c.redirect(resolveUrl('/auth/request-access')) + } - if (emailMatchDomain) { - const domain = emailMatchDomain.slice( - emailMatchDomain.lastIndexOf('@') + 1, - ) - console.log( - `Authorized ${ghUser.login} via email domain match: @${domain}`, - ) + if (entry.status === 'pending') { + return c.redirect(resolveUrl('/auth/access-pending')) + } + + if (entry.status === 'revoked') { + console.log( + `Authorization denied for revoked user: ${ghUser.login}`, + ) + return c.redirect(resolveUrl('/auth/access-denied')) + } + + // entry.status === 'approved' — fall through to session creation + } } // Persist user profile @@ -193,5 +222,74 @@ export function createAuthRoutes(userStore: UserStore): Hono { return c.redirect(resolveUrl('/')) }) + // GET /auth/request-access — shows "request access" page + auth.get('/request-access', (c) => { + const user = c.get('user') + return c.html( + ` + Request Access + +

Request Access to Forms Lab

+ ${user ? `

Signed in as @${user.login}

` : ''} +
+ +
+ `, + ) + }) + + // POST /auth/request-access — records the request + auth.post('/request-access', async (c) => { + const user = c.get('user') + if (!user) { + return c.redirect(resolveUrl('/auth/signin')) + } + accessStore.requestAccess(user.login) + + // Fire notification (best-effort) + try { + const { notifyEvent } = await import('../../../../services/notifications') + await notifyEvent({ + type: 'access.requested', + title: `Access requested by @${user.login}`, + status: 'info', + details: `${user.name} (${user.login}) requested access to Forms Lab`, + url: 'https://forms.labs.flexion.us/admin/users', + }) + } catch { + // Notification failure should not block the request + } + + // Clear the temporary session — they don't have access yet + deleteCookie(c, COOKIE_NAME) + return c.redirect(resolveUrl('/auth/access-pending')) + }) + + // GET /auth/access-pending + auth.get('/access-pending', (c) => { + return c.html( + ` + Access Pending + +

Access Request Pending

+

Your request is being reviewed. You'll be notified when approved.

+

Back to home

+ `, + ) + }) + + // GET /auth/access-denied + auth.get('/access-denied', (c) => { + return c.html( + ` + Access Denied + +

Access Denied

+

Your access has been revoked. Contact an administrator for assistance.

+

Back to home

+ `, + ) + }) + return auth } diff --git a/src/entrypoints/app/server.tsx b/src/entrypoints/app/server.tsx index f25c45627..0e8ded44b 100644 --- a/src/entrypoints/app/server.tsx +++ b/src/entrypoints/app/server.tsx @@ -8,7 +8,7 @@ import { loadFixturePdf, } from '../../../fixtures/index' import { Layout } from '../../design-system/components/flex-layout' -import { createUserStore } from '../../services/auth' +import { createAccessStore, createUserStore } from '../../services/auth' import type { DataCollectionSpec } from '../../services/data-collection' import { createExtractorRegistry } from '../../services/extraction' import { @@ -73,6 +73,7 @@ mkdirSync(reposPath, { recursive: true }) const projectStore = createProjectStore(projectDbPath) const cacheStore = createCacheStore(cacheDbPath) const userStore = createUserStore(projectDbPath) +const accessStore = createAccessStore(projectDbPath) const formProjectRepo = createFormProjectRepo(reposPath) // Variant registries: one per task. Each user's preferred variant is @@ -286,7 +287,7 @@ app.use( ) // Mount auth routes -app.route('/auth', createAuthRoutes(userStore)) +app.route('/auth', createAuthRoutes(userStore, accessStore)) // Mount settings routes (variant picker) app.route( diff --git a/test/access-control.test.ts b/test/access-control.test.ts new file mode 100644 index 000000000..342c413d5 --- /dev/null +++ b/test/access-control.test.ts @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test' +import { unlinkSync } from 'node:fs' +import { Hono } from 'hono' +import { sessionReader } from '../src/entrypoints/app/middleware/auth' +import { createAuthRoutes } from '../src/entrypoints/app/routes/auth/index' +import { + type AccessStore, + createAccessStore, + createUserStore, +} from '../src/services/auth' + +const TEST_DB = '/tmp/test-access-control.sqlite' + +function cleanup() { + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(`${TEST_DB}${suffix}`) + } catch { + /* ignore */ + } + } +} + +function buildApp(accessStore: AccessStore) { + const userStore = createUserStore(TEST_DB) + const app = new Hono() + process.env.SESSION_SECRET = 'test-secret-key-32-bytes-long!' + process.env.GITHUB_CLIENT_ID = 'test-client-id' + process.env.GITHUB_CLIENT_SECRET = 'test-client-secret' + app.use('*', sessionReader()) + app.route('/auth', createAuthRoutes(userStore, accessStore)) + return app +} + +function mockGitHubApis( + login: string, + emails: Array<{ email: string; verified: boolean }>, +) { + const responses: Response[] = [ + new Response(JSON.stringify({ access_token: 'gho_test' }), { status: 200 }), + new Response( + JSON.stringify({ + login, + name: login, + avatar_url: 'https://example.com/a.png', + }), + { status: 200 }, + ), + new Response( + JSON.stringify( + emails.map((e) => ({ + ...e, + primary: true, + visibility: null, + })), + ), + { status: 200 }, + ), + ] + let callIndex = 0 + // biome-ignore lint/suspicious/noExplicitAny: mock signature + global.fetch = mock(() => Promise.resolve(responses[callIndex++])) as any +} + +describe('OAuth callback authorization', () => { + afterEach(() => { + cleanup() + delete process.env.ALLOWED_USERS + delete process.env.ALLOWED_EMAIL_DOMAINS + }) + + it('auto-approves user in ALLOWED_USERS and records in access store', async () => { + const accessStore = createAccessStore(TEST_DB) + const app = buildApp(accessStore) + process.env.ALLOWED_USERS = 'trusteduser' + process.env.ALLOWED_EMAIL_DOMAINS = '' + + mockGitHubApis('trusteduser', []) + + const state = JSON.stringify({ returnTo: '/' }) + const res = await app.request( + `/auth/callback?code=test&state=${encodeURIComponent(state)}`, + ) + + expect(res.status).toBe(302) + expect(res.headers.get('Location')).toBe('/') + + const entry = accessStore.get('trusteduser') + expect(entry?.status).toBe('approved') + expect(entry?.source).toBe('env') + }) + + it('auto-approves user with flexion.us email and records in access store', async () => { + const accessStore = createAccessStore(TEST_DB) + const app = buildApp(accessStore) + process.env.ALLOWED_USERS = '' + process.env.ALLOWED_EMAIL_DOMAINS = 'flexion.us' + + mockGitHubApis('flexionuser', [ + { email: 'user@flexion.us', verified: true }, + ]) + + const state = JSON.stringify({ returnTo: '/' }) + const res = await app.request( + `/auth/callback?code=test&state=${encodeURIComponent(state)}`, + ) + + expect(res.status).toBe(302) + expect(res.headers.get('Location')).toBe('/') + + const entry = accessStore.get('flexionuser') + expect(entry?.status).toBe('approved') + expect(entry?.source).toBe('domain') + }) + + it('redirects to request-access for unknown external user', async () => { + const accessStore = createAccessStore(TEST_DB) + const app = buildApp(accessStore) + process.env.ALLOWED_USERS = '' + process.env.ALLOWED_EMAIL_DOMAINS = 'flexion.us' + + mockGitHubApis('outsider', [ + { email: 'outsider@gmail.com', verified: true }, + ]) + + const state = JSON.stringify({ returnTo: '/' }) + const res = await app.request( + `/auth/callback?code=test&state=${encodeURIComponent(state)}`, + ) + + expect(res.status).toBe(302) + expect(res.headers.get('Location')).toContain('/auth/request-access') + }) + + it('redirects to access-pending for user with pending status', async () => { + const accessStore = createAccessStore(TEST_DB) + accessStore.requestAccess('pendinguser') + const app = buildApp(accessStore) + process.env.ALLOWED_USERS = '' + process.env.ALLOWED_EMAIL_DOMAINS = 'flexion.us' + + mockGitHubApis('pendinguser', [ + { email: 'pending@gmail.com', verified: true }, + ]) + + const state = JSON.stringify({ returnTo: '/' }) + const res = await app.request( + `/auth/callback?code=test&state=${encodeURIComponent(state)}`, + ) + + expect(res.status).toBe(302) + expect(res.headers.get('Location')).toContain('/auth/access-pending') + }) + + it('redirects to access-denied for revoked user', async () => { + const accessStore = createAccessStore(TEST_DB) + accessStore.setApproved('baduser', 'admin') + accessStore.revoke('baduser', 'admin1') + const app = buildApp(accessStore) + process.env.ALLOWED_USERS = '' + process.env.ALLOWED_EMAIL_DOMAINS = 'flexion.us' + + mockGitHubApis('baduser', [{ email: 'bad@gmail.com', verified: true }]) + + const state = JSON.stringify({ returnTo: '/' }) + const res = await app.request( + `/auth/callback?code=test&state=${encodeURIComponent(state)}`, + ) + + expect(res.status).toBe(302) + expect(res.headers.get('Location')).toContain('/auth/access-denied') + }) + + it('allows previously-approved external user to sign in', async () => { + const accessStore = createAccessStore(TEST_DB) + accessStore.setApproved('approvedext', 'admin') + const app = buildApp(accessStore) + process.env.ALLOWED_USERS = '' + process.env.ALLOWED_EMAIL_DOMAINS = 'flexion.us' + + mockGitHubApis('approvedext', [{ email: 'ext@gmail.com', verified: true }]) + + const state = JSON.stringify({ returnTo: '/' }) + const res = await app.request( + `/auth/callback?code=test&state=${encodeURIComponent(state)}`, + ) + + expect(res.status).toBe(302) + expect(res.headers.get('Location')).toBe('/') + }) +}) diff --git a/test/auth-routes.test.ts b/test/auth-routes.test.ts index 760cb48de..54cbba214 100644 --- a/test/auth-routes.test.ts +++ b/test/auth-routes.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' import { Hono } from 'hono' import { sessionReader } from '../src/entrypoints/app/middleware/auth' import { createAuthRoutes } from '../src/entrypoints/app/routes/auth' -import type { GitHubUser, UserStore } from '../src/services/auth' +import type { AccessStore, GitHubUser, UserStore } from '../src/services/auth' import { COOKIE_NAME } from '../src/services/auth' describe('Auth Routes', () => { @@ -10,6 +10,7 @@ describe('Auth Routes', () => { let originalEnv: NodeJS.ProcessEnv let originalFetch: typeof global.fetch let mockUserStore: UserStore + let mockAccessStore: AccessStore beforeEach(() => { // Save original environment and fetch @@ -29,10 +30,21 @@ describe('Auth Routes', () => { exists: mock(() => false), } + // Create mock AccessStore + mockAccessStore = { + get: mock(() => null), + requestAccess: mock(() => {}), + approve: mock(() => {}), + deny: mock(() => {}), + revoke: mock(() => {}), + setApproved: mock(() => {}), + listByStatus: mock(() => []), + } + // Create app app = new Hono() app.use('*', sessionReader()) - app.route('/auth', createAuthRoutes(mockUserStore)) + app.route('/auth', createAuthRoutes(mockUserStore, mockAccessStore)) }) afterEach(() => { @@ -132,7 +144,7 @@ describe('Auth Routes', () => { }) }) - it('redirects to home with error for unauthorized user', async () => { + it('redirects to request-access for unauthorized user', async () => { const mockUser: GitHubUser = { login: 'unauthorized', name: 'Unauthorized User', @@ -172,10 +184,11 @@ describe('Auth Routes', () => { ) expect(res.status).toBe(302) - expect(res.headers.get('Location')).toBe('/?error=unauthorized') + expect(res.headers.get('Location')).toBe('/auth/request-access') + // User gets a temporary session so request-access page knows who they are const cookie = res.headers.get('Set-Cookie') - expect(cookie).toBeNull() + expect(cookie).toContain(COOKIE_NAME) }) it('redirects to root when returnTo is not specified', async () => { From a0c423dbeeb01544dbef79c9ed93b044ae35285a Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Wed, 6 May 2026 07:49:41 +0000 Subject: [PATCH 4/7] feat(auth): add admin user management routes GET /admin/users shows pending, approved, and revoked users. POST actions for approve, deny, revoke, and add. Protected by requireAdmin middleware (ADMIN_USERS env var). Part of #118 --- src/entrypoints/app/routes/admin/index.ts | 182 ++++++++++++++++++++++ test/admin-routes.test.ts | 154 ++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 src/entrypoints/app/routes/admin/index.ts create mode 100644 test/admin-routes.test.ts diff --git a/src/entrypoints/app/routes/admin/index.ts b/src/entrypoints/app/routes/admin/index.ts new file mode 100644 index 000000000..613548e7a --- /dev/null +++ b/src/entrypoints/app/routes/admin/index.ts @@ -0,0 +1,182 @@ +import { Hono } from 'hono' +import type { AccessStore, UserStore } from '../../../../services/auth' +import { resolveUrl } from '../../../../shared/base-path' +import { requireAdmin } from '../../middleware/auth' + +export function createAdminRoutes( + accessStore: AccessStore, + userStore: UserStore, +): Hono { + const admin = new Hono() + + // All admin routes require admin + admin.use('*', requireAdmin()) + + // GET /admin/users — admin dashboard + admin.get('/users', (c) => { + const pending = accessStore.listByStatus('pending') + const approved = accessStore.listByStatus('approved') + const revoked = accessStore.listByStatus('revoked') + + const enriched = (entries: typeof pending) => + entries.map((entry) => { + const profile = userStore.get(entry.login) + return { + ...entry, + name: profile?.name ?? entry.login, + avatarUrl: profile?.avatarUrl ?? null, + } + }) + + const pendingUsers = enriched(pending) + const approvedUsers = enriched(approved) + const revokedUsers = enriched(revoked) + + // Raw HTML for now — Task 5 will replace with JSX components + return c.html( + ` + User Management + +

User Management

+ +

Pending Requests (${pendingUsers.length})

+ ${ + pendingUsers.length === 0 + ? '

No pending requests.

' + : ` + + + + ${pendingUsers + .map( + (u) => ` + + + + + + `, + ) + .join('')} + +
UserRequestedActions
${u.name} (@${u.login})${u.requestedAt ? new Date(u.requestedAt * 1000).toLocaleDateString() : '—'} +
+ + +
+
+ + +
+
` + } + +

Approved Users (${approvedUsers.length})

+ ${ + approvedUsers.length === 0 + ? '

No approved users.

' + : ` + + + + ${approvedUsers + .map( + (u) => ` + + + + + + `, + ) + .join('')} + +
UserSourceActions
${u.name} (@${u.login})${u.source} + ${ + u.source === 'env' + ? 'env-managed' + : ` +
+ + +
` + } +
` + } + +

Add User

+
+ + +
+ + ${ + revokedUsers.length > 0 + ? ` +

Revoked (${revokedUsers.length})

+ + + + ${revokedUsers + .map( + (u) => ` + + + + + `, + ) + .join('')} + +
UserRevoked by
${u.name} (@${u.login})${u.decidedBy ?? '—'}
` + : '' + } + `, + ) + }) + + // POST /admin/users/approve + admin.post('/users/approve', async (c) => { + const body = await c.req.parseBody() + const login = String(body.login ?? '').trim() + const user = c.get('user') + if (login && user) { + accessStore.approve(login, user.login) + } + return c.redirect(resolveUrl('/admin/users')) + }) + + // POST /admin/users/deny + admin.post('/users/deny', async (c) => { + const body = await c.req.parseBody() + const login = String(body.login ?? '').trim() + const user = c.get('user') + if (login && user) { + accessStore.deny(login, user.login) + } + return c.redirect(resolveUrl('/admin/users')) + }) + + // POST /admin/users/revoke + admin.post('/users/revoke', async (c) => { + const body = await c.req.parseBody() + const login = String(body.login ?? '').trim() + const user = c.get('user') + if (login && user) { + accessStore.revoke(login, user.login) + } + return c.redirect(resolveUrl('/admin/users')) + }) + + // POST /admin/users/add + admin.post('/users/add', async (c) => { + const body = await c.req.parseBody() + const login = String(body.login ?? '').trim() + if (login) { + accessStore.setApproved(login, 'admin') + } + return c.redirect(resolveUrl('/admin/users')) + }) + + return admin +} diff --git a/test/admin-routes.test.ts b/test/admin-routes.test.ts new file mode 100644 index 000000000..279bf85f7 --- /dev/null +++ b/test/admin-routes.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { unlinkSync } from 'node:fs' +import { Hono } from 'hono' +import { sessionReader } from '../src/entrypoints/app/middleware/auth' +import { createAdminRoutes } from '../src/entrypoints/app/routes/admin/index' +import { + COOKIE_NAME, + createAccessStore, + createUserStore, + encryptSession, +} from '../src/services/auth' + +const TEST_DB = '/tmp/test-admin-routes.sqlite' +const SECRET = 'test-secret-key-32-bytes-long!' + +function cleanup() { + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(`${TEST_DB}${suffix}`) + } catch { + /* ignore */ + } + } +} + +async function adminCookie() { + return encryptSession( + { + login: 'adminuser', + name: 'Admin', + avatarUrl: 'https://example.com/a.png', + }, + SECRET, + ) +} + +async function regularCookie() { + return encryptSession( + { + login: 'regular', + name: 'Regular', + avatarUrl: 'https://example.com/a.png', + }, + SECRET, + ) +} + +function buildApp() { + const accessStore = createAccessStore(TEST_DB) + const userStore = createUserStore(TEST_DB) + const app = new Hono() + process.env.SESSION_SECRET = SECRET + process.env.ADMIN_USERS = 'adminuser' + + app.use('*', sessionReader()) + app.route('/admin', createAdminRoutes(accessStore, userStore)) + return { app, accessStore, userStore } +} + +describe('Admin Routes', () => { + afterEach(cleanup) + + it('GET /admin/users returns 403 for non-admin', async () => { + const { app } = buildApp() + const cookie = await regularCookie() + const res = await app.request('/admin/users', { + headers: { Cookie: `${COOKIE_NAME}=${cookie}` }, + }) + expect(res.status).toBe(403) + }) + + it('GET /admin/users returns 200 for admin', async () => { + const { app } = buildApp() + const cookie = await adminCookie() + const res = await app.request('/admin/users', { + headers: { Cookie: `${COOKIE_NAME}=${cookie}` }, + }) + expect(res.status).toBe(200) + }) + + it('POST /admin/users/approve approves a pending user', async () => { + const { app, accessStore } = buildApp() + accessStore.requestAccess('pendinguser') + + const cookie = await adminCookie() + const res = await app.request('/admin/users/approve', { + method: 'POST', + headers: { + Cookie: `${COOKIE_NAME}=${cookie}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'login=pendinguser', + }) + + expect(res.status).toBe(302) + expect(accessStore.get('pendinguser')?.status).toBe('approved') + expect(accessStore.get('pendinguser')?.decidedBy).toBe('adminuser') + }) + + it('POST /admin/users/deny denies a pending user', async () => { + const { app, accessStore } = buildApp() + accessStore.requestAccess('pendinguser') + + const cookie = await adminCookie() + const res = await app.request('/admin/users/deny', { + method: 'POST', + headers: { + Cookie: `${COOKIE_NAME}=${cookie}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'login=pendinguser', + }) + + expect(res.status).toBe(302) + expect(accessStore.get('pendinguser')?.status).toBe('revoked') + }) + + it('POST /admin/users/revoke revokes an approved user', async () => { + const { app, accessStore } = buildApp() + accessStore.setApproved('approveduser', 'admin') + + const cookie = await adminCookie() + const res = await app.request('/admin/users/revoke', { + method: 'POST', + headers: { + Cookie: `${COOKIE_NAME}=${cookie}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'login=approveduser', + }) + + expect(res.status).toBe(302) + expect(accessStore.get('approveduser')?.status).toBe('revoked') + }) + + it('POST /admin/users/add adds a new approved user', async () => { + const { app, accessStore } = buildApp() + + const cookie = await adminCookie() + const res = await app.request('/admin/users/add', { + method: 'POST', + headers: { + Cookie: `${COOKIE_NAME}=${cookie}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'login=newuser', + }) + + expect(res.status).toBe(302) + const entry = accessStore.get('newuser') + expect(entry?.status).toBe('approved') + expect(entry?.source).toBe('admin') + }) +}) From e8f507cb78695f25908d8b9e409a0734f8b662bb Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Wed, 6 May 2026 07:55:24 +0000 Subject: [PATCH 5/7] feat(auth): wire up access control in server, add JSX components Create access store alongside user store in server.tsx. Mount admin routes at /admin with requireAuth guard. Replace raw HTML in auth and admin pages with design-system JSX components. Part of #118 --- .../app/routes/admin/components.tsx | 212 ++++++++++++++++++ src/entrypoints/app/routes/admin/index.ts | 182 --------------- src/entrypoints/app/routes/admin/index.tsx | 92 ++++++++ .../app/routes/auth/components.tsx | 59 +++++ .../app/routes/auth/{index.ts => index.tsx} | 38 ++-- src/entrypoints/app/server.tsx | 7 +- 6 files changed, 384 insertions(+), 206 deletions(-) create mode 100644 src/entrypoints/app/routes/admin/components.tsx delete mode 100644 src/entrypoints/app/routes/admin/index.ts create mode 100644 src/entrypoints/app/routes/admin/index.tsx create mode 100644 src/entrypoints/app/routes/auth/components.tsx rename src/entrypoints/app/routes/auth/{index.ts => index.tsx} (89%) diff --git a/src/entrypoints/app/routes/admin/components.tsx b/src/entrypoints/app/routes/admin/components.tsx new file mode 100644 index 000000000..0b8901fa3 --- /dev/null +++ b/src/entrypoints/app/routes/admin/components.tsx @@ -0,0 +1,212 @@ +import type { FC } from 'hono/jsx' +import type { AccessEntry } from '../../../../services/auth' +import { resolveUrl } from '../../../../shared/base-path' + +interface EnrichedEntry extends AccessEntry { + name: string + avatarUrl: string | null +} + +export const AdminUsersPage: FC<{ + pending: EnrichedEntry[] + approved: EnrichedEntry[] + revoked: EnrichedEntry[] +}> = ({ pending, approved, revoked }) => ( +
+

User Management

+ +
+

Pending Requests ({pending.length})

+ {pending.length === 0 ? ( +

No pending requests.

+ ) : ( + + + + + + + + + + {pending.map((u) => ( + + + + + + ))} + +
UserRequestedActions
+
+ {u.avatarUrl && ( + + )} + + {u.name} (@{u.login}) + +
+
+ {u.requestedAt + ? new Date(u.requestedAt * 1000).toLocaleDateString() + : '\u2014'} + +
+
+ + +
+
+ + +
+
+
+ )} +
+ +
+

Approved Users ({approved.length})

+ {approved.length === 0 ? ( +

No approved users.

+ ) : ( + + + + + + + + + + {approved.map((u) => ( + + + + + + ))} + +
UserSourceActions
+
+ {u.avatarUrl && ( + + )} + + {u.name} (@{u.login}) + +
+
{u.source} + {u.source === 'env' ? ( + env-managed + ) : ( +
+ + +
+ )} +
+ )} +
+ +
+

Add User

+
+ + +
+
+ + {revoked.length > 0 && ( +
+

Revoked ({revoked.length})

+ + + + + + + + + {revoked.map((u) => ( + + + + + ))} + +
UserRevoked by
+
+ {u.avatarUrl && ( + + )} + + {u.name} (@{u.login}) + +
+
{u.decidedBy ?? '\u2014'}
+
+ )} +
+) diff --git a/src/entrypoints/app/routes/admin/index.ts b/src/entrypoints/app/routes/admin/index.ts deleted file mode 100644 index 613548e7a..000000000 --- a/src/entrypoints/app/routes/admin/index.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { Hono } from 'hono' -import type { AccessStore, UserStore } from '../../../../services/auth' -import { resolveUrl } from '../../../../shared/base-path' -import { requireAdmin } from '../../middleware/auth' - -export function createAdminRoutes( - accessStore: AccessStore, - userStore: UserStore, -): Hono { - const admin = new Hono() - - // All admin routes require admin - admin.use('*', requireAdmin()) - - // GET /admin/users — admin dashboard - admin.get('/users', (c) => { - const pending = accessStore.listByStatus('pending') - const approved = accessStore.listByStatus('approved') - const revoked = accessStore.listByStatus('revoked') - - const enriched = (entries: typeof pending) => - entries.map((entry) => { - const profile = userStore.get(entry.login) - return { - ...entry, - name: profile?.name ?? entry.login, - avatarUrl: profile?.avatarUrl ?? null, - } - }) - - const pendingUsers = enriched(pending) - const approvedUsers = enriched(approved) - const revokedUsers = enriched(revoked) - - // Raw HTML for now — Task 5 will replace with JSX components - return c.html( - ` - User Management - -

User Management

- -

Pending Requests (${pendingUsers.length})

- ${ - pendingUsers.length === 0 - ? '

No pending requests.

' - : ` - - - - ${pendingUsers - .map( - (u) => ` - - - - - - `, - ) - .join('')} - -
UserRequestedActions
${u.name} (@${u.login})${u.requestedAt ? new Date(u.requestedAt * 1000).toLocaleDateString() : '—'} -
- - -
-
- - -
-
` - } - -

Approved Users (${approvedUsers.length})

- ${ - approvedUsers.length === 0 - ? '

No approved users.

' - : ` - - - - ${approvedUsers - .map( - (u) => ` - - - - - - `, - ) - .join('')} - -
UserSourceActions
${u.name} (@${u.login})${u.source} - ${ - u.source === 'env' - ? 'env-managed' - : ` -
- - -
` - } -
` - } - -

Add User

-
- - -
- - ${ - revokedUsers.length > 0 - ? ` -

Revoked (${revokedUsers.length})

- - - - ${revokedUsers - .map( - (u) => ` - - - - - `, - ) - .join('')} - -
UserRevoked by
${u.name} (@${u.login})${u.decidedBy ?? '—'}
` - : '' - } - `, - ) - }) - - // POST /admin/users/approve - admin.post('/users/approve', async (c) => { - const body = await c.req.parseBody() - const login = String(body.login ?? '').trim() - const user = c.get('user') - if (login && user) { - accessStore.approve(login, user.login) - } - return c.redirect(resolveUrl('/admin/users')) - }) - - // POST /admin/users/deny - admin.post('/users/deny', async (c) => { - const body = await c.req.parseBody() - const login = String(body.login ?? '').trim() - const user = c.get('user') - if (login && user) { - accessStore.deny(login, user.login) - } - return c.redirect(resolveUrl('/admin/users')) - }) - - // POST /admin/users/revoke - admin.post('/users/revoke', async (c) => { - const body = await c.req.parseBody() - const login = String(body.login ?? '').trim() - const user = c.get('user') - if (login && user) { - accessStore.revoke(login, user.login) - } - return c.redirect(resolveUrl('/admin/users')) - }) - - // POST /admin/users/add - admin.post('/users/add', async (c) => { - const body = await c.req.parseBody() - const login = String(body.login ?? '').trim() - if (login) { - accessStore.setApproved(login, 'admin') - } - return c.redirect(resolveUrl('/admin/users')) - }) - - return admin -} diff --git a/src/entrypoints/app/routes/admin/index.tsx b/src/entrypoints/app/routes/admin/index.tsx new file mode 100644 index 000000000..6a475398c --- /dev/null +++ b/src/entrypoints/app/routes/admin/index.tsx @@ -0,0 +1,92 @@ +import { Hono } from 'hono' +import { Layout } from '../../../../design-system/components/flex-layout' +import type { AccessStore, UserStore } from '../../../../services/auth' +import { resolveUrl } from '../../../../shared/base-path' +import { requireAdmin } from '../../middleware/auth' +import { AdminUsersPage } from './components' + +export function createAdminRoutes( + accessStore: AccessStore, + userStore: UserStore, +): Hono { + const admin = new Hono() + + // All admin routes require admin + admin.use('*', requireAdmin()) + + // GET /admin/users — admin dashboard + admin.get('/users', (c) => { + const pending = accessStore.listByStatus('pending') + const approved = accessStore.listByStatus('approved') + const revoked = accessStore.listByStatus('revoked') + + const enriched = (entries: typeof pending) => + entries.map((entry) => { + const profile = userStore.get(entry.login) + return { + ...entry, + name: profile?.name ?? entry.login, + avatarUrl: profile?.avatarUrl ?? null, + } + }) + + const pendingUsers = enriched(pending) + const approvedUsers = enriched(approved) + const revokedUsers = enriched(revoked) + + return c.html( + + + , + ) + }) + + // POST /admin/users/approve + admin.post('/users/approve', async (c) => { + const body = await c.req.parseBody() + const login = String(body.login ?? '').trim() + const user = c.get('user') + if (login && user) { + accessStore.approve(login, user.login) + } + return c.redirect(resolveUrl('/admin/users')) + }) + + // POST /admin/users/deny + admin.post('/users/deny', async (c) => { + const body = await c.req.parseBody() + const login = String(body.login ?? '').trim() + const user = c.get('user') + if (login && user) { + accessStore.deny(login, user.login) + } + return c.redirect(resolveUrl('/admin/users')) + }) + + // POST /admin/users/revoke + admin.post('/users/revoke', async (c) => { + const body = await c.req.parseBody() + const login = String(body.login ?? '').trim() + const user = c.get('user') + if (login && user) { + accessStore.revoke(login, user.login) + } + return c.redirect(resolveUrl('/admin/users')) + }) + + // POST /admin/users/add + admin.post('/users/add', async (c) => { + const body = await c.req.parseBody() + const login = String(body.login ?? '').trim() + if (login) { + accessStore.setApproved(login, 'admin') + } + return c.redirect(resolveUrl('/admin/users')) + }) + + return admin +} diff --git a/src/entrypoints/app/routes/auth/components.tsx b/src/entrypoints/app/routes/auth/components.tsx new file mode 100644 index 000000000..73016837d --- /dev/null +++ b/src/entrypoints/app/routes/auth/components.tsx @@ -0,0 +1,59 @@ +import type { FC } from 'hono/jsx' +import type { SessionUser } from '../../../../services/auth' +import { resolveUrl } from '../../../../shared/base-path' + +export const RequestAccessPage: FC<{ user: SessionUser | null }> = ({ + user, +}) => ( +
+

Request Access to Forms Lab

+ {user && ( +

+ Signed in as @{user.login} +

+ )} +

+ You don't currently have access to Forms Lab. Click below to request + access from an administrator. +

+
+ +
+
+) + +export const AccessPendingPage: FC = () => ( +
+

Access Request Pending

+

Your request is being reviewed. You'll be notified when approved.

+

+ Back to home +

+
+) + +export const AccessDeniedPage: FC = () => ( +
+

Access Denied

+

+ Your access has been revoked. Contact an administrator for assistance. +

+

+ Back to home +

+
+) diff --git a/src/entrypoints/app/routes/auth/index.ts b/src/entrypoints/app/routes/auth/index.tsx similarity index 89% rename from src/entrypoints/app/routes/auth/index.ts rename to src/entrypoints/app/routes/auth/index.tsx index 60e2dc153..168917294 100644 --- a/src/entrypoints/app/routes/auth/index.ts +++ b/src/entrypoints/app/routes/auth/index.tsx @@ -1,5 +1,6 @@ import { Hono } from 'hono' import { deleteCookie, setCookie } from 'hono/cookie' +import { Layout } from '../../../../design-system/components/flex-layout' import type { AccessStore, UserStore } from '../../../../services/auth' import { COOKIE_MAX_AGE, @@ -11,6 +12,11 @@ import { hasAllowedEmailDomain, } from '../../../../services/auth' import { resolveUrl } from '../../../../shared/base-path' +import { + AccessDeniedPage, + AccessPendingPage, + RequestAccessPage, +} from './components' export function createAuthRoutes( userStore: UserStore, @@ -226,15 +232,9 @@ export function createAuthRoutes( auth.get('/request-access', (c) => { const user = c.get('user') return c.html( - ` - Request Access - -

Request Access to Forms Lab

- ${user ? `

Signed in as @${user.login}

` : ''} -
- -
- `, + + + , ) }) @@ -268,26 +268,18 @@ export function createAuthRoutes( // GET /auth/access-pending auth.get('/access-pending', (c) => { return c.html( - ` - Access Pending - -

Access Request Pending

-

Your request is being reviewed. You'll be notified when approved.

-

Back to home

- `, + + + , ) }) // GET /auth/access-denied auth.get('/access-denied', (c) => { return c.html( - ` - Access Denied - -

Access Denied

-

Your access has been revoked. Contact an administrator for assistance.

-

Back to home

- `, + + + , ) }) diff --git a/src/entrypoints/app/server.tsx b/src/entrypoints/app/server.tsx index 0e8ded44b..ecc44c916 100644 --- a/src/entrypoints/app/server.tsx +++ b/src/entrypoints/app/server.tsx @@ -45,6 +45,7 @@ import { } from '../../services/variant-preferences' import { getBasePath, resolveUrl } from '../../shared/base-path' import { requireAuth, sessionReader } from './middleware/auth' +import { createAdminRoutes } from './routes/admin/index' import { createAuthRoutes } from './routes/auth/index' import catalog from './routes/catalog/index' import { createFormRouter } from './routes/forms/index' @@ -289,6 +290,10 @@ app.use( // Mount auth routes app.route('/auth', createAuthRoutes(userStore, accessStore)) +// Mount admin routes (requireAdmin is applied inside createAdminRoutes) +app.use('/admin/*', requireAuth(accessStore)) +app.route('/admin', createAdminRoutes(accessStore, userStore)) + // Mount settings routes (variant picker) app.route( '/settings', @@ -310,7 +315,7 @@ app.get('/health', (c) => { }) // New project routes (requires auth) -app.use('/new', requireAuth()) +app.use('/new', requireAuth(accessStore)) // Resolve the callout payload the /new page needs to describe the user's // currently-selected extraction variant. Factored out because both GET and From e0629c576686c473e8ff2f4e8b0d88fc6bf999d3 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Wed, 6 May 2026 08:16:58 +0000 Subject: [PATCH 6/7] fix(auth): enforce access check on all protected routes Thread accessStore through to requireAuth() calls in forms and settings routes, not just /new and /admin. Previously a revoked user with a valid session could still access form and settings routes. Part of #118 --- src/entrypoints/app/routes/auth/index.tsx | 3 ++- src/entrypoints/app/routes/forms/index.tsx | 4 +++- src/entrypoints/app/routes/settings/index.tsx | 4 +++- src/entrypoints/app/server.tsx | 7 ++++++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/entrypoints/app/routes/auth/index.tsx b/src/entrypoints/app/routes/auth/index.tsx index 168917294..b587a4773 100644 --- a/src/entrypoints/app/routes/auth/index.tsx +++ b/src/entrypoints/app/routes/auth/index.tsx @@ -130,7 +130,8 @@ export function createAuthRoutes( .split(',') .map((d) => d.trim()) .filter(Boolean) - // Always include flexion.us as a hardcoded domain bypass + // Policy: flexion.us is always auto-approved regardless of config. + // This is intentional — org users should never need manual approval. if ( !allowedDomains.map((d) => d.toLowerCase()).includes('flexion.us') ) { diff --git a/src/entrypoints/app/routes/forms/index.tsx b/src/entrypoints/app/routes/forms/index.tsx index 5fa51c060..5ae6e7397 100644 --- a/src/entrypoints/app/routes/forms/index.tsx +++ b/src/entrypoints/app/routes/forms/index.tsx @@ -7,6 +7,7 @@ import { FormPageView } from '../../../../design-system/components/flex-form-pag import { FormReview } from '../../../../design-system/components/flex-form-review' import { Layout } from '../../../../design-system/components/flex-layout' import { PreviewBanner } from '../../../../design-system/components/flex-preview-banner' +import type { AccessStore } from '../../../../services/auth' import type { DataCollectionSpec } from '../../../../services/data-collection' import type { FieldMapping } from '../../../../services/form-documents' import { fillPdf } from '../../../../services/form-documents' @@ -79,6 +80,7 @@ interface FormRouterDeps { specId: string, specVersion: string, ) => Promise + accessStore?: AccessStore } const MAIN_BRANCH = 'main' @@ -134,7 +136,7 @@ export function createFormRouter(deps: FormRouterDeps) { const forms = new Hono() // All form routes require authentication - forms.use('*', requireAuth()) + forms.use('*', requireAuth(deps.accessStore)) // Forms index forms.get('/', async (c) => { diff --git a/src/entrypoints/app/routes/settings/index.tsx b/src/entrypoints/app/routes/settings/index.tsx index 5a0388e66..c279a93d4 100644 --- a/src/entrypoints/app/routes/settings/index.tsx +++ b/src/entrypoints/app/routes/settings/index.tsx @@ -1,5 +1,6 @@ import { Hono } from 'hono' import { Layout } from '../../../../design-system/components/flex-layout' +import type { AccessStore } from '../../../../services/auth' import type { TaskRegistries, VariantPreferencesService, @@ -12,11 +13,12 @@ import { VariantPickerPage } from './components' export interface SettingsRoutesDeps { preferences: VariantPreferencesService registries: TaskRegistries + accessStore?: AccessStore } export function createSettingsRoutes(deps: SettingsRoutesDeps): Hono { const app = new Hono() - app.use('*', requireAuth()) + app.use('*', requireAuth(deps.accessStore)) app.get('/variants', (c) => { const user = c.get('user') diff --git a/src/entrypoints/app/server.tsx b/src/entrypoints/app/server.tsx index ecc44c916..6fbf2e605 100644 --- a/src/entrypoints/app/server.tsx +++ b/src/entrypoints/app/server.tsx @@ -297,7 +297,11 @@ app.route('/admin', createAdminRoutes(accessStore, userStore)) // Mount settings routes (variant picker) app.route( '/settings', - createSettingsRoutes({ preferences: variantPreferences, registries }), + createSettingsRoutes({ + preferences: variantPreferences, + registries, + accessStore, + }), ) // Mount catalog routes @@ -577,6 +581,7 @@ app.route( if (!buf) return null return JSON.parse(buf.toString()) }, + accessStore, }), ) From 4dba0cbc244bef5c45783c1592bfa3aad7537be7 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Wed, 6 May 2026 12:36:31 +0000 Subject: [PATCH 7/7] feat(auth): add Admin nav link for admin users Show "Admin" nav item in the header when the logged-in user is in ADMIN_USERS. Derives admin status from user.login + env var in Layout, avoiding changes to 70+ Layout call sites. Also extracts parseAdminUsers helper in middleware to reduce duplication. Part of #118 --- .../components/flex-layout/index.tsx | 15 +++++++++++++++ src/entrypoints/app/middleware/auth.ts | 14 ++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/design-system/components/flex-layout/index.tsx b/src/design-system/components/flex-layout/index.tsx index 611f146fa..c9f1a3312 100644 --- a/src/design-system/components/flex-layout/index.tsx +++ b/src/design-system/components/flex-layout/index.tsx @@ -17,6 +17,14 @@ interface LayoutProps { contentWidth?: 'centered' | 'full' } +function isAdminUser(login: string): boolean { + const admins = (process.env.ADMIN_USERS ?? 'danielnaab') + .split(',') + .map((u) => u.trim()) + .filter(Boolean) + return admins.includes(login) +} + export const Layout: FC> = (props) => { const title = props.title ? `${props.title} | Forms Lab` : 'Forms Lab' @@ -101,6 +109,13 @@ export const Layout: FC> = (props) => { label="Catalog" current={props.currentPath?.startsWith('/catalog') ?? false} /> + {isAdminUser(props.user.login) && ( + + )} ) : ( <> diff --git a/src/entrypoints/app/middleware/auth.ts b/src/entrypoints/app/middleware/auth.ts index a584fcd12..5bba98a51 100644 --- a/src/entrypoints/app/middleware/auth.ts +++ b/src/entrypoints/app/middleware/auth.ts @@ -14,6 +14,13 @@ declare module 'hono' { } } +function parseAdminUsers(): string[] { + return (process.env.ADMIN_USERS ?? 'danielnaab') + .split(',') + .map((u) => u.trim()) + .filter(Boolean) +} + export function sessionReader() { return createMiddleware(async (c, next) => { const secret = process.env.SESSION_SECRET @@ -67,12 +74,7 @@ export function requireAdmin() { return c.text('Forbidden', 403) } - const adminUsers = (process.env.ADMIN_USERS ?? 'danielnaab') - .split(',') - .map((u) => u.trim()) - .filter(Boolean) - - if (!adminUsers.includes(user.login)) { + if (!parseAdminUsers().includes(user.login)) { return c.text('Forbidden', 403) }