From 9c2aa73fdd4953cd156ee6e84b7c480d30ae09b7 Mon Sep 17 00:00:00 2001 From: Tyler Gillispie Date: Thu, 27 Aug 2026 23:50:44 -0700 Subject: [PATCH] fix(launchpad): populate fallback domains for apps, seed default app domains, and add app admin mutations --- src/core/db.ts | 14 +-- src/features/admin/admin_actions_routes.ts | 108 +++++++++++++++++++++ src/features/admin/queries.ts | 66 +++++++++++-- src/features/sessions/routes.tsx | 8 +- 4 files changed, 177 insertions(+), 19 deletions(-) diff --git a/src/core/db.ts b/src/core/db.ts index c4968ec..9d4c2b0 100644 --- a/src/core/db.ts +++ b/src/core/db.ts @@ -308,18 +308,18 @@ export async function initDb(): Promise { ); `; - // Seed ed-droid app record with spiffe_id + // Seed ed-droid app record with spiffe_id and domain await sql` - INSERT INTO apps (name, spiffe_id) - VALUES ('ed-droid', 'spiffe://system.local/ed-droid-backend') - ON CONFLICT (spiffe_id) DO NOTHING + INSERT INTO apps (name, spiffe_id, domain, description) + VALUES ('ed-droid', 'spiffe://system.local/ed-droid-backend', 'ed-droid.atyg.org', 'Autonomous Agent UI & Workload Control') + ON CONFLICT (spiffe_id) DO UPDATE SET domain = COALESCE(apps.domain, EXCLUDED.domain), description = COALESCE(apps.description, EXCLUDED.description) `; // Seed the Central Auth-Yes Management App for global administration await sql` - INSERT INTO apps (name, spiffe_id) - VALUES ('Auth-Yes Management Console', 'spiffe://system.local/auth-yes-management') - ON CONFLICT (spiffe_id) DO NOTHING + INSERT INTO apps (name, spiffe_id, domain, description) + VALUES ('Auth-Yes Management Console', 'spiffe://system.local/auth-yes-management', 'auth.atyg.org', 'Central Identity, Delegation, and Zero-Trust Control Fabric') + ON CONFLICT (spiffe_id) DO UPDATE SET domain = COALESCE(apps.domain, EXCLUDED.domain), description = COALESCE(apps.description, EXCLUDED.description) `; // Seed initial bootstrap admin invite if no users exist in the database diff --git a/src/features/admin/admin_actions_routes.ts b/src/features/admin/admin_actions_routes.ts index 26353da..0370e84 100644 --- a/src/features/admin/admin_actions_routes.ts +++ b/src/features/admin/admin_actions_routes.ts @@ -7,9 +7,11 @@ import { getClientIp } from "../../core/middleware.ts"; import { addAaguid, assignUserGrant, + createApp, createInvite, createRecoveryLink, createRole, + deleteApp, deleteRole, getUserById, removeAaguid, @@ -18,6 +20,7 @@ import { revokeInvite, revokeSessionById, revokeUserPasskey, + updateApp, updateRole, updateUserProfile, updateUserStatus, @@ -280,3 +283,108 @@ adminActionsRoutes.delete("/aaguid/:id", async (c) => { await removeAaguid(id); return c.json({ success: true }); }); + +// App Registry Mutations +adminActionsRoutes.post("/apps", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const { + name, + spiffeId, + description, + domain, + is_public, + bypass_paths, + allowed_cidrs, + } = await c.req.json(); + + if (!name || !spiffeId) { + return c.json({ error: "Name and SPIFFE ID are required" }, 400); + } + + try { + const newApp = await createApp( + name, + spiffeId, + description, + domain, + is_public, + bypass_paths, + allowed_cidrs, + ); + + auditWrapper.auditLog(auth.userId, "app_registered", newApp.id, { + name: newApp.name, + spiffe_id: newApp.spiffe_id, + }, getClientIp(c)); + return c.json({ success: true, app: newApp }); + } catch (err: any) { + if (err.code === "23505") { + return c.json({ + error: "An application with this SPIFFE ID already exists", + }, 409); + } + return c.json({ error: "Failed to register application" }, 500); + } +}); + +adminActionsRoutes.put("/apps/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const appId = c.req.param("id"); + const { + name, + description, + domain, + is_public, + bypass_paths, + allowed_cidrs, + } = await c.req.json(); + + if (!name) { + return c.json({ error: "Application name is required" }, 400); + } + + try { + const updatedApp = await updateApp( + appId, + name, + description, + domain, + is_public, + bypass_paths, + allowed_cidrs, + ); + + if (!updatedApp) return c.json({ error: "Application not found" }, 404); + + auditWrapper.auditLog(auth.userId, "app_updated", updatedApp.id, { + name: updatedApp.name, + }, getClientIp(c)); + + return c.json({ success: true, app: updatedApp }); + } catch (_err: any) { + return c.json({ error: "Failed to update application" }, 500); + } +}); + +adminActionsRoutes.delete("/apps/:id", async (c) => { + const auth = await getAuthenticatedUser(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + + const appId = c.req.param("id"); + const app = await deleteApp(appId); + if (app) { + auditWrapper.auditLog( + auth.userId, + "app_deleted", + appId, + { name: app.name }, + getClientIp(c), + ); + return c.json({ success: true }); + } + return c.json({ error: "Application not found" }, 404); +}); diff --git a/src/features/admin/queries.ts b/src/features/admin/queries.ts index cba7030..7a3bfc5 100644 --- a/src/features/admin/queries.ts +++ b/src/features/admin/queries.ts @@ -162,6 +162,54 @@ export const revokeSessionById = async (sessionId: string) => { `.then((res: any) => res[0]); }; +export const createApp = async ( + name: string, + spiffeId: string, + description?: string | null, + domain?: string | null, + isPublic?: boolean, + bypassPaths?: string[], + allowedCidrs?: string[], +) => { + return await sqlWrapper.sql` + INSERT INTO apps (name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs) + VALUES (${name.trim()}, ${spiffeId.trim()}, ${ + description?.trim() || null + }, ${domain?.trim() || null}, ${isPublic || false}, ${bypassPaths || []}, ${ + allowedCidrs || [] + }) + RETURNING id, name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs, created_at + `.then((res: any) => res[0]); +}; + +export const updateApp = async ( + appId: string, + name: string, + description?: string | null, + domain?: string | null, + isPublic?: boolean, + bypassPaths?: string[], + allowedCidrs?: string[], +) => { + return await sqlWrapper.sql` + UPDATE apps + SET name = ${name.trim()}, + description = ${description?.trim() || null}, + domain = ${domain?.trim() || null}, + is_public = ${isPublic || false}, + bypass_paths = ${bypassPaths || []}, + allowed_cidrs = ${allowedCidrs || []} + WHERE id = ${appId} + RETURNING id, name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs + `.then((res: any) => res[0]); +}; + +export const deleteApp = async (appId: string) => { + return await sqlWrapper.sql` + DELETE FROM apps WHERE id = ${appId} RETURNING id, name + `.then((res: any) => res[0]); +}; + export const getDashboardApps = async ( userId: string, isAdmin: boolean, @@ -169,9 +217,8 @@ export const getDashboardApps = async ( ) => { if (isAdmin) { return await sqlWrapper.sql` - SELECT id, name, description, domain, 'Admin' as role + SELECT id, name, description, COALESCE(domain, name || '.atyg.org') as domain, 'Admin' as role FROM apps - WHERE domain IS NOT NULL ORDER BY name ASC `; } else { @@ -185,29 +232,28 @@ export const getDashboardApps = async ( if (isUniversalGuest) { return await sqlWrapper.sql` - SELECT id, name, description, domain, 'Guest (Viewer)' as role + SELECT id, name, description, COALESCE(domain, name || '.atyg.org') as domain, 'Guest (Viewer)' as role FROM apps - WHERE domain IS NOT NULL ORDER BY name ASC `; } else if (appNames.length > 0) { return await sqlWrapper.sql` - SELECT a.id, a.name, a.description, a.domain, g.role + SELECT a.id, a.name, a.description, COALESCE(a.domain, a.name || '.atyg.org') as 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 + WHERE g.user_id = ${userId} UNION - SELECT id, name, description, domain, 'Guest (Viewer)' as role + SELECT id, name, description, COALESCE(domain, name || '.atyg.org') as domain, 'Guest (Viewer)' as role FROM apps - WHERE domain IS NOT NULL AND name = ANY(${appNames}::text[]) + WHERE name = ANY(${appNames}::text[]) ORDER BY name ASC `; } else { return await sqlWrapper.sql` - SELECT a.id, a.name, a.description, a.domain, g.role + SELECT a.id, a.name, a.description, COALESCE(a.domain, a.name || '.atyg.org') as 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 + WHERE g.user_id = ${userId} ORDER BY a.name ASC `; } diff --git a/src/features/sessions/routes.tsx b/src/features/sessions/routes.tsx index 351a75f..94da7e1 100644 --- a/src/features/sessions/routes.tsx +++ b/src/features/sessions/routes.tsx @@ -2,7 +2,11 @@ import { Hono } from "jsr:@hono/hono@4"; import { streamDatastar } from "../../core/sse_adapter.ts"; import { renderErrorToastFragment } from "../../core/error_fragments.tsx"; -import { getAuthenticatedUser, hasScope } from "../../core/session.ts"; +import { + getAuthenticatedUser, + hasScope, + isGlobalAdmin, +} from "../../core/session.ts"; import { getAllApps, getDashboardApps, @@ -29,7 +33,7 @@ sessionRoutes.get("/dashboard", async (c) => { return c.redirect("/login"); } - const isAdmin = hasScope(auth, "admin") || hasScope(auth, "*"); + const isAdmin = await isGlobalAdmin(auth.userId); const apps = await getDashboardApps(auth.userId, isAdmin, auth.customScopes); return c.html(