diff --git a/server/auth-session.ts b/server/auth-session.ts index 726981e..06114b1 100644 --- a/server/auth-session.ts +++ b/server/auth-session.ts @@ -70,6 +70,96 @@ export async function getAuthenticatedUser( return null; } +/** + * Helper to get an application by host. + * Checks Valkey cache first, falls back to PostgreSQL, and populates Valkey. + */ +export async function getAppByHost( + host: string, +): Promise<{ id: string; name: string } | null> { + const cacheKey = `auth:app_by_host:${host}`; + + // 1. Try Valkey cache + try { + const cachedStr = await valkey.get(cacheKey); + if (cachedStr) { + return JSON.parse(cachedStr); + } + } catch (_err) { + // Valkey cache miss or connection error + } + + // 2. Fallback to PostgreSQL + try { + // Search by domain first + let app = await sqlWrapper.sql` + SELECT id, name FROM apps WHERE domain = ${host} + `.then((res: any) => res[0]); + + // Fallback: match name against the first subdomain segment + if (!app) { + const subdomain = host.split(".")[0]; + if (subdomain) { + app = await sqlWrapper.sql` + SELECT id, name FROM apps WHERE name = ${subdomain} + `.then((res: any) => res[0]); + } + } + + if (app) { + // Repopulate Valkey + try { + await valkey.setex(cacheKey, 3600, JSON.stringify(app)); // Cache for 1 hour + } catch (_e) {} + return app; + } + } catch (_err) { + return null; + } + + return null; +} + +/** + * Helper to get a user's role grant for a specific app. + * Checks Valkey cache first, falls back to PostgreSQL, and populates Valkey. + */ +export async function getUserGrant( + userId: string, + appId: string, +): Promise { + const cacheKey = `auth:grants:${userId}:${appId}`; + + // 1. Try Valkey cache + try { + const cachedRole = await valkey.get(cacheKey); + if (cachedRole) { + return cachedRole; + } + } catch (_err) { + // Valkey cache miss or connection error + } + + // 2. Fallback to PostgreSQL + try { + const grant = await sqlWrapper.sql` + SELECT role FROM grants WHERE user_id = ${userId} AND app_id = ${appId} + `.then((res: any) => res[0]); + + if (grant && grant.role) { + // Repopulate Valkey + try { + await valkey.setex(cacheKey, 3600, grant.role); // Cache for 1 hour + } catch (_e) {} + return grant.role; + } + } catch (_err) { + return null; + } + + return null; +} + /** * Helper to check if user has global admin privileges. * Strict check: Requires an explicit 'admin' grant on the Management Console diff --git a/server/db.ts b/server/db.ts index 539d94e..e795cbe 100644 --- a/server/db.ts +++ b/server/db.ts @@ -62,6 +62,13 @@ export async function initDb(): Promise { // Ignore and check next } + // Ensure domain column exists for Edge authorization routing + try { + await sql`ALTER TABLE apps ADD COLUMN IF NOT EXISTS domain TEXT UNIQUE`; + } catch { + // Soft ignore if column already exists + } + await sql` CREATE TABLE IF NOT EXISTS roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/server/main.test.ts b/server/main.test.ts index 72331d5..89fdc78 100644 --- a/server/main.test.ts +++ b/server/main.test.ts @@ -30,7 +30,24 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => { setMockSql(() => Promise.resolve([mockUser])); - const valkeyStub = stub(valkey, "get", () => { + let queryCount = 0; + + setMockSql(() => { + queryCount++; + if (queryCount === 1) return Promise.resolve([mockUser]); // User lookup + return Promise.resolve([]); // isGlobalAdmin lookup + }); + + const valkeyStub = stub(valkey, "get", (key: any) => { + const k = String(key); + if (k.startsWith("auth:app_by_host:")) { + return Promise.resolve( + JSON.stringify({ id: "app-id-1", name: "test-app" }), + ); + } + if (k.startsWith("auth:grants:")) { + return Promise.resolve("viewer"); + } return Promise.resolve( JSON.stringify({ uuid: "user-id-1", username: "alice" }), ); @@ -39,6 +56,7 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => { const req = new Request("http://localhost/api/forward-auth", { headers: { Cookie: "session_id=mock-session-id", + "X-Forwarded-Host": "test.app.local", }, }); @@ -46,24 +64,58 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => { assertEquals(res.status, 200); assertEquals(res.headers.get("X-Forwarded-User"), "alice"); assertEquals(res.headers.get("X-Forwarded-User-Id"), "user-id-1"); + assertEquals(res.headers.get("X-Forwarded-Scopes"), "viewer"); + assertEquals(res.headers.get("X-Forwarded-App-Id"), "app-id-1"); restoreMockSql(); valkeyStub.restore(); }); Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session", async () => { - const req = new Request("http://localhost/api/forward-auth"); + const req = new Request("http://localhost/api/forward-auth", { + headers: { "X-Forwarded-Host": "test.app.local" }, + }); + + let valkeyStub: any; + if ((valkey.get as any).restore) { + valkeyStub = valkey.get as any; + } else { + valkeyStub = stub(valkey, "get", (key: any) => { + const k = String(key); + if (k.startsWith("auth:app_by_host:")) { + return Promise.resolve( + JSON.stringify({ id: "app-id-1", name: "test-app" }), + ); + } + return Promise.resolve(null); + }); + } const res = await app.request(req); assertEquals(res.status, 401); + valkeyStub.restore(); }); Deno.test("Tier 1 & 2: GET /api/forward-auth - Expired session", async () => { - const valkeyStub = stub(valkey, "get", () => { - return Promise.resolve(null); - }); + let valkeyStub: any; + if ((valkey.get as any).restore) { + valkeyStub = valkey.get as any; + } else { + valkeyStub = stub(valkey, "get", (key: any) => { + const k = String(key); + if (k.startsWith("auth:app_by_host:")) { + return Promise.resolve( + JSON.stringify({ id: "app-id-1", name: "test-app" }), + ); + } + return Promise.resolve(null); + }); + } const req = new Request("http://localhost/api/forward-auth", { - headers: { Cookie: "session_id=expired-session-id" }, + headers: { + Cookie: "session_id=expired-session-id", + "X-Forwarded-Host": "test.app.local", + }, }); const res = await app.request(req); assertEquals(res.status, 401); @@ -79,14 +131,28 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Suspended account", async () => { setMockSql(() => Promise.resolve([mockUser])); - const valkeyStub = stub(valkey, "get", () => { - return Promise.resolve( - JSON.stringify({ uuid: "user-id-1", username: "alice" }), - ); - }); + let valkeyStub: any; + if ((valkey.get as any).restore) { + valkeyStub = valkey.get as any; + } else { + valkeyStub = stub(valkey, "get", (key: any) => { + const k = String(key); + if (k.startsWith("auth:app_by_host:")) { + return Promise.resolve( + JSON.stringify({ id: "app-id-1", name: "test-app" }), + ); + } + return Promise.resolve( + JSON.stringify({ uuid: "user-id-1", username: "alice" }), + ); + }); + } const req = new Request("http://localhost/api/forward-auth", { - headers: { Cookie: "session_id=mock-session-id" }, + headers: { + Cookie: "session_id=mock-session-id", + "X-Forwarded-Host": "test.app.local", + }, }); const res = await app.request(req); assertEquals(res.status, 403); @@ -112,12 +178,16 @@ Deno.test("Tier 3: ValidateSession ConnectRPC - Default-Deny", async () => { // Re-define valkey stub - const originalValkeyGet = valkey.get; - valkey.get = () => { - return Promise.resolve( - JSON.stringify({ uuid: "user-1", username: "alice" }), - ); - }; + let valkeyStub: any; + if ((valkey.get as any).restore) { + valkeyStub = valkey.get as any; + } else { + valkeyStub = stub(valkey, "get", () => { + return Promise.resolve( + JSON.stringify({ uuid: "user-1", username: "alice" }), + ); + }); + } let auditCalled = false; const originalAudit = auditWrapper.auditLog; @@ -158,7 +228,7 @@ Deno.test("Tier 3: ValidateSession ConnectRPC - Default-Deny", async () => { spireWrapper.extractSpiffeIdFromCert = originalExtract; restoreMockSql(); - valkey.get = originalValkeyGet; + valkeyStub.restore(); auditWrapper.auditLog = originalAudit; }); @@ -176,12 +246,16 @@ Deno.test("Tier 3: ValidateSession ConnectRPC - Valid RBAC Grant", async () => { } }); - const originalValkeyGet = valkey.get; - valkey.get = () => { - return Promise.resolve( - JSON.stringify({ uuid: "user-1", username: "alice" }), - ); - }; + let valkeyStub: any; + if ((valkey.get as any).restore) { + valkeyStub = valkey.get as any; + } else { + valkeyStub = stub(valkey, "get", () => { + return Promise.resolve( + JSON.stringify({ uuid: "user-1", username: "alice" }), + ); + }); + } const req = new Request( "http://localhost/auth.v1.AuthService/ValidateSession", @@ -206,7 +280,7 @@ Deno.test("Tier 3: ValidateSession ConnectRPC - Valid RBAC Grant", async () => { spireWrapper.extractSpiffeIdFromCert = originalExtract; restoreMockSql(); - valkey.get = originalValkeyGet; + valkeyStub.restore(); }); Deno.test("Tier 3: ValidateSession ConnectRPC - SPIFFE Attestation Failure", async () => { diff --git a/server/main.ts b/server/main.ts index e959541..36c22bb 100644 --- a/server/main.ts +++ b/server/main.ts @@ -962,12 +962,29 @@ app.post("/api/admin/users/:id/status", async (c) => { // Traefik ForwardAuth Edge Proxy Route (Tier 2) // --------------------------------------------------------- +import { getAppByHost, getUserGrant } from "./auth-session.ts"; + app.get("/api/forward-auth", async (c) => { + const host = c.req.header("X-Forwarded-Host"); + if (!host) { + return c.text("Bad Request: Missing X-Forwarded-Host header", 400); + } + + // 1. Resolve Target App (Valkey -> DB) + const appRecord = await getAppByHost(host); + if (!appRecord) { + // Default-Deny if app is not registered + return c.text("Forbidden: Application not registered", 403); + } + + // 2. Validate Session const auth = await getAuthenticatedUser(c); if (!auth) { return c.text("Unauthorized", 401); } + // Cache lookup for user status can be added later; hitting DB to be safe for now, + // but let's just make sure account is active. const user = await sqlWrapper.sql` SELECT id, username, account_status FROM users @@ -978,8 +995,26 @@ app.get("/api/forward-auth", async (c) => { return c.text("Forbidden: Account inactive", 403); } + // 3. Resolve Grants and Roles + const globalAdmin = await isGlobalAdmin(auth.userId); + const grantRole = await getUserGrant(auth.userId, appRecord.id); + + if (!globalAdmin && !grantRole) { + // Enforce Default-Deny if no app-specific grants and not global admin + return c.text("Forbidden: Access denied to this application", 403); + } + + // Combine scopes, ensuring no duplicates and formatting as comma-separated string + const scopes = [ + ...new Set([grantRole, globalAdmin ? "admin" : null].filter(Boolean)), + ].join(","); + + // 4. Inject Headers c.header("X-Forwarded-User", user.username); c.header("X-Forwarded-User-Id", user.id); + c.header("X-Forwarded-Scopes", scopes); + c.header("X-Forwarded-App-Id", appRecord.id); + return c.text("OK", 200); }); diff --git a/tasks/new/2026-0824.03.jul.feat.auth-api.ingress-grant-injection-1200.md b/tasks/complete/2026-0824.03.jul.feat.auth-api.ingress-grant-injection-1200.md similarity index 100% rename from tasks/new/2026-0824.03.jul.feat.auth-api.ingress-grant-injection-1200.md rename to tasks/complete/2026-0824.03.jul.feat.auth-api.ingress-grant-injection-1200.md