Merge phase/05-dashboard into milestone/v0.1-bootstrap
Wave E (dashboard surfacing) complete. All 5 execution waves shipped. ---ci--- phase: 5 milestone: v0.1 status: complete ---/ci---
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* GET /api/audit/export — CSV export of the tenant's audit log (REQ-038).
|
||||
*
|
||||
* RBAC: admin (audit export is admin-only per spec §2.2 — no query UI in M1,
|
||||
* just a download button). Under withTenant + RLS — CSV is tenant-scoped; T1
|
||||
* cannot download T2's audit rows.
|
||||
*
|
||||
* Columns: id, timestamp, event_type, user_id, target_id, payload. The payload
|
||||
* is serialized as JSON (redaction of internal hash-chain fields — we don't
|
||||
* surface prev_hash/curr_hash to the CSV consumer).
|
||||
*
|
||||
* Response Content-Type: text/csv; filename=coreci-audit-<tenantId>-<ts>.csv
|
||||
*/
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { withTenant, type ScopedClient } from "@coreci/db";
|
||||
import { requireAuth } from "../../../../lib/auth.js";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface AuditLogRow {
|
||||
id: string;
|
||||
created_at: string;
|
||||
event_type: string;
|
||||
user_id: string | null;
|
||||
target_id: string | null;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
const CSV_COLUMNS = ["id", "timestamp", "event_type", "user_id", "target_id", "payload"] as const;
|
||||
|
||||
/** RFC 4180 CSV field escaping: wrap in quotes if it contains comma/quote/newline. */
|
||||
function csvField(value: string): string {
|
||||
if (value === "") return "";
|
||||
if (/[",\r\n]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function rowToCsvLine(r: AuditLogRow): string {
|
||||
const payloadJson = r.payload === null || r.payload === undefined
|
||||
? ""
|
||||
: JSON.stringify(r.payload);
|
||||
const fields = [
|
||||
String(r.id),
|
||||
r.created_at,
|
||||
r.event_type,
|
||||
r.user_id ?? "",
|
||||
r.target_id ?? "",
|
||||
payloadJson,
|
||||
];
|
||||
return fields.map(csvField).join(",");
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest): Promise<Response> {
|
||||
const auth = await requireAuth(req);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
if (auth.user.role !== "admin") {
|
||||
return Response.json(
|
||||
{ error: "forbidden", detail: "audit export requires the Admin role" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const tenantId = auth.user.tenantId;
|
||||
const rows = await withTenant(tenantId, async (c: ScopedClient) => {
|
||||
// Explicit tenant_id filter (defense-in-depth — see /api/targets note).
|
||||
const res = await c.query<AuditLogRow>(
|
||||
`SELECT id, created_at, event_type, user_id, target_id, payload
|
||||
FROM audit_log
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY id ASC`,
|
||||
[tenantId],
|
||||
);
|
||||
return res.rows;
|
||||
});
|
||||
|
||||
const lines = [CSV_COLUMNS.join(",")];
|
||||
for (const r of rows) lines.push(rowToCsvLine(r));
|
||||
const csv = lines.join("\r\n");
|
||||
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filename = `coreci-audit-${tenantId.slice(0, 8)}-${ts}.csv`;
|
||||
|
||||
return new Response(csv, {
|
||||
headers: {
|
||||
"content-type": "text/csv; charset=utf-8",
|
||||
"content-disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -33,9 +33,9 @@ function signingKey(): string {
|
||||
const TOKEN_LIFETIME_HOURS = 24;
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
let ctx: ReturnType<typeof requireAdmin>;
|
||||
let ctx;
|
||||
try {
|
||||
ctx = requireAdmin(req);
|
||||
ctx = await requireAdmin(req);
|
||||
} catch (err) {
|
||||
return toAuthResponse(err);
|
||||
}
|
||||
@@ -50,13 +50,13 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
);
|
||||
}
|
||||
|
||||
const token = issueRelayToken(ctx.tenantId, key, TOKEN_LIFETIME_HOURS);
|
||||
const token = issueRelayToken(ctx.user.tenantId, key, TOKEN_LIFETIME_HOURS);
|
||||
const expiresAt = new Date(Date.now() + TOKEN_LIFETIME_HOURS * 60 * 60 * 1000).toISOString();
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
token,
|
||||
tenantId: ctx.tenantId,
|
||||
tenantId: ctx.user.tenantId,
|
||||
expiresAt,
|
||||
lifetimeHours: TOKEN_LIFETIME_HOURS,
|
||||
// The install command the dashboard embeds (Wave E renders this verbatim).
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* GET /api/relay/status — Server-Sent Events fan-out of target status (REQ-014).
|
||||
*
|
||||
* Streams target status updates to connected dashboard clients.
|
||||
* - On connect: send the current target list with health status (`snapshot`).
|
||||
* - Every 5s: re-query targets, compute health, send an `update` event.
|
||||
* - On client disconnect: stop the interval + clean up.
|
||||
*
|
||||
* RBAC: read (any authenticated role). The session is verified once on connect
|
||||
* (SSE doesn't allow re-sending cookies mid-stream, so we auth the long-lived
|
||||
* stream at handshake time — acceptable for M1 admin dashboards).
|
||||
*
|
||||
* Uses the in-memory `getConnectedAgents()` from the WS server for real-time
|
||||
* status: if a target is currently WS-connected, it's green even if last_seen
|
||||
* is slightly stale (heartbeat batching). Content-Type: text/event-stream.
|
||||
*/
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { withTenant, type ScopedClient } from "@coreci/db";
|
||||
import { requireAuth } from "../../../../lib/auth.js";
|
||||
import { computeHealth, type HealthStatus } from "../../../../lib/health.js";
|
||||
import { getDb } from "../../../../lib/db.js";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
/** Force dynamic — SSE must never be statically rendered. */
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TICK_INTERVAL_MS = 5000;
|
||||
|
||||
interface TargetRow {
|
||||
id: string;
|
||||
hostname: string;
|
||||
os_name: string;
|
||||
os_version: string;
|
||||
ip_address: string | null;
|
||||
agent_version: string;
|
||||
last_seen_at: string | null;
|
||||
registered_at: string;
|
||||
}
|
||||
|
||||
export interface TargetStatusDto {
|
||||
id: string;
|
||||
hostname: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
ipAddress: string | null;
|
||||
agentVersion: string;
|
||||
lastSeenAt: string | null;
|
||||
registeredAt: string;
|
||||
health: HealthStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of currently-connected target ids (from the in-memory WS registry).
|
||||
* The WS server is a separate process in prod; for M1 dev / this route we read
|
||||
* the in-memory registry exported from ws-server.ts. If the WS server isn't
|
||||
* loaded in this process (e.g. Next-only deploy), the registry is empty and
|
||||
* health falls back to last_seen_at alone — still correct, just not real-time.
|
||||
*/
|
||||
async function connectedTargetIds(): Promise<ReadonlySet<string>> {
|
||||
try {
|
||||
const { getConnectedAgents } = await import("../../../../ws-server.js");
|
||||
return new Set(getConnectedAgents().map((a) => a.targetId));
|
||||
} catch {
|
||||
// WS server module not loaded in this process → empty set (graceful).
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
async function queryTargets(tenantId: string): Promise<TargetRow[]> {
|
||||
return withTenant(tenantId, async (c: ScopedClient) => {
|
||||
// Explicit tenant_id filter (defense-in-depth — see /api/targets note).
|
||||
const res = await c.query<TargetRow>(
|
||||
`SELECT id, hostname, os_name, os_version, ip_address, agent_version,
|
||||
last_seen_at, registered_at
|
||||
FROM targets
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY registered_at DESC`,
|
||||
[tenantId],
|
||||
);
|
||||
return res.rows;
|
||||
});
|
||||
}
|
||||
|
||||
async function buildSnapshot(tenantId: string): Promise<TargetStatusDto[]> {
|
||||
const rows = await queryTargets(tenantId);
|
||||
const connected = await connectedTargetIds();
|
||||
const now = Date.now();
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
hostname: r.hostname,
|
||||
osName: r.os_name,
|
||||
osVersion: r.os_version,
|
||||
ipAddress: r.ip_address,
|
||||
agentVersion: r.agent_version,
|
||||
lastSeenAt: r.last_seen_at,
|
||||
registeredAt: r.registered_at,
|
||||
health: computeHealth(
|
||||
{ lastSeenAt: r.last_seen_at, targetId: r.id, connectedTargetIds: connected },
|
||||
now,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function sseMessage(event: string, data: unknown): string {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest): Promise<Response> {
|
||||
// SSE handshake: verify session + RBAC before opening the stream. We re-use
|
||||
// requireAuth but need the user context even though SSE returns a stream.
|
||||
const auth = await requireAuth(req);
|
||||
if (auth instanceof Response) return auth;
|
||||
const tenantId = auth.user.tenantId;
|
||||
|
||||
// Ensure the DB is bootstrapped (the snapshot query needs it).
|
||||
await getDb();
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const enc = new TextEncoder();
|
||||
let closed = false;
|
||||
const send = (event: string, data: unknown): void => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(enc.encode(sseMessage(event, data)));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial snapshot.
|
||||
try {
|
||||
const snapshot = await buildSnapshot(tenantId);
|
||||
send("snapshot", snapshot);
|
||||
} catch (err) {
|
||||
send("error", { error: "snapshot_failed", detail: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
|
||||
// Periodic updates.
|
||||
const interval = setInterval(async () => {
|
||||
if (closed) return;
|
||||
try {
|
||||
const snapshot = await buildSnapshot(tenantId);
|
||||
send("update", snapshot);
|
||||
} catch (err) {
|
||||
send("error", { error: "update_failed", detail: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}, TICK_INTERVAL_MS);
|
||||
|
||||
// SSE keepalive comment every 15s (so proxies don't idle-close).
|
||||
const keepalive = setInterval(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(enc.encode(`: keepalive\n\n`));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
// Clean up on cancel (client closes the tab).
|
||||
req.signal.addEventListener("abort", () => {
|
||||
closed = true;
|
||||
clearInterval(interval);
|
||||
clearInterval(keepalive);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache, no-transform",
|
||||
connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* GET /api/targets/[id]/logs — last 100 log lines for a target (REQ-014).
|
||||
*
|
||||
* RBAC: read. Under withTenant + RLS.
|
||||
*
|
||||
* For M1, "logs" are the audit_log entries for this target (event_type in
|
||||
* ['provision', 'config', 'ssh_command', 'tool_call'] that reference the
|
||||
* target). In M2+ this will include SSH command logs from the Relay Agent.
|
||||
*
|
||||
* Returns the last 100 audit_log entries for this target, newest first. Each
|
||||
* entry: { id, eventType, createdAt, payload (redacted summary) }. We never
|
||||
* surface the raw hash-chain bytes; the payload is a curated summary so we
|
||||
* don't leak internal audit metadata to the dashboard viewer.
|
||||
*/
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { withTenant, type ScopedClient } from "@coreci/db";
|
||||
import { requireAuth } from "../../../../../lib/auth.js";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
interface AuditLogRow {
|
||||
id: string;
|
||||
event_type: string;
|
||||
created_at: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export interface TargetLogEntry {
|
||||
id: string;
|
||||
eventType: string;
|
||||
createdAt: string;
|
||||
/** Redacted summary of the audit payload (no hash bytes, no internal fields). */
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Allowed event types surfaced as "logs" for a target in M1. */
|
||||
const TARGET_LOG_EVENT_TYPES = ["provision", "config", "ssh_command", "tool_call"];
|
||||
|
||||
/** Redact/curate the audit payload for dashboard display. */
|
||||
function redactPayload(payload: unknown): Record<string, unknown> {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return { summary: String(payload) };
|
||||
}
|
||||
const p = payload as Record<string, unknown>;
|
||||
// Surface the human-actionable fields; drop anything that could leak
|
||||
// internal state. Keep it lossy on purpose — this is a summary view.
|
||||
const out: Record<string, unknown> = {};
|
||||
if (typeof p.action === "string") out.action = p.action;
|
||||
if (typeof p.hostname === "string") out.hostname = p.hostname;
|
||||
if (typeof p.os === "string") out.os = p.os;
|
||||
if (typeof p.osVersion === "string") out.osVersion = p.osVersion;
|
||||
if (typeof p.ip === "string") out.ip = p.ip;
|
||||
if (typeof p.agentVersion === "string") out.agentVersion = p.agentVersion;
|
||||
if (p.targetId !== undefined) out.targetId = p.targetId;
|
||||
if (typeof p.error === "string") out.error = p.error;
|
||||
if (typeof p.detail === "string") out.detail = p.detail;
|
||||
if (Object.keys(out).length === 0) {
|
||||
// Unknown shape — fall back to a generic summary so the row still renders.
|
||||
return { summary: "event recorded" };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const auth = await requireAuth(req);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
const { id } = await ctx.params;
|
||||
if (!id) return Response.json({ error: "missing id" }, { status: 400 });
|
||||
|
||||
const rows = await withTenant(auth.user.tenantId, async (c: ScopedClient) => {
|
||||
// Explicit tenant_id filter (defense-in-depth — see /api/targets note).
|
||||
const res = await c.query<AuditLogRow>(
|
||||
`SELECT id, event_type, created_at, payload
|
||||
FROM audit_log
|
||||
WHERE target_id = $1
|
||||
AND tenant_id = $2
|
||||
AND event_type = ANY($3::text[])
|
||||
ORDER BY id DESC
|
||||
LIMIT 100`,
|
||||
[id, auth.user.tenantId, TARGET_LOG_EVENT_TYPES],
|
||||
);
|
||||
return res.rows;
|
||||
});
|
||||
|
||||
// If no audit rows AND the target doesn't exist in this tenant → 404.
|
||||
// (RLS: a cross-tenant target id yields zero rows here too.) We do a cheap
|
||||
// existence check so a stale target id doesn't return an empty 200 that
|
||||
// looks like "exists but no logs".
|
||||
if (rows.length === 0) {
|
||||
const exists = await withTenant(auth.user.tenantId, async (c: ScopedClient) => {
|
||||
const res = await c.query<{ id: string }>(
|
||||
"SELECT id FROM targets WHERE id = $1 AND tenant_id = $2",
|
||||
[id, auth.user.tenantId],
|
||||
);
|
||||
return res.rows.length > 0;
|
||||
});
|
||||
if (!exists) return Response.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const entries: TargetLogEntry[] = rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
eventType: r.event_type,
|
||||
createdAt: r.created_at,
|
||||
payload: redactPayload(r.payload),
|
||||
}));
|
||||
|
||||
return Response.json({ logs: entries });
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* GET /api/targets/[id] — single target metadata + health (REQ-014).
|
||||
*
|
||||
* RBAC: read. Under withTenant + RLS — T1 cannot view T2's target. If the
|
||||
* target id belongs to another tenant, RLS returns zero rows → 404 (we never
|
||||
* reveal cross-tenant existence).
|
||||
*/
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { withTenant, type ScopedClient } from "@coreci/db";
|
||||
import { requireAuth } from "../../../../lib/auth.js";
|
||||
import { computeHealth, type HealthStatus } from "../../../../lib/health.js";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
interface TargetRow {
|
||||
id: string;
|
||||
hostname: string;
|
||||
os_name: string;
|
||||
os_version: string;
|
||||
ip_address: string | null;
|
||||
agent_version: string;
|
||||
last_seen_at: string | null;
|
||||
registered_at: string;
|
||||
}
|
||||
|
||||
export interface TargetDetailDto {
|
||||
id: string;
|
||||
hostname: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
ipAddress: string | null;
|
||||
agentVersion: string;
|
||||
lastSeenAt: string | null;
|
||||
registeredAt: string;
|
||||
health: HealthStatus;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> {
|
||||
const auth = await requireAuth(req);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
const { id } = await ctx.params;
|
||||
if (!id) return Response.json({ error: "missing id" }, { status: 400 });
|
||||
|
||||
const row = await withTenant(auth.user.tenantId, async (c: ScopedClient) => {
|
||||
// Explicit tenant_id filter (defense-in-depth — see /api/targets note).
|
||||
const res = await c.query<TargetRow>(
|
||||
`SELECT id, hostname, os_name, os_version, ip_address, agent_version,
|
||||
last_seen_at, registered_at
|
||||
FROM targets
|
||||
WHERE id = $1 AND tenant_id = $2`,
|
||||
[id, auth.user.tenantId],
|
||||
);
|
||||
return res.rows[0] ?? null;
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
// Cross-tenant access (RLS returned zero rows) OR genuinely missing — both 404.
|
||||
return Response.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const dto: TargetDetailDto = {
|
||||
id: row.id,
|
||||
hostname: row.hostname,
|
||||
osName: row.os_name,
|
||||
osVersion: row.os_version,
|
||||
ipAddress: row.ip_address,
|
||||
agentVersion: row.agent_version,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
registeredAt: row.registered_at,
|
||||
health: computeHealth({ lastSeenAt: row.last_seen_at, targetId: row.id }),
|
||||
};
|
||||
return Response.json(dto);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* GET /api/targets — list Relay Agent targets for the current tenant (REQ-014).
|
||||
*
|
||||
* RBAC: read (any authenticated role). Under withTenant + RLS — T1 cannot see
|
||||
* T2's targets (RLS enforces tenant_id = current_setting('app.tenant_id')).
|
||||
*
|
||||
* Each row includes: id, hostname, os_name, os_version, ip_address,
|
||||
* agent_version, last_seen_at, registered_at, and the computed health status
|
||||
* (green/yellow/red per lib/health).
|
||||
*
|
||||
* The dashboard's /dashboard/targets page subscribes to /api/relay/status
|
||||
* (SSE) for live updates; this route is the initial snapshot.
|
||||
*/
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { withTenant, type ScopedClient } from "@coreci/db";
|
||||
import { requireAuth } from "../../../lib/auth.js";
|
||||
import { computeHealth, type HealthStatus } from "../../../lib/health.js";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
interface TargetRow {
|
||||
id: string;
|
||||
hostname: string;
|
||||
os_name: string;
|
||||
os_version: string;
|
||||
ip_address: string | null;
|
||||
agent_version: string;
|
||||
last_seen_at: string | null;
|
||||
registered_at: string;
|
||||
}
|
||||
|
||||
export interface TargetDto {
|
||||
id: string;
|
||||
hostname: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
ipAddress: string | null;
|
||||
agentVersion: string;
|
||||
lastSeenAt: string | null;
|
||||
registeredAt: string;
|
||||
health: HealthStatus;
|
||||
}
|
||||
|
||||
async function listTargets(tenantId: string): Promise<TargetRow[]> {
|
||||
return withTenant(tenantId, async (c: ScopedClient) => {
|
||||
// Explicit tenant_id filter (defense-in-depth): RLS enforces this in prod
|
||||
// Postgres, but PGlite 0.5.7 does not enforce RLS on SELECT, so the
|
||||
// application-layer filter is the primary enforcement in dev/test.
|
||||
const res = await c.query<TargetRow>(
|
||||
`SELECT id, hostname, os_name, os_version, ip_address, agent_version,
|
||||
last_seen_at, registered_at
|
||||
FROM targets
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY registered_at DESC`,
|
||||
[tenantId],
|
||||
);
|
||||
return res.rows;
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest): Promise<Response> {
|
||||
const auth = await requireAuth(req);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
const rows = await listTargets(auth.user.tenantId);
|
||||
const now = Date.now();
|
||||
const dto: TargetDto[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
hostname: r.hostname,
|
||||
osName: r.os_name,
|
||||
osVersion: r.os_version,
|
||||
ipAddress: r.ip_address,
|
||||
agentVersion: r.agent_version,
|
||||
lastSeenAt: r.last_seen_at,
|
||||
registeredAt: r.registered_at,
|
||||
health: computeHealth({ lastSeenAt: r.last_seen_at, targetId: r.id }, now),
|
||||
}));
|
||||
|
||||
return Response.json(dto);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* dashboard onboarding helper — fetch /api/targets to determine onboarding
|
||||
* step status (Wave E Task 4). Returns null on auth failure.
|
||||
*/
|
||||
|
||||
import { headers, cookies } from "next/headers";
|
||||
import type { HealthStatus } from "../../lib/health.js";
|
||||
|
||||
export interface TargetSummary {
|
||||
id: string;
|
||||
health: HealthStatus;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
export async function getTargetsSummary(): Promise<TargetSummary[] | null> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionCookie = cookieStore.get("coreci_session")?.value;
|
||||
if (!sessionCookie) return null;
|
||||
|
||||
const h = await headers();
|
||||
const host = h.get("host") ?? "localhost:3000";
|
||||
const proto = h.get("x-forwarded-proto") ?? "http";
|
||||
|
||||
const res = await fetch(`${proto}://${host}/api/targets`, {
|
||||
headers: { cookie: `coreci_session=${sessionCookie}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) return null;
|
||||
if (res.status !== 200) return [];
|
||||
const targets = (await res.json()) as { id: string; health: HealthStatus; lastSeenAt: string | null }[];
|
||||
return targets.map((t) => ({ id: t.id, health: t.health, lastSeenAt: t.lastSeenAt }));
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* /dashboard/audit — audit export (admin only, REQ-038, spec §2.2).
|
||||
*
|
||||
* "Download audit log (CSV)" button — no query UI in M1 (spec §2.2 mandates
|
||||
* append-only audit + admin-only export with no query surface). The CSV is
|
||||
* tenant-scoped under RLS (the /api/audit/export route enforces withTenant).
|
||||
*
|
||||
* Non-admins see a "you must be an admin" notice. Admins see the download
|
||||
* button which GETs /api/audit/export (the browser streams the CSV).
|
||||
*/
|
||||
|
||||
import { getMe } from "../me.js";
|
||||
|
||||
export default async function AuditPage() {
|
||||
const me = await getMe();
|
||||
if (!me) {
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", maxWidth: "32rem", margin: "4rem auto", padding: "0 1rem" }}>
|
||||
<h1>Audit log</h1>
|
||||
<p>You are not signed in.</p>
|
||||
<p>
|
||||
<a href="/login">
|
||||
<button style={{ padding: "0.6rem 1.2rem", font: "inherit" }}>Go to login</button>
|
||||
</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (me.role !== "admin") {
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", maxWidth: "48rem", margin: "2rem auto", padding: "0 1rem" }}>
|
||||
<h1>Audit log</h1>
|
||||
<p style={{ color: "#666" }}>
|
||||
Only admins can export the audit log. You are a <strong>{me.role}</strong>.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/dashboard">← Dashboard</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", maxWidth: "48rem", margin: "2rem auto", padding: "0 1rem" }}>
|
||||
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||
<h1>Audit log</h1>
|
||||
<span style={{ color: "#666", fontSize: "0.85rem" }}>
|
||||
{me.role} · tenant {me.tenantId.slice(0, 8)}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
The audit log is an append-only, per-tenant hash-chained record of every
|
||||
business event (provisioning, config changes, validations, SSH commands,
|
||||
prompts, responses). Download it as CSV for compliance review.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="/api/audit/export">
|
||||
<button style={{ padding: "0.6rem 1.2rem", font: "inherit" }}>
|
||||
Download audit log (CSV)
|
||||
</button>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style={{ color: "#666", fontSize: "0.85rem", marginTop: "1rem" }}>
|
||||
No query UI in M1 (spec §2.2). The CSV is scoped to your tenant under RLS.
|
||||
</p>
|
||||
|
||||
<p style={{ marginTop: "2rem" }}>
|
||||
<a href="/dashboard">← Dashboard</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,28 @@
|
||||
/**
|
||||
* /dashboard — M1 admin dashboard shell (REQ-002).
|
||||
* /dashboard — M1 admin dashboard (REQ-002, REQ-014).
|
||||
*
|
||||
* The 5-step onboarding checklist is rendered here (all grey — steps light up
|
||||
* as later waves ship). Wave E wires live Relay Agent health + targets; this
|
||||
* shell proves the SSO → /dashboard redirect works end-to-end.
|
||||
* The 5-step onboarding checklist. Wave E wires the live "Verify Green Status"
|
||||
* step: it turns green when at least one target has green health (M1 demo
|
||||
* path: SSO → BYOM green → install → register → green dashboard). The "Register
|
||||
* Target" step turns green when at least one target exists (any health).
|
||||
*/
|
||||
|
||||
import { getMe } from "./me.js";
|
||||
import { getTargetsSummary, type TargetSummary } from "./_onboarding.js";
|
||||
|
||||
type StepStatus = "grey" | "green";
|
||||
|
||||
function badgeColor(status: StepStatus): string {
|
||||
return status === "green" ? "#22c55e" : "#ccc";
|
||||
}
|
||||
|
||||
function anyGreen(targets: TargetSummary[] | null): boolean {
|
||||
return !!targets && targets.some((t) => t.health === "green");
|
||||
}
|
||||
|
||||
function anyTarget(targets: TargetSummary[] | null): boolean {
|
||||
return !!targets && targets.length > 0;
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const me = await getMe();
|
||||
@@ -25,12 +41,25 @@ export default async function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ id: "byom", label: "Configure BYOM", wave: "C" },
|
||||
{ id: "relay", label: "Install Relay Agent", wave: "D" },
|
||||
{ id: "target", label: "Register Target", wave: "D" },
|
||||
{ id: "verify", label: "Verify Green Status", wave: "E" },
|
||||
{ id: "team", label: "Invite Team", wave: "B" },
|
||||
// Fetch target status for the onboarding checklist (Wave E Task 4).
|
||||
const targets = await getTargetsSummary();
|
||||
const hasTarget = anyTarget(targets);
|
||||
const hasGreen = anyGreen(targets);
|
||||
|
||||
// Steps light up as later waves ship:
|
||||
// byom → green when a validated BYOM endpoint exists (Wave C marks this
|
||||
// via /api/byom; here we keep it grey until Wave C's status fetch
|
||||
// is wired — but leave the hook in place).
|
||||
// relay → install instructions exist (always green for M1 demo guidance)
|
||||
// target → green when at least one target is registered (any health)
|
||||
// verify → green when at least one target has green health
|
||||
// team → grey (admin invites when ready)
|
||||
const steps: { id: string; label: string; wave: string; status: StepStatus; hint?: string }[] = [
|
||||
{ id: "byom", label: "Configure BYOM", wave: "C", status: "grey", hint: "Validate your BYOM endpoint" },
|
||||
{ id: "relay", label: "Install Relay Agent", wave: "D", status: "grey", hint: "Copy the curl|bash command" },
|
||||
{ id: "target", label: "Register Target", wave: "D", status: hasTarget ? "green" : "grey", hint: "Relay Agent connects + registers" },
|
||||
{ id: "verify", label: "Verify Green Status", wave: "E", status: hasGreen ? "green" : "grey", hint: "At least one green target" },
|
||||
{ id: "team", label: "Invite Team", wave: "B", status: "grey", hint: "Invite members + roles" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -45,25 +74,60 @@ export default async function DashboardPage() {
|
||||
<section>
|
||||
<h2>Onboarding</h2>
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{steps.map((s) => (
|
||||
{steps.map((s) => {
|
||||
const stepHref =
|
||||
s.id === "target" || s.id === "verify"
|
||||
? "/dashboard/targets"
|
||||
: s.id === "team"
|
||||
? "/dashboard/team"
|
||||
: null;
|
||||
return (
|
||||
<li key={s.id} style={{ padding: "0.4rem 0", display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||
<span
|
||||
aria-label={s.id + " status"}
|
||||
title={s.status === "green" ? "complete" : "pending"}
|
||||
style={{
|
||||
width: "0.9rem",
|
||||
height: "0.9rem",
|
||||
borderRadius: "50%",
|
||||
background: "#ccc",
|
||||
background: badgeColor(s.status),
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
<span>{s.label}</span>
|
||||
<span>
|
||||
{stepHref ? <a href={stepHref}>{s.label}</a> : s.label}
|
||||
</span>
|
||||
<span style={{ color: "#999", fontSize: "0.75rem" }}>(Wave {s.wave})</span>
|
||||
{s.hint && (
|
||||
<span style={{ color: "#999", fontSize: "0.75rem" }}>— {s.hint}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section style={{ marginTop: "1.5rem" }}>
|
||||
<h2>Targets</h2>
|
||||
{hasTarget ? (
|
||||
<p>
|
||||
{targets!.length} target(s) registered.{" "}
|
||||
<a href="/dashboard/targets">View targets →</a>
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ color: "#666" }}>
|
||||
No targets registered yet. Install the Relay Agent (Wave D).
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ marginTop: "1.5rem" }}>
|
||||
<h2>Compliance</h2>
|
||||
<p>
|
||||
<a href="/dashboard/audit">Audit log export (CSV)</a> — admin only, tenant-scoped.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p style={{ marginTop: "2rem" }}>
|
||||
<a href="/api/auth/logout">Sign out</a> ·{" "}
|
||||
<a href="/dashboard/team">Team</a>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TargetStatusStream — subscribes to /api/relay/status (SSE) and re-renders
|
||||
* the targets table with live health updates (REQ-014, Wave E Task 1).
|
||||
*
|
||||
* Used by /dashboard/targets. Server component provides the initial snapshot
|
||||
* (via /api/targets); this client component overlays live health updates from
|
||||
* the SSE fan-out. Minimal client JS — only the badge color + lastSeenAt
|
||||
* fields update; the table structure is server-rendered.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { HealthStatus } from "../../../lib/health.js";
|
||||
|
||||
export interface TargetRowState {
|
||||
id: string;
|
||||
hostname: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
ipAddress: string | null;
|
||||
agentVersion: string;
|
||||
lastSeenAt: string | null;
|
||||
registeredAt: string;
|
||||
health: HealthStatus;
|
||||
}
|
||||
|
||||
interface SseSnapshotEvent {
|
||||
id: string;
|
||||
hostname: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
ipAddress: string | null;
|
||||
agentVersion: string;
|
||||
lastSeenAt: string | null;
|
||||
registeredAt: string;
|
||||
health: HealthStatus;
|
||||
}
|
||||
|
||||
function badgeColor(status: HealthStatus): string {
|
||||
switch (status) {
|
||||
case "green":
|
||||
return "#22c55e";
|
||||
case "yellow":
|
||||
return "#eab308";
|
||||
case "red":
|
||||
return "#ef4444";
|
||||
}
|
||||
}
|
||||
|
||||
export function TargetStatusStream({ initial }: { initial: TargetRowState[] }) {
|
||||
const [rows, setRows] = useState<TargetRowState[]>(initial);
|
||||
|
||||
useEffect(() => {
|
||||
// EventSource doesn't send cookies cross-origin reliably in all setups,
|
||||
// but same-origin (the dashboard → /api/relay/status) includes the
|
||||
// httpOnly session cookie by default. M1 dashboard is same-origin.
|
||||
const es = new EventSource("/api/relay/status");
|
||||
|
||||
const apply = (data: string): void => {
|
||||
let snapshot: SseSnapshotEvent[];
|
||||
try {
|
||||
snapshot = JSON.parse(data) as SseSnapshotEvent[];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Replace the row set with the latest snapshot (id-keyed upsert would be
|
||||
// more granular, but M1 target counts are small — full replace is fine).
|
||||
setRows(snapshot);
|
||||
};
|
||||
|
||||
es.addEventListener("snapshot", (e) => apply((e as MessageEvent).data));
|
||||
es.addEventListener("update", (e) => apply((e as MessageEvent).data));
|
||||
es.addEventListener("error", () => {
|
||||
// The browser auto-reconnects SSE on transient errors; on permanent
|
||||
// failure (auth dropped) we close so the user can re-auth.
|
||||
// EventSource already retries; leave the last good snapshot in place.
|
||||
});
|
||||
return () => {
|
||||
es.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p style={{ color: "#666" }}>
|
||||
No Relay Agent targets registered yet. See the{" "}
|
||||
<a href="/dashboard/relay">install instructions</a> to register one.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table style={{ borderCollapse: "collapse", width: "100%", fontSize: "0.9rem" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", borderBottom: "2px solid #eee" }}>
|
||||
<th style={{ padding: "0.4rem" }}>Health</th>
|
||||
<th style={{ padding: "0.4rem" }}>Hostname</th>
|
||||
<th style={{ padding: "0.4rem" }}>OS</th>
|
||||
<th style={{ padding: "0.4rem" }}>IP</th>
|
||||
<th style={{ padding: "0.4rem" }}>Agent</th>
|
||||
<th style={{ padding: "0.4rem" }}>Last seen</th>
|
||||
<th style={{ padding: "0.4rem" }}>Logs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} style={{ borderBottom: "1px solid #f0f0f0" }}>
|
||||
<td style={{ padding: "0.4rem" }}>
|
||||
<span
|
||||
aria-label={`health ${r.health}`}
|
||||
title={r.health}
|
||||
style={{
|
||||
width: "0.9rem",
|
||||
height: "0.9rem",
|
||||
borderRadius: "50%",
|
||||
background: badgeColor(r.health),
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
<span style={{ marginLeft: "0.4rem", fontSize: "0.8rem" }}>{r.health}</span>
|
||||
</td>
|
||||
<td style={{ padding: "0.4rem" }}>{r.hostname}</td>
|
||||
<td style={{ padding: "0.4rem" }}>
|
||||
{r.osName} {r.osVersion}
|
||||
</td>
|
||||
<td style={{ padding: "0.4rem" }}>{r.ipAddress ?? "—"}</td>
|
||||
<td style={{ padding: "0.4rem" }}>{r.agentVersion}</td>
|
||||
<td style={{ padding: "0.4rem" }}>
|
||||
{r.lastSeenAt ? new Date(r.lastSeenAt).toLocaleString() : "never"}
|
||||
</td>
|
||||
<td style={{ padding: "0.4rem" }}>
|
||||
<a href={`/dashboard/targets/${r.id}`}>View logs</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* dashboard/targets/[id] helper — server-side fetch of target detail + logs.
|
||||
*
|
||||
* Hits /api/targets/[id] and /api/targets/[id]/logs through the API gateway
|
||||
* (cookie forwarded) so the dashboard never bypasses RLS / RBAC. Under RLS,
|
||||
* T1 admin cannot view T2's target — /api/targets/[id] returns 404 (RLS
|
||||
* yields zero rows) and this helper surfaces `null` so the page renders a
|
||||
* not-found view.
|
||||
*/
|
||||
|
||||
import { headers, cookies } from "next/headers";
|
||||
import type { HealthStatus } from "../../../../lib/health.js";
|
||||
|
||||
export interface TargetDetailView {
|
||||
id: string;
|
||||
hostname: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
ipAddress: string | null;
|
||||
agentVersion: string;
|
||||
lastSeenAt: string | null;
|
||||
registeredAt: string;
|
||||
health: HealthStatus;
|
||||
}
|
||||
|
||||
export interface TargetLogView {
|
||||
id: string;
|
||||
eventType: string;
|
||||
createdAt: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TargetDetailResult {
|
||||
target: TargetDetailView;
|
||||
logs: TargetLogView[];
|
||||
}
|
||||
|
||||
export async function getTargetDetail(id: string): Promise<TargetDetailResult | null> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionCookie = cookieStore.get("coreci_session")?.value;
|
||||
if (!sessionCookie) return null;
|
||||
|
||||
const h = await headers();
|
||||
const host = h.get("host") ?? "localhost:3000";
|
||||
const proto = h.get("x-forwarded-proto") ?? "http";
|
||||
const base = `${proto}://${host}`;
|
||||
|
||||
const targetRes = await fetch(`${base}/api/targets/${id}`, {
|
||||
headers: { cookie: `coreci_session=${sessionCookie}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (targetRes.status === 401 || targetRes.status === 403) return null;
|
||||
if (targetRes.status === 404) return null;
|
||||
if (targetRes.status !== 200) return null;
|
||||
const target = (await targetRes.json()) as TargetDetailView;
|
||||
|
||||
const logsRes = await fetch(`${base}/api/targets/${id}/logs`, {
|
||||
headers: { cookie: `coreci_session=${sessionCookie}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
let logs: TargetLogView[] = [];
|
||||
if (logsRes.status === 200) {
|
||||
const body = (await logsRes.json()) as { logs: TargetLogView[] };
|
||||
logs = body.logs;
|
||||
}
|
||||
|
||||
return { target, logs };
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* /dashboard/targets/[id] — target detail (REQ-014, Wave E Task 3).
|
||||
*
|
||||
* Server component: fetches /api/targets/[id] + /api/targets/[id]/logs (under
|
||||
* RLS via the API gateway). Shows health badge, registration metadata, and
|
||||
* the last 100 log lines in a scrollable panel. Under RLS — T1 admin cannot
|
||||
* view T2's target (the API returns 404 under RLS; this page renders a
|
||||
* not-found view).
|
||||
*/
|
||||
|
||||
import { getMe } from "../../me.js";
|
||||
import { getTargetDetail, type TargetLogView, type TargetDetailView } from "./_lib.js";
|
||||
|
||||
function badgeColor(status: "green" | "yellow" | "red"): string {
|
||||
switch (status) {
|
||||
case "green":
|
||||
return "#22c55e";
|
||||
case "yellow":
|
||||
return "#eab308";
|
||||
case "red":
|
||||
return "#ef4444";
|
||||
}
|
||||
}
|
||||
|
||||
function Metadata({ k, v }: { k: string; v: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "0.6rem" }}>
|
||||
<span style={{ color: "#666", minWidth: "8rem" }}>{k}</span>
|
||||
<span>{v}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function TargetDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const me = await getMe();
|
||||
if (!me) {
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", maxWidth: "32rem", margin: "4rem auto", padding: "0 1rem" }}>
|
||||
<h1>Target</h1>
|
||||
<p>You are not signed in.</p>
|
||||
<p>
|
||||
<a href="/login">
|
||||
<button style={{ padding: "0.6rem 1.2rem", font: "inherit" }}>Go to login</button>
|
||||
</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const data = await getTargetDetail(id);
|
||||
if (!data) {
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", maxWidth: "48rem", margin: "2rem auto", padding: "0 1rem" }}>
|
||||
<h1>Target not found</h1>
|
||||
<p>This target does not exist in your tenant, or you don't have access.</p>
|
||||
<p>
|
||||
<a href="/dashboard/targets">← Back to targets</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const t: TargetDetailView = data.target;
|
||||
const logs: TargetLogView[] = data.logs;
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", maxWidth: "64rem", margin: "2rem auto", padding: "0 1rem" }}>
|
||||
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||
<h1>{t.hostname}</h1>
|
||||
<span style={{ color: "#666", fontSize: "0.85rem" }}>
|
||||
{me.role} · tenant {me.tenantId.slice(0, 8)}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section style={{ margin: "1.5rem 0" }}>
|
||||
<h2>Health</h2>
|
||||
<p style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
|
||||
<span
|
||||
aria-label={`health ${t.health}`}
|
||||
style={{
|
||||
width: "1.1rem",
|
||||
height: "1.1rem",
|
||||
borderRadius: "50%",
|
||||
background: badgeColor(t.health),
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
<strong style={{ textTransform: "uppercase" }}>{t.health}</strong>
|
||||
<span style={{ color: "#666", fontSize: "0.85rem" }}>
|
||||
{t.lastSeenAt
|
||||
? `last seen ${new Date(t.lastSeenAt).toLocaleString()}`
|
||||
: "never seen"}
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ margin: "1.5rem 0" }}>
|
||||
<h2>Registration metadata</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}>
|
||||
<Metadata k="Hostname" v={t.hostname} />
|
||||
<Metadata k="OS" v={`${t.osName} ${t.osVersion}`} />
|
||||
<Metadata k="IP address" v={t.ipAddress ?? "—"} />
|
||||
<Metadata k="Agent version" v={t.agentVersion} />
|
||||
<Metadata k="Registered at" v={new Date(t.registeredAt).toLocaleString()} />
|
||||
<Metadata k="Last seen" v={t.lastSeenAt ? new Date(t.lastSeenAt).toLocaleString() : "never"} />
|
||||
<Metadata k="Target id" v={t.id} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ margin: "1.5rem 0" }}>
|
||||
<h2>Last 100 log lines</h2>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: "24rem",
|
||||
overflow: "auto",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
padding: "0.6rem",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
fontSize: "0.8rem",
|
||||
background: "#fafafa",
|
||||
}}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<p style={{ color: "#666" }}>No log entries for this target yet.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{logs.map((log) => (
|
||||
<li key={log.id} style={{ padding: "0.2rem 0", borderBottom: "1px solid #eee" }}>
|
||||
<span style={{ color: "#666" }}>{new Date(log.createdAt).toLocaleString()}</span>{" "}
|
||||
<span style={{ color: "#2563eb" }}>[{log.eventType}]</span>{" "}
|
||||
<span>{JSON.stringify(log.payload)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p style={{ marginTop: "2rem" }}>
|
||||
<a href="/dashboard/targets">← Back to targets</a> ·{" "}
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* dashboard/targets helper — server-side fetch of /api/targets.
|
||||
*
|
||||
* Server components call this to render the initial target list. Hits the API
|
||||
* gateway (cookie forwarded) so the dashboard never bypasses RLS / RBAC.
|
||||
* Returns null on 401 (caller should redirect to /login).
|
||||
*/
|
||||
|
||||
import { headers, cookies } from "next/headers";
|
||||
import type { HealthStatus } from "../../../lib/health.js";
|
||||
|
||||
export interface TargetView {
|
||||
id: string;
|
||||
hostname: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
ipAddress: string | null;
|
||||
agentVersion: string;
|
||||
lastSeenAt: string | null;
|
||||
registeredAt: string;
|
||||
health: HealthStatus;
|
||||
}
|
||||
|
||||
export async function getTargets(): Promise<TargetView[] | null> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionCookie = cookieStore.get("coreci_session")?.value;
|
||||
if (!sessionCookie) return null;
|
||||
|
||||
const h = await headers();
|
||||
const host = h.get("host") ?? "localhost:3000";
|
||||
const proto = h.get("x-forwarded-proto") ?? "http";
|
||||
|
||||
const res = await fetch(`${proto}://${host}/api/targets`, {
|
||||
headers: { cookie: `coreci_session=${sessionCookie}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) return null;
|
||||
if (res.status !== 200) return [];
|
||||
return (await res.json()) as TargetView[];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* /dashboard/targets — Relay Agent targets list (REQ-014, Wave E Task 2).
|
||||
*
|
||||
* Server component: fetches /api/targets (under RLS via the API gateway),
|
||||
* renders a table (hostname, OS, IP, agent version, health badge, last-seen,
|
||||
* "View logs" link). A client component subscribes to /api/relay/status SSE
|
||||
* for live updates so the health badge turns green within 90s of the first
|
||||
* heartbeat (M1 acceptance criterion).
|
||||
*/
|
||||
|
||||
import { getMe } from "../me.js";
|
||||
import { getTargets, type TargetView } from "./_lib.js";
|
||||
import { TargetStatusStream, type TargetRowState } from "./TargetStatusStream.js";
|
||||
|
||||
export default async function TargetsPage() {
|
||||
const me = await getMe();
|
||||
if (!me) {
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", maxWidth: "32rem", margin: "4rem auto", padding: "0 1rem" }}>
|
||||
<h1>Targets</h1>
|
||||
<p>You are not signed in.</p>
|
||||
<p>
|
||||
<a href="/login">
|
||||
<button style={{ padding: "0.6rem 1.2rem", font: "inherit" }}>Go to login</button>
|
||||
</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const targets = (await getTargets()) ?? [];
|
||||
const initial: TargetRowState[] = targets.map((t: TargetView) => ({
|
||||
id: t.id,
|
||||
hostname: t.hostname,
|
||||
osName: t.osName,
|
||||
osVersion: t.osVersion,
|
||||
ipAddress: t.ipAddress,
|
||||
agentVersion: t.agentVersion,
|
||||
lastSeenAt: t.lastSeenAt,
|
||||
registeredAt: t.registeredAt,
|
||||
health: t.health,
|
||||
}));
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui", maxWidth: "64rem", margin: "2rem auto", padding: "0 1rem" }}>
|
||||
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||
<h1>Relay Agent Targets</h1>
|
||||
<span style={{ color: "#666", fontSize: "0.85rem" }}>
|
||||
{me.role} · tenant {me.tenantId.slice(0, 8)}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<p style={{ color: "#666", fontSize: "0.85rem" }}>
|
||||
Health:{" "}
|
||||
<span style={{ color: "#22c55e" }}>green</span> < 60s ·{" "}
|
||||
<span style={{ color: "#eab308" }}>yellow</span> < 5min ·{" "}
|
||||
<span style={{ color: "#ef4444" }}>red</span> > 5min / never seen. Updates live.
|
||||
</p>
|
||||
|
||||
<TargetStatusStream initial={initial} />
|
||||
|
||||
<p style={{ marginTop: "2rem" }}>
|
||||
<a href="/dashboard">← Dashboard</a>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+103
-42
@@ -1,25 +1,26 @@
|
||||
/**
|
||||
* control-plane lib/auth — inline admin guard for the relay token-issuance
|
||||
* route (Wave D, G-005).
|
||||
* control-plane lib/auth — API gateway auth helpers (Wave B + D + E).
|
||||
*
|
||||
* TODO(Wave B merge): replace this placeholder with the real RBAC middleware
|
||||
* from @coreci/auth (createAuthMiddleware / authenticate). Wave B's middleware
|
||||
* verifies the `coreci_session` cookie, resolves the tenant, and enforces RBAC
|
||||
* (Admin-only for relay token issuance per the route table in
|
||||
* packages/auth/src/rbac.ts: /api/relay/* → admin). On this branch @coreci/auth
|
||||
* is not yet wired, so this guard does a minimal cookie-presence check + reads
|
||||
* the tenant id from a header/env for local development. The full RBAC
|
||||
* enforcement (REQ-005: Viewer → 403, Operator → 403 on admin routes) applies at
|
||||
* the Wave B merge.
|
||||
* `requireAuth(req)` and `requireAdmin(req)` wire @coreci/auth's
|
||||
* `authenticate(...)` against the control-plane's DbClient (lib/db) and the
|
||||
* platform bootstrap SESSION_SIGNING_KEY. They verify the `coreci_session`
|
||||
* httpOnly cookie, resolve the tenant, and enforce RBAC at the gateway
|
||||
* (REQ-005 critical-path). Every /api/* Route Handler that touches tenant data
|
||||
* calls one of these first.
|
||||
*
|
||||
* The contract the real middleware will implement:
|
||||
* - read `coreci_session` httpOnly cookie
|
||||
* - verifySession → SessionData { id, tenantId, role }
|
||||
* - enforceRbac(role, "POST", "/api/relay/issue-token") → 403 if not admin
|
||||
* - attach req.user = { id, tenantId, role }
|
||||
* - requireAuth → (method, path) RBAC enforced; returns the authorized
|
||||
* user or a Response (401/403) the handler returns verbatim.
|
||||
* - requireAdmin → admin-only (used by relay token issuance). Throws
|
||||
* AuthError (kept for the existing Wave D call site).
|
||||
*
|
||||
* The DB lookup is cached via getDb() (lib/db singleton). The signing key is
|
||||
* SESSION_SIGNING_KEY (infra bootstrap cred, G-010 tier (a), NOT a tenant
|
||||
* secret).
|
||||
*/
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { authenticate, SESSION_COOKIE, type Role } from "@coreci/auth";
|
||||
import { getDb } from "./db.js";
|
||||
|
||||
export interface AuthContext {
|
||||
tenantId: string;
|
||||
@@ -36,37 +37,97 @@ export class AuthError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** The cookie name carrying the signed session JWT (matches @coreci/auth). */
|
||||
export const SESSION_COOKIE = "coreci_session";
|
||||
export { SESSION_COOKIE };
|
||||
|
||||
function signingKey(): string {
|
||||
const k = process.env.SESSION_SIGNING_KEY;
|
||||
if (!k) throw new Error("SESSION_SIGNING_KEY not configured");
|
||||
return k;
|
||||
}
|
||||
|
||||
/** Authorized context attached to a verified request. */
|
||||
export interface AuthorizedRequest {
|
||||
user: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
role: Role;
|
||||
};
|
||||
}
|
||||
|
||||
function toNextRequestShape(req: NextRequest) {
|
||||
return {
|
||||
method: req.method,
|
||||
path: req.nextUrl.pathname,
|
||||
getCookie: (name: string) => req.cookies.get(name)?.value,
|
||||
};
|
||||
}
|
||||
|
||||
function unauthorizedResponse(status: number, reason: string): Response {
|
||||
if (status === 401) {
|
||||
return Response.json({ error: "unauthorized", detail: reason }, { status: 401 });
|
||||
}
|
||||
return Response.json({ error: "forbidden", detail: reason }, { status: 403 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the authenticated admin context for a relay token-issuance request.
|
||||
*
|
||||
* Throws AuthError(401) if no session cookie is present, AuthError(403) if the
|
||||
* caller's role is not admin. In local dev the tenant id may be supplied via
|
||||
* the `x-coreci-tenant-id` header (the Wave B middleware will read it from the
|
||||
* verified session instead).
|
||||
* Verify the session cookie + enforce RBAC for (method, path). Returns the
|
||||
* authorized user context, or a Response (401/403) the Route Handler should
|
||||
* return verbatim. REQ-005: enforced at the gateway from the first endpoint.
|
||||
*/
|
||||
export function requireAdmin(req: NextRequest): AuthContext {
|
||||
const cookie = req.cookies.get(SESSION_COOKIE)?.value;
|
||||
if (!cookie) {
|
||||
export async function requireAuth(req: NextRequest): Promise<AuthorizedRequest | Response> {
|
||||
let result;
|
||||
try {
|
||||
const db = await getDb();
|
||||
result = await authenticate(db, signingKey(), toNextRequestShape(req));
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: "internal_error", detail: err instanceof Error ? err.message : String(err) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status === "unauthorized") {
|
||||
return unauthorizedResponse(401, result.reason);
|
||||
}
|
||||
if (result.status === "forbidden") {
|
||||
return unauthorizedResponse(403, `role '${result.user.role}' lacks '${result.decision.required}' for ${req.method} ${req.nextUrl.pathname}`);
|
||||
}
|
||||
return {
|
||||
user: {
|
||||
id: result.user.userId,
|
||||
tenantId: result.user.tenantId,
|
||||
role: result.user.role,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only guard. Throws AuthError(401/403) for the existing Wave D call
|
||||
* sites (relay token issuance). Equivalent to requireAuth but throws instead
|
||||
* of returning a Response, and enforces role === "admin".
|
||||
*/
|
||||
export async function requireAdmin(req: NextRequest): Promise<AuthorizedRequest> {
|
||||
let result;
|
||||
try {
|
||||
const db = await getDb();
|
||||
result = await authenticate(db, signingKey(), toNextRequestShape(req));
|
||||
} catch (err) {
|
||||
throw new AuthError(500, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
if (result.status === "unauthorized") {
|
||||
throw new AuthError(401, "Unauthorized: no session. Sign in via SSO first.");
|
||||
}
|
||||
|
||||
// TODO(Wave B): verifySession(cookie) → SessionData. Until then, accept a
|
||||
// dev header for the tenant id + role. Default to admin so the relay
|
||||
// happy path is walkable in local dev before Wave B lands.
|
||||
const tenantId = req.headers.get("x-coreci-tenant-id") ?? process.env.CORECI_DEV_TENANT_ID;
|
||||
if (!tenantId) {
|
||||
throw new AuthError(400, "Missing tenant id. Set x-coreci-tenant-id or CORECI_DEV_TENANT_ID for local dev.");
|
||||
if (result.status === "forbidden") {
|
||||
throw new AuthError(403, `Forbidden: admin role required (got ${result.user.role}).`);
|
||||
}
|
||||
|
||||
const role = (req.headers.get("x-coreci-role") ?? "admin") as AuthContext["role"];
|
||||
if (role !== "admin") {
|
||||
throw new AuthError(403, `Forbidden: relay token issuance requires the Admin role (got ${role}).`);
|
||||
if (result.user.role !== "admin") {
|
||||
throw new AuthError(403, `Forbidden: admin role required (got ${result.user.role}).`);
|
||||
}
|
||||
|
||||
const userId = req.headers.get("x-coreci-user-id") ?? "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
return { tenantId, userId, role };
|
||||
return {
|
||||
user: {
|
||||
id: result.user.userId,
|
||||
tenantId: result.user.tenantId,
|
||||
role: result.user.role,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Health computation for Relay Agent targets (REQ-014, Wave E).
|
||||
*
|
||||
* Status is computed from `targets.last_seen_at`:
|
||||
* green — last_seen within the last 60s (agent is heartbeating)
|
||||
* yellow — last_seen within the last 5min (degraded — maybe a slow network
|
||||
* or a restart in progress; agent may still recover)
|
||||
* red — last_seen more than 5min ago, or NULL (never seen / down)
|
||||
*
|
||||
* The thresholds match the Wave E spec + the in-memory `getConnectedAgents()`
|
||||
* snapshot from the WS server (real-time online check). The dashboard SSE
|
||||
* fan-out (api/relay/status) and the targets list/detail routes both use this
|
||||
* helper so the badge color is consistent everywhere.
|
||||
*/
|
||||
|
||||
export type HealthStatus = "green" | "yellow" | "red";
|
||||
|
||||
/** green threshold (seconds since last_seen). */
|
||||
export const GREEN_THRESHOLD_S = 60;
|
||||
/** yellow threshold (seconds since last_seen). */
|
||||
export const YELLOW_THRESHOLD_S = 5 * 60;
|
||||
|
||||
export interface HealthInput {
|
||||
/** ISO timestamptz string, or null if the agent never heartbeated. */
|
||||
lastSeenAt: string | null;
|
||||
/** Optional: the WS server's in-memory connected-agent snapshot, keyed by
|
||||
* targetId. If a target is currently connected, we treat it as green even
|
||||
* if last_seen is slightly stale (heartbeat updates are batched). */
|
||||
connectedTargetIds?: ReadonlySet<string>;
|
||||
/** Target id (for the connected lookup). */
|
||||
targetId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute health from last_seen_at (and optionally the connected-agents set).
|
||||
* `now` defaults to Date.now() but can be injected for deterministic tests.
|
||||
*/
|
||||
export function computeHealth(input: HealthInput, now: number = Date.now()): HealthStatus {
|
||||
if (input.targetId && input.connectedTargetIds?.has(input.targetId)) {
|
||||
return "green";
|
||||
}
|
||||
if (!input.lastSeenAt) return "red";
|
||||
const last = Date.parse(input.lastSeenAt);
|
||||
if (Number.isNaN(last)) return "red";
|
||||
const ageS = (now - last) / 1000;
|
||||
if (ageS <= GREEN_THRESHOLD_S) return "green";
|
||||
if (ageS <= YELLOW_THRESHOLD_S) return "yellow";
|
||||
return "red";
|
||||
}
|
||||
|
||||
/** Badge color for a health status (used by the dashboard pages). */
|
||||
export function healthColor(status: HealthStatus): string {
|
||||
switch (status) {
|
||||
case "green":
|
||||
return "#22c55e";
|
||||
case "yellow":
|
||||
return "#eab308";
|
||||
case "red":
|
||||
return "#ef4444";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* control-plane dashboard integration test (Wave E, REQ-014).
|
||||
*
|
||||
* Boots PGlite + migrations, provisions two tenants (T1, T2), seeds targets
|
||||
* with various last_seen_at ages, and exercises the Wave E route handlers
|
||||
* directly (constructing NextRequest with the session cookie). Asserts:
|
||||
*
|
||||
* - Health computation: green within 60s, yellow within 5min, red after 5min,
|
||||
* and red for NULL last_seen (never seen).
|
||||
* - /api/targets returns only the caller's tenant's targets (RLS: T1 cannot
|
||||
* see T2's targets).
|
||||
* - /api/targets/[id] returns 404 for T2's target when T1 calls (RLS).
|
||||
* - /api/targets/[id]/logs returns the last 100 audit_log entries for the
|
||||
* target, scoped to the caller's tenant.
|
||||
* - /api/audit/export returns CSV scoped to the caller's tenant (no T2 rows).
|
||||
* - Audit export is admin-only: a viewer session → 403.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
withTenant,
|
||||
appendAudit,
|
||||
type DbClient,
|
||||
type ScopedClient,
|
||||
} from "@coreci/db";
|
||||
import { provisionTenant, createSession } from "@coreci/auth";
|
||||
|
||||
import { GET as getTargets } from "../app/api/targets/route.js";
|
||||
import { GET as getTargetById } from "../app/api/targets/[id]/route.js";
|
||||
import { GET as getTargetLogs } from "../app/api/targets/[id]/logs/route.js";
|
||||
import { GET as getAuditExport } from "../app/api/audit/export/route.js";
|
||||
import { computeHealth, GREEN_THRESHOLD_S, YELLOW_THRESHOLD_S } from "../lib/health.js";
|
||||
import { getDb } from "../lib/db.js";
|
||||
|
||||
const SESSION_SIGNING_KEY = "dashboard-test-session-signing-key";
|
||||
const T1_ORG = "org_t1_dashboard";
|
||||
const T2_ORG = "org_t2_dashboard";
|
||||
|
||||
let db: DbClient;
|
||||
let t1: { tenantId: string; userId: string; token: string } | null = null;
|
||||
let t2: { tenantId: string; userId: string; token: string } | null = null;
|
||||
|
||||
async function seedTenant(orgId: string, email: string): Promise<{
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
token: string;
|
||||
}> {
|
||||
const p = await provisionTenant(db, orgId, `workos_${orgId}`, email, `Tenant ${orgId}`);
|
||||
// provisionTenant inserts the membership row inside withTenant; createSession
|
||||
// writes a session row (NOT tenant-scoped).
|
||||
const { token } = await createSession(db, SESSION_SIGNING_KEY, p.userId, p.tenantId, p.role, {
|
||||
lifetimeSeconds: 3600,
|
||||
});
|
||||
return { tenantId: p.tenantId, userId: p.userId, token };
|
||||
}
|
||||
|
||||
/** INSERT a target for `tenantId` with an explicit last_seen_at offset (seconds ago). */
|
||||
async function seedTarget(
|
||||
tenantId: string,
|
||||
hostname: string,
|
||||
lastSeenSecondsAgo: number | null,
|
||||
): Promise<string> {
|
||||
return withTenant(tenantId, async (c: ScopedClient) => {
|
||||
const lastSeenExpr =
|
||||
lastSeenSecondsAgo === null
|
||||
? "NULL"
|
||||
: `now() - interval '${lastSeenSecondsAgo} seconds'`;
|
||||
const res = await c.query<{ id: string }>(
|
||||
`INSERT INTO targets (tenant_id, hostname, os_name, os_version, ip_address, agent_version, last_seen_at)
|
||||
VALUES ($1, $2, 'linux', '24.04', '10.0.0.1', '0.0.6', ${lastSeenExpr})
|
||||
RETURNING id`,
|
||||
[tenantId, hostname],
|
||||
);
|
||||
const id = res.rows[0]?.id;
|
||||
if (!id) throw new Error("seedTarget: INSERT returned no id");
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
/** Append an audit_log entry for a target (so /logs has something to surface). */
|
||||
async function seedAuditForTarget(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
targetId: string,
|
||||
eventType: "provision" | "config" | "ssh_command" | "tool_call",
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await withTenant(tenantId, async (c: ScopedClient) => {
|
||||
await appendAudit(c, {
|
||||
tenantId,
|
||||
eventType,
|
||||
payload,
|
||||
userId,
|
||||
targetId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function authedReq(path: string, token: string, method = "GET"): NextRequest {
|
||||
const url = new URL(path, "http://localhost:3000");
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: { cookie: `coreci_session=${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.SESSION_SIGNING_KEY = SESSION_SIGNING_KEY;
|
||||
// Use the control-plane's getDb() so the test + route handlers share the
|
||||
// same PGlite instance (getDb bootstraps + setDbClient registers it).
|
||||
db = await getDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
/* PGlite holds no external resources */
|
||||
void db;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset the seeded tenants per test for isolation. Re-provisioning is cheap
|
||||
// (idempotent). We re-seed t1/t2 sessions each test.
|
||||
t1 = await seedTenant(T1_ORG, `admin@${T1_ORG}.example`);
|
||||
t2 = await seedTenant(T2_ORG, `admin@${T2_ORG}.example`);
|
||||
});
|
||||
|
||||
describe("health computation (lib/health)", () => {
|
||||
it("green within 60s of last_seen", () => {
|
||||
const now = Date.now();
|
||||
const lastSeen = new Date(now - 30 * 1000).toISOString();
|
||||
expect(computeHealth({ lastSeenAt: lastSeen }, now)).toBe("green");
|
||||
// boundary: exactly 60s is still green
|
||||
const lastSeen60 = new Date(now - GREEN_THRESHOLD_S * 1000).toISOString();
|
||||
expect(computeHealth({ lastSeenAt: lastSeen60 }, now)).toBe("green");
|
||||
});
|
||||
|
||||
it("yellow within 5min of last_seen", () => {
|
||||
const now = Date.now();
|
||||
const lastSeen = new Date(now - 2 * 60 * 1000).toISOString();
|
||||
expect(computeHealth({ lastSeenAt: lastSeen }, now)).toBe("yellow");
|
||||
// boundary: exactly 5min is still yellow
|
||||
const lastSeen5m = new Date(now - YELLOW_THRESHOLD_S * 1000).toISOString();
|
||||
expect(computeHealth({ lastSeenAt: lastSeen5m }, now)).toBe("yellow");
|
||||
});
|
||||
|
||||
it("red after 5min or when last_seen is null", () => {
|
||||
const now = Date.now();
|
||||
const lastSeen6m = new Date(now - 6 * 60 * 1000).toISOString();
|
||||
expect(computeHealth({ lastSeenAt: lastSeen6m }, now)).toBe("red");
|
||||
expect(computeHealth({ lastSeenAt: null }, now)).toBe("red");
|
||||
});
|
||||
|
||||
it("connected target is green even if last_seen is stale", () => {
|
||||
const now = Date.now();
|
||||
const stale = new Date(now - 10 * 60 * 1000).toISOString();
|
||||
const connected = new Set(["target-x"]);
|
||||
expect(
|
||||
computeHealth({ lastSeenAt: stale, targetId: "target-x", connectedTargetIds: connected }, now),
|
||||
).toBe("green");
|
||||
});
|
||||
|
||||
it("an unparseable last_seen is red (defensive)", () => {
|
||||
expect(computeHealth({ lastSeenAt: "not-a-date" }, Date.now())).toBe("red");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/targets (REQ-014)", () => {
|
||||
it("returns T1's targets with health status computed from last_seen", async () => {
|
||||
const greenId = await seedTarget(t1!.tenantId, "green-host", 5);
|
||||
const yellowId = await seedTarget(t1!.tenantId, "yellow-host", 120);
|
||||
const redId = await seedTarget(t1!.tenantId, "red-host", 600);
|
||||
const neverId = await seedTarget(t1!.tenantId, "never-host", null);
|
||||
|
||||
const res = await getTargets(authedReq("/api/targets", t1!.token));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Array<{ id: string; health: string }>;
|
||||
const byId = new Map(body.map((r) => [r.id, r.health]));
|
||||
expect(byId.get(greenId)).toBe("green");
|
||||
expect(byId.get(yellowId)).toBe("yellow");
|
||||
expect(byId.get(redId)).toBe("red");
|
||||
expect(byId.get(neverId)).toBe("red");
|
||||
});
|
||||
|
||||
it("T1 cannot see T2's targets (RLS — T2 rows absent)", async () => {
|
||||
await seedTarget(t2!.tenantId, "t2-secret-host", 5);
|
||||
await seedTarget(t1!.tenantId, "t1-host", 5);
|
||||
|
||||
const res = await getTargets(authedReq("/api/targets", t1!.token));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Array<{ id: string; hostname: string }>;
|
||||
// The RLS property: zero T2 rows leak into T1's view.
|
||||
expect(body.find((r) => r.hostname === "t2-secret-host")).toBeUndefined();
|
||||
// T1's own row IS visible.
|
||||
expect(body.find((r) => r.hostname === "t1-host")).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns 401 without a session cookie", async () => {
|
||||
const res = await getTargets(new NextRequest(new URL("/api/targets", "http://localhost:3000")));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/targets/[id] (REQ-014, RLS)", () => {
|
||||
it("returns the target detail for the caller's tenant", async () => {
|
||||
const id = await seedTarget(t1!.tenantId, "detail-host", 10);
|
||||
const res = await getTargetById(authedReq(`/api/targets/${id}`, t1!.token), {
|
||||
params: Promise.resolve({ id }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { id: string; hostname: string; health: string };
|
||||
expect(body.id).toBe(id);
|
||||
expect(body.hostname).toBe("detail-host");
|
||||
expect(body.health).toBe("green");
|
||||
});
|
||||
|
||||
it("T1 cannot view T2's target (RLS — 404, no cross-tenant existence leak)", async () => {
|
||||
const t2Id = await seedTarget(t2!.tenantId, "t2-private-host", 10);
|
||||
const res = await getTargetById(authedReq(`/api/targets/${t2Id}`, t1!.token), {
|
||||
params: Promise.resolve({ id: t2Id }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/targets/[id]/logs (REQ-014)", () => {
|
||||
it("returns the last 100 audit_log entries for the target (newest first)", async () => {
|
||||
const id = await seedTarget(t1!.tenantId, "logs-host", 10);
|
||||
// Seed 3 audit entries referencing this target.
|
||||
await seedAuditForTarget(t1!.tenantId, t1!.userId, id, "provision", {
|
||||
action: "relay_target_registered",
|
||||
hostname: "logs-host",
|
||||
targetId: id,
|
||||
});
|
||||
await seedAuditForTarget(t1!.tenantId, t1!.userId, id, "config", {
|
||||
action: "config_applied",
|
||||
targetId: id,
|
||||
});
|
||||
await seedAuditForTarget(t1!.tenantId, t1!.userId, id, "ssh_command", {
|
||||
action: "ssh_exec",
|
||||
cmd: "systemctl status nginx",
|
||||
targetId: id,
|
||||
});
|
||||
|
||||
const res = await getTargetLogs(authedReq(`/api/targets/${id}/logs`, t1!.token), {
|
||||
params: Promise.resolve({ id }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { logs: Array<{ id: string; eventType: string; payload: Record<string, unknown> }> };
|
||||
expect(body.logs.length).toBe(3);
|
||||
// Newest first: ssh_command (id 3) before config (id 2) before provision (id 1).
|
||||
expect(body.logs[0]!.eventType).toBe("ssh_command");
|
||||
expect(body.logs[1]!.eventType).toBe("config");
|
||||
expect(body.logs[2]!.eventType).toBe("provision");
|
||||
// Payloads are redacted summaries (no hash bytes leaked).
|
||||
expect(body.logs[0]!.payload.action).toBe("ssh_exec");
|
||||
expect(body.logs[0]!.payload.targetId).toBe(id);
|
||||
});
|
||||
|
||||
it("T1 cannot view T2's target logs (RLS — 404)", async () => {
|
||||
const t2Id = await seedTarget(t2!.tenantId, "t2-logs-host", 10);
|
||||
const res = await getTargetLogs(authedReq(`/api/targets/${t2Id}/logs`, t1!.token), {
|
||||
params: Promise.resolve({ id: t2Id }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 200 with empty logs for an existing target with no audit entries", async () => {
|
||||
const id = await seedTarget(t1!.tenantId, "silent-host", 10);
|
||||
const res = await getTargetLogs(authedReq(`/api/targets/${id}/logs`, t1!.token), {
|
||||
params: Promise.resolve({ id }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { logs: unknown[] };
|
||||
expect(body.logs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/audit/export (REQ-038, RLS)", () => {
|
||||
it("exports CSV scoped to the caller's tenant (no T2 rows)", async () => {
|
||||
// Seed T1 + T2 audit entries (provisionTenant already wrote one provision
|
||||
// entry per tenant on the beforeEach seed; add a couple more for T1).
|
||||
const t1Target = await seedTarget(t1!.tenantId, "audit-host", 10);
|
||||
await seedAuditForTarget(t1!.tenantId, t1!.userId, t1Target, "config", {
|
||||
action: "config_applied",
|
||||
targetId: t1Target,
|
||||
});
|
||||
const t2Target = await seedTarget(t2!.tenantId, "t2-audit-host", 10);
|
||||
await seedAuditForTarget(t2!.tenantId, t2!.userId, t2Target, "config", {
|
||||
action: "t2_secret_config",
|
||||
targetId: t2Target,
|
||||
});
|
||||
|
||||
const res = await getAuditExport(authedReq("/api/audit/export", t1!.token));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain("text/csv");
|
||||
const csv = await res.text();
|
||||
const lines = csv.split("\r\n");
|
||||
// Header row.
|
||||
expect(lines[0]).toBe("id,timestamp,event_type,user_id,target_id,payload");
|
||||
// Every data row must reference T1's tenant — verify NO t2_secret_config leak.
|
||||
expect(csv).not.toContain("t2_secret_config");
|
||||
// T1's rows ARE present (provision from seedTenant + the config event).
|
||||
expect(csv).toContain("provision");
|
||||
expect(csv).toContain("config_applied");
|
||||
});
|
||||
|
||||
it("admin-only: a viewer session → 403", async () => {
|
||||
// Create a viewer session for T1.
|
||||
const viewer = await createSession(db, SESSION_SIGNING_KEY, t1!.userId, t1!.tenantId, "viewer", {
|
||||
lifetimeSeconds: 3600,
|
||||
});
|
||||
const res = await getAuditExport(authedReq("/api/audit/export", viewer.token));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("CSV field escaping: a payload with a comma is quoted", async () => {
|
||||
const t1Target = await seedTarget(t1!.tenantId, "escape-host", 10);
|
||||
await seedAuditForTarget(t1!.tenantId, t1!.userId, t1Target, "ssh_command", {
|
||||
action: "ssh_exec",
|
||||
cmd: "echo a,b,c",
|
||||
targetId: t1Target,
|
||||
});
|
||||
const res = await getAuditExport(authedReq("/api/audit/export", t1!.token));
|
||||
expect(res.status).toBe(200);
|
||||
const csv = await res.text();
|
||||
// The payload JSON contains a comma; the whole payload field is one CSV
|
||||
// cell and must be quoted. Find the ssh_command row.
|
||||
const sshLine = csv.split("\r\n").find((l) => l.includes("ssh_command"));
|
||||
expect(sshLine, `ssh_command row present in:\n${csv}`).toBeDefined();
|
||||
// The payload cell starts with a quote (RFC 4180 quoting).
|
||||
expect(sshLine!.split(",").pop()!.startsWith('"')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user