fix: rbac implementation general issues (local squash)

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

260
log.txt Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -475,6 +475,56 @@ class DataCrypto {
return false;
}
}
/**
* 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 };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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