Skip to content

Commit 66acc9c

Browse files
[cueweb/docs] Add CueWeb Audit web action audit system (#2461)
## Related Issues Main issue: - #2016 Issues related to this PR: - #2462 ## Summarize your change. Record who performed which state-changing action, when, against which target, on which facility, and with what outcome, surfaced on a new admin-only page reachable from the top menu and left sidebar (Admin -> CueWeb Audit). Implementation: - Capture every mutating action at the single gateway chokepoint (handleRoute in app/utils/gateway_server.ts -> auditGatewayCall in lib/audit.ts): classify endpoint, extract target, resolve actor and facility, sanitize params, skip read-only calls. Sign in/out captured via NextAuth events in lib/auth.ts. - Persist to an append-only JSONL store (lib/audit-store.ts), mirroring the facility-store pattern so CueWeb stays stateless. Configurable via CUEWEB_AUDIT_STORE; size-bounded via CUEWEB_AUDIT_MAX_RECORDS. - Admin-gated read API (app/api/admin/audit) and SSR page (app/admin/audit) with a filterable, paginated, mobile-friendly table: search, actor/category/result/time filters, page navigation with rows-per-page matching Monitor Jobs, auto-refresh, expandable details, CSV export. - Reuse the optional group-authorization gate: add /admin and /api/admin to ADMIN_PATH_PREFIXES; isGateActive/isEffectiveAdmin show the page to everyone when no group authorization is configured, else restrict to CUEWEB_ADMIN_GROUPS. Hide admin-only menus from non-admins in the header, sidebar, and mobile nav. - Document the feature across all eight CueWeb docs sections and add unit tests for the store/query logic and the effective-admin gating. ## LLM usage disclosure Parts of this solution's implementation were developed with assistance from Claude Opus.
1 parent 7f22324 commit 66acc9c

28 files changed

Lines changed: 2066 additions & 16 deletions

cueweb/.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ NEXT_PUBLIC_OPENCUE_ENDPOINT=http://your-rest-gateway-url.com
3838
# overrides across container restarts (defaults to a file in the OS temp dir).
3939
# CUEWEB_FACILITY_STORE=/data/cueweb/facilities.json
4040

41+
# CueWeb Audit (Admin -> CueWeb Audit). Append-only JSONL trail of who performed
42+
# which action, when, against which target, and the outcome — captured at the
43+
# gateway chokepoint for every state-changing CueWeb action, plus sign-in/out.
44+
# Point CUEWEB_AUDIT_STORE at a mounted volume to keep the trail across restarts
45+
# (defaults to a file in the OS temp dir). CUEWEB_AUDIT_MAX_RECORDS caps the
46+
# retained records (older lines drop on write; default 50000, 0 = no cap).
47+
# The page is admin-only via the same gate as the CueCommander pages
48+
# (CUEWEB_ADMIN_GROUPS); with no group authorization configured it is open to all.
49+
# CUEWEB_AUDIT_STORE=/data/cueweb/cueweb-audit.jsonl
50+
# CUEWEB_AUDIT_MAX_RECORDS=50000
51+
4152
# Optional allow-list for the Stuck Frames "Last Line" reader
4253
# (/api/stuck-frames/lastline), as a colon-separated list of absolute path
4354
# prefixes. When set, only .rqlog files under one of these roots are read.
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Copyright Contributors to the OpenCue Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { promises as fs } from "fs";
18+
import os from "os";
19+
import path from "path";
20+
21+
// STORE_PATH is captured at module load from CUEWEB_AUDIT_STORE, so set a
22+
// unique temp path BEFORE requiring the module under test.
23+
const TMP_STORE = path.join(
24+
os.tmpdir(),
25+
`cueweb-audit-test-${process.pid}-${Date.now()}.jsonl`,
26+
);
27+
process.env.CUEWEB_AUDIT_STORE = TMP_STORE;
28+
29+
const {
30+
recordAudit,
31+
readAudit,
32+
readAuditFacets,
33+
auditStorePath,
34+
} = require("@/lib/audit-store") as typeof import("@/lib/audit-store");
35+
36+
afterAll(async () => {
37+
await fs.rm(TMP_STORE, { force: true }).catch(() => undefined);
38+
});
39+
40+
// Reset the file between tests so each starts from a clean trail.
41+
beforeEach(async () => {
42+
await fs.rm(TMP_STORE, { force: true }).catch(() => undefined);
43+
});
44+
45+
function rec(over: Partial<Parameters<typeof recordAudit>[0]> = {}) {
46+
return {
47+
at: new Date().toISOString(),
48+
actor: "alice@example.com",
49+
category: "job" as const,
50+
action: "Kill",
51+
target: "job:comp_v1",
52+
facility: "DEV",
53+
result: "success" as const,
54+
...over,
55+
};
56+
}
57+
58+
describe("audit-store", () => {
59+
it("reports the configured store path", () => {
60+
expect(auditStorePath()).toBe(TMP_STORE);
61+
});
62+
63+
it("returns an empty page when nothing has been recorded", async () => {
64+
const page = await readAudit();
65+
expect(page).toEqual({ records: [], total: 0 });
66+
});
67+
68+
it("records and reads back events, newest first", async () => {
69+
await recordAudit(rec({ at: "2026-06-22T10:00:00.000Z", action: "Pause" }));
70+
await recordAudit(rec({ at: "2026-06-22T11:00:00.000Z", action: "Kill" }));
71+
72+
const { records, total } = await readAudit();
73+
expect(total).toBe(2);
74+
expect(records.map((r) => r.action)).toEqual(["Kill", "Pause"]);
75+
});
76+
77+
it("filters by actor (case-insensitive substring), category and result", async () => {
78+
await recordAudit(rec({ actor: "alice@example.com", category: "job" }));
79+
await recordAudit(rec({ actor: "bob@example.com", category: "host", action: "Lock Host" }));
80+
await recordAudit(rec({ actor: "alice@example.com", result: "error", error: "boom" }));
81+
82+
expect((await readAudit({ actor: "ALICE" })).total).toBe(2);
83+
expect((await readAudit({ category: "host" })).total).toBe(1);
84+
expect((await readAudit({ result: "error" })).total).toBe(1);
85+
});
86+
87+
it("filters by time window", async () => {
88+
await recordAudit(rec({ at: "2026-06-20T00:00:00.000Z" }));
89+
await recordAudit(rec({ at: "2026-06-22T00:00:00.000Z" }));
90+
await recordAudit(rec({ at: "2026-06-24T00:00:00.000Z" }));
91+
92+
const page = await readAudit({
93+
since: "2026-06-21T00:00:00.000Z",
94+
until: "2026-06-23T00:00:00.000Z",
95+
});
96+
expect(page.total).toBe(1);
97+
expect(page.records[0].at).toBe("2026-06-22T00:00:00.000Z");
98+
});
99+
100+
it("searches across actor / action / target / error", async () => {
101+
await recordAudit(rec({ action: "Kill", target: "job:render_final" }));
102+
await recordAudit(rec({ action: "Pause", target: "job:other", result: "error", error: "timeout" }));
103+
104+
expect((await readAudit({ search: "render_final" })).total).toBe(1);
105+
expect((await readAudit({ search: "timeout" })).total).toBe(1);
106+
expect((await readAudit({ search: "nope" })).total).toBe(0);
107+
});
108+
109+
it("paginates with limit and offset while reporting the full total", async () => {
110+
for (let i = 0; i < 5; i++) {
111+
await recordAudit(rec({ at: `2026-06-22T0${i}:00:00.000Z`, action: `A${i}` }));
112+
}
113+
const page = await readAudit({ limit: 2, offset: 1 });
114+
expect(page.total).toBe(5);
115+
expect(page.records).toHaveLength(2);
116+
// Newest first => A4, A3, A2, A1, A0; offset 1 + limit 2 => A3, A2.
117+
expect(page.records.map((r) => r.action)).toEqual(["A3", "A2"]);
118+
});
119+
120+
it("exposes distinct actors and categories as facets", async () => {
121+
await recordAudit(rec({ actor: "alice@example.com", category: "job" }));
122+
await recordAudit(rec({ actor: "bob@example.com", category: "host" }));
123+
await recordAudit(rec({ actor: "alice@example.com", category: "job" }));
124+
125+
const facets = await readAuditFacets();
126+
expect(facets.actors).toEqual(["alice@example.com", "bob@example.com"]);
127+
expect(facets.categories).toEqual(["host", "job"]);
128+
});
129+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright Contributors to the OpenCue Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {
18+
isAdminPath,
19+
isEffectiveAdmin,
20+
isGateActive,
21+
} from "@/lib/authz";
22+
23+
// authz reads the env at call time, so each test just sets the vars it needs.
24+
const ENV_KEYS = [
25+
"NEXT_PUBLIC_AUTH_PROVIDER",
26+
"CUEWEB_AUTHZ_ENABLED",
27+
"CUEWEB_ADMIN_GROUPS",
28+
] as const;
29+
const ORIGINAL: Record<string, string | undefined> = {};
30+
beforeAll(() => ENV_KEYS.forEach((k) => (ORIGINAL[k] = process.env[k])));
31+
afterEach(() => {
32+
ENV_KEYS.forEach((k) => {
33+
if (ORIGINAL[k] === undefined) delete process.env[k];
34+
else process.env[k] = ORIGINAL[k];
35+
});
36+
});
37+
38+
function setEnv(env: Partial<Record<(typeof ENV_KEYS)[number], string>>) {
39+
ENV_KEYS.forEach((k) => delete process.env[k]);
40+
Object.entries(env).forEach(([k, v]) => (process.env[k] = v));
41+
}
42+
43+
describe("authz admin helpers", () => {
44+
it("treats /admin and /api/admin as admin-only paths", () => {
45+
expect(isAdminPath("/admin/audit")).toBe(true);
46+
expect(isAdminPath("/api/admin/audit")).toBe(true);
47+
expect(isAdminPath("/monitor-cue")).toBe(false);
48+
});
49+
50+
it("gate is inactive without an auth provider", () => {
51+
setEnv({ CUEWEB_AUTHZ_ENABLED: "true", CUEWEB_ADMIN_GROUPS: "admins" });
52+
expect(isGateActive()).toBe(false);
53+
// Inactive gate => everyone is effectively admin (show to everyone).
54+
expect(isEffectiveAdmin([])).toBe(true);
55+
});
56+
57+
it("gate is inactive when CUEWEB_AUTHZ_ENABLED is off", () => {
58+
setEnv({ NEXT_PUBLIC_AUTH_PROVIDER: "okta", CUEWEB_AUTHZ_ENABLED: "false" });
59+
expect(isGateActive()).toBe(false);
60+
expect(isEffectiveAdmin([])).toBe(true);
61+
});
62+
63+
it("active gate with no admin groups configured => everyone is admin", () => {
64+
setEnv({ NEXT_PUBLIC_AUTH_PROVIDER: "okta", CUEWEB_AUTHZ_ENABLED: "true" });
65+
expect(isGateActive()).toBe(true);
66+
expect(isEffectiveAdmin([])).toBe(true);
67+
expect(isEffectiveAdmin(["anything"])).toBe(true);
68+
});
69+
70+
it("active gate with admin groups restricts to members", () => {
71+
setEnv({
72+
NEXT_PUBLIC_AUTH_PROVIDER: "okta",
73+
CUEWEB_AUTHZ_ENABLED: "true",
74+
CUEWEB_ADMIN_GROUPS: "cue-admins",
75+
});
76+
expect(isGateActive()).toBe(true);
77+
expect(isEffectiveAdmin(["cue-admins"])).toBe(true);
78+
expect(isEffectiveAdmin(["CUE-ADMINS"])).toBe(true); // case-insensitive
79+
expect(isEffectiveAdmin(["renderers"])).toBe(false);
80+
expect(isEffectiveAdmin([])).toBe(false);
81+
});
82+
});

0 commit comments

Comments
 (0)