import { and, desc, eq, gte, inArray, isNull, or, sql } from "drizzle-orm"; import { hostAccess, hosts, roles, sharedHostSecrets, snippetAccess, snippets, users, } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; export type RbacAccessTargetType = "user" | "role"; export interface RbacAccessListItem { id: number; targetType: RbacAccessTargetType; userId: string | null; roleId: number | null; username: string | null; roleName: string | null; roleDisplayName: string | null; grantedBy: string; grantedByUsername: string | null; permissionLevel: string; expiresAt: string | null; createdAt: string; } export interface RbacSharedHost { id: number; name: string | null; ip: string; port: number; username: string; folder: string | null; tags: string | null; permissionLevel: string; expiresAt: string | null; grantedBy: string; ownerUsername: string; } export interface RbacSharedSnippet { id: number; name: string; content: string; description: string | null; folder: string | null; ownerUsername: string; permissionLevel: string; expiresAt: string | null; } export interface RbacVisibleSharedSnippet extends RbacSharedSnippet { userId: string; order: number; createdAt: string; updatedAt: string; } export interface RbacAccessibleSnippet extends RbacVisibleSharedSnippet { hostFilter: string | null; } export interface RbacRoleHostAccessCredentialSource { hostAccessId: number; credentialId: number | null; rdpCredentialId: number | null; vncCredentialId: number | null; telnetCredentialId: number | null; hostId: number; hostOwnerId: string; } export interface RbacVisibleHostAccessEntry { hostId: number; permissionLevel: string; expiresAt: string | null; } export type RbacAccessTarget = | { targetType: "user"; targetUserId: string } | { targetType: "role"; targetRoleId: number }; export type UpsertHostAccessInput = RbacAccessTarget & { hostId: number; grantedBy: string; permissionLevel: string; expiresAt: string | null; }; export type UpsertSnippetAccessInput = RbacAccessTarget & { snippetId: number; grantedBy: string; expiresAt: string | null; }; type RawAccessListItem = Omit; function toAccessListItem(access: RawAccessListItem): RbacAccessListItem { return { ...access, targetType: access.userId ? "user" : "role", }; } export class RbacAccessRepository { constructor( private readonly context: DatabaseContext, private readonly onWrite?: () => void | Promise, ) {} async listHostAccess(hostId: number): Promise { const rows = await this.context.drizzle .select({ id: hostAccess.id, userId: hostAccess.userId, roleId: hostAccess.roleId, username: users.username, roleName: roles.name, roleDisplayName: roles.displayName, grantedBy: hostAccess.grantedBy, grantedByUsername: sql< string | null >`(SELECT username FROM users WHERE id = ${hostAccess.grantedBy})`, permissionLevel: hostAccess.permissionLevel, expiresAt: hostAccess.expiresAt, createdAt: hostAccess.createdAt, }) .from(hostAccess) .leftJoin(users, eq(hostAccess.userId, users.id)) .leftJoin(roles, eq(hostAccess.roleId, roles.id)) .where(eq(hostAccess.hostId, hostId)) .orderBy(desc(hostAccess.createdAt)); return rows.map(toAccessListItem); } async upsertHostAccess(input: UpsertHostAccessInput): Promise<{ id: number; created: boolean; }> { const existing = await this.findHostAccess(input.hostId, input); if (existing) { await this.context.drizzle .update(hostAccess) .set({ permissionLevel: input.permissionLevel, expiresAt: input.expiresAt, }) .where(eq(hostAccess.id, existing.id)); await this.afterWrite(); return { id: existing.id, created: false }; } const result = await this.context.drizzle.insert(hostAccess).values({ hostId: input.hostId, userId: input.targetType === "user" ? input.targetUserId : null, roleId: input.targetType === "role" ? input.targetRoleId : null, grantedBy: input.grantedBy, permissionLevel: input.permissionLevel, expiresAt: input.expiresAt, }); await this.afterWrite(); return { id: Number(result.lastInsertRowid), created: true }; } async revokeHostAccess(accessId: number, hostId: number): Promise { await this.context.drizzle .delete(hostAccess) .where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId))); await this.afterWrite(); } async deleteHostAccessForHost(hostId: number): Promise { const rows = await this.context.drizzle .delete(hostAccess) .where(eq(hostAccess.hostId, hostId)) .returning({ id: hostAccess.id }); if (rows.length > 0) { await this.afterWrite(); } return rows.length; } async deleteHostAccessForHosts(hostIds: number[]): Promise { if (hostIds.length === 0) { return 0; } const rows = await this.context.drizzle .delete(hostAccess) .where(inArray(hostAccess.hostId, hostIds)) .returning({ id: hostAccess.id }); if (rows.length > 0) { await this.afterWrite(); } return rows.length; } async deleteHostAccessForUserReferences(userId: string): Promise { const directRows = await this.context.drizzle .delete(hostAccess) .where(eq(hostAccess.userId, userId)) .returning({ id: hostAccess.id }); const grantedRows = await this.context.drizzle .delete(hostAccess) .where(eq(hostAccess.grantedBy, userId)) .returning({ id: hostAccess.id }); const deletedCount = directRows.length + grantedRows.length; if (deletedCount > 0) { await this.afterWrite(); } return deletedCount; } async findDirectHostAccess( hostId: number, userId: string, ): Promise { const rows = await this.context.drizzle .select() .from(hostAccess) .where(and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId))) .limit(1); return rows[0] ?? null; } async updateHostAccessOverrideCredential( accessId: number, credentialId: number | null, ): Promise { await this.context.drizzle .update(hostAccess) .set({ overrideCredentialId: credentialId }) .where(eq(hostAccess.id, accessId)); await this.afterWrite(); } async listSnippetAccess(snippetId: number): Promise { const rows = await this.context.drizzle .select({ id: snippetAccess.id, userId: snippetAccess.userId, roleId: snippetAccess.roleId, username: users.username, roleName: roles.name, roleDisplayName: roles.displayName, grantedBy: snippetAccess.grantedBy, grantedByUsername: sql< string | null >`(SELECT username FROM users WHERE id = ${snippetAccess.grantedBy})`, permissionLevel: snippetAccess.permissionLevel, expiresAt: snippetAccess.expiresAt, createdAt: snippetAccess.createdAt, }) .from(snippetAccess) .leftJoin(users, eq(snippetAccess.userId, users.id)) .leftJoin(roles, eq(snippetAccess.roleId, roles.id)) .where(eq(snippetAccess.snippetId, snippetId)) .orderBy(desc(snippetAccess.createdAt)); return rows.map(toAccessListItem); } async upsertSnippetAccess(input: UpsertSnippetAccessInput): Promise<{ id: number; created: boolean; }> { const existing = await this.findSnippetAccess(input.snippetId, input); if (existing) { await this.context.drizzle .update(snippetAccess) .set({ expiresAt: input.expiresAt }) .where(eq(snippetAccess.id, existing.id)); await this.afterWrite(); return { id: existing.id, created: false }; } const result = await this.context.drizzle.insert(snippetAccess).values({ snippetId: input.snippetId, userId: input.targetType === "user" ? input.targetUserId : null, roleId: input.targetType === "role" ? input.targetRoleId : null, grantedBy: input.grantedBy, permissionLevel: "view", expiresAt: input.expiresAt, }); await this.afterWrite(); return { id: Number(result.lastInsertRowid), created: true }; } async revokeSnippetAccess( accessId: number, snippetId: number, ): Promise { await this.context.drizzle .delete(snippetAccess) .where( and( eq(snippetAccess.id, accessId), eq(snippetAccess.snippetId, snippetId), ), ); await this.afterWrite(); } async listSharedHosts( userId: string, roleIds: number[], now = new Date().toISOString(), ): Promise { return this.context.drizzle .select({ id: hosts.id, name: hosts.name, ip: hosts.ip, port: hosts.port, username: hosts.username, folder: hosts.folder, tags: hosts.tags, permissionLevel: hostAccess.permissionLevel, expiresAt: hostAccess.expiresAt, grantedBy: hostAccess.grantedBy, ownerUsername: users.username, }) .from(hostAccess) .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) .innerJoin(users, eq(hosts.userId, users.id)) .where( and( this.userOrRoleHostAccessFilter(userId, roleIds), or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), ), ) .orderBy(desc(hostAccess.createdAt)); } async listVisibleHostAccessEntries( userId: string, roleIds: number[], now = new Date().toISOString(), ): Promise { return this.context.drizzle .select({ hostId: hostAccess.hostId, permissionLevel: hostAccess.permissionLevel, expiresAt: hostAccess.expiresAt, }) .from(hostAccess) .where( and( this.userOrRoleHostAccessFilter(userId, roleIds), or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), ), ) .orderBy(desc(hostAccess.createdAt)); } async listSharedSnippets( userId: string, roleIds: number[], now = new Date().toISOString(), ): Promise { const directShared = await this.context.drizzle .select({ id: snippets.id, name: snippets.name, content: snippets.content, description: snippets.description, folder: snippets.folder, ownerUsername: users.username, permissionLevel: snippetAccess.permissionLevel, expiresAt: snippetAccess.expiresAt, }) .from(snippetAccess) .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) .innerJoin(users, eq(snippets.userId, users.id)) .where( and( eq(snippetAccess.userId, userId), or( isNull(snippetAccess.expiresAt), gte(snippetAccess.expiresAt, now), ), ), ); if (roleIds.length === 0) { return directShared; } const directIds = new Set(directShared.map((snippet) => snippet.id)); const roleShared = await this.context.drizzle .select({ id: snippets.id, name: snippets.name, content: snippets.content, description: snippets.description, folder: snippets.folder, ownerUsername: users.username, permissionLevel: snippetAccess.permissionLevel, expiresAt: snippetAccess.expiresAt, }) .from(snippetAccess) .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) .innerJoin(users, eq(snippets.userId, users.id)) .where( and( or( isNull(snippetAccess.expiresAt), gte(snippetAccess.expiresAt, now), ), inArray(snippetAccess.roleId, roleIds), ), ); return [ ...directShared, ...roleShared.filter((snippet) => !directIds.has(snippet.id)), ]; } async listVisibleSharedSnippets( userId: string, roleIds: number[], now = new Date().toISOString(), ): Promise { return this.context.drizzle .select({ id: snippets.id, userId: snippets.userId, name: snippets.name, content: snippets.content, description: snippets.description, folder: snippets.folder, order: snippets.order, createdAt: snippets.createdAt, updatedAt: snippets.updatedAt, ownerUsername: users.username, permissionLevel: snippetAccess.permissionLevel, expiresAt: snippetAccess.expiresAt, }) .from(snippetAccess) .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) .innerJoin(users, eq(snippets.userId, users.id)) .where( and( this.userOrRoleSnippetAccessFilter(userId, roleIds), or( isNull(snippetAccess.expiresAt), gte(snippetAccess.expiresAt, now), ), ), ); } async findAccessibleSharedSnippet( snippetId: number, userId: string, roleIds: number[], now = new Date().toISOString(), ): Promise { const rows = await this.context.drizzle .select({ id: snippets.id, userId: snippets.userId, name: snippets.name, content: snippets.content, description: snippets.description, folder: snippets.folder, order: snippets.order, createdAt: snippets.createdAt, updatedAt: snippets.updatedAt, hostFilter: snippets.hostFilter, ownerUsername: users.username, permissionLevel: snippetAccess.permissionLevel, expiresAt: snippetAccess.expiresAt, }) .from(snippetAccess) .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) .innerJoin(users, eq(snippets.userId, users.id)) .where( and( eq(snippetAccess.snippetId, snippetId), this.userOrRoleSnippetAccessFilter(userId, roleIds), or( isNull(snippetAccess.expiresAt), gte(snippetAccess.expiresAt, now), ), ), ) .limit(1); return rows[0] ?? null; } async deleteExpiredHostAccess( now = new Date().toISOString(), ): Promise { const rows = await this.context.drizzle .delete(hostAccess) .where( and( sql`${hostAccess.expiresAt} IS NOT NULL`, sql`${hostAccess.expiresAt} <= ${now}`, ), ) .returning({ id: hostAccess.id }); if (rows.length > 0) { await this.afterWrite(); } return rows.length; } async findActiveHostAccess( hostId: number, userId: string, roleIds: number[], now = new Date().toISOString(), ): Promise { const rows = await this.context.drizzle .select() .from(hostAccess) .where( and( eq(hostAccess.hostId, hostId), this.userOrRoleHostAccessFilter(userId, roleIds), or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), ), ) .limit(1); return rows[0] ?? null; } async touchHostAccess( accessId: number, lastAccessedAt = new Date().toISOString(), ): Promise { await this.context.drizzle .update(hostAccess) .set({ lastAccessedAt }) .where(eq(hostAccess.id, accessId)); await this.afterWrite(); } async listRoleHostAccessCredentialSources( roleId: number, ): Promise { return this.context.drizzle .select({ hostAccessId: hostAccess.id, credentialId: hosts.credentialId, rdpCredentialId: hosts.rdpCredentialId, vncCredentialId: hosts.vncCredentialId, telnetCredentialId: hosts.telnetCredentialId, hostId: hosts.id, hostOwnerId: hosts.userId, }) .from(hostAccess) .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) .where(eq(hostAccess.roleId, roleId)); } async findSharedSecretForHostUserProtocol( hostId: number, userId: string, protocol: string, ): Promise { const rows = await this.context.drizzle .select({ secret: sharedHostSecrets, }) .from(sharedHostSecrets) .innerJoin(hostAccess, eq(sharedHostSecrets.hostAccessId, hostAccess.id)) .where( and( eq(hostAccess.hostId, hostId), eq(sharedHostSecrets.targetUserId, userId), eq(sharedHostSecrets.protocol, protocol), ), ) .limit(1); return rows[0]?.secret ?? null; } async listActiveHostAccessGrants( hostId: number, now = new Date().toISOString(), ): Promise<(typeof hostAccess.$inferSelect)[]> { return this.context.drizzle .select() .from(hostAccess) .where( and( eq(hostAccess.hostId, hostId), or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), ), ); } async findHostAccessById( accessId: number, hostId: number, ): Promise { const rows = await this.context.drizzle .select() .from(hostAccess) .where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId))) .limit(1); return rows[0] ?? null; } async updateHostAccessGrant( accessId: number, hostId: number, update: { permissionLevel?: string; expiresAt?: string | null }, ): Promise { const rows = await this.context.drizzle .update(hostAccess) .set(update) .where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId))) .returning({ id: hostAccess.id }); if (rows.length > 0) { await this.afterWrite(); } return rows.length > 0; } async findHostAccessOwnerId(hostAccessId: number): Promise { const rows = await this.context.drizzle .select({ ownerId: hosts.userId }) .from(hostAccess) .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) .where(eq(hostAccess.id, hostAccessId)) .limit(1); return rows[0]?.ownerId ?? null; } private userOrRoleHostAccessFilter(userId: string, roleIds: number[]) { if (roleIds.length === 0) { return eq(hostAccess.userId, userId); } return or( eq(hostAccess.userId, userId), inArray(hostAccess.roleId, roleIds), ); } private userOrRoleSnippetAccessFilter(userId: string, roleIds: number[]) { if (roleIds.length === 0) { return eq(snippetAccess.userId, userId); } return or( eq(snippetAccess.userId, userId), inArray(snippetAccess.roleId, roleIds), ); } private async findHostAccess(hostId: number, target: RbacAccessTarget) { const rows = await this.context.drizzle .select() .from(hostAccess) .where( and( eq(hostAccess.hostId, hostId), target.targetType === "user" ? eq(hostAccess.userId, target.targetUserId) : eq(hostAccess.roleId, target.targetRoleId), ), ) .limit(1); return rows[0] ?? null; } private async findSnippetAccess(snippetId: number, target: RbacAccessTarget) { const rows = await this.context.drizzle .select() .from(snippetAccess) .where( and( eq(snippetAccess.snippetId, snippetId), target.targetType === "user" ? eq(snippetAccess.userId, target.targetUserId) : eq(snippetAccess.roleId, target.targetRoleId), ), ) .limit(1); return rows[0] ?? null; } private async afterWrite(): Promise { await this.onWrite?.(); } }