fix: rbac implementation general issues (local squash)
This commit is contained in:
260
log.txt
Normal file
260
log.txt
Normal 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]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"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",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
|
||||
@@ -590,6 +590,11 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
|
||||
|
||||
// System-encrypted fields for offline credential sharing
|
||||
addColumnIfNotExists("ssh_credentials", "system_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "system_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT");
|
||||
|
||||
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||
@@ -842,6 +847,42 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// RBAC: Shared Credentials table
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS shared_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_access_id INTEGER NOT NULL,
|
||||
original_credential_id INTEGER NOT NULL,
|
||||
target_user_id TEXT NOT NULL,
|
||||
encrypted_username TEXT NOT NULL,
|
||||
encrypted_auth_type TEXT NOT NULL,
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
encrypted_key_type TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
needs_re_encryption INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
databaseLogger.info("Created shared_credentials table", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create shared_credentials table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old system roles and seed correct ones
|
||||
try {
|
||||
// First, check what roles exist
|
||||
|
||||
@@ -185,6 +185,12 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
key_password: text("key_password"),
|
||||
keyType: text("key_type"),
|
||||
detectedKeyType: text("detected_key_type"),
|
||||
|
||||
// System-encrypted fields for offline credential sharing
|
||||
systemPassword: text("system_password"),
|
||||
systemKey: text("system_key", { length: 16384 }),
|
||||
systemKeyPassword: text("system_key_password"),
|
||||
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
createdAt: text("created_at")
|
||||
@@ -307,10 +313,10 @@ export const hostAccess = sqliteTable("host_access", {
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// Permission level
|
||||
// Permission level (view-only)
|
||||
permissionLevel: text("permission_level")
|
||||
.notNull()
|
||||
.default("use"), // "view" | "use" | "manage"
|
||||
.default("view"), // Only "view" is supported
|
||||
|
||||
// Time-based access
|
||||
expiresAt: text("expires_at"), // NULL = never expires
|
||||
@@ -323,6 +329,47 @@ export const hostAccess = sqliteTable("host_access", {
|
||||
accessCount: integer("access_count").notNull().default(0),
|
||||
});
|
||||
|
||||
// RBAC: Shared Credentials (per-user encrypted credential copies)
|
||||
export const sharedCredentials = sqliteTable("shared_credentials", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
|
||||
// Link to the host access grant (CASCADE delete when share revoked)
|
||||
hostAccessId: integer("host_access_id")
|
||||
.notNull()
|
||||
.references(() => hostAccess.id, { onDelete: "cascade" }),
|
||||
|
||||
// Link to the original credential (for tracking updates/CASCADE delete)
|
||||
originalCredentialId: integer("original_credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
|
||||
// Target user (recipient of the share) - CASCADE delete when user deleted
|
||||
targetUserId: text("target_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// Encrypted credential data (encrypted with targetUserId's DEK)
|
||||
encryptedUsername: text("encrypted_username").notNull(),
|
||||
encryptedAuthType: text("encrypted_auth_type").notNull(),
|
||||
encryptedPassword: text("encrypted_password"),
|
||||
encryptedKey: text("encrypted_key", { length: 16384 }),
|
||||
encryptedKeyPassword: text("encrypted_key_password"),
|
||||
encryptedKeyType: text("encrypted_key_type"),
|
||||
|
||||
// Metadata
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
// Track if needs re-encryption (when original credential updated but target user offline)
|
||||
needsReEncryption: integer("needs_re_encryption", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
// RBAC Phase 2: Roles
|
||||
export const roles = sqliteTable("roles", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
|
||||
@@ -4,7 +4,12 @@ import type {
|
||||
} from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js";
|
||||
import {
|
||||
sshCredentials,
|
||||
sshCredentialUsage,
|
||||
sshData,
|
||||
hostAccess,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
@@ -473,6 +478,15 @@ router.put(
|
||||
userId,
|
||||
);
|
||||
|
||||
// Update shared credentials if this credential is shared
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.updateSharedCredentialsForOriginal(
|
||||
parseInt(id),
|
||||
userId,
|
||||
);
|
||||
|
||||
const credential = updated[0];
|
||||
authLogger.success(
|
||||
`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`,
|
||||
@@ -555,8 +569,36 @@ router.delete(
|
||||
eq(sshData.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
// Revoke all shares for hosts that used this credential
|
||||
for (const host of hostsUsingCredential) {
|
||||
const revokedShares = await db
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, host.id))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (revokedShares.length > 0) {
|
||||
authLogger.info(
|
||||
"Auto-revoked host shares due to credential deletion",
|
||||
{
|
||||
operation: "auto_revoke_shares",
|
||||
hostId: host.id,
|
||||
credentialId: parseInt(id),
|
||||
revokedCount: revokedShares.length,
|
||||
reason: "credential_deleted",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete shared credentials for this original credential
|
||||
// Note: This will also be handled by CASCADE, but we do it explicitly for logging
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id));
|
||||
|
||||
// sshCredentialUsage will be automatically deleted by ON DELETE CASCADE
|
||||
// No need for manual deletion
|
||||
|
||||
@@ -1601,10 +1643,7 @@ router.post(
|
||||
}
|
||||
}
|
||||
|
||||
const deployResult = await deploySSHKeyToHost(
|
||||
hostConfig,
|
||||
credData,
|
||||
);
|
||||
const deployResult = await deploySSHKeyToHost(hostConfig, credData);
|
||||
|
||||
if (deployResult.success) {
|
||||
res.json({
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
roles,
|
||||
userRoles,
|
||||
auditLogs,
|
||||
sharedCredentials,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
@@ -47,7 +48,7 @@ router.post(
|
||||
targetUserId,
|
||||
targetRoleId,
|
||||
durationHours,
|
||||
permissionLevel = "use",
|
||||
permissionLevel = "view", // Only "view" is supported
|
||||
} = req.body;
|
||||
|
||||
// Validate target type
|
||||
@@ -129,11 +130,11 @@ router.post(
|
||||
expiresAt = expiryDate.toISOString();
|
||||
}
|
||||
|
||||
// Validate permission level
|
||||
const validLevels = ["view", "use", "manage"];
|
||||
// Validate permission level (only "view" is supported)
|
||||
const validLevels = ["view"];
|
||||
if (!validLevels.includes(permissionLevel)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid permission level",
|
||||
error: "Invalid permission level. Only 'view' is supported.",
|
||||
validLevels,
|
||||
});
|
||||
}
|
||||
@@ -162,6 +163,30 @@ router.post(
|
||||
})
|
||||
.where(eq(hostAccess.id, existing[0].id));
|
||||
|
||||
// Re-create shared credential (delete old, create new)
|
||||
await db
|
||||
.delete(sharedCredentials)
|
||||
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
databaseLogger.info("Updated existing host access", {
|
||||
operation: "share_host",
|
||||
hostId,
|
||||
@@ -189,6 +214,27 @@ router.post(
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Create shared credential for the target
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
databaseLogger.info("Created host access", {
|
||||
operation: "share_host",
|
||||
hostId,
|
||||
@@ -308,8 +354,6 @@ router.get(
|
||||
permissionLevel: hostAccess.permissionLevel,
|
||||
expiresAt: hostAccess.expiresAt,
|
||||
createdAt: hostAccess.createdAt,
|
||||
lastAccessedAt: hostAccess.lastAccessedAt,
|
||||
accessCount: hostAccess.accessCount,
|
||||
})
|
||||
.from(hostAccess)
|
||||
.leftJoin(users, eq(hostAccess.userId, users.id))
|
||||
@@ -331,8 +375,6 @@ router.get(
|
||||
permissionLevel: access.permissionLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
createdAt: access.createdAt,
|
||||
lastAccessedAt: access.lastAccessedAt,
|
||||
accessCount: access.accessCount,
|
||||
}));
|
||||
|
||||
res.json({ accessList });
|
||||
@@ -651,31 +693,24 @@ router.delete(
|
||||
});
|
||||
}
|
||||
|
||||
// Check if role is in use
|
||||
const usageCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.roleId, roleId));
|
||||
// Delete user-role assignments first
|
||||
const deletedUserRoles = await db
|
||||
.delete(userRoles)
|
||||
.where(eq(userRoles.roleId, roleId))
|
||||
.returning({ userId: userRoles.userId });
|
||||
|
||||
if (usageCount[0].count > 0) {
|
||||
return res.status(409).json({
|
||||
error: `Cannot delete role: ${usageCount[0].count} user(s) are assigned to this role`,
|
||||
usageCount: usageCount[0].count,
|
||||
});
|
||||
// Invalidate permission cache for affected users
|
||||
for (const { userId } of deletedUserRoles) {
|
||||
permissionManager.invalidateUserPermissionCache(userId);
|
||||
}
|
||||
|
||||
// Check if role is used in host_access
|
||||
const hostAccessCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(hostAccess)
|
||||
.where(eq(hostAccess.roleId, roleId));
|
||||
// Delete host_access entries for this role
|
||||
const deletedHostAccess = await db
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.roleId, roleId))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (hostAccessCount[0].count > 0) {
|
||||
return res.status(409).json({
|
||||
error: `Cannot delete role: ${hostAccessCount[0].count} host(s) are shared with this role`,
|
||||
hostAccessCount: hostAccessCount[0].count,
|
||||
});
|
||||
}
|
||||
// Note: sharedCredentials will be auto-deleted by CASCADE
|
||||
|
||||
// Delete role
|
||||
await db.delete(roles).where(eq(roles.id, roleId));
|
||||
@@ -773,6 +808,51 @@ router.post(
|
||||
grantedBy: currentUserId,
|
||||
});
|
||||
|
||||
// Create shared credentials for all hosts shared with this role
|
||||
const hostsSharedWithRole = await db
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
|
||||
.where(eq(hostAccess.roleId, roleId));
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
|
||||
for (const { host_access, ssh_data } of hostsSharedWithRole) {
|
||||
if (ssh_data.credentialId) {
|
||||
try {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
host_access.id,
|
||||
ssh_data.credentialId,
|
||||
targetUserId,
|
||||
ssh_data.userId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to create shared credential for new role member",
|
||||
error,
|
||||
{
|
||||
operation: "assign_role_create_credentials",
|
||||
targetUserId,
|
||||
roleId,
|
||||
hostId: ssh_data.id,
|
||||
},
|
||||
);
|
||||
// Continue with other hosts even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostsSharedWithRole.length > 0) {
|
||||
databaseLogger.info("Created shared credentials for new role member", {
|
||||
operation: "assign_role_create_credentials",
|
||||
targetUserId,
|
||||
roleId,
|
||||
hostCount: hostsSharedWithRole.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate permission cache
|
||||
permissionManager.invalidateUserPermissionCache(targetUserId);
|
||||
|
||||
|
||||
@@ -391,7 +391,8 @@ router.post(
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
const resolvedHost =
|
||||
(await resolveHostCredentials(baseHost, userId)) || baseHost;
|
||||
|
||||
sshLogger.success(
|
||||
`SSH host created: ${name} (${ip}:${port}) by user ${userId}`,
|
||||
@@ -619,9 +620,25 @@ router.put(
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
|
||||
// Shared users cannot edit hosts (view-only)
|
||||
if (!accessInfo.isOwner) {
|
||||
sshLogger.warn("Shared user attempted to update host (view-only)", {
|
||||
operation: "host_update",
|
||||
hostId: parseInt(hostId),
|
||||
userId,
|
||||
});
|
||||
return res.status(403).json({
|
||||
error: "Only the host owner can modify host configuration",
|
||||
});
|
||||
}
|
||||
|
||||
// Get the actual owner ID for the update
|
||||
const hostRecord = await db
|
||||
.select({ userId: sshData.userId })
|
||||
.select({
|
||||
userId: sshData.userId,
|
||||
credentialId: sshData.credentialId,
|
||||
authType: sshData.authType,
|
||||
})
|
||||
.from(sshData)
|
||||
.where(eq(sshData.id, Number(hostId)))
|
||||
.limit(1);
|
||||
@@ -637,6 +654,56 @@ router.put(
|
||||
|
||||
const ownerId = hostRecord[0].userId;
|
||||
|
||||
// Only owner can change credentialId
|
||||
if (
|
||||
!accessInfo.isOwner &&
|
||||
sshDataObj.credentialId !== undefined &&
|
||||
sshDataObj.credentialId !== hostRecord[0].credentialId
|
||||
) {
|
||||
return res.status(403).json({
|
||||
error: "Only the host owner can change the credential",
|
||||
});
|
||||
}
|
||||
|
||||
// Only owner can change authType
|
||||
if (
|
||||
!accessInfo.isOwner &&
|
||||
sshDataObj.authType !== undefined &&
|
||||
sshDataObj.authType !== hostRecord[0].authType
|
||||
) {
|
||||
return res.status(403).json({
|
||||
error: "Only the host owner can change the authentication type",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if credentialId is changing from non-null to null
|
||||
// This happens when switching from "credential" auth to "password"/"key"/"none"
|
||||
if (sshDataObj.credentialId !== undefined) {
|
||||
if (
|
||||
hostRecord[0].credentialId !== null &&
|
||||
sshDataObj.credentialId === null
|
||||
) {
|
||||
// Auth type changed away from credential - revoke all shares
|
||||
const revokedShares = await db
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, Number(hostId)))
|
||||
.returning({ id: hostAccess.id, userId: hostAccess.userId });
|
||||
|
||||
if (revokedShares.length > 0) {
|
||||
sshLogger.info(
|
||||
"Auto-revoked host shares due to auth type change from credential",
|
||||
{
|
||||
operation: "auto_revoke_shares",
|
||||
hostId: Number(hostId),
|
||||
revokedCount: revokedShares.length,
|
||||
reason: "auth_type_changed_from_credential",
|
||||
},
|
||||
);
|
||||
// Note: sharedCredentials will be auto-deleted by CASCADE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await SimpleDBOps.update(
|
||||
sshData,
|
||||
"ssh_data",
|
||||
@@ -691,7 +758,8 @@ router.put(
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
const resolvedHost =
|
||||
(await resolveHostCredentials(baseHost, userId)) || baseHost;
|
||||
|
||||
sshLogger.success(
|
||||
`SSH host updated: ${name} (${ip}:${port}) by user ${userId}`,
|
||||
@@ -854,12 +922,51 @@ router.get(
|
||||
),
|
||||
);
|
||||
|
||||
// Decrypt and format the data
|
||||
const data = await SimpleDBOps.select(
|
||||
Promise.resolve(rawData),
|
||||
"ssh_data",
|
||||
// Separate own hosts from shared hosts for proper decryption
|
||||
const ownHosts = rawData.filter((row) => row.userId === userId);
|
||||
const sharedHosts = rawData.filter((row) => row.userId !== userId);
|
||||
|
||||
// Decrypt own hosts with user's DEK
|
||||
let decryptedOwnHosts: any[] = [];
|
||||
try {
|
||||
decryptedOwnHosts = await SimpleDBOps.select(
|
||||
Promise.resolve(ownHosts),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
sshLogger.debug("Own hosts decrypted successfully", {
|
||||
operation: "host_fetch_own_decrypted",
|
||||
userId,
|
||||
count: decryptedOwnHosts.length,
|
||||
});
|
||||
} catch (decryptError) {
|
||||
sshLogger.error("Failed to decrypt own hosts", decryptError, {
|
||||
operation: "host_fetch_own_decrypt_failed",
|
||||
userId,
|
||||
});
|
||||
// Return empty array if decryption fails
|
||||
decryptedOwnHosts = [];
|
||||
}
|
||||
|
||||
// For shared hosts, DON'T try to decrypt them with user's DEK
|
||||
// Just pass them through as plain objects without encrypted credential fields
|
||||
// The credentials will be resolved via SharedCredentialManager later when resolveHostCredentials is called
|
||||
sshLogger.info("Processing shared hosts", {
|
||||
operation: "host_fetch_shared_process",
|
||||
userId,
|
||||
);
|
||||
count: sharedHosts.length,
|
||||
});
|
||||
|
||||
const sanitizedSharedHosts = sharedHosts;
|
||||
|
||||
sshLogger.info("Combining hosts", {
|
||||
operation: "host_fetch_combine",
|
||||
userId,
|
||||
ownCount: decryptedOwnHosts.length,
|
||||
sharedCount: sanitizedSharedHosts.length,
|
||||
});
|
||||
|
||||
const data = [...decryptedOwnHosts, ...sanitizedSharedHosts];
|
||||
|
||||
const result = await Promise.all(
|
||||
data.map(async (row: Record<string, unknown>) => {
|
||||
@@ -900,10 +1007,18 @@ router.get(
|
||||
sharedExpiresAt: row.expiresAt || undefined,
|
||||
};
|
||||
|
||||
return (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
const resolved =
|
||||
(await resolveHostCredentials(baseHost, userId)) || baseHost;
|
||||
return resolved;
|
||||
}),
|
||||
);
|
||||
|
||||
sshLogger.info("Credential resolution complete, sending response", {
|
||||
operation: "host_fetch_complete",
|
||||
userId,
|
||||
hostCount: result.length,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch SSH hosts from database", err, {
|
||||
@@ -978,7 +1093,7 @@ router.get(
|
||||
: [],
|
||||
};
|
||||
|
||||
res.json((await resolveHostCredentials(result)) || result);
|
||||
res.json((await resolveHostCredentials(result, userId)) || result);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch SSH host by ID from database", err, {
|
||||
operation: "host_fetch_by_id",
|
||||
@@ -1022,7 +1137,7 @@ router.get(
|
||||
|
||||
const host = hosts[0];
|
||||
|
||||
const resolvedHost = (await resolveHostCredentials(host)) || host;
|
||||
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
|
||||
|
||||
const exportData = {
|
||||
name: resolvedHost.name,
|
||||
@@ -1644,12 +1759,68 @@ router.delete(
|
||||
|
||||
async function resolveHostCredentials(
|
||||
host: Record<string, unknown>,
|
||||
requestingUserId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
sshLogger.info("Resolving credentials for host", {
|
||||
operation: "resolve_credentials_start",
|
||||
hostId: host.id as number,
|
||||
hasCredentialId: !!host.credentialId,
|
||||
requestingUserId,
|
||||
ownerId: (host.ownerId || host.userId) as string,
|
||||
});
|
||||
|
||||
if (host.credentialId && (host.userId || host.ownerId)) {
|
||||
const credentialId = host.credentialId as number;
|
||||
const ownerId = (host.ownerId || host.userId) as string;
|
||||
|
||||
// Check if this is a shared host access
|
||||
if (requestingUserId && requestingUserId !== ownerId) {
|
||||
// User is accessing a shared host - use shared credential
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
host.id as number,
|
||||
requestingUserId,
|
||||
);
|
||||
|
||||
if (sharedCred) {
|
||||
const resolvedHost: Record<string, unknown> = {
|
||||
...host,
|
||||
authType: sharedCred.authType,
|
||||
password: sharedCred.password,
|
||||
key: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
};
|
||||
|
||||
// Only override username if overrideCredentialUsername is not enabled
|
||||
if (!host.overrideCredentialUsername) {
|
||||
resolvedHost.username = sharedCred.username;
|
||||
}
|
||||
|
||||
return resolvedHost;
|
||||
}
|
||||
} catch (sharedCredError) {
|
||||
sshLogger.warn(
|
||||
"Failed to get shared credential, falling back to owner credential",
|
||||
{
|
||||
operation: "resolve_shared_credential_fallback",
|
||||
hostId: host.id as number,
|
||||
requestingUserId,
|
||||
error:
|
||||
sharedCredError instanceof Error
|
||||
? sharedCredError.message
|
||||
: "Unknown error",
|
||||
},
|
||||
);
|
||||
// Fall through to try owner's credential
|
||||
}
|
||||
}
|
||||
|
||||
// Original owner access - use original credential
|
||||
const credentials = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
|
||||
@@ -1215,6 +1215,21 @@ router.post("/login", async (req, res) => {
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
|
||||
// Re-encrypt any pending shared credentials for this user
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.reEncryptPendingCredentialsForUser(userRecord.id);
|
||||
} catch (error) {
|
||||
authLogger.warn("Failed to re-encrypt pending shared credentials", {
|
||||
operation: "reencrypt_pending_credentials",
|
||||
userId: userRecord.id,
|
||||
error,
|
||||
});
|
||||
// Continue with login even if re-encryption fails
|
||||
}
|
||||
|
||||
if (userRecord.totp_enabled) {
|
||||
const tempToken = await authManager.generateJWTToken(userRecord.id, {
|
||||
pendingTOTP: true,
|
||||
|
||||
@@ -373,12 +373,9 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get host configuration
|
||||
// Get host configuration - check both owned and shared hosts
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
||||
getDb().select().from(sshData).where(eq(sshData.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
@@ -388,6 +385,27 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
host.jumpHosts = JSON.parse(host.jumpHosts);
|
||||
@@ -427,29 +445,61 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
};
|
||||
|
||||
if (host.credentialId) {
|
||||
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,
|
||||
);
|
||||
const ownerId = host.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,
|
||||
};
|
||||
// Check if this is a shared host access
|
||||
if (userId !== 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,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -499,12 +499,7 @@ async function connectSSHTunnel(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, tunnelConfig.sourceCredentialId),
|
||||
eq(sshCredentials.userId, tunnelConfig.sourceUserId),
|
||||
),
|
||||
),
|
||||
.where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)),
|
||||
"ssh_credentials",
|
||||
tunnelConfig.sourceUserId,
|
||||
);
|
||||
@@ -582,12 +577,7 @@ async function connectSSHTunnel(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, tunnelConfig.endpointCredentialId),
|
||||
eq(sshCredentials.userId, tunnelConfig.endpointUserId),
|
||||
),
|
||||
),
|
||||
.where(eq(sshCredentials.id, tunnelConfig.endpointCredentialId)),
|
||||
"ssh_credentials",
|
||||
tunnelConfig.endpointUserId,
|
||||
);
|
||||
@@ -1021,7 +1011,8 @@ async function connectSSHTunnel(
|
||||
if (
|
||||
tunnelConfig.useSocks5 &&
|
||||
(tunnelConfig.socks5Host ||
|
||||
(tunnelConfig.socks5ProxyChain && tunnelConfig.socks5ProxyChain.length > 0))
|
||||
(tunnelConfig.socks5ProxyChain &&
|
||||
tunnelConfig.socks5ProxyChain.length > 0))
|
||||
) {
|
||||
try {
|
||||
const socks5Socket = await createSocks5Connection(
|
||||
@@ -1088,12 +1079,7 @@ async function killRemoteTunnelByMarker(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, tunnelConfig.sourceCredentialId),
|
||||
eq(sshCredentials.userId, tunnelConfig.sourceUserId),
|
||||
),
|
||||
),
|
||||
.where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)),
|
||||
"ssh_credentials",
|
||||
tunnelConfig.sourceUserId,
|
||||
);
|
||||
@@ -1298,7 +1284,8 @@ async function killRemoteTunnelByMarker(
|
||||
if (
|
||||
tunnelConfig.useSocks5 &&
|
||||
(tunnelConfig.socks5Host ||
|
||||
(tunnelConfig.socks5ProxyChain && tunnelConfig.socks5ProxyChain.length > 0))
|
||||
(tunnelConfig.socks5ProxyChain &&
|
||||
tunnelConfig.socks5ProxyChain.length > 0))
|
||||
) {
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
@@ -154,9 +154,8 @@ class AuthManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const { getSqlite, saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { getSqlite, saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
|
||||
const sqlite = getSqlite();
|
||||
|
||||
@@ -169,6 +168,33 @@ class AuthManager {
|
||||
if (migrationResult.migrated) {
|
||||
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) {
|
||||
databaseLogger.error("Lazy encryption migration failed", error, {
|
||||
operation: "lazy_encryption_migration_error",
|
||||
@@ -231,9 +257,8 @@ class AuthManager {
|
||||
});
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
@@ -334,9 +359,8 @@ class AuthManager {
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
@@ -387,9 +411,8 @@ class AuthManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
@@ -430,9 +453,8 @@ class AuthManager {
|
||||
.where(sql`${sessions.expiresAt} < datetime('now')`);
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
@@ -568,9 +590,8 @@ class AuthManager {
|
||||
.where(eq(sessions.id, payload.sessionId))
|
||||
.then(async () => {
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
const remainingSessions = await db
|
||||
@@ -714,9 +735,8 @@ class AuthManager {
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
|
||||
164
src/backend/utils/credential-system-encryption-migration.ts
Normal file
164
src/backend/utils/credential-system-encryption-migration.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,6 +475,56 @@ class DataCrypto {
|
||||
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 };
|
||||
|
||||
@@ -19,7 +19,7 @@ interface HostAccessInfo {
|
||||
hasAccess: boolean;
|
||||
isOwner: boolean;
|
||||
isShared: boolean;
|
||||
permissionLevel?: string;
|
||||
permissionLevel?: "view"; // Only "view" is supported for shared access
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
@@ -246,32 +246,28 @@ class PermissionManager {
|
||||
if (sharedAccess.length > 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") {
|
||||
const level = access.permissionLevel;
|
||||
if (level === "view" || level === "readonly") {
|
||||
return {
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
isShared: true,
|
||||
permissionLevel: level,
|
||||
expiresAt: access.expiresAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
isShared: true,
|
||||
permissionLevel: access.permissionLevel as "view",
|
||||
expiresAt: access.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Update last accessed time
|
||||
try {
|
||||
db.update(hostAccess)
|
||||
await db
|
||||
.update(hostAccess)
|
||||
.set({
|
||||
lastAccessedAt: now,
|
||||
accessCount: sql`${hostAccess.accessCount} + 1`,
|
||||
})
|
||||
.where(eq(hostAccess.id, access.id))
|
||||
.run();
|
||||
.where(eq(hostAccess.id, access.id));
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to update host access stats", {
|
||||
operation: "update_host_access_stats",
|
||||
databaseLogger.warn("Failed to update host access timestamp", {
|
||||
operation: "update_host_access_timestamp",
|
||||
error,
|
||||
});
|
||||
}
|
||||
@@ -280,7 +276,7 @@ class PermissionManager {
|
||||
hasAccess: true,
|
||||
isOwner: false,
|
||||
isShared: true,
|
||||
permissionLevel: access.permissionLevel,
|
||||
permissionLevel: access.permissionLevel as "view",
|
||||
expiresAt: access.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
817
src/backend/utils/shared-credential-manager.ts
Normal file
817
src/backend/utils/shared-credential-manager.ts
Normal 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 };
|
||||
@@ -2,7 +2,12 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
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 {
|
||||
static async insert<T extends Record<string, unknown>>(
|
||||
@@ -23,6 +28,21 @@ class SimpleDBOps {
|
||||
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) {
|
||||
delete encryptedData.id;
|
||||
}
|
||||
@@ -105,6 +125,21 @@ class SimpleDBOps {
|
||||
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()
|
||||
.update(table)
|
||||
.set(encryptedData)
|
||||
|
||||
@@ -8,6 +8,7 @@ class SystemCrypto {
|
||||
private jwtSecret: string | null = null;
|
||||
private databaseKey: Buffer | null = null;
|
||||
private internalAuthToken: string | null = null;
|
||||
private credentialSharingKey: Buffer | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -158,6 +159,48 @@ class SystemCrypto {
|
||||
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> {
|
||||
const newSecret = crypto.randomBytes(32).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> {
|
||||
try {
|
||||
const secret = await this.getJWTSecret();
|
||||
|
||||
@@ -875,6 +875,7 @@
|
||||
"selectCredentialPlaceholder": "Choose a credential...",
|
||||
"credentialRequired": "Credential is required when using credential authentication",
|
||||
"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",
|
||||
"keyPassword": "Key Password",
|
||||
"keyType": "Key Type",
|
||||
@@ -2308,11 +2309,7 @@
|
||||
"sharing": "Sharing",
|
||||
"selectUserAndRole": "Please select both a user and a role",
|
||||
"view": "View Only",
|
||||
"viewDesc": "Can view and connect to the host in read-only mode",
|
||||
"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"
|
||||
"viewDesc": "Due to the Termix encryption system, other permission levels will come at a later date"
|
||||
},
|
||||
"commandPalette": {
|
||||
"searchPlaceholder": "Search for hosts or quick actions...",
|
||||
|
||||
@@ -60,9 +60,9 @@ export interface SSHHost {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
// Shared access metadata
|
||||
// Shared access metadata (view-only)
|
||||
isShared?: boolean;
|
||||
permissionLevel?: "view" | "manage";
|
||||
permissionLevel?: "view";
|
||||
sharedExpiresAt?: string;
|
||||
}
|
||||
|
||||
@@ -394,6 +394,7 @@ export interface TabContextTab {
|
||||
hostConfig?: SSHHost;
|
||||
terminalRef?: any;
|
||||
initialTab?: string;
|
||||
_updateTimestamp?: number;
|
||||
}
|
||||
|
||||
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";
|
||||
@@ -485,6 +486,7 @@ export interface HostManagerProps {
|
||||
isTopbarOpen?: boolean;
|
||||
initialTab?: string;
|
||||
hostConfig?: SSHHost;
|
||||
_updateTimestamp?: number;
|
||||
rightSidebarOpen?: boolean;
|
||||
rightSidebarWidth?: number;
|
||||
}
|
||||
|
||||
@@ -277,6 +277,7 @@ function AppContent() {
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
initialTab={currentTabData?.initialTab}
|
||||
hostConfig={currentTabData?.hostConfig}
|
||||
_updateTimestamp={currentTabData?._updateTimestamp}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
/>
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
EllipsisVertical,
|
||||
ArrowDownUp,
|
||||
Container,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { getRecentActivity, getSSHHosts } from "@/ui/main-axios.ts";
|
||||
import type { RecentActivityItem } from "@/ui/main-axios.ts";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -52,8 +55,10 @@ interface SSHHost {
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
enableDocker: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: unknown[];
|
||||
statsConfig?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -88,7 +93,10 @@ export function CommandPalette({
|
||||
const handleAddHost = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
updateTab(sshManagerTab.id, { initialTab: "add_host" });
|
||||
updateTab(sshManagerTab.id, {
|
||||
initialTab: "add_host",
|
||||
hostConfig: undefined,
|
||||
});
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
@@ -104,7 +112,10 @@ export function CommandPalette({
|
||||
const handleAddCredential = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
updateTab(sshManagerTab.id, { initialTab: "add_credential" });
|
||||
updateTab(sshManagerTab.id, {
|
||||
initialTab: "add_credential",
|
||||
hostConfig: undefined,
|
||||
});
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
@@ -216,6 +227,22 @@ export function CommandPalette({
|
||||
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 title = host.name?.trim()
|
||||
? host.name
|
||||
@@ -301,6 +328,33 @@ export function CommandPalette({
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${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 (
|
||||
<CommandItem
|
||||
key={`host-${index}-${host.id}`}
|
||||
@@ -335,30 +389,62 @@ export function CommandPalette({
|
||||
side="right"
|
||||
className="w-56 bg-canvas border-edge text-foreground"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostServerDetailsClick(host);
|
||||
}}
|
||||
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">
|
||||
{t("commandPalette.openServerDetails")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostFileManagerClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("commandPalette.openFileManager")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
{shouldShowMetrics && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostServerDetailsClick(host);
|
||||
}}
|
||||
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">
|
||||
{t("hosts.openServerStats")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostFileManagerClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<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
|
||||
onClick={(e) => {
|
||||
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"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("commandPalette.edit")}
|
||||
</span>
|
||||
<span className="flex-1">{t("common.edit")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -317,7 +317,10 @@ export function Dashboard({
|
||||
const handleAddHost = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
updateTab(sshManagerTab.id, { initialTab: "add_host" });
|
||||
updateTab(sshManagerTab.id, {
|
||||
initialTab: "add_host",
|
||||
hostConfig: undefined,
|
||||
});
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
@@ -332,7 +335,10 @@ export function Dashboard({
|
||||
const handleAddCredential = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
updateTab(sshManagerTab.id, { initialTab: "add_credential" });
|
||||
updateTab(sshManagerTab.id, {
|
||||
initialTab: "add_credential",
|
||||
hostConfig: undefined,
|
||||
});
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
|
||||
@@ -18,6 +18,7 @@ export function HostManager({
|
||||
isTopbarOpen,
|
||||
initialTab = "host_viewer",
|
||||
hostConfig,
|
||||
_updateTimestamp,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
}: HostManagerProps): React.ReactElement {
|
||||
@@ -36,20 +37,39 @@ export function HostManager({
|
||||
const ignoreNextHostConfigChangeRef = useRef<boolean>(false);
|
||||
const lastProcessedHostIdRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// Sync state when tab is updated externally (via updateTab or addTab)
|
||||
useEffect(() => {
|
||||
if (initialTab) {
|
||||
setActiveTab(initialTab);
|
||||
}
|
||||
}, [initialTab]);
|
||||
// Always sync on timestamp changes
|
||||
if (_updateTimestamp !== undefined) {
|
||||
// Update activeTab if initialTab has changed
|
||||
if (initialTab && initialTab !== activeTab) {
|
||||
setActiveTab(initialTab);
|
||||
}
|
||||
|
||||
// Update editingHost when hostConfig changes
|
||||
useEffect(() => {
|
||||
if (hostConfig) {
|
||||
setEditingHost(hostConfig);
|
||||
setActiveTab("add_host");
|
||||
lastProcessedHostIdRef.current = hostConfig.id;
|
||||
// Update editingHost if hostConfig has changed
|
||||
if (hostConfig && hostConfig.id !== editingHost?.id) {
|
||||
setEditingHost(hostConfig);
|
||||
lastProcessedHostIdRef.current = hostConfig.id;
|
||||
} else if (!hostConfig && editingHost) {
|
||||
// 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) => {
|
||||
setEditingHost(host);
|
||||
|
||||
@@ -1465,6 +1465,7 @@ export function HostManagerEditor({
|
||||
<Tabs
|
||||
value={authTab}
|
||||
onValueChange={(value) => {
|
||||
if (editingHost?.isShared) return;
|
||||
const newAuthType = value as
|
||||
| "password"
|
||||
| "key"
|
||||
@@ -1478,25 +1479,29 @@ export function HostManagerEditor({
|
||||
<TabsList className="bg-button border border-edge-medium">
|
||||
<TabsTrigger
|
||||
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")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
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")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
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")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
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")}
|
||||
</TabsTrigger>
|
||||
@@ -1709,26 +1714,34 @@ export function HostManagerEditor({
|
||||
name="credentialId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<CredentialSelector
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
onCredentialSelect={(credential) => {
|
||||
if (
|
||||
credential &&
|
||||
!form.getValues(
|
||||
"overrideCredentialUsername",
|
||||
)
|
||||
) {
|
||||
form.setValue(
|
||||
"username",
|
||||
credential.username,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t("hosts.credentialDescription")}
|
||||
</FormDescription>
|
||||
{editingHost?.isShared ? (
|
||||
<div className="text-sm text-muted-foreground p-3 bg-base border border-edge-medium rounded-md">
|
||||
{t("hosts.cannotChangeAuthAsSharedUser")}
|
||||
</div>
|
||||
) : (
|
||||
<CredentialSelector
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
onCredentialSelect={(credential) => {
|
||||
if (
|
||||
credential &&
|
||||
!form.getValues(
|
||||
"overrideCredentialUsername",
|
||||
)
|
||||
) {
|
||||
form.setValue(
|
||||
"username",
|
||||
credential.username,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!editingHost?.isShared && (
|
||||
<FormDescription>
|
||||
{t("hosts.credentialDescription")}
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -3769,7 +3782,7 @@ export function HostManagerEditor({
|
||||
</ScrollArea>
|
||||
<footer className="shrink-0 w-full pb-0">
|
||||
<Separator className="p-0.25" />
|
||||
{!(editingHost?.permissionLevel === "view") && (
|
||||
{!editingHost?.isShared && (
|
||||
<Button className="translate-y-2" type="submit" variant="outline">
|
||||
{editingHost
|
||||
? editingHost.id
|
||||
|
||||
@@ -76,10 +76,8 @@ interface User {
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
const PERMISSION_LEVELS = [
|
||||
{ value: "view", labelKey: "rbac.view" },
|
||||
{ value: "manage", labelKey: "rbac.manage" },
|
||||
];
|
||||
// Only view permission is supported (manage removed due to encryption constraints)
|
||||
const PERMISSION_LEVELS = [{ value: "view", labelKey: "rbac.view" }];
|
||||
|
||||
export function HostSharingTab({
|
||||
hostId,
|
||||
@@ -430,26 +428,12 @@ export function HostSharingTab({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Permission Level */}
|
||||
{/* Permission Level - Always "view" (read-only) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="permission-level">
|
||||
{t("rbac.permissionLevel")}
|
||||
</Label>
|
||||
<Select
|
||||
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>
|
||||
<Label>{t("rbac.permissionLevel")}</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("rbac.view")} - {t("rbac.viewDesc")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expiration */}
|
||||
@@ -496,7 +480,6 @@ export function HostSharingTab({
|
||||
<TableHead>{t("rbac.permissionLevel")}</TableHead>
|
||||
<TableHead>{t("rbac.grantedBy")}</TableHead>
|
||||
<TableHead>{t("rbac.expires")}</TableHead>
|
||||
<TableHead>{t("rbac.accessCount")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
@@ -506,7 +489,7 @@ export function HostSharingTab({
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("common.loading")}
|
||||
@@ -515,7 +498,7 @@ export function HostSharingTab({
|
||||
) : accessList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("rbac.noAccessRecords")}
|
||||
@@ -582,7 +565,6 @@ export function HostSharingTab({
|
||||
t("rbac.never")
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{access.accessCount}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -790,7 +790,7 @@ export function Auth({
|
||||
}}
|
||||
{...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="text-center">
|
||||
<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}
|
||||
>
|
||||
<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)" }}
|
||||
>
|
||||
<div className="mb-6 text-center">
|
||||
|
||||
@@ -84,6 +84,19 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
const shouldShowStatus = statsConfig.statusCheckEnabled !== 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(() => {
|
||||
if (!shouldShowStatus) {
|
||||
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"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">{t('hosts.openServerStats')}</span>
|
||||
<span className="flex-1">{t("hosts.openServerStats")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{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"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">{t('hosts.openFileManager')}</span>
|
||||
<span className="flex-1">{t("hosts.openFileManager")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
{host.enableTunnel && hasTunnelConnections && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
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"
|
||||
>
|
||||
<ArrowDownUp className="h-4 w-4" />
|
||||
<span className="flex-1">{t('hosts.openTunnels')}</span>
|
||||
<span className="flex-1">{t("hosts.openTunnels")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{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"
|
||||
>
|
||||
<Container className="h-4 w-4" />
|
||||
<span className="flex-1">{t('hosts.openDocker')}</span>
|
||||
<span className="flex-1">{t("hosts.openDocker")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({
|
||||
type: "ssh_manager",
|
||||
title: t('nav.hostManager'),
|
||||
title: t("nav.hostManager"),
|
||||
hostConfig: 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"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="flex-1">{t('common.edit')}</span>
|
||||
<span className="flex-1">{t("common.edit")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -280,7 +280,11 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
|
||||
const updateTab = (tabId: number, updates: Partial<Omit<Tab, "id">>) => {
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) => (tab.id === tabId ? { ...tab, ...updates } : tab)),
|
||||
prev.map((tab) =>
|
||||
tab.id === tabId
|
||||
? { ...tab, ...updates, _updateTimestamp: Date.now() }
|
||||
: tab,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -48,11 +48,9 @@ export interface AccessRecord {
|
||||
roleDisplayName: string | null;
|
||||
grantedBy: string;
|
||||
grantedByUsername: string;
|
||||
permissionLevel: string;
|
||||
permissionLevel: "view"; // Only view permission is supported
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
lastAccessedAt: string | null;
|
||||
accessCount: number;
|
||||
}
|
||||
import {
|
||||
apiLogger,
|
||||
@@ -3292,7 +3290,7 @@ export async function shareHost(
|
||||
targetType: "user" | "role";
|
||||
targetUserId?: string;
|
||||
targetRoleId?: number;
|
||||
permissionLevel: string;
|
||||
permissionLevel: "view"; // Only view permission is supported
|
||||
durationHours?: number;
|
||||
},
|
||||
): Promise<{ success: boolean }> {
|
||||
|
||||
Reference in New Issue
Block a user