diff --git a/console/index.html b/console/index.html index 1ae331d..a737b15 100644 --- a/console/index.html +++ b/console/index.html @@ -18,6 +18,7 @@ + Activity diff --git a/console/src/fixtures.ts b/console/src/fixtures.ts index 7d579e8..b71cfbf 100644 --- a/console/src/fixtures.ts +++ b/console/src/fixtures.ts @@ -1,4 +1,4 @@ -import type { Deployment } from "./types"; +import type { Deployment, RuntimeContext } from "./types"; // Stand-in data so the console renders without a live core. Mirrors the shape // studio-cp's `deploy_list` / `deploy_get` return. Swapped for the Tauri source @@ -37,3 +37,25 @@ export const FIXTURE_DEPLOYMENTS: Deployment[] = [ instances: [], }, ]; + +// Stand-in identity so the browser build renders the panel without a core. +// A healthy example: a task role that matches its binding's expectation. +export const FIXTURE_RUNTIME_CONTEXT: RuntimeContext = { + cluster: "oab", + principal: + "arn:aws:sts::504190915686:assumed-role/openab-orca-task-role/session", + principal_kind: "role", + scope: "504190915686", + location: "ap-east-2", + source: "container-credentials (task/pod role)", + caller_id: "AROAEXAMPLE:session", + binding: { + name: "prod", + profile: null, + region: "ap-east-2", + expected_principal: + "arn:aws:iam::504190915686:role/openab-orca-task-role", + }, + expected_principal: "arn:aws:iam::504190915686:role/openab-orca-task-role", + identity_matches: true, +}; diff --git a/console/src/main.ts b/console/src/main.ts index 013d3c9..9be421a 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -1,11 +1,12 @@ import { defaultSource } from "./source"; -import { renderRoster } from "./render"; +import { renderRoster, renderIdentity } from "./render"; import { createPane, bindBackend, type Level } from "./log"; const POLL_MS = 5000; const CLUSTER = "oab"; const roster = document.getElementById("roster"); +const identityEl = document.getElementById("identity"); const clusterLabel = document.getElementById("cluster-label"); const pollStatus = document.getElementById("poll-status"); const logEl = document.getElementById("log"); @@ -89,6 +90,19 @@ async function tick(): Promise { } } +// The effective managing identity for this cluster (ADR #19). Fetched once on +// boot and refreshed when the roster recovers — it changes rarely, so it does +// not need the 5s poll (and each call is a live STS lookup server-side). +async function refreshIdentity(): Promise { + if (!identityEl) return; + try { + renderIdentity(identityEl, await source.runtimeContext(CLUSTER)); + } catch (e) { + note("error", `identity: ${errText(e)}`); + renderIdentity(identityEl, null); + } +} + // The Tauri command bridge — present only inside the desktop shell (the browser // build has no `__TAURI__`, so callers no-op / hide their UI). type Invoke = (cmd: string, args?: Record) => Promise; @@ -178,6 +192,7 @@ async function boot(): Promise { note("info", `polling cluster "${CLUSTER}" every ${POLL_MS / 1000}s`); setupUpdater(); await startCore(); + void refreshIdentity(); void tick(); window.setInterval(() => void tick(), POLL_MS); } diff --git a/console/src/render.test.ts b/console/src/render.test.ts index 55b3458..e4f062f 100644 --- a/console/src/render.test.ts +++ b/console/src/render.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect } from "vitest"; -import { rosterHtml } from "./render"; -import { FIXTURE_DEPLOYMENTS } from "./fixtures"; -import { AGENT_STATES, type Deployment } from "./types"; +import { rosterHtml, identityHtml } from "./render"; +import { FIXTURE_DEPLOYMENTS, FIXTURE_RUNTIME_CONTEXT } from "./fixtures"; +import { AGENT_STATES, type Deployment, type RuntimeContext } from "./types"; + +function ctx(partial: Partial): RuntimeContext { + return { ...structuredClone(FIXTURE_RUNTIME_CONTEXT), ...partial }; +} function dep(partial: Partial): Deployment { return { @@ -63,3 +67,52 @@ describe("rosterHtml", () => { expect(html).not.toContain(""); }); }); + +describe("identityHtml", () => { + it("shows principal, account, region and the role kind badge", () => { + const html = identityHtml(FIXTURE_RUNTIME_CONTEXT); + expect(html).toContain('class="kind k-role"'); + expect(html).toContain("504190915686"); + expect(html).toContain("ap-east-2"); + expect(html).toContain("openab-orca-task-role"); + }); + + it("flags a mismatch and shows the expected principal", () => { + const html = identityHtml( + ctx({ + principal: "arn:aws:iam::916371022086:user/brett.chien", + principal_kind: "user", + scope: "916371022086", + identity_matches: false, + }), + ); + expect(html).toContain('class="identity mismatch"'); + expect(html).toContain("identity mismatch"); + expect(html).toContain("class=\"kind k-user\""); + expect(html).toContain("arn:aws:iam::504190915686:role/openab-orca-task-role"); + }); + + it("shows a matches verdict when identity_matches is true", () => { + expect(identityHtml(ctx({ identity_matches: true }))).toContain( + "matches expected", + ); + }); + + it("shows no verdict when there is no expectation", () => { + const html = identityHtml( + ctx({ identity_matches: null, expected_principal: null }), + ); + expect(html).not.toContain("mismatch"); + expect(html).not.toContain("matches expected"); + }); + + it("renders an unavailable state for null", () => { + expect(identityHtml(null)).toContain("identity unavailable"); + }); + + it("escapes the principal ARN", () => { + const html = identityHtml(ctx({ principal: "