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`
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

View File

@ -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);
});

View File

@ -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
`;
}

View File

@ -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(