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: 8 additions & 3 deletions apps/console/src/components/config-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,18 @@ describe("ConfigForm (live) whole-config-replace safety", () => {
expect(screen.getByText(/error 1108/)).toBeInTheDocument();
});

it("does not offer Apply to a non-admin and makes no apply RPC", async () => {
// Was hidden entirely, replaced by a "requires admin level" note; #183
// moves it to the kit's lock pattern instead.
it("locks Apply for a non-admin and makes no apply RPC", async () => {
level = "viewer";
render(<ConfigForm node={issuingNode()} />);

await screen.findByLabelText(/revocation base url/i);
expect(screen.queryByRole("button", { name: /apply/i })).not.toBeInTheDocument();
expect(screen.getByText(/requires admin level/i)).toBeInTheDocument();
const button = screen.getByRole("button", { name: /apply/i });
expect(button).not.toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAccessibleDescription(/needs the admin role/i);
fireEvent.click(button);
expect(applyNodeConfig).not.toHaveBeenCalled();
});
});
Expand Down
17 changes: 8 additions & 9 deletions apps/console/src/components/config-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,15 +245,14 @@ const LiveConfigForm = ({ node }: { node: Node }) => {
</Notice>
) : null}

{isAdmin ? (
<Button disabled={pending} onClick={() => void submit()} type="button">
{pending ? "Applying…" : "Apply"}
</Button>
) : (
<p className="font-mono text-xs text-muted-foreground">
Read-only — applying config requires admin level.
</p>
)}
<Button
disabled={isAdmin && pending}
lockedReason={isAdmin ? undefined : "Needs the admin role"}
onClick={() => void submit()}
type="button"
>
{pending ? "Applying…" : "Apply"}
</Button>
</div>
);
};
Expand Down
11 changes: 10 additions & 1 deletion apps/console/src/components/issue-form-paste.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,20 @@ import "reflect-metadata";
import { Pkcs10CertificateRequestGenerator } from "@peculiar/x509";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeAll, describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";

import { IssueForm } from "@/components/issue-form";
import { nodesList } from "@/lib/nodes";

// IssueForm needs operator level or above; the gate itself is covered in
// issue-form.test.tsx, so this file runs as an operator throughout.
vi.mock("@/context/auth", () => ({
useAuth: () => ({
operator: { commonName: "op@acme.example", level: "operator", serial: "AA" },
status: "authenticated",
}),
}));

let csrPem = "";

beforeAll(async () => {
Expand Down
27 changes: 27 additions & 0 deletions apps/console/src/components/issue-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,19 @@ vi.mock("@/lib/certs", async (importOriginal) => {
return { ...actual, issueCert: (...args: unknown[]) => issueCert(...args) };
});

// Issuing needs operator level or above; every other test here runs as an
// operator so the gate itself is exercised separately below.
let operatorLevel: "admin" | "operator" | "viewer" = "operator";
vi.mock("@/context/auth", () => ({
useAuth: () => ({
operator: { commonName: "op@acme.example", level: operatorLevel, serial: "AA" },
status: "authenticated",
}),
}));

describe("IssueForm profile picker", () => {
beforeEach(() => {
operatorLevel = "operator";
__resetCerts();
__resetProfiles();
fleetMode.mockReturnValue("mock");
Expand Down Expand Up @@ -80,12 +91,28 @@ describe("IssueForm profile picker", () => {
);
await waitFor(() => expect(onIssued).toHaveBeenCalled());
});

// Issuing was never gated in the console at all, even though the manager
// requires operator level; #183 catches the presentation up with the kit's
// lock pattern so a viewer sees why before submitting, not after.
it("locks Issue for a viewer and makes no issue RPC", () => {
operatorLevel = "viewer";
render(<IssueForm node={issuingNode} onIssued={vi.fn()} />);
const button = screen.getByRole("button", { name: /^issue/i });
expect(button).not.toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAccessibleDescription(/needs the operator role/i);
issueCert.mockClear();
fireEvent.click(button);
expect(issueCert).not.toHaveBeenCalled();
});
});

describe("IssueForm live keygen and guarded export", () => {
const fakePrivateKey = { extractable: true } as unknown as CryptoKey;

beforeEach(() => {
operatorLevel = "operator";
__resetCerts();
__resetProfiles();
fleetMode.mockReturnValue("live");
Expand Down
10 changes: 9 additions & 1 deletion apps/console/src/components/issue-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useEffect, useState } from "react";

import type { Node } from "@/lib/mock";

import { useAuth } from "@/context/auth";
import { canIssue, type Cert, type CertKind, issueCert } from "@/lib/certs";
import { parseCsr, type ParsedCsr } from "@/lib/crypto/csr";
import {
Expand Down Expand Up @@ -133,6 +134,8 @@ const KeyExport = ({ privateKey, subjectCn }: { privateKey: CryptoKey; subjectCn
};

export const IssueForm = ({ node, onIssued }: { node: Node; onIssued: (cert: Cert) => void }) => {
const { operator } = useAuth();
const canSubmit = operator ? operator.level !== "viewer" : false;
const kinds = canIssue(node);
const profiles = useProfiles();
const [kind, setKind] = useState<CertKind>(kinds[0]);
Expand Down Expand Up @@ -384,7 +387,12 @@ export const IssueForm = ({ node, onIssued }: { node: Node; onIssued: (cert: Cer
</Field>

{error ? <Notice tone="danger">{error}</Notice> : null}
<Button disabled={pending} onClick={() => void submit()} type="button">
<Button
disabled={canSubmit && pending}
lockedReason={canSubmit ? undefined : "Needs the operator role"}
onClick={() => void submit()}
type="button"
>
{pending ? "Issuing…" : "Issue"}
</Button>
</div>
Expand Down
25 changes: 25 additions & 0 deletions apps/console/src/components/rekey-wizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ vi.mock("@/lib/rekey", async () => {
});
vi.mock("@/lib/fleet/mode", () => ({ fleetMode: () => mode }));

// Re-keying needs operator level or above; the live describe block below runs
// as an operator so the gate itself is exercised separately.
let operatorLevel: "admin" | "operator" | "viewer" = "operator";
vi.mock("@/context/auth", () => ({
useAuth: () => ({
operator: { commonName: "op@acme.example", level: operatorLevel, serial: "AA" },
status: "authenticated",
}),
}));

describe("RekeyWizard (mock)", () => {
beforeEach(() => {
mode = "mock";
Expand All @@ -60,6 +70,7 @@ describe("RekeyWizard (mock)", () => {
describe("RekeyWizard (live)", () => {
beforeEach(() => {
mode = "live";
operatorLevel = "operator";
rekeyNode.mockReset().mockResolvedValue({
chainLen: 2,
issuerCn: "ACME Intermediate CA",
Expand Down Expand Up @@ -93,4 +104,18 @@ describe("RekeyWizard (live)", () => {
expect(screen.queryByRole("button", { name: /re-key/i })).not.toBeInTheDocument();
expect(rekeyNode).not.toHaveBeenCalled();
});

// Re-keying was never gated in the console at all, even though the manager
// requires operator level; #183 catches the presentation up with the kit's
// lock pattern so a viewer sees why before submitting, not after.
it("locks Re-key for a viewer and makes no RPC", () => {
operatorLevel = "viewer";
render(<RekeyWizard node={issuingNode()} />);
const button = screen.getByRole("button", { name: /^re-key/i });
expect(button).not.toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAccessibleDescription(/needs the operator role/i);
fireEvent.click(button);
expect(rekeyNode).not.toHaveBeenCalled();
});
});
10 changes: 9 additions & 1 deletion apps/console/src/components/rekey-wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useState } from "react";

import type { Node } from "@/lib/mock";

import { useAuth } from "@/context/auth";
import { fleetMode } from "@/lib/fleet/mode";
import { DEFAULT_REKEY_PROFILE, rekeyNode, type RekeyResult } from "@/lib/rekey";

Expand Down Expand Up @@ -55,6 +56,8 @@ const MockRekeyDemo = ({ node }: { node: Node }) => {
// reports the re-keyed identity. An RPC failure surfaces inline (no native
// popup) and does not advance to the done state, so the operator can retry.
const LiveRekey = ({ node }: { node: Node }) => {
const { operator } = useAuth();
const canSubmit = operator ? operator.level !== "viewer" : false;
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState<null | RekeyResult>(null);
Expand Down Expand Up @@ -110,7 +113,12 @@ const LiveRekey = ({ node }: { node: Node }) => {
{error}
</Notice>
) : null}
<Button disabled={pending} onClick={() => void submit()} type="button">
<Button
disabled={canSubmit && pending}
lockedReason={canSubmit ? undefined : "Needs the operator role"}
onClick={() => void submit()}
type="button"
>
{pending ? "Re-keying…" : `Re-key ${node.name}`}
</Button>
</div>
Expand Down
26 changes: 19 additions & 7 deletions apps/console/src/pages/enrollment-detail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,20 @@ describe("EnrollmentDetailPage", () => {
});

describe("operator level gating", () => {
it("viewer: approve and reject are disabled with a read-only note", () => {
// Was toBeDisabled() plus a shared "read-only" note; #183 moves both
// buttons to the kit's lock pattern (focusable, each with its own reason).
it("viewer: approve and reject are locked for the operator role", () => {
mockOperator.mockReturnValue({ level: "viewer" });
renderAt("/enrollment/enr-0001");
expect(screen.getByRole("button", { name: /^approve$/i })).toBeDisabled();
expect(screen.getByRole("button", { name: /^reject$/i })).toBeDisabled();
expect(screen.getByText(/read-only/i)).toBeInTheDocument();
const approve = screen.getByRole("button", { name: /^approve/i });
const reject = screen.getByRole("button", { name: /^reject/i });
for (const button of [approve, reject]) {
expect(button).not.toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAccessibleDescription(/needs the operator role/i);
}
fireEvent.click(approve);
expect(screen.queryByText(/link approval material/i)).not.toBeInTheDocument();
});

it("operator: SUBORDINATE approve is enabled", () => {
Expand All @@ -132,11 +140,15 @@ describe("EnrollmentDetailPage", () => {
expect(screen.getByRole("button", { name: /^approve$/i })).not.toBeDisabled();
});

it("operator: LINK approve is disabled with an admin-required note", () => {
// Was toBeDisabled() plus an admin-required note; #183 moves it to the
// kit's lock pattern.
it("operator: LINK approve is locked for the admin role", () => {
mockOperator.mockReturnValue({ level: "operator" });
renderAt("/enrollment/enr-link-01");
expect(screen.getByRole("button", { name: /^approve$/i })).toBeDisabled();
expect(screen.getByText(/requires the admin level/i)).toBeInTheDocument();
const approve = screen.getByRole("button", { name: /^approve/i });
expect(approve).not.toBeDisabled();
expect(approve).toHaveAttribute("aria-disabled", "true");
expect(approve).toHaveAccessibleDescription(/needs the admin role/i);
});

it("admin: LINK approve is enabled and reveals the material form", () => {
Expand Down
30 changes: 21 additions & 9 deletions apps/console/src/pages/enrollment-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ const levelAllowsApprove = (level: "admin" | "operator" | "viewer", req: Enrollm
return true;
};

// The lock tooltip for a role-blocked Approve: a viewer always needs operator
// level; an operator blocked on a LINK request needs admin specifically.
const approveLockReasonFor = (
allowed: boolean,
level: "admin" | "operator" | "viewer",
): string | undefined => {
if (allowed) return undefined;
if (level === "viewer") return "Needs the operator role";
return "Needs the admin role for a LINK request";
};

// Attestation panel: SUBORDINATE keeps the existing TPM/CSR display (the CA
// signs a CSR from an attested TPM identity); LINK has neither -- the trust
// anchor is the manager's TOFU-pinned SHA-256 of the node's admin key,
Expand Down Expand Up @@ -164,6 +175,8 @@ export const EnrollmentDetailPage = () => {
const approvable = canApprove(req);
const allowedByLevel = levelAllowsApprove(level, req);
const canApproveNow = approvable.ok && allowedByLevel;
const approveLockReason = approveLockReasonFor(allowedByLevel, level);
const rejectLockReason = level === "viewer" ? "Needs the operator role" : undefined;

const runApprove = async (link?: LinkApprovalMaterial) => {
setPending(true);
Expand Down Expand Up @@ -206,13 +219,6 @@ export const EnrollmentDetailPage = () => {
{approvable.ok ? null : (
<p className="font-mono text-xs text-destructive">{approvable.reason}</p>
)}
{allowedByLevel ? null : (
<p className="font-mono text-xs text-muted-foreground">
{level === "viewer"
? "Read-only — your operator level cannot approve or reject."
: "Approving a LINK request requires the admin level."}
</p>
)}
{error ? (
<Notice role="alert" tone="danger">
{error}
Expand All @@ -226,11 +232,17 @@ export const EnrollmentDetailPage = () => {
/>
) : (
<div className="flex gap-2">
<Button disabled={!canApproveNow || pending} onClick={handleApproveClick} size="sm">
<Button
disabled={!approveLockReason && (!canApproveNow || pending)}
lockedReason={approveLockReason}
onClick={handleApproveClick}
size="sm"
>
{pending ? "Approving…" : "Approve"}
</Button>
<Button
disabled={level === "viewer" || pending}
disabled={!rejectLockReason && pending}
lockedReason={rejectLockReason}
onClick={() => setRejecting(true)}
size="sm"
variant="destructive"
Expand Down
14 changes: 11 additions & 3 deletions apps/console/src/pages/enrollment.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,23 @@ describe("EnrollmentPage", () => {
expect(screen.queryByText(approved.proposedName)).not.toBeInTheDocument();
});

it("disables New enrollment for a viewer", () => {
// Was toBeDisabled()/a plain "Read-only" notice; #183 moves this to the
// kit's lock pattern so a viewer still sees and can reach the control.
it("locks New enrollment for a viewer, with a reason a screen reader announces", () => {
mockOperator.mockReturnValue({ level: "viewer" });
render(
<MemoryRouter>
<EnrollmentPage />
</MemoryRouter>,
);
expect(screen.getByRole("button", { name: /new enrollment/i })).toBeDisabled();
expect(screen.getByText(/read-only/i)).toBeInTheDocument();
const button = screen.getByRole("button", { name: /new enrollment/i });
expect(button).not.toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAccessibleDescription(/needs the operator role/i);
button.focus();
expect(button).toHaveFocus();
fireEvent.click(button);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("submits a SUBORDINATE create draft", async () => {
Expand Down
11 changes: 5 additions & 6 deletions apps/console/src/pages/enrollment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -352,17 +352,16 @@ export const EnrollmentPage = () => {
>
Simulate incoming request
</Button>
<Button disabled={!canCreate} onClick={() => setCreating(true)} size="sm">
<Button
lockedReason={canCreate ? undefined : "Needs the operator role"}
onClick={() => setCreating(true)}
size="sm"
>
New enrollment
</Button>
</div>
</div>

{canCreate ? null : (
<p className="font-mono text-xs text-muted-foreground">
Read-only — your operator level cannot create enrollment requests.
</p>
)}
{error ? (
<Notice role="alert" tone="danger">
{error}
Expand Down
Loading
Loading