feat: bitwise RBAC engine with channel-level permission overrides
Replace string-based role checks (role === 'admin') with a bitwise BigInt permission system. Adds computePermissions() resolution engine following Discord's model: @everyone base → role union → admin shortcut → channel overrides (role deny/allow → member deny/allow). Ready payload now filters channels by VIEW_CHANNEL and attaches per-user myPermissions to each server and channel. Includes channel_overrides table, @everyone role auto-creation, migration for existing servers, and override CRUD API.
This commit is contained in:
@@ -144,6 +144,15 @@ function createTables(db: Database.Database): void {
|
||||
PRIMARY KEY (server_id, user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_overrides (
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
allow TEXT NOT NULL DEFAULT '0',
|
||||
deny TEXT NOT NULL DEFAULT '0',
|
||||
PRIMARY KEY (channel_id, target_type, target_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS read_states (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
channel_id TEXT NOT NULL,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@opencord/shared/src/permissions.js';
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
console.log('Checking for database migrations...');
|
||||
@@ -65,6 +66,75 @@ export function runMigrations(db: Database.Database): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Ensure channel_overrides table exists (idempotent)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS channel_overrides (
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
allow TEXT NOT NULL DEFAULT '0',
|
||||
deny TEXT NOT NULL DEFAULT '0',
|
||||
PRIMARY KEY (channel_id, target_type, target_id)
|
||||
);
|
||||
`);
|
||||
|
||||
// ─── RBAC Migration: Ensure @everyone roles exist for all servers ─────────
|
||||
migrateEveryoneRoles(db);
|
||||
|
||||
console.log('Migrations complete.');
|
||||
}
|
||||
|
||||
/** For each server, ensure an @everyone role exists with id === server.id */
|
||||
function migrateEveryoneRoles(db: Database.Database): void {
|
||||
const servers = db.prepare('SELECT id FROM servers').all() as { id: string }[];
|
||||
const now = Date.now();
|
||||
const defaultPerms = permissionsToString(DEFAULT_EVERYONE_PERMISSIONS);
|
||||
const adminPerms = permissionsToString(ALL_PERMISSIONS);
|
||||
|
||||
const insertRole = db.prepare(
|
||||
'INSERT OR IGNORE INTO roles (id, server_id, name, color, position, permissions, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
|
||||
for (const server of servers) {
|
||||
// Create @everyone role if it doesn't exist (id = server.id)
|
||||
insertRole.run(server.id, server.id, '@everyone', '#b9bbbe', 0, defaultPerms, now);
|
||||
}
|
||||
|
||||
// Migrate existing admin members: ensure an Admin role exists and assign it
|
||||
const adminMembers = db.prepare(
|
||||
"SELECT server_id, user_id FROM server_members WHERE role = 'admin'"
|
||||
).all() as { server_id: string; user_id: string }[];
|
||||
|
||||
if (adminMembers.length > 0) {
|
||||
// Group by server
|
||||
const serverAdmins = new Map<string, string[]>();
|
||||
for (const row of adminMembers) {
|
||||
let arr = serverAdmins.get(row.server_id);
|
||||
if (!arr) { arr = []; serverAdmins.set(row.server_id, arr); }
|
||||
arr.push(row.user_id);
|
||||
}
|
||||
|
||||
const checkAdminRole = db.prepare(
|
||||
"SELECT id FROM roles WHERE server_id = ? AND name = 'Admin' AND permissions = ?"
|
||||
);
|
||||
const insertMemberRole = db.prepare(
|
||||
'INSERT OR IGNORE INTO member_roles (server_id, user_id, role_id) VALUES (?, ?, ?)'
|
||||
);
|
||||
|
||||
for (const [serverId, userIds] of serverAdmins) {
|
||||
// Find or create Admin role for this server
|
||||
let adminRole = checkAdminRole.get(serverId, adminPerms) as { id: string } | undefined;
|
||||
if (!adminRole) {
|
||||
// Generate a simple unique ID for the admin role
|
||||
const adminRoleId = `${serverId}-admin`;
|
||||
insertRole.run(adminRoleId, serverId, 'Admin', '#e74c3c', 1, adminPerms, now);
|
||||
adminRole = { id: adminRoleId };
|
||||
}
|
||||
|
||||
for (const userId of userIds) {
|
||||
insertMemberRole.run(serverId, userId, adminRole.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,16 @@ export const memberRoles = sqliteTable('member_roles', {
|
||||
pk: primaryKey({ columns: [table.serverId, table.userId, table.roleId] }),
|
||||
}));
|
||||
|
||||
export const channelOverrides = sqliteTable('channel_overrides', {
|
||||
channelId: text('channel_id').notNull().references(() => channels.id, { onDelete: 'cascade' }),
|
||||
targetType: text('target_type').notNull(), // 'role' | 'member'
|
||||
targetId: text('target_id').notNull(), // role ID or user ID
|
||||
allow: text('allow').notNull().default('0'), // BigInt decimal string
|
||||
deny: text('deny').notNull().default('0'), // BigInt decimal string
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.channelId, table.targetType, table.targetId] }),
|
||||
}));
|
||||
|
||||
export const readStates = sqliteTable('read_states', {
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
channelId: text('channel_id').notNull(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getDb, schema } from './index.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { hashPassword } from '../utils/auth.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { DEFAULT_EVERYONE_PERMISSIONS, permissionsToString } from '@opencord/shared/src/permissions.js';
|
||||
|
||||
export async function seedDatabase(): Promise<void> {
|
||||
const db = getDb();
|
||||
@@ -63,6 +64,17 @@ export async function seedDatabase(): Promise<void> {
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
// Create @everyone role (id === serverId convention)
|
||||
db.insert(schema.roles).values({
|
||||
id: serverId,
|
||||
serverId: serverId,
|
||||
name: '@everyone',
|
||||
color: '#b9bbbe',
|
||||
position: 0,
|
||||
permissions: permissionsToString(DEFAULT_EVERYONE_PERMISSIONS),
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
console.log('Database seeded successfully');
|
||||
console.log(` Default server: Opencord (invite code: opencord)`);
|
||||
console.log(` Admin user: admin / admin123`);
|
||||
|
||||
Reference in New Issue
Block a user