feat: share credentials with users and roles, inherit data on account deletion (#1342)

* 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
This commit is contained in:
ZacharyZcR
2026-08-25 04:12:47 +08:00
committed by GitHub
parent 5f55289e00
commit 82143946c7
41 changed files with 30051 additions and 59 deletions
+40
View File
@@ -622,6 +622,46 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS credential_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_id INTEGER NOT NULL,
user_id TEXT,
role_id INTEGER,
granted_by TEXT NOT NULL,
permission_level TEXT NOT NULL DEFAULT 'use',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_credential_access_user_id ON credential_access (user_id);
CREATE INDEX IF NOT EXISTS idx_credential_access_role_id ON credential_access (role_id);
CREATE INDEX IF NOT EXISTS idx_credential_access_credential_id ON credential_access (credential_id);
CREATE TABLE IF NOT EXISTS shared_credential_secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_access_id INTEGER NOT NULL,
target_user_id TEXT NOT NULL,
credential_id INTEGER NOT NULL,
encrypted_username TEXT,
auth_type TEXT NOT NULL DEFAULT 'password',
encrypted_password TEXT,
encrypted_key TEXT,
encrypted_key_password TEXT,
key_type TEXT,
public_key TEXT,
cert_public_key TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (credential_access_id, target_user_id),
FOREIGN KEY (credential_access_id) REFERENCES credential_access (id) ON DELETE CASCADE,
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_shared_credential_secrets_target ON shared_credential_secrets (target_user_id, credential_id);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
+87
View File
@@ -2048,3 +2048,90 @@ export const secretSources = mysqlTable(
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
*/
export const credentialAccess = mysqlTable(
"credential_access",
{
id: int("id").autoincrement().primaryKey(),
credentialId: int("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: int("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("use"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_credential_access_user_id").on(table.userId),
index("idx_credential_access_role_id").on(table.roleId),
index("idx_credential_access_credential_id").on(table.credentialId),
],
);
/**
* A recipient's copy of a shared credential's secrets, re-encrypted under
* the recipient's data key (the owner's key cannot be used by anyone else).
* Rebuilt whenever the owner edits the credential; one row per grant and
* recipient, like shared_host_secrets.
*/
export const sharedCredentialSecrets = mysqlTable(
"shared_credential_secrets",
{
id: int("id").autoincrement().primaryKey(),
credentialAccessId: int("credential_access_id")
.notNull()
.references(() => credentialAccess.id, { onDelete: "cascade" }),
targetUserId: varchar("target_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
credentialId: int("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
encryptedUsername: text("encrypted_username"),
authType: text("auth_type").notNull().default("password"),
encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key"),
encryptedKeyPassword: text("encrypted_key_password"),
keyType: text("key_type"),
publicKey: text("public_key"),
certPublicKey: text("cert_public_key"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_shared_credential_secrets_scope").on(
table.credentialAccessId,
table.targetUserId,
),
index("idx_shared_credential_secrets_target").on(
table.targetUserId,
table.credentialId,
),
],
);
+87
View File
@@ -2049,3 +2049,90 @@ export const secretSources = pgTable(
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
*/
export const credentialAccess = pgTable(
"credential_access",
{
id: serial("id").primaryKey(),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("use"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_credential_access_user_id").on(table.userId),
index("idx_credential_access_role_id").on(table.roleId),
index("idx_credential_access_credential_id").on(table.credentialId),
],
);
/**
* A recipient's copy of a shared credential's secrets, re-encrypted under
* the recipient's data key (the owner's key cannot be used by anyone else).
* Rebuilt whenever the owner edits the credential; one row per grant and
* recipient, like shared_host_secrets.
*/
export const sharedCredentialSecrets = pgTable(
"shared_credential_secrets",
{
id: serial("id").primaryKey(),
credentialAccessId: integer("credential_access_id")
.notNull()
.references(() => credentialAccess.id, { onDelete: "cascade" }),
targetUserId: varchar("target_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
encryptedUsername: text("encrypted_username"),
authType: text("auth_type").notNull().default("password"),
encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key"),
encryptedKeyPassword: text("encrypted_key_password"),
keyType: text("key_type"),
publicKey: text("public_key"),
certPublicKey: text("cert_public_key"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_shared_credential_secrets_scope").on(
table.credentialAccessId,
table.targetUserId,
),
index("idx_shared_credential_secrets_target").on(
table.targetUserId,
table.credentialId,
),
],
);
+87
View File
@@ -2045,3 +2045,90 @@ export const secretSources = sqliteTable(
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
*/
export const credentialAccess = sqliteTable(
"credential_access",
{
id: integer("id").primaryKey({ autoIncrement: true }),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: text("granted_by")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("use"),
expiresAt: text("expires_at"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_credential_access_user_id").on(table.userId),
index("idx_credential_access_role_id").on(table.roleId),
index("idx_credential_access_credential_id").on(table.credentialId),
],
);
/**
* A recipient's copy of a shared credential's secrets, re-encrypted under
* the recipient's data key (the owner's key cannot be used by anyone else).
* Rebuilt whenever the owner edits the credential; one row per grant and
* recipient, like shared_host_secrets.
*/
export const sharedCredentialSecrets = sqliteTable(
"shared_credential_secrets",
{
id: integer("id").primaryKey({ autoIncrement: true }),
credentialAccessId: integer("credential_access_id")
.notNull()
.references(() => credentialAccess.id, { onDelete: "cascade" }),
targetUserId: text("target_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
encryptedUsername: text("encrypted_username"),
authType: text("auth_type").notNull().default("password"),
encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key", { length: 16384 }),
encryptedKeyPassword: text("encrypted_key_password"),
keyType: text("key_type"),
publicKey: text("public_key", { length: 4096 }),
certPublicKey: text("cert_public_key", { length: 8192 }),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_shared_credential_secrets_scope").on(
table.credentialAccessId,
table.targetUserId,
),
index("idx_shared_credential_secrets_target").on(
table.targetUserId,
table.credentialId,
),
],
);
@@ -0,0 +1,276 @@
import { and, eq, gte, inArray, isNull, or, type SQL } from "drizzle-orm";
import { alias } from "drizzle-orm/sqlite-core";
import {
credentialAccess,
roles,
sshCredentials,
users,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { insertReturning } from "./returning.js";
export type CredentialAccessRecord = typeof credentialAccess.$inferSelect;
export type CredentialPermissionLevel = "use" | "manage";
export interface CredentialAccessListItem extends CredentialAccessRecord {
targetType: "user" | "role";
username: string | null;
roleName: string | null;
roleDisplayName: string | null;
grantedByUsername: string | null;
}
export type CredentialAccessTarget =
| { targetType: "user"; targetUserId: string }
| { targetType: "role"; targetRoleId: number };
export interface UpsertCredentialAccessInput {
credentialId: number;
grantedBy: string;
permissionLevel: CredentialPermissionLevel;
expiresAt: string | null;
target: CredentialAccessTarget;
}
/** A credential shared with the caller, with the grant that admits them. */
export interface SharedCredentialGrant {
accessId: number;
credentialId: number;
ownerId: string;
permissionLevel: string;
expiresAt: string | null;
}
function activeFilter(now: string): SQL {
return or(
isNull(credentialAccess.expiresAt),
gte(credentialAccess.expiresAt, now),
)!;
}
function granteeFilter(userId: string, roleIds: number[]): SQL {
return roleIds.length === 0
? eq(credentialAccess.userId, userId)
: or(
eq(credentialAccess.userId, userId),
inArray(credentialAccess.roleId, roleIds),
)!;
}
export class CredentialAccessRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listForCredential(
credentialId: number,
): Promise<CredentialAccessListItem[]> {
const granter = alias(users, "granter");
const rows = await this.context.drizzle
.select({
access: credentialAccess,
username: users.username,
roleName: roles.name,
roleDisplayName: roles.displayName,
grantedByUsername: granter.username,
})
.from(credentialAccess)
.leftJoin(users, eq(credentialAccess.userId, users.id))
.leftJoin(roles, eq(credentialAccess.roleId, roles.id))
.leftJoin(granter, eq(credentialAccess.grantedBy, granter.id))
.where(eq(credentialAccess.credentialId, credentialId));
return rows.map((row) => ({
...row.access,
targetType: row.access.roleId ? ("role" as const) : ("user" as const),
username: row.username,
roleName: row.roleName,
roleDisplayName: row.roleDisplayName,
grantedByUsername: row.grantedByUsername,
}));
}
async upsert(
input: UpsertCredentialAccessInput,
): Promise<{ id: number; created: boolean }> {
const targetFilter =
input.target.targetType === "user"
? eq(credentialAccess.userId, input.target.targetUserId)
: eq(credentialAccess.roleId, input.target.targetRoleId);
const existing = await this.context.drizzle
.select({ id: credentialAccess.id })
.from(credentialAccess)
.where(
and(
eq(credentialAccess.credentialId, input.credentialId),
targetFilter,
),
)
.limit(1);
if (existing[0]) {
await this.context.drizzle
.update(credentialAccess)
.set({
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
grantedBy: input.grantedBy,
})
.where(eq(credentialAccess.id, existing[0].id));
await this.afterWrite();
return { id: existing[0].id, created: false };
}
const [created] = await insertReturning(this.context, credentialAccess, {
credentialId: input.credentialId,
userId:
input.target.targetType === "user" ? input.target.targetUserId : null,
roleId:
input.target.targetType === "role" ? input.target.targetRoleId : null,
grantedBy: input.grantedBy,
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
});
await this.afterWrite();
return { id: created.id, created: true };
}
async findById(
accessId: number,
credentialId: number,
): Promise<CredentialAccessRecord | null> {
const rows = await this.context.drizzle
.select()
.from(credentialAccess)
.where(
and(
eq(credentialAccess.id, accessId),
eq(credentialAccess.credentialId, credentialId),
),
)
.limit(1);
return rows[0] ?? null;
}
async revoke(accessId: number, credentialId: number): Promise<void> {
await this.context.drizzle
.delete(credentialAccess)
.where(
and(
eq(credentialAccess.id, accessId),
eq(credentialAccess.credentialId, credentialId),
),
);
await this.afterWrite();
}
/** The strongest unexpired grant admitting this user to the credential. */
async findActiveGrant(
credentialId: number,
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<CredentialAccessRecord | null> {
const rows = await this.context.drizzle
.select()
.from(credentialAccess)
.where(
and(
eq(credentialAccess.credentialId, credentialId),
granteeFilter(userId, roleIds),
activeFilter(now),
),
);
return (
rows.find((row) => row.permissionLevel === "manage") ?? rows[0] ?? null
);
}
/** Every credential shared with this user (directly or via a role). */
async listSharedWithUser(
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<SharedCredentialGrant[]> {
const rows = await this.context.drizzle
.select({
accessId: credentialAccess.id,
credentialId: credentialAccess.credentialId,
ownerId: sshCredentials.userId,
permissionLevel: credentialAccess.permissionLevel,
expiresAt: credentialAccess.expiresAt,
})
.from(credentialAccess)
.innerJoin(
sshCredentials,
eq(credentialAccess.credentialId, sshCredentials.id),
)
.where(and(granteeFilter(userId, roleIds), activeFilter(now)));
// Direct and role grants can overlap; keep the strongest per credential.
const best = new Map<number, SharedCredentialGrant>();
for (const row of rows) {
if (row.ownerId === userId) continue;
const current = best.get(row.credentialId);
if (!current || row.permissionLevel === "manage")
best.set(row.credentialId, row);
}
return Array.from(best.values());
}
/** Active grants on one credential, expanded to user and role targets. */
async listActiveGrants(
credentialId: number,
now = new Date().toISOString(),
): Promise<CredentialAccessRecord[]> {
return this.context.drizzle
.select()
.from(credentialAccess)
.where(
and(eq(credentialAccess.credentialId, credentialId), activeFilter(now)),
);
}
/** Grants a role holds, with the credential owner - for new-member snapshots. */
async listRoleGrants(
roleId: number,
): Promise<
Array<{ accessId: number; credentialId: number; ownerId: string }>
> {
return this.context.drizzle
.select({
accessId: credentialAccess.id,
credentialId: credentialAccess.credentialId,
ownerId: sshCredentials.userId,
})
.from(credentialAccess)
.innerJoin(
sshCredentials,
eq(credentialAccess.credentialId, sshCredentials.id),
)
.where(eq(credentialAccess.roleId, roleId));
}
async deleteForUserReferences(userId: string): Promise<void> {
await this.context.drizzle
.delete(credentialAccess)
.where(
or(
eq(credentialAccess.userId, userId),
eq(credentialAccess.grantedBy, userId),
),
);
await this.afterWrite();
}
async reassignGrantedBy(fromUserId: string, toUserId: string): Promise<void> {
await this.context.drizzle
.update(credentialAccess)
.set({ grantedBy: toUserId })
.where(eq(credentialAccess.grantedBy, fromUserId));
await this.afterWrite();
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -348,6 +348,54 @@ export class CredentialRepository {
);
}
/**
* 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,
@@ -40,6 +40,8 @@ import { SessionRepository } from "./session-repository.js";
import { SessionShareRepository } from "./session-share-repository.js";
import { CollabRoomRepository } from "./collab-room-repository.js";
import { SecretSourceRepository } from "./secret-source-repository.js";
import { CredentialAccessRepository } from "./credential-access-repository.js";
import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js";
import { SettingsRepository } from "./settings-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
@@ -508,6 +510,22 @@ export function createCurrentUserRepository(): UserRepository {
);
}
export function createCurrentCredentialAccessRepository(): CredentialAccessRepository {
return new CredentialAccessRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("credential_access_repository_write"),
);
}
export function createCurrentSharedCredentialSecretsRepository(): SharedCredentialSecretsRepository {
return new SharedCredentialSecretsRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"shared_credential_secrets_repository_write",
),
);
}
export function createCurrentSecretSourceRepository(): SecretSourceRepository {
return new SecretSourceRepository(
createCurrentRepositoryContext(),
@@ -168,6 +168,47 @@ export class HostRepository {
return rows[0] ?? null;
}
/** Re-keys every host of one user to another; see CredentialRepository.transferAllToUser. */
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(hosts)
.where(eq(hosts.userId, fromUserId));
const moved: number[] = [];
for (const row of rows) {
const plain = DataCrypto.decryptRecord(
"ssh_data",
row,
fromUserId,
fromKey,
);
const {
id: _id,
userId: _userId,
...fields
} = plain as Record<string, unknown>;
const encrypted = DataCrypto.encryptRecord(
"ssh_data",
{ ...fields, id: row.id },
toUserId,
toKey,
) as Record<string, unknown>;
delete encrypted.id;
await this.context.drizzle
.update(hosts)
.set({ ...(encrypted as Partial<HostUpdate>), userId: toUserId })
.where(eq(hosts.id, row.id));
moved.push(row.id);
}
if (moved.length) await this.afterWrite();
return moved;
}
async updateEncryptedForUser(
userId: string,
hostId: number,
@@ -207,6 +207,18 @@ export class RbacAccessRepository {
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)
@@ -0,0 +1,90 @@
import { and, eq, inArray } from "drizzle-orm";
import { credentialAccess, sharedCredentialSecrets } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
export type SharedCredentialSecretRecord =
typeof sharedCredentialSecrets.$inferSelect;
export type NewSharedCredentialSecretRecord =
typeof sharedCredentialSecrets.$inferInsert;
export class SharedCredentialSecretsRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async upsert(record: NewSharedCredentialSecretRecord): Promise<void> {
const existing = await this.context.drizzle
.select({ id: sharedCredentialSecrets.id })
.from(sharedCredentialSecrets)
.where(
and(
eq(
sharedCredentialSecrets.credentialAccessId,
record.credentialAccessId,
),
eq(sharedCredentialSecrets.targetUserId, record.targetUserId),
),
)
.limit(1);
if (existing[0]) {
await this.context.drizzle
.update(sharedCredentialSecrets)
.set({ ...record, updatedAt: new Date().toISOString() })
.where(eq(sharedCredentialSecrets.id, existing[0].id));
} else {
await this.context.drizzle.insert(sharedCredentialSecrets).values(record);
}
await this.afterWrite();
}
/** The recipient's snapshot for a credential, whichever grant produced it. */
async findForCredentialUser(
credentialId: number,
targetUserId: string,
): Promise<SharedCredentialSecretRecord | null> {
const rows = await this.context.drizzle
.select()
.from(sharedCredentialSecrets)
.where(
and(
eq(sharedCredentialSecrets.credentialId, credentialId),
eq(sharedCredentialSecrets.targetUserId, targetUserId),
),
)
.limit(1);
return rows[0] ?? null;
}
async deleteForRoleMember(
roleId: number,
targetUserId: string,
): Promise<void> {
const grants = await this.context.drizzle
.select({ id: credentialAccess.id })
.from(credentialAccess)
.where(eq(credentialAccess.roleId, roleId));
if (grants.length === 0) return;
await this.context.drizzle.delete(sharedCredentialSecrets).where(
and(
inArray(
sharedCredentialSecrets.credentialAccessId,
grants.map((g) => g.id),
),
eq(sharedCredentialSecrets.targetUserId, targetUserId),
),
);
await this.afterWrite();
}
async deleteByTargetUserId(userId: string): Promise<void> {
await this.context.drizzle
.delete(sharedCredentialSecrets)
.where(eq(sharedCredentialSecrets.targetUserId, userId));
await this.afterWrite();
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
+109 -14
View File
@@ -15,6 +15,9 @@ import {
} from "../../utils/audit-logger.js";
import {
createCurrentCredentialRepository,
createCurrentUserRepository,
createCurrentCredentialAccessRepository,
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentSyncTombstoneRepository,
@@ -266,8 +269,11 @@ router.get(
try {
const credentials =
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
const own = credentials.map((cred) => formatCredentialOutput(cred));
res.json(credentials.map((cred) => formatCredentialOutput(cred)));
// Credentials shared with this user, read from their own snapshots.
const shared = await listSharedCredentialsForUser(userId);
res.json([...own, ...shared]);
} catch (err) {
authLogger.error("Failed to fetch credentials", err);
res.status(500).json({ error: "Failed to fetch credentials" });
@@ -362,11 +368,14 @@ router.get(
}
try {
const credentialRepository = createCurrentCredentialRepository();
const ownCredential = await credentialRepository.findDecryptedByIdForUser(
userId,
parseInt(id),
);
const credential =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
parseInt(id),
);
ownCredential ??
(await findSharedCredentialForUser(parseInt(id), userId));
if (!credential) {
return res.status(404).json({ error: "Credential not found" });
@@ -617,13 +626,21 @@ router.put(
});
try {
const existingCredential =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
credentialId,
);
// A recipient holding "manage" edits the owner's row on the owner's
// behalf; the row stays encrypted under the owner's key and every
// recipient's snapshot is rebuilt below.
const editableOwnerId = await resolveEditableCredentialOwner(
credentialId,
userId,
);
const existingCredential = editableOwnerId
? await createCurrentCredentialRepository().findDecryptedByIdForUser(
editableOwnerId,
credentialId,
)
: null;
if (!existingCredential) {
if (!existingCredential || !editableOwnerId) {
return res.status(404).json({ error: "Credential not found" });
}
@@ -686,14 +703,14 @@ router.put(
const credentialRepository = createCurrentCredentialRepository();
const updated = await credentialRepository.updateEncryptedForUser(
userId,
editableOwnerId,
credentialId,
updateFields,
);
const updatedCredential =
updated ??
(await credentialRepository.findDecryptedByIdForUser(
userId,
editableOwnerId,
credentialId,
));
@@ -701,7 +718,13 @@ router.put(
await import("../../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().resyncHostsForCredential(
credentialId,
userId,
editableOwnerId,
);
const { SharedCredentialSecretsManager } =
await import("../../utils/shared-credential-secrets-manager.js");
await SharedCredentialSecretsManager.getInstance().resyncCredential(
credentialId,
editableOwnerId,
);
authLogger.success("SSH credential updated", {
@@ -1002,6 +1025,78 @@ router.get(
},
);
/** The owner id if the caller may edit this credential (owner or "manage"), else null. */
async function resolveEditableCredentialOwner(
credentialId: number,
userId: string,
): Promise<string | null> {
const row = await createCurrentCredentialRepository().findById(credentialId);
if (!row) return null;
if (row.userId === userId) return userId;
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const grant = await createCurrentCredentialAccessRepository().findActiveGrant(
credentialId,
userId,
roleIds,
);
return grant?.permissionLevel === "manage" ? row.userId : null;
}
async function findSharedCredentialForUser(
credentialId: number,
userId: string,
): Promise<Record<string, unknown> | null> {
const shared = (await listSharedCredentialsForUser(userId)).find(
(cred) => cred.id === credentialId,
);
return shared ?? null;
}
/** Shared credentials shaped like the caller's own, plus who shared them. */
async function listSharedCredentialsForUser(
userId: string,
): Promise<Record<string, unknown>[]> {
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const grants =
await createCurrentCredentialAccessRepository().listSharedWithUser(
userId,
roleIds,
);
if (grants.length === 0) return [];
const credentialRepository = createCurrentCredentialRepository();
const userRepository = createCurrentUserRepository();
const { findUsableCredential } =
await import("../../hosts/usable-credential.js");
const results: Record<string, unknown>[] = [];
for (const grant of grants) {
const row = await credentialRepository.findById(grant.credentialId);
if (!row) continue;
let secrets: Record<string, unknown> | null = null;
try {
secrets = (await findUsableCredential(
grant.credentialId,
userId,
)) as Record<string, unknown> | null;
} catch {
secrets = null;
}
const owner = await userRepository.findById(grant.ownerId);
results.push({
...formatCredentialOutput({
...row,
username: secrets?.username ?? row.username,
publicKey: secrets?.publicKey ?? null,
certPublicKey: secrets?.certPublicKey ?? null,
}),
isShared: true,
ownerUsername: owner?.username ?? null,
permissionLevel: grant.permissionLevel,
sharedExpiresAt: grant.expiresAt,
});
}
return results;
}
function formatCredentialOutput(
credential: Record<string, unknown>,
): Record<string, unknown> {
@@ -40,14 +40,33 @@ import {
createCurrentTransferRecentRepository,
createCurrentVaultProfileRepository,
createCurrentSecretSourceRepository,
createCurrentSharedCredentialSecretsRepository,
createCurrentCredentialAccessRepository,
createCurrentVaultTokenRepository,
} from "../repositories/factory.js";
export async function deleteUserAndRelatedData(userId: string): Promise<void> {
export async function deleteUserAndRelatedData(
userId: string,
options: { successorUserId?: string } = {},
): Promise<void> {
try {
// With a successor, hosts and credentials (and the shares on them)
// change owner instead of disappearing with the account.
if (options.successorUserId) {
const { transferOwnership } =
await import("../../utils/transfer-ownership.js");
await transferOwnership(userId, options.successorUserId);
}
await createCurrentSharedHostSecretsRepository().deleteByTargetUserId(
userId,
);
await createCurrentSharedCredentialSecretsRepository().deleteByTargetUserId(
userId,
);
await createCurrentCredentialAccessRepository().deleteForUserReferences(
userId,
);
// Retained rather than deleted: these outlive the account by design.
// See anonymizeByUserId on each repository.
+3 -9
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { findUsableCredential } from "../../hosts/usable-credential.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import axios from "axios";
@@ -716,11 +717,7 @@ router.post(
let resolvedUsername = username;
if (authType === "credential" && credentialId) {
const cred =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
Number(credentialId),
userId,
);
const cred = await findUsableCredential(Number(credentialId), userId);
if (!cred) {
return res.status(404).json({ error: "Credential not found" });
@@ -2693,10 +2690,7 @@ async function resolveHostCredentials(
const credential =
preloadedCredentials?.get(credentialId) ??
(await createCurrentHostResolutionRepository().findCredentialByIdForUser(
credentialId,
credentialOwnerId,
));
(await findUsableCredential(credentialId, credentialOwnerId));
if (credential) {
const resolvedHost: Record<string, unknown> = {
+252 -1
View File
@@ -3,7 +3,11 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { getRequestMeta } from "../../utils/audit-logger.js";
import {
getRequestMeta,
getAuditUsername,
logAudit,
} from "../../utils/audit-logger.js";
import { isAuthOverrideProtocol } from "../../../types/auth-protocols.js";
import {
SharedHostAuthOverrideService,
@@ -20,6 +24,8 @@ import {
} from "../../utils/permission-catalog.js";
import {
createCurrentHostFolderRepository,
createCurrentCredentialRepository,
createCurrentCredentialAccessRepository,
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
@@ -1362,6 +1368,12 @@ router.post(
roleId,
targetUserId,
);
const { SharedCredentialSecretsManager } =
await import("../../utils/shared-credential-secrets-manager.js");
await SharedCredentialSecretsManager.getInstance().snapshotForRoleMember(
roleId,
targetUserId,
);
} catch (error) {
databaseLogger.error(
"Failed to snapshot shared host secrets for new role member",
@@ -1471,6 +1483,12 @@ router.delete(
roleId,
targetUserId,
);
const { createCurrentSharedCredentialSecretsRepository } =
await import("../repositories/factory.js");
await createCurrentSharedCredentialSecretsRepository().deleteForRoleMember(
roleId,
targetUserId,
);
} catch (cleanupError) {
databaseLogger.warn(
"Failed to clean shared host secrets after role removal",
@@ -1559,6 +1577,239 @@ router.get(
},
);
// CREDENTIAL SHARING
const CREDENTIAL_LEVELS = ["use", "manage"] as const;
type CredentialLevel = (typeof CREDENTIAL_LEVELS)[number];
/** Owner, or a recipient holding "manage". */
async function canManageCredentialSharing(
userId: string,
credentialId: number,
): Promise<{ allowed: boolean; ownerId: string | null }> {
const row = await createCurrentCredentialRepository().findById(credentialId);
if (!row) return { allowed: false, ownerId: null };
if (row.userId === userId) return { allowed: true, ownerId: row.userId };
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const grant = await createCurrentCredentialAccessRepository().findActiveGrant(
credentialId,
userId,
roleIds,
);
return { allowed: grant?.permissionLevel === "manage", ownerId: row.userId };
}
/**
* @openapi
* /rbac/credential/{id}/share:
* post:
* summary: Share a credential with users or roles
* description: Recipients get a copy of the secrets re-encrypted under their own key. "use" lets them attach it to hosts and connect; "manage" also lets them edit and re-share. Owner or a "manage" recipient only.
* tags:
* - RBAC
*/
router.post(
"/credential/:id/share",
authenticateJWT,
permissionManager.requirePermission("credentials.share"),
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const credentialId = parseInt(id, 10);
const userId = req.userId!;
if (isNaN(credentialId)) {
return res.status(400).json({ error: "Invalid credential ID" });
}
const targets = parseShareTargets(req.body ?? {});
if (!targets) {
return res.status(400).json({
error:
"targets must be a non-empty array of { type: 'user'|'role', id } entries",
});
}
const { durationHours, permissionLevel = "use" } = req.body ?? {};
if (!CREDENTIAL_LEVELS.includes(permissionLevel)) {
return res.status(400).json({
error: "Invalid permission level",
validLevels: CREDENTIAL_LEVELS,
});
}
try {
const sharing = await canManageCredentialSharing(userId, credentialId);
if (!sharing.allowed || !sharing.ownerId) {
return res
.status(403)
.json({ error: "Not allowed to share this credential" });
}
const ownerId = sharing.ownerId;
const userRepository = createCurrentUserRepository();
const roleRepository = createCurrentRoleRepository();
for (const target of targets) {
const found =
target.type === "user"
? await userRepository.findById(target.id as string)
: await roleRepository.findRoleById(target.id as number);
if (!found) {
return res.status(404).json({
error: `Target ${target.type} not found`,
targetId: target.id,
});
}
}
const expiresAt = expiryFromDuration(durationHours);
const accessRepository = createCurrentCredentialAccessRepository();
const { SharedCredentialSecretsManager } =
await import("../../utils/shared-credential-secrets-manager.js");
const manager = SharedCredentialSecretsManager.getInstance();
for (const target of targets) {
if (target.type === "user" && target.id === ownerId) continue;
const grant = await accessRepository.upsert({
credentialId,
grantedBy: userId,
permissionLevel: permissionLevel as CredentialLevel,
expiresAt,
target:
target.type === "user"
? { targetType: "user", targetUserId: target.id as string }
: { targetType: "role", targetRoleId: target.id as number },
});
try {
if (target.type === "user") {
await manager.snapshotForUser(
grant.id,
credentialId,
target.id as string,
ownerId,
);
} else {
await manager.snapshotForRole(
grant.id,
credentialId,
target.id as number,
ownerId,
);
}
} catch (snapshotError) {
databaseLogger.warn("Credential shared but secret snapshot failed", {
operation: "rbac_credential_share_snapshot_failed",
credentialId,
accessId: grant.id,
error: getErrorMessage(snapshotError),
});
}
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "credential_share",
resourceType: "credential",
resourceId: String(credentialId),
details: JSON.stringify({ targets, permissionLevel, expiresAt }),
ipAddress,
userAgent,
success: true,
});
res.json({ success: true, permissionLevel, expiresAt });
} catch (error) {
databaseLogger.error("Failed to share credential", error, {
operation: "share_credential",
credentialId,
userId,
});
res.status(500).json({ error: "Failed to share credential" });
}
},
);
/**
* @openapi
* /rbac/credential/{id}/access:
* get:
* summary: List who a credential is shared with
* tags:
* - RBAC
*/
router.get(
"/credential/:id/access",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const credentialId = parseInt(id, 10);
if (isNaN(credentialId)) {
return res.status(400).json({ error: "Invalid credential ID" });
}
try {
const sharing = await canManageCredentialSharing(
req.userId!,
credentialId,
);
if (!sharing.allowed) {
return res.status(403).json({ error: "Not allowed" });
}
res.json({
access:
await createCurrentCredentialAccessRepository().listForCredential(
credentialId,
),
});
} catch (error) {
databaseLogger.error("Failed to list credential access", error, {
operation: "list_credential_access",
credentialId,
});
res.status(500).json({ error: "Failed to list credential access" });
}
},
);
/**
* @openapi
* /rbac/credential/{id}/access/{accessId}:
* delete:
* summary: Revoke a credential share (recipients' copies are removed with it)
* tags:
* - RBAC
*/
router.delete(
"/credential/:id/access/:accessId",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const credentialId = parseInt(String(req.params.id), 10);
const accessId = parseInt(String(req.params.accessId), 10);
if (isNaN(credentialId) || isNaN(accessId)) {
return res.status(400).json({ error: "Invalid ID" });
}
try {
const sharing = await canManageCredentialSharing(
req.userId!,
credentialId,
);
if (!sharing.allowed) {
return res.status(403).json({ error: "Not allowed" });
}
const repository = createCurrentCredentialAccessRepository();
if (!(await repository.findById(accessId, credentialId))) {
return res.status(404).json({ error: "Access grant not found" });
}
await repository.revoke(accessId, credentialId);
res.json({ success: true });
} catch (error) {
databaseLogger.error("Failed to revoke credential access", error, {
operation: "revoke_credential_access",
credentialId,
accessId,
});
res.status(500).json({ error: "Failed to revoke credential access" });
}
},
);
// SNIPPET SHARING
/**
+20 -1
View File
@@ -92,6 +92,11 @@ async function syncSharedCredentialsForUserRoles(
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId);
const { SharedCredentialSecretsManager } =
await import("../../utils/shared-credential-secrets-manager.js");
await SharedCredentialSecretsManager.getInstance().snapshotForUserRoles(
userId,
);
} catch (error) {
authLogger.warn("Failed to sync role shared host secrets", {
operation,
@@ -2980,7 +2985,21 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
const targetUserId = targetUser.id;
await deleteUserAndRelatedData(targetUserId);
// Inherit rather than drop: the deleting admin takes over the hosts and
// credentials unless another successor is named; "none" discards them.
const { successorUserId: requestedSuccessor } = req.body ?? {};
let successorUserId: string | undefined = userId;
if (requestedSuccessor === "none") {
successorUserId = undefined;
} else if (isNonEmptyString(requestedSuccessor)) {
const successor = await userRepository.findById(requestedSuccessor);
if (!successor || successor.id === targetUserId) {
return res.status(400).json({ error: "Invalid successor user" });
}
successorUserId = successor.id;
}
await deleteUserAndRelatedData(targetUserId, { successorUserId });
authLogger.warn("User account deleted by admin", {
operation: "admin_delete_user",
+2 -1
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../utils/error-message.js";
import { findUsableCredential } from "./usable-credential.js";
import { resolveExternalSecretRefs } from "./external-secrets.js";
import {
createCurrentHostResolutionRepository,
@@ -190,7 +191,7 @@ export async function resolveHostById(
if (effectiveCredentialId) {
try {
const cred = (await repository.findCredentialByIdForUser(
const cred = (await findUsableCredential(
effectiveCredentialId,
ownerId,
)) as Record<string, unknown> | null;
+50
View File
@@ -0,0 +1,50 @@
import {
createCurrentCredentialAccessRepository,
createCurrentCredentialRepository,
createCurrentHostResolutionRepository,
createCurrentRoleRepository,
} from "../database/repositories/factory.js";
import type { HostResolutionCredentialRecord } from "../database/repositories/host-resolution-repository.js";
import {
SharedCredentialSecretsManager,
snapshotAsCredentialRecord,
} from "../utils/shared-credential-secrets-manager.js";
/**
* A credential the user may use: their own, or one shared with them (read
* from their re-encrypted snapshot). This is the one gate that replaces
* "credentials are a private namespace" - every place that turned a
* credentialId into secrets goes through here.
*/
export async function findUsableCredential(
credentialId: number,
userId: string,
): Promise<HostResolutionCredentialRecord | null> {
const own =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
credentialId,
userId,
);
if (own) return own;
const row = await createCurrentCredentialRepository().findById(credentialId);
if (!row || row.userId === userId) return null;
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const grant = await createCurrentCredentialAccessRepository().findActiveGrant(
credentialId,
userId,
roleIds,
);
if (!grant) return null;
const manager = SharedCredentialSecretsManager.getInstance();
let data = await manager.getSecretForUser(credentialId, userId);
if (!data) {
// No snapshot yet (granted while this user's key was unavailable).
await manager.snapshotForUser(grant.id, credentialId, userId, row.userId);
data = await manager.getSecretForUser(credentialId, userId);
}
return data
? snapshotAsCredentialRecord(credentialId, row.userId, data)
: null;
}
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
own: new Map<string, Record<string, unknown>>(), // `${userId}:${id}`
rows: new Map<number, { id: number; userId: string }>(),
grants: new Map<string, { id: number; permissionLevel: string }>(), // `${id}:${userId}`
snapshots: new Map<string, Record<string, unknown>>(), // `${id}:${userId}`
snapshotCalls: [] as unknown[][],
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findCredentialByIdForUser: async (id: number, userId: string) =>
state.own.get(`${userId}:${id}`) ?? null,
}),
createCurrentCredentialRepository: () => ({
findById: async (id: number) => state.rows.get(id) ?? null,
}),
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
createCurrentCredentialAccessRepository: () => ({
findActiveGrant: async (id: number, userId: string) =>
state.grants.get(`${id}:${userId}`) ?? null,
}),
}));
vi.mock("../../utils/shared-credential-secrets-manager.js", async () => {
const actual = await vi.importActual<
typeof import("../../utils/shared-credential-secrets-manager.js")
>("../../utils/shared-credential-secrets-manager.js");
return {
...actual,
SharedCredentialSecretsManager: {
getInstance: () => ({
getSecretForUser: async (id: number, userId: string) =>
state.snapshots.get(`${id}:${userId}`) ?? null,
snapshotForUser: async (...args: unknown[]) => {
state.snapshotCalls.push(args);
const [, id, userId] = args as [number, number, string];
state.snapshots.set(`${id}:${userId}`, {
authType: "password",
username: "svc",
password: "pw",
});
},
}),
},
};
});
const { findUsableCredential } =
await import("../../hosts/usable-credential.js");
describe("findUsableCredential", () => {
beforeEach(() => {
state.own.clear();
state.rows.clear();
state.grants.clear();
state.snapshots.clear();
state.snapshotCalls.length = 0;
});
it("returns the user's own credential first", async () => {
state.own.set("alice:1", { id: 1, userId: "alice", password: "mine" });
expect(await findUsableCredential(1, "alice")).toMatchObject({
password: "mine",
});
});
it("returns a shared credential from the recipient's snapshot, shaped like a row", async () => {
state.rows.set(2, { id: 2, userId: "owner" });
state.grants.set("2:bob", { id: 10, permissionLevel: "use" });
state.snapshots.set("2:bob", {
authType: "key",
username: "deploy",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----",
keyType: "ssh-ed25519",
});
const cred = await findUsableCredential(2, "bob");
expect(cred).toMatchObject({
id: 2,
userId: "owner",
username: "deploy",
authType: "key",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----",
key: null,
});
});
it("builds the snapshot on demand when a grant exists but no copy yet", async () => {
state.rows.set(3, { id: 3, userId: "owner" });
state.grants.set("3:bob", { id: 11, permissionLevel: "use" });
const cred = await findUsableCredential(3, "bob");
expect(state.snapshotCalls).toEqual([[11, 3, "bob", "owner"]]);
expect(cred).toMatchObject({ username: "svc", password: "pw" });
});
it("refuses credentials that are neither owned nor shared", async () => {
state.rows.set(4, { id: 4, userId: "owner" });
expect(await findUsableCredential(4, "mallory")).toBeNull();
expect(await findUsableCredential(99, "mallory")).toBeNull();
});
});
+1
View File
@@ -44,6 +44,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
"credentials.create",
"credentials.edit",
"credentials.delete",
"credentials.share",
],
},
{
@@ -0,0 +1,218 @@
import { DataCrypto } from "./data-crypto.js";
import { FieldCrypto } from "./field-crypto.js";
import { databaseLogger } from "./logger.js";
import {
createCurrentCredentialAccessRepository,
createCurrentHostResolutionRepository,
createCurrentRoleRepository,
createCurrentSharedCredentialSecretsRepository,
} from "../database/repositories/factory.js";
import type { HostResolutionCredentialRecord } from "../database/repositories/host-resolution-repository.js";
/**
* Keeps recipients' re-encrypted copies of shared credentials in step with
* the owner's row. Mirrors SharedHostSecretsManager: snapshots are taken when
* a share is created, when someone joins a role that holds one, on login as a
* backstop, and rebuilt whenever the owner edits the credential.
*/
function recordId(accessId: number, targetUserId: string): string {
return `shared-credential-${accessId}-${targetUserId}`;
}
export interface SharedCredentialData {
username?: string;
authType: string;
password?: string;
privateKey?: string;
keyPassword?: string;
keyType?: string;
publicKey?: string;
certPublicKey?: string;
}
class SharedCredentialSecretsManager {
private static instance: SharedCredentialSecretsManager;
static getInstance(): SharedCredentialSecretsManager {
if (!this.instance) this.instance = new SharedCredentialSecretsManager();
return this.instance;
}
async snapshotForUser(
accessId: number,
credentialId: number,
targetUserId: string,
ownerId: string,
): Promise<void> {
if (targetUserId === ownerId) return;
const targetDEK = DataCrypto.validateUserAccess(targetUserId);
DataCrypto.validateUserAccess(ownerId);
const credential =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
credentialId,
ownerId,
);
if (!credential) throw new Error(`Credential ${credentialId} not found`);
const id = recordId(accessId, targetUserId);
const encrypt = (value: string | null | undefined, field: string) =>
value ? FieldCrypto.encryptField(value, targetDEK, id, field) : null;
await createCurrentSharedCredentialSecretsRepository().upsert({
credentialAccessId: accessId,
targetUserId,
credentialId,
encryptedUsername: encrypt(credential.username, "username"),
authType: credential.authType,
encryptedPassword: encrypt(credential.password, "password"),
encryptedKey: encrypt(credential.privateKey || credential.key, "key"),
encryptedKeyPassword: encrypt(credential.keyPassword, "key_password"),
keyType: credential.keyType ?? null,
publicKey: credential.publicKey ?? null,
certPublicKey: credential.certPublicKey ?? null,
});
}
async snapshotForRole(
accessId: number,
credentialId: number,
roleId: number,
ownerId: string,
): Promise<void> {
const members = await createCurrentRoleRepository().listRoleUserIds(roleId);
for (const memberId of members) {
try {
await this.snapshotForUser(accessId, credentialId, memberId, ownerId);
} catch (error) {
databaseLogger.warn(
"Failed to snapshot shared credential for role member",
{
operation: "shared_credential_snapshot_role_member",
accessId,
memberId,
error,
},
);
}
}
}
/** A user just joined a role: give them every credential the role holds. */
async snapshotForRoleMember(
roleId: number,
targetUserId: string,
): Promise<void> {
const grants =
await createCurrentCredentialAccessRepository().listRoleGrants(roleId);
for (const grant of grants) {
try {
await this.snapshotForUser(
grant.accessId,
grant.credentialId,
targetUserId,
grant.ownerId,
);
} catch (error) {
databaseLogger.warn(
"Failed to snapshot shared credential for new role member",
{
operation: "shared_credential_snapshot_new_member",
accessId: grant.accessId,
targetUserId,
error,
},
);
}
}
}
/** Login backstop: every role the user holds. */
async snapshotForUserRoles(userId: string): Promise<void> {
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
for (const roleId of roleIds) {
await this.snapshotForRoleMember(roleId, userId);
}
}
/** The owner changed the credential: rebuild every recipient's copy. */
async resyncCredential(credentialId: number, ownerId: string): Promise<void> {
const accessRepository = createCurrentCredentialAccessRepository();
const roleRepository = createCurrentRoleRepository();
for (const grant of await accessRepository.listActiveGrants(credentialId)) {
const targets = grant.userId
? [grant.userId]
: grant.roleId
? await roleRepository.listRoleUserIds(grant.roleId)
: [];
for (const targetUserId of targets) {
try {
await this.snapshotForUser(
grant.id,
credentialId,
targetUserId,
ownerId,
);
} catch (error) {
databaseLogger.warn("Failed to resync shared credential", {
operation: "shared_credential_resync",
credentialId,
targetUserId,
error,
});
}
}
}
}
/** The recipient's decrypted copy, if any. */
async getSecretForUser(
credentialId: number,
userId: string,
): Promise<SharedCredentialData | null> {
const secret =
await createCurrentSharedCredentialSecretsRepository().findForCredentialUser(
credentialId,
userId,
);
if (!secret) return null;
const userDEK = DataCrypto.getUserDataKey(userId);
if (!userDEK) return null;
const id = recordId(secret.credentialAccessId, secret.targetUserId);
const decrypt = (value: string | null, field: string) =>
value ? FieldCrypto.decryptField(value, userDEK, id, field) : undefined;
return {
username: decrypt(secret.encryptedUsername, "username"),
authType: secret.authType,
password: decrypt(secret.encryptedPassword, "password"),
privateKey: decrypt(secret.encryptedKey, "key"),
keyPassword: decrypt(secret.encryptedKeyPassword, "key_password"),
keyType: secret.keyType ?? undefined,
publicKey: secret.publicKey ?? undefined,
certPublicKey: secret.certPublicKey ?? undefined,
};
}
}
/** Shapes a recipient's snapshot like the owner's decrypted credential row. */
export function snapshotAsCredentialRecord(
credentialId: number,
ownerId: string,
data: SharedCredentialData,
): HostResolutionCredentialRecord {
return {
id: credentialId,
userId: ownerId,
username: data.username ?? null,
authType: data.authType,
password: data.password ?? null,
key: null,
privateKey: data.privateKey ?? null,
keyPassword: data.keyPassword ?? null,
keyType: data.keyType ?? null,
publicKey: data.publicKey ?? null,
certPublicKey: data.certPublicKey ?? null,
} as unknown as HostResolutionCredentialRecord;
}
export { SharedCredentialSecretsManager };
+164
View File
@@ -0,0 +1,164 @@
import { eq } from "drizzle-orm";
import {
credentialAccess,
hostAccess,
hosts,
sshCredentials,
} from "../database/db/schema.js";
import { needsExplicitPersist } from "../database/db/dialect.js";
import { createCurrentRepositoryContext } from "../database/repositories/factory.js";
import type { HostUpdate } from "../database/repositories/host-repository.js";
import type { NewCredentialRecord } from "../database/repositories/credential-repository.js";
import { DataCrypto } from "./data-crypto.js";
import { DatabaseSaveTrigger } from "./database-save-trigger.js";
import { databaseLogger } from "./logger.js";
import { SharedHostSecretsManager } from "./shared-host-secrets-manager.js";
import { SharedCredentialSecretsManager } from "./shared-credential-secrets-manager.js";
/**
* Hands one user's hosts and credentials to another before the account is
* deleted, so what they shared keeps working for everyone else: rows are
* re-encrypted under the successor's key, the grants they issued are now
* the successor's, and every recipient's snapshot is rebuilt from the new
* owner's copy.
*/
export async function transferOwnership(
fromUserId: string,
toUserId: string,
): Promise<{ hosts: number; credentials: number }> {
if (fromUserId === toUserId) {
throw new Error("Cannot transfer ownership to the same user");
}
const context = createCurrentRepositoryContext();
const fromKey = DataCrypto.validateUserAccess(fromUserId);
const toKey = DataCrypto.validateUserAccess(toUserId);
const credentialRows = await context.drizzle
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, fromUserId));
const hostRows = await context.drizzle
.select()
.from(hosts)
.where(eq(hosts.userId, fromUserId));
// Decrypt and re-encrypt everything before opening the write transaction.
// A corrupt row therefore fails without changing ownership of earlier rows.
const credentials = credentialRows.map((row) => {
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;
return { id: row.id, update: encrypted as Partial<NewCredentialRecord> };
});
const transferredHosts = hostRows.map((row) => {
const plain = DataCrypto.decryptRecord(
"ssh_data",
row,
fromUserId,
fromKey,
);
const {
id: _id,
userId: _userId,
...fields
} = plain as Record<string, unknown>;
const encrypted = DataCrypto.encryptRecord(
"ssh_data",
{ ...fields, id: row.id },
toUserId,
toKey,
) as Record<string, unknown>;
delete encrypted.id;
return { id: row.id, update: encrypted as HostUpdate };
});
const apply = (tx: typeof context.drizzle, sync: boolean) => {
const writes = [
...credentials.map(({ id, update }) =>
tx
.update(sshCredentials)
.set({ ...update, userId: toUserId })
.where(eq(sshCredentials.id, id)),
),
...transferredHosts.map(({ id, update }) =>
tx
.update(hosts)
.set({ ...update, userId: toUserId })
.where(eq(hosts.id, id)),
),
tx
.update(hostAccess)
.set({ grantedBy: toUserId })
.where(eq(hostAccess.grantedBy, fromUserId)),
tx
.update(credentialAccess)
.set({ grantedBy: toUserId })
.where(eq(credentialAccess.grantedBy, fromUserId)),
];
if (sync) {
for (const write of writes) write.run();
return undefined;
}
return Promise.all(writes);
};
if (context.dialect === "sqlite") {
context.drizzle.transaction((tx) => apply(tx, true));
} else {
await context.drizzle.transaction((tx) => apply(tx, false));
}
if (needsExplicitPersist(context.dialect)) {
await DatabaseSaveTrigger.forceSave("transfer_ownership");
}
const credentialIds = credentials.map(({ id }) => id);
const hostIds = transferredHosts.map(({ id }) => id);
const hostSecrets = SharedHostSecretsManager.getInstance();
for (const hostId of hostIds) {
try {
await hostSecrets.resyncHost(hostId);
} catch (error) {
databaseLogger.warn("Failed to resync host shares after transfer", {
operation: "transfer_ownership_host_resync",
hostId,
error,
});
}
}
const credentialSecrets = SharedCredentialSecretsManager.getInstance();
for (const credentialId of credentialIds) {
try {
await credentialSecrets.resyncCredential(credentialId, toUserId);
} catch (error) {
databaseLogger.warn("Failed to resync credential shares after transfer", {
operation: "transfer_ownership_credential_resync",
credentialId,
error,
});
}
}
databaseLogger.info("Ownership transferred", {
operation: "transfer_ownership",
fromUserId,
toUserId,
hosts: hostIds.length,
credentials: credentialIds.length,
});
return { hosts: hostIds.length, credentials: credentialIds.length };
}