Skip to content

Commit f49ea09

Browse files
feat(agentos): OIDC/Keycloak auth, DB-backed RBAC, ownership, refresh, new home
Replace the shared-password gate with a full auth/authorization stack and restructure the server routes. Authentication (BFF): - OIDC code+PKCE flow via Keycloak (Okta federated); tokens stay server-side, browser gets an httpOnly signed session-snapshot cookie. jose JWKS verify. - Reactive token refresh: short session cookie + server-held rotating refresh token; POST /auth/refresh re-signs the session on 401. SPA does single-flight refresh-and-retry, falling back to SSO sign-in when the refresh token is dead. Authorization (DB-backed RBAC): - Code-defined permission catalog + roles collection (role -> permissions, editable in Settings -> Roles), resolvePermissions + authorize(perm) per route. - AGENTOS_DEFAULT_ROLE / AGENTOS_BOOTSTRAP_ADMINS / AGENTOS_DEV_AUTH. Ownership / tenancy: - Resources stamped with ownerGroup + ownerUser; strict canRead/canWrite hard isolation (creator or owner-group; admins see all). Groups: read-only Settings -> Groups sourced from Keycloak Admin API. API keys: carry roleIds (capability) and group (tenancy) orthogonally, with a mint-time escalation guard; harness introspection echoes both. Routes: buildApp() composition, versioned /agentos/api/v1/* dashboard, nested agents router, trust-boundary groups (service / dashboard / observability), asyncHandler + standardized error envelope. SPA: - AuthContext + useAuth; SSO-only LoginPage; design-system fix (body bg/fg). - New personalized-workspace home (KPI tiles + agents/sessions/schedules). - RBAC-gate every create/delete control across registry, sessions, schedules, policies, evals, and API keys.
1 parent 818487d commit f49ea09

87 files changed

Lines changed: 5559 additions & 952 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎agentos/src/App.tsx‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { Home as HomeIcon, Activity, Shield, Boxes, FlaskConical } from "lucide-react";
1+
import { Home as HomeIcon, Activity, Shield, Boxes, FlaskConical, Settings as SettingsIcon } from "lucide-react";
22
import { NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
33
import { HomePage } from "./components/HomePage.tsx";
44
import { PoliciesPage } from "./components/PoliciesPage.tsx";
55
import { EvalsPage } from "./components/EvalsPage.tsx";
66
import { ObservabilityTab } from "./components/observability/ObservabilityTab.tsx";
77
import { RegistryPage } from "./components/RegistryPage.tsx";
88
import { AgentDashboard } from "./components/AgentDashboard.tsx";
9+
import { SettingsPage } from "./components/SettingsPage.tsx";
910
import { Separator } from "./components/ui/separator.tsx";
1011
import { useAgents } from "./context/AgentsContext.tsx";
1112
import { cn } from "./lib/cn.ts";
@@ -20,6 +21,7 @@ export default function App() {
2021
<Route path="observability" element={<ObservabilityTab />} />
2122
<Route path="policies" element={<PoliciesPage />} />
2223
<Route path="evals" element={<EvalsPage />} />
24+
<Route path="settings" element={<SettingsPage />} />
2325
<Route path="agents/:id" element={<AgentDashboard />} />
2426
<Route path="*" element={<Navigate to="/home" replace />} />
2527
</Route>
@@ -56,6 +58,10 @@ function Layout() {
5658

5759
<div className="flex-1" />
5860

61+
{/* System-level config lives at the bottom of the rail. */}
62+
<nav className="px-2 pb-2 space-y-1">
63+
<RailLink to="/settings" icon={SettingsIcon} label="Settings" />
64+
</nav>
5965
<Separator />
6066
<div className="px-4 py-3 text-[10px] text-muted-foreground/70">agentos.clawagent.sh</div>
6167
</aside>
@@ -72,15 +78,8 @@ function Layout() {
7278
// keep their existing prop contracts and need no router awareness. ──
7379

7480
function HomeRoute() {
75-
const { agents } = useAgents();
76-
const navigate = useNavigate();
77-
return (
78-
<HomePage
79-
agents={agents}
80-
onLaunch={(agentId, message) => navigate(`/agents/${encodeURIComponent(agentId)}`, { state: { message } })}
81-
onOpenDashboard={() => navigate(agents[0] ? `/agents/${encodeURIComponent(agents[0].id)}` : "/registry")}
82-
/>
83-
);
81+
// HomePage is self-sufficient (reads agents/auth from context, navigates itself).
82+
return <HomePage />;
8483
}
8584

8685
function RegistryRoute() {

‎agentos/src/api.ts‎

Lines changed: 171 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ export interface Agent {
4242
origin?: "in-memory" | "registry";
4343
registeredBy?: string | null;
4444
lastSeen?: string | null;
45+
/** True when the agent is archived: kept (with all its history) but refused
46+
* by every execution path. The UI lists archived agents in a separate,
47+
* greyed section and disables chat/run for them. Unset/false ⇒ active. */
48+
archived?: boolean;
49+
archivedAt?: string | null;
50+
/** Owning group (visibility) + owning user id (mutate/delete). */
51+
ownerGroup?: string | null;
52+
ownerUser?: string | null;
4553
}
4654

4755
export interface RegisterAgentInput {
@@ -51,6 +59,8 @@ export interface RegisterAgentInput {
5159
source?: string;
5260
model?: string;
5361
registeredBy?: string;
62+
/** The group the new agent belongs to (one of the creator's groups). */
63+
ownerGroup?: string;
5464
}
5565

5666
/** Result of `displaySource(agent.source)`. Drives `<SourceBadge>` rendering. */
@@ -219,29 +229,134 @@ export interface NewSchedule {
219229
minuteUtc?: number;
220230
}
221231

232+
// API keys — minted + stored (hashed) by the server; the plaintext is returned
233+
// exactly once on create. List/responses are redacted (prefix + last4 only).
234+
export interface ApiKey {
235+
_id: string;
236+
prefix: string;
237+
last4: string;
238+
label: string;
239+
/** Display label of the group/role the key acts as. */
240+
group?: string | null;
241+
/** Roles the key inherits → resolved to permissions via the role map. */
242+
roleIds?: string[];
243+
scopes?: string[]; // DEPRECATED
244+
createdBy: string;
245+
createdAt: string;
246+
expiresAt?: string | null;
247+
lastUsedAt?: string | null;
248+
revoked: boolean;
249+
revokedAt?: string | null;
250+
}
251+
252+
// Current principal, from GET /me. Drives the SPA's permission gating.
253+
export interface Me {
254+
id: string; // principal id (Keycloak sub) — compare to resource ownerUser
255+
user: string;
256+
displayName?: string | null;
257+
source: "oidc" | "api-key" | "cookie" | "dev";
258+
kind: "user" | "service";
259+
roles: string[];
260+
groups: string[];
261+
permissions: string[];
262+
}
263+
264+
// Editable role → permission map (Settings → Roles).
265+
export interface Role {
266+
_id: string;
267+
description: string;
268+
permissions: string[];
269+
builtin: boolean;
270+
updatedAt?: string;
271+
}
272+
export interface PermissionDef {
273+
key: string;
274+
description: string;
275+
}
276+
277+
// Groups — read-only, sourced from Keycloak (Okta/Keycloak own groups + membership).
278+
export interface Group {
279+
id: string;
280+
name: string;
281+
path: string;
282+
}
283+
export interface GroupMember {
284+
id: string;
285+
username: string | null;
286+
email: string | null;
287+
name: string | null;
288+
roles: string[];
289+
}
290+
291+
// ── Auth: reactive token refresh ─────────────────────────────────────────────
292+
// The BFF session cookie is short-lived (it tracks the Keycloak access-token
293+
// expiry, ~5 min). When a dashboard request 401s we silently POST /auth/refresh
294+
// (which rotates the server-held refresh token and re-signs the cookie) and
295+
// replay the original request once. All concurrent 401s share ONE in-flight
296+
// refresh — refresh tokens rotate and can be spent only once, so a stampede
297+
// would invalidate itself. On a hard refresh failure the session is truly gone:
298+
// we notify AuthContext (→ SSO sign-in screen).
299+
300+
let refreshInFlight: Promise<boolean> | null = null;
301+
let onAuthLost: (() => void) | null = null;
302+
303+
/** AuthContext registers a callback here to flip to the anonymous/login state
304+
* when the refresh token is dead (idle timeout / revocation / logout). */
305+
export function setAuthLostHandler(fn: (() => void) | null): void {
306+
onAuthLost = fn;
307+
}
308+
309+
function tryRefresh(): Promise<boolean> {
310+
if (!refreshInFlight) {
311+
refreshInFlight = fetch(`/api/v1/auth/refresh`, {
312+
method: "POST",
313+
headers: { accept: "application/json" },
314+
credentials: "include",
315+
})
316+
.then((r) => r.ok)
317+
.catch(() => false)
318+
.finally(() => {
319+
refreshInFlight = null;
320+
});
321+
}
322+
return refreshInFlight;
323+
}
324+
325+
/** fetch against the dashboard API with credentials. On 401, refresh once and
326+
* replay; if refresh fails, signal auth-lost and return the 401 response. */
327+
async function authedFetch(path: string, init: RequestInit): Promise<Response> {
328+
const url = `/api/v1${path}`;
329+
const opts: RequestInit = { credentials: "include", ...init };
330+
let r = await fetch(url, opts);
331+
if (r.status === 401) {
332+
const ok = await tryRefresh();
333+
if (ok) {
334+
r = await fetch(url, opts);
335+
} else {
336+
onAuthLost?.();
337+
}
338+
}
339+
return r;
340+
}
341+
222342
async function getJSON<T>(path: string): Promise<T> {
223-
const r = await fetch(`/api${path}`, {
224-
headers: { accept: "application/json" },
225-
credentials: "include",
226-
});
343+
const r = await authedFetch(path, { headers: { accept: "application/json" } });
227344
if (!r.ok) throw new Error(`${path} → ${r.status}`);
228345
return r.json() as Promise<T>;
229346
}
230347
async function postJSON<T>(path: string, body: unknown): Promise<T> {
231-
const r = await fetch(`/api${path}`, {
348+
const r = await authedFetch(path, {
232349
method: "POST",
233350
headers: { "content-type": "application/json", accept: "application/json" },
234-
credentials: "include",
235351
body: JSON.stringify(body),
236352
});
237353
if (!r.ok) throw new Error(`${path} → ${r.status}`);
238354
return r.json() as Promise<T>;
239355
}
240356
async function reqJSON<T>(method: string, path: string, body?: unknown): Promise<T> {
241-
const r = await fetch(`/api${path}`, {
357+
const r = await authedFetch(path, {
242358
method,
243359
headers: { "content-type": "application/json", accept: "application/json" },
244-
credentials: "include",
245360
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
246361
});
247362
if (!r.ok) throw new Error(`${path} → ${r.status}`);
@@ -345,6 +460,13 @@ export const api = {
345460
reqJSON<DeleteResult>("DELETE", `/agents/${encodeURIComponent(agentId)}`),
346461
patchAgent: (agentId: string, fields: Partial<Omit<RegisterAgentInput, "name">>) =>
347462
reqJSON<{ ok: boolean }>("PATCH", `/agents/${encodeURIComponent(agentId)}`, fields),
463+
archiveAgent: (agentId: string) =>
464+
reqJSON<{ ok: boolean; disposed: number; schedulesDisabled: number; warnings: string[] }>(
465+
"POST",
466+
`/agents/${encodeURIComponent(agentId)}/archive`,
467+
),
468+
unarchiveAgent: (agentId: string) =>
469+
reqJSON<{ ok: boolean }>("POST", `/agents/${encodeURIComponent(agentId)}/unarchive`),
348470
logs: (agentId?: string, limit = 100) =>
349471
getJSON<{ logs: LogEntry[] }>(`/logs?limit=${limit}${agentId ? `&agentId=${encodeURIComponent(agentId)}` : ""}`).then((d) => d.logs),
350472
sessions: (agentId?: string, limit = 100) =>
@@ -363,9 +485,9 @@ export const api = {
363485
logWebTurn: (entry: { bot: string; sessionId: string; query: string; reply: string; ok: boolean }) =>
364486
postJSON<{ ok: boolean }>("/logs", { ...entry, requester: "web" }),
365487
// SSE chat — caller reads the stream. Path goes through the same /api proxy.
366-
chatStreamUrl: (sandboxId: string) => `/api/sandboxes/${encodeURIComponent(sandboxId)}/chat`,
488+
chatStreamUrl: (sandboxId: string) => `/api/v1/sandboxes/${encodeURIComponent(sandboxId)}/chat`,
367489
// SSE one-shot run (deepagents). Server builds the /run body from {message}.
368-
runStreamUrl: (agentId: string) => `/api/agents/${encodeURIComponent(agentId)}/run`,
490+
runStreamUrl: (agentId: string) => `/api/v1/agents/${encodeURIComponent(agentId)}/run`,
369491
// Schedules
370492
schedules: (agentId?: string) =>
371493
getJSON<{ schedules: Schedule[] }>(`/schedules${agentId ? `?agentId=${encodeURIComponent(agentId)}` : ""}`).then((d) => d.schedules),
@@ -397,6 +519,45 @@ export const api = {
397519
deleteOpaPolicy: (id: string) =>
398520
reqJSON<{ success?: boolean }>("DELETE", `/opa-policies/${encodeURIComponent(id)}`),
399521

522+
// Current principal + session.
523+
auth: {
524+
me: () => getJSON<Me>("/me"),
525+
logout: () => postJSON<{ ok: boolean; logoutUrl?: string }>("/logout", {}),
526+
loginUrl: () => "/api/v1/auth/login",
527+
},
528+
529+
// Roles — editable role→permission map + the permission catalog.
530+
roles: {
531+
list: () => getJSON<{ roles: Role[] }>("/roles").then((d) => d.roles),
532+
permissions: () => getJSON<{ permissions: PermissionDef[] }>("/permissions").then((d) => d.permissions),
533+
create: (body: { name: string; description?: string; permissions: string[] }) =>
534+
postJSON<{ role: Role }>("/roles", body).then((d) => d.role),
535+
update: (id: string, body: { description?: string; permissions?: string[] }) =>
536+
reqJSON<{ role: Role }>("PUT", `/roles/${encodeURIComponent(id)}`, body).then((d) => d.role),
537+
remove: (id: string) => reqJSON<{ ok: boolean }>("DELETE", `/roles/${encodeURIComponent(id)}`),
538+
},
539+
540+
// Groups — read-only window into Keycloak (creation/membership live in Okta/KC).
541+
groups: {
542+
list: () => getJSON<{ groups: Group[] }>("/groups").then((d) => d.groups),
543+
members: (id: string) =>
544+
getJSON<{ members: GroupMember[]; truncated: boolean }>(`/groups/${encodeURIComponent(id)}/members`),
545+
},
546+
547+
// API keys — mint (plaintext returned once), list (redacted), revoke. A key is
548+
// minted bound to a group/role and inherits its permissions.
549+
apiKeys: {
550+
list: () => getJSON<{ apiKeys: ApiKey[] }>("/api-keys").then((d) => d.apiKeys),
551+
create: (label: string, opts?: { expiresAt?: string | null; group?: string | null; roleIds?: string[] }) =>
552+
postJSON<{ key: string; apiKey: ApiKey }>("/api-keys", {
553+
label,
554+
...(opts?.expiresAt ? { expiresAt: opts.expiresAt } : {}),
555+
...(opts?.group ? { group: opts.group } : {}),
556+
...(opts?.roleIds?.length ? { roleIds: opts.roleIds } : {}),
557+
}),
558+
revoke: (id: string) => reqJSON<{ ok: boolean }>("DELETE", `/api-keys/${encodeURIComponent(id)}`),
559+
},
560+
400561
// Evals — suite CRUD + run trigger + run readback.
401562
evals: {
402563
listSuites: () => getJSON<{ suites: EvalSuite[] }>("/evals/suites").then((d) => d.suites),

‎agentos/src/components/AgentCard.tsx‎

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GitBranch, FolderTree, Code2, MessageSquare, Activity, Clock, ExternalLink, Trash2 } from "lucide-react";
1+
import { GitBranch, FolderTree, Code2, MessageSquare, Activity, Clock, ExternalLink, Trash2, Archive, ArchiveRestore } from "lucide-react";
22
import { type Agent, displaySource } from "../api.ts";
33
import { Badge } from "./ui/badge.tsx";
44
import { cn } from "../lib/cn.ts";
@@ -36,12 +36,18 @@ export function AgentCard({
3636
selected,
3737
onClick,
3838
onDelete,
39+
onArchive,
40+
onUnarchive,
3941
}: {
4042
agent: Agent;
4143
selected: boolean;
4244
onClick: () => void;
4345
/** When provided, a trash affordance shows on hover (used by the registry). */
4446
onDelete?: () => void;
47+
/** When provided (active agents), an archive affordance shows on hover. */
48+
onArchive?: () => void;
49+
/** When provided (archived agents), an unarchive affordance shows on hover. */
50+
onUnarchive?: () => void;
4551
}) {
4652
// Show the registered name exactly as typed — never derive it from the
4753
// source URL. The source/repo still renders on its own line below.
@@ -58,24 +64,59 @@ export function AgentCard({
5864
className={cn(
5965
"group relative w-full text-left rounded-xl border bg-background transition overflow-hidden shadow-sm",
6066
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
67+
a.archived && "opacity-60 saturate-50",
6168
selected
6269
? "border-primary/50 ring-1 ring-primary/30 shadow-md shadow-primary/10"
6370
: "border-border/60 hover:border-border hover:bg-muted/30 hover:shadow-md",
6471
)}
6572
>
66-
{onDelete && (
67-
<span
68-
role="button"
69-
tabIndex={0}
70-
title="Delete agent"
71-
aria-label={`Delete ${a.name}`}
72-
onClick={(e) => { e.stopPropagation(); onDelete(); }}
73-
onKeyDown={(e) => {
74-
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onDelete(); }
75-
}}
76-
className="absolute right-2 top-2 z-10 h-6 w-6 grid place-items-center rounded-md text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-opacity cursor-pointer"
77-
>
78-
<Trash2 className="h-3.5 w-3.5" />
73+
{(onDelete || onArchive || onUnarchive) && (
74+
<span className="absolute right-2 top-2 z-10 flex items-center gap-1">
75+
{onArchive && (
76+
<span
77+
role="button"
78+
tabIndex={0}
79+
title="Archive agent"
80+
aria-label={`Archive ${a.name}`}
81+
onClick={(e) => { e.stopPropagation(); onArchive(); }}
82+
onKeyDown={(e) => {
83+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onArchive(); }
84+
}}
85+
className="h-6 w-6 grid place-items-center rounded-md text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:bg-amber-500/10 hover:text-amber-500 transition-opacity cursor-pointer"
86+
>
87+
<Archive className="h-3.5 w-3.5" />
88+
</span>
89+
)}
90+
{onUnarchive && (
91+
<span
92+
role="button"
93+
tabIndex={0}
94+
title="Unarchive agent"
95+
aria-label={`Unarchive ${a.name}`}
96+
onClick={(e) => { e.stopPropagation(); onUnarchive(); }}
97+
onKeyDown={(e) => {
98+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onUnarchive(); }
99+
}}
100+
className="h-6 w-6 grid place-items-center rounded-md text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:bg-emerald-500/10 hover:text-emerald-500 transition-opacity cursor-pointer"
101+
>
102+
<ArchiveRestore className="h-3.5 w-3.5" />
103+
</span>
104+
)}
105+
{onDelete && (
106+
<span
107+
role="button"
108+
tabIndex={0}
109+
title="Delete agent"
110+
aria-label={`Delete ${a.name}`}
111+
onClick={(e) => { e.stopPropagation(); onDelete(); }}
112+
onKeyDown={(e) => {
113+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onDelete(); }
114+
}}
115+
className="h-6 w-6 grid place-items-center rounded-md text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-opacity cursor-pointer"
116+
>
117+
<Trash2 className="h-3.5 w-3.5" />
118+
</span>
119+
)}
79120
</span>
80121
)}
81122

@@ -118,6 +159,11 @@ export function AgentCard({
118159
1-shot
119160
</Badge>
120161
)}
162+
{a.archived && (
163+
<Badge variant="outline" className="shrink-0 h-4 text-[9px] uppercase tracking-wider px-1.5 border-muted-foreground/40 text-muted-foreground" title="archived — cannot run until unarchived">
164+
archived
165+
</Badge>
166+
)}
121167
</div>
122168
<div className="mt-0.5 text-[10.5px] text-muted-foreground/80 font-mono truncate" title={a.harness}>
123169
{a.harness}

0 commit comments

Comments
 (0)