feat: namespace plugin permissions and keep them valid when a plugin is off

This commit is contained in:
LukeGus committed 2026-09-22 17:30:00 -05:00
1 parent 8431570ff7
commit cc15559ea1
53 files changed
+31442 -351

No files matched your search

+16
View File
@@ -268,9 +268,25 @@ export async function getHostAccess(
}
}
export interface PermissionCatalogItem {
permission: string;
/** Plugin-relative keys; resolve them with pluginKey(). */
titleKey: string;
descriptionKey: string;
}
export interface PermissionCatalogEntry {
group: string;
permissions: string[];
/** Set for a group a plugin contributed. */
pluginId?: string;
/** Core groups carry an i18n key, plugin groups their display name. */
labelKey?: string;
label?: string;
icon?: string;
/** False once the owning plugin is disabled or gone. */
enabled?: boolean;
items?: PermissionCatalogItem[];
}
export async function getPermissionsCatalog(): Promise<{
+13
View File
@@ -0,0 +1,13 @@
/**
* Where a plugin's i18n keys resolve from.
*
* A manifest writes plugin-relative keys ("permissions.devices.view.title") so
* it never has to know where the strings live. Today they live in core
* en.json under `plugins.<pluginId>.`, because the frontend does not load
* plugin locale bundles yet. When it does, that subtree moves into
* plugins/<id>/locales/en.json as the plugin's own namespace and only this
* function changes; no manifest is touched.
*/
export function pluginKey(pluginId: string, key: string): string {
return `plugins.${pluginId}.${key}`;
}
+17
View File
@@ -0,0 +1,17 @@
import { icons, type LucideProps } from "lucide-react";
import { Puzzle } from "lucide-react";
/**
* A manifest names its icon as a Lucide component name ("Sparkles").
*
* An unknown or missing name falls back to a generic plugin glyph rather than
* rendering nothing, so a plugin shipping a typo still gets a row an admin can
* read.
*/
export function PluginIcon({
name,
...props
}: { name?: string } & LucideProps) {
const Icon = (name && icons[name as keyof typeof icons]) || Puzzle;
return <Icon {...props} />;
}
+64 -1
View File
@@ -3953,7 +3953,14 @@
"saved": "Role permissions saved",
"saveError": "Failed to save role permissions",
"save": "Save",
"saving": "Saving..."
"saving": "Saving...",
"pluginDisabled": "Plugin disabled",
"groups": {
"hosts": "Hosts",
"snippets": "Snippets",
"credentials": "Credentials",
"admin": "Administration"
}
},
"aiGloballyEnabled": "AI assistant",
"aiGloballyEnabledDesc": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone.",
@@ -5199,6 +5206,62 @@
"title": "Add its own screens",
"consequence": "Tabs, panels and settings you can hide later."
}
},
"ai": {
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other plugins",
"description": "Other plugins may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other plugins may use the stored AI API key without seeing it."
}
}
},
"automations": {
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
}
},
"tailscale": {
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
}
}
}
}
+1
View File
@@ -2252,6 +2252,7 @@ export type {
SharePermissionLevel,
ShareTarget,
PermissionCatalogEntry,
PermissionCatalogItem,
} from "@/api/rbac-api";
// ============================================================================
+39 -2
View File
@@ -9,6 +9,8 @@ import {
updateRole,
} from "@/main-axios";
import type { PermissionCatalogEntry, Role } from "@/main-axios";
import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
@@ -671,8 +673,23 @@ export function AdminRolesSection({
{catalog.map((entry) => {
const wildcard = `${entry.group}.*`;
const wildcardOn = editingPermissions.has(wildcard);
// A plugin group with enabled === false is still granted,
// it just cannot be reached right now.
const offline = entry.enabled === false;
const titleFor = (permission: string) => {
const item = entry.items?.find(
(candidate) => candidate.permission === permission,
);
if (!item) return null;
return entry.pluginId
? t(pluginKey(entry.pluginId, item.titleKey))
: t(item.titleKey);
};
return (
<div key={entry.group} className="flex flex-col gap-1">
<div
key={entry.group}
className={`flex flex-col gap-1 ${offline ? "opacity-50" : ""}`}
>
<button
onClick={() => togglePermission(wildcard)}
className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-left"
@@ -684,6 +701,12 @@ export function AdminRolesSection({
<Check className="size-2 text-background" />
)}
</span>
{entry.pluginId && (
<PluginIcon
name={entry.icon}
className="size-3 shrink-0 text-muted-foreground"
/>
)}
<span
className={
wildcardOn
@@ -691,13 +714,22 @@ export function AdminRolesSection({
: "text-muted-foreground"
}
>
{entry.group}.*
{entry.label ??
(entry.labelKey
? t(entry.labelKey)
: entry.group)}
</span>
{offline && (
<span className="px-1 border border-border text-[9px] font-normal normal-case tracking-normal text-muted-foreground">
{t("admin.rolePermissions.pluginDisabled")}
</span>
)}
</button>
<div className="flex flex-col gap-0.5 pl-4">
{entry.permissions.map((permission) => {
const checked =
wildcardOn || editingPermissions.has(permission);
const title = titleFor(permission);
return (
<button
key={permission}
@@ -712,6 +744,11 @@ export function AdminRolesSection({
<Check className="size-2 text-background" />
)}
</span>
{title && (
<span className="text-foreground">
{title}
</span>
)}
<span className="font-mono text-muted-foreground">
{permission}
</span>
@@ -0,0 +1,204 @@
/**
* The role editor's permission catalog: plugin grouping, the disabled state,
* and what actually gets saved.
*
* The behaviour that matters is that a disabled plugin's permission stays
* ticked and stays in the payload. Dropping it would silently revoke a grant
* just because the plugin happened to be off.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AdminRolesSection } from "../../sidebar/AdminManagementSections";
import type { PermissionCatalogEntry, Role } from "../../main-axios";
const api = vi.hoisted(() => ({
getPermissionsCatalog: vi.fn(),
updateRole: vi.fn(
async (_roleId: number, _update: { permissions?: string[] }) => ({}),
),
deleteRole: vi.fn(async () => ({})),
getRoleMembers: vi.fn(async () => ({ members: [] as unknown[] })),
getSessions: vi.fn(async () => ({ sessions: [] as unknown[] })),
revokeSession: vi.fn(async () => ({})),
}));
vi.mock("@/main-axios", () => api);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const CATALOG: PermissionCatalogEntry[] = [
{
group: "hosts",
labelKey: "admin.rolePermissions.groups.hosts",
permissions: ["hosts.view", "hosts.create"],
},
{
group: "ai",
pluginId: "ai",
label: "AI Assistant",
icon: "Sparkles",
enabled: false,
permissions: ["ai.use"],
items: [
{
permission: "ai.use",
titleKey: "permissions.use.title",
descriptionKey: "permissions.use.description",
},
],
},
{
group: "automations",
pluginId: "automations",
label: "Automations",
icon: "Workflow",
enabled: true,
permissions: ["automations.run"],
items: [
{
permission: "automations.run",
titleKey: "permissions.run.title",
descriptionKey: "permissions.run.description",
},
],
},
];
function makeRole(permissions: string[]): Role {
return {
id: 7,
name: "operators",
displayName: "Operators",
description: null,
isSystem: false,
permissions,
createdAt: "",
updatedAt: "",
};
}
function renderSection(role: Role) {
return render(
<AdminRolesSection
open
onToggle={() => {}}
roles={[role]}
setRoles={() => {}}
showCreateRole={false}
setShowCreateRole={() => {}}
newRoleName=""
setNewRoleName={() => {}}
newRoleDisplayName=""
setNewRoleDisplayName={() => {}}
newRoleDescription=""
setNewRoleDescription={() => {}}
handleCreateRole={() => {}}
createRoleLoading={false}
/>,
);
}
async function openEditor(role: Role) {
const user = userEvent.setup();
renderSection(role);
await user.click(
screen.getByRole("button", { name: /admin.rolePermissions.editAction/i }),
);
await waitFor(() => expect(api.getPermissionsCatalog).toHaveBeenCalled());
return user;
}
beforeEach(() => {
vi.clearAllMocks();
api.getPermissionsCatalog.mockResolvedValue({ catalog: CATALOG });
});
describe("role permission groups", () => {
it("labels a core group by its i18n key, not the raw wildcard", async () => {
await openEditor(makeRole([]));
expect(screen.getByText("admin.rolePermissions.groups.hosts")).toBeTruthy();
expect(screen.queryByText("hosts.*")).toBeNull();
});
it("labels a plugin group with the plugin's display name", async () => {
await openEditor(makeRole([]));
expect(screen.getByText("AI Assistant")).toBeTruthy();
expect(screen.getByText("Automations")).toBeTruthy();
});
it("shows a plugin permission's title beside its raw id", async () => {
await openEditor(makeRole([]));
expect(
screen.getByText("plugins.automations.permissions.run.title"),
).toBeTruthy();
expect(screen.getByText("automations.run")).toBeTruthy();
});
it("marks a disabled plugin's group", async () => {
await openEditor(makeRole([]));
expect(
screen.getByText("admin.rolePermissions.pluginDisabled"),
).toBeTruthy();
});
it("does not mark an enabled plugin's group", async () => {
await openEditor(makeRole([]));
expect(
screen.getAllByText("admin.rolePermissions.pluginDisabled"),
).toHaveLength(1);
});
it("lists a group once per catalog entry", async () => {
await openEditor(makeRole([]));
expect(screen.getAllByText("AI Assistant")).toHaveLength(1);
});
});
describe("saving a role", () => {
// The regression A5 fixes: the permission must survive the round trip even
// though its plugin is disabled.
it("keeps a disabled plugin's permission in the payload", async () => {
const role = makeRole(["hosts.view", "ai.use"]);
const user = await openEditor(role);
await user.click(
screen.getByRole("button", { name: /admin.rolePermissions.save/i }),
);
await waitFor(() => expect(api.updateRole).toHaveBeenCalled());
const [, payload] = api.updateRole.mock.calls[0];
expect(payload.permissions).toContain("ai.use");
expect(payload.permissions).toContain("hosts.view");
});
it("still lets a disabled plugin's permission be revoked deliberately", async () => {
const role = makeRole(["hosts.view", "ai.use"]);
const user = await openEditor(role);
await user.click(screen.getByText("ai.use"));
await user.click(
screen.getByRole("button", { name: /admin.rolePermissions.save/i }),
);
await waitFor(() => expect(api.updateRole).toHaveBeenCalled());
const [, payload] = api.updateRole.mock.calls[0];
expect(payload.permissions).not.toContain("ai.use");
expect(payload.permissions).toContain("hosts.view");
});
});