From fd0639e3abd901f05e1fdeb8d51c810cb818fb43 Mon Sep 17 00:00:00 2001 From: Oleksandr Andriienko Date: Tue, 4 Aug 2026 16:29:08 +0300 Subject: [PATCH] feat(rbac): Implement database index for casbin table Signed-off-by: Oleksandr Andriienko --- .../20260804120000_casbin_rule_indexes.js | 86 +++++++++++++++++++ .../database/casbin-adapter-factory.test.ts | 78 ++++++++++++++++- .../src/database/casbin-adapter-factory.ts | 59 +++++++++++++ 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 workspaces/rbac/plugins/rbac-backend/migrations/20260804120000_casbin_rule_indexes.js diff --git a/workspaces/rbac/plugins/rbac-backend/migrations/20260804120000_casbin_rule_indexes.js b/workspaces/rbac/plugins/rbac-backend/migrations/20260804120000_casbin_rule_indexes.js new file mode 100644 index 00000000000..2dec5a360ec --- /dev/null +++ b/workspaces/rbac/plugins/rbac-backend/migrations/20260804120000_casbin_rule_indexes.js @@ -0,0 +1,86 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Indexes for casbin_rule filtered-policy queries. + * Also applied at runtime from CasbinDBAdapterFactory.ensureCasbinRuleIndexes + * because typeorm-adapter synchronize may drop undeclared indexes. + */ + +const INDEXES = [ + { + name: 'IDX_casbin_rule_ptype_v0_v1_v2', + columns: ['ptype', 'v0', 'v1', 'v2'], + }, + { name: 'IDX_casbin_rule_ptype_v0', columns: ['ptype', 'v0'] }, + { name: 'IDX_casbin_rule_ptype_v1', columns: ['ptype', 'v1'] }, +]; + +/** + * @param { import("knex").Knex } knex + */ +async function hasIndex(knex, table, indexName) { + const client = knex.client.config.client; + if (client === 'pg') { + const row = await knex + .select('indexname') + .from('pg_indexes') + .where({ tablename: table, indexname: indexName }) + .first(); + return Boolean(row); + } + if (client === 'better-sqlite3') { + const row = await knex + .select('name') + .from('sqlite_master') + .where({ type: 'index', name: indexName }) + .first(); + return Boolean(row); + } + return false; +} + +exports.up = async function up(knex) { + const exists = await knex.schema.hasTable('casbin_rule'); + if (!exists) { + return; + } + + for (const { name, columns } of INDEXES) { + if (await hasIndex(knex, 'casbin_rule', name)) { + continue; + } + await knex.schema.alterTable('casbin_rule', table => { + table.index(columns, name); + }); + } +}; + +exports.down = async function down(knex) { + const exists = await knex.schema.hasTable('casbin_rule'); + if (!exists) { + return; + } + + for (const { name } of INDEXES) { + if (!(await hasIndex(knex, 'casbin_rule', name))) { + continue; + } + await knex.schema.alterTable('casbin_rule', table => { + table.dropIndex([], name); + }); + } +}; diff --git a/workspaces/rbac/plugins/rbac-backend/src/database/casbin-adapter-factory.test.ts b/workspaces/rbac/plugins/rbac-backend/src/database/casbin-adapter-factory.test.ts index 5325c828e53..195e483d328 100644 --- a/workspaces/rbac/plugins/rbac-backend/src/database/casbin-adapter-factory.test.ts +++ b/workspaces/rbac/plugins/rbac-backend/src/database/casbin-adapter-factory.test.ts @@ -18,7 +18,10 @@ import { mockServices } from '@backstage/backend-test-utils'; import knex, { Knex } from 'knex'; import TypeORMAdapter from 'typeorm-adapter'; -import { CasbinDBAdapterFactory } from './casbin-adapter-factory'; +import { + CASBIN_RULE_INDEXES, + CasbinDBAdapterFactory, +} from './casbin-adapter-factory'; jest.mock('typeorm-adapter', () => { return { @@ -37,6 +40,10 @@ describe('CasbinAdapterFactory', () => { Promise >; jest.clearAllMocks(); + // Index ensure hits the real Knex client; unit tests only assert adapter wiring. + jest + .spyOn(CasbinDBAdapterFactory.prototype, 'ensureCasbinRuleIndexes') + .mockResolvedValue(undefined); }); it('test building an adapter using a better-sqlite3 configuration.', async () => { @@ -557,4 +564,73 @@ describe('CasbinAdapterFactory', () => { ); expect(newAdapterMock).not.toHaveBeenCalled(); }); + + describe('ensureCasbinRuleIndexes', () => { + let sqliteDb: Knex; + + beforeEach(async () => { + jest.restoreAllMocks(); + sqliteDb = knex.knex({ + client: 'better-sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }); + await sqliteDb.schema.createTable('casbin_rule', table => { + table.increments('id').primary(); + table.string('ptype'); + table.string('v0'); + table.string('v1'); + table.string('v2'); + table.string('v3'); + table.string('v4'); + table.string('v5'); + table.string('v6'); + }); + }); + + afterEach(async () => { + await sqliteDb.destroy(); + }); + + it('creates filtered-policy indexes when missing', async () => { + const config = mockServices.rootConfig({ data: {} }); + const factory = new CasbinDBAdapterFactory(config, sqliteDb); + + await factory.ensureCasbinRuleIndexes(); + + for (const { name } of CASBIN_RULE_INDEXES) { + const row = await sqliteDb + .select('name') + .from('sqlite_master') + .where({ type: 'index', name }) + .first(); + expect(row).toBeDefined(); + } + }); + + it('is idempotent when indexes already exist', async () => { + const config = mockServices.rootConfig({ data: {} }); + const factory = new CasbinDBAdapterFactory(config, sqliteDb); + + await factory.ensureCasbinRuleIndexes(); + await factory.ensureCasbinRuleIndexes(); + + for (const { name } of CASBIN_RULE_INDEXES) { + const row = await sqliteDb + .select('name') + .from('sqlite_master') + .where({ type: 'index', name }) + .first(); + expect(row).toBeDefined(); + } + }); + + it('no-ops when casbin_rule table is absent', async () => { + await sqliteDb.schema.dropTable('casbin_rule'); + const config = mockServices.rootConfig({ data: {} }); + const factory = new CasbinDBAdapterFactory(config, sqliteDb); + + await expect(factory.ensureCasbinRuleIndexes()).resolves.toBeUndefined(); + }); + }); }); diff --git a/workspaces/rbac/plugins/rbac-backend/src/database/casbin-adapter-factory.ts b/workspaces/rbac/plugins/rbac-backend/src/database/casbin-adapter-factory.ts index b8c00f6986e..88c90fd2e89 100644 --- a/workspaces/rbac/plugins/rbac-backend/src/database/casbin-adapter-factory.ts +++ b/workspaces/rbac/plugins/rbac-backend/src/database/casbin-adapter-factory.ts @@ -29,6 +29,19 @@ const DEFAULT_SQLITE3_STORAGE_FILE_NAME = 'rbac.sqlite'; const UNSUPPORTED_PG_CONNECTION_STRING_ERROR = 'Postgres connection config in string format is not supported yet, an object is expected'; +/** Indexes for filtered-policy / grouping lookups on casbin_rule. */ +export const CASBIN_RULE_INDEXES: ReadonlyArray<{ + name: string; + columns: readonly string[]; +}> = [ + { + name: 'IDX_casbin_rule_ptype_v0_v1_v2', + columns: ['ptype', 'v0', 'v1', 'v2'], + }, + { name: 'IDX_casbin_rule_ptype_v0', columns: ['ptype', 'v0'] }, + { name: 'IDX_casbin_rule_ptype_v1', columns: ['ptype', 'v1'] }, +]; + export class CasbinDBAdapterFactory { public constructor( private readonly config: ConfigApi, @@ -97,9 +110,55 @@ export class CasbinDBAdapterFactory { throw new Error(`Unsupported database client ${client}`); } + // typeorm-adapter enables synchronize by default and has no indexes on + // CasbinRule. Recreate query indexes after open so filtered loads do not + // seq-scan (and so a later sync drop is healed on the next startup). + await this.ensureCasbinRuleIndexes(); + return adapter; } + async ensureCasbinRuleIndexes(): Promise { + const hasTable = await this.databaseClient.schema.hasTable('casbin_rule'); + if (!hasTable) { + return; + } + + for (const { name, columns } of CASBIN_RULE_INDEXES) { + const hasIndex = await this.hasIndex('casbin_rule', name); + if (hasIndex) { + continue; + } + await this.databaseClient.schema.alterTable('casbin_rule', table => { + table.index([...columns], name); + }); + } + } + + private async hasIndex(table: string, indexName: string): Promise { + const client = this.databaseClient.client.config.client; + if (client === 'pg') { + const result = await this.databaseClient + .select('indexname') + .from('pg_indexes') + .where({ tablename: table, indexname: indexName }) + .first(); + return Boolean(result); + } + + // better-sqlite3 / others: ask the driver via knex raw pragma / sqlite_master + if (client === 'better-sqlite3') { + const rows = await this.databaseClient + .select('name') + .from('sqlite_master') + .where({ type: 'index', name: indexName }) + .first(); + return Boolean(rows); + } + + return false; + } + private async resolveKnexPgConnection(): Promise { const connection = this.databaseClient.client.config.connection;