Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cueweb/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ NEXT_PUBLIC_OPENCUE_ENDPOINT=http://your-rest-gateway-url.com
# overrides across container restarts (defaults to a file in the OS temp dir).
# CUEWEB_FACILITY_STORE=/data/cueweb/facilities.json

# CueWeb Audit (Admin -> CueWeb Audit). Append-only JSONL trail of who performed
# which action, when, against which target, and the outcome — captured at the
# gateway chokepoint for every state-changing CueWeb action, plus sign-in/out.
# Point CUEWEB_AUDIT_STORE at a mounted volume to keep the trail across restarts
# (defaults to a file in the OS temp dir). CUEWEB_AUDIT_MAX_RECORDS caps the
# retained records (older lines drop on write; default 50000, 0 = no cap).
# The page is admin-only via the same gate as the CueCommander pages
# (CUEWEB_ADMIN_GROUPS); with no group authorization configured it is open to all.
# CUEWEB_AUDIT_STORE=/data/cueweb/cueweb-audit.jsonl
# CUEWEB_AUDIT_MAX_RECORDS=50000

# Optional allow-list for the Stuck Frames "Last Line" reader
# (/api/stuck-frames/lastline), as a colon-separated list of absolute path
# prefixes. When set, only .rqlog files under one of these roots are read.
Expand Down
129 changes: 129 additions & 0 deletions cueweb/app/__tests__/lib/audit-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Copyright Contributors to the OpenCue Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { promises as fs } from "fs";
import os from "os";
import path from "path";

// STORE_PATH is captured at module load from CUEWEB_AUDIT_STORE, so set a
// unique temp path BEFORE requiring the module under test.
const TMP_STORE = path.join(
os.tmpdir(),
`cueweb-audit-test-${process.pid}-${Date.now()}.jsonl`,
);
process.env.CUEWEB_AUDIT_STORE = TMP_STORE;

const {
recordAudit,
readAudit,
readAuditFacets,
auditStorePath,
} = require("@/lib/audit-store") as typeof import("@/lib/audit-store");

afterAll(async () => {
await fs.rm(TMP_STORE, { force: true }).catch(() => undefined);
});

// Reset the file between tests so each starts from a clean trail.
beforeEach(async () => {
await fs.rm(TMP_STORE, { force: true }).catch(() => undefined);
});

function rec(over: Partial<Parameters<typeof recordAudit>[0]> = {}) {
return {
at: new Date().toISOString(),
actor: "alice@example.com",
category: "job" as const,
action: "Kill",
target: "job:comp_v1",
facility: "DEV",
result: "success" as const,
...over,
};
}

describe("audit-store", () => {
it("reports the configured store path", () => {
expect(auditStorePath()).toBe(TMP_STORE);
});

it("returns an empty page when nothing has been recorded", async () => {
const page = await readAudit();
expect(page).toEqual({ records: [], total: 0 });
});

it("records and reads back events, newest first", async () => {
await recordAudit(rec({ at: "2026-06-22T10:00:00.000Z", action: "Pause" }));
await recordAudit(rec({ at: "2026-06-22T11:00:00.000Z", action: "Kill" }));

const { records, total } = await readAudit();
expect(total).toBe(2);
expect(records.map((r) => r.action)).toEqual(["Kill", "Pause"]);
});

it("filters by actor (case-insensitive substring), category and result", async () => {
await recordAudit(rec({ actor: "alice@example.com", category: "job" }));
await recordAudit(rec({ actor: "bob@example.com", category: "host", action: "Lock Host" }));
await recordAudit(rec({ actor: "alice@example.com", result: "error", error: "boom" }));

expect((await readAudit({ actor: "ALICE" })).total).toBe(2);
expect((await readAudit({ category: "host" })).total).toBe(1);
expect((await readAudit({ result: "error" })).total).toBe(1);
});

it("filters by time window", async () => {
await recordAudit(rec({ at: "2026-06-20T00:00:00.000Z" }));
await recordAudit(rec({ at: "2026-06-22T00:00:00.000Z" }));
await recordAudit(rec({ at: "2026-06-24T00:00:00.000Z" }));

const page = await readAudit({
since: "2026-06-21T00:00:00.000Z",
until: "2026-06-23T00:00:00.000Z",
});
expect(page.total).toBe(1);
expect(page.records[0].at).toBe("2026-06-22T00:00:00.000Z");
});

it("searches across actor / action / target / error", async () => {
await recordAudit(rec({ action: "Kill", target: "job:render_final" }));
await recordAudit(rec({ action: "Pause", target: "job:other", result: "error", error: "timeout" }));

expect((await readAudit({ search: "render_final" })).total).toBe(1);
expect((await readAudit({ search: "timeout" })).total).toBe(1);
expect((await readAudit({ search: "nope" })).total).toBe(0);
});

it("paginates with limit and offset while reporting the full total", async () => {
for (let i = 0; i < 5; i++) {
await recordAudit(rec({ at: `2026-06-22T0${i}:00:00.000Z`, action: `A${i}` }));
}
const page = await readAudit({ limit: 2, offset: 1 });
expect(page.total).toBe(5);
expect(page.records).toHaveLength(2);
// Newest first => A4, A3, A2, A1, A0; offset 1 + limit 2 => A3, A2.
expect(page.records.map((r) => r.action)).toEqual(["A3", "A2"]);
});

it("exposes distinct actors and categories as facets", async () => {
await recordAudit(rec({ actor: "alice@example.com", category: "job" }));
await recordAudit(rec({ actor: "bob@example.com", category: "host" }));
await recordAudit(rec({ actor: "alice@example.com", category: "job" }));

const facets = await readAuditFacets();
expect(facets.actors).toEqual(["alice@example.com", "bob@example.com"]);
expect(facets.categories).toEqual(["host", "job"]);
});
});
82 changes: 82 additions & 0 deletions cueweb/app/__tests__/lib/authz-admin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright Contributors to the OpenCue Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {
isAdminPath,
isEffectiveAdmin,
isGateActive,
} from "@/lib/authz";

// authz reads the env at call time, so each test just sets the vars it needs.
const ENV_KEYS = [
"NEXT_PUBLIC_AUTH_PROVIDER",
"CUEWEB_AUTHZ_ENABLED",
"CUEWEB_ADMIN_GROUPS",
] as const;
const ORIGINAL: Record<string, string | undefined> = {};
beforeAll(() => ENV_KEYS.forEach((k) => (ORIGINAL[k] = process.env[k])));
afterEach(() => {
ENV_KEYS.forEach((k) => {
if (ORIGINAL[k] === undefined) delete process.env[k];
else process.env[k] = ORIGINAL[k];
});
});

function setEnv(env: Partial<Record<(typeof ENV_KEYS)[number], string>>) {
ENV_KEYS.forEach((k) => delete process.env[k]);
Object.entries(env).forEach(([k, v]) => (process.env[k] = v));
}

describe("authz admin helpers", () => {
it("treats /admin and /api/admin as admin-only paths", () => {
expect(isAdminPath("/admin/audit")).toBe(true);
expect(isAdminPath("/api/admin/audit")).toBe(true);
expect(isAdminPath("/monitor-cue")).toBe(false);
});

it("gate is inactive without an auth provider", () => {
setEnv({ CUEWEB_AUTHZ_ENABLED: "true", CUEWEB_ADMIN_GROUPS: "admins" });
expect(isGateActive()).toBe(false);
// Inactive gate => everyone is effectively admin (show to everyone).
expect(isEffectiveAdmin([])).toBe(true);
});

it("gate is inactive when CUEWEB_AUTHZ_ENABLED is off", () => {
setEnv({ NEXT_PUBLIC_AUTH_PROVIDER: "okta", CUEWEB_AUTHZ_ENABLED: "false" });
expect(isGateActive()).toBe(false);
expect(isEffectiveAdmin([])).toBe(true);
});

it("active gate with no admin groups configured => everyone is admin", () => {
setEnv({ NEXT_PUBLIC_AUTH_PROVIDER: "okta", CUEWEB_AUTHZ_ENABLED: "true" });
expect(isGateActive()).toBe(true);
expect(isEffectiveAdmin([])).toBe(true);
expect(isEffectiveAdmin(["anything"])).toBe(true);
});

it("active gate with admin groups restricts to members", () => {
setEnv({
NEXT_PUBLIC_AUTH_PROVIDER: "okta",
CUEWEB_AUTHZ_ENABLED: "true",
CUEWEB_ADMIN_GROUPS: "cue-admins",
});
expect(isGateActive()).toBe(true);
expect(isEffectiveAdmin(["cue-admins"])).toBe(true);
expect(isEffectiveAdmin(["CUE-ADMINS"])).toBe(true); // case-insensitive
expect(isEffectiveAdmin(["renderers"])).toBe(false);
expect(isEffectiveAdmin([])).toBe(false);
});
});
Loading
Loading