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