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

@@ -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?.();
}
}