Files
Termix/src/backend/database/repositories/rbac-access-repository.ts
T

447 lines
12 KiB
TypeScript

import { and, desc, eq, gte, inArray, isNull, or, sql } from "drizzle-orm";
import {
hostAccess,
hosts,
roles,
sharedHostSecrets,
users,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.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 RbacRoleHostAccessCredentialSource {
hostAccessId: number;
credentialId: 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;
};
type RawAccessListItem = Omit<RbacAccessListItem, "targetType">;
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<void>,
) {}
async listHostAccess(hostId: number): Promise<RbacAccessListItem[]> {
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 [created] = await insertReturning(this.context, hostAccess, {
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: created.id, created: true };
}
async revokeHostAccess(accessId: number, hostId: number): Promise<void> {
await this.context.drizzle
.delete(hostAccess)
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)));
await this.afterWrite();
}
async deleteHostAccessForHost(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(hostAccess)
.where(eq(hostAccess.hostId, hostId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteHostAccessForHosts(hostIds: number[]): Promise<number> {
if (hostIds.length === 0) {
return 0;
}
const result = await this.context.drizzle
.delete(hostAccess)
.where(inArray(hostAccess.hostId, hostIds));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
/** Grants the departing user handed out now count as the successor's. */
async reassignHostAccessGrantedBy(
fromUserId: string,
toUserId: string,
): Promise<void> {
await this.context.drizzle
.update(hostAccess)
.set({ grantedBy: toUserId })
.where(eq(hostAccess.grantedBy, fromUserId));
await this.afterWrite();
}
async deleteHostAccessForUserReferences(userId: string): Promise<number> {
const directResult = await this.context.drizzle
.delete(hostAccess)
.where(eq(hostAccess.userId, userId));
const result = await this.context.drizzle
.delete(hostAccess)
.where(eq(hostAccess.grantedBy, userId));
const deletedCount = rowsAffected(directResult) + rowsAffected(result);
if (deletedCount > 0) {
await this.afterWrite();
}
return deletedCount;
}
async findDirectHostAccess(
hostId: number,
userId: string,
): Promise<typeof hostAccess.$inferSelect | null> {
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 listSharedHosts(
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<RbacSharedHost[]> {
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<RbacVisibleHostAccessEntry[]> {
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 deleteExpiredHostAccess(
now = new Date().toISOString(),
): Promise<number> {
const result = await this.context.drizzle
.delete(hostAccess)
.where(
and(
sql`${hostAccess.expiresAt} IS NOT NULL`,
sql`${hostAccess.expiresAt} <= ${now}`,
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async findActiveHostAccess(
hostId: number,
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<typeof hostAccess.$inferSelect | null> {
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<void> {
await this.context.drizzle
.update(hostAccess)
.set({ lastAccessedAt })
.where(eq(hostAccess.id, accessId));
await this.afterWrite();
}
async listRoleHostAccessCredentialSources(
roleId: number,
): Promise<RbacRoleHostAccessCredentialSource[]> {
return this.context.drizzle
.select({
hostAccessId: hostAccess.id,
credentialId: hosts.credentialId,
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<typeof sharedHostSecrets.$inferSelect | null> {
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<typeof hostAccess.$inferSelect | null> {
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<boolean> {
const result = await this.context.drizzle
.update(hostAccess)
.set(update)
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async findHostAccessOwnerId(hostAccessId: number): Promise<string | null> {
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 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 afterWrite(): Promise<void> {
await this.onWrite?.();
}
}