mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
* feat: share credentials with users and roles, inherit data on account deletion Credentials can be shared at "use" or "manage" level. Recipients get a copy re-encrypted under their own data key (shared_credential_secrets), kept in step with the owner's row through the same lifecycle hooks as shared host secrets. One gate, findUsableCredential(), replaces the private-namespace lookups so a shared credential works wherever a private one does. Deleting a user now hands their hosts and credentials to a successor (the deleting admin by default) instead of revoking everything they shared. * fix: harden credential ownership transfer
416 lines
11 KiB
TypeScript
416 lines
11 KiB
TypeScript
import { and, desc, eq, sql } from "drizzle-orm";
|
|
import { randomUUID } from "crypto";
|
|
import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
|
|
import type { DatabaseContext } from "./database-context.js";
|
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
|
import { rowsAffected } from "./mutation-result.js";
|
|
import {
|
|
deleteReturning,
|
|
insertReturning,
|
|
updateReturning,
|
|
} from "./returning.js";
|
|
|
|
export type CredentialRecord = typeof sshCredentials.$inferSelect;
|
|
export type NewCredentialRecord = typeof sshCredentials.$inferInsert;
|
|
export type CredentialUpdate = Partial<
|
|
Omit<NewCredentialRecord, "id" | "userId">
|
|
>;
|
|
|
|
export class CredentialRepository {
|
|
constructor(
|
|
private readonly context: DatabaseContext,
|
|
private readonly onWrite?: () => void | Promise<void>,
|
|
) {}
|
|
|
|
async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
|
|
const rows = await insertReturning(this.context, sshCredentials, {
|
|
syncId: randomUUID(),
|
|
...credential,
|
|
});
|
|
await this.afterWrite();
|
|
return rows[0];
|
|
}
|
|
|
|
async createEncryptedForUser(
|
|
userId: string,
|
|
credential: NewCredentialRecord | Record<string, unknown>,
|
|
): Promise<CredentialRecord> {
|
|
const userDataKey = DataCrypto.validateUserAccess(userId);
|
|
const tempId = credential.id ?? Date.now();
|
|
const dataWithTempId = {
|
|
syncId: randomUUID(),
|
|
...credential,
|
|
id: tempId,
|
|
};
|
|
const encryptedCredential = this.encryptCredentialRecordForWrite(
|
|
dataWithTempId,
|
|
userId,
|
|
userDataKey,
|
|
);
|
|
|
|
if (!credential.id) {
|
|
delete (encryptedCredential as Partial<NewCredentialRecord>).id;
|
|
}
|
|
|
|
const rows = await insertReturning(
|
|
this.context,
|
|
sshCredentials,
|
|
encryptedCredential as NewCredentialRecord,
|
|
);
|
|
|
|
await this.afterWrite();
|
|
return DataCrypto.decryptRecord(
|
|
"ssh_credentials",
|
|
rows[0],
|
|
userId,
|
|
userDataKey,
|
|
);
|
|
}
|
|
|
|
async findByIdForUser(
|
|
userId: string,
|
|
credentialId: number,
|
|
): Promise<CredentialRecord | null> {
|
|
const rows = await this.context.drizzle
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.id, credentialId),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
async findById(credentialId: number): Promise<CredentialRecord | null> {
|
|
const rows = await this.context.drizzle
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(eq(sshCredentials.id, credentialId))
|
|
.limit(1);
|
|
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
async listByUserId(userId: string): Promise<CredentialRecord[]> {
|
|
return this.context.drizzle
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(eq(sshCredentials.userId, userId))
|
|
.orderBy(desc(sshCredentials.updatedAt));
|
|
}
|
|
|
|
async existsForImportIdentity(
|
|
userId: string,
|
|
name: string,
|
|
username: string | null,
|
|
): Promise<boolean> {
|
|
const rows = await this.context.drizzle
|
|
.select({ id: sshCredentials.id })
|
|
.from(sshCredentials)
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.userId, userId),
|
|
eq(sshCredentials.name, name),
|
|
eq(sshCredentials.username, username),
|
|
),
|
|
)
|
|
.limit(1);
|
|
|
|
return rows.length > 0;
|
|
}
|
|
|
|
async findDecryptedByIdForUser(
|
|
userId: string,
|
|
credentialId: number,
|
|
): Promise<CredentialRecord | null> {
|
|
const row = await this.findByIdForUser(userId, credentialId);
|
|
return this.decryptOne(row, userId);
|
|
}
|
|
|
|
async listDecryptedByUserId(userId: string): Promise<CredentialRecord[]> {
|
|
const rows = await this.listByUserId(userId);
|
|
return this.decryptMany(rows, userId);
|
|
}
|
|
|
|
async listFolders(userId: string): Promise<string[]> {
|
|
const rows = await this.context.drizzle
|
|
.select({ folder: sshCredentials.folder })
|
|
.from(sshCredentials)
|
|
.where(eq(sshCredentials.userId, userId));
|
|
|
|
return [...new Set(rows.map((row) => row.folder).filter(Boolean))].sort();
|
|
}
|
|
|
|
async renameFolder(
|
|
userId: string,
|
|
oldName: string,
|
|
newName: string,
|
|
): Promise<number> {
|
|
const result = await this.context.drizzle
|
|
.update(sshCredentials)
|
|
.set({ folder: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.userId, userId),
|
|
eq(sshCredentials.folder, oldName),
|
|
),
|
|
);
|
|
|
|
if (rowsAffected(result) > 0) {
|
|
await this.afterWrite();
|
|
}
|
|
|
|
return rowsAffected(result);
|
|
}
|
|
|
|
async updateForUser(
|
|
userId: string,
|
|
credentialId: number,
|
|
update: CredentialUpdate,
|
|
): Promise<CredentialRecord | null> {
|
|
const rows = await updateReturning(
|
|
this.context,
|
|
sshCredentials,
|
|
{ ...update, updatedAt: sql`CURRENT_TIMESTAMP` },
|
|
and(
|
|
eq(sshCredentials.id, credentialId),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
);
|
|
|
|
await this.afterWrite();
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
async updateEncryptedForUser(
|
|
userId: string,
|
|
credentialId: number,
|
|
update: CredentialUpdate,
|
|
): Promise<CredentialRecord | null> {
|
|
const userDataKey = DataCrypto.validateUserAccess(userId);
|
|
const encryptedUpdate = this.encryptCredentialRecordForWrite(
|
|
update,
|
|
userId,
|
|
userDataKey,
|
|
);
|
|
|
|
const rows = await updateReturning(
|
|
this.context,
|
|
sshCredentials,
|
|
{ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` },
|
|
and(
|
|
eq(sshCredentials.id, credentialId),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
);
|
|
|
|
await this.afterWrite();
|
|
return this.decryptOne(rows[0] ?? null, userId);
|
|
}
|
|
|
|
/**
|
|
* Manual drag-to-reorder write path. Updates sortOrder for each id one row
|
|
* at a time inside a transaction rather than a single set-for-all-matching
|
|
* -ids statement, matching HostRepository.reorderForUser.
|
|
*/
|
|
async reorderForUser(
|
|
userId: string,
|
|
positions: { id: number; sortOrder: number }[],
|
|
): Promise<number> {
|
|
if (positions.length === 0) return 0;
|
|
|
|
let affected: number;
|
|
if (this.context.dialect === "sqlite") {
|
|
affected = this.context.drizzle.transaction((tx) => {
|
|
let count = 0;
|
|
for (const { id, sortOrder } of positions) {
|
|
const result = tx
|
|
.update(sshCredentials)
|
|
.set({ sortOrder, updatedAt: sql`CURRENT_TIMESTAMP` })
|
|
.where(
|
|
and(eq(sshCredentials.id, id), eq(sshCredentials.userId, userId)),
|
|
)
|
|
.run();
|
|
count += rowsAffected(result);
|
|
}
|
|
return count;
|
|
});
|
|
} else {
|
|
affected = await this.context.drizzle.transaction(async (tx) => {
|
|
let count = 0;
|
|
for (const { id, sortOrder } of positions) {
|
|
const result = await tx
|
|
.update(sshCredentials)
|
|
.set({ sortOrder, updatedAt: sql`CURRENT_TIMESTAMP` })
|
|
.where(
|
|
and(eq(sshCredentials.id, id), eq(sshCredentials.userId, userId)),
|
|
);
|
|
count += rowsAffected(result);
|
|
}
|
|
return count;
|
|
});
|
|
}
|
|
|
|
if (affected > 0) {
|
|
await this.afterWrite();
|
|
}
|
|
|
|
return affected;
|
|
}
|
|
|
|
async deleteForUser(
|
|
userId: string,
|
|
credentialId: number,
|
|
): Promise<{ syncId: string | null } | null> {
|
|
const rows = await deleteReturning(
|
|
this.context,
|
|
sshCredentials,
|
|
and(
|
|
eq(sshCredentials.id, credentialId),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
);
|
|
|
|
await this.afterWrite();
|
|
return rows[0] ? { syncId: rows[0].syncId } : null;
|
|
}
|
|
|
|
async deleteByUserId(userId: string): Promise<number> {
|
|
const result = await this.context.drizzle
|
|
.delete(sshCredentials)
|
|
.where(eq(sshCredentials.userId, userId));
|
|
|
|
if (rowsAffected(result) > 0) {
|
|
await this.afterWrite();
|
|
}
|
|
|
|
return rowsAffected(result);
|
|
}
|
|
|
|
async recordUsage(
|
|
userId: string,
|
|
credentialId: number,
|
|
hostId: number,
|
|
usedAt = new Date().toISOString(),
|
|
): Promise<void> {
|
|
await this.context.drizzle.insert(sshCredentialUsage).values({
|
|
credentialId,
|
|
hostId,
|
|
userId,
|
|
usedAt,
|
|
});
|
|
|
|
await this.context.drizzle
|
|
.update(sshCredentials)
|
|
.set({
|
|
lastUsed: usedAt,
|
|
usageCount: sql`${sshCredentials.usageCount} + 1`,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.id, credentialId),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
);
|
|
await this.afterWrite();
|
|
}
|
|
|
|
private decryptOne<T extends Record<string, unknown>>(
|
|
record: T | null,
|
|
userId: string,
|
|
): T | null {
|
|
if (!record) return null;
|
|
const userDataKey = DataCrypto.getUserDataKey(userId);
|
|
if (!userDataKey) return null;
|
|
return DataCrypto.decryptRecord(
|
|
"ssh_credentials",
|
|
record,
|
|
userId,
|
|
userDataKey,
|
|
);
|
|
}
|
|
|
|
private decryptMany<T extends Record<string, unknown>>(
|
|
records: T[],
|
|
userId: string,
|
|
): T[] {
|
|
const userDataKey = DataCrypto.getUserDataKey(userId);
|
|
if (!userDataKey) return [];
|
|
return DataCrypto.decryptRecords(
|
|
"ssh_credentials",
|
|
records,
|
|
userId,
|
|
userDataKey,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Re-keys every credential of one user to another: decrypt under the old
|
|
* owner's key, encrypt under the new one, change the owner. Used when an
|
|
* account is deleted and its data is inherited rather than dropped.
|
|
*/
|
|
async transferAllToUser(
|
|
fromUserId: string,
|
|
toUserId: string,
|
|
): Promise<number[]> {
|
|
const fromKey = DataCrypto.validateUserAccess(fromUserId);
|
|
const toKey = DataCrypto.validateUserAccess(toUserId);
|
|
const rows = await this.context.drizzle
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(eq(sshCredentials.userId, fromUserId));
|
|
const moved: number[] = [];
|
|
for (const row of rows) {
|
|
const plain = DataCrypto.decryptRecord(
|
|
"ssh_credentials",
|
|
row,
|
|
fromUserId,
|
|
fromKey,
|
|
);
|
|
const {
|
|
id: _id,
|
|
userId: _userId,
|
|
...fields
|
|
} = plain as Record<string, unknown>;
|
|
const encrypted = DataCrypto.encryptRecord(
|
|
"ssh_credentials",
|
|
{ ...fields, id: row.id },
|
|
toUserId,
|
|
toKey,
|
|
) as Record<string, unknown>;
|
|
delete encrypted.id;
|
|
await this.context.drizzle
|
|
.update(sshCredentials)
|
|
.set({
|
|
...(encrypted as Partial<NewCredentialRecord>),
|
|
userId: toUserId,
|
|
})
|
|
.where(eq(sshCredentials.id, row.id));
|
|
moved.push(row.id);
|
|
}
|
|
if (moved.length) await this.afterWrite();
|
|
return moved;
|
|
}
|
|
|
|
private encryptCredentialRecordForWrite<T extends Record<string, unknown>>(
|
|
record: T,
|
|
userId: string,
|
|
userDataKey: Buffer,
|
|
): T {
|
|
return DataCrypto.encryptRecord(
|
|
"ssh_credentials",
|
|
record,
|
|
userId,
|
|
userDataKey,
|
|
);
|
|
}
|
|
|
|
private async afterWrite(): Promise<void> {
|
|
await this.onWrite?.();
|
|
}
|
|
}
|