diff --git a/agentos/src/components/AgentCard.tsx b/agentos/src/components/AgentCard.tsx index 4a8221a..42f1172 100644 --- a/agentos/src/components/AgentCard.tsx +++ b/agentos/src/components/AgentCard.tsx @@ -1,4 +1,4 @@ -import { GitBranch, FolderTree, Code2, MessageSquare, Activity, Clock, ExternalLink, Trash2, Archive, ArchiveRestore } from "lucide-react"; +import { GitBranch, FolderTree, Code2, MessageSquare, Activity, Clock, ExternalLink, Trash2, Archive, ArchiveRestore, Users2, Cpu, ArrowUpRight } from "lucide-react"; import { type Agent, displaySource } from "../api.ts"; import { Badge } from "./ui/badge.tsx"; import { cn } from "../lib/cn.ts"; @@ -19,17 +19,28 @@ function timeAgo(iso: string | null): string { return `${Math.floor(s / 86400)}d`; } +/** Compact, human model label: drop a provider prefix ("anthropic:…") and the + * "claude-" vendor prefix so "claude-haiku-4-5" → "haiku-4-5". */ +function shortModel(model: string | null): string | null { + if (!model) return null; + const afterProvider = model.includes(":") ? model.slice(model.lastIndexOf(":") + 1) : model; + return afterProvider.replace(/^claude-/, ""); +} + /** - * Single agent card for the rail. + * Single agent card for the registry grid (Refined layout). * - * Two zones with a hairline divider between them: - * Top: avatar (harness logo on white) + name + status pill + LIB/1-shot chip - * source line: owner/repo with kind glyph + clickable external icon - * Bottom: stat row — sessions / logs / lastActivity, each with icon + * Top: avatar (harness logo / gradient initial) + live status dot + * name + quiet badges (lib / 1-shot / archived) + * status pill (Live ×N / Idle) · harness + * chips: model · owner group + * Source: owner/repo with kind glyph + external-link icon + * Footer: sessions / logs / last activity * - * Click anywhere on the card opens the agent's workspace (chat). The - * external-link icon is the only nested clickable; stopPropagation - * prevents card-click bubbling. + * The whole card is a button that opens the agent's workspace. Nested + * affordances (hover actions, source link) stopPropagation. Hover actions are + * passed in by the parent only when the caller is permitted (RBAC), so absence + * of a handler hides the control. */ export function AgentCard({ agent: a, @@ -57,6 +68,7 @@ export function AgentCard({ const initial = displayName.charAt(0).toUpperCase(); const isLive = a.activeSandboxes > 0; const sd = displaySource(a.source); + const model = shortModel(a.model); return ( ); diff --git a/agentos/src/components/RegistryPage.tsx b/agentos/src/components/RegistryPage.tsx index 473b08e..887dd9f 100644 --- a/agentos/src/components/RegistryPage.tsx +++ b/agentos/src/components/RegistryPage.tsx @@ -167,7 +167,7 @@ export function RegistryPage({ {err &&
{err}
} {!loaded && !err && ( -
+
{Array.from({ length: 8 }).map((_, i) => ( ))} @@ -192,7 +192,7 @@ export function RegistryPage({ {grouped.hosted.length > 0 && (
-
+
{grouped.hosted.map((a) => ( 0 && (
-
+
{grouped.library.map((a) => ( 0 && (
-
+
{grouped.archived.map((a) => ( => - obsApi - .fieldValues("agent", 100) - .then((rows) => rows.map((r) => ({ value: r.value, count: r.count }))); +import { FieldValueFilter } from "./FieldValueFilter.tsx"; export function AgentFilter({ value, @@ -20,27 +12,13 @@ export function AgentFilter({ onChange: (v: string) => void; }) { return ( -
- - {value && ( - - )} -
+ ); } diff --git a/agentos/src/components/observability/Dashboard.tsx b/agentos/src/components/observability/Dashboard.tsx index d07978c..c412042 100644 --- a/agentos/src/components/observability/Dashboard.tsx +++ b/agentos/src/components/observability/Dashboard.tsx @@ -24,7 +24,19 @@ import { // reference even when ChartContainer (which uses it internally) is the only call site. void _RC; -export function Dashboard({ agent, from, to }: { agent?: string; from: string; to: string }) { +export function Dashboard({ + agent, + group, + actor, + from, + to, +}: { + agent?: string; + group?: string; + actor?: string; + from: string; + to: string; +}) { const [data, setData] = useState(null); const [err, setErr] = useState(null); const [loading, setLoading] = useState(true); @@ -32,14 +44,14 @@ export function Dashboard({ agent, from, to }: { agent?: string; from: string; t useEffect(() => { setLoading(true); obsApi - .dashboard({ agent, from, to }) + .dashboard({ agent, group, actor, from, to }) .then((d) => { setData(d); setErr(null); }) .catch((e) => setErr(String(e))) .finally(() => setLoading(false)); - }, [agent, from, to]); + }, [agent, group, actor, from, to]); if (err) return
{err}
; if (loading || !data) { diff --git a/agentos/src/components/observability/FieldValueFilter.tsx b/agentos/src/components/observability/FieldValueFilter.tsx new file mode 100644 index 0000000..f516218 --- /dev/null +++ b/agentos/src/components/observability/FieldValueFilter.tsx @@ -0,0 +1,58 @@ +// Generic selector for the Observability views. Sources its options from the +// live `/v1/fields/:field/values` endpoint, which is RBAC-scoped server-side +// (ownerScopeFor) — so the dropdown lists exactly the values the caller may see +// for that field (agent name, owning group, actor, …). Empty value = no filter. + +import { X } from "lucide-react"; +import { obsApi } from "../../obs-api.ts"; +import { Combobox, type ComboOption } from "../ui/combobox.tsx"; +import { Button } from "../ui/button.tsx"; + +export function FieldValueFilter({ + field, + value, + onChange, + placeholder, + emptyMessage, + width = "w-[180px]", + clearLabel, +}: { + /** Backend field key, e.g. "agent" | "group_id" | "actor_id". */ + field: string; + value: string; + onChange: (v: string) => void; + placeholder: string; + emptyMessage: string; + width?: string; + clearLabel?: string; +}) { + const loadOptions = (): Promise => + obsApi + .fieldValues(field, 100) + .then((rows) => rows.map((r) => ({ value: r.value, count: r.count }))); + + return ( +
+ + {value && ( + + )} +
+ ); +} diff --git a/agentos/src/components/observability/ObservabilityTab.tsx b/agentos/src/components/observability/ObservabilityTab.tsx index c4d381e..1a37249 100644 --- a/agentos/src/components/observability/ObservabilityTab.tsx +++ b/agentos/src/components/observability/ObservabilityTab.tsx @@ -6,7 +6,7 @@ import { TraceList } from "./TraceList.tsx"; import { TraceDetail } from "./TraceDetail.tsx"; import { Dashboard } from "./Dashboard.tsx"; import { DateRangePicker, type Range } from "./DateRangePicker.tsx"; -import { AgentFilter } from "./AgentFilter.tsx"; +import { FieldValueFilter } from "./FieldValueFilter.tsx"; import { PageHeader } from "../composite/PageHeader.tsx"; import { StatusDot } from "../composite/StatusDot.tsx"; import { Tabs, TabsList, TabsTrigger } from "../ui/tabs.tsx"; @@ -22,6 +22,8 @@ export function ObservabilityTab() { const [sub, setSub] = useState("dashboard"); const [range, setRange] = useState({ from: "now-15m", to: "now" }); const [agent, setAgent] = useState(""); + const [group, setGroup] = useState(""); + const [actor, setActor] = useState(""); const [filters, setFilters] = useState([]); const [traces, setTraces] = useState([]); const [loading, setLoading] = useState(false); @@ -37,10 +39,16 @@ export function ObservabilityTab() { .catch(() => setChOk(false)); }, []); - // Merge the agent selector into the explicit filter list — the agent acts as - // an implicit `agent eq ` AND-filter on top of whatever the QueryBuilder holds. - const effectiveFilters = (): Filter[] => - agent ? [{ field: "agent", op: "eq", value: agent }, ...filters] : filters; + // Merge the header selectors into the explicit filter list — agent / group / + // actor each act as an implicit ` eq ` AND-filter on top of + // whatever the QueryBuilder holds. All are RBAC-scoped server-side. + const effectiveFilters = (): Filter[] => { + const implicit: Filter[] = []; + if (agent) implicit.push({ field: "agent", op: "eq", value: agent }); + if (group) implicit.push({ field: "group_id", op: "eq", value: group }); + if (actor) implicit.push({ field: "actor_id", op: "eq", value: actor }); + return [...implicit, ...filters]; + }; // Fetch the first (newest) page, replacing any existing rows. const run = () => { @@ -79,7 +87,7 @@ export function ObservabilityTab() { useEffect(() => { if (sub === "explorer") run(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [range.from, range.to, sub, agent]); + }, [range.from, range.to, sub, agent, group, actor]); const healthLabel = chOk == null ? "checking…" : chOk ? "ClickHouse up" : "ClickHouse down"; const healthStatus = chOk == null ? "loading" : chOk ? "live" : "error"; @@ -98,7 +106,29 @@ export function ObservabilityTab() { - + + + @@ -107,7 +137,13 @@ export function ObservabilityTab() { {sub === "dashboard" ? (
- +
) : (
diff --git a/agentos/src/obs-api.ts b/agentos/src/obs-api.ts index 0ea3bcc..7197ed8 100644 --- a/agentos/src/obs-api.ts +++ b/agentos/src/obs-api.ts @@ -122,9 +122,11 @@ export const obsApi = { postJSON<{ traces: TraceSummary[] }>(`/v1/traces/search`, q).then((d) => d.traces), trace: (traceId: string) => getJSON(`/v1/traces/${encodeURIComponent(traceId)}`), - dashboard: (opts: { agent?: string; from?: string; to?: string } = {}) => { + dashboard: (opts: { agent?: string; group?: string; actor?: string; from?: string; to?: string } = {}) => { const params = new URLSearchParams(); if (opts.agent) params.set("agent", opts.agent); + if (opts.group) params.set("group", opts.group); + if (opts.actor) params.set("actor", opts.actor); if (opts.from) params.set("from", opts.from); if (opts.to) params.set("to", opts.to); const qs = params.toString(); diff --git a/agentos/src/obs-fields.ts b/agentos/src/obs-fields.ts index e46e7a3..2c3a238 100644 --- a/agentos/src/obs-fields.ts +++ b/agentos/src/obs-fields.ts @@ -44,6 +44,10 @@ export const FIELDS: FieldDef[] = [ { key: "provider", label: "Provider", type: "string", ops: ["eq", "neq", "in", "not_in", "exists"] }, { key: "tool", label: "Tool", type: "string", ops: ["eq", "neq", "in", "contains", "exists"] }, { key: "conversation_id", label: "Conversation", type: "string", ops: ["eq", "contains"] }, + // RBAC / multi-tenancy identity (stamped by the computeragent-server flow). + { key: "group_id", label: "Group", type: "string", ops: ["eq", "neq", "in", "not_in", "exists"] }, + { key: "owner_id", label: "Owner", type: "string", ops: ["eq", "neq", "in", "not_in", "exists"] }, + { key: "actor_id", label: "Actor", type: "string", ops: ["eq", "neq", "in", "not_in", "exists"] }, { key: "service", label: "Service", type: "string", ops: ["eq", "neq", "in"] }, { key: "span_name", label: "Span Name", type: "string", ops: ["eq", "neq", "contains"] }, { key: "duration_ms", label: "Duration (ms)", type: "number", ops: ["gt", "gte", "lt", "lte", "eq"] }, diff --git a/examples/computeragent-server.ts b/examples/computeragent-server.ts index ef8fa78..c24d3d4 100644 --- a/examples/computeragent-server.ts +++ b/examples/computeragent-server.ts @@ -40,7 +40,12 @@ import { streamSSE } from "hono/streaming"; import { serve, type ServerType } from "@hono/node-server"; import { ComputerAgent, LocalSubstrate } from "computeragent"; import type { IdentitySource, Substrate } from "computeragent"; -import { makeApiKeyVerifier, type ApiKeyVerifier } from "./introspection-auth.ts"; +import { + makeApiKeyVerifier, + requiredPermissionFor, + principalHasPermission, + type ApiKeyVerifier, +} from "./introspection-auth.ts"; import type { HarnessEvent, PersistedEvent, @@ -826,6 +831,10 @@ export class ComputerAgentServer { const verifier: ApiKeyVerifier | null = apiKeyEnabled ? makeApiKeyVerifier({ url: introspectUrl!, serviceSecret: introspectSecret!, logger: console }) : null; + // One-shot warning when introspection returns no `permissions` (an AgentOS + // that predates capability resolution): capability checks are then disabled + // for back-compat. Logged once so a rolling upgrade is visible, not silent. + let warnedNoPerms = false; if (basicEnabled || apiKeyEnabled) { const expected = basicEnabled @@ -845,12 +854,34 @@ export class ComputerAgentServer { } catch { /* length mismatch — fall through */ } } - // (b) API key — Authorization: Bearer cak_… validated via introspection. + // (b) API key — Authorization: Bearer cak_… validated via introspection, + // THEN a per-route capability check. AgentOS resolves the key's roles + // to effective permissions; the CAS gates each route on one of them + // (GET → agents:read, execute/mutate → agents:run). A valid-but- + // under-privileged key (e.g. a viewer key) gets 403, not 401. + // NB: the Basic branch above is the agentos-server loopback path + // (caAuthHeader sends Basic) — it already enforced full RBAC + + // ownership, so it intentionally bypasses this capability check. if (verifier) { const m = /^Bearer\s+(.+)$/i.exec(got); if (m) { const principal = await verifier(m[1]!); - if (principal) return next(); + if (principal) { + if (principal.permissions === undefined && !warnedNoPerms) { + warnedNoPerms = true; + console.warn( + "[auth] introspection returned no `permissions` field — capability " + + "checks DISABLED (back-compat). Upgrade AgentOS so it resolves API-key " + + "roles to permissions, then the CAS enforces agents:run / agents:read.", + ); + } + const required = requiredPermissionFor(c.req.method, path); + if (principalHasPermission(principal, required)) return next(); + return c.json( + { error: { code: "INSUFFICIENT_PERMISSION", message: `requires ${required}` } }, + 403, + ); + } } } diff --git a/examples/introspection-auth.test.ts b/examples/introspection-auth.test.ts index 7c7ee66..70579ed 100644 --- a/examples/introspection-auth.test.ts +++ b/examples/introspection-auth.test.ts @@ -5,7 +5,12 @@ // shape (service bearer + {key} body). import { describe, expect, it, vi } from "vitest"; -import { makeApiKeyVerifier } from "./introspection-auth.ts"; +import { + makeApiKeyVerifier, + requiredPermissionFor, + principalHasPermission, + type ApiKeyPrincipal, +} from "./introspection-auth.ts"; function jsonResponse(body: unknown, ok = true, status = 200) { return { ok, status, json: async () => body } as unknown as Response; @@ -28,6 +33,34 @@ describe("makeApiKeyVerifier", () => { expect(JSON.parse((init as any).body)).toEqual({ key: "cak_abc" }); }); + it("captures resolved permissions + group from the introspection response", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + active: true, + principal: "key_1", + permissions: ["agents:run", "agents:read"], + group: "team-a", + scopes: ["*"], + }), + ); + const verify = makeApiKeyVerifier({ ...base, fetchImpl: fetchImpl as unknown as typeof fetch }); + + const r = await verify("cak_abc"); + expect(r).toEqual({ + principal: "key_1", + permissions: ["agents:run", "agents:read"], + group: "team-a", + scopes: ["*"], + }); + }); + + it("leaves permissions undefined when the response omits it (old AgentOS)", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ active: true, principal: "key_1" })); + const verify = makeApiKeyVerifier({ ...base, fetchImpl: fetchImpl as unknown as typeof fetch }); + const r = await verify("cak_abc"); + expect(r?.permissions).toBeUndefined(); + }); + it("caches a positive result (no 2nd fetch within TTL)", async () => { const fetchImpl = vi.fn(async () => jsonResponse({ active: true, principal: "key_1" })); const verify = makeApiKeyVerifier({ ...base, fetchImpl: fetchImpl as unknown as typeof fetch }); @@ -72,3 +105,45 @@ describe("makeApiKeyVerifier", () => { expect(fetchImpl).toHaveBeenCalledTimes(3); }); }); + +describe("requiredPermissionFor", () => { + it("maps GET → agents:read", () => { + expect(requiredPermissionFor("GET", "/sandboxes")).toBe("agents:read"); + expect(requiredPermissionFor("get", "/sandboxes/abc/artifact")).toBe("agents:read"); + }); + + it("maps execute/mutate methods → agents:run", () => { + expect(requiredPermissionFor("POST", "/run")).toBe("agents:run"); + expect(requiredPermissionFor("POST", "/sandboxes/abc/chat")).toBe("agents:run"); + expect(requiredPermissionFor("DELETE", "/sandboxes/abc")).toBe("agents:run"); + }); +}); + +describe("principalHasPermission", () => { + const withPerms = (permissions?: string[]): ApiKeyPrincipal => ({ principal: "key_1", ...(permissions ? { permissions } : {}) }); + + it("allows when the required permission is held", () => { + expect(principalHasPermission(withPerms(["agents:run", "agents:read"]), "agents:run")).toBe(true); + }); + + it("allows anything for the wildcard (admin key)", () => { + expect(principalHasPermission(withPerms(["*"]), "agents:run")).toBe(true); + expect(principalHasPermission(withPerms(["*"]), "agents:read")).toBe(true); + }); + + it("denies a viewer key (read-only) from a run route", () => { + expect(principalHasPermission(withPerms(["agents:read"]), "agents:run")).toBe(false); + }); + + it("denies when the key has an empty permission set", () => { + expect(principalHasPermission(withPerms([]), "agents:run")).toBe(false); + }); + + it("back-compat: allows when permissions is undefined (old AgentOS)", () => { + expect(principalHasPermission(withPerms(undefined), "agents:run")).toBe(true); + }); + + it("returns true when no permission is required (null)", () => { + expect(principalHasPermission(withPerms(["agents:read"]), null)).toBe(true); + }); +}); diff --git a/examples/introspection-auth.ts b/examples/introspection-auth.ts index 6b6ee35..9d1424b 100644 --- a/examples/introspection-auth.ts +++ b/examples/introspection-auth.ts @@ -33,9 +33,50 @@ export interface ApiKeyVerifierOptions { export interface ApiKeyPrincipal { principal: string; + /** + * Effective permission keys resolved from the key's roles by AgentOS (e.g. + * `["agents:run", "agents:read", ...]` or `["*"]` for admin). Gated per route + * by {@link requiredPermissionFor} + {@link principalHasPermission}. + * + * `undefined` means the introspection response carried NO `permissions` field + * — an AgentOS that predates capability resolution. The gate treats that as a + * back-compat "active ⇒ allow" to avoid breaking a rolling upgrade; as soon as + * AgentOS returns `permissions` (even `[]`), enforcement kicks in. + */ + permissions?: string[]; + /** The key's bound group (tenancy), if any. Captured for audit/identity. */ + group?: string | null; + /** @deprecated Always `["*"]` from AgentOS; retained for back-compat only. */ scopes?: string[]; } +/** Permission catalog keys the CAS gates on (subset of AgentOS's catalog). */ +const PERM_AGENTS_RUN = "agents:run"; +const PERM_AGENTS_READ = "agents:read"; +const WILDCARD = "*"; + +/** + * Map an inbound CAS request to the permission it requires. The CAS surface is + * small and uniform: reads (GET) need `agents:read`; everything that executes or + * mutates an agent/sandbox/task (POST/DELETE on /run, /sandboxes, /tasks, …) + * needs `agents:run`. `/health` + `/slack/*` are unauthenticated and never reach + * here. Returns null when no permission is required. + */ +export function requiredPermissionFor(method: string, _path: string): string | null { + return method.toUpperCase() === "GET" ? PERM_AGENTS_READ : PERM_AGENTS_RUN; +} + +/** + * Capability check for a verified key. `["*"]` satisfies anything. A principal + * with NO `permissions` field (old AgentOS) is allowed for back-compat (see + * {@link ApiKeyPrincipal.permissions}); the caller logs that case once. + */ +export function principalHasPermission(p: ApiKeyPrincipal, perm: string | null): boolean { + if (perm === null) return true; + if (p.permissions === undefined) return true; // back-compat: pre-capability AgentOS + return p.permissions.includes(WILDCARD) || p.permissions.includes(perm); +} + export type ApiKeyVerifier = (token: string) => Promise; interface CacheEntry { @@ -100,13 +141,23 @@ export function makeApiKeyVerifier(opts: ApiKeyVerifierOptions): ApiKeyVerifier cacheSet(cacheKey, null); // fail-closed return null; } - const data = (await resp.json()) as { active?: boolean; principal?: string; scopes?: string[] }; + const data = (await resp.json()) as { + active?: boolean; + principal?: string; + permissions?: string[]; + group?: string | null; + scopes?: string[]; + }; if (!data?.active || !data.principal) { cacheSet(cacheKey, null); return null; } const value: ApiKeyPrincipal = { principal: data.principal, + // Preserve `undefined` (field absent) vs `[]` (present, no perms) — the + // gate distinguishes them for the rolling-upgrade back-compat path. + ...(Array.isArray(data.permissions) ? { permissions: data.permissions } : {}), + ...(typeof data.group === "string" ? { group: data.group } : {}), ...(Array.isArray(data.scopes) ? { scopes: data.scopes } : {}), }; cacheSet(cacheKey, value); diff --git a/packages/agentos-server/src/auth/authorize.ts b/packages/agentos-server/src/auth/authorize.ts index f99e575..74e9aec 100644 --- a/packages/agentos-server/src/auth/authorize.ts +++ b/packages/agentos-server/src/auth/authorize.ts @@ -70,8 +70,12 @@ export const resolvePermissions: RequestHandler = (_req, res, next) => { * empty (the token carried no role that maps to an AgentOS role) and * AGENTOS_DEFAULT_ROLE is set, fall back to that role's permissions so any * authenticated user has a baseline (e.g. agentos-viewer). Unset → deny-by-default. + * + * Exported so the key-introspection endpoint resolves an API key's `roleIds` + * to the SAME effective permission set the dashboard uses — keeping the role map + * server-side (the CAS receives resolved permissions, never the raw role map). */ -async function resolveEffectivePermissions(roles: string[]): Promise { +export async function resolveEffectivePermissions(roles: string[]): Promise { const perms = await roleStore.permissionsFor(roles); if (perms.length > 0) return perms; const fallback = process.env["AGENTOS_DEFAULT_ROLE"]; diff --git a/packages/agentos-server/src/auth/keycloak-admin.ts b/packages/agentos-server/src/auth/keycloak-admin.ts index 7a70ddf..55a91bd 100644 --- a/packages/agentos-server/src/auth/keycloak-admin.ts +++ b/packages/agentos-server/src/auth/keycloak-admin.ts @@ -92,6 +92,15 @@ export function listGroupMembers(groupId: string, max = 100): Promise(`/groups/${encodeURIComponent(groupId)}/members?max=${max}`); } +/** Group names a user belongs to, normalized the same way the token's `groups` + * claim is (leading "/" stripped from the path) so they compare equal to a + * resource's stored `ownerGroup`. Used as a fallback when the access token + * carries no `groups` claim (missing Group Membership mapper). */ +export async function listUserGroups(userId: string): Promise { + const groups = await adminGet(`/users/${encodeURIComponent(userId)}/groups?max=200`); + return groups.map((g) => (g.path ?? g.name ?? "").replace(/^\//, "")).filter(Boolean); +} + /** Directly-assigned realm role names for a user (the per-user capability). */ export async function listUserRealmRoles(userId: string): Promise { const roles = await adminGet>( diff --git a/packages/agentos-server/src/fields.identity.test.ts b/packages/agentos-server/src/fields.identity.test.ts new file mode 100644 index 0000000..eb46311 --- /dev/null +++ b/packages/agentos-server/src/fields.identity.test.ts @@ -0,0 +1,29 @@ +// Guards the RBAC identity fields in the query-builder whitelist. The obs UI's +// Group/Actor dropdowns hit GET /fields/:name/values, which 404s unless the +// field is in FIELDS. These entries must also map to the correct +// `computeragent.*` span attributes (ClickHouse) / NRQL paths, or the dropdowns +// + the implicit group_id/actor_id eq-filters would silently match nothing. + +import { describe, expect, it } from "vitest"; +import { FIELDS } from "./fields.js"; + +describe("FIELDS — RBAC identity fields", () => { + const cases: Array<{ key: string; sqlExpr: string; nrqlAttr: string }> = [ + { key: "group_id", sqlExpr: "SpanAttributes['computeragent.group.id']", nrqlAttr: "computeragent.group.id" }, + { key: "owner_id", sqlExpr: "SpanAttributes['computeragent.owner.id']", nrqlAttr: "computeragent.owner.id" }, + { key: "actor_id", sqlExpr: "SpanAttributes['computeragent.actor.id']", nrqlAttr: "computeragent.actor.id" }, + ]; + + for (const c of cases) { + it(`registers ${c.key} mapped to the right span attribute`, () => { + const def = FIELDS[c.key]; + expect(def, `${c.key} must be in the FIELDS whitelist`).toBeDefined(); + expect(def!.sqlExpr).toBe(c.sqlExpr); + expect(def!.nrqlAttr).toBe(c.nrqlAttr); + // The dropdowns filter by equality + membership; autocomplete needs string type. + expect(def!.type).toBe("string"); + expect(def!.ops).toContain("eq"); + expect(def!.ops).toContain("in"); + }); + } +}); diff --git a/packages/agentos-server/src/routes/auth.ts b/packages/agentos-server/src/routes/auth.ts index 23be6bf..69315de 100644 --- a/packages/agentos-server/src/routes/auth.ts +++ b/packages/agentos-server/src/routes/auth.ts @@ -23,6 +23,7 @@ import { import { authenticate } from "../auth/authenticate.js"; import { resolvePermissions } from "../auth/authorize.js"; import type { Principal } from "../auth/principal.js"; +import { keycloakAdminConfigured, listUserGroups } from "../auth/keycloak-admin.js"; import { oidcConfigured, makeState, @@ -58,6 +59,21 @@ interface OidcTx { // (seconds). Caps how long an idle tab can silently refresh before re-login. const REFRESH_FALLBACK_SEC = parseInt(process.env["AGENTOS_REFRESH_MAX_AGE_SEC"] ?? "1800", 10); +// Tenancy depends on the principal's groups, which come from the token's +// `groups` claim. If that claim is absent (no Group Membership mapper on the +// client), fall back to the Keycloak Admin API so group-scoped visibility still +// works. Best-effort: any failure leaves groups as-is. +async function withGroups(principal: Principal): Promise { + if (principal.groups.length > 0 || !principal.id || !keycloakAdminConfigured()) return principal; + try { + const groups = await listUserGroups(principal.id); + if (groups.length) return { ...principal, groups }; + } catch { + /* admin API unavailable / insufficient role — leave groups empty */ + } + return principal; +} + function accessTokenExpMs(accessToken: string): number { try { const [, body] = accessToken.split("."); @@ -154,7 +170,7 @@ authRouter.get("/auth/callback", async (req, res, next) => { } const tokens = await exchangeCode(code, tx.verifier); - const principal = claimsToPrincipal(await verifyAccessToken(tokens.access_token)); + const principal = await withGroups(claimsToPrincipal(await verifyAccessToken(tokens.access_token))); setSessionCookies(res, tokens, principal); res.redirect("/"); } catch (err) { @@ -200,7 +216,7 @@ authRouter.post("/auth/refresh", async (req, res, next) => { res.clearCookie(ID_TOKEN_COOKIE, { path: "/" }); return res.status(401).json({ error: { code: "REFRESH_FAILED" } }); } - const principal = claimsToPrincipal(await verifyAccessToken(tokens.access_token)); + const principal = await withGroups(claimsToPrincipal(await verifyAccessToken(tokens.access_token))); setSessionCookies(res, tokens, principal); res.json({ ok: true }); } catch (err) { diff --git a/packages/agentos-server/src/routes/keys-introspect.ts b/packages/agentos-server/src/routes/keys-introspect.ts index 1103ac4..d274c98 100644 --- a/packages/agentos-server/src/routes/keys-introspect.ts +++ b/packages/agentos-server/src/routes/keys-introspect.ts @@ -5,6 +5,7 @@ import { Router, type Router as IRouter } from "express"; import { apiKeyStore } from "../stores/api-key-store.js"; +import { resolveEffectivePermissions } from "../auth/authorize.js"; export const keysIntrospectRouter: IRouter = Router(); @@ -16,10 +17,15 @@ keysIntrospectRouter.post("/keys/introspect", async (req, res, next) => { } const result = await apiKeyStore.verify(key); if (!result) return res.json({ active: false }); + // Resolve the key's roleIds to the SAME effective permissions the dashboard + // uses, so the ComputerAgent server can enforce capability (e.g. agents:run) + // without ever holding the role→permission map. `["*"]` = admin. + const permissions = await resolveEffectivePermissions(result.roleIds); res.json({ active: true, principal: result.principal, roleIds: result.roleIds, + permissions, // resolved capability set — the CAS gates routes on this group: result.group ?? null, scopes: result.scopes, // DEPRECATED — back-compat for existing CAS verifier ...(result.expiresAt ? { exp: Math.floor(result.expiresAt.getTime() / 1000) } : {}), diff --git a/packages/agentos-server/src/routes/obs-dashboard.ts b/packages/agentos-server/src/routes/obs-dashboard.ts index e15e467..f258617 100644 --- a/packages/agentos-server/src/routes/obs-dashboard.ts +++ b/packages/agentos-server/src/routes/obs-dashboard.ts @@ -47,6 +47,8 @@ obsDashboardRouter.get("/dashboard", async (req, res, next) => { const from = typeof req.query["from"] === "string" ? req.query["from"] : "now-24h"; const to = typeof req.query["to"] === "string" ? req.query["to"] : "now"; const agent = typeof req.query["agent"] === "string" ? req.query["agent"] : undefined; + const group = typeof req.query["group"] === "string" ? req.query["group"] : undefined; + const actor = typeof req.query["actor"] === "string" ? req.query["actor"] : undefined; const fromDate = parseTime(from); const toDate = parseTime(to); @@ -63,8 +65,8 @@ obsDashboardRouter.get("/dashboard", async (req, res, next) => { const scope = ownerScopeFor(res.locals.principal); const payload = traceBackend() === "newrelic" - ? await dashboardFromNrql({ fromDate, toDate, intervalSec, agent, scope }) - : await dashboardFromClickhouse({ fromDate, toDate, intervalSec, agent, scope }); + ? await dashboardFromNrql({ fromDate, toDate, intervalSec, agent, group, actor, scope }) + : await dashboardFromClickhouse({ fromDate, toDate, intervalSec, agent, group, actor, scope }); res.json({ intervalSec, window: { from, to }, ...payload }); } catch (err) { @@ -79,6 +81,8 @@ async function dashboardFromClickhouse(opts: { toDate: Date; intervalSec: number; agent?: string; + group?: string; + actor?: string; scope?: OwnerScope; }): Promise> { const params: Record = { @@ -90,11 +94,23 @@ async function dashboardFromClickhouse(opts: { params["agent"] = opts.agent; agentClause = "AND SpanAttributes['gen_ai.agent.name'] = {agent:String}"; } + let groupClause = ""; + if (opts.group) { + params["group"] = opts.group; + groupClause = "AND SpanAttributes['computeragent.group.id'] = {group:String}"; + } + let actorClause = ""; + if (opts.actor) { + params["actor"] = opts.actor; + actorClause = "AND SpanAttributes['computeragent.actor.id'] = {actor:String}"; + } const scope = clickhouseScopeClause(opts.scope, params); const scopeClause = scope ? `AND ${scope}` : ""; const whereBase = `Timestamp >= parseDateTime64BestEffort({t_from:String}, 9) AND Timestamp < parseDateTime64BestEffort({t_to:String}, 9) ${agentClause} + ${groupClause} + ${actorClause} ${scopeClause}`; const [ @@ -273,6 +289,8 @@ async function dashboardFromNrql(opts: { toDate: Date; intervalSec: number; agent?: string; + group?: string; + actor?: string; scope?: OwnerScope; }): Promise> { const params: Record = { @@ -284,9 +302,19 @@ async function dashboardFromNrql(opts: { params["agent"] = opts.agent; agentClause = " AND `gen_ai.agent.name` = {agent:String}"; } + let groupClause = ""; + if (opts.group) { + params["group"] = opts.group; + groupClause = " AND `computeragent.group.id` = {group:String}"; + } + let actorClause = ""; + if (opts.actor) { + params["actor"] = opts.actor; + actorClause = " AND `computeragent.actor.id` = {actor:String}"; + } const scope = nrqlScopeClause(opts.scope, params); const scopeClause = scope ? ` AND ${scope}` : ""; - const baseWhere = `WHERE 1=1${agentClause}${scopeClause}`; + const baseWhere = `WHERE 1=1${agentClause}${groupClause}${actorClause}${scopeClause}`; const sinceUntil = `SINCE {t_from:Timestamp} UNTIL {t_to:Timestamp}`; const [ diff --git a/packages/agentos-server/src/routes/obs-fields.ts b/packages/agentos-server/src/routes/obs-fields.ts index 39789a5..53b513b 100644 --- a/packages/agentos-server/src/routes/obs-fields.ts +++ b/packages/agentos-server/src/routes/obs-fields.ts @@ -28,6 +28,22 @@ obsFieldsRouter.get("/fields", (_req, res) => { type ValueRow = { value: string; count: number; lastSeenMs: number }; +// Fields the `otel_field_values` materialized view actually covers (see +// migrations.ts FIELD_MVS). Any field NOT in this set — notably the RBAC +// identity fields group_id/owner_id/actor_id — has no MV rows, so the fast path +// would return empty. Those fall through to the DISTINCT scan instead. +const MV_MATERIALIZED_FIELDS = new Set([ + "agent", + "model", + "operation", + "provider", + "tool", + "conversation_id", + "service", + "span_name", + "status", +]); + obsFieldsRouter.get("/fields/:name/values", async (req, res, next) => { try { const name = req.params["name"] ?? ""; @@ -72,11 +88,13 @@ async function fetchValuesClickhouse( values: ValueRow[]; source: "materialized" | "distinct-scan"; }> { - // Fast path — materialized view (created by migrations on boot). SKIPPED for - // scoped (non-superuser) reads: `otel_field_values` aggregates (field, value) - // with no owner/group dimension, so it can't be filtered by RBAC scope. Those - // callers fall straight through to the scoped DISTINCT scan below. - if (!scope) { + // Fast path — materialized view (created by migrations on boot). SKIPPED when: + // • the read is scoped (non-superuser): the MV aggregates (field, value) with + // no owner/group dimension, so it can't be filtered by RBAC scope; and + // • the field isn't materialized (e.g. the identity fields group_id/owner_id/ + // actor_id): the MV has no rows for it, so the fast path would return empty. + // Both cases fall through to the DISTINCT scan below (scoped when `scope` set). + if (!scope && MV_MATERIALIZED_FIELDS.has(name)) { try { const rows = await chQueryRows( `SELECT value,