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

260
log.txt Normal file
View File

@@ -0,0 +1,260 @@
C:\nvm4w\nodejs\npm.cmd run dev:backend
> termix@1.9.0 dev:backend
> tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js
[4:10:16 PM] [INFO] [📦] Termix Backend starting - Version: 1.9.0 [op:startup]
[4:10:16 PM] [INFO] [🚀] SSL not enabled - skipping certificate generation [op:ssl_disabled_default]
[4:10:16 PM] [INFO] [🗄️] Initializing SQLite database [op:db_init]
[4:10:16 PM] [INFO] [🗄️] Current roles in database [op:schema_migration]
[4:10:16 PM] [INFO] [🗄️] Cleanup completed [op:schema_migration]
[4:10:16 PM] [INFO] [🗄️] System roles migration completed [op:schema_migration]
[4:10:16 PM] [INFO] [🗄️] Migrated admin users to admin role [op:schema_migration]
[4:10:16 PM] [INFO] [🗄️] Migrated normal users to user role [op:schema_migration]
[4:10:16 PM] [SUCCESS] [🗄️] Schema migration completed [op:schema_migration]
[4:10:16 PM] [WARN] [🗄️] Session not found during JWT verification [op:jwt_verify_session_not_found,user:GHNBjn7uGyG8OIrh67L-G,session:tNjsSYvIkLkJcJFGn2NLm]
[4:10:16 PM] [WARN] [🗄️] Session not found during JWT verification [op:jwt_verify_session_not_found,user:GHNBjn7uGyG8OIrh67L-G,session:tNjsSYvIkLkJcJFGn2NLm]
[4:10:16 PM] [INFO] [🚀] Docker backend server started on port 30007
[4:10:16 PM] [INFO] [🚀] Docker console WebSocket server started on port 30008 [op:startup]
[4:10:18 PM] [INFO] [📡] Found 0 autostart hosts and 2 total hosts for endpointHost resolution
[4:10:20 PM] [INFO] [🗄️] Re-encrypt step 1: Fetching shared credential [op:reencrypt_step1,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🗄️] Re-encrypt step 2: Fetching host access [op:reencrypt_step2]
[4:10:20 PM] [INFO] [🗄️] Re-encrypt step 3: Checking DEKs [op:reencrypt_step3,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [WARN] [🗄️] Re-encrypt: owner DEK not available [op:reencrypt_owner_offline]
[4:10:20 PM] [INFO] [🗄️] Re-encrypted pending credentials for user [op:reencrypt_pending_credentials,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [SUCCESS] [🔐] User logged in successfully: test123 [op:user_login_success,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:20 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1]
[4:10:20 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1]
[4:10:20 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:20 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:20 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:20 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:20 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:20 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1]
[4:10:21 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1]
[4:10:21 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1]
[4:10:21 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1]
[4:10:21 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:21 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1]
[4:10:21 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:21 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1]
[4:10:22 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1]
[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1]
[4:10:22 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1]
[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:22 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1]
[4:10:22 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1]
[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:22 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:24 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:24 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:24 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:24 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:24 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:24 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:24 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:24 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:24 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:24 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:24 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:24 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:24 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:24 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1]
[4:10:24 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1]
[4:10:24 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:24 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:24 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:24 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:24 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:24 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G]
[4:10:26 PM] [WARN] [🖥️] No credentials found for host 1 [op:ssh_credentials,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:10:26 PM] [ERROR] [🖥️] No valid authentication method provided
[4:11:29 PM] [INFO] [🖥️] Starting host fetch [op:host_fetch_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:11:29 PM] [INFO] [🖥️] Fetched user roles [op:host_fetch_roles,user:GHNBjn7uGyG8OIrh67L-G]
[4:11:29 PM] [INFO] [🖥️] Raw data fetched [op:host_fetch_raw,user:GHNBjn7uGyG8OIrh67L-G]
[4:11:29 PM] [INFO] [🖥️] About to filter hosts [op:host_fetch_filter_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:11:29 PM] [INFO] [🖥️] About to filter own hosts [op:host_fetch_filter_own_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:11:29 PM] [INFO] [🖥️] Own hosts filtered [op:host_fetch_own_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:11:29 PM] [INFO] [🖥️] About to filter shared hosts [op:host_fetch_filter_shared_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:11:29 PM] [INFO] [🖥️] Shared hosts filtered [op:host_fetch_shared_filtered,user:GHNBjn7uGyG8OIrh67L-G]
[4:11:29 PM] [INFO] [🖥️] Processing hosts for user [op:host_fetch_decrypt,user:GHNBjn7uGyG8OIrh67L-G]
[4:11:29 PM] [INFO] [🖥️] Own hosts decrypted successfully [op:host_fetch_own_decrypted,user:GHNBjn7uGyG8OIrh67L-G]
[4:11:29 PM] [INFO] [🖥️] Processing shared hosts [op:host_fetch_shared_process,user:GHNBjn7uGyG8OIrh67L-G]
[4:11:29 PM] [INFO] [🖥️] Combining hosts [op:host_fetch_combine,user:GHNBjn7uGyG8OIrh67L-G]
[4:11:29 PM] [INFO] [🖥️] Starting credential resolution [op:host_fetch_resolve_start,user:GHNBjn7uGyG8OIrh67L-G]
[4:11:29 PM] [INFO] [🖥️] Resolving credentials for host [op:resolve_credentials_start,host:1]
[4:11:29 PM] [INFO] [🖥️] Attempting to resolve shared credential [op:resolve_shared_credential,host:1]
[4:11:29 PM] [INFO] [🗄️] Getting shared credential - step 1: Check user DEK [op:get_shared_credential_step1,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:11:29 PM] [INFO] [🗄️] Getting shared credential - step 2: Query database [op:get_shared_credential_step2,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:11:29 PM] [INFO] [🗄️] Getting shared credential - step 3: Check results [op:get_shared_credential_step3,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:11:29 PM] [INFO] [🗄️] Getting shared credential - step 4: Check re-encryption [op:get_shared_credential_step4,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:11:29 PM] [WARN] [🗄️] Shared credential needs re-encryption but cannot be accessed yet [op:get_shared_credential_pending,user:GHNBjn7uGyG8OIrh67L-G,host:1]
[4:11:29 PM] [INFO] [🖥️] Credential resolution complete, sending response [op:host_fetch_complete,user:GHNBjn7uGyG8OIrh67L-G]

View File

@@ -1,7 +1,7 @@
{ {
"name": "termix", "name": "termix",
"private": true, "private": true,
"version": "1.9.0", "version": "1.10.0",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",

View File

@@ -590,6 +590,11 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "detected_key_type", "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_recent", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL"); addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_shortcuts", "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 // Clean up old system roles and seed correct ones
try { try {
// First, check what roles exist // First, check what roles exist

View File

@@ -185,6 +185,12 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
key_password: text("key_password"), key_password: text("key_password"),
keyType: text("key_type"), keyType: text("key_type"),
detectedKeyType: text("detected_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), usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"), lastUsed: text("last_used"),
createdAt: text("created_at") createdAt: text("created_at")
@@ -307,10 +313,10 @@ export const hostAccess = sqliteTable("host_access", {
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
// Permission level // Permission level (view-only)
permissionLevel: text("permission_level") permissionLevel: text("permission_level")
.notNull() .notNull()
.default("use"), // "view" | "use" | "manage" .default("view"), // Only "view" is supported
// Time-based access // Time-based access
expiresAt: text("expires_at"), // NULL = never expires expiresAt: text("expires_at"), // NULL = never expires
@@ -323,6 +329,47 @@ export const hostAccess = sqliteTable("host_access", {
accessCount: integer("access_count").notNull().default(0), 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 // RBAC Phase 2: Roles
export const roles = sqliteTable("roles", { export const roles = sqliteTable("roles", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),

View File

@@ -4,7 +4,12 @@ import type {
} from "../../../types/index.js"; } from "../../../types/index.js";
import express from "express"; import express from "express";
import { db } from "../db/index.js"; 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 { eq, and, desc, sql } from "drizzle-orm";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js"; import { authLogger } from "../../utils/logger.js";
@@ -473,6 +478,15 @@ router.put(
userId, 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]; const credential = updated[0];
authLogger.success( authLogger.success(
`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`, `SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`,
@@ -555,8 +569,36 @@ router.delete(
eq(sshData.userId, userId), 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 // sshCredentialUsage will be automatically deleted by ON DELETE CASCADE
// No need for manual deletion // No need for manual deletion
@@ -1601,10 +1643,7 @@ router.post(
} }
} }
const deployResult = await deploySSHKeyToHost( const deployResult = await deploySSHKeyToHost(hostConfig, credData);
hostConfig,
credData,
);
if (deployResult.success) { if (deployResult.success) {
res.json({ res.json({

View File

@@ -8,6 +8,7 @@ import {
roles, roles,
userRoles, userRoles,
auditLogs, auditLogs,
sharedCredentials,
} from "../db/schema.js"; } from "../db/schema.js";
import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm"; import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
@@ -47,7 +48,7 @@ router.post(
targetUserId, targetUserId,
targetRoleId, targetRoleId,
durationHours, durationHours,
permissionLevel = "use", permissionLevel = "view", // Only "view" is supported
} = req.body; } = req.body;
// Validate target type // Validate target type
@@ -129,11 +130,11 @@ router.post(
expiresAt = expiryDate.toISOString(); expiresAt = expiryDate.toISOString();
} }
// Validate permission level // Validate permission level (only "view" is supported)
const validLevels = ["view", "use", "manage"]; const validLevels = ["view"];
if (!validLevels.includes(permissionLevel)) { if (!validLevels.includes(permissionLevel)) {
return res.status(400).json({ return res.status(400).json({
error: "Invalid permission level", error: "Invalid permission level. Only 'view' is supported.",
validLevels, validLevels,
}); });
} }
@@ -162,6 +163,30 @@ router.post(
}) })
.where(eq(hostAccess.id, existing[0].id)); .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", { databaseLogger.info("Updated existing host access", {
operation: "share_host", operation: "share_host",
hostId, hostId,
@@ -189,6 +214,27 @@ router.post(
expiresAt, 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", { databaseLogger.info("Created host access", {
operation: "share_host", operation: "share_host",
hostId, hostId,
@@ -308,8 +354,6 @@ router.get(
permissionLevel: hostAccess.permissionLevel, permissionLevel: hostAccess.permissionLevel,
expiresAt: hostAccess.expiresAt, expiresAt: hostAccess.expiresAt,
createdAt: hostAccess.createdAt, createdAt: hostAccess.createdAt,
lastAccessedAt: hostAccess.lastAccessedAt,
accessCount: hostAccess.accessCount,
}) })
.from(hostAccess) .from(hostAccess)
.leftJoin(users, eq(hostAccess.userId, users.id)) .leftJoin(users, eq(hostAccess.userId, users.id))
@@ -331,8 +375,6 @@ router.get(
permissionLevel: access.permissionLevel, permissionLevel: access.permissionLevel,
expiresAt: access.expiresAt, expiresAt: access.expiresAt,
createdAt: access.createdAt, createdAt: access.createdAt,
lastAccessedAt: access.lastAccessedAt,
accessCount: access.accessCount,
})); }));
res.json({ accessList }); res.json({ accessList });
@@ -651,31 +693,24 @@ router.delete(
}); });
} }
// Check if role is in use // Delete user-role assignments first
const usageCount = await db const deletedUserRoles = await db
.select({ count: sql<number>`count(*)` }) .delete(userRoles)
.from(userRoles) .where(eq(userRoles.roleId, roleId))
.where(eq(userRoles.roleId, roleId)); .returning({ userId: userRoles.userId });
if (usageCount[0].count > 0) { // Invalidate permission cache for affected users
return res.status(409).json({ for (const { userId } of deletedUserRoles) {
error: `Cannot delete role: ${usageCount[0].count} user(s) are assigned to this role`, permissionManager.invalidateUserPermissionCache(userId);
usageCount: usageCount[0].count,
});
} }
// Check if role is used in host_access // Delete host_access entries for this role
const hostAccessCount = await db const deletedHostAccess = await db
.select({ count: sql<number>`count(*)` }) .delete(hostAccess)
.from(hostAccess) .where(eq(hostAccess.roleId, roleId))
.where(eq(hostAccess.roleId, roleId)); .returning({ id: hostAccess.id });
if (hostAccessCount[0].count > 0) { // Note: sharedCredentials will be auto-deleted by CASCADE
return res.status(409).json({
error: `Cannot delete role: ${hostAccessCount[0].count} host(s) are shared with this role`,
hostAccessCount: hostAccessCount[0].count,
});
}
// Delete role // Delete role
await db.delete(roles).where(eq(roles.id, roleId)); await db.delete(roles).where(eq(roles.id, roleId));
@@ -773,6 +808,51 @@ router.post(
grantedBy: currentUserId, 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 // Invalidate permission cache
permissionManager.invalidateUserPermissionCache(targetUserId); permissionManager.invalidateUserPermissionCache(targetUserId);

View File

@@ -391,7 +391,8 @@ router.post(
: undefined, : undefined,
}; };
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost; const resolvedHost =
(await resolveHostCredentials(baseHost, userId)) || baseHost;
sshLogger.success( sshLogger.success(
`SSH host created: ${name} (${ip}:${port}) by user ${userId}`, `SSH host created: ${name} (${ip}:${port}) by user ${userId}`,
@@ -619,9 +620,25 @@ router.put(
return res.status(403).json({ error: "Access denied" }); 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 // Get the actual owner ID for the update
const hostRecord = await db const hostRecord = await db
.select({ userId: sshData.userId }) .select({
userId: sshData.userId,
credentialId: sshData.credentialId,
authType: sshData.authType,
})
.from(sshData) .from(sshData)
.where(eq(sshData.id, Number(hostId))) .where(eq(sshData.id, Number(hostId)))
.limit(1); .limit(1);
@@ -637,6 +654,56 @@ router.put(
const ownerId = hostRecord[0].userId; 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( await SimpleDBOps.update(
sshData, sshData,
"ssh_data", "ssh_data",
@@ -691,7 +758,8 @@ router.put(
: undefined, : undefined,
}; };
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost; const resolvedHost =
(await resolveHostCredentials(baseHost, userId)) || baseHost;
sshLogger.success( sshLogger.success(
`SSH host updated: ${name} (${ip}:${port}) by user ${userId}`, `SSH host updated: ${name} (${ip}:${port}) by user ${userId}`,
@@ -854,12 +922,51 @@ router.get(
), ),
); );
// Decrypt and format the data // Separate own hosts from shared hosts for proper decryption
const data = await SimpleDBOps.select( const ownHosts = rawData.filter((row) => row.userId === userId);
Promise.resolve(rawData), const sharedHosts = rawData.filter((row) => row.userId !== userId);
"ssh_data",
// 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, 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( const result = await Promise.all(
data.map(async (row: Record<string, unknown>) => { data.map(async (row: Record<string, unknown>) => {
@@ -900,10 +1007,18 @@ router.get(
sharedExpiresAt: row.expiresAt || undefined, 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); res.json(result);
} catch (err) { } catch (err) {
sshLogger.error("Failed to fetch SSH hosts from database", 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) { } catch (err) {
sshLogger.error("Failed to fetch SSH host by ID from database", err, { sshLogger.error("Failed to fetch SSH host by ID from database", err, {
operation: "host_fetch_by_id", operation: "host_fetch_by_id",
@@ -1022,7 +1137,7 @@ router.get(
const host = hosts[0]; const host = hosts[0];
const resolvedHost = (await resolveHostCredentials(host)) || host; const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
const exportData = { const exportData = {
name: resolvedHost.name, name: resolvedHost.name,
@@ -1644,12 +1759,68 @@ router.delete(
async function resolveHostCredentials( async function resolveHostCredentials(
host: Record<string, unknown>, host: Record<string, unknown>,
requestingUserId?: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { 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)) { if (host.credentialId && (host.userId || host.ownerId)) {
const credentialId = host.credentialId as number; const credentialId = host.credentialId as number;
const ownerId = (host.ownerId || host.userId) as string; 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( const credentials = await SimpleDBOps.select(
db db
.select() .select()

View File

@@ -1215,6 +1215,21 @@ router.post("/login", async (req, res) => {
return res.status(401).json({ error: "Incorrect password" }); 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) { if (userRecord.totp_enabled) {
const tempToken = await authManager.generateJWTToken(userRecord.id, { const tempToken = await authManager.generateJWTToken(userRecord.id, {
pendingTOTP: true, pendingTOTP: true,

View File

@@ -373,12 +373,9 @@ app.post("/docker/ssh/connect", async (req, res) => {
} }
try { try {
// Get host configuration // Get host configuration - check both owned and shared hosts
const hosts = await SimpleDBOps.select( const hosts = await SimpleDBOps.select(
getDb() getDb().select().from(sshData).where(eq(sshData.id, hostId)),
.select()
.from(sshData)
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
"ssh_data", "ssh_data",
userId, userId,
); );
@@ -388,6 +385,27 @@ app.post("/docker/ssh/connect", async (req, res) => {
} }
const host = hosts[0] as unknown as SSHHost; const host = hosts[0] as unknown as SSHHost;
// Verify user has access to this host (either owner or shared access)
if (host.userId !== userId) {
const { PermissionManager } =
await import("../utils/permission-manager.js");
const permissionManager = PermissionManager.getInstance();
const accessInfo = await permissionManager.canAccessHost(
userId,
hostId,
"execute",
);
if (!accessInfo.hasAccess) {
dockerLogger.warn("User does not have access to host", {
operation: "docker_connect",
hostId,
userId,
});
return res.status(403).json({ error: "Access denied" });
}
}
if (typeof host.jumpHosts === "string" && host.jumpHosts) { if (typeof host.jumpHosts === "string" && host.jumpHosts) {
try { try {
host.jumpHosts = JSON.parse(host.jumpHosts); host.jumpHosts = JSON.parse(host.jumpHosts);
@@ -427,29 +445,61 @@ app.post("/docker/ssh/connect", async (req, res) => {
}; };
if (host.credentialId) { if (host.credentialId) {
const credentials = await SimpleDBOps.select( const ownerId = host.userId;
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId as number),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
if (credentials.length > 0) { // Check if this is a shared host access
const credential = credentials[0]; if (userId !== ownerId) {
resolvedCredentials = { // User is accessing a shared host - use shared credential
password: credential.password, try {
sshKey: const { SharedCredentialManager } =
credential.private_key || credential.privateKey || credential.key, await import("../utils/shared-credential-manager.js");
keyPassword: credential.key_password || credential.keyPassword, const sharedCredManager = SharedCredentialManager.getInstance();
authType: credential.auth_type || credential.authType, const sharedCred = await sharedCredManager.getSharedCredentialForUser(
}; host.id,
userId,
);
if (sharedCred) {
resolvedCredentials = {
password: sharedCred.password,
sshKey: sharedCred.key,
keyPassword: sharedCred.keyPassword,
authType: sharedCred.authType,
};
}
} catch (error) {
dockerLogger.error("Failed to resolve shared credential", error, {
operation: "docker_connect",
hostId,
userId,
});
}
} else {
// Owner accessing their own host
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId as number),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
sshKey:
credential.private_key || credential.privateKey || credential.key,
keyPassword: credential.key_password || credential.keyPassword,
authType: credential.auth_type || credential.authType,
};
}
} }
} }

View File

@@ -499,12 +499,7 @@ async function connectSSHTunnel(
getDb() getDb()
.select() .select()
.from(sshCredentials) .from(sshCredentials)
.where( .where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)),
and(
eq(sshCredentials.id, tunnelConfig.sourceCredentialId),
eq(sshCredentials.userId, tunnelConfig.sourceUserId),
),
),
"ssh_credentials", "ssh_credentials",
tunnelConfig.sourceUserId, tunnelConfig.sourceUserId,
); );
@@ -582,12 +577,7 @@ async function connectSSHTunnel(
getDb() getDb()
.select() .select()
.from(sshCredentials) .from(sshCredentials)
.where( .where(eq(sshCredentials.id, tunnelConfig.endpointCredentialId)),
and(
eq(sshCredentials.id, tunnelConfig.endpointCredentialId),
eq(sshCredentials.userId, tunnelConfig.endpointUserId),
),
),
"ssh_credentials", "ssh_credentials",
tunnelConfig.endpointUserId, tunnelConfig.endpointUserId,
); );
@@ -1021,7 +1011,8 @@ async function connectSSHTunnel(
if ( if (
tunnelConfig.useSocks5 && tunnelConfig.useSocks5 &&
(tunnelConfig.socks5Host || (tunnelConfig.socks5Host ||
(tunnelConfig.socks5ProxyChain && tunnelConfig.socks5ProxyChain.length > 0)) (tunnelConfig.socks5ProxyChain &&
tunnelConfig.socks5ProxyChain.length > 0))
) { ) {
try { try {
const socks5Socket = await createSocks5Connection( const socks5Socket = await createSocks5Connection(
@@ -1088,12 +1079,7 @@ async function killRemoteTunnelByMarker(
getDb() getDb()
.select() .select()
.from(sshCredentials) .from(sshCredentials)
.where( .where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)),
and(
eq(sshCredentials.id, tunnelConfig.sourceCredentialId),
eq(sshCredentials.userId, tunnelConfig.sourceUserId),
),
),
"ssh_credentials", "ssh_credentials",
tunnelConfig.sourceUserId, tunnelConfig.sourceUserId,
); );
@@ -1298,7 +1284,8 @@ async function killRemoteTunnelByMarker(
if ( if (
tunnelConfig.useSocks5 && tunnelConfig.useSocks5 &&
(tunnelConfig.socks5Host || (tunnelConfig.socks5Host ||
(tunnelConfig.socks5ProxyChain && tunnelConfig.socks5ProxyChain.length > 0)) (tunnelConfig.socks5ProxyChain &&
tunnelConfig.socks5ProxyChain.length > 0))
) { ) {
(async () => { (async () => {
try { try {

View File

@@ -154,9 +154,8 @@ class AuthManager {
return; return;
} }
const { getSqlite, saveMemoryDatabaseToFile } = await import( const { getSqlite, saveMemoryDatabaseToFile } =
"../database/db/index.js" await import("../database/db/index.js");
);
const sqlite = getSqlite(); const sqlite = getSqlite();
@@ -169,6 +168,33 @@ class AuthManager {
if (migrationResult.migrated) { if (migrationResult.migrated) {
await saveMemoryDatabaseToFile(); await saveMemoryDatabaseToFile();
} }
// Migrate credentials to system encryption for offline sharing
try {
const { CredentialSystemEncryptionMigration } =
await import("./credential-system-encryption-migration.js");
const credMigration = new CredentialSystemEncryptionMigration();
const credResult = await credMigration.migrateUserCredentials(userId);
if (credResult.migrated > 0) {
databaseLogger.info(
"Credentials migrated to system encryption on login",
{
operation: "login_credential_migration",
userId,
migrated: credResult.migrated,
},
);
await saveMemoryDatabaseToFile();
}
} catch (error) {
// Log but don't fail login
databaseLogger.warn("Credential migration failed during login", {
operation: "login_credential_migration_failed",
userId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
} catch (error) { } catch (error) {
databaseLogger.error("Lazy encryption migration failed", error, { databaseLogger.error("Lazy encryption migration failed", error, {
operation: "lazy_encryption_migration_error", operation: "lazy_encryption_migration_error",
@@ -231,9 +257,8 @@ class AuthManager {
}); });
try { try {
const { saveMemoryDatabaseToFile } = await import( const { saveMemoryDatabaseToFile } =
"../database/db/index.js" await import("../database/db/index.js");
);
await saveMemoryDatabaseToFile(); await saveMemoryDatabaseToFile();
} catch (saveError) { } catch (saveError) {
databaseLogger.error( databaseLogger.error(
@@ -334,9 +359,8 @@ class AuthManager {
await db.delete(sessions).where(eq(sessions.id, sessionId)); await db.delete(sessions).where(eq(sessions.id, sessionId));
try { try {
const { saveMemoryDatabaseToFile } = await import( const { saveMemoryDatabaseToFile } =
"../database/db/index.js" await import("../database/db/index.js");
);
await saveMemoryDatabaseToFile(); await saveMemoryDatabaseToFile();
} catch (saveError) { } catch (saveError) {
databaseLogger.error( databaseLogger.error(
@@ -387,9 +411,8 @@ class AuthManager {
} }
try { try {
const { saveMemoryDatabaseToFile } = await import( const { saveMemoryDatabaseToFile } =
"../database/db/index.js" await import("../database/db/index.js");
);
await saveMemoryDatabaseToFile(); await saveMemoryDatabaseToFile();
} catch (saveError) { } catch (saveError) {
databaseLogger.error( databaseLogger.error(
@@ -430,9 +453,8 @@ class AuthManager {
.where(sql`${sessions.expiresAt} < datetime('now')`); .where(sql`${sessions.expiresAt} < datetime('now')`);
try { try {
const { saveMemoryDatabaseToFile } = await import( const { saveMemoryDatabaseToFile } =
"../database/db/index.js" await import("../database/db/index.js");
);
await saveMemoryDatabaseToFile(); await saveMemoryDatabaseToFile();
} catch (saveError) { } catch (saveError) {
databaseLogger.error( databaseLogger.error(
@@ -568,9 +590,8 @@ class AuthManager {
.where(eq(sessions.id, payload.sessionId)) .where(eq(sessions.id, payload.sessionId))
.then(async () => { .then(async () => {
try { try {
const { saveMemoryDatabaseToFile } = await import( const { saveMemoryDatabaseToFile } =
"../database/db/index.js" await import("../database/db/index.js");
);
await saveMemoryDatabaseToFile(); await saveMemoryDatabaseToFile();
const remainingSessions = await db const remainingSessions = await db
@@ -714,9 +735,8 @@ class AuthManager {
await db.delete(sessions).where(eq(sessions.id, sessionId)); await db.delete(sessions).where(eq(sessions.id, sessionId));
try { try {
const { saveMemoryDatabaseToFile } = await import( const { saveMemoryDatabaseToFile } =
"../database/db/index.js" await import("../database/db/index.js");
);
await saveMemoryDatabaseToFile(); await saveMemoryDatabaseToFile();
} catch (saveError) { } catch (saveError) {
databaseLogger.error( databaseLogger.error(

View File

@@ -0,0 +1,164 @@
import { db } from "../database/db/index.js";
import { sshCredentials } from "../database/db/schema.js";
import { eq, and, or, isNull } from "drizzle-orm";
import { DataCrypto } from "./data-crypto.js";
import { SystemCrypto } from "./system-crypto.js";
import { FieldCrypto } from "./field-crypto.js";
import { databaseLogger } from "./logger.js";
/**
* Migrates credentials to include system-encrypted fields for offline sharing
*/
export class CredentialSystemEncryptionMigration {
/**
* Migrates a user's credentials to include system-encrypted fields
* Requires user to be logged in (DEK available)
*/
async migrateUserCredentials(userId: string): Promise<{
migrated: number;
failed: number;
skipped: number;
}> {
try {
// Get user's DEK (requires logged in)
const userDEK = DataCrypto.getUserDataKey(userId);
if (!userDEK) {
throw new Error("User must be logged in to migrate credentials");
}
// Get system key
const systemCrypto = SystemCrypto.getInstance();
const CSKEK = await systemCrypto.getCredentialSharingKey();
// Find credentials without system encryption
const credentials = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.userId, userId),
or(
isNull(sshCredentials.systemPassword),
isNull(sshCredentials.systemKey),
isNull(sshCredentials.systemKeyPassword),
),
),
);
let migrated = 0;
let failed = 0;
const skipped = 0;
for (const cred of credentials) {
try {
// Decrypt with user DEK
const plainPassword = cred.password
? FieldCrypto.decryptField(
cred.password,
userDEK,
cred.id.toString(),
"password",
)
: null;
const plainKey = cred.key
? FieldCrypto.decryptField(
cred.key,
userDEK,
cred.id.toString(),
"key",
)
: null;
const plainKeyPassword = cred.key_password
? FieldCrypto.decryptField(
cred.key_password,
userDEK,
cred.id.toString(),
"key_password",
)
: null;
// Re-encrypt with CSKEK
const systemPassword = plainPassword
? FieldCrypto.encryptField(
plainPassword,
CSKEK,
cred.id.toString(),
"password",
)
: null;
const systemKey = plainKey
? FieldCrypto.encryptField(
plainKey,
CSKEK,
cred.id.toString(),
"key",
)
: null;
const systemKeyPassword = plainKeyPassword
? FieldCrypto.encryptField(
plainKeyPassword,
CSKEK,
cred.id.toString(),
"key_password",
)
: null;
// Update database
await db
.update(sshCredentials)
.set({
systemPassword,
systemKey,
systemKeyPassword,
updatedAt: new Date().toISOString(),
})
.where(eq(sshCredentials.id, cred.id));
migrated++;
databaseLogger.info("Credential migrated for offline sharing", {
operation: "credential_system_encryption_migrated",
credentialId: cred.id,
userId,
});
} catch (error) {
databaseLogger.error("Failed to migrate credential", error, {
credentialId: cred.id,
userId,
});
failed++;
}
}
if (migrated > 0) {
databaseLogger.success(
"Credential system encryption migration completed",
{
operation: "credential_migration_complete",
userId,
migrated,
failed,
skipped,
},
);
}
return { migrated, failed, skipped };
} catch (error) {
databaseLogger.error(
"Credential system encryption migration failed",
error,
{
operation: "credential_migration_failed",
userId,
error: error instanceof Error ? error.message : "Unknown error",
},
);
throw error;
}
}
}

View File

@@ -475,6 +475,56 @@ class DataCrypto {
return false; return false;
} }
} }
/**
* Encrypt sensitive credential fields with system key for offline sharing
* Returns an object with systemPassword, systemKey, systemKeyPassword fields
*/
static async encryptRecordWithSystemKey<T extends Record<string, unknown>>(
tableName: string,
record: T,
systemKey: Buffer,
): Promise<Partial<T>> {
const systemEncrypted: Record<string, unknown> = {};
const recordId = record.id || "temp-" + Date.now();
// Only encrypt for sshCredentials table
if (tableName !== "ssh_credentials") {
return systemEncrypted as Partial<T>;
}
// Encrypt password field
if (record.password && typeof record.password === "string") {
systemEncrypted.systemPassword = FieldCrypto.encryptField(
record.password as string,
systemKey,
recordId as string,
"password",
);
}
// Encrypt key field
if (record.key && typeof record.key === "string") {
systemEncrypted.systemKey = FieldCrypto.encryptField(
record.key as string,
systemKey,
recordId as string,
"key",
);
}
// Encrypt key_password field
if (record.key_password && typeof record.key_password === "string") {
systemEncrypted.systemKeyPassword = FieldCrypto.encryptField(
record.key_password as string,
systemKey,
recordId as string,
"key_password",
);
}
return systemEncrypted as Partial<T>;
}
} }
export { DataCrypto }; export { DataCrypto };

View File

@@ -19,7 +19,7 @@ interface HostAccessInfo {
hasAccess: boolean; hasAccess: boolean;
isOwner: boolean; isOwner: boolean;
isShared: boolean; isShared: boolean;
permissionLevel?: string; permissionLevel?: "view"; // Only "view" is supported for shared access
expiresAt?: string | null; expiresAt?: string | null;
} }
@@ -246,32 +246,28 @@ class PermissionManager {
if (sharedAccess.length > 0) { if (sharedAccess.length > 0) {
const access = sharedAccess[0]; const access = sharedAccess[0];
// Check permission level for write/delete actions // All shared access is view-only - deny write/delete
if (action === "write" || action === "delete") { if (action === "write" || action === "delete") {
const level = access.permissionLevel; return {
if (level === "view" || level === "readonly") { hasAccess: false,
return { isOwner: false,
hasAccess: false, isShared: true,
isOwner: false, permissionLevel: access.permissionLevel as "view",
isShared: true, expiresAt: access.expiresAt,
permissionLevel: level, };
expiresAt: access.expiresAt,
};
}
} }
// Update last accessed time // Update last accessed time
try { try {
db.update(hostAccess) await db
.update(hostAccess)
.set({ .set({
lastAccessedAt: now, lastAccessedAt: now,
accessCount: sql`${hostAccess.accessCount} + 1`,
}) })
.where(eq(hostAccess.id, access.id)) .where(eq(hostAccess.id, access.id));
.run();
} catch (error) { } catch (error) {
databaseLogger.warn("Failed to update host access stats", { databaseLogger.warn("Failed to update host access timestamp", {
operation: "update_host_access_stats", operation: "update_host_access_timestamp",
error, error,
}); });
} }
@@ -280,7 +276,7 @@ class PermissionManager {
hasAccess: true, hasAccess: true,
isOwner: false, isOwner: false,
isShared: true, isShared: true,
permissionLevel: access.permissionLevel, permissionLevel: access.permissionLevel as "view",
expiresAt: access.expiresAt, expiresAt: access.expiresAt,
}; };
} }

View File

@@ -0,0 +1,817 @@
import { db } from "../database/db/index.js";
import {
sharedCredentials,
sshCredentials,
hostAccess,
users,
userRoles,
sshData,
} from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { DataCrypto } from "./data-crypto.js";
import { FieldCrypto } from "./field-crypto.js";
import { databaseLogger } from "./logger.js";
interface CredentialData {
username: string;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
}
/**
* Manages shared credentials for RBAC host sharing.
* Creates per-user encrypted credential copies to enable credential sharing
* without requiring the credential owner to be online.
*/
class SharedCredentialManager {
private static instance: SharedCredentialManager;
private constructor() {}
static getInstance(): SharedCredentialManager {
if (!this.instance) {
this.instance = new SharedCredentialManager();
}
return this.instance;
}
/**
* Create shared credential for a specific user
* Called when sharing a host with a user
*/
async createSharedCredentialForUser(
hostAccessId: number,
originalCredentialId: number,
targetUserId: string,
ownerId: string,
): Promise<void> {
try {
// Try owner's DEK first (existing path)
const ownerDEK = DataCrypto.getUserDataKey(ownerId);
if (ownerDEK) {
// Owner online - use existing flow
const targetDEK = DataCrypto.getUserDataKey(targetUserId);
if (!targetDEK) {
// Target user is offline, mark for lazy re-encryption
await this.createPendingSharedCredential(
hostAccessId,
originalCredentialId,
targetUserId,
);
return;
}
// Fetch and decrypt original credential using owner's DEK
const credentialData = await this.getDecryptedCredential(
originalCredentialId,
ownerId,
ownerDEK,
);
// Encrypt credential data with target user's DEK
const encryptedForTarget = this.encryptCredentialForUser(
credentialData,
targetUserId,
targetDEK,
hostAccessId,
);
// Store shared credential
await db.insert(sharedCredentials).values({
hostAccessId,
originalCredentialId,
targetUserId,
...encryptedForTarget,
needsReEncryption: false,
});
databaseLogger.info("Created shared credential for user", {
operation: "create_shared_credential",
hostAccessId,
targetUserId,
});
} else {
// NEW: Owner offline - use system key fallback
databaseLogger.info(
"Owner offline, attempting to share using system key",
{
operation: "create_shared_credential_system_key",
hostAccessId,
targetUserId,
ownerId,
},
);
// Get target user's DEK
const targetDEK = DataCrypto.getUserDataKey(targetUserId);
if (!targetDEK) {
// Both offline - create pending
await this.createPendingSharedCredential(
hostAccessId,
originalCredentialId,
targetUserId,
);
return;
}
// Decrypt using system key
const credentialData =
await this.getDecryptedCredentialViaSystemKey(originalCredentialId);
// Encrypt for target user
const encryptedForTarget = this.encryptCredentialForUser(
credentialData,
targetUserId,
targetDEK,
hostAccessId,
);
// Store shared credential
await db.insert(sharedCredentials).values({
hostAccessId,
originalCredentialId,
targetUserId,
...encryptedForTarget,
needsReEncryption: false,
});
databaseLogger.info("Created shared credential using system key", {
operation: "create_shared_credential_system_key",
hostAccessId,
targetUserId,
});
}
} catch (error) {
databaseLogger.error("Failed to create shared credential", error, {
operation: "create_shared_credential",
hostAccessId,
targetUserId,
});
throw error;
}
}
/**
* Create shared credentials for all users in a role
* Called when sharing a host with a role
*/
async createSharedCredentialsForRole(
hostAccessId: number,
originalCredentialId: number,
roleId: number,
ownerId: string,
): Promise<void> {
try {
// Get all users in the role
const roleUsers = await db
.select({ userId: userRoles.userId })
.from(userRoles)
.where(eq(userRoles.roleId, roleId));
// Create shared credential for each user
for (const { userId } of roleUsers) {
try {
await this.createSharedCredentialForUser(
hostAccessId,
originalCredentialId,
userId,
ownerId,
);
} catch (error) {
databaseLogger.error(
"Failed to create shared credential for role member",
error,
{
operation: "create_shared_credentials_role",
hostAccessId,
roleId,
userId,
},
);
// Continue with other users even if one fails
}
}
databaseLogger.info("Created shared credentials for role", {
operation: "create_shared_credentials_role",
hostAccessId,
roleId,
userCount: roleUsers.length,
});
} catch (error) {
databaseLogger.error(
"Failed to create shared credentials for role",
error,
{
operation: "create_shared_credentials_role",
hostAccessId,
roleId,
},
);
throw error;
}
}
/**
* Get credential data for a shared user
* Called when a shared user connects to a host
*/
async getSharedCredentialForUser(
hostId: number,
userId: string,
): Promise<CredentialData | null> {
try {
const userDEK = DataCrypto.getUserDataKey(userId);
if (!userDEK) {
throw new Error(`User ${userId} data not unlocked`);
}
// Find shared credential via hostAccess
const sharedCred = await db
.select()
.from(sharedCredentials)
.innerJoin(
hostAccess,
eq(sharedCredentials.hostAccessId, hostAccess.id),
)
.where(
and(
eq(hostAccess.hostId, hostId),
eq(sharedCredentials.targetUserId, userId),
),
)
.limit(1);
if (sharedCred.length === 0) {
return null;
}
const cred = sharedCred[0].shared_credentials;
// Check if needs re-encryption
if (cred.needsReEncryption) {
databaseLogger.warn(
"Shared credential needs re-encryption but cannot be accessed yet",
{
operation: "get_shared_credential_pending",
hostId,
userId,
},
);
// Credential is pending re-encryption - owner must be offline
// Return null instead of trying to re-encrypt (which would cause infinite loop)
return null;
}
// Decrypt credential data with user's DEK
return this.decryptSharedCredential(cred, userDEK);
} catch (error) {
databaseLogger.error("Failed to get shared credential", error, {
operation: "get_shared_credential",
hostId,
userId,
});
throw error;
}
}
/**
* Update all shared credentials when original credential is updated
* Called when credential owner updates credential
*/
async updateSharedCredentialsForOriginal(
credentialId: number,
ownerId: string,
): Promise<void> {
try {
// Get all shared credentials for this original credential
const sharedCreds = await db
.select()
.from(sharedCredentials)
.where(eq(sharedCredentials.originalCredentialId, credentialId));
// Try owner's DEK first
const ownerDEK = DataCrypto.getUserDataKey(ownerId);
let credentialData: CredentialData;
if (ownerDEK) {
// Owner online - use owner's DEK
credentialData = await this.getDecryptedCredential(
credentialId,
ownerId,
ownerDEK,
);
} else {
// Owner offline - use system key fallback
databaseLogger.info(
"Updating shared credentials using system key (owner offline)",
{
operation: "update_shared_credentials_system_key",
credentialId,
ownerId,
},
);
try {
credentialData =
await this.getDecryptedCredentialViaSystemKey(credentialId);
} catch (error) {
databaseLogger.warn(
"Cannot update shared credentials: owner offline and credential not migrated",
{
operation: "update_shared_credentials_failed",
credentialId,
ownerId,
error: error instanceof Error ? error.message : "Unknown error",
},
);
// Mark all shared credentials for re-encryption
await db
.update(sharedCredentials)
.set({ needsReEncryption: true })
.where(eq(sharedCredentials.originalCredentialId, credentialId));
return;
}
}
// Update each shared credential
for (const sharedCred of sharedCreds) {
const targetDEK = DataCrypto.getUserDataKey(sharedCred.targetUserId);
if (!targetDEK) {
// Target user offline, mark for lazy re-encryption
await db
.update(sharedCredentials)
.set({ needsReEncryption: true })
.where(eq(sharedCredentials.id, sharedCred.id));
continue;
}
// Re-encrypt with target user's DEK
const encryptedForTarget = this.encryptCredentialForUser(
credentialData,
sharedCred.targetUserId,
targetDEK,
sharedCred.hostAccessId,
);
await db
.update(sharedCredentials)
.set({
...encryptedForTarget,
needsReEncryption: false,
updatedAt: new Date().toISOString(),
})
.where(eq(sharedCredentials.id, sharedCred.id));
}
databaseLogger.info("Updated shared credentials for original", {
operation: "update_shared_credentials",
credentialId,
count: sharedCreds.length,
});
} catch (error) {
databaseLogger.error("Failed to update shared credentials", error, {
operation: "update_shared_credentials",
credentialId,
});
}
}
/**
* Delete shared credentials when original credential is deleted
* Called from credential deletion route
*/
async deleteSharedCredentialsForOriginal(
credentialId: number,
): Promise<void> {
try {
const result = await db
.delete(sharedCredentials)
.where(eq(sharedCredentials.originalCredentialId, credentialId))
.returning({ id: sharedCredentials.id });
databaseLogger.info("Deleted shared credentials for original", {
operation: "delete_shared_credentials",
credentialId,
count: result.length,
});
} catch (error) {
databaseLogger.error("Failed to delete shared credentials", error, {
operation: "delete_shared_credentials",
credentialId,
});
}
}
/**
* Re-encrypt pending shared credentials for a user when they log in
* Called during user login
*/
async reEncryptPendingCredentialsForUser(userId: string): Promise<void> {
try {
const userDEK = DataCrypto.getUserDataKey(userId);
if (!userDEK) {
return; // User not unlocked yet
}
const pendingCreds = await db
.select()
.from(sharedCredentials)
.where(
and(
eq(sharedCredentials.targetUserId, userId),
eq(sharedCredentials.needsReEncryption, true),
),
);
for (const cred of pendingCreds) {
await this.reEncryptSharedCredential(cred.id, userId);
}
if (pendingCreds.length > 0) {
databaseLogger.info("Re-encrypted pending credentials for user", {
operation: "reencrypt_pending_credentials",
userId,
count: pendingCreds.length,
});
}
} catch (error) {
databaseLogger.error("Failed to re-encrypt pending credentials", error, {
operation: "reencrypt_pending_credentials",
userId,
});
}
}
// ========== PRIVATE HELPER METHODS ==========
private async getDecryptedCredential(
credentialId: number,
ownerId: string,
ownerDEK: Buffer,
): Promise<CredentialData> {
const creds = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, ownerId),
),
)
.limit(1);
if (creds.length === 0) {
throw new Error(`Credential ${credentialId} not found`);
}
const cred = creds[0];
// Decrypt sensitive fields
// Note: username and authType are NOT encrypted
return {
username: cred.username,
authType: cred.authType,
password: cred.password
? this.decryptField(cred.password, ownerDEK, credentialId, "password")
: undefined,
key: cred.key
? this.decryptField(cred.key, ownerDEK, credentialId, "key")
: undefined,
keyPassword: cred.key_password
? this.decryptField(
cred.key_password,
ownerDEK,
credentialId,
"key_password",
)
: undefined,
keyType: cred.keyType,
};
}
/**
* Decrypt credential using system key (for offline sharing when owner is offline)
*/
private async getDecryptedCredentialViaSystemKey(
credentialId: number,
): Promise<CredentialData> {
const creds = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, credentialId))
.limit(1);
if (creds.length === 0) {
throw new Error(`Credential ${credentialId} not found`);
}
const cred = creds[0];
// Check if system fields exist
if (!cred.systemPassword && !cred.systemKey && !cred.systemKeyPassword) {
throw new Error(
"Credential not yet migrated for offline sharing. " +
"Please ask credential owner to log in to enable sharing.",
);
}
// Get system key
const { SystemCrypto } = await import("./system-crypto.js");
const systemCrypto = SystemCrypto.getInstance();
const CSKEK = await systemCrypto.getCredentialSharingKey();
// Decrypt using system-encrypted fields
return {
username: cred.username,
authType: cred.authType,
password: cred.systemPassword
? this.decryptField(
cred.systemPassword,
CSKEK,
credentialId,
"password",
)
: undefined,
key: cred.systemKey
? this.decryptField(cred.systemKey, CSKEK, credentialId, "key")
: undefined,
keyPassword: cred.systemKeyPassword
? this.decryptField(
cred.systemKeyPassword,
CSKEK,
credentialId,
"key_password",
)
: undefined,
keyType: cred.keyType,
};
}
private encryptCredentialForUser(
credentialData: CredentialData,
targetUserId: string,
targetDEK: Buffer,
hostAccessId: number,
): {
encryptedUsername: string;
encryptedAuthType: string;
encryptedPassword: string | null;
encryptedKey: string | null;
encryptedKeyPassword: string | null;
encryptedKeyType: string | null;
} {
const recordId = `shared-${hostAccessId}-${targetUserId}`;
return {
encryptedUsername: FieldCrypto.encryptField(
credentialData.username,
targetDEK,
recordId,
"username",
),
encryptedAuthType: credentialData.authType, // authType is not sensitive
encryptedPassword: credentialData.password
? FieldCrypto.encryptField(
credentialData.password,
targetDEK,
recordId,
"password",
)
: null,
encryptedKey: credentialData.key
? FieldCrypto.encryptField(
credentialData.key,
targetDEK,
recordId,
"key",
)
: null,
encryptedKeyPassword: credentialData.keyPassword
? FieldCrypto.encryptField(
credentialData.keyPassword,
targetDEK,
recordId,
"key_password",
)
: null,
encryptedKeyType: credentialData.keyType || null,
};
}
private decryptSharedCredential(
sharedCred: typeof sharedCredentials.$inferSelect,
userDEK: Buffer,
): CredentialData {
const recordId = `shared-${sharedCred.hostAccessId}-${sharedCred.targetUserId}`;
return {
username: FieldCrypto.decryptField(
sharedCred.encryptedUsername,
userDEK,
recordId,
"username",
),
authType: sharedCred.encryptedAuthType,
password: sharedCred.encryptedPassword
? FieldCrypto.decryptField(
sharedCred.encryptedPassword,
userDEK,
recordId,
"password",
)
: undefined,
key: sharedCred.encryptedKey
? FieldCrypto.decryptField(
sharedCred.encryptedKey,
userDEK,
recordId,
"key",
)
: undefined,
keyPassword: sharedCred.encryptedKeyPassword
? FieldCrypto.decryptField(
sharedCred.encryptedKeyPassword,
userDEK,
recordId,
"key_password",
)
: undefined,
keyType: sharedCred.encryptedKeyType || undefined,
};
}
private decryptField(
encryptedValue: string,
dek: Buffer,
recordId: number | string,
fieldName: string,
): string {
try {
return FieldCrypto.decryptField(
encryptedValue,
dek,
recordId.toString(),
fieldName,
);
} catch (error) {
// If decryption fails, value might not be encrypted (legacy data)
databaseLogger.warn("Field decryption failed, returning as-is", {
operation: "decrypt_field",
fieldName,
recordId,
});
return encryptedValue;
}
}
private async createPendingSharedCredential(
hostAccessId: number,
originalCredentialId: number,
targetUserId: string,
): Promise<void> {
// Create placeholder with needsReEncryption flag
await db.insert(sharedCredentials).values({
hostAccessId,
originalCredentialId,
targetUserId,
encryptedUsername: "", // Will be filled during re-encryption
encryptedAuthType: "",
needsReEncryption: true,
});
databaseLogger.info("Created pending shared credential", {
operation: "create_pending_shared_credential",
hostAccessId,
targetUserId,
});
}
private async reEncryptSharedCredential(
sharedCredId: number,
userId: string,
): Promise<void> {
try {
// Get the shared credential
const sharedCred = await db
.select()
.from(sharedCredentials)
.where(eq(sharedCredentials.id, sharedCredId))
.limit(1);
if (sharedCred.length === 0) {
databaseLogger.warn("Re-encrypt: shared credential not found", {
operation: "reencrypt_not_found",
sharedCredId,
});
return;
}
const cred = sharedCred[0];
// Get the host access to find the owner
const access = await db
.select()
.from(hostAccess)
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
.where(eq(hostAccess.id, cred.hostAccessId))
.limit(1);
if (access.length === 0) {
databaseLogger.warn("Re-encrypt: host access not found", {
operation: "reencrypt_access_not_found",
sharedCredId,
});
return;
}
const ownerId = access[0].ssh_data.userId;
// Get user's DEK (must be available)
const userDEK = DataCrypto.getUserDataKey(userId);
if (!userDEK) {
databaseLogger.warn("Re-encrypt: user DEK not available", {
operation: "reencrypt_user_offline",
sharedCredId,
userId,
});
// User offline, keep pending
return;
}
// Try owner's DEK first
const ownerDEK = DataCrypto.getUserDataKey(ownerId);
let credentialData: CredentialData;
if (ownerDEK) {
// Owner online - use owner's DEK
credentialData = await this.getDecryptedCredential(
cred.originalCredentialId,
ownerId,
ownerDEK,
);
} else {
// Owner offline - use system key fallback
databaseLogger.info("Re-encrypt: using system key (owner offline)", {
operation: "reencrypt_system_key",
sharedCredId,
ownerId,
});
try {
credentialData = await this.getDecryptedCredentialViaSystemKey(
cred.originalCredentialId,
);
} catch (error) {
databaseLogger.warn(
"Re-encrypt: system key decryption failed, credential may not be migrated yet",
{
operation: "reencrypt_system_key_failed",
sharedCredId,
error: error instanceof Error ? error.message : "Unknown error",
},
);
// Keep pending if system fields don't exist yet
return;
}
}
// Re-encrypt for user
const encryptedForTarget = this.encryptCredentialForUser(
credentialData,
userId,
userDEK,
cred.hostAccessId,
);
// Update shared credential
await db
.update(sharedCredentials)
.set({
...encryptedForTarget,
needsReEncryption: false,
updatedAt: new Date().toISOString(),
})
.where(eq(sharedCredentials.id, sharedCredId));
databaseLogger.info("Re-encrypted shared credential successfully", {
operation: "reencrypt_shared_credential",
sharedCredId,
userId,
});
} catch (error) {
databaseLogger.error("Failed to re-encrypt shared credential", error, {
operation: "reencrypt_shared_credential",
sharedCredId,
userId,
});
}
}
}
export { SharedCredentialManager };

View File

@@ -2,7 +2,12 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
import { DataCrypto } from "./data-crypto.js"; import { DataCrypto } from "./data-crypto.js";
import type { SQLiteTable } from "drizzle-orm/sqlite-core"; import type { SQLiteTable } from "drizzle-orm/sqlite-core";
type TableName = "users" | "ssh_data" | "ssh_credentials" | "recent_activity" | "socks5_proxy_presets"; type TableName =
| "users"
| "ssh_data"
| "ssh_credentials"
| "recent_activity"
| "socks5_proxy_presets";
class SimpleDBOps { class SimpleDBOps {
static async insert<T extends Record<string, unknown>>( static async insert<T extends Record<string, unknown>>(
@@ -23,6 +28,21 @@ class SimpleDBOps {
userDataKey, userDataKey,
); );
// Also encrypt with system key for ssh_credentials (offline sharing)
if (tableName === "ssh_credentials") {
const { SystemCrypto } = await import("./system-crypto.js");
const systemCrypto = SystemCrypto.getInstance();
const systemKey = await systemCrypto.getCredentialSharingKey();
const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey(
tableName,
dataWithTempId,
systemKey,
);
Object.assign(encryptedData, systemEncrypted);
}
if (!data.id) { if (!data.id) {
delete encryptedData.id; delete encryptedData.id;
} }
@@ -105,6 +125,21 @@ class SimpleDBOps {
userDataKey, userDataKey,
); );
// Also encrypt with system key for ssh_credentials (offline sharing)
if (tableName === "ssh_credentials") {
const { SystemCrypto } = await import("./system-crypto.js");
const systemCrypto = SystemCrypto.getInstance();
const systemKey = await systemCrypto.getCredentialSharingKey();
const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey(
tableName,
data,
systemKey,
);
Object.assign(encryptedData, systemEncrypted);
}
const result = await getDb() const result = await getDb()
.update(table) .update(table)
.set(encryptedData) .set(encryptedData)

View File

@@ -8,6 +8,7 @@ class SystemCrypto {
private jwtSecret: string | null = null; private jwtSecret: string | null = null;
private databaseKey: Buffer | null = null; private databaseKey: Buffer | null = null;
private internalAuthToken: string | null = null; private internalAuthToken: string | null = null;
private credentialSharingKey: Buffer | null = null;
private constructor() {} private constructor() {}
@@ -158,6 +159,48 @@ class SystemCrypto {
return this.internalAuthToken!; return this.internalAuthToken!;
} }
async initializeCredentialSharingKey(): Promise<void> {
try {
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
const envKey = process.env.CREDENTIAL_SHARING_KEY;
if (envKey && envKey.length >= 64) {
this.credentialSharingKey = Buffer.from(envKey, "hex");
return;
}
try {
const envContent = await fs.readFile(envPath, "utf8");
const csKeyMatch = envContent.match(/^CREDENTIAL_SHARING_KEY=(.+)$/m);
if (csKeyMatch && csKeyMatch[1] && csKeyMatch[1].length >= 64) {
this.credentialSharingKey = Buffer.from(csKeyMatch[1], "hex");
process.env.CREDENTIAL_SHARING_KEY = csKeyMatch[1];
return;
}
} catch (fileError) {}
await this.generateAndGuideCredentialSharingKey();
} catch (error) {
databaseLogger.error(
"Failed to initialize credential sharing key",
error,
{
operation: "cred_sharing_key_init_failed",
dataDir: process.env.DATA_DIR || "./db/data",
},
);
throw new Error("Credential sharing key initialization failed");
}
}
async getCredentialSharingKey(): Promise<Buffer> {
if (!this.credentialSharingKey) {
await this.initializeCredentialSharingKey();
}
return this.credentialSharingKey!;
}
private async generateAndGuideUser(): Promise<void> { private async generateAndGuideUser(): Promise<void> {
const newSecret = crypto.randomBytes(32).toString("hex"); const newSecret = crypto.randomBytes(32).toString("hex");
const instanceId = crypto.randomBytes(8).toString("hex"); const instanceId = crypto.randomBytes(8).toString("hex");
@@ -210,6 +253,26 @@ class SystemCrypto {
); );
} }
private async generateAndGuideCredentialSharingKey(): Promise<void> {
const newKey = crypto.randomBytes(32);
const newKeyHex = newKey.toString("hex");
const instanceId = crypto.randomBytes(8).toString("hex");
this.credentialSharingKey = newKey;
await this.updateEnvFile("CREDENTIAL_SHARING_KEY", newKeyHex);
databaseLogger.success(
"Credential sharing key auto-generated and saved to .env",
{
operation: "cred_sharing_key_auto_generated",
instanceId,
envVarName: "CREDENTIAL_SHARING_KEY",
note: "Used for offline credential sharing - no restart required",
},
);
}
async validateJWTSecret(): Promise<boolean> { async validateJWTSecret(): Promise<boolean> {
try { try {
const secret = await this.getJWTSecret(); const secret = await this.getJWTSecret();

View File

@@ -875,6 +875,7 @@
"selectCredentialPlaceholder": "Choose a credential...", "selectCredentialPlaceholder": "Choose a credential...",
"credentialRequired": "Credential is required when using credential authentication", "credentialRequired": "Credential is required when using credential authentication",
"credentialDescription": "Selecting a credential will overwrite the current username and use the credential's authentication details.", "credentialDescription": "Selecting a credential will overwrite the current username and use the credential's authentication details.",
"cannotChangeAuthAsSharedUser": "Cannot change authentication as shared user",
"sshPrivateKey": "SSH Private Key", "sshPrivateKey": "SSH Private Key",
"keyPassword": "Key Password", "keyPassword": "Key Password",
"keyType": "Key Type", "keyType": "Key Type",
@@ -2308,11 +2309,7 @@
"sharing": "Sharing", "sharing": "Sharing",
"selectUserAndRole": "Please select both a user and a role", "selectUserAndRole": "Please select both a user and a role",
"view": "View Only", "view": "View Only",
"viewDesc": "Can view and connect to the host in read-only mode", "viewDesc": "Due to the Termix encryption system, other permission levels will come at a later date"
"use": "Use",
"useDesc": "Can use the host normally but cannot modify host configuration",
"manage": "Manage",
"manageDesc": "Full control including modifying host configuration and sharing settings"
}, },
"commandPalette": { "commandPalette": {
"searchPlaceholder": "Search for hosts or quick actions...", "searchPlaceholder": "Search for hosts or quick actions...",

View File

@@ -60,9 +60,9 @@ export interface SSHHost {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
// Shared access metadata // Shared access metadata (view-only)
isShared?: boolean; isShared?: boolean;
permissionLevel?: "view" | "manage"; permissionLevel?: "view";
sharedExpiresAt?: string; sharedExpiresAt?: string;
} }
@@ -394,6 +394,7 @@ export interface TabContextTab {
hostConfig?: SSHHost; hostConfig?: SSHHost;
terminalRef?: any; terminalRef?: any;
initialTab?: string; initialTab?: string;
_updateTimestamp?: number;
} }
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid"; export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";
@@ -485,6 +486,7 @@ export interface HostManagerProps {
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
initialTab?: string; initialTab?: string;
hostConfig?: SSHHost; hostConfig?: SSHHost;
_updateTimestamp?: number;
rightSidebarOpen?: boolean; rightSidebarOpen?: boolean;
rightSidebarWidth?: number; rightSidebarWidth?: number;
} }

View File

@@ -277,6 +277,7 @@ function AppContent() {
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
initialTab={currentTabData?.initialTab} initialTab={currentTabData?.initialTab}
hostConfig={currentTabData?.hostConfig} hostConfig={currentTabData?.hostConfig}
_updateTimestamp={currentTabData?._updateTimestamp}
rightSidebarOpen={rightSidebarOpen} rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth} rightSidebarWidth={rightSidebarWidth}
/> />

View File

@@ -19,6 +19,8 @@ import {
FolderOpen, FolderOpen,
Pencil, Pencil,
EllipsisVertical, EllipsisVertical,
ArrowDownUp,
Container,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BiMoney, BiSupport } from "react-icons/bi"; import { BiMoney, BiSupport } from "react-icons/bi";
@@ -27,6 +29,7 @@ import { GrUpdate } from "react-icons/gr";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { getRecentActivity, getSSHHosts } from "@/ui/main-axios.ts"; import { getRecentActivity, getSSHHosts } from "@/ui/main-axios.ts";
import type { RecentActivityItem } from "@/ui/main-axios.ts"; import type { RecentActivityItem } from "@/ui/main-axios.ts";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@@ -52,8 +55,10 @@ interface SSHHost {
enableTerminal: boolean; enableTerminal: boolean;
enableTunnel: boolean; enableTunnel: boolean;
enableFileManager: boolean; enableFileManager: boolean;
enableDocker: boolean;
defaultPath: string; defaultPath: string;
tunnelConnections: unknown[]; tunnelConnections: unknown[];
statsConfig?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -88,7 +93,10 @@ export function CommandPalette({
const handleAddHost = () => { const handleAddHost = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) { if (sshManagerTab) {
updateTab(sshManagerTab.id, { initialTab: "add_host" }); updateTab(sshManagerTab.id, {
initialTab: "add_host",
hostConfig: undefined,
});
setCurrentTab(sshManagerTab.id); setCurrentTab(sshManagerTab.id);
} else { } else {
const id = addTab({ const id = addTab({
@@ -104,7 +112,10 @@ export function CommandPalette({
const handleAddCredential = () => { const handleAddCredential = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) { if (sshManagerTab) {
updateTab(sshManagerTab.id, { initialTab: "add_credential" }); updateTab(sshManagerTab.id, {
initialTab: "add_credential",
hostConfig: undefined,
});
setCurrentTab(sshManagerTab.id); setCurrentTab(sshManagerTab.id);
} else { } else {
const id = addTab({ const id = addTab({
@@ -216,6 +227,22 @@ export function CommandPalette({
setIsOpen(false); setIsOpen(false);
}; };
const handleHostTunnelClick = (host: SSHHost) => {
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({ type: "tunnel", title, hostConfig: host });
setIsOpen(false);
};
const handleHostDockerClick = (host: SSHHost) => {
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({ type: "docker", title, hostConfig: host });
setIsOpen(false);
};
const handleHostEditClick = (host: SSHHost) => { const handleHostEditClick = (host: SSHHost) => {
const title = host.name?.trim() const title = host.name?.trim()
? host.name ? host.name
@@ -301,6 +328,33 @@ export function CommandPalette({
const title = host.name?.trim() const title = host.name?.trim()
? host.name ? host.name
: `${host.username}@${host.ip}:${host.port}`; : `${host.username}@${host.ip}:${host.port}`;
// Parse statsConfig to determine if metrics should be shown
let shouldShowMetrics = true;
try {
const statsConfig = host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
shouldShowMetrics = statsConfig.metricsEnabled !== false;
} catch {
shouldShowMetrics = true;
}
// Check if host has at least one tunnel connection
let hasTunnelConnections = false;
try {
const tunnelConnections = Array.isArray(
host.tunnelConnections,
)
? host.tunnelConnections
: JSON.parse(host.tunnelConnections as string);
hasTunnelConnections =
Array.isArray(tunnelConnections) &&
tunnelConnections.length > 0;
} catch {
hasTunnelConnections = false;
}
return ( return (
<CommandItem <CommandItem
key={`host-${index}-${host.id}`} key={`host-${index}-${host.id}`}
@@ -335,30 +389,62 @@ export function CommandPalette({
side="right" side="right"
className="w-56 bg-canvas border-edge text-foreground" className="w-56 bg-canvas border-edge text-foreground"
> >
<DropdownMenuItem {shouldShowMetrics && (
onClick={(e) => { <DropdownMenuItem
e.stopPropagation(); onClick={(e) => {
handleHostServerDetailsClick(host); e.stopPropagation();
}} handleHostServerDetailsClick(host);
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" }}
> className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
<Server className="h-4 w-4" /> >
<span className="flex-1"> <Server className="h-4 w-4" />
{t("commandPalette.openServerDetails")} <span className="flex-1">
</span> {t("hosts.openServerStats")}
</DropdownMenuItem> </span>
<DropdownMenuItem </DropdownMenuItem>
onClick={(e) => { )}
e.stopPropagation(); {host.enableFileManager && (
handleHostFileManagerClick(host); <DropdownMenuItem
}} onClick={(e) => {
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" e.stopPropagation();
> handleHostFileManagerClick(host);
<FolderOpen className="h-4 w-4" /> }}
<span className="flex-1"> className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
{t("commandPalette.openFileManager")} >
</span> <FolderOpen className="h-4 w-4" />
</DropdownMenuItem> <span className="flex-1">
{t("hosts.openFileManager")}
</span>
</DropdownMenuItem>
)}
{host.enableTunnel && hasTunnelConnections && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleHostTunnelClick(host);
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
>
<ArrowDownUp className="h-4 w-4" />
<span className="flex-1">
{t("hosts.openTunnels")}
</span>
</DropdownMenuItem>
)}
{host.enableDocker && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleHostDockerClick(host);
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
>
<Container className="h-4 w-4" />
<span className="flex-1">
{t("hosts.openDocker")}
</span>
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -367,9 +453,7 @@ export function CommandPalette({
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
<span className="flex-1"> <span className="flex-1">{t("common.edit")}</span>
{t("commandPalette.edit")}
</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -317,7 +317,10 @@ export function Dashboard({
const handleAddHost = () => { const handleAddHost = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) { if (sshManagerTab) {
updateTab(sshManagerTab.id, { initialTab: "add_host" }); updateTab(sshManagerTab.id, {
initialTab: "add_host",
hostConfig: undefined,
});
setCurrentTab(sshManagerTab.id); setCurrentTab(sshManagerTab.id);
} else { } else {
const id = addTab({ const id = addTab({
@@ -332,7 +335,10 @@ export function Dashboard({
const handleAddCredential = () => { const handleAddCredential = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) { if (sshManagerTab) {
updateTab(sshManagerTab.id, { initialTab: "add_credential" }); updateTab(sshManagerTab.id, {
initialTab: "add_credential",
hostConfig: undefined,
});
setCurrentTab(sshManagerTab.id); setCurrentTab(sshManagerTab.id);
} else { } else {
const id = addTab({ const id = addTab({

View File

@@ -18,6 +18,7 @@ export function HostManager({
isTopbarOpen, isTopbarOpen,
initialTab = "host_viewer", initialTab = "host_viewer",
hostConfig, hostConfig,
_updateTimestamp,
rightSidebarOpen = false, rightSidebarOpen = false,
rightSidebarWidth = 400, rightSidebarWidth = 400,
}: HostManagerProps): React.ReactElement { }: HostManagerProps): React.ReactElement {
@@ -36,20 +37,39 @@ export function HostManager({
const ignoreNextHostConfigChangeRef = useRef<boolean>(false); const ignoreNextHostConfigChangeRef = useRef<boolean>(false);
const lastProcessedHostIdRef = useRef<number | undefined>(undefined); const lastProcessedHostIdRef = useRef<number | undefined>(undefined);
// Sync state when tab is updated externally (via updateTab or addTab)
useEffect(() => { useEffect(() => {
if (initialTab) { // Always sync on timestamp changes
setActiveTab(initialTab); if (_updateTimestamp !== undefined) {
} // Update activeTab if initialTab has changed
}, [initialTab]); if (initialTab && initialTab !== activeTab) {
setActiveTab(initialTab);
}
// Update editingHost when hostConfig changes // Update editingHost if hostConfig has changed
useEffect(() => { if (hostConfig && hostConfig.id !== editingHost?.id) {
if (hostConfig) { setEditingHost(hostConfig);
setEditingHost(hostConfig); lastProcessedHostIdRef.current = hostConfig.id;
setActiveTab("add_host"); } else if (!hostConfig && editingHost) {
lastProcessedHostIdRef.current = hostConfig.id; // Clear editingHost if hostConfig is now undefined
setEditingHost(null);
}
// Clear editingCredential if switching away from add_credential
if (initialTab !== "add_credential" && editingCredential) {
setEditingCredential(null);
}
} else {
// Initial mount - set state from props
if (initialTab) {
setActiveTab(initialTab);
}
if (hostConfig) {
setEditingHost(hostConfig);
lastProcessedHostIdRef.current = hostConfig.id;
}
} }
}, [hostConfig?.id]); }, [_updateTimestamp, initialTab, hostConfig?.id]);
const handleEditHost = (host: SSHHost) => { const handleEditHost = (host: SSHHost) => {
setEditingHost(host); setEditingHost(host);

View File

@@ -1465,6 +1465,7 @@ export function HostManagerEditor({
<Tabs <Tabs
value={authTab} value={authTab}
onValueChange={(value) => { onValueChange={(value) => {
if (editingHost?.isShared) return;
const newAuthType = value as const newAuthType = value as
| "password" | "password"
| "key" | "key"
@@ -1478,25 +1479,29 @@ export function HostManagerEditor({
<TabsList className="bg-button border border-edge-medium"> <TabsList className="bg-button border border-edge-medium">
<TabsTrigger <TabsTrigger
value="password" value="password"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium" disabled={editingHost?.isShared}
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
{t("hosts.password")} {t("hosts.password")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="key" value="key"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium" disabled={editingHost?.isShared}
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
{t("hosts.key")} {t("hosts.key")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="credential" value="credential"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium" disabled={editingHost?.isShared}
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
{t("hosts.credential")} {t("hosts.credential")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="none" value="none"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium" disabled={editingHost?.isShared}
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
{t("hosts.none")} {t("hosts.none")}
</TabsTrigger> </TabsTrigger>
@@ -1709,26 +1714,34 @@ export function HostManagerEditor({
name="credentialId" name="credentialId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<CredentialSelector {editingHost?.isShared ? (
value={field.value} <div className="text-sm text-muted-foreground p-3 bg-base border border-edge-medium rounded-md">
onValueChange={field.onChange} {t("hosts.cannotChangeAuthAsSharedUser")}
onCredentialSelect={(credential) => { </div>
if ( ) : (
credential && <CredentialSelector
!form.getValues( value={field.value}
"overrideCredentialUsername", onValueChange={field.onChange}
) onCredentialSelect={(credential) => {
) { if (
form.setValue( credential &&
"username", !form.getValues(
credential.username, "overrideCredentialUsername",
); )
} ) {
}} form.setValue(
/> "username",
<FormDescription> credential.username,
{t("hosts.credentialDescription")} );
</FormDescription> }
}}
/>
)}
{!editingHost?.isShared && (
<FormDescription>
{t("hosts.credentialDescription")}
</FormDescription>
)}
</FormItem> </FormItem>
)} )}
/> />
@@ -3769,7 +3782,7 @@ export function HostManagerEditor({
</ScrollArea> </ScrollArea>
<footer className="shrink-0 w-full pb-0"> <footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" /> <Separator className="p-0.25" />
{!(editingHost?.permissionLevel === "view") && ( {!editingHost?.isShared && (
<Button className="translate-y-2" type="submit" variant="outline"> <Button className="translate-y-2" type="submit" variant="outline">
{editingHost {editingHost
? editingHost.id ? editingHost.id

View File

@@ -76,10 +76,8 @@ interface User {
is_admin: boolean; is_admin: boolean;
} }
const PERMISSION_LEVELS = [ // Only view permission is supported (manage removed due to encryption constraints)
{ value: "view", labelKey: "rbac.view" }, const PERMISSION_LEVELS = [{ value: "view", labelKey: "rbac.view" }];
{ value: "manage", labelKey: "rbac.manage" },
];
export function HostSharingTab({ export function HostSharingTab({
hostId, hostId,
@@ -430,26 +428,12 @@ export function HostSharingTab({
</TabsContent> </TabsContent>
</Tabs> </Tabs>
{/* Permission Level */} {/* Permission Level - Always "view" (read-only) */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="permission-level"> <Label>{t("rbac.permissionLevel")}</Label>
{t("rbac.permissionLevel")} <div className="text-sm text-muted-foreground">
</Label> {t("rbac.view")} - {t("rbac.viewDesc")}
<Select </div>
value={permissionLevel || "use"}
onValueChange={(v) => setPermissionLevel(v || "use")}
>
<SelectTrigger id="permission-level">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PERMISSION_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{t(level.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
{/* Expiration */} {/* Expiration */}
@@ -496,7 +480,6 @@ export function HostSharingTab({
<TableHead>{t("rbac.permissionLevel")}</TableHead> <TableHead>{t("rbac.permissionLevel")}</TableHead>
<TableHead>{t("rbac.grantedBy")}</TableHead> <TableHead>{t("rbac.grantedBy")}</TableHead>
<TableHead>{t("rbac.expires")}</TableHead> <TableHead>{t("rbac.expires")}</TableHead>
<TableHead>{t("rbac.accessCount")}</TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
{t("common.actions")} {t("common.actions")}
</TableHead> </TableHead>
@@ -506,7 +489,7 @@ export function HostSharingTab({
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={7} colSpan={6}
className="text-center text-muted-foreground" className="text-center text-muted-foreground"
> >
{t("common.loading")} {t("common.loading")}
@@ -515,7 +498,7 @@ export function HostSharingTab({
) : accessList.length === 0 ? ( ) : accessList.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={7} colSpan={6}
className="text-center text-muted-foreground" className="text-center text-muted-foreground"
> >
{t("rbac.noAccessRecords")} {t("rbac.noAccessRecords")}
@@ -582,7 +565,6 @@ export function HostSharingTab({
t("rbac.never") t("rbac.never")
)} )}
</TableCell> </TableCell>
<TableCell>{access.accessCount}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <Button
type="button" type="button"

View File

@@ -790,7 +790,7 @@ export function Auth({
}} }}
{...props} {...props}
> >
<div className="w-[420px] max-w-full p-6 flex flex-col bg-elevated border-2 border-edge rounded-md overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300"> <div className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300">
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
<div className="text-center"> <div className="text-center">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" /> <div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
@@ -821,7 +821,7 @@ export function Auth({
{...props} {...props}
> >
<div <div
className="w-[420px] max-w-full p-6 flex flex-col bg-elevated border-2 border-edge rounded-md overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300" className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300"
style={{ maxHeight: "calc(100vh - 1rem)" }} style={{ maxHeight: "calc(100vh - 1rem)" }}
> >
<div className="mb-6 text-center"> <div className="mb-6 text-center">

View File

@@ -84,6 +84,19 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
const shouldShowStatus = statsConfig.statusCheckEnabled !== false; const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
const shouldShowMetrics = statsConfig.metricsEnabled !== false; const shouldShowMetrics = statsConfig.metricsEnabled !== false;
// Check if host has at least one tunnel connection
const hasTunnelConnections = useMemo(() => {
if (!host.tunnelConnections) return false;
try {
const tunnelConnections = Array.isArray(host.tunnelConnections)
? host.tunnelConnections
: JSON.parse(host.tunnelConnections);
return Array.isArray(tunnelConnections) && tunnelConnections.length > 0;
} catch {
return false;
}
}, [host.tunnelConnections]);
useEffect(() => { useEffect(() => {
if (!shouldShowStatus) { if (!shouldShowStatus) {
setServerStatus("offline"); setServerStatus("offline");
@@ -179,7 +192,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
> >
<Server className="h-4 w-4" /> <Server className="h-4 w-4" />
<span className="flex-1">{t('hosts.openServerStats')}</span> <span className="flex-1">{t("hosts.openServerStats")}</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{host.enableFileManager && ( {host.enableFileManager && (
@@ -190,10 +203,10 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
> >
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4" />
<span className="flex-1">{t('hosts.openFileManager')}</span> <span className="flex-1">{t("hosts.openFileManager")}</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{host.enableTunnel && ( {host.enableTunnel && hasTunnelConnections && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
addTab({ type: "tunnel", title, hostConfig: host }) addTab({ type: "tunnel", title, hostConfig: host })
@@ -201,7 +214,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
> >
<ArrowDownUp className="h-4 w-4" /> <ArrowDownUp className="h-4 w-4" />
<span className="flex-1">{t('hosts.openTunnels')}</span> <span className="flex-1">{t("hosts.openTunnels")}</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{host.enableDocker && ( {host.enableDocker && (
@@ -212,14 +225,14 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
> >
<Container className="h-4 w-4" /> <Container className="h-4 w-4" />
<span className="flex-1">{t('hosts.openDocker')}</span> <span className="flex-1">{t("hosts.openDocker")}</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
addTab({ addTab({
type: "ssh_manager", type: "ssh_manager",
title: t('nav.hostManager'), title: t("nav.hostManager"),
hostConfig: host, hostConfig: host,
initialTab: "add_host", initialTab: "add_host",
}) })
@@ -227,7 +240,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
<span className="flex-1">{t('common.edit')}</span> <span className="flex-1">{t("common.edit")}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -280,7 +280,11 @@ export function TabProvider({ children }: TabProviderProps) {
const updateTab = (tabId: number, updates: Partial<Omit<Tab, "id">>) => { const updateTab = (tabId: number, updates: Partial<Omit<Tab, "id">>) => {
setTabs((prev) => setTabs((prev) =>
prev.map((tab) => (tab.id === tabId ? { ...tab, ...updates } : tab)), prev.map((tab) =>
tab.id === tabId
? { ...tab, ...updates, _updateTimestamp: Date.now() }
: tab,
),
); );
}; };

View File

@@ -48,11 +48,9 @@ export interface AccessRecord {
roleDisplayName: string | null; roleDisplayName: string | null;
grantedBy: string; grantedBy: string;
grantedByUsername: string; grantedByUsername: string;
permissionLevel: string; permissionLevel: "view"; // Only view permission is supported
expiresAt: string | null; expiresAt: string | null;
createdAt: string; createdAt: string;
lastAccessedAt: string | null;
accessCount: number;
} }
import { import {
apiLogger, apiLogger,
@@ -3292,7 +3290,7 @@ export async function shareHost(
targetType: "user" | "role"; targetType: "user" | "role";
targetUserId?: string; targetUserId?: string;
targetRoleId?: number; targetRoleId?: number;
permissionLevel: string; permissionLevel: "view"; // Only view permission is supported
durationHours?: number; durationHours?: number;
}, },
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {