mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15: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
@@ -751,6 +751,22 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rbac_known_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
permission TEXT NOT NULL,
|
||||
plugin_id TEXT,
|
||||
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (permission)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rbac_applied_defaults (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
role_name TEXT NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (role_name, permission)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
|
||||
@@ -2282,3 +2282,57 @@ export const pluginMigrations = mysqlTable(
|
||||
);
|
||||
|
||||
// --- plugins end ---
|
||||
|
||||
// --- rbac plugin permissions begin ---
|
||||
|
||||
/**
|
||||
* Every role permission core has ever registered.
|
||||
*
|
||||
* Deliberately has no foreign key to `plugins`: the whole point is that a role
|
||||
* keeps working when the plugin that contributed a permission is disabled or
|
||||
* uninstalled. Without this, unregistering a group made PUT /rbac/roles/:id
|
||||
* reject the entire role.
|
||||
*/
|
||||
export const rbacKnownPermissions = mysqlTable(
|
||||
"rbac_known_permissions",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
permission: varchar("permission", { length: 255 }).notNull(),
|
||||
/** Which plugin contributed it, or null for a core permission. */
|
||||
pluginId: varchar("plugin_id", { length: 255 }),
|
||||
firstSeenAt: text("first_seen_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_rbac_known_permissions_permission").on(table.permission),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which plugin role defaults have already been applied.
|
||||
*
|
||||
* Applying a default is a one-time suggestion, so an admin who revokes it does
|
||||
* not get it handed back on the next restart. This lived in `plugin_storage`,
|
||||
* which cascades with the plugin, so uninstall-then-reinstall silently re-added
|
||||
* a permission that had been deliberately removed.
|
||||
*/
|
||||
export const rbacAppliedDefaults = mysqlTable(
|
||||
"rbac_applied_defaults",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
roleName: varchar("role_name", { length: 255 }).notNull(),
|
||||
permission: varchar("permission", { length: 255 }).notNull(),
|
||||
appliedAt: text("applied_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_rbac_applied_defaults_role_permission").on(
|
||||
table.roleName,
|
||||
table.permission,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// --- rbac plugin permissions end ---
|
||||
@@ -2283,3 +2283,57 @@ export const pluginMigrations = pgTable(
|
||||
);
|
||||
|
||||
// --- plugins end ---
|
||||
|
||||
// --- rbac plugin permissions begin ---
|
||||
|
||||
/**
|
||||
* Every role permission core has ever registered.
|
||||
*
|
||||
* Deliberately has no foreign key to `plugins`: the whole point is that a role
|
||||
* keeps working when the plugin that contributed a permission is disabled or
|
||||
* uninstalled. Without this, unregistering a group made PUT /rbac/roles/:id
|
||||
* reject the entire role.
|
||||
*/
|
||||
export const rbacKnownPermissions = pgTable(
|
||||
"rbac_known_permissions",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
permission: varchar("permission", { length: 255 }).notNull(),
|
||||
/** Which plugin contributed it, or null for a core permission. */
|
||||
pluginId: varchar("plugin_id", { length: 255 }),
|
||||
firstSeenAt: text("first_seen_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_rbac_known_permissions_permission").on(table.permission),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which plugin role defaults have already been applied.
|
||||
*
|
||||
* Applying a default is a one-time suggestion, so an admin who revokes it does
|
||||
* not get it handed back on the next restart. This lived in `plugin_storage`,
|
||||
* which cascades with the plugin, so uninstall-then-reinstall silently re-added
|
||||
* a permission that had been deliberately removed.
|
||||
*/
|
||||
export const rbacAppliedDefaults = pgTable(
|
||||
"rbac_applied_defaults",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
roleName: varchar("role_name", { length: 255 }).notNull(),
|
||||
permission: varchar("permission", { length: 255 }).notNull(),
|
||||
appliedAt: text("applied_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_rbac_applied_defaults_role_permission").on(
|
||||
table.roleName,
|
||||
table.permission,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// --- rbac plugin permissions end ---
|
||||
@@ -2281,3 +2281,57 @@ export const pluginMigrations = sqliteTable(
|
||||
);
|
||||
|
||||
// --- plugins end ---
|
||||
|
||||
// --- rbac plugin permissions begin ---
|
||||
|
||||
/**
|
||||
* Every role permission core has ever registered.
|
||||
*
|
||||
* Deliberately has no foreign key to `plugins`: the whole point is that a role
|
||||
* keeps working when the plugin that contributed a permission is disabled or
|
||||
* uninstalled. Without this, unregistering a group made PUT /rbac/roles/:id
|
||||
* reject the entire role.
|
||||
*/
|
||||
export const rbacKnownPermissions = sqliteTable(
|
||||
"rbac_known_permissions",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
permission: text("permission").notNull(),
|
||||
/** Which plugin contributed it, or null for a core permission. */
|
||||
pluginId: text("plugin_id"),
|
||||
firstSeenAt: text("first_seen_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_rbac_known_permissions_permission").on(table.permission),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which plugin role defaults have already been applied.
|
||||
*
|
||||
* Applying a default is a one-time suggestion, so an admin who revokes it does
|
||||
* not get it handed back on the next restart. This lived in `plugin_storage`,
|
||||
* which cascades with the plugin, so uninstall-then-reinstall silently re-added
|
||||
* a permission that had been deliberately removed.
|
||||
*/
|
||||
export const rbacAppliedDefaults = sqliteTable(
|
||||
"rbac_applied_defaults",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
roleName: text("role_name").notNull(),
|
||||
permission: text("permission").notNull(),
|
||||
appliedAt: text("applied_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_rbac_applied_defaults_role_permission").on(
|
||||
table.roleName,
|
||||
table.permission,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// --- rbac plugin permissions end ---
|
||||
@@ -39,6 +39,7 @@ import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repos
|
||||
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
|
||||
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
|
||||
import { RbacAccessRepository } from "./rbac-access-repository.js";
|
||||
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
|
||||
import { RecentActivityRepository } from "./recent-activity-repository.js";
|
||||
import { RoleRepository } from "./role-repository.js";
|
||||
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
||||
@@ -401,6 +402,13 @@ export function createCurrentPluginMigrationRepository(): PluginMigrationReposit
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentRbacPermissionRepository(): RbacPermissionRepository {
|
||||
return new RbacPermissionRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("rbac_permission_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginRegistryRepository(): PluginRegistryRepository {
|
||||
return new PluginRegistryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { rbacKnownPermissions, rbacAppliedDefaults } from "../db/schema.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type RbacKnownPermissionRecord =
|
||||
typeof rbacKnownPermissions.$inferSelect;
|
||||
export type RbacAppliedDefaultRecord = typeof rbacAppliedDefaults.$inferSelect;
|
||||
|
||||
export interface KnownPermissionInput {
|
||||
permission: string;
|
||||
pluginId?: string | null;
|
||||
}
|
||||
|
||||
export interface AppliedDefaultInput {
|
||||
roleName: string;
|
||||
permission: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two records that make plugin permissions outlive their plugin.
|
||||
*
|
||||
* `rbac_known_permissions` is why a role holding `ai.use` still saves while the
|
||||
* ai plugin is disabled. `rbac_applied_defaults` is why a default an admin
|
||||
* revoked is not handed back on the next boot. Neither has a foreign key to
|
||||
* `plugins`, because both have to survive the plugin going away.
|
||||
*/
|
||||
export class RbacPermissionRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listKnown(): Promise<RbacKnownPermissionRecord[]> {
|
||||
return this.context.drizzle.select().from(rbacKnownPermissions);
|
||||
}
|
||||
|
||||
/** Inserts the ones that are new. Re-recording an existing one is a no-op. */
|
||||
async recordKnown(
|
||||
entries: KnownPermissionInput[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<number> {
|
||||
if (entries.length === 0) return 0;
|
||||
|
||||
const existing = new Set(
|
||||
(await this.listKnown()).map((row) => row.permission),
|
||||
);
|
||||
|
||||
let written = 0;
|
||||
for (const entry of entries) {
|
||||
if (existing.has(entry.permission)) continue;
|
||||
existing.add(entry.permission);
|
||||
|
||||
// Another boot can win the race on a shared database, and losing it just
|
||||
// means the row is already there.
|
||||
try {
|
||||
await insertReturning(this.context, rbacKnownPermissions, {
|
||||
permission: entry.permission,
|
||||
pluginId: entry.pluginId ?? null,
|
||||
firstSeenAt: now,
|
||||
});
|
||||
written += 1;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (written > 0) await this.afterWrite();
|
||||
return written;
|
||||
}
|
||||
|
||||
async listAppliedDefaults(): Promise<RbacAppliedDefaultRecord[]> {
|
||||
return this.context.drizzle.select().from(rbacAppliedDefaults);
|
||||
}
|
||||
|
||||
async recordAppliedDefaults(
|
||||
entries: AppliedDefaultInput[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<number> {
|
||||
if (entries.length === 0) return 0;
|
||||
|
||||
const existing = new Set(
|
||||
(await this.listAppliedDefaults()).map(
|
||||
(row) => `${row.roleName}:${row.permission}`,
|
||||
),
|
||||
);
|
||||
|
||||
let written = 0;
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.roleName}:${entry.permission}`;
|
||||
if (existing.has(key)) continue;
|
||||
existing.add(key);
|
||||
|
||||
try {
|
||||
await insertReturning(this.context, rbacAppliedDefaults, {
|
||||
roleName: entry.roleName,
|
||||
permission: entry.permission,
|
||||
appliedAt: now,
|
||||
});
|
||||
written += 1;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (written > 0) await this.afterWrite();
|
||||
return written;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user