mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
447 lines
12 KiB
TypeScript
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?.();
|
|
}
|
|
}
|