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
1 change: 1 addition & 0 deletions app/app/(admin)/actions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default async function ActionsPage({
<div className="grid gap-8">
<CrudWorkspace resourceKey="capability-actions" searchParams={sp} />
<CrudWorkspace resourceKey="capabilities" searchParams={sp} />
<CrudWorkspace resourceKey="action-assignment-rules" searchParams={sp} />
</div>
);
}
14 changes: 14 additions & 0 deletions app/components/crud/crud-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,20 @@ function TableRowActions({
Delete
</Button>
</>
) : resourceKey === "action-assignment-rules" ? (
<Button
disabled={missingDelete || destroyPending}
onClick={() =>
onDelete(
`Delete assignment guardrail "${String(row.entityKind)} ${String(row.actionName)} ${String(row.objectKind)}:${String(row.objectType ?? "NULL")}"? This cannot be undone.`,
)
}
size="sm"
variant="outline"
className="border-red-500/50 text-red-600 hover:bg-red-500/10 hover:text-red-600 dark:border-red-500/40 dark:text-red-400"
>
Delete
</Button>
) : resourceKey === "policies" ? (
<DeleteActionButtons
isDestroyPending={destroyPending}
Expand Down
8 changes: 8 additions & 0 deletions app/components/crud/table/create-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { FallbackCreateForm } from "@/components/crud/table/quick-create-form";
import { singularize } from "@/components/crud/table/utils";
import { EntityCreateForm } from "@/components/entities/entity-create-form";
import { ActionAssignmentRuleCreateForm } from "@/components/guardrails/action-assignment-rule-create-form";
import { PermissionBlockCreateForm } from "@/components/permission-blocks/permission-block-create-form";
import { PolicyCreateForm } from "@/components/policy/policy-create-form";
import { ProfileCreateForm } from "@/components/profiles/profile-create-form";
Expand Down Expand Up @@ -95,6 +96,12 @@ export function CrudCreateSheet({
onSaved={onRefresh}
/>
) : null}
{resource.key === "action-assignment-rules" ? (
<ActionAssignmentRuleCreateForm
onCancel={() => onOpenChange(false)}
onSaved={onRefresh}
/>
) : null}
{resource.key === "policies" ? (
<PolicyCreateForm
onCancel={() => onOpenChange(false)}
Expand Down Expand Up @@ -125,6 +132,7 @@ function usesFallbackCreateForm(resourceKey: string) {
"permission-blocks",
"capability-actions",
"capabilities",
"action-assignment-rules",
"policies",
].includes(resourceKey);
}
151 changes: 151 additions & 0 deletions app/components/guardrails/action-assignment-rule-create-form.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TenantContext } from "@/components/app-shell/tenant-provider";
import { ActionAssignmentRuleCreateForm } from "@/components/guardrails/action-assignment-rule-create-form";
import { GLOBAL_TENANT, type TenantSelection } from "@/lib/tenant/context";

const mocks = vi.hoisted(() => ({
graphqlClient: vi.fn(),
}));

vi.mock("@/lib/graphql/client", () => ({
graphqlClient: mocks.graphqlClient,
}));

function renderForm(selection: TenantSelection) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});

return render(
<TenantContext.Provider value={{ selection, setTenant: vi.fn() }}>
<QueryClientProvider client={queryClient}>
<ActionAssignmentRuleCreateForm onCancel={vi.fn()} onSaved={vi.fn()} />
</QueryClientProvider>
</TenantContext.Provider>,
);
}

async function selectOption(label: RegExp, option: string) {
const user = userEvent.setup();
await user.click(screen.getByLabelText(label));
await user.click(await screen.findByRole("option", { name: option }));
}

function createMutationVariables() {
const call = mocks.graphqlClient.mock.calls.find(([arg]) =>
String(arg.query).includes("mutation CreateActionAssignmentRule"),
);
return call?.[0].variables;
}

describe("ActionAssignmentRuleCreateForm", () => {
afterEach(() => {
cleanup();
});

beforeEach(() => {
mocks.graphqlClient.mockReset();
vi.stubGlobal(
"ResizeObserver",
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
);
if (!Element.prototype.hasPointerCapture) {
Element.prototype.hasPointerCapture = () => false;
}
if (!Element.prototype.setPointerCapture) {
Element.prototype.setPointerCapture = () => {};
}
if (!Element.prototype.releasePointerCapture) {
Element.prototype.releasePointerCapture = () => {};
}
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
mocks.graphqlClient.mockImplementation(({ query }) => {
if (String(query).includes("ActionAssignmentRuleFormActions")) {
return Promise.resolve({
actions: {
items: [{ id: "action-manage", name: "manage", description: null }],
},
});
}
if (String(query).includes("CreateActionAssignmentRule")) {
return Promise.resolve({
createActionAssignmentRule: {
id: "rule-1",
tenantId: null,
entityKind: "device",
actionName: "manage",
objectKind: "resource",
objectType: null,
decision: "allow",
isAbsolute: false,
createdAt: "2026-01-01T00:00:00Z",
},
});
}
return Promise.resolve({});
});
});

it("does not expose require_override in the create decision selector", async () => {
renderForm({ id: GLOBAL_TENANT, name: "Global" });

const user = userEvent.setup();
await user.click(screen.getByLabelText(/decision/i));

expect(screen.getByRole("option", { name: "allow" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "deny" })).toBeInTheDocument();
expect(screen.queryByText("require_override")).not.toBeInTheDocument();
});

it("submits global guardrails with a null tenantId", async () => {
renderForm({ id: GLOBAL_TENANT, name: "Global" });

const user = userEvent.setup();
await selectOption(/action_name/i, "manage");
await user.click(screen.getByRole("button", { name: "Create guardrail" }));

await waitFor(() => {
expect(createMutationVariables()).toMatchObject({
input: {
tenantId: null,
entityKind: "device",
actionName: "manage",
objectKind: "resource",
decision: "allow",
isAbsolute: false,
},
});
});
});

it("submits tenant guardrails with the selected tenantId and deny decision", async () => {
renderForm({ id: "tenant-1", name: "Tenant 1" });

const user = userEvent.setup();
await selectOption(/action_name/i, "manage");
await user.click(screen.getByRole("button", { name: "Create guardrail" }));

await waitFor(() => {
expect(createMutationVariables()).toMatchObject({
input: {
tenantId: "tenant-1",
actionName: "manage",
decision: "deny",
isAbsolute: false,
},
});
});
});
});
Loading
Loading