fix: rbac implementation general issues (local squash)

This commit is contained in:
LukeGus
2025-12-27 03:04:17 -06:00
parent 4b257dc21c
commit 8af1911358
29 changed files with 2206 additions and 251 deletions

View File

@@ -590,6 +590,11 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
// System-encrypted fields for offline credential sharing
addColumnIfNotExists("ssh_credentials", "system_password", "TEXT");
addColumnIfNotExists("ssh_credentials", "system_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT");
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
@@ -842,6 +847,42 @@ const migrateSchema = () => {
}
}
// RBAC: Shared Credentials table
try {
sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS shared_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_access_id INTEGER NOT NULL,
original_credential_id INTEGER NOT NULL,
target_user_id TEXT NOT NULL,
encrypted_username TEXT NOT NULL,
encrypted_auth_type TEXT NOT NULL,
encrypted_password TEXT,
encrypted_key TEXT,
encrypted_key_password TEXT,
encrypted_key_type TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
needs_re_encryption INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE,
FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
databaseLogger.info("Created shared_credentials table", {
operation: "schema_migration",
});
} catch (createError) {
databaseLogger.warn("Failed to create shared_credentials table", {
operation: "schema_migration",
error: createError,
});
}
}
// Clean up old system roles and seed correct ones
try {
// First, check what roles exist

View File

@@ -185,6 +185,12 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
key_password: text("key_password"),
keyType: text("key_type"),
detectedKeyType: text("detected_key_type"),
// System-encrypted fields for offline credential sharing
systemPassword: text("system_password"),
systemKey: text("system_key", { length: 16384 }),
systemKeyPassword: text("system_key_password"),
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
createdAt: text("created_at")
@@ -307,10 +313,10 @@ export const hostAccess = sqliteTable("host_access", {
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Permission level
// Permission level (view-only)
permissionLevel: text("permission_level")
.notNull()
.default("use"), // "view" | "use" | "manage"
.default("view"), // Only "view" is supported
// Time-based access
expiresAt: text("expires_at"), // NULL = never expires
@@ -323,6 +329,47 @@ export const hostAccess = sqliteTable("host_access", {
accessCount: integer("access_count").notNull().default(0),
});
// RBAC: Shared Credentials (per-user encrypted credential copies)
export const sharedCredentials = sqliteTable("shared_credentials", {
id: integer("id").primaryKey({ autoIncrement: true }),
// Link to the host access grant (CASCADE delete when share revoked)
hostAccessId: integer("host_access_id")
.notNull()
.references(() => hostAccess.id, { onDelete: "cascade" }),
// Link to the original credential (for tracking updates/CASCADE delete)
originalCredentialId: integer("original_credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
// Target user (recipient of the share) - CASCADE delete when user deleted
targetUserId: text("target_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Encrypted credential data (encrypted with targetUserId's DEK)
encryptedUsername: text("encrypted_username").notNull(),
encryptedAuthType: text("encrypted_auth_type").notNull(),
encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key", { length: 16384 }),
encryptedKeyPassword: text("encrypted_key_password"),
encryptedKeyType: text("encrypted_key_type"),
// Metadata
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
// Track if needs re-encryption (when original credential updated but target user offline)
needsReEncryption: integer("needs_re_encryption", { mode: "boolean" })
.notNull()
.default(false),
});
// RBAC Phase 2: Roles
export const roles = sqliteTable("roles", {
id: integer("id").primaryKey({ autoIncrement: true }),

View File

@@ -4,7 +4,12 @@ import type {
} from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js";
import {
sshCredentials,
sshCredentialUsage,
sshData,
hostAccess,
} from "../db/schema.js";
import { eq, and, desc, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js";
@@ -473,6 +478,15 @@ router.put(
userId,
);
// Update shared credentials if this credential is shared
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.updateSharedCredentialsForOriginal(
parseInt(id),
userId,
);
const credential = updated[0];
authLogger.success(
`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`,
@@ -555,8 +569,36 @@ router.delete(
eq(sshData.userId, userId),
),
);
// Revoke all shares for hosts that used this credential
for (const host of hostsUsingCredential) {
const revokedShares = await db
.delete(hostAccess)
.where(eq(hostAccess.hostId, host.id))
.returning({ id: hostAccess.id });
if (revokedShares.length > 0) {
authLogger.info(
"Auto-revoked host shares due to credential deletion",
{
operation: "auto_revoke_shares",
hostId: host.id,
credentialId: parseInt(id),
revokedCount: revokedShares.length,
reason: "credential_deleted",
},
);
}
}
}
// Delete shared credentials for this original credential
// Note: This will also be handled by CASCADE, but we do it explicitly for logging
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id));
// sshCredentialUsage will be automatically deleted by ON DELETE CASCADE
// No need for manual deletion
@@ -1601,10 +1643,7 @@ router.post(
}
}
const deployResult = await deploySSHKeyToHost(
hostConfig,
credData,
);
const deployResult = await deploySSHKeyToHost(hostConfig, credData);
if (deployResult.success) {
res.json({

View File

@@ -8,6 +8,7 @@ import {
roles,
userRoles,
auditLogs,
sharedCredentials,
} from "../db/schema.js";
import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm";
import type { Request, Response } from "express";
@@ -47,7 +48,7 @@ router.post(
targetUserId,
targetRoleId,
durationHours,
permissionLevel = "use",
permissionLevel = "view", // Only "view" is supported
} = req.body;
// Validate target type
@@ -129,11 +130,11 @@ router.post(
expiresAt = expiryDate.toISOString();
}
// Validate permission level
const validLevels = ["view", "use", "manage"];
// Validate permission level (only "view" is supported)
const validLevels = ["view"];
if (!validLevels.includes(permissionLevel)) {
return res.status(400).json({
error: "Invalid permission level",
error: "Invalid permission level. Only 'view' is supported.",
validLevels,
});
}
@@ -162,6 +163,30 @@ router.post(
})
.where(eq(hostAccess.id, existing[0].id));
// Re-create shared credential (delete old, create new)
await db
.delete(sharedCredentials)
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
if (targetType === "user") {
await sharedCredManager.createSharedCredentialForUser(
existing[0].id,
host[0].credentialId,
targetUserId!,
userId,
);
} else {
await sharedCredManager.createSharedCredentialsForRole(
existing[0].id,
host[0].credentialId,
targetRoleId!,
userId,
);
}
databaseLogger.info("Updated existing host access", {
operation: "share_host",
hostId,
@@ -189,6 +214,27 @@ router.post(
expiresAt,
});
// Create shared credential for the target
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
if (targetType === "user") {
await sharedCredManager.createSharedCredentialForUser(
result.lastInsertRowid as number,
host[0].credentialId,
targetUserId!,
userId,
);
} else {
await sharedCredManager.createSharedCredentialsForRole(
result.lastInsertRowid as number,
host[0].credentialId,
targetRoleId!,
userId,
);
}
databaseLogger.info("Created host access", {
operation: "share_host",
hostId,
@@ -308,8 +354,6 @@ router.get(
permissionLevel: hostAccess.permissionLevel,
expiresAt: hostAccess.expiresAt,
createdAt: hostAccess.createdAt,
lastAccessedAt: hostAccess.lastAccessedAt,
accessCount: hostAccess.accessCount,
})
.from(hostAccess)
.leftJoin(users, eq(hostAccess.userId, users.id))
@@ -331,8 +375,6 @@ router.get(
permissionLevel: access.permissionLevel,
expiresAt: access.expiresAt,
createdAt: access.createdAt,
lastAccessedAt: access.lastAccessedAt,
accessCount: access.accessCount,
}));
res.json({ accessList });
@@ -651,31 +693,24 @@ router.delete(
});
}
// Check if role is in use
const usageCount = await db
.select({ count: sql<number>`count(*)` })
.from(userRoles)
.where(eq(userRoles.roleId, roleId));
// Delete user-role assignments first
const deletedUserRoles = await db
.delete(userRoles)
.where(eq(userRoles.roleId, roleId))
.returning({ userId: userRoles.userId });
if (usageCount[0].count > 0) {
return res.status(409).json({
error: `Cannot delete role: ${usageCount[0].count} user(s) are assigned to this role`,
usageCount: usageCount[0].count,
});
// Invalidate permission cache for affected users
for (const { userId } of deletedUserRoles) {
permissionManager.invalidateUserPermissionCache(userId);
}
// Check if role is used in host_access
const hostAccessCount = await db
.select({ count: sql<number>`count(*)` })
.from(hostAccess)
.where(eq(hostAccess.roleId, roleId));
// Delete host_access entries for this role
const deletedHostAccess = await db
.delete(hostAccess)
.where(eq(hostAccess.roleId, roleId))
.returning({ id: hostAccess.id });
if (hostAccessCount[0].count > 0) {
return res.status(409).json({
error: `Cannot delete role: ${hostAccessCount[0].count} host(s) are shared with this role`,
hostAccessCount: hostAccessCount[0].count,
});
}
// Note: sharedCredentials will be auto-deleted by CASCADE
// Delete role
await db.delete(roles).where(eq(roles.id, roleId));
@@ -773,6 +808,51 @@ router.post(
grantedBy: currentUserId,
});
// Create shared credentials for all hosts shared with this role
const hostsSharedWithRole = await db
.select()
.from(hostAccess)
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
.where(eq(hostAccess.roleId, roleId));
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
for (const { host_access, ssh_data } of hostsSharedWithRole) {
if (ssh_data.credentialId) {
try {
await sharedCredManager.createSharedCredentialForUser(
host_access.id,
ssh_data.credentialId,
targetUserId,
ssh_data.userId,
);
} catch (error) {
databaseLogger.error(
"Failed to create shared credential for new role member",
error,
{
operation: "assign_role_create_credentials",
targetUserId,
roleId,
hostId: ssh_data.id,
},
);
// Continue with other hosts even if one fails
}
}
}
if (hostsSharedWithRole.length > 0) {
databaseLogger.info("Created shared credentials for new role member", {
operation: "assign_role_create_credentials",
targetUserId,
roleId,
hostCount: hostsSharedWithRole.length,
});
}
// Invalidate permission cache
permissionManager.invalidateUserPermissionCache(targetUserId);

View File

@@ -391,7 +391,8 @@ router.post(
: undefined,
};
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
const resolvedHost =
(await resolveHostCredentials(baseHost, userId)) || baseHost;
sshLogger.success(
`SSH host created: ${name} (${ip}:${port}) by user ${userId}`,
@@ -619,9 +620,25 @@ router.put(
return res.status(403).json({ error: "Access denied" });
}
// Shared users cannot edit hosts (view-only)
if (!accessInfo.isOwner) {
sshLogger.warn("Shared user attempted to update host (view-only)", {
operation: "host_update",
hostId: parseInt(hostId),
userId,
});
return res.status(403).json({
error: "Only the host owner can modify host configuration",
});
}
// Get the actual owner ID for the update
const hostRecord = await db
.select({ userId: sshData.userId })
.select({
userId: sshData.userId,
credentialId: sshData.credentialId,
authType: sshData.authType,
})
.from(sshData)
.where(eq(sshData.id, Number(hostId)))
.limit(1);
@@ -637,6 +654,56 @@ router.put(
const ownerId = hostRecord[0].userId;
// Only owner can change credentialId
if (
!accessInfo.isOwner &&
sshDataObj.credentialId !== undefined &&
sshDataObj.credentialId !== hostRecord[0].credentialId
) {
return res.status(403).json({
error: "Only the host owner can change the credential",
});
}
// Only owner can change authType
if (
!accessInfo.isOwner &&
sshDataObj.authType !== undefined &&
sshDataObj.authType !== hostRecord[0].authType
) {
return res.status(403).json({
error: "Only the host owner can change the authentication type",
});
}
// Check if credentialId is changing from non-null to null
// This happens when switching from "credential" auth to "password"/"key"/"none"
if (sshDataObj.credentialId !== undefined) {
if (
hostRecord[0].credentialId !== null &&
sshDataObj.credentialId === null
) {
// Auth type changed away from credential - revoke all shares
const revokedShares = await db
.delete(hostAccess)
.where(eq(hostAccess.hostId, Number(hostId)))
.returning({ id: hostAccess.id, userId: hostAccess.userId });
if (revokedShares.length > 0) {
sshLogger.info(
"Auto-revoked host shares due to auth type change from credential",
{
operation: "auto_revoke_shares",
hostId: Number(hostId),
revokedCount: revokedShares.length,
reason: "auth_type_changed_from_credential",
},
);
// Note: sharedCredentials will be auto-deleted by CASCADE
}
}
}
await SimpleDBOps.update(
sshData,
"ssh_data",
@@ -691,7 +758,8 @@ router.put(
: undefined,
};
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
const resolvedHost =
(await resolveHostCredentials(baseHost, userId)) || baseHost;
sshLogger.success(
`SSH host updated: ${name} (${ip}:${port}) by user ${userId}`,
@@ -854,12 +922,51 @@ router.get(
),
);
// Decrypt and format the data
const data = await SimpleDBOps.select(
Promise.resolve(rawData),
"ssh_data",
// Separate own hosts from shared hosts for proper decryption
const ownHosts = rawData.filter((row) => row.userId === userId);
const sharedHosts = rawData.filter((row) => row.userId !== userId);
// Decrypt own hosts with user's DEK
let decryptedOwnHosts: any[] = [];
try {
decryptedOwnHosts = await SimpleDBOps.select(
Promise.resolve(ownHosts),
"ssh_data",
userId,
);
sshLogger.debug("Own hosts decrypted successfully", {
operation: "host_fetch_own_decrypted",
userId,
count: decryptedOwnHosts.length,
});
} catch (decryptError) {
sshLogger.error("Failed to decrypt own hosts", decryptError, {
operation: "host_fetch_own_decrypt_failed",
userId,
});
// Return empty array if decryption fails
decryptedOwnHosts = [];
}
// For shared hosts, DON'T try to decrypt them with user's DEK
// Just pass them through as plain objects without encrypted credential fields
// The credentials will be resolved via SharedCredentialManager later when resolveHostCredentials is called
sshLogger.info("Processing shared hosts", {
operation: "host_fetch_shared_process",
userId,
);
count: sharedHosts.length,
});
const sanitizedSharedHosts = sharedHosts;
sshLogger.info("Combining hosts", {
operation: "host_fetch_combine",
userId,
ownCount: decryptedOwnHosts.length,
sharedCount: sanitizedSharedHosts.length,
});
const data = [...decryptedOwnHosts, ...sanitizedSharedHosts];
const result = await Promise.all(
data.map(async (row: Record<string, unknown>) => {
@@ -900,10 +1007,18 @@ router.get(
sharedExpiresAt: row.expiresAt || undefined,
};
return (await resolveHostCredentials(baseHost)) || baseHost;
const resolved =
(await resolveHostCredentials(baseHost, userId)) || baseHost;
return resolved;
}),
);
sshLogger.info("Credential resolution complete, sending response", {
operation: "host_fetch_complete",
userId,
hostCount: result.length,
});
res.json(result);
} catch (err) {
sshLogger.error("Failed to fetch SSH hosts from database", err, {
@@ -978,7 +1093,7 @@ router.get(
: [],
};
res.json((await resolveHostCredentials(result)) || result);
res.json((await resolveHostCredentials(result, userId)) || result);
} catch (err) {
sshLogger.error("Failed to fetch SSH host by ID from database", err, {
operation: "host_fetch_by_id",
@@ -1022,7 +1137,7 @@ router.get(
const host = hosts[0];
const resolvedHost = (await resolveHostCredentials(host)) || host;
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
const exportData = {
name: resolvedHost.name,
@@ -1644,12 +1759,68 @@ router.delete(
async function resolveHostCredentials(
host: Record<string, unknown>,
requestingUserId?: string,
): Promise<Record<string, unknown>> {
try {
sshLogger.info("Resolving credentials for host", {
operation: "resolve_credentials_start",
hostId: host.id as number,
hasCredentialId: !!host.credentialId,
requestingUserId,
ownerId: (host.ownerId || host.userId) as string,
});
if (host.credentialId && (host.userId || host.ownerId)) {
const credentialId = host.credentialId as number;
const ownerId = (host.ownerId || host.userId) as string;
// Check if this is a shared host access
if (requestingUserId && requestingUserId !== ownerId) {
// User is accessing a shared host - use shared credential
try {
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
host.id as number,
requestingUserId,
);
if (sharedCred) {
const resolvedHost: Record<string, unknown> = {
...host,
authType: sharedCred.authType,
password: sharedCred.password,
key: sharedCred.key,
keyPassword: sharedCred.keyPassword,
keyType: sharedCred.keyType,
};
// Only override username if overrideCredentialUsername is not enabled
if (!host.overrideCredentialUsername) {
resolvedHost.username = sharedCred.username;
}
return resolvedHost;
}
} catch (sharedCredError) {
sshLogger.warn(
"Failed to get shared credential, falling back to owner credential",
{
operation: "resolve_shared_credential_fallback",
hostId: host.id as number,
requestingUserId,
error:
sharedCredError instanceof Error
? sharedCredError.message
: "Unknown error",
},
);
// Fall through to try owner's credential
}
}
// Original owner access - use original credential
const credentials = await SimpleDBOps.select(
db
.select()

View File

@@ -1215,6 +1215,21 @@ router.post("/login", async (req, res) => {
return res.status(401).json({ error: "Incorrect password" });
}
// Re-encrypt any pending shared credentials for this user
try {
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.reEncryptPendingCredentialsForUser(userRecord.id);
} catch (error) {
authLogger.warn("Failed to re-encrypt pending shared credentials", {
operation: "reencrypt_pending_credentials",
userId: userRecord.id,
error,
});
// Continue with login even if re-encryption fails
}
if (userRecord.totp_enabled) {
const tempToken = await authManager.generateJWTToken(userRecord.id, {
pendingTOTP: true,