fix(launchpad): populate fallback domains for apps, seed default app domains, and add app admin mutations

This commit is contained in:
Tyler Gillispie 2026-08-27 23:50:44 -07:00
parent 64f758e7cb
commit 9c2aa73fdd
4 changed files with 177 additions and 19 deletions

View File

@ -308,18 +308,18 @@ export async function initDb(): Promise<void> {
); );
`; `;
// Seed ed-droid app record with spiffe_id // Seed ed-droid app record with spiffe_id and domain
await sql` await sql`
INSERT INTO apps (name, spiffe_id) INSERT INTO apps (name, spiffe_id, domain, description)
VALUES ('ed-droid', 'spiffe://system.local/ed-droid-backend') VALUES ('ed-droid', 'spiffe://system.local/ed-droid-backend', 'ed-droid.atyg.org', 'Autonomous Agent UI & Workload Control')
ON CONFLICT (spiffe_id) DO NOTHING 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 // Seed the Central Auth-Yes Management App for global administration
await sql` await sql`
INSERT INTO apps (name, spiffe_id) INSERT INTO apps (name, spiffe_id, domain, description)
VALUES ('Auth-Yes Management Console', 'spiffe://system.local/auth-yes-management') 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 NOTHING 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 // Seed initial bootstrap admin invite if no users exist in the database

View File

@ -7,9 +7,11 @@ import { getClientIp } from "../../core/middleware.ts";
import { import {
addAaguid, addAaguid,
assignUserGrant, assignUserGrant,
createApp,
createInvite, createInvite,
createRecoveryLink, createRecoveryLink,
createRole, createRole,
deleteApp,
deleteRole, deleteRole,
getUserById, getUserById,
removeAaguid, removeAaguid,
@ -18,6 +20,7 @@ import {
revokeInvite, revokeInvite,
revokeSessionById, revokeSessionById,
revokeUserPasskey, revokeUserPasskey,
updateApp,
updateRole, updateRole,
updateUserProfile, updateUserProfile,
updateUserStatus, updateUserStatus,
@ -280,3 +283,108 @@ adminActionsRoutes.delete("/aaguid/:id", async (c) => {
await removeAaguid(id); await removeAaguid(id);
return c.json({ success: true }); 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);
});

View File

@ -162,6 +162,54 @@ export const revokeSessionById = async (sessionId: string) => {
`.then((res: any) => res[0]); `.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 ( export const getDashboardApps = async (
userId: string, userId: string,
isAdmin: boolean, isAdmin: boolean,
@ -169,9 +217,8 @@ export const getDashboardApps = async (
) => { ) => {
if (isAdmin) { if (isAdmin) {
return await sqlWrapper.sql` 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 FROM apps
WHERE domain IS NOT NULL
ORDER BY name ASC ORDER BY name ASC
`; `;
} else { } else {
@ -185,29 +232,28 @@ export const getDashboardApps = async (
if (isUniversalGuest) { if (isUniversalGuest) {
return await sqlWrapper.sql` 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 FROM apps
WHERE domain IS NOT NULL
ORDER BY name ASC ORDER BY name ASC
`; `;
} else if (appNames.length > 0) { } else if (appNames.length > 0) {
return await sqlWrapper.sql` 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 FROM apps a
JOIN grants g ON a.id = g.app_id 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 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 FROM apps
WHERE domain IS NOT NULL AND name = ANY(${appNames}::text[]) WHERE name = ANY(${appNames}::text[])
ORDER BY name ASC ORDER BY name ASC
`; `;
} else { } else {
return await sqlWrapper.sql` 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 FROM apps a
JOIN grants g ON a.id = g.app_id 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 ORDER BY a.name ASC
`; `;
} }

View File

@ -2,7 +2,11 @@ import { Hono } from "jsr:@hono/hono@4";
import { streamDatastar } from "../../core/sse_adapter.ts"; import { streamDatastar } from "../../core/sse_adapter.ts";
import { renderErrorToastFragment } from "../../core/error_fragments.tsx"; import { renderErrorToastFragment } from "../../core/error_fragments.tsx";
import { getAuthenticatedUser, hasScope } from "../../core/session.ts"; import {
getAuthenticatedUser,
hasScope,
isGlobalAdmin,
} from "../../core/session.ts";
import { import {
getAllApps, getAllApps,
getDashboardApps, getDashboardApps,
@ -29,7 +33,7 @@ sessionRoutes.get("/dashboard", async (c) => {
return c.redirect("/login"); 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); const apps = await getDashboardApps(auth.userId, isAdmin, auth.customScopes);
return c.html( return c.html(