mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: namespace plugin permissions and keep them valid when a plugin is off
This commit is contained in:
1 parent
8431570ff7
commit
cc15559ea1
53 files changed
+31442
-351
No files matched your search
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
createCurrentRbacPermissionRepository,
|
||||
createCurrentRoleRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import {
|
||||
loadKnownPermissions,
|
||||
rememberPermissionGroup,
|
||||
isValidPermission,
|
||||
} from "./permission-catalog.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
function isConcretePermission(permission: string): boolean {
|
||||
return permission !== "*" && !permission.endsWith(".*");
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads every permission core has ever registered, and backfills the record
|
||||
* from what roles already hold.
|
||||
*
|
||||
* The backfill is what makes the upgrade lossless. Before A5 a plugin
|
||||
* permission existed only in memory while its plugin was active, so an install
|
||||
* upgrading with the ai plugin disabled would have had `ai.use` rejected on the
|
||||
* next role save. Seeding from the roles themselves means anything an admin was
|
||||
* already relying on stays valid, whether or not the plugin ever loads again.
|
||||
*/
|
||||
export async function primeKnownPermissions(): Promise<void> {
|
||||
try {
|
||||
const rbacRepository = createCurrentRbacPermissionRepository();
|
||||
|
||||
const stored = await rbacRepository.listKnown();
|
||||
for (const row of stored) {
|
||||
if (isConcretePermission(row.permission)) {
|
||||
loadKnownPermissions([row.permission]);
|
||||
} else {
|
||||
// A stored "<group>.*" row records a group nothing concrete named.
|
||||
rememberPermissionGroup(row.permission.slice(0, -2));
|
||||
}
|
||||
}
|
||||
|
||||
const fromRoles = new Set<string>();
|
||||
for (const role of await createCurrentRoleRepository().listRoles()) {
|
||||
if (!role.permissions) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(role.permissions);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(parsed)) continue;
|
||||
for (const permission of parsed) {
|
||||
if (typeof permission !== "string") continue;
|
||||
// Already known, from the static catalog or a previous boot.
|
||||
if (isValidPermission(permission)) continue;
|
||||
|
||||
// A role holding only "ai.*" still has to keep saving, so the group is
|
||||
// remembered even though the wildcard itself is not a stored id.
|
||||
if (!isConcretePermission(permission)) {
|
||||
rememberPermissionGroup(permission.slice(0, -2));
|
||||
}
|
||||
// Wildcards are stored too, so the group survives the next restart.
|
||||
fromRoles.add(permission);
|
||||
}
|
||||
}
|
||||
|
||||
if (fromRoles.size === 0) return;
|
||||
|
||||
loadKnownPermissions(
|
||||
[...fromRoles].filter((permission) => isConcretePermission(permission)),
|
||||
);
|
||||
await rbacRepository.recordKnown(
|
||||
[...fromRoles].map((permission) => ({ permission })),
|
||||
);
|
||||
|
||||
databaseLogger.info(
|
||||
`Recorded ${fromRoles.size} permission(s) already held by a role`,
|
||||
{ operation: "prime_known_permissions" },
|
||||
);
|
||||
} catch (error) {
|
||||
// A failure here only means a plugin permission may be rejected on a role
|
||||
// save until its plugin registers again. It must never stop the boot.
|
||||
databaseLogger.warn("Could not load known permissions", {
|
||||
operation: "prime_known_permissions",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,31 @@
|
||||
// Single source of truth for role permission strings. The admin role editor
|
||||
// renders this catalog and PUT /rbac/roles/:id validates against it;
|
||||
// PermissionManager.hasPermission resolves wildcards ("*", "<group>.*").
|
||||
export interface PermissionCatalogItem {
|
||||
permission: string;
|
||||
/** Plugin-relative i18n keys; the frontend resolves them per plugin. */
|
||||
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 const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
|
||||
{
|
||||
group: "hosts",
|
||||
labelKey: "admin.rolePermissions.groups.hosts",
|
||||
permissions: [
|
||||
"hosts.view",
|
||||
"hosts.create",
|
||||
@@ -19,6 +36,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
|
||||
},
|
||||
{
|
||||
group: "snippets",
|
||||
labelKey: "admin.rolePermissions.groups.snippets",
|
||||
permissions: [
|
||||
"snippets.view",
|
||||
"snippets.create",
|
||||
@@ -27,18 +45,9 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
|
||||
"snippets.share",
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "automations",
|
||||
permissions: [
|
||||
"automations.view",
|
||||
"automations.create",
|
||||
"automations.edit",
|
||||
"automations.delete",
|
||||
"automations.run",
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "credentials",
|
||||
labelKey: "admin.rolePermissions.groups.credentials",
|
||||
permissions: [
|
||||
"credentials.view",
|
||||
"credentials.create",
|
||||
@@ -47,12 +56,9 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
|
||||
"credentials.share",
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "ai",
|
||||
permissions: ["ai.use", "ai.manage_providers", "ai.apply_proposals"],
|
||||
},
|
||||
{
|
||||
group: "admin",
|
||||
labelKey: "admin.rolePermissions.groups.admin",
|
||||
permissions: [
|
||||
"admin.users.view",
|
||||
"admin.users.manage",
|
||||
@@ -64,18 +70,36 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Groups registered at runtime, e.g. by plugins. Kept separate from the
|
||||
// static catalog so plugin state never mutates the built-in array.
|
||||
const runtimePermissionGroups = new Map<string, PermissionCatalogEntry>();
|
||||
// Groups registered at runtime by plugins. Kept separate from the static
|
||||
// catalog so plugin state never mutates the built-in array.
|
||||
const pluginPermissionGroups = new Map<string, PermissionCatalogEntry>();
|
||||
|
||||
/**
|
||||
* Every permission string core has ever registered, loaded from
|
||||
* rbac_known_permissions at boot.
|
||||
*
|
||||
* This is what lets a role keep a plugin's permission while that plugin is
|
||||
* disabled or uninstalled. Without it, unregistering a group made
|
||||
* PUT /rbac/roles/:id reject the whole role, so an admin could not save an
|
||||
* unrelated change to any role that still held one.
|
||||
*/
|
||||
const knownPermissions = new Set<string>();
|
||||
const knownGroups = new Set<string>();
|
||||
|
||||
let validPermissionsCache: Set<string> | null = null;
|
||||
|
||||
function buildValidPermissions(): Set<string> {
|
||||
return new Set<string>(
|
||||
[...PERMISSION_CATALOG, ...runtimePermissionGroups.values()]
|
||||
.flatMap((entry) => [...entry.permissions, `${entry.group}.*`])
|
||||
.concat("*"),
|
||||
);
|
||||
const valid = new Set<string>(["*"]);
|
||||
for (const entry of [
|
||||
...PERMISSION_CATALOG,
|
||||
...pluginPermissionGroups.values(),
|
||||
]) {
|
||||
valid.add(`${entry.group}.*`);
|
||||
for (const permission of entry.permissions) valid.add(permission);
|
||||
}
|
||||
for (const permission of knownPermissions) valid.add(permission);
|
||||
for (const group of knownGroups) valid.add(`${group}.*`);
|
||||
return valid;
|
||||
}
|
||||
|
||||
function getValidPermissions(): Set<string> {
|
||||
@@ -85,27 +109,73 @@ function getValidPermissions(): Set<string> {
|
||||
return validPermissionsCache;
|
||||
}
|
||||
|
||||
export function registerPermissionGroup(entry: PermissionCatalogEntry): void {
|
||||
runtimePermissionGroups.set(entry.group, entry);
|
||||
/** Records permissions as known, so they stay valid without the plugin. */
|
||||
export function rememberPermissions(permissions: Iterable<string>): void {
|
||||
for (const permission of permissions) {
|
||||
knownPermissions.add(permission);
|
||||
const group = permission.split(".")[0];
|
||||
if (group) knownGroups.add(group);
|
||||
}
|
||||
validPermissionsCache = null;
|
||||
}
|
||||
|
||||
export function unregisterPermissionGroup(group: string): void {
|
||||
runtimePermissionGroups.delete(group);
|
||||
/**
|
||||
* Records a group as known without naming a permission in it.
|
||||
*
|
||||
* A role can hold only "ai.*", with no concrete ai permission anywhere, and it
|
||||
* still has to keep saving while the plugin is off.
|
||||
*/
|
||||
export function rememberPermissionGroup(group: string): void {
|
||||
if (!group) return;
|
||||
knownGroups.add(group);
|
||||
validPermissionsCache = null;
|
||||
}
|
||||
|
||||
/** Seeds the known set at boot, before any request is served. */
|
||||
export function loadKnownPermissions(permissions: Iterable<string>): void {
|
||||
rememberPermissions(permissions);
|
||||
}
|
||||
|
||||
export function registerPluginPermissions(entry: PermissionCatalogEntry): void {
|
||||
pluginPermissionGroups.set(entry.group, { ...entry, enabled: true });
|
||||
rememberPermissions(entry.permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a plugin's group disabled without removing it.
|
||||
*
|
||||
* The permissions stay valid, because a role that holds one is not wrong just
|
||||
* because the plugin is off. The editor greys the group instead.
|
||||
*/
|
||||
export function markPluginPermissionsDisabled(pluginId: string): void {
|
||||
for (const [group, entry] of pluginPermissionGroups) {
|
||||
if (entry.pluginId !== pluginId) continue;
|
||||
pluginPermissionGroups.set(group, { ...entry, enabled: false });
|
||||
}
|
||||
validPermissionsCache = null;
|
||||
}
|
||||
|
||||
// Static entries plus any groups registered at runtime.
|
||||
export function getPermissionCatalog(): PermissionCatalogEntry[] {
|
||||
return [...PERMISSION_CATALOG, ...runtimePermissionGroups.values()];
|
||||
return [...PERMISSION_CATALOG, ...pluginPermissionGroups.values()];
|
||||
}
|
||||
|
||||
export function isValidPermission(permission: string): boolean {
|
||||
return getValidPermissions().has(permission);
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetPermissionCatalog(): void {
|
||||
pluginPermissionGroups.clear();
|
||||
knownPermissions.clear();
|
||||
knownGroups.clear();
|
||||
validPermissionsCache = null;
|
||||
}
|
||||
|
||||
// What the seeded system roles grant. Applied only to a role row that has no
|
||||
// permissions yet, so an admin's edits to these roles survive restarts.
|
||||
// Plugin permissions are not listed here; a plugin asks for its own defaults
|
||||
// through contributes.permissions[].defaultRoles.
|
||||
export const SYSTEM_ROLE_DEFAULTS = {
|
||||
admin: {
|
||||
description: "Administrator with full access",
|
||||
@@ -113,12 +183,6 @@ export const SYSTEM_ROLE_DEFAULTS = {
|
||||
},
|
||||
user: {
|
||||
description: "Regular user",
|
||||
permissions: [
|
||||
"hosts.*",
|
||||
"snippets.*",
|
||||
"automations.*",
|
||||
"credentials.*",
|
||||
"ai.*",
|
||||
],
|
||||
permissions: ["hosts.*", "snippets.*", "credentials.*"],
|
||||
},
|
||||
} as const;
|
||||
Reference in new issue
Block a user