From eed5c8a0fd87a8a16da5acfef9255dab319bf23c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:34:00 +0000 Subject: [PATCH] feat: Implement Phase 1 Event & Session Overhaul (Guest Ingress & Audits) - Allowed guest accounts to be evaluated in `forward-auth` - Validated `guest` account's `customScopes` and rejected ungranted access - Added Array parameterization and `UNION` query in `getDashboardApps` - Mapped `customScopes` to `getDashboardApps` in the UI route `/dashboard` - Wired web and CLI joins in `events.ts` to `auditWrapper.auditLog` using correct schema (`event.id`, `{slug, method}`) - Added `auditWrapper.auditLog` unit test validations in `events.test.ts` - Added guest session scope unit tests in `forward_auth.test.ts` - Moved Markdown tasks logic from `tasks/new/` to `tasks/complete/` Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com> --- server/routes/auth_forward.ts | 43 +++++++-- server/routes/events.ts | 16 ++++ server/tests/events.test.ts | 46 +++++++++- server/tests/forward_auth.test.ts | 88 +++++++++++++++++++ ...y.events.phase-1-guest-ingress-1400.ph1.md | 0 ui/db_queries.ts | 38 ++++++-- ui/mod.ts | 2 +- 7 files changed, 214 insertions(+), 19 deletions(-) rename tasks/{new => complete}/2026-0826.01.jul.story.events.phase-1-guest-ingress-1400.ph1.md (100%) diff --git a/server/routes/auth_forward.ts b/server/routes/auth_forward.ts index 8916024..50dab3f 100644 --- a/server/routes/auth_forward.ts +++ b/server/routes/auth_forward.ts @@ -117,22 +117,47 @@ forwardAuthRoutes.get("/api/forward-auth", async (c) => { WHERE id = ${auth.userId} `.then((res: any) => res[0]); - if (!user || user.account_status !== "active") { + if ( + !user || + (user.account_status !== "active" && user.account_status !== "guest") + ) { 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); + let scopes = ""; - if (!globalAdmin && !grantRole) { - return c.text("Forbidden: Access denied to this application", 403); + if ( + user.account_status === "guest" || + (auth.customScopes && auth.customScopes.length > 0) + ) { + // Guest or delegated session handling + const customScopes = auth.customScopes || []; + const hasAppScope = customScopes.includes(`app:${appRecord.name}`) || + customScopes.includes("*"); + + if (!hasAppScope) { + return c.text( + "Forbidden: Access denied to this application (Guest/Delegated)", + 403, + ); + } + + scopes = customScopes.filter(Boolean).join(",") || "viewer"; + } else { + // Standard user handling + const globalAdmin = await isGlobalAdmin(auth.userId); + const grantRole = await getUserGrant(auth.userId, appRecord.id); + + if (!globalAdmin && !grantRole) { + return c.text("Forbidden: Access denied to this application", 403); + } + + scopes = [ + ...new Set([grantRole, globalAdmin ? "admin" : null].filter(Boolean)), + ].join(","); } - 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); diff --git a/server/routes/events.ts b/server/routes/events.ts index c71eacc..ee14f7c 100644 --- a/server/routes/events.ts +++ b/server/routes/events.ts @@ -8,6 +8,8 @@ import { getCookieDomain, hasScope, } from "../auth-session.ts"; +import { getClientIp } from "../middleware.ts"; +import { auditWrapper } from "../audit.ts"; import { EventJoinPage } from "../../ui/components/EventJoinPage.tsx"; import { EventSplashPage } from "../../ui/components/EventSplashPage.tsx"; @@ -243,6 +245,13 @@ eventRoutes.post("/api/join", async (c) => { }), ); + auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { + slug: event.slug, + name: event.name, + seatNumber: event.seats_claimed, + method: "web", + }, getClientIp(c)); + deleteCookie(c, "session_id", { path: "/" }); const rpID = Deno.env.get("RP_ID"); const cookieDomain = getCookieDomain(rpID); @@ -343,6 +352,13 @@ eventRoutes.get("/join/:slug", async (c) => { }), ); + auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { + slug: event.slug, + name: event.name, + seatNumber: event.seats_claimed, + method: "cli", + }, getClientIp(c)); + if (format === "env") { return c.text( `export AUTH_YES_TOKEN="${sessionId}"\nexport AUTH_YES_USER="${username}"\n`, diff --git a/server/tests/events.test.ts b/server/tests/events.test.ts index 8f5f0d0..b7fc62d 100644 --- a/server/tests/events.test.ts +++ b/server/tests/events.test.ts @@ -93,10 +93,27 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { () => Promise.resolve("OK" as any), ); + const { auditWrapper } = await import("../audit.ts"); + let auditCalled = false; + let auditPayload: any = null; + const auditStub = stub( + auditWrapper, + "auditLog", + (_userId, action, resource, metadata, _ip) => { + if (action === "event_seat_claimed") { + auditCalled = true; + auditPayload = { resource, metadata }; + } + }, + ); + try { const res = await app.request("/api/join", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "X-Forwarded-For": "192.168.1.1", + }, body: JSON.stringify({ code: "749-123" }), }); @@ -109,9 +126,15 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { const cookies = res.headers.get("set-cookie"); assertExists(cookies); assert(cookies.includes(`session_id=${json.token};`)); + + assert(auditCalled); + assertEquals(auditPayload.resource, "event-uuid-1"); + assertEquals(auditPayload.metadata.slug, "deno-lab"); + assertEquals(auditPayload.metadata.method, "web"); } finally { sqlWrapper.sql = originalSql; valkeySetexStub.restore(); + auditStub.restore(); } }, ); @@ -145,18 +168,39 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => { () => Promise.resolve("OK" as any), ); + const { auditWrapper } = await import("../audit.ts"); + let auditCalled = false; + let auditPayload: any = null; + const auditStub = stub( + auditWrapper, + "auditLog", + (_userId, action, resource, metadata, _ip) => { + if (action === "event_seat_claimed") { + auditCalled = true; + auditPayload = { resource, metadata }; + } + }, + ); + try { const res = await app.request("/join/deno-lab?format=env", { method: "GET", + headers: { "X-Forwarded-For": "192.168.1.2" }, }); assertEquals(res.status, 200); const text = await res.text(); assert(text.includes('export AUTH_YES_TOKEN="ay_sess_')); assert(text.includes('export AUTH_YES_USER="guest_deno-lab_2"')); + + assert(auditCalled); + assertEquals(auditPayload.resource, "event-uuid-1"); + assertEquals(auditPayload.metadata.slug, "deno-lab"); + assertEquals(auditPayload.metadata.method, "cli"); } finally { sqlWrapper.sql = originalSql; valkeySetexStub.restore(); + auditStub.restore(); } }, ); diff --git a/server/tests/forward_auth.test.ts b/server/tests/forward_auth.test.ts index 76a0e1b..ad0f6fa 100644 --- a/server/tests/forward_auth.test.ts +++ b/server/tests/forward_auth.test.ts @@ -268,6 +268,94 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Dynamic Bypass (bypass_paths)", a restoreMockSql(); }); +Deno.test("Tier 1 & 2: GET /api/forward-auth - Guest session with app scope allowed", async () => { + const mockUser = { + id: "guest-id-1", + username: "guest_event_1", + account_status: "guest", + }; + + setMockSql(() => Promise.resolve([mockUser])); + + 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" }), + ); + } + return Promise.resolve( + JSON.stringify({ + uuid: "guest-id-1", + username: "guest_event_1", + account_status: "guest", + customScopes: ["app:test-app", "viewer"], + }), + ); + }); + + const req = new Request("http://localhost/api/forward-auth", { + headers: { + Cookie: "session_id=mock-guest-session", + "X-Forwarded-Host": "test.app.local", + }, + }); + + const res = await app.request(req); + assertEquals(res.status, 200); + assertEquals(res.headers.get("X-Forwarded-User"), "guest_event_1"); + assertEquals(res.headers.get("X-Forwarded-User-Id"), "guest-id-1"); + assertEquals(res.headers.get("X-Forwarded-Scopes"), "app:test-app,viewer"); + assertEquals(res.headers.get("X-Forwarded-App-Id"), "app-id-1"); + + restoreMockSql(); + valkeyStub.restore(); +}); + +Deno.test("Tier 1 & 2: GET /api/forward-auth - Guest session without app scope rejected (403)", async () => { + const mockUser = { + id: "guest-id-1", + username: "guest_event_1", + account_status: "guest", + }; + + setMockSql(() => Promise.resolve([mockUser])); + + 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" }), + ); + } + return Promise.resolve( + JSON.stringify({ + uuid: "guest-id-1", + username: "guest_event_1", + account_status: "guest", + customScopes: ["app:other-app", "viewer"], + }), + ); + }); + + const req = new Request("http://localhost/api/forward-auth", { + headers: { + Cookie: "session_id=mock-guest-session", + "X-Forwarded-Host": "test.app.local", + }, + }); + + const res = await app.request(req); + assertEquals(res.status, 403); + assertEquals( + await res.text(), + "Forbidden: Access denied to this application (Guest/Delegated)", + ); + + restoreMockSql(); + valkeyStub.restore(); +}); + Deno.test("Tier 1 & 2: POST /api/guests/sandbox - Creates guest session", async () => { const { valkey } = await import("../valkey.ts"); // Mock valkey.setex to prevent connection errors during tests diff --git a/tasks/new/2026-0826.01.jul.story.events.phase-1-guest-ingress-1400.ph1.md b/tasks/complete/2026-0826.01.jul.story.events.phase-1-guest-ingress-1400.ph1.md similarity index 100% rename from tasks/new/2026-0826.01.jul.story.events.phase-1-guest-ingress-1400.ph1.md rename to tasks/complete/2026-0826.01.jul.story.events.phase-1-guest-ingress-1400.ph1.md diff --git a/ui/db_queries.ts b/ui/db_queries.ts index 8c71321..f4e3a47 100644 --- a/ui/db_queries.ts +++ b/ui/db_queries.ts @@ -1,6 +1,10 @@ import { sql } from "../server/db.ts"; -export async function getDashboardApps(userId: string, isAdmin: boolean) { +export async function getDashboardApps( + userId: string, + isAdmin: boolean, + customScopes?: string[], +) { if (isAdmin) { return await sql` SELECT id, name, description, domain, 'Admin' as role @@ -9,13 +13,31 @@ export async function getDashboardApps(userId: string, isAdmin: boolean) { ORDER BY name ASC ` as any[]; } else { - return await sql` - SELECT a.id, a.name, a.description, a.domain, g.role - FROM apps a - JOIN grants g ON a.id = g.app_id - WHERE g.user_id = ${userId} AND a.domain IS NOT NULL - ORDER BY a.name ASC - ` as any[]; + const appNames = (customScopes || []) + .filter((s) => s.startsWith("app:")) + .map((s) => s.split(":")[1]); + + if (appNames.length > 0) { + return await sql` + SELECT a.id, a.name, a.description, a.domain, g.role + FROM apps a + JOIN grants g ON a.id = g.app_id + WHERE g.user_id = ${userId} AND a.domain IS NOT NULL + UNION + SELECT id, name, description, domain, 'Guest (Viewer)' as role + FROM apps + WHERE domain IS NOT NULL AND name = ANY(${appNames}::text[]) + ORDER BY name ASC + ` as any[]; + } else { + return await sql` + SELECT a.id, a.name, a.description, a.domain, g.role + FROM apps a + JOIN grants g ON a.id = g.app_id + WHERE g.user_id = ${userId} AND a.domain IS NOT NULL + ORDER BY a.name ASC + ` as any[]; + } } } diff --git a/ui/mod.ts b/ui/mod.ts index f0c3a0b..0a30bd2 100644 --- a/ui/mod.ts +++ b/ui/mod.ts @@ -151,7 +151,7 @@ uiApp.get("/dashboard", async (c) => { if (authRes instanceof Response) return authRes; const { auth, isAdmin } = authRes; - const apps = await getDashboardApps(auth.userId, isAdmin); + const apps = await getDashboardApps(auth.userId, isAdmin, auth.customScopes); return c.html(AppLaunchpadPage({ apps: apps as any, isAdmin })); });