Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
});
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -37,6 +40,10 @@ describe('CasbinAdapterFactory', () => {
Promise<TypeORMAdapter>
>;
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 () => {
Expand Down Expand Up @@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
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<boolean> {
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<Knex.PgConnectionConfig> {
const connection = this.databaseClient.client.config.connection;

Expand Down
Loading