From bba06f7ff65a68ae4af0a81585e7e4939ea2d278 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 29 Sep 2026 12:33:09 -0500 Subject: [PATCH] feat: store rdp, vnc and telnet logins in a generic per-protocol table --- drizzle/mysql/0063_needy_radioactive_man.sql | 26 + drizzle/mysql/meta/0063_snapshot.json | 4775 +++++++++++++++++ drizzle/mysql/meta/_journal.json | 7 + drizzle/postgres/0063_giant_starfox.sql | 25 + drizzle/postgres/meta/0063_snapshot.json | 4758 ++++++++++++++++ drizzle/postgres/meta/_journal.json | 7 + drizzle/sqlite/0058_bitter_thunderbolt.sql | 25 + drizzle/sqlite/meta/0058_snapshot.json | 4503 ++++++++++++++++ drizzle/sqlite/meta/_journal.json | 7 + .../plugin-sdk/schema/manifest.schema.json | 49 + packages/plugin-sdk/src/backend.ts | 22 +- packages/plugin-sdk/src/frontend.ts | 50 +- packages/plugin-sdk/src/manifest.ts | 129 + packages/plugin-sdk/src/testing.ts | 6 + plugins/proxmox/src/backend/routes.ts | 7 - plugins/remote-desktop/manifest.json | 22 + .../src/backend/host-settings.ts | 36 + plugins/remote-desktop/src/backend/index.ts | 2 + plugins/remote-desktop/src/backend/routes.ts | 2 +- .../src/frontend/GuacamoleApp.tsx | 5 +- .../src/frontend/HostEditorGuacamoleTabs.tsx | 23 +- .../src/frontend/host-remote.ts | 24 +- plugins/remote-desktop/src/frontend/index.tsx | 5 +- .../src/frontend/quick-connect-guac-host.ts | 12 +- .../src/frontend/remote-form.ts | 91 +- .../tests/backend/host-settings.test.ts | 36 + .../tests/backend/routes.test.ts | 2 +- .../frontend/quick-connect-guac-host.test.ts | 17 +- .../tests/frontend/remote-form.test.ts | 91 + src/backend/auth/login-pipeline.ts | 3 + src/backend/boot.ts | 6 + src/backend/database/database.ts | 96 +- src/backend/database/db/index.ts | 79 +- src/backend/database/db/schema.mysql.ts | 62 +- src/backend/database/db/schema.pg.ts | 62 +- src/backend/database/db/schema.ts | 62 +- src/backend/database/repositories/factory.ts | 8 + .../host-protocol-auth-repository.ts | 243 + .../host-resolution-repository.ts | 6 - .../repositories/rbac-access-repository.ts | 6 - .../shared-host-secrets-repository.ts | 29 +- .../database/routes/host-bulk-routes.ts | 25 +- .../database/routes/host-normalizers.ts | 83 +- src/backend/database/routes/host.ts | 285 +- src/backend/database/routes/rbac.ts | 4 +- .../database/routes/user-settings-routes.ts | 2 +- src/backend/hosts/external-secrets.ts | 3 - .../hosts/protocol-auth/protocol-auth.ts | 660 +++ src/backend/hosts/protocol-auth/registry.ts | 63 + src/backend/hosts/protocol-auth/summary.ts | 43 + src/backend/plugins/ctx-credentials.ts | 124 +- src/backend/plugins/ctx-hosts.ts | 3 - src/backend/plugins/index.ts | 12 + src/backend/sync/entities.ts | 36 +- src/backend/sync/host-protocol-auth.ts | 84 + src/backend/sync/store.ts | 8 +- .../host-protocol-auth-repository.test.ts | 178 + .../host-resolution-repository.test.ts | 3 - .../rbac-access-repository.test.ts | 7 +- .../shared-host-secrets-repository.test.ts | 11 +- .../database/routes/host-normalizers.test.ts | 107 +- .../routes/rbac-host-auth-override.test.ts | 17 +- src/backend/tests/fixtures/upgrade/db.sqlite | Bin 909312 -> 909312 bytes src/backend/tests/fixtures/upgrade/rows.ts | 10 + .../tests/fixtures/upgrade/upgrade-mysql.sql | 28 + .../fixtures/upgrade/upgrade-postgres.sql | 27 + .../hosts/protocol-auth/protocol-auth.test.ts | 205 + .../hosts/protocol-auth/registry.test.ts | 48 + .../tests/hosts/protocol-auth/summary.test.ts | 39 + .../tests/plugins/ctx-credentials.test.ts | 181 +- src/backend/tests/plugins/manifest.test.ts | 64 + .../plugins/protocol-auth-fixture.test.ts | 387 ++ .../security/credential-shapes.test.ts | 3 - .../tests/plugins/upgrade-fixture.test.ts | 40 +- .../tests/sync/host-protocol-auth.test.ts | 131 + .../upgrade/protocol-auth-migration.test.ts | 223 + .../utils/shared-host-secrets-manager.test.ts | 252 +- .../upgrade/protocol-auth-migration.ts | 241 + src/backend/utils/field-crypto.ts | 3 - src/backend/utils/lazy-field-encryption.ts | 36 - .../shared-host-auth-override-service.ts | 11 +- .../utils/shared-host-auth-resolver.ts | 16 +- .../utils/shared-host-secrets-manager.ts | 154 +- src/backend/utils/swagger.ts | 2 +- src/backend/utils/user-data-export.ts | 23 +- src/types/auth-protocols.ts | 55 +- src/types/index.ts | 57 +- src/types/ui-types.ts | 24 +- src/ui/api/admin-user-data-api.ts | 9 +- src/ui/api/credentials-api.ts | 9 +- src/ui/lib/connection-origin.ts | 2 +- src/ui/lib/host-connection-tabs.ts | 4 +- src/ui/main-axios.ts | 4 +- src/ui/sidebar/HostAuthOverrideModal.tsx | 7 +- src/ui/sidebar/HostEditor.tsx | 19 - src/ui/sidebar/HostEditorData.ts | 150 +- src/ui/sidebar/HostManager.tsx | 2 +- src/ui/sidebar/HostManagerData.ts | 55 +- src/ui/sidebar/host-permissions.ts | 32 +- src/ui/sidebar/host-protocols.ts | 2 +- src/ui/sidebar/quick-connect-host.ts | 17 +- src/ui/sidebar/tree/HostItem/HostItem.tsx | 12 +- src/ui/sidebar/tree/SidebarTree.tsx | 23 +- .../sidebar/HostAuthOverrideModal.test.tsx | 2 +- .../tests/sidebar/HostCredentialList.test.tsx | 4 +- src/ui/tests/sidebar/HostEditorData.test.ts | 166 +- .../tests/sidebar/quick-connect-host.test.ts | 11 +- 107 files changed, 18750 insertions(+), 1224 deletions(-) create mode 100644 drizzle/mysql/0063_needy_radioactive_man.sql create mode 100644 drizzle/mysql/meta/0063_snapshot.json create mode 100644 drizzle/postgres/0063_giant_starfox.sql create mode 100644 drizzle/postgres/meta/0063_snapshot.json create mode 100644 drizzle/sqlite/0058_bitter_thunderbolt.sql create mode 100644 drizzle/sqlite/meta/0058_snapshot.json create mode 100644 plugins/remote-desktop/tests/frontend/remote-form.test.ts create mode 100644 src/backend/database/repositories/host-protocol-auth-repository.ts create mode 100644 src/backend/hosts/protocol-auth/protocol-auth.ts create mode 100644 src/backend/hosts/protocol-auth/registry.ts create mode 100644 src/backend/hosts/protocol-auth/summary.ts create mode 100644 src/backend/sync/host-protocol-auth.ts create mode 100644 src/backend/tests/database/repositories/host-protocol-auth-repository.test.ts create mode 100644 src/backend/tests/hosts/protocol-auth/protocol-auth.test.ts create mode 100644 src/backend/tests/hosts/protocol-auth/registry.test.ts create mode 100644 src/backend/tests/hosts/protocol-auth/summary.test.ts create mode 100644 src/backend/tests/plugins/protocol-auth-fixture.test.ts create mode 100644 src/backend/tests/sync/host-protocol-auth.test.ts create mode 100644 src/backend/tests/upgrade/protocol-auth-migration.test.ts create mode 100644 src/backend/upgrade/protocol-auth-migration.ts diff --git a/drizzle/mysql/0063_needy_radioactive_man.sql b/drizzle/mysql/0063_needy_radioactive_man.sql new file mode 100644 index 000000000..d6abb22d5 --- /dev/null +++ b/drizzle/mysql/0063_needy_radioactive_man.sql @@ -0,0 +1,26 @@ +-- The RDP, VNC and Telnet logins moved out of ssh_data into host_protocol_auth. +-- A boot migration copies them after this runs, so the old ssh_data columns +-- and their foreign keys stay in place, unused, until 3.0.0. +CREATE TABLE `host_protocol_auth` ( + `id` int AUTO_INCREMENT NOT NULL, + `host_id` int NOT NULL, + `user_id` varchar(255) NOT NULL, + `protocol` varchar(255) NOT NULL, + `auth_type` text NOT NULL DEFAULT ('direct'), + `credential_id` int, + `username` text, + `password` text, + `fields` text, + `secret_fields` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `host_protocol_auth_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_host_protocol_auth_host_protocol` UNIQUE(`host_id`,`protocol`) +); +--> statement-breakpoint +ALTER TABLE `shared_host_secrets` ADD `encrypted_fields` text;--> statement-breakpoint +ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_host_protocol_auth_user` ON `host_protocol_auth` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_host_protocol_auth_credential` ON `host_protocol_auth` (`credential_id`); diff --git a/drizzle/mysql/meta/0063_snapshot.json b/drizzle/mysql/meta/0063_snapshot.json new file mode 100644 index 000000000..cc50fa1ee --- /dev/null +++ b/drizzle/mysql/meta/0063_snapshot.json @@ -0,0 +1,4775 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "81525cde-2267-463d-8352-2b41916bc2c8", + "prevId": "d1cbf786-2bd3-4aad-b99e-b63fc5d10a6a", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "api_keys_id": { + "name": "api_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + "action", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + "resource_type", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audit_logs_id": { + "name": "audit_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "credential_access": { + "name": "credential_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('use')" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "credential_access_id": { + "name": "credential_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "credential_sidebar_preferences_user_id": { + "name": "credential_sidebar_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folder_access": { + "name": "folder_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('connect')" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + "owner_user_id", + "folder" + ], + "isUnique": false + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "folder_access_id": { + "name": "folder_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('connect')" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_access_id": { + "name": "host_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_protocol_auth": { + "name": "host_protocol_auth", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('direct')" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_fields": { + "name": "secret_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + "host_id", + "protocol" + ], + "isUnique": true + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_protocol_auth_id": { + "name": "host_protocol_auth_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_sidebar_preferences_user_id": { + "name": "host_sidebar_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('ssh')" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_options": { + "name": "ssh_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('sha256')" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + "parent_host_id" + ], + "isUnique": false + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_data_id": { + "name": "ssh_data_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "plugin_install_counts": { + "name": "plugin_install_counts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('aggregate-telemetry')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + "plugin_id", + "registry_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_install_counts_id": { + "name": "plugin_install_counts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_migrations": { + "name": "plugin_migrations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "migration_id": { + "name": "migration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + "plugin_id", + "migration_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_migrations_id": { + "name": "plugin_migrations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_permission_grants": { + "name": "plugin_permission_grants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('admin')" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + "plugin_id", + "capability" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_permission_grants_id": { + "name": "plugin_permission_grants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_registries": { + "name": "plugin_registries", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('community')" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_registries_id": { + "name": "plugin_registries_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "enum('admin','user','host','secret')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + "plugin_id", + "scope", + "scope_id", + "key" + ], + "isUnique": true + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + "plugin_id", + "scope" + ], + "isUnique": false + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_settings_id": { + "name": "plugin_settings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + "plugin_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_storage_id": { + "name": "plugin_storage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('available')" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('community')" + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('disabled')" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + "registry_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugins_id": { + "name": "plugins_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "role_name": { + "name": "role_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + "role_name", + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rbac_applied_defaults_id": { + "name": "rbac_applied_defaults_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rbac_known_permissions": { + "name": "rbac_known_permissions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rbac_known_permissions_id": { + "name": "rbac_known_permissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "recent_activity": { + "name": "recent_activity", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "recent_activity_id": { + "name": "recent_activity_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "roles_id": { + "name": "roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "settings_key": { + "name": "settings_key", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_credential_secrets": { + "name": "shared_credential_secrets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('password')" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + "credential_access_id", + "target_user_id" + ], + "isUnique": true + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + "target_user_id", + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_credential_secrets_id": { + "name": "shared_credential_secrets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + "host_id", + "user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_host_auth_overrides_id": { + "name": "shared_host_auth_overrides_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('credential')" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_fields": { + "name": "encrypted_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_host_secrets_id": { + "name": "shared_host_secrets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credential_usage_id": { + "name": "ssh_credential_usage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credentials_id": { + "name": "ssh_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_folders_id": { + "name": "ssh_folders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "sync_conflicts": { + "name": "sync_conflicts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_revision": { + "name": "server_revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_conflicts_id": { + "name": "sync_conflicts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_link": { + "name": "sync_link", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('idle')" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_link_id": { + "name": "sync_link_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_records": { + "name": "sync_records", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + "user_id", + "entity_type", + "sync_id" + ], + "isUnique": true + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + "user_id", + "seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_records_id": { + "name": "sync_records_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "trusted_devices": { + "name": "trusted_devices", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "trusted_devices_id": { + "name": "trusted_devices_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ui_preferences_user_id": { + "name": "ui_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_external_identities": { + "name": "user_external_identities", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + "provider_id", + "subject" + ], + "isUnique": true + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_external_identities_id": { + "name": "user_external_identities_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_open_tabs": { + "name": "user_open_tabs", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_open_tabs_id": { + "name": "user_open_tabs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_autocomplete": { + "name": "command_autocomplete", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_defaults": { + "name": "terminal_defaults", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_macros": { + "name": "terminal_macros", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_preferences_user_id": { + "name": "user_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + "user_id", + "role_id" + ], + "isUnique": true + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_id": { + "name": "user_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_second_factors": { + "name": "user_second_factors", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "factor_id": { + "name": "factor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + "user_id", + "plugin_id", + "factor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_second_factors_id": { + "name": "user_second_factors_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('openid email profile')" + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/mysql/meta/_journal.json b/drizzle/mysql/meta/_journal.json index ab8bec959..e20a4e154 100644 --- a/drizzle/mysql/meta/_journal.json +++ b/drizzle/mysql/meta/_journal.json @@ -442,6 +442,13 @@ "when": 1790651054515, "tag": "0062_smooth_speed", "breakpoints": true + }, + { + "idx": 63, + "version": "5", + "when": 1790700739900, + "tag": "0063_needy_radioactive_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/postgres/0063_giant_starfox.sql b/drizzle/postgres/0063_giant_starfox.sql new file mode 100644 index 000000000..34e1b976b --- /dev/null +++ b/drizzle/postgres/0063_giant_starfox.sql @@ -0,0 +1,25 @@ +-- The RDP, VNC and Telnet logins moved out of ssh_data into host_protocol_auth. +-- A boot migration copies them after this runs, so the old ssh_data columns +-- and their foreign keys stay in place, unused, until 3.0.0. +CREATE TABLE "host_protocol_auth" ( + "id" serial PRIMARY KEY NOT NULL, + "host_id" integer NOT NULL, + "user_id" varchar(255) NOT NULL, + "protocol" varchar(255) NOT NULL, + "auth_type" text DEFAULT 'direct' NOT NULL, + "credential_id" integer, + "username" text, + "password" text, + "fields" text, + "secret_fields" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +ALTER TABLE "shared_host_secrets" ADD COLUMN "encrypted_fields" text;--> statement-breakpoint +ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_host_protocol_auth_host_protocol" ON "host_protocol_auth" USING btree ("host_id","protocol");--> statement-breakpoint +CREATE INDEX "idx_host_protocol_auth_user" ON "host_protocol_auth" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "idx_host_protocol_auth_credential" ON "host_protocol_auth" USING btree ("credential_id"); diff --git a/drizzle/postgres/meta/0063_snapshot.json b/drizzle/postgres/meta/0063_snapshot.json new file mode 100644 index 000000000..9495f2aff --- /dev/null +++ b/drizzle/postgres/meta/0063_snapshot.json @@ -0,0 +1,4758 @@ +{ + "id": "30d79a7f-1cb5-4b61-aeb4-73ba1c0dc39d", + "prevId": "4a556893-db89-4ba1-9412-b29f731a9008", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_access": { + "name": "credential_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'use'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder_access": { + "name": "folder_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_access": { + "name": "host_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_protocol_auth": { + "name": "host_protocol_auth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'direct'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_fields": { + "name": "secret_fields", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_data": { + "name": "ssh_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_options": { + "name": "ssh_options", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 22 + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + { + "expression": "parent_host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_install_counts": { + "name": "plugin_install_counts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'aggregate-telemetry'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_migrations": { + "name": "plugin_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "migration_id": { + "name": "migration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_permission_grants": { + "name": "plugin_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "capability": { + "name": "capability", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "capability", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_registries": { + "name": "plugin_registries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'community'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_settings": { + "name": "plugin_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'community'" + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'disabled'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + { + "expression": "registry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "role_name": { + "name": "role_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + { + "expression": "role_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_known_permissions": { + "name": "rbac_known_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recent_activity": { + "name": "recent_activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_credential_secrets": { + "name": "shared_credential_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'password'" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + { + "expression": "credential_access_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_host_secrets": { + "name": "shared_host_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_fields": { + "name": "encrypted_fields", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + { + "expression": "host_access_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credential_usage": { + "name": "ssh_credential_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credentials": { + "name": "ssh_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_folders": { + "name": "ssh_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_conflicts": { + "name": "sync_conflicts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_revision": { + "name": "server_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_link": { + "name": "sync_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_records": { + "name": "sync_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sync_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trusted_devices": { + "name": "trusted_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ui_preferences": { + "name": "ui_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_external_identities": { + "name": "user_external_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_open_tabs": { + "name": "user_open_tabs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command_autocomplete": { + "name": "command_autocomplete", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_defaults": { + "name": "terminal_defaults", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_macros": { + "name": "terminal_macros", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_second_factors": { + "name": "user_second_factors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "factor_id": { + "name": "factor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "factor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'openid email profile'" + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index 45a4430ed..6caadff3a 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -442,6 +442,13 @@ "when": 1790651053871, "tag": "0062_curvy_killmonger", "breakpoints": true + }, + { + "idx": 63, + "version": "7", + "when": 1790700738799, + "tag": "0063_giant_starfox", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0058_bitter_thunderbolt.sql b/drizzle/sqlite/0058_bitter_thunderbolt.sql new file mode 100644 index 000000000..eb02149c5 --- /dev/null +++ b/drizzle/sqlite/0058_bitter_thunderbolt.sql @@ -0,0 +1,25 @@ +-- The RDP, VNC and Telnet logins moved out of ssh_data into host_protocol_auth. +-- A boot migration copies them after this runs, so the old ssh_data columns +-- are not dropped here; they stay in place, unused, until 3.0.0. +CREATE TABLE `host_protocol_auth` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `host_id` integer NOT NULL, + `user_id` text NOT NULL, + `protocol` text NOT NULL, + `auth_type` text DEFAULT 'direct' NOT NULL, + `credential_id` integer, + `username` text, + `password` text, + `fields` text, + `secret_fields` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_host_protocol_auth_host_protocol` ON `host_protocol_auth` (`host_id`,`protocol`);--> statement-breakpoint +CREATE INDEX `idx_host_protocol_auth_user` ON `host_protocol_auth` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_host_protocol_auth_credential` ON `host_protocol_auth` (`credential_id`);--> statement-breakpoint +ALTER TABLE `shared_host_secrets` ADD `encrypted_fields` text; \ No newline at end of file diff --git a/drizzle/sqlite/meta/0058_snapshot.json b/drizzle/sqlite/meta/0058_snapshot.json new file mode 100644 index 000000000..1bd964117 --- /dev/null +++ b/drizzle/sqlite/meta/0058_snapshot.json @@ -0,0 +1,4503 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6cf2bc9e-0162-4233-a2c0-0285be6f8763", + "prevId": "d6a6fe2a-f47e-4553-b490-65cd5ba3d64f", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + "action", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + "resource_type", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credential_access": { + "name": "credential_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'use'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "folder_access": { + "name": "folder_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + "owner_user_id", + "folder" + ], + "isUnique": false + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_protocol_auth": { + "name": "host_protocol_auth", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_fields": { + "name": "secret_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + "host_id", + "protocol" + ], + "isUnique": true + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_options": { + "name": "ssh_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "use_socks5": { + "name": "use_socks5", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + "parent_host_id" + ], + "isUnique": false + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_install_counts": { + "name": "plugin_install_counts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registry_id": { + "name": "registry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'aggregate-telemetry'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + "plugin_id", + "registry_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_migrations": { + "name": "plugin_migrations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "migration_id": { + "name": "migration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + "plugin_id", + "migration_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_permission_grants": { + "name": "plugin_permission_grants", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + "plugin_id", + "capability" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_registries": { + "name": "plugin_registries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'community'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted": { + "name": "encrypted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + "plugin_id", + "scope", + "scope_id", + "key" + ], + "isUnique": true + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + "plugin_id", + "scope" + ], + "isUnique": false + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + "plugin_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'available'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'community'" + }, + "registry_id": { + "name": "registry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'disabled'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "auto_update": { + "name": "auto_update", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + "registry_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + "role_name", + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rbac_known_permissions": { + "name": "rbac_known_permissions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recent_activity": { + "name": "recent_activity", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_credential_secrets": { + "name": "shared_credential_secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'password'" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + "credential_access_id", + "target_user_id" + ], + "isUnique": true + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + "target_user_id", + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + "host_id", + "user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_fields": { + "name": "encrypted_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_conflicts": { + "name": "sync_conflicts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_revision": { + "name": "server_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_link": { + "name": "sync_link", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_records": { + "name": "sync_records", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + "user_id", + "entity_type", + "sync_id" + ], + "isUnique": true + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + "user_id", + "seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_devices": { + "name": "trusted_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_external_identities": { + "name": "user_external_identities", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + "provider_id", + "subject" + ], + "isUnique": true + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_open_tabs": { + "name": "user_open_tabs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_autocomplete": { + "name": "command_autocomplete", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_defaults": { + "name": "terminal_defaults", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_macros": { + "name": "terminal_macros", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + "user_id", + "role_id" + ], + "isUnique": true + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_second_factors": { + "name": "user_second_factors", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "factor_id": { + "name": "factor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + "user_id", + "plugin_id", + "factor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'openid email profile'" + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index e569cf85b..1fdf032c3 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -407,6 +407,13 @@ "when": 1790651053202, "tag": "0057_green_hydra", "breakpoints": true + }, + { + "idx": 58, + "version": "6", + "when": 1790700721290, + "tag": "0058_bitter_thunderbolt", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index bc206c474..e9cd6dd35 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -279,6 +279,55 @@ "guest": { "type": "boolean" }, + "protocols": { + "type": "array", + "description": "Connection protocols whose per-host login core stores, encrypts, shares and syncs for the plugin.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "not": { "enum": ["ssh"] } + }, + "credentialFields": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["key"], + "properties": { + "key": { + "type": "string", + "pattern": "^[a-zA-Z][a-zA-Z0-9]{0,31}$", + "not": { + "enum": [ + "username", + "password", + "authType", + "credentialId" + ] + } + }, + "secret": { "type": "boolean" } + } + } + }, + "defaultPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "hostLoginFallback": { + "type": "array", + "uniqueItems": true, + "items": { "enum": ["username", "password"] } + } + } + } + }, "keybindingActions": { "type": "array", "description": "Keybinding actions the plugin adds, with the parameters a saved binding carries.", diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index ddbd6dc70..cc018f370 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -167,6 +167,8 @@ export interface SyncWriteEvent { /** The row as it arrived, before references were resolved. */ wire: SyncRow; created: boolean; + /** Another entity's syncId to its local id here, for ids serialize put on the wire. */ + resolveId?: (entityType: string, syncId: string) => Promise; } export interface SyncEntityRegistration { @@ -586,7 +588,7 @@ export interface PluginHostRecord { /** * Fields a plugin may set when creating a host it will own or manage. Fields - * that belong to another plugin's host settings (enableRdp, rdpPort) are + * that belong to another plugin's host settings (a protocol switch or port) are * handed to that plugin's host import normalizer, as on a bulk import. */ export type PluginHostCreateInput = Partial< @@ -964,7 +966,7 @@ export interface PluginSsh { /** * A ready client at the end of a jump host chain, each hop resolved and * authenticated through the pipeline. For forwarding to something that is - * not an SSH server (an RDP port behind a bastion). + * not an SSH server (a remote desktop port behind a bastion). */ jumpChain: ( jumpHosts: Array<{ hostId: number }>, @@ -1426,8 +1428,11 @@ export interface PluginDesktop { available: () => boolean; } -/** A host protocol whose credentials core keeps next to the host. */ -export type PluginHostProtocol = "rdp" | "vnc" | "telnet"; +/** + * A host protocol whose login core keeps next to the host: any id a plugin + * declares in contributes.protocols. + */ +export type PluginHostProtocol = string; /** * What a plugin needs to hand a host's protocol login to something core @@ -1452,7 +1457,8 @@ export interface PluginProtocolTarget { authType: string; username: string; password: string; - domain: string; + /** The protocol's declared credentialFields, secret ones included; "" when unset. */ + fields: Record; }; } @@ -1492,7 +1498,11 @@ export interface PluginCredentials { * permission and an unlocked data key. Audited. */ createSshKey: (input: PluginSshKeyCredentialInput) => Promise<{ id: number }>; - /** Null when the host does not exist or the acting user cannot connect to it. */ + /** + * The host's login for one of this plugin's own contributes.protocols. + * Null when the host does not exist or the acting user cannot connect to + * it. Needs credentials:read. Audited. + */ resolveHostProtocol: ( hostId: number, protocol: PluginHostProtocol, diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index acb4c9a88..1893c18bb 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -45,9 +45,55 @@ export interface PluginHostRecord { sshOptions?: HostSshOptions | null; /** Each enabled plugin's host settings, secrets redacted. */ pluginSettings?: Record>; + /** Each declared protocol's login, secrets left out. */ + protocolAuth?: Record; + /** Set on a Quick Connect host, which is never saved. */ + quickConnectLogin?: QuickConnectLogin; [key: string]: unknown; } +/** The protocol login a Quick Connect host carries, in plain text. */ +export interface QuickConnectLogin { + protocol: string; + username?: string; + password?: string; + /** "domain" when the protocol's Quick Connect entry shows that field. */ + fields?: Record; +} + +/** "direct" (a username and password), "credential" (a saved one) or "none". */ +export type HostProtocolAuthType = "direct" | "credential" | "none"; + +/** A host's login for a protocol a plugin declares, as the host API returns it. */ +export interface HostProtocolAuthSummary { + authType: HostProtocolAuthType; + /** Left out for a shared recipient at connect level. */ + credentialId?: number | null; + username?: string | null; + /** The declared non-secret credential fields. */ + fields?: Record; + /** Owner only. */ + hasPassword?: boolean; + /** Owner only: the secret credential fields that hold a value. */ + secretFieldKeys?: string[]; +} + +/** + * One protocol's login in the host editor form (`form.protocolAuth[id]`). + * Core sends it with the host; `password` and secret fields hold + * HOST_PROTOCOL_SECRET_KEPT while the saved value is left alone. + */ +export interface HostProtocolAuthForm { + authType: HostProtocolAuthType; + credentialId: string; + username: string; + password: string; + fields: Record; +} + +/** Stands in for a saved protocol secret the editor has not changed. */ +export const HOST_PROTOCOL_SECRET_KEPT = "existing_protocol_secret"; + /** An open tab as the shell holds it. `data` is the plugin's own payload. */ export interface PluginTabRecord { id: string; @@ -328,7 +374,7 @@ export interface HostActionContribution { titleKey: string; icon: IconComponent; /** - * "connect" is a way to open a session (terminal, RDP) and is offered as a + * "connect" is a way to open a session (terminal, remote desktop) and is offered as a * host's default action by priority. "open" opens a tool for the host. */ kind: "connect" | "open"; @@ -1197,7 +1243,7 @@ export function useSshAuthTypes(): { */ /** * The connection protocols a host has switched on: "ssh" plus every protocol - * a running plugin registered with registerHostProtocol (RDP, VNC). Lets a + * a running plugin registered with registerHostProtocol. Lets a * plugin offer a protocol without knowing which plugin owns its settings. */ export function hostProtocols(record: PluginHostRecord): string[] { diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 38f256146..c28a6e8a9 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -351,8 +351,48 @@ export interface PluginContributions { * naming a sync entity over sync. */ keybindingActions?: PluginKeybindingActionContribution[]; + /** + * Connection protocols whose per-host login core stores, encrypts, + * shares, exports and syncs for this plugin, read back with + * ctx.credentials.resolveHostProtocol. + */ + protocols?: PluginProtocolContribution[]; } +/** A field a protocol login carries besides username and password. */ +export interface PluginProtocolCredentialField { + key: string; + /** Encrypted with the owner's data key and never sent to a browser. */ + secret?: boolean; +} + +export interface PluginProtocolContribution { + /** Stored with each login. Never change it once hosts use it. */ + id: string; + credentialFields?: PluginProtocolCredentialField[]; + /** + * The port an import row without one gets, for a host whose main + * protocol this is. + */ + defaultPort?: number; + /** + * What the owner's login falls back to from the host's own SSH login + * when the protocol login leaves it empty. + */ + hostLoginFallback?: Array<"username" | "password">; +} + +/** Protocol ids core keeps for itself. */ +export const RESERVED_PROTOCOL_IDS: readonly string[] = ["ssh"]; + +/** Login keys a declared credential field may not reuse. */ +export const RESERVED_PROTOCOL_FIELD_KEYS: readonly string[] = [ + "username", + "password", + "authType", + "credentialId", +]; + /** One parameter a saved keybinding action carries next to its type. */ export interface PluginKeybindingParam { type: "string" | "boolean"; @@ -523,6 +563,7 @@ const ALLOWED_CONTRIBUTES = new Set([ "auth", "syncEntities", "keybindingActions", + "protocols", ]); const ALLOWED_SETTINGS_FIELD = [ @@ -993,6 +1034,94 @@ function validateContributes( validateHttpContribution(contributes.http, pluginId, errors); validateUiPresets(contributes.uiPresets, errors); validateKeybindingActions(contributes.keybindingActions, errors); + validateProtocols(contributes.protocols, errors); +} + +export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/; +export const PROTOCOL_FIELD_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9]{0,31}$/; + +function validateProtocols(value: unknown, errors: string[]): void { + if (value === undefined) return; + const where = "contributes.protocols"; + if (!Array.isArray(value)) { + errors.push(`Field "${where}" must be an array`); + return; + } + const seen = new Set(); + value.forEach((raw, index) => { + const at = `${where}[${index}]`; + if (!isPlainObject(raw)) { + errors.push(`${at} must be an object`); + return; + } + rejectUnknown( + raw, + ["id", "credentialFields", "defaultPort", "hostLoginFallback"], + at, + errors, + ); + if (typeof raw.id !== "string" || !PROTOCOL_ID_PATTERN.test(raw.id)) { + errors.push( + `${at}.id must be lowercase letters, digits and dashes, starting with a letter`, + ); + } else if (RESERVED_PROTOCOL_IDS.includes(raw.id)) { + errors.push(`${at}.id "${raw.id}" belongs to core`); + } else { + if (seen.has(raw.id)) errors.push(`${at}.id duplicates "${raw.id}"`); + seen.add(raw.id); + } + if ( + raw.defaultPort !== undefined && + (typeof raw.defaultPort !== "number" || + !Number.isInteger(raw.defaultPort) || + raw.defaultPort < 1 || + raw.defaultPort > 65535) + ) { + errors.push(`${at}.defaultPort must be a port number`); + } + if (raw.hostLoginFallback !== undefined) { + if ( + !Array.isArray(raw.hostLoginFallback) || + raw.hostLoginFallback.some( + (entry) => entry !== "username" && entry !== "password", + ) + ) { + errors.push( + `${at}.hostLoginFallback must list "username" and/or "password"`, + ); + } + } + if (raw.credentialFields === undefined) return; + if (!Array.isArray(raw.credentialFields)) { + errors.push(`${at}.credentialFields must be an array`); + return; + } + const keys = new Set(); + raw.credentialFields.forEach((field, fieldIndex) => { + const fieldAt = `${at}.credentialFields[${fieldIndex}]`; + if (!isPlainObject(field)) { + errors.push(`${fieldAt} must be an object`); + return; + } + rejectUnknown(field, ["key", "secret"], fieldAt, errors); + if ( + typeof field.key !== "string" || + !PROTOCOL_FIELD_KEY_PATTERN.test(field.key) + ) { + errors.push(`${fieldAt}.key must be letters and digits`); + } else if (RESERVED_PROTOCOL_FIELD_KEYS.includes(field.key)) { + errors.push(`${fieldAt}.key "${field.key}" is part of every login`); + } else { + if (keys.has(field.key)) { + errors.push(`${fieldAt}.key duplicates "${field.key}"`); + } + keys.add(field.key); + } + if (field.secret !== undefined && typeof field.secret !== "boolean") { + errors.push(`${fieldAt}.secret must be a boolean`); + } + }); + }); } const KEYBINDING_ACTION_PATTERN = /^[a-zA-Z][a-zA-Z0-9.-]{0,63}$/; diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index ed3ead2a6..3fb429a7d 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -1692,6 +1692,12 @@ export function createMockCtx( }, resolveHostProtocol: async (hostId, protocol) => { require("credentials:read"); + const declared = ctx.manifest.contributes?.protocols; + if (declared && !declared.some((entry) => entry.id === protocol)) { + throw new Error( + `Plugin ${pluginId} did not declare protocol "${protocol}" in contributes.protocols`, + ); + } return ctx.credentials.resolveHostProtocol(hostId, protocol); }, registerSecretResolver: (scheme, resolve) => { diff --git a/plugins/proxmox/src/backend/routes.ts b/plugins/proxmox/src/backend/routes.ts index 4100f5801..10efd9a93 100644 --- a/plugins/proxmox/src/backend/routes.ts +++ b/plugins/proxmox/src/backend/routes.ts @@ -606,14 +606,7 @@ async function syncProxmoxHost( key: null, keyPassword: null, keyType: null, - rdpUser: null, - rdpPassword: null, - rdpDomain: null, rdpPort: connectionType === "rdp" ? 3389 : null, - vncUser: null, - vncPassword: null, - telnetUser: null, - telnetPassword: null, jumpHosts: serializeProxmoxJumpHosts(discovery.jumpHosts), quickActions: null, forceKeyboardInteractive: "false", diff --git a/plugins/remote-desktop/manifest.json b/plugins/remote-desktop/manifest.json index af3489ab6..73efaf10a 100644 --- a/plugins/remote-desktop/manifest.json +++ b/plugins/remote-desktop/manifest.json @@ -74,6 +74,28 @@ "accepts": ["button"] } ], + "protocols": [ + { + "id": "rdp", + "credentialFields": [ + { + "key": "domain" + } + ], + "defaultPort": 3389, + "hostLoginFallback": ["username", "password"] + }, + { + "id": "vnc", + "defaultPort": 5900, + "hostLoginFallback": ["password"] + }, + { + "id": "telnet", + "defaultPort": 23, + "hostLoginFallback": ["password"] + } + ], "guest": true, "permissions": [ { diff --git a/plugins/remote-desktop/src/backend/host-settings.ts b/plugins/remote-desktop/src/backend/host-settings.ts index b7d51e61c..e62dd7b30 100644 --- a/plugins/remote-desktop/src/backend/host-settings.ts +++ b/plugins/remote-desktop/src/backend/host-settings.ts @@ -141,6 +141,42 @@ export function normalizeImportedHost( return Object.keys(out).length > 0 ? out : null; } +/** + * Registered as "remote-desktop.hostPayloadLegacy": each protocol's login + * in the flat 2.8 shape (rdpUser, rdpDomain, vncAuthType and so on), for + * Termix-Mobile until it reads protocolAuth. Built from the payload's own + * protocolAuth, which core already reduced for a shared recipient. + */ +export function hostPayloadLegacy( + _values: Record, + host: Record, +): Record | null { + const logins = asObject(host.protocolAuth); + if (!logins) return null; + const out: Record = {}; + for (const protocol of ["rdp", "vnc", "telnet"] as const) { + const login = asObject(logins[protocol]); + if (!login) continue; + out[`${protocol}AuthType`] = login.authType; + if (login.credentialId !== undefined) { + out[`${protocol}CredentialId`] = login.credentialId; + } + if (typeof login.username === "string" && login.username) { + out[`${protocol}User`] = login.username; + } + if (typeof login.hasPassword === "boolean") { + const name = protocol.charAt(0).toUpperCase() + protocol.slice(1); + out[`has${name}Password`] = login.hasPassword; + } + const domain = asObject(login.fields)?.domain; + if (protocol === "rdp" && typeof domain === "string" && domain) { + out.rdpDomain = domain; + out.domain = domain; + } + } + return Object.keys(out).length > 0 ? out : null; +} + /** User setting key to the guacd parameter it sets. */ const USER_DEFAULT_PARAMS: Record = { colorDepth: "color-depth", diff --git a/plugins/remote-desktop/src/backend/index.ts b/plugins/remote-desktop/src/backend/index.ts index 7c8268eae..7fc19ebc0 100644 --- a/plugins/remote-desktop/src/backend/index.ts +++ b/plugins/remote-desktop/src/backend/index.ts @@ -9,6 +9,7 @@ import { resolveGuacdOptions } from "./guacd-config.js"; import { registerRoutes } from "./routes.js"; import { createLiveSessions } from "./live-sessions.js"; import { + hostPayloadLegacy, normalizeImportedHost, PORT_KEY, readHostSettings, @@ -107,6 +108,7 @@ export async function activate(ctx: PluginContext) { "remote-desktop.hostImportNormalizer", normalizeImportedHost, ); + ctx.registry.provide("remote-desktop.hostPayloadLegacy", hostPayloadLegacy); // Core's status dot pings these hosts on the protocol's own port. for (const protocol of ["rdp", "vnc", "telnet"] as const) { diff --git a/plugins/remote-desktop/src/backend/routes.ts b/plugins/remote-desktop/src/backend/routes.ts index 40f4863b2..34907fd9a 100644 --- a/plugins/remote-desktop/src/backend/routes.ts +++ b/plugins/remote-desktop/src/backend/routes.ts @@ -337,7 +337,7 @@ export function registerRoutes(router: Router, deps: RouteDeps): void { const domain = resolveRdpDomain( target.auth.authType, body.promptedDomain, - target.auth.domain, + target.auth.fields.domain ?? "", ); let hostname = target.host.ip; diff --git a/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx b/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx index 3848ae0d4..cea630725 100644 --- a/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx +++ b/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx @@ -44,6 +44,7 @@ import { errorMessage, hostRemoteOptions, type Protocol, + rdpDomain, type RemoteHostLogin, } from "./host-remote"; import { GuacamoleToolbar } from "./GuacamoleToolbar.tsx"; @@ -94,9 +95,9 @@ function savedHostConfig( connectionType: protocol ?? (options.enableRdp ? "rdp" : options.enableVnc ? "vnc" : "telnet"), - domain: host.domain, + domain: rdpDomain(host), guacamoleConfig: options.guacamoleConfig, - rdpAuthType: host.rdpAuthType, + rdpAuthType: host.protocolAuth?.rdp?.authType, authOverrides: host.authOverrides, syncId: host.syncId, connectionOrigin: host.connectionOrigin, diff --git a/plugins/remote-desktop/src/frontend/HostEditorGuacamoleTabs.tsx b/plugins/remote-desktop/src/frontend/HostEditorGuacamoleTabs.tsx index 408d10386..e5e655726 100644 --- a/plugins/remote-desktop/src/frontend/HostEditorGuacamoleTabs.tsx +++ b/plugins/remote-desktop/src/frontend/HostEditorGuacamoleTabs.tsx @@ -1,4 +1,7 @@ -import { useTranslation } from "@termix/plugin-sdk/frontend"; +import { + HOST_PROTOCOL_SECRET_KEPT, + useTranslation, +} from "@termix/plugin-sdk/frontend"; import { FakeSwitch, Input, @@ -198,17 +201,17 @@ export function HostEditorRdpTab({ { - if (form.rdpPassword === "existing_rdp_password") + if (form.rdpPassword === HOST_PROTOCOL_SECRET_KEPT) setField("rdpPassword", ""); }} onChange={(e) => setField("rdpPassword", e.target.value)} @@ -1053,17 +1056,17 @@ export function HostEditorVncTab({ { - if (form.vncPassword === "existing_vnc_password") + if (form.vncPassword === HOST_PROTOCOL_SECRET_KEPT) setField("vncPassword", ""); }} onChange={(e) => setField("vncPassword", e.target.value)} @@ -1544,17 +1547,17 @@ export function HostEditorTelnetTab({ { - if (form.telnetPassword === "existing_telnet_password") + if (form.telnetPassword === HOST_PROTOCOL_SECRET_KEPT) setField("telnetPassword", ""); }} onChange={(e) => setField("telnetPassword", e.target.value)} diff --git a/plugins/remote-desktop/src/frontend/host-remote.ts b/plugins/remote-desktop/src/frontend/host-remote.ts index 0e16cd36b..22b416c8a 100644 --- a/plugins/remote-desktop/src/frontend/host-remote.ts +++ b/plugins/remote-desktop/src/frontend/host-remote.ts @@ -1,4 +1,8 @@ -import type { PluginHostRecord } from "@termix/plugin-sdk/frontend"; +import type { + HostProtocolAuthSummary, + PluginHostRecord, + QuickConnectLogin, +} from "@termix/plugin-sdk/frontend"; import type { GuacamoleConfig } from "./guacamole-config"; export const PLUGIN_ID = "remote-desktop"; @@ -37,19 +41,18 @@ export const DEFAULT_PORT: Record = { telnet: 23, }; -/** The core fields holding a protocol's login, which stay on the host. */ +/** + * The host fields a session reads. Each protocol's login is core's, under + * protocolAuth, since this plugin declares its protocols in the manifest. + */ export interface RemoteHostLogin { id?: number | string; name?: string | null; ip?: string; - domain?: string; syncId?: string | null; connectionOrigin?: "local" | "remote" | null; - rdpAuthType?: string; - rdpUser?: string; - rdpPassword?: string; - vncUser?: string; - vncPassword?: string; + protocolAuth?: Record; + quickConnectLogin?: QuickConnectLogin; authOverrides?: Record< string, { @@ -61,6 +64,11 @@ export interface RemoteHostLogin { pluginSettings?: Record>; } +/** The RDP domain a host's saved login carries. */ +export function rdpDomain(host: RemoteHostLogin): string | undefined { + return host.protocolAuth?.rdp?.fields?.domain || undefined; +} + function port(value: unknown, fallback: number): number { const parsed = typeof value === "string" ? Number(value) : value; return typeof parsed === "number" && Number.isInteger(parsed) && parsed > 0 diff --git a/plugins/remote-desktop/src/frontend/index.tsx b/plugins/remote-desktop/src/frontend/index.tsx index bf0c27b05..4bd41612e 100644 --- a/plugins/remote-desktop/src/frontend/index.tsx +++ b/plugins/remote-desktop/src/frontend/index.tsx @@ -45,6 +45,7 @@ import { isQuickConnectHost, protocolEnabled, type Protocol, + rdpDomain, type RemoteHostLogin, } from "./host-remote"; import { remoteDesktopForm } from "./remote-form"; @@ -249,8 +250,8 @@ function registerNativeRdp(app: TermixApp): void { void openNativeRdp({ host: String(record.ip ?? ""), port: hostRemoteOptions(record).rdpPort, - username: record.rdpUser, - domain: record.domain, + username: record.protocolAuth?.rdp?.username ?? undefined, + domain: rdpDomain(record), }) .then((result) => { if (result.success) toast.success(app.t("hosts.nativeRdpOpened")); diff --git a/plugins/remote-desktop/src/frontend/quick-connect-guac-host.ts b/plugins/remote-desktop/src/frontend/quick-connect-guac-host.ts index 96949b4fd..42eda507c 100644 --- a/plugins/remote-desktop/src/frontend/quick-connect-guac-host.ts +++ b/plugins/remote-desktop/src/frontend/quick-connect-guac-host.ts @@ -11,14 +11,18 @@ export function quickConnectGuacHost( ): GuacamoleQuickHost { const options = hostRemoteOptions(host); const connectionType: Protocol = options.enableVnc ? "vnc" : "rdp"; + const login = + host.quickConnectLogin?.protocol === connectionType + ? host.quickConnectLogin + : undefined; return { name: host.name ?? undefined, ip: host.ip ?? "", connectionType, - domain: host.domain, + domain: login?.fields?.domain, port: connectionType === "vnc" ? options.vncPort : options.rdpPort, - rdpAuthType: host.rdpAuthType, - username: connectionType === "vnc" ? host.vncUser : host.rdpUser, - password: connectionType === "vnc" ? host.vncPassword : host.rdpPassword, + rdpAuthType: "direct", + username: login?.username, + password: login?.password, }; } diff --git a/plugins/remote-desktop/src/frontend/remote-form.ts b/plugins/remote-desktop/src/frontend/remote-form.ts index f9e92915f..ad801826a 100644 --- a/plugins/remote-desktop/src/frontend/remote-form.ts +++ b/plugins/remote-desktop/src/frontend/remote-form.ts @@ -1,12 +1,16 @@ -import type { HostEditorSectionProps } from "@termix/plugin-sdk/frontend"; -import { PLUGIN_ID, remoteOptions } from "./host-remote"; +import type { + HostEditorSectionProps, + HostProtocolAuthForm, +} from "@termix/plugin-sdk/frontend"; +import { PLUGIN_ID, remoteOptions, type Protocol } from "./host-remote"; /* eslint-disable @typescript-eslint/no-explicit-any */ /** * The host editor fields the RDP, VNC and Telnet tabs edit. The logins are - * core host fields; the options (ports, security, guacd settings) are this - * plugin's host settings, presented here under the names the tabs use. + * core's, under form.protocolAuth (this plugin declares the protocols in its + * manifest); the options (ports, security, guacd settings) are this plugin's + * host settings. Both are presented here under the names the tabs use. */ export interface RemoteDesktopForm { domain: string; @@ -46,7 +50,45 @@ const SETTING_FOR_FIELD: Partial> = { enableToolbar: "enableToolbar", }; +type LoginPart = "authType" | "credentialId" | "username" | "password"; + +/** Tab field name to the protocol login it edits. */ +const LOGIN_FOR_FIELD: Partial< + Record +> = { + rdpAuthType: ["rdp", "authType"], + rdpCredentialId: ["rdp", "credentialId"], + rdpUser: ["rdp", "username"], + rdpPassword: ["rdp", "password"], + domain: ["rdp", "domain"], + vncAuthType: ["vnc", "authType"], + vncCredentialId: ["vnc", "credentialId"], + vncUser: ["vnc", "username"], + vncPassword: ["vnc", "password"], + telnetAuthType: ["telnet", "authType"], + telnetCredentialId: ["telnet", "credentialId"], + telnetUser: ["telnet", "username"], + telnetPassword: ["telnet", "password"], +}; + type PluginSettingsBag = Record>; +type LoginBag = Record; + +const EMPTY_LOGIN: HostProtocolAuthForm = { + authType: "direct", + credentialId: "", + username: "", + password: "", + fields: {}, +}; + +export function loginOf( + bag: LoginBag | undefined, + protocol: Protocol, +): HostProtocolAuthForm { + const login = bag?.[protocol]; + return login ? { ...EMPTY_LOGIN, ...login } : EMPTY_LOGIN; +} function patchSettings( current: Record, @@ -59,15 +101,34 @@ function patchSettings( }; } +/** The form patch that sets one part of a protocol's login. */ +export function patchLogin( + current: Record, + protocol: Protocol, + part: LoginPart | "domain", + value: unknown, +): Record { + const bag = (current.protocolAuth as LoginBag | undefined) ?? {}; + const login = loginOf(bag, protocol); + const next: HostProtocolAuthForm = + part === "domain" + ? { ...login, fields: { ...login.fields, domain: String(value ?? "") } } + : ({ ...login, [part]: value } as HostProtocolAuthForm); + return { protocolAuth: { ...bag, [protocol]: next } }; +} + export function remoteDesktopForm(props: HostEditorSectionProps): { form: RemoteDesktopForm; setField: RemoteFormSetField; setGuacField: (key: string, value: unknown) => void; } { const bag = props.form?.pluginSettings as PluginSettingsBag | undefined; + const logins = props.form?.protocolAuth as LoginBag | undefined; const options = remoteOptions(bag?.[PLUGIN_ID]); + const rdp = loginOf(logins, "rdp"); + const vnc = loginOf(logins, "vnc"); + const telnet = loginOf(logins, "telnet"); const form: RemoteDesktopForm = { - ...(props.form as RemoteDesktopForm), rdpPort: options.rdpPort, vncPort: options.vncPort, telnetPort: options.telnetPort, @@ -75,9 +136,29 @@ export function remoteDesktopForm(props: HostEditorSectionProps): { ignoreCert: options.rdpIgnoreCert, guacamoleConfig: options.guacamoleConfig as Record, enableToolbar: options.enableToolbar, + domain: rdp.fields.domain ?? "", + rdpAuthType: rdp.authType, + rdpCredentialId: rdp.credentialId, + rdpUser: rdp.username, + rdpPassword: rdp.password, + vncAuthType: vnc.authType === "credential" ? "credential" : "direct", + vncCredentialId: vnc.credentialId, + vncUser: vnc.username, + vncPassword: vnc.password, + telnetAuthType: telnet.authType === "credential" ? "credential" : "direct", + telnetCredentialId: telnet.credentialId, + telnetUser: telnet.username, + telnetPassword: telnet.password, }; const setField: RemoteFormSetField = (key, value) => { + const login = LOGIN_FOR_FIELD[key]; + if (login) { + props.updateForm((current) => + patchLogin(current, login[0], login[1], value), + ); + return; + } const setting = SETTING_FOR_FIELD[key]; if (!setting) { props.setField(key, value); diff --git a/plugins/remote-desktop/tests/backend/host-settings.test.ts b/plugins/remote-desktop/tests/backend/host-settings.test.ts index 79bf6ef25..95a427ae3 100644 --- a/plugins/remote-desktop/tests/backend/host-settings.test.ts +++ b/plugins/remote-desktop/tests/backend/host-settings.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + hostPayloadLegacy, normalizeImportedHost, userDefaultParams, } from "../../src/backend/host-settings.js"; @@ -80,3 +81,38 @@ describe("userDefaultParams", () => { }); }); }); + +describe("hostPayloadLegacy", () => { + it("puts each login back in its 2.8 flat shape for older clients", () => { + expect( + hostPayloadLegacy( + {}, + { + protocolAuth: { + rdp: { + authType: "direct", + credentialId: null, + username: "admin", + fields: { domain: "CORP" }, + hasPassword: true, + }, + vnc: { authType: "credential", credentialId: 4 }, + }, + }, + ), + ).toEqual({ + rdpAuthType: "direct", + rdpCredentialId: null, + rdpUser: "admin", + hasRdpPassword: true, + rdpDomain: "CORP", + domain: "CORP", + vncAuthType: "credential", + vncCredentialId: 4, + }); + }); + + it("adds nothing for a host without logins", () => { + expect(hostPayloadLegacy({}, {})).toBeNull(); + }); +}); diff --git a/plugins/remote-desktop/tests/backend/routes.test.ts b/plugins/remote-desktop/tests/backend/routes.test.ts index ee66ecdb0..cb33bd5cb 100644 --- a/plugins/remote-desktop/tests/backend/routes.test.ts +++ b/plugins/remote-desktop/tests/backend/routes.test.ts @@ -37,7 +37,7 @@ function target( authType: "direct", username: "admin", password: "secret", - domain: "CORP", + fields: { domain: "CORP" }, ...overrides, }, }; diff --git a/plugins/remote-desktop/tests/frontend/quick-connect-guac-host.test.ts b/plugins/remote-desktop/tests/frontend/quick-connect-guac-host.test.ts index e11b87c7c..4a5829d2c 100644 --- a/plugins/remote-desktop/tests/frontend/quick-connect-guac-host.test.ts +++ b/plugins/remote-desktop/tests/frontend/quick-connect-guac-host.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { quickConnectGuacHost } from "../../src/frontend/quick-connect-guac-host"; // The shape core's Quick Connect builds for a plugin protocol: the switch and -// port in this plugin's host settings, the login on the protocol's fields. +// port in this plugin's host settings, the unsaved login in quickConnectLogin. function quickHost(protocol: "rdp" | "vnc", extra: Record) { return { id: "quick-connect-1", @@ -24,9 +24,12 @@ describe("quickConnectGuacHost", () => { expect( quickConnectGuacHost( quickHost("rdp", { - rdpUser: "admin", - rdpPassword: "pw", - domain: "CORP", + quickConnectLogin: { + protocol: "rdp", + username: "admin", + password: "pw", + fields: { domain: "CORP" }, + }, }), ), ).toMatchObject({ @@ -41,7 +44,11 @@ describe("quickConnectGuacHost", () => { it("takes a VNC quick connect's login from the VNC fields", () => { expect( - quickConnectGuacHost(quickHost("vnc", { vncPassword: "vncpw" })), + quickConnectGuacHost( + quickHost("vnc", { + quickConnectLogin: { protocol: "vnc", password: "vncpw" }, + }), + ), ).toMatchObject({ connectionType: "vnc", port: 5901, diff --git a/plugins/remote-desktop/tests/frontend/remote-form.test.ts b/plugins/remote-desktop/tests/frontend/remote-form.test.ts new file mode 100644 index 000000000..6fca9c30b --- /dev/null +++ b/plugins/remote-desktop/tests/frontend/remote-form.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import type { HostEditorSectionProps } from "@termix/plugin-sdk/frontend"; +import { + loginOf, + patchLogin, + remoteDesktopForm, +} from "../../src/frontend/remote-form"; + +function sectionProps(form: Record) { + const state = { form: { ...form } }; + const props = { + form: state.form, + setField: (key: string, value: unknown) => { + state.form = { ...state.form, [key]: value }; + }, + updateForm: ( + patch: (current: Record) => Record, + ) => { + state.form = { ...state.form, ...patch(state.form) }; + }, + } as unknown as HostEditorSectionProps; + return { props, state }; +} + +describe("remote desktop editor form", () => { + it("reads each protocol's login from the host form's protocolAuth", () => { + const { props } = sectionProps({ + protocolAuth: { + rdp: { + authType: "credential", + credentialId: "4", + username: "", + password: "", + fields: { domain: "CORP" }, + }, + vnc: { + authType: "direct", + credentialId: "", + username: "", + password: "saved", + fields: {}, + }, + }, + }); + const { form } = remoteDesktopForm(props); + expect(form).toMatchObject({ + rdpAuthType: "credential", + rdpCredentialId: "4", + domain: "CORP", + vncPassword: "saved", + telnetAuthType: "direct", + telnetUser: "", + }); + }); + + it("writes the tabs' login fields into protocolAuth", () => { + const { props, state } = sectionProps({}); + const { setField } = remoteDesktopForm(props); + setField("rdpUser", "admin"); + setField("domain", "CORP"); + setField("telnetAuthType", "credential"); + + expect(state.form.protocolAuth).toEqual({ + rdp: { + authType: "direct", + credentialId: "", + username: "admin", + password: "", + fields: { domain: "CORP" }, + }, + telnet: { + authType: "credential", + credentialId: "", + username: "", + password: "", + fields: {}, + }, + }); + }); + + it("keeps other protocols when one changes", () => { + const current = { + protocolAuth: { vnc: { ...loginOf(undefined, "vnc"), username: "v" } }, + }; + const next = patchLogin(current, "rdp", "password", "pw"); + expect(next.protocolAuth).toMatchObject({ + vnc: { username: "v" }, + rdp: { password: "pw" }, + }); + }); +}); diff --git a/src/backend/auth/login-pipeline.ts b/src/backend/auth/login-pipeline.ts index 655aa82ee..cfbbfeccb 100644 --- a/src/backend/auth/login-pipeline.ts +++ b/src/backend/auth/login-pipeline.ts @@ -243,6 +243,9 @@ export async function runLogin( const { runNotificationChannelMigration } = await import("../upgrade/notification-channel-migration.js"); await runNotificationChannelMigration(user.id); + const { runProtocolAuthMigration } = + await import("../upgrade/protocol-auth-migration.js"); + await runProtocolAuthMigration(user.id); } await syncSharedCredentialsForUserRoles( user.id, diff --git a/src/backend/boot.ts b/src/backend/boot.ts index 85678b8a8..fdf207424 100644 --- a/src/backend/boot.ts +++ b/src/backend/boot.ts @@ -47,6 +47,12 @@ export async function runCoreBootMigrations(): Promise { await AuthManager.getInstance().initialize(); DataCrypto.initialize(); + // Before anything snapshots shared secrets, which would drop a protocol + // snapshot whose login has not been copied yet. + const { runProtocolAuthMigration } = + await import("./upgrade/protocol-auth-migration.js"); + await runProtocolAuthMigration(); + const { runLegacySharedSshAuthOptInMigration } = await import("./utils/crypto-migration/legacy-shared-ssh-auth-opt-in-migration.js"); await runLegacySharedSshAuthOptInMigration(); diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index d4ec32092..d15c64f1d 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -64,6 +64,13 @@ import { } from "./repositories/factory.js"; import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js"; import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js"; +import { + keepUsableProtocolCredentials, + listProtocolLogins, + readProtocolAuthPayload, + toPortableLogins, + writeProtocolAuth, +} from "../hosts/protocol-auth/protocol-auth.js"; import { parseUserAgent } from "../utils/user-agent-parser.js"; import type { GitHubRelease, AuthenticatedRequest } from "../../types/index.js"; import { DatabaseSaveTrigger } from "./db/index.js"; @@ -732,7 +739,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => { socks5_username TEXT, socks5_password TEXT, socks5_proxy_chain TEXT, - domain TEXT, port_knock_sequence TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -745,6 +751,16 @@ app.post("/database/export", authenticateJWT, async (req, res) => { value TEXT ); + CREATE TABLE host_protocol_auth ( + host_id INTEGER NOT NULL, + protocol TEXT NOT NULL, + auth_type TEXT NOT NULL, + credential_id INTEGER, + username TEXT, + password TEXT, + fields TEXT + ); + CREATE TABLE ssh_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, @@ -801,8 +817,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => { const sshHosts = await createCurrentHostRepository().listDecryptedByUserId(userId); const insertHost = exportDb.prepare(` - INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, terminal_config, ssh_options, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, port_knock_sequence, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, terminal_config, ssh_options, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, port_knock_sequence, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const decrypted of sshHosts) { @@ -842,7 +858,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => { decrypted.socks5Username || null, decrypted.socks5Password || null, decrypted.socks5ProxyChain || null, - decrypted.domain || null, decrypted.portKnockSequence || null, decrypted.createdAt, decrypted.updatedAt, @@ -869,6 +884,27 @@ app.post("/database/export", authenticateJWT, async (req, res) => { ); } + // Plugin protocol logins, decrypted like the host's own secrets. + const insertProtocolAuth = exportDb.prepare( + "INSERT INTO host_protocol_auth (host_id, protocol, auth_type, credential_id, username, password, fields) VALUES (?, ?, ?, ?, ?, ?, ?)", + ); + for (const host of sshHosts) { + const logins = toPortableLogins( + await listProtocolLogins(host.id as number, userId), + ); + for (const [protocol, login] of Object.entries(logins)) { + insertProtocolAuth.run( + host.id, + protocol, + login.authType, + login.credentialId, + login.username, + login.password, + JSON.stringify(login.fields), + ); + } + } + const credentials = await createCurrentCredentialRepository().listDecryptedByUserId(userId); const insertCred = exportDb.prepare(` @@ -1178,6 +1214,12 @@ app.post( Number(created.id), importedHostPluginSettings(importDb, host), ); + await importHostProtocolLogins( + importDb, + host, + Number(created.id), + userId, + ); result.summary.sshHostsImported++; } catch (hostError) { result.summary.errors.push( @@ -1861,6 +1903,52 @@ function legacyStatusCheck(row: Record): { }; } +/** + * A host's plugin protocol logins from an export. The file's credential ids + * belong to the server that wrote it, so a login keeps one only when it is + * a credential the importing user can use. + */ +async function importHostProtocolLogins( + importDb: Database.Database, + host: Record, + hostId: number, + userId: string, +): Promise { + let rows: Array>; + try { + rows = importDb + .prepare("SELECT * FROM host_protocol_auth WHERE host_id = ?") + .all(host.id) as Array>; + } catch { + // An export from before 2.9.0 has no protocol logins. + return; + } + const protocolAuth: Record = {}; + for (const row of rows) { + let fields: unknown = {}; + try { + fields = JSON.parse(String(row.fields ?? "{}")); + } catch { + fields = {}; + } + protocolAuth[String(row.protocol)] = { + authType: row.auth_type, + credentialId: row.credential_id, + username: row.username, + password: row.password, + fields, + }; + } + const patch = readProtocolAuthPayload({ protocolAuth }); + if (!patch) return; + await writeProtocolAuth( + userId, + hostId, + await keepUsableProtocolCredentials(patch, userId), + { isOwner: true }, + ); +} + /** * What an exported host carries for plugins: the plugin_settings rows a 2.9 * export writes, plus the whole row in camelCase so a plugin's import diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 91da853ef..b6f0e5e40 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -1185,7 +1185,6 @@ const migrateSchema = () => { { column: "share_ssh_auth", sql: "ALTER TABLE ssh_data ADD COLUMN share_ssh_auth INTEGER NOT NULL DEFAULT 0" }, { column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" }, { column: "quick_actions", sql: "ALTER TABLE ssh_data ADD COLUMN quick_actions TEXT" }, - { column: "domain", sql: "ALTER TABLE ssh_data ADD COLUMN domain TEXT" }, { column: "socks5_proxy_chain", sql: "ALTER TABLE ssh_data ADD COLUMN socks5_proxy_chain TEXT" }, { column: "host_key_fingerprint", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_fingerprint TEXT" }, { column: "host_key_type", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_type TEXT" }, @@ -1196,19 +1195,6 @@ const migrateSchema = () => { { column: "port_knock_sequence", sql: "ALTER TABLE ssh_data ADD COLUMN port_knock_sequence TEXT" }, { column: "enable_ssh", sql: "ALTER TABLE ssh_data ADD COLUMN enable_ssh INTEGER NOT NULL DEFAULT 1" }, { column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" }, - { column: "rdp_user", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_user TEXT" }, - { column: "rdp_password", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_password TEXT" }, - { column: "rdp_domain", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_domain TEXT" }, - { column: "vnc_password", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_password TEXT" }, - { column: "vnc_user", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_user TEXT" }, - { column: "telnet_user", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_user TEXT" }, - { column: "telnet_password", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_password TEXT" }, - { column: "rdp_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" }, - { column: "vnc_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" }, - { column: "telnet_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" }, - { column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" }, - { column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" }, - { column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" }, { column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" }, { column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" }, ]; @@ -1262,39 +1248,6 @@ const migrateSchema = () => { }); } - // Copy unencrypted username/domain into protocol-specific columns for old guac hosts. - // Passwords are handled via the legacy field name fallback in lazy-field-encryption.ts. - const usernameDomainBackfills = [ - { - protocol: "rdp", - sql: "UPDATE ssh_data SET rdp_user = username, rdp_password = password, rdp_domain = domain WHERE connection_type = 'rdp' AND rdp_user IS NULL AND rdp_password IS NULL", - }, - { - protocol: "vnc", - sql: "UPDATE ssh_data SET vnc_user = username, vnc_password = password WHERE connection_type = 'vnc' AND vnc_user IS NULL AND vnc_password IS NULL", - }, - { - protocol: "telnet", - sql: "UPDATE ssh_data SET telnet_user = username, telnet_password = password WHERE connection_type = 'telnet' AND telnet_user IS NULL AND telnet_password IS NULL", - }, - ]; - for (const backfill of usernameDomainBackfills) { - try { - const result = sqlite.prepare(backfill.sql).run(); - if (result.changes > 0) { - databaseLogger.info( - `Backfilled ${result.changes} ${backfill.protocol} host credential(s)`, - { operation: "guac_credential_backfill" }, - ); - } - } catch (e) { - databaseLogger.warn(`Failed to backfill ${backfill.protocol} host credentials`, { - operation: "guac_credential_backfill", - error: e, - }); - } - } - try { sqlite.prepare("SELECT id FROM shared_host_secrets LIMIT 1").get(); } catch { @@ -1330,6 +1283,38 @@ const migrateSchema = () => { } } + addColumnIfNotExists("shared_host_secrets", "encrypted_fields", "TEXT"); + + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS host_protocol_auth ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + protocol TEXT NOT NULL, + auth_type TEXT NOT NULL DEFAULT 'direct', + credential_id INTEGER, + username TEXT, + password TEXT, + fields TEXT, + secret_fields TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_host_protocol_auth_host_protocol ON host_protocol_auth (host_id, protocol); + CREATE INDEX IF NOT EXISTS idx_host_protocol_auth_user ON host_protocol_auth (user_id); + CREATE INDEX IF NOT EXISTS idx_host_protocol_auth_credential ON host_protocol_auth (credential_id); + `); + } catch (createError) { + databaseLogger.warn("Failed to create host_protocol_auth table", { + operation: "schema_migration", + error: createError, + }); + } + try { if (getRawSettingValue("rbac_permission_levels_v2") === null) { sqlite.exec( diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts index 9d777db00..4ea97b924 100644 --- a/src/backend/database/db/schema.mysql.ts +++ b/src/backend/database/db/schema.mysql.ts @@ -210,25 +210,6 @@ export const hosts = mysqlTable( sshPort: int("ssh_port").default(22), - rdpCredentialId: int("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), - rdpUser: text("rdp_user"), - rdpPassword: text("rdp_password"), - rdpDomain: text("rdp_domain"), - - vncCredentialId: int("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), - vncPassword: text("vnc_password"), - vncUser: text("vnc_user"), - - telnetUser: text("telnet_user"), - telnetPassword: text("telnet_password"), - telnetCredentialId: int("telnet_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), - - rdpAuthType: text("rdp_auth_type"), - vncAuthType: text("vnc_auth_type"), - telnetAuthType: text("telnet_auth_type"), - - domain: text("domain"), - useSocks5: boolean("use_socks5"), socks5Host: text("socks5_host"), socks5Port: int("socks5_port"), @@ -239,7 +220,7 @@ export const hosts = mysqlTable( // null = use the desktop app's global default; "local" | "remote" pins // this specific host's SSH/Docker-console/Serial connections to originate // from the embedded local backend or a connected remote sync server. - // Ignored for rdp/vnc/telnet, which always require the remote server. + // Ignored for plugin protocols, which always need the remote server. connectionOrigin: text("connection_origin"), portKnockSequence: text("port_knock_sequence"), @@ -346,6 +327,45 @@ export const sshCredentialUsage = mysqlTable( ], ); +// A host's login for a protocol a plugin declares (contributes.protocols), +// owned and encrypted like the host itself. `fields` holds the protocol's +// non-secret declared fields as JSON, `secret_fields` its secret ones. +export const hostProtocolAuth = mysqlTable( + "host_protocol_auth", + { + id: int("id").autoincrement().primaryKey(), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + protocol: varchar("protocol", { length: 255 }).notNull(), + authType: text("auth_type").notNull().default("direct"), + credentialId: int("credential_id").references(() => sshCredentials.id, { + onDelete: "set null", + }), + username: text("username"), + password: text("password"), + fields: text("fields"), + secretFields: text("secret_fields"), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + (table) => [ + uniqueIndex("idx_host_protocol_auth_host_protocol").on( + table.hostId, + table.protocol, + ), + index("idx_host_protocol_auth_user").on(table.userId), + index("idx_host_protocol_auth_credential").on(table.credentialId), + ], +); + export const sshFolders = mysqlTable( "ssh_folders", { @@ -493,6 +513,8 @@ export const sharedHostSecrets = mysqlTable( encryptedKeyPassword: text("encrypted_key_password"), encryptedKeyType: text("encrypted_key_type"), encryptedDomain: text("encrypted_domain"), + // A plugin protocol's declared credential fields, JSON, encrypted. + encryptedFields: text("encrypted_fields"), createdAt: text("created_at") .notNull() diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts index f36793ac1..2bc50afa4 100644 --- a/src/backend/database/db/schema.pg.ts +++ b/src/backend/database/db/schema.pg.ts @@ -210,25 +210,6 @@ export const hosts = pgTable( sshPort: integer("ssh_port").default(22), - rdpCredentialId: integer("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), - rdpUser: text("rdp_user"), - rdpPassword: text("rdp_password"), - rdpDomain: text("rdp_domain"), - - vncCredentialId: integer("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), - vncPassword: text("vnc_password"), - vncUser: text("vnc_user"), - - telnetUser: text("telnet_user"), - telnetPassword: text("telnet_password"), - telnetCredentialId: integer("telnet_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), - - rdpAuthType: text("rdp_auth_type"), - vncAuthType: text("vnc_auth_type"), - telnetAuthType: text("telnet_auth_type"), - - domain: text("domain"), - useSocks5: boolean("use_socks5"), socks5Host: text("socks5_host"), socks5Port: integer("socks5_port"), @@ -239,7 +220,7 @@ export const hosts = pgTable( // null = use the desktop app's global default; "local" | "remote" pins // this specific host's SSH/Docker-console/Serial connections to originate // from the embedded local backend or a connected remote sync server. - // Ignored for rdp/vnc/telnet, which always require the remote server. + // Ignored for plugin protocols, which always need the remote server. connectionOrigin: text("connection_origin"), portKnockSequence: text("port_knock_sequence"), @@ -346,6 +327,45 @@ export const sshCredentialUsage = pgTable( ], ); +// A host's login for a protocol a plugin declares (contributes.protocols), +// owned and encrypted like the host itself. `fields` holds the protocol's +// non-secret declared fields as JSON, `secret_fields` its secret ones. +export const hostProtocolAuth = pgTable( + "host_protocol_auth", + { + id: serial("id").primaryKey(), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + protocol: varchar("protocol", { length: 255 }).notNull(), + authType: text("auth_type").notNull().default("direct"), + credentialId: integer("credential_id").references(() => sshCredentials.id, { + onDelete: "set null", + }), + username: text("username"), + password: text("password"), + fields: text("fields"), + secretFields: text("secret_fields"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [ + uniqueIndex("idx_host_protocol_auth_host_protocol").on( + table.hostId, + table.protocol, + ), + index("idx_host_protocol_auth_user").on(table.userId), + index("idx_host_protocol_auth_credential").on(table.credentialId), + ], +); + export const sshFolders = pgTable( "ssh_folders", { @@ -493,6 +513,8 @@ export const sharedHostSecrets = pgTable( encryptedKeyPassword: text("encrypted_key_password"), encryptedKeyType: text("encrypted_key_type"), encryptedDomain: text("encrypted_domain"), + // A plugin protocol's declared credential fields, JSON, encrypted. + encryptedFields: text("encrypted_fields"), createdAt: text("created_at") .notNull() diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 0622c0c6a..60e31b056 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -202,25 +202,6 @@ export const hosts = sqliteTable( sshPort: integer("ssh_port").default(22), - rdpCredentialId: integer("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), - rdpUser: text("rdp_user"), - rdpPassword: text("rdp_password"), - rdpDomain: text("rdp_domain"), - - vncCredentialId: integer("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), - vncPassword: text("vnc_password"), - vncUser: text("vnc_user"), - - telnetUser: text("telnet_user"), - telnetPassword: text("telnet_password"), - telnetCredentialId: integer("telnet_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), - - rdpAuthType: text("rdp_auth_type"), - vncAuthType: text("vnc_auth_type"), - telnetAuthType: text("telnet_auth_type"), - - domain: text("domain"), - useSocks5: integer("use_socks5", { mode: "boolean" }), socks5Host: text("socks5_host"), socks5Port: integer("socks5_port"), @@ -231,7 +212,7 @@ export const hosts = sqliteTable( // null = use the desktop app's global default; "local" | "remote" pins // this specific host's SSH/Docker-console/Serial connections to originate // from the embedded local backend or a connected remote sync server. - // Ignored for rdp/vnc/telnet, which always require the remote server. + // Ignored for plugin protocols, which always need the remote server. connectionOrigin: text("connection_origin"), portKnockSequence: text("port_knock_sequence"), @@ -338,6 +319,45 @@ export const sshCredentialUsage = sqliteTable( ], ); +// A host's login for a protocol a plugin declares (contributes.protocols), +// owned and encrypted like the host itself. `fields` holds the protocol's +// non-secret declared fields as JSON, `secret_fields` its secret ones. +export const hostProtocolAuth = sqliteTable( + "host_protocol_auth", + { + id: integer("id").primaryKey({ autoIncrement: true }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + protocol: text("protocol").notNull(), + authType: text("auth_type").notNull().default("direct"), + credentialId: integer("credential_id").references(() => sshCredentials.id, { + onDelete: "set null", + }), + username: text("username"), + password: text("password"), + fields: text("fields"), + secretFields: text("secret_fields"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [ + uniqueIndex("idx_host_protocol_auth_host_protocol").on( + table.hostId, + table.protocol, + ), + index("idx_host_protocol_auth_user").on(table.userId), + index("idx_host_protocol_auth_credential").on(table.credentialId), + ], +); + export const sshFolders = sqliteTable( "ssh_folders", { @@ -485,6 +505,8 @@ export const sharedHostSecrets = sqliteTable( encryptedKeyPassword: text("encrypted_key_password"), encryptedKeyType: text("encrypted_key_type"), encryptedDomain: text("encrypted_domain"), + // A plugin protocol's declared credential fields, JSON, encrypted. + encryptedFields: text("encrypted_fields"), createdAt: text("created_at") .notNull() diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index 723a6ebc8..7da436f19 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -28,6 +28,7 @@ import { CredentialAccessRepository } from "./credential-access-repository.js"; import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js"; import { FolderAccessRepository } from "./folder-access-repository.js"; import { SettingsRepository } from "./settings-repository.js"; +import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js"; import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js"; import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js"; import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js"; @@ -286,6 +287,13 @@ export function createCurrentSharedHostSecretsRepository(): SharedHostSecretsRep ); } +export function createCurrentHostProtocolAuthRepository(): HostProtocolAuthRepository { + return new HostProtocolAuthRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("host_protocol_auth_repository_write"), + ); +} + export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthOverrideRepository { return new SharedHostAuthOverrideRepository( createCurrentRepositoryContext(), diff --git a/src/backend/database/repositories/host-protocol-auth-repository.ts b/src/backend/database/repositories/host-protocol-auth-repository.ts new file mode 100644 index 000000000..db80eeac2 --- /dev/null +++ b/src/backend/database/repositories/host-protocol-auth-repository.ts @@ -0,0 +1,243 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { hostProtocolAuth } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; +import { FieldCrypto } from "../../utils/field-crypto.js"; +import { databaseLogger } from "../../utils/logger.js"; + +export type HostProtocolAuthRecord = typeof hostProtocolAuth.$inferSelect; + +/** A host's login for one plugin protocol, decrypted. */ +export interface HostProtocolLogin { + protocol: string; + authType: string; + credentialId: number | null; + username: string | null; + password: string | null; + /** Non-secret declared fields. */ + fields: Record; + /** Secret declared fields. */ + secretFields: Record; +} + +/** The encryption context of a login's secret columns. */ +export function protocolAuthRecordId(hostId: number, protocol: string): string { + return `host-protocol-auth-${hostId}-${protocol}`; +} + +function parseStringMap(value: string | null): Record { + if (!value) return {}; + try { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + const out: Record = {}; + for (const [key, entry] of Object.entries(parsed)) { + if (typeof entry === "string") out[key] = entry; + } + return out; + } catch { + return {}; + } +} + +function openField( + value: string | null, + dek: Buffer, + recordId: string, + fieldName: string, +): string | null { + if (!value) return null; + if (!FieldCrypto.isEncrypted(value)) return value; + try { + return FieldCrypto.decryptField(value, dek, recordId, fieldName); + } catch (error) { + // A value that does not open is treated as missing, never passed on. + databaseLogger.warn("A host protocol login could not be decrypted", { + operation: "host_protocol_auth_decrypt", + recordId, + fieldName, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +function sealField( + value: string | null, + dek: Buffer, + recordId: string, + fieldName: string, +): string | null { + if (!value) return null; + return FieldCrypto.encryptField(value, dek, recordId, fieldName); +} + +export function decryptProtocolLogin( + row: HostProtocolAuthRecord, + dek: Buffer, +): HostProtocolLogin { + const recordId = protocolAuthRecordId(row.hostId, row.protocol); + const secrets = openField(row.secretFields, dek, recordId, "secretFields"); + return { + protocol: row.protocol, + authType: row.authType || "direct", + credentialId: row.credentialId ?? null, + username: row.username ?? null, + password: openField(row.password, dek, recordId, "password"), + fields: parseStringMap(row.fields), + secretFields: parseStringMap(secrets), + }; +} + +export function encryptProtocolLogin( + hostId: number, + login: HostProtocolLogin, + dek: Buffer, +): Pick< + HostProtocolAuthRecord, + | "authType" + | "credentialId" + | "username" + | "password" + | "fields" + | "secretFields" +> { + const recordId = protocolAuthRecordId(hostId, login.protocol); + const secretFields = Object.keys(login.secretFields).length + ? JSON.stringify(login.secretFields) + : null; + return { + authType: login.authType || "direct", + credentialId: login.credentialId ?? null, + username: login.username || null, + password: sealField(login.password, dek, recordId, "password"), + fields: Object.keys(login.fields).length + ? JSON.stringify(login.fields) + : null, + secretFields: sealField(secretFields, dek, recordId, "secretFields"), + }; +} + +/** + * host_protocol_auth: one login per host and plugin protocol, encrypted with + * the host owner's data key. + */ +export class HostProtocolAuthRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listRowsForHosts(hostIds: number[]): Promise { + const unique = Array.from(new Set(hostIds)); + if (unique.length === 0) return []; + return this.context.drizzle + .select() + .from(hostProtocolAuth) + .where(inArray(hostProtocolAuth.hostId, unique)); + } + + async listRowsForHost(hostId: number): Promise { + return this.listRowsForHosts([hostId]); + } + + async findRow( + hostId: number, + protocol: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(hostProtocolAuth) + .where( + and( + eq(hostProtocolAuth.hostId, hostId), + eq(hostProtocolAuth.protocol, protocol), + ), + ) + .limit(1); + return rows[0] ?? null; + } + + async listForHost(hostId: number, dek: Buffer): Promise { + return (await this.listRowsForHost(hostId)).map((row) => + decryptProtocolLogin(row, dek), + ); + } + + async find( + hostId: number, + protocol: string, + dek: Buffer, + ): Promise { + const row = await this.findRow(hostId, protocol); + return row ? decryptProtocolLogin(row, dek) : null; + } + + async upsert( + ownerId: string, + hostId: number, + login: HostProtocolLogin, + dek: Buffer, + ): Promise { + const values = encryptProtocolLogin(hostId, login, dek); + const existing = await this.findRow(hostId, login.protocol); + if (existing) { + await this.context.drizzle + .update(hostProtocolAuth) + .set({ + ...values, + userId: ownerId, + updatedAt: new Date().toISOString(), + }) + .where(eq(hostProtocolAuth.id, existing.id)); + } else { + await this.context.drizzle.insert(hostProtocolAuth).values({ + ...values, + hostId, + userId: ownerId, + protocol: login.protocol, + }); + } + await this.afterWrite(); + } + + async delete(hostId: number, protocol: string): Promise { + const existing = await this.findRow(hostId, protocol); + if (!existing) return false; + await this.context.drizzle + .delete(hostProtocolAuth) + .where(eq(hostProtocolAuth.id, existing.id)); + await this.afterWrite(); + return true; + } + + /** The owner's hosts whose login for some protocol uses the credential. */ + async listHostIdsForCredential( + ownerId: string, + credentialId: number, + ): Promise { + const rows = await this.context.drizzle + .select({ hostId: hostProtocolAuth.hostId }) + .from(hostProtocolAuth) + .where( + and( + eq(hostProtocolAuth.userId, ownerId), + eq(hostProtocolAuth.credentialId, credentialId), + ), + ); + return Array.from(new Set(rows.map((row) => row.hostId))); + } + + /** Every login a user owns, for an export. */ + async listRowsForUser(userId: string): Promise { + return this.context.drizzle + .select() + .from(hostProtocolAuth) + .where(eq(hostProtocolAuth.userId, userId)); + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/host-resolution-repository.ts b/src/backend/database/repositories/host-resolution-repository.ts index 9bbc1547f..181e85382 100644 --- a/src/backend/database/repositories/host-resolution-repository.ts +++ b/src/backend/database/repositories/host-resolution-repository.ts @@ -15,9 +15,6 @@ export interface HostKeyVerificationRecord { export interface HostUpdateStateRecord { userId: string; credentialId: number | null; - rdpCredentialId: number | null; - vncCredentialId: number | null; - telnetCredentialId: number | null; authType: string; parentHostId: number | null; folder: string | null; @@ -142,9 +139,6 @@ export class HostResolutionRepository { .select({ userId: hosts.userId, credentialId: hosts.credentialId, - rdpCredentialId: hosts.rdpCredentialId, - vncCredentialId: hosts.vncCredentialId, - telnetCredentialId: hosts.telnetCredentialId, authType: hosts.authType, parentHostId: hosts.parentHostId, folder: hosts.folder, diff --git a/src/backend/database/repositories/rbac-access-repository.ts b/src/backend/database/repositories/rbac-access-repository.ts index 3a7945b87..8556a6f8f 100644 --- a/src/backend/database/repositories/rbac-access-repository.ts +++ b/src/backend/database/repositories/rbac-access-repository.ts @@ -44,9 +44,6 @@ export interface RbacSharedHost { export interface RbacRoleHostAccessCredentialSource { hostAccessId: number; credentialId: number | null; - rdpCredentialId: number | null; - vncCredentialId: number | null; - telnetCredentialId: number | null; hostId: number; hostOwnerId: string; } @@ -328,9 +325,6 @@ export class RbacAccessRepository { .select({ hostAccessId: hostAccess.id, credentialId: hosts.credentialId, - rdpCredentialId: hosts.rdpCredentialId, - vncCredentialId: hosts.vncCredentialId, - telnetCredentialId: hosts.telnetCredentialId, hostId: hosts.id, hostOwnerId: hosts.userId, }) diff --git a/src/backend/database/repositories/shared-host-secrets-repository.ts b/src/backend/database/repositories/shared-host-secrets-repository.ts index 56cd22499..b42c48cea 100644 --- a/src/backend/database/repositories/shared-host-secrets-repository.ts +++ b/src/backend/database/repositories/shared-host-secrets-repository.ts @@ -1,12 +1,18 @@ -import { and, eq, inArray, or } from "drizzle-orm"; -import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js"; +import { and, eq, inArray } from "drizzle-orm"; +import { + hostAccess, + hostProtocolAuth, + hosts, + sharedHostSecrets, +} from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { rowsAffected } from "./mutation-result.js"; export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect; export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert; -export type ShareProtocol = "ssh" | "rdp" | "vnc" | "telnet"; +/** "ssh", or a protocol id a plugin declares. */ +export type ShareProtocol = string; export class SharedHostSecretsRepository { constructor( @@ -178,19 +184,20 @@ export class SharedHostSecretsRepository { const rows = await this.context.drizzle .select({ id: hosts.id }) .from(hosts) + .where( + and(eq(hosts.userId, ownerId), eq(hosts.credentialId, credentialId)), + ); + const protocolRows = await this.context.drizzle + .select({ id: hostProtocolAuth.hostId }) + .from(hostProtocolAuth) .where( and( - eq(hosts.userId, ownerId), - or( - eq(hosts.credentialId, credentialId), - eq(hosts.rdpCredentialId, credentialId), - eq(hosts.vncCredentialId, credentialId), - eq(hosts.telnetCredentialId, credentialId), - ), + eq(hostProtocolAuth.userId, ownerId), + eq(hostProtocolAuth.credentialId, credentialId), ), ); - return rows.map((row) => row.id); + return Array.from(new Set([...rows, ...protocolRows].map((row) => row.id))); } private async afterWrite(): Promise { diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index 40f961502..7b150dd32 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -3,6 +3,11 @@ import { remapImportedJumpHosts, } from "./host-import-order.js"; import { sshOptionsForWrite } from "../../hosts/ssh-options.js"; +import { + keepUsableProtocolCredentials, + readProtocolAuthPayload, + writeProtocolAuth, +} from "../../hosts/protocol-auth/protocol-auth.js"; import { getErrorMessage } from "../../utils/error-message.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; @@ -707,13 +712,6 @@ export function registerHostBulkRoutes( sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; - sshDataObj.rdpUser = hostData.rdpUser || null; - sshDataObj.rdpPassword = hostData.rdpPassword || null; - sshDataObj.rdpDomain = hostData.rdpDomain || null; - sshDataObj.vncUser = hostData.vncUser || null; - sshDataObj.vncPassword = hostData.vncPassword || null; - sshDataObj.telnetUser = hostData.telnetUser || null; - sshDataObj.telnetPassword = hostData.telnetPassword || null; } else { sshDataObj.password = hostData.authType === "password" ? hostData.password : null; @@ -725,7 +723,6 @@ export function registerHostBulkRoutes( hostData.authType === "key" ? hostData.keyPassword || null : null; sshDataObj.keyType = hostData.authType === "key" ? hostData.keyType || "auto" : null; - sshDataObj.domain = null; } const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; @@ -753,6 +750,18 @@ export function registerHostBulkRoutes( results.success++; } + const protocolAuth = readProtocolAuthPayload( + hostData as Record, + ); + if (protocolAuth) { + await writeProtocolAuth( + userId, + savedHostId, + await keepUsableProtocolCredentials(protocolAuth, userId), + { isOwner: true }, + ); + } + // Every enabled plugin that declares host-scope settings and // registered a hostImportNormalizer validates and writes its own // fields here, so this loop does not need to know which plugins diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index a03600824..00f01507e 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -1,8 +1,9 @@ -import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js"; import { parseSshOptions, type HostSshOptions, } from "../../hosts/ssh-options.js"; +import { listHostProtocols } from "../../hosts/protocol-auth/registry.js"; +import { sanitizeProtocolAuthForRecipient } from "../../hosts/protocol-auth/summary.js"; export function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; @@ -41,6 +42,10 @@ export function normalizeProtocolEnableFields( ); } +/** + * The owner's SSH authentication, which a shared editor may not change. A + * plugin protocol's login is guarded in writeProtocolAuth instead. + */ export const OWNER_PRIVATE_AUTH_FIELDS = { ssh: [ "authType", @@ -54,21 +59,7 @@ export const OWNER_PRIVATE_AUTH_FIELDS = { "keyType", "sudoPassword", ], - rdp: [ - "rdpAuthType", - "rdpCredentialId", - "rdpUser", - "rdpPassword", - "rdpDomain", - ], - vnc: ["vncAuthType", "vncCredentialId", "vncUser", "vncPassword"], - telnet: [ - "telnetAuthType", - "telnetCredentialId", - "telnetUser", - "telnetPassword", - ], -} as const satisfies Record; +} as const; export const OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS = [ "sudoPassword", @@ -80,7 +71,7 @@ export const OWNER_PRIVATE_SSH_OPTION_FIELDS = ["agentSocketPath"] as const; export function containsOwnerPrivateAuthUpdate( hostData: Record, - protocol: AuthOverrideProtocol, + protocol: keyof typeof OWNER_PRIVATE_AUTH_FIELDS, ): boolean { return OWNER_PRIVATE_AUTH_FIELDS[protocol].some((field) => Object.prototype.hasOwnProperty.call(hostData, field), @@ -193,34 +184,41 @@ export type NormalizedImportedHost = Record & { socks5ProxyChain?: unknown; portKnockSequence?: unknown; overrideCredentialUsername?: unknown; - domain?: unknown; enableSsh: boolean; }; +function legacyPrefix(protocolId: string): string { + return protocolId.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase()); +} + +function legacyKey(protocolId: string): string { + const prefix = legacyPrefix(protocolId); + return prefix.charAt(0).toUpperCase() + prefix.slice(1); +} + export function normalizeImportedHost( hostData: Record, ): NormalizedImportedHost { const credentialAlias = asString(hostData.credentialAlias) || asString(hostData.credentialName); + // A row with no connectionType names its protocol the way a 2.8 export + // did: an "enable" flag and a "Port". + const flagged = listHostProtocols().find((protocol) => + asBoolean(hostData[`enable${legacyKey(protocol.id)}`]), + ); const connectionType = - asString(hostData.connectionType) || - (asBoolean(hostData.enableRdp) - ? "rdp" - : asBoolean(hostData.enableVnc) - ? "vnc" - : asBoolean(hostData.enableTelnet) - ? "telnet" - : "ssh"); + asString(hostData.connectionType) || flagged?.id || "ssh"; + const protocol = listHostProtocols().find( + (entry) => entry.id === connectionType, + ); const port = asPort(hostData.port) || - (connectionType === "rdp" - ? asPort(hostData.rdpPort) || 3389 - : connectionType === "vnc" - ? asPort(hostData.vncPort) || 5900 - : connectionType === "telnet" - ? asPort(hostData.telnetPort) || 23 - : asPort(hostData.sshPort) || 22); + (protocol + ? asPort(hostData[`${legacyPrefix(protocol.id)}Port`]) || + protocol.defaultPort || + 22 + : asPort(hostData.sshPort) || 22); return { ...hostData, @@ -260,9 +258,6 @@ const SENSITIVE_FIELDS = [ "password", "sudoPassword", "socks5Password", - "rdpPassword", - "vncPassword", - "telnetPassword", "autostartPassword", ]; @@ -282,9 +277,6 @@ export function stripSensitiveFields( // 2.8 editors kept the sudo password inside terminal_config. result.hasSudoPassword = !!host.sudoPassword || !!terminalConfigForSudo?.sudoPassword; - result.hasRdpPassword = !!host.rdpPassword; - result.hasVncPassword = !!host.vncPassword; - result.hasTelnetPassword = !!host.telnetPassword; for (const field of SENSITIVE_FIELDS) { delete result[field]; } @@ -326,7 +318,7 @@ const CONNECT_LEVEL_FIELDS = new Set([ "statusCheckInterval", "enableSsh", "sshPort", - "rdpAuthType", + "protocolAuth", "jumpHosts", "createdAt", "updatedAt", @@ -368,6 +360,12 @@ export function sanitizeHostForRecipient( !Array.isArray(authOverrides.ssh) ? (authOverrides.ssh as Record) : undefined; + if (stripped.protocolAuth !== undefined) { + stripped.protocolAuth = sanitizeProtocolAuthForRecipient( + stripped.protocolAuth, + permissionLevel, + ); + } if (!sshOverride?.credentialId) { stripped.hasPassword = false; stripped.hasKey = false; @@ -468,10 +466,6 @@ export function transformHostResponse( shareSshAuth: !!host.shareSshAuth, enableSsh: !!host.enableSsh, sshPort: host.sshPort ?? host.port ?? 22, - rdpUser: host.rdpUser || undefined, - rdpDomain: host.rdpDomain || undefined, - vncUser: host.vncUser || undefined, - telnetUser: host.telnetUser || undefined, jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [], // Moved into a plugin's host settings; its hostPayloadLegacy puts it // back for 2.8 clients. @@ -490,6 +484,5 @@ export function transformHostResponse( portKnockSequence: host.portKnockSequence ? JSON.parse(host.portKnockSequence as string) : [], - domain: host.domain || undefined, }; } diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 1c47462b5..c80a29798 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -71,6 +71,24 @@ import { } from "../../utils/shared-host-auth-resolver.js"; import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js"; import { sshOptionsForWrite } from "../../hosts/ssh-options.js"; +import { + applyProtocolAuthPlan, + attachProtocolAuth, + firstProtocolUsername, + listProtocolLogins, + loadProtocolAuthSummaries, + planProtocolAuthWrite, + ProtocolAuthWriteError, + readProtocolAuthPayload, + toPortableLogins, + withProtocolAuth, + writeProtocolAuth, + type PlannedProtocolAuth, +} from "../../hosts/protocol-auth/protocol-auth.js"; +import { + findHostProtocol, + listHostProtocols, +} from "../../hosts/protocol-auth/registry.js"; import { mergeStoredTerminalFields, parseTerminalConfig, @@ -199,7 +217,6 @@ router.post( terminalConfig, sshOptions, forceKeyboardInteractive, - domain, notes, useSocks5, socks5Host, @@ -213,20 +230,8 @@ router.post( overrideCredentialUsername, enableSsh, sshPort, - rdpAuthType, - rdpCredentialId, - rdpUser, - rdpPassword, - rdpDomain, - vncAuthType, - vncCredentialId, - vncPassword, - vncUser, - telnetAuthType, - telnetCredentialId, - telnetUser, - telnetPassword, } = hostData; + const protocolAuthPatch = readProtocolAuthPayload(hostData); databaseLogger.info("Creating SSH host", { operation: "host_create", userId, @@ -274,7 +279,7 @@ router.post( authMethod || (effectiveConnectionType !== "ssh" ? "password" : undefined); const effectiveUsername = - username || rdpUser || vncUser || telnetUser || ""; + username || firstProtocolUsername(protocolAuthPatch); const effectiveName = name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip)); const sshDataObj: Record = { @@ -304,7 +309,6 @@ router.post( : null, sshOptions: sshOptionsForWrite({ sshOptions, terminalConfig }) ?? null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", - domain: domain || null, notes: notes || null, sudoPassword: sudoPassword || null, useSocks5: useSocks5 ? 1 : 0, @@ -325,28 +329,9 @@ router.post( : null, ...normalizeProtocolEnableFields(hostData), sshPort: sshPort || port || 22, - rdpAuthType: rdpAuthType || null, - rdpCredentialId: - rdpAuthType === "credential" && rdpCredentialId - ? rdpCredentialId - : null, - rdpUser: rdpUser || null, - rdpDomain: rdpDomain || null, - vncAuthType: vncAuthType || null, - vncCredentialId: - vncAuthType === "credential" && vncCredentialId - ? vncCredentialId - : null, - vncUser: vncUser || null, - telnetAuthType: telnetAuthType || null, - telnetCredentialId: - telnetAuthType === "credential" && telnetCredentialId - ? telnetCredentialId - : null, - telnetUser: telnetUser || null, }; - // For non-SSH hosts (RDP, VNC, Telnet), always save password if provided + // A host whose main protocol is a plugin's keeps any password it is given. if (effectiveConnectionType !== "ssh") { sshDataObj.password = password || null; sshDataObj.key = null; @@ -399,10 +384,6 @@ router.post( sshDataObj.keyType = null; } - sshDataObj.rdpPassword = rdpPassword || null; - sshDataObj.vncPassword = vncPassword || null; - sshDataObj.telnetPassword = telnetPassword || null; - try { const result = await createCurrentHostRepository().createEncryptedForUser( userId, @@ -421,6 +402,14 @@ router.post( } const createdHost = result; + if (protocolAuthPatch) { + await writeProtocolAuth( + userId, + createdHost.id as number, + protocolAuthPatch, + { isOwner: true }, + ); + } // Standing folder shares apply to the newcomer. try { @@ -464,7 +453,7 @@ router.post( name: String(name ?? ip), }); - res.json(stripSensitiveFields(resolvedHost)); + res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost))); notifyStatsHostUpdated(createdHost.id as number, userId, "host_create"); } catch (err) { sshLogger.error("Failed to save SSH host to database", err, { @@ -794,7 +783,6 @@ router.put( terminalConfig, sshOptions, forceKeyboardInteractive, - domain, notes, useSocks5, socks5Host, @@ -808,20 +796,8 @@ router.put( overrideCredentialUsername, enableSsh, sshPort, - rdpAuthType, - rdpCredentialId, - rdpUser, - rdpPassword, - rdpDomain, - vncAuthType, - vncCredentialId, - vncPassword, - vncUser, - telnetAuthType, - telnetCredentialId, - telnetUser, - telnetPassword, } = hostData; + const protocolAuthPatch = readProtocolAuthPayload(hostData); databaseLogger.info("Updating SSH host", { operation: "host_update", userId, @@ -871,7 +847,7 @@ router.put( const effectiveAuthType = authType || authMethod; const effectiveUsername = - username || rdpUser || vncUser || telnetUser || ""; + username || firstProtocolUsername(protocolAuthPatch); const effectiveName = name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip)); const sshDataObj: Record = { @@ -899,7 +875,6 @@ router.put( : JSON.stringify(terminalConfig) : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", - domain: domain || null, notes: notes || null, useSocks5: useSocks5 ? 1 : 0, socks5Host: socks5Host || null, @@ -919,25 +894,6 @@ router.put( : null, ...normalizeProtocolEnableFields(hostData), sshPort: sshPort || port || 22, - rdpAuthType: rdpAuthType || null, - rdpCredentialId: - rdpAuthType === "credential" && rdpCredentialId - ? rdpCredentialId - : null, - rdpUser: rdpUser || null, - rdpDomain: rdpDomain || null, - vncAuthType: vncAuthType || null, - vncCredentialId: - vncAuthType === "credential" && vncCredentialId - ? vncCredentialId - : null, - vncUser: vncUser || null, - telnetAuthType: telnetAuthType || null, - telnetCredentialId: - telnetAuthType === "credential" && telnetCredentialId - ? telnetCredentialId - : null, - telnetUser: telnetUser || null, }; const nextSshOptions = sshOptionsForWrite({ sshOptions, terminalConfig }); @@ -947,7 +903,7 @@ router.put( sshDataObj.sudoPassword = sudoPassword || null; } - // For non-SSH hosts (RDP, VNC, Telnet), always save password if provided + // A host whose main protocol is a plugin's keeps any password it is given. if ((connectionType || "ssh") !== "ssh") { if (password) { sshDataObj.password = password; @@ -1007,10 +963,6 @@ router.put( sshDataObj.keyType = null; } - if (rdpPassword) sshDataObj.rdpPassword = rdpPassword; - if (vncPassword) sshDataObj.vncPassword = vncPassword; - if (telnetPassword) sshDataObj.telnetPassword = telnetPassword; - if (validatedParentHostId !== undefined) { sshDataObj.parentHostId = validatedParentHostId; } else if (folder !== undefined) { @@ -1087,37 +1039,28 @@ router.put( }); } - const referenceViolations: Array<[unknown, number | null, string]> = [ - [ - hostData.rdpCredentialId, - hostRecord.rdpCredentialId, - "RDP credential", - ], - [ - hostData.vncCredentialId, - hostRecord.vncCredentialId, - "VNC credential", - ], - [ - hostData.telnetCredentialId, - hostRecord.telnetCredentialId, - "Telnet credential", - ], - ]; - - for (const [incoming, current, label] of referenceViolations) { - if (incoming !== undefined && (incoming ?? null) !== current) { - return res.status(403).json({ - error: `Only the host owner can change the ${label}`, - }); - } - } - for (const field of OWNER_PRIVATE_AUTH_FIELDS.ssh) { delete sshDataObj[field]; } } + let protocolAuthPlan: PlannedProtocolAuth | null = null; + if (protocolAuthPatch) { + try { + protocolAuthPlan = await planProtocolAuthWrite( + ownerId, + Number(hostId), + protocolAuthPatch, + { isOwner: accessInfo.isOwner }, + ); + } catch (error) { + if (error instanceof ProtocolAuthWriteError) { + return res.status(error.status).json({ error: error.message }); + } + throw error; + } + } + const terminalFieldsError = await mergeStoredTerminalFields( sshDataObj, hostData, @@ -1134,6 +1077,9 @@ router.put( Number(hostId), sshDataObj, ); + if (protocolAuthPlan) { + await applyProtocolAuthPlan(ownerId, Number(hostId), protocolAuthPlan); + } // A host that moved into a folder inherits that folder's standing shares. try { @@ -1202,7 +1148,7 @@ router.put( success: true, }); - res.json(stripSensitiveFields(resolvedHost)); + res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost))); notifyStatsHostUpdated(parseInt(hostId), userId, "host_update"); } catch (err) { sshLogger.error("Failed to update SSH host in database", err, { @@ -1338,6 +1284,7 @@ router.get( }), ); + attachProtocolAuth(result, await loadProtocolAuthSummaries(result)); const sanitized = result.map((host) => host.isShared ? sanitizeHostForRecipient( @@ -1425,7 +1372,9 @@ router.get( (await resolveHostCredentials(result, userId)) || result; return res.json( - await withHostPluginSettings(stripSensitiveFields(resolved)), + await withHostPluginSettings( + await withProtocolAuth(stripSensitiveFields(resolved)), + ), ); } @@ -1473,8 +1422,9 @@ router.get( sharedExpiresAt: accessInfo.expiresAt || undefined, ownerUsername, }; - const resolvedSharedResult = - (await resolveHostCredentials(sharedResult, userId)) || sharedResult; + const resolvedSharedResult = await withProtocolAuth( + (await resolveHostCredentials(sharedResult, userId)) || sharedResult, + ); res.json( await withHostPluginSettings( @@ -1585,7 +1535,12 @@ router.get( * name: field * schema: * type: string - * enum: [password, sudoPassword, rdpPassword, vncPassword, telnetPassword, key, keyPassword] + * enum: [password, sudoPassword, key, keyPassword] + * - in: query + * name: protocol + * schema: + * type: string + * description: A plugin protocol id; returns that protocol's saved password instead of field. * responses: * 200: * description: The requested password value. @@ -1600,18 +1555,22 @@ router.get( const hostId = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; const field = (req.query.field as string) || "password"; + const coreField = [ + "password", + "sudoPassword", + "key", + "keyPassword", + ].includes(field); + // 2.8 clients ask for "Password". + const protocol = + typeof req.query.protocol === "string" + ? req.query.protocol + : coreField + ? undefined + : /^([a-z][a-zA-Z0-9]*)Password$/.exec(field)?.[1]; + const protocolId = protocol ? findHostProtocol(protocol)?.id : undefined; - if ( - ![ - "password", - "sudoPassword", - "rdpPassword", - "vncPassword", - "telnetPassword", - "key", - "keyPassword", - ].includes(field) - ) { + if (!protocolId && (protocol || !coreField)) { return res.status(400).json({ error: "Invalid field" }); } @@ -1626,6 +1585,16 @@ router.get( return res.status(404).json({ error: "Host not found" }); } + if (protocolId) { + const login = (await listProtocolLogins(hostId, userId)).find( + (entry) => entry.protocol === protocolId, + ); + if (!login?.password) { + return res.status(404).json({ error: "No password set" }); + } + return res.json({ value: login.password }); + } + const resolved = (await resolveHostCredentials(host, userId)) || host; let value = resolved[field]; @@ -1724,25 +1693,14 @@ router.get( notes: resolvedHost.notes || null, // Every plugin's host settings, secrets redacted, for import to hand back. pluginSettings: hostPluginSettings ?? {}, + // Each plugin protocol's login, secrets included like the SSH ones. + protocolAuth: toPortableLogins( + await listProtocolLogins(Number(hostId), userId), + ), }; const exportData = isRemoteDesktop - ? { - ...baseExportData, - rdpAuthType: resolvedHost.rdpAuthType || null, - rdpCredentialId: resolvedHost.rdpCredentialId || null, - rdpUser: resolvedHost.rdpUser || null, - rdpPassword: resolvedHost.rdpPassword || null, - rdpDomain: resolvedHost.rdpDomain || null, - vncAuthType: resolvedHost.vncAuthType || null, - vncCredentialId: resolvedHost.vncCredentialId || null, - vncUser: resolvedHost.vncUser || null, - vncPassword: resolvedHost.vncPassword || null, - telnetAuthType: resolvedHost.telnetAuthType || null, - telnetCredentialId: resolvedHost.telnetCredentialId || null, - telnetUser: resolvedHost.telnetUser || null, - telnetPassword: resolvedHost.telnetPassword || null, - } + ? baseExportData : { ...baseExportData, authType: resolvedHost.authType, @@ -1867,13 +1825,16 @@ router.get( notes: resolvedHost.notes || null, // Every plugin's host settings, secrets redacted, for import to hand back. pluginSettings: hostPluginSettings ?? {}, + protocolAuth: shareableLogins( + toPortableLogins( + await listProtocolLogins(host.id as number, userId), + ), + shareMode, + ), }; const exportData = isRemoteDesktop - ? { - ...baseExportData, - domain: resolvedHost.domain || null, - } + ? baseExportData : { ...baseExportData, authType: resolvedHost.authType, @@ -2091,6 +2052,38 @@ router.delete( // the file-manager plugin, under /plugin-api/file-manager/, and command // history to the ssh-terminal plugin, under /plugin-api/ssh-terminal/. +/** + * A share export leaves out every secret and credential link, the same as + * it does for SSH. + */ +function shareableLogins( + logins: ReturnType, + shareMode: boolean, +): ReturnType { + if (!shareMode) return logins; + const out: ReturnType = {}; + for (const [protocol, login] of Object.entries(logins)) { + const declared = findHostProtocol(protocol); + const secret = new Set( + (declared?.credentialFields ?? []) + .filter((field) => field.secret) + .map((field) => field.key), + ); + out[protocol] = { + authType: login.authType === "credential" ? "direct" : login.authType, + credentialId: null, + username: login.username, + password: null, + fields: Object.fromEntries( + Object.entries(login.fields).filter( + ([key]) => declared && !secret.has(key), + ), + ), + }; + } + return out; +} + async function resolveHostCredentials( host: Record, requestingUserId?: string, @@ -2128,9 +2121,9 @@ async function resolveHostCredentials( host.id, requestingUserId, ); - // Whether a protocol is on is the remote-desktop plugin's host - // setting; the client only offers an override for one that is. - for (const protocol of ["rdp", "vnc", "telnet"] as const) { + // Whether a plugin protocol is on is its plugin's host setting; the + // client only offers an override for one that is. + for (const { id: protocol } of listHostProtocols()) { authOverrides[protocol] = { credentialId: overrideCredentialIds[protocol], required: false, diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index 19a538679..8e895f724 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -8,7 +8,7 @@ import { getAuditUsername, logAudit, } from "../../utils/audit-logger.js"; -import { isAuthOverrideProtocol } from "../../../types/auth-protocols.js"; +import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js"; import { SharedHostAuthOverrideService, SharedHostAuthOverrideServiceError, @@ -1945,7 +1945,7 @@ router.delete( * /rbac/host-access/{hostId}/auth/{protocol}: * put: * summary: Set personal authentication for a shared host protocol - * description: Selects one of the authenticated recipient's own credentials, or clears the selection with null. Only SSH is currently supported. + * description: Selects one of the authenticated recipient's own credentials, or clears the selection with null. The protocol is "ssh" or one a plugin declares. * tags: [RBAC] * security: * - bearerAuth: [] diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index b551f4c40..ed393b635 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -346,7 +346,7 @@ export function registerUserSettingsRoutes( * /users/session-sharing-enabled: * get: * summary: Get session sharing globally enabled setting - * description: Returns whether live session sharing (terminal/RDP/VNC/Telnet share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false. + * description: Returns whether live session sharing (terminal and remote desktop share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false. * tags: * - Users * responses: diff --git a/src/backend/hosts/external-secrets.ts b/src/backend/hosts/external-secrets.ts index dafb1ff47..feb6a72a2 100644 --- a/src/backend/hosts/external-secrets.ts +++ b/src/backend/hosts/external-secrets.ts @@ -20,9 +20,6 @@ const SECRET_FIELDS = [ "keyPassword", "sudoPassword", "socks5Password", - "rdpPassword", - "vncPassword", - "telnetPassword", ] as const; const REFERENCE_PATTERN = /^([a-z][a-z0-9+.-]*):\/\//i; diff --git a/src/backend/hosts/protocol-auth/protocol-auth.ts b/src/backend/hosts/protocol-auth/protocol-auth.ts new file mode 100644 index 000000000..2d16ab80f --- /dev/null +++ b/src/backend/hosts/protocol-auth/protocol-auth.ts @@ -0,0 +1,660 @@ +/** + * Per-host logins for the protocols plugins declare (contributes.protocols). + * + * Core stores them in host_protocol_auth, encrypted with the host owner's + * data key, and knows each protocol only by its declaration: which extra + * fields a login carries and which of those are secret. Writes, the host + * payload, sharing, export and sync all go through here, so a new protocol + * needs no core change. + */ + +import { + createCurrentHostProtocolAuthRepository, + createCurrentSharedHostAuthOverrideRepository, +} from "../../database/repositories/factory.js"; +import { + decryptProtocolLogin, + type HostProtocolAuthRecord, + type HostProtocolLogin, +} from "../../database/repositories/host-protocol-auth-repository.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; +import { findUsableCredential } from "../usable-credential.js"; +import { + isSecretReference, + resolveSecretReference, +} from "../external-secrets.js"; +import { + findHostProtocol, + listHostProtocols, + plainFieldKeys, + secretFieldKeys, + type DeclaredHostProtocol, +} from "./registry.js"; +import type { ProtocolAuthSummary } from "./summary.js"; + +export type { ProtocolAuthSummary } from "./summary.js"; + +export const PROTOCOL_AUTH_TYPES = ["direct", "credential", "none"] as const; +export type ProtocolAuthType = (typeof PROTOCOL_AUTH_TYPES)[number]; + +/** One protocol's login as a client sends it. Absent keys keep what is stored. */ +export interface ProtocolAuthInput { + authType?: unknown; + credentialId?: unknown; + username?: unknown; + password?: unknown; + fields?: Record; +} + +/** Protocol id to its new login, or null to remove it. */ +export type ProtocolAuthPatch = Map; + +/** A login ready to hand to the program that connects. */ +export interface ResolvedProtocolLogin { + authType: string; + username: string; + password: string; + fields: Record; +} + +export class ProtocolAuthWriteError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "ProtocolAuthWriteError"; + } +} + +/** The owner's data key; throws while it is locked. */ +function ownerKey(ownerId: string): Buffer { + const dek = DataCrypto.getUserDataKey(ownerId); + if (!dek) throw new Error(`The data key of ${ownerId} is locked`); + return dek; +} + +function isObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function str(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function asCredentialId(value: unknown): number | null { + const id = typeof value === "string" ? Number(value) : value; + return typeof id === "number" && Number.isInteger(id) && id > 0 ? id : null; +} + +function asAuthType(value: unknown): ProtocolAuthType | undefined { + return PROTOCOL_AUTH_TYPES.includes(value as ProtocolAuthType) + ? (value as ProtocolAuthType) + : undefined; +} + +/** + * The flat keys a 2.8 client or export used for a protocol: + * `User`, `Password`, `CredentialId`, `AuthType` and + * `` for each declared field. + */ +function legacyInput( + hostData: Record, + protocol: DeclaredHostProtocol, +): ProtocolAuthInput | null | undefined { + const prefix = protocol.id.replace(/-([a-z0-9])/g, (_, c: string) => + c.toUpperCase(), + ); + const key = (suffix: string) => `${prefix}${suffix}`; + const has = (name: string) => + Object.prototype.hasOwnProperty.call(hostData, name); + const fieldKeys = (protocol.credentialFields ?? []).map((field) => field.key); + const names = [ + key("User"), + key("Password"), + key("CredentialId"), + key("AuthType"), + ...fieldKeys.map((field) => key(capitalize(field))), + ]; + if (!names.some(has)) return undefined; + + const input: ProtocolAuthInput = {}; + if (has(key("AuthType"))) input.authType = hostData[key("AuthType")]; + if (has(key("CredentialId"))) { + input.credentialId = hostData[key("CredentialId")]; + } + if (has(key("User"))) input.username = hostData[key("User")]; + if (has(key("Password"))) { + // A 2.8 editor sent null for "leave the saved password alone". + const password = hostData[key("Password")]; + if (password) input.password = password; + } + const fields: Record = {}; + for (const field of fieldKeys) { + const name = key(capitalize(field)); + if (has(name)) fields[field] = hostData[name]; + } + if (Object.keys(fields).length) input.fields = fields; + + // A 2.8 editor cleared a protocol it switched off by sending nulls. + if ( + has(key("AuthType")) && + input.authType == null && + !input.username && + !input.password && + !input.credentialId && + Object.values(fields).every((value) => !value) + ) { + return null; + } + return input; +} + +/** + * The protocol logins a host write carries: `protocolAuth` keyed by + * protocol id, or the flat fields a 2.8 client sends. Null when it carries + * none, which leaves every stored login alone. + */ +export function readProtocolAuthPayload( + hostData: Record, +): ProtocolAuthPatch | null { + const patch: ProtocolAuthPatch = new Map(); + if (isObject(hostData.protocolAuth)) { + for (const [id, value] of Object.entries(hostData.protocolAuth)) { + if (!findHostProtocol(id)) continue; + if (value === null) patch.set(id, null); + else if (isObject(value)) patch.set(id, value as ProtocolAuthInput); + } + } else { + for (const protocol of listHostProtocols()) { + const input = legacyInput(hostData, protocol); + if (input !== undefined) patch.set(protocol.id, input); + } + } + return patch.size ? patch : null; +} + +/** Folds a client's input over the stored login. Pure. */ +export function mergeProtocolLogin( + protocolId: string, + declared: DeclaredHostProtocol | undefined, + current: HostProtocolLogin | null, + input: ProtocolAuthInput, +): HostProtocolLogin { + const inputCredential = + input.credentialId === undefined + ? undefined + : asCredentialId(input.credentialId); + const authType: ProtocolAuthType = + asAuthType(input.authType) ?? + (input.authType === undefined + ? asAuthType(current?.authType) + : undefined) ?? + (inputCredential ? "credential" : "direct"); + + const credentialId = + authType === "credential" + ? inputCredential !== undefined + ? inputCredential + : (current?.credentialId ?? null) + : null; + + const direct = authType === "direct"; + const username = direct + ? input.username !== undefined + ? str(input.username) || null + : (current?.username ?? null) + : null; + // An empty password keeps the saved one, as 2.8 did: the editor never + // shows it, and a shared editor cannot see whether one is set. + const password = direct + ? str(input.password) || (current?.password ?? null) + : null; + + const incoming = isObject(input.fields) ? input.fields : {}; + const pick = ( + keys: string[], + stored: Record, + keepEmpty: boolean, + ): Record => { + const out: Record = {}; + for (const key of keys) { + const given = Object.prototype.hasOwnProperty.call(incoming, key) + ? str(incoming[key]) + : ""; + const value = + given || + (keepEmpty || !Object.prototype.hasOwnProperty.call(incoming, key) + ? (stored[key] ?? "") + : ""); + if (value) out[key] = value; + } + return out; + }; + + return { + protocol: protocolId, + authType, + credentialId, + username, + password, + fields: declared + ? pick(plainFieldKeys(declared), current?.fields ?? {}, false) + : {}, + secretFields: declared + ? pick(secretFieldKeys(declared), current?.secretFields ?? {}, true) + : {}, + }; +} + +function isEmptyLogin(login: HostProtocolLogin): boolean { + return ( + login.authType === "direct" && + !login.username && + !login.password && + Object.keys(login.fields).length === 0 && + Object.keys(login.secretFields).length === 0 + ); +} + +export type PlannedProtocolAuth = Array<[string, HostProtocolLogin | null]>; + +/** + * Works out a host write's protocol logins without writing them. A shared + * editor works on the owner's record but may not point a login at a + * different credential, which throws a 403 before anything is written. + */ +export async function planProtocolAuthWrite( + ownerId: string, + hostId: number, + patch: ProtocolAuthPatch, + options: { isOwner: boolean }, +): Promise { + const repository = createCurrentHostProtocolAuthRepository(); + const dek = ownerKey(ownerId); + + const planned: PlannedProtocolAuth = []; + for (const [protocolId, input] of patch) { + const current = await repository.find(hostId, protocolId, dek); + const next = + input === null + ? null + : mergeProtocolLogin( + protocolId, + findHostProtocol(protocolId), + current, + input, + ); + if ( + !options.isOwner && + (next?.credentialId ?? null) !== (current?.credentialId ?? null) + ) { + throw new ProtocolAuthWriteError( + `Only the host owner can change the ${protocolId} credential`, + 403, + ); + } + planned.push([protocolId, next && isEmptyLogin(next) ? null : next]); + } + return planned; +} + +export async function applyProtocolAuthPlan( + ownerId: string, + hostId: number, + planned: PlannedProtocolAuth, +): Promise { + const repository = createCurrentHostProtocolAuthRepository(); + const dek = ownerKey(ownerId); + for (const [protocolId, next] of planned) { + if (next) await repository.upsert(ownerId, hostId, next, dek); + else await repository.delete(hostId, protocolId); + } +} + +export async function writeProtocolAuth( + ownerId: string, + hostId: number, + patch: ProtocolAuthPatch, + options: { isOwner: boolean }, +): Promise { + await applyProtocolAuthPlan( + ownerId, + hostId, + await planProtocolAuthWrite(ownerId, hostId, patch, options), + ); +} + +/** + * An import names credentials by another server's ids. Any the importing + * user cannot use falls back to a direct login. + */ +export async function keepUsableProtocolCredentials( + patch: ProtocolAuthPatch, + userId: string, +): Promise { + const out: ProtocolAuthPatch = new Map(); + for (const [protocol, input] of patch) { + const credentialId = asCredentialId(input?.credentialId); + if ( + input && + credentialId && + !(await findUsableCredential(credentialId, userId)) + ) { + out.set(protocol, { + ...input, + credentialId: null, + authType: input.authType === "credential" ? "direct" : input.authType, + }); + } else { + out.set(protocol, input); + } + } + return out; +} + +/** The first username a write's logins carry, for a host with no SSH username. */ +export function firstProtocolUsername(patch: ProtocolAuthPatch | null): string { + for (const input of patch?.values() ?? []) { + const username = str(input?.username); + if (username) return username; + } + return ""; +} + +/** Replaces every login of a host with the given ones, as a sync or import does. */ +export async function replaceProtocolLogins( + ownerId: string, + hostId: number, + logins: HostProtocolLogin[], +): Promise { + const repository = createCurrentHostProtocolAuthRepository(); + const dek = ownerKey(ownerId); + const keep = new Set(); + for (const login of logins) { + keep.add(login.protocol); + if (isEmptyLogin(login)) await repository.delete(hostId, login.protocol); + else await repository.upsert(ownerId, hostId, login, dek); + } + for (const row of await repository.listRowsForHost(hostId)) { + if (!keep.has(row.protocol)) await repository.delete(hostId, row.protocol); + } +} + +/** Every login of a host, decrypted with its owner's key. */ +export async function listProtocolLogins( + hostId: number, + ownerId: string, +): Promise { + const dek = ownerKey(ownerId); + return createCurrentHostProtocolAuthRepository().listForHost(hostId, dek); +} + +function summarize( + row: HostProtocolAuthRecord, + dek: Buffer | null, +): ProtocolAuthSummary { + const login = dek ? decryptProtocolLogin(row, dek) : null; + let fields: Record = {}; + try { + const parsed = row.fields ? JSON.parse(row.fields) : {}; + if (isObject(parsed)) { + fields = Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + } + } catch { + fields = {}; + } + return { + authType: row.authType || "direct", + credentialId: row.credentialId ?? null, + username: row.username ?? null, + fields, + hasPassword: !!row.password, + secretFieldKeys: login ? Object.keys(login.secretFields) : [], + }; +} + +/** Browser-safe logins for a list of hosts, keyed by host id then protocol. */ +export async function loadProtocolAuthSummaries( + hosts: Array>, +): Promise>> { + const ids = hosts + .map((host) => Number(host.id)) + .filter((id) => Number.isInteger(id) && id > 0); + const result = new Map>(); + if (ids.length === 0) return result; + const rows = + await createCurrentHostProtocolAuthRepository().listRowsForHosts(ids); + const keys = new Map(); + for (const row of rows) { + if (!keys.has(row.userId)) { + keys.set(row.userId, DataCrypto.getUserDataKey(row.userId)); + } + const own = result.get(row.hostId) ?? {}; + own[row.protocol] = summarize(row, keys.get(row.userId) ?? null); + result.set(row.hostId, own); + } + return result; +} + +/** Sets `protocolAuth` on each host from the loaded summaries. */ +export function attachProtocolAuth( + hosts: Array>, + summaries: Map>, +): void { + for (const host of hosts) { + host.protocolAuth = summaries.get(Number(host.id)) ?? {}; + } +} + +export async function withProtocolAuth>( + host: T, +): Promise { + attachProtocolAuth([host], await loadProtocolAuthSummaries([host])); + return host; +} + +async function openReference(userId: string, value: string): Promise { + return isSecretReference(value) + ? resolveSecretReference(userId, value) + : value; +} + +function withDeclaredFields( + declared: DeclaredHostProtocol, + ...sources: Array | undefined> +): Record { + const out: Record = {}; + for (const field of declared.credentialFields ?? []) { + out[field.key] = ""; + for (const source of sources) { + if (source?.[field.key]) { + out[field.key] = source[field.key]; + break; + } + } + } + return out; +} + +/** The owner's own login: the stored one, or the credential it points at. */ +export async function resolveOwnerProtocolLogin( + host: Record, + declared: DeclaredHostProtocol, +): Promise { + const hostId = Number(host.id); + const ownerId = String(host.userId); + const dek = ownerKey(ownerId); + const login = await createCurrentHostProtocolAuthRepository().find( + hostId, + declared.id, + dek, + ); + + const authType = login?.authType || "direct"; + let username = login?.username ?? ""; + let password = login?.password ?? ""; + if (authType === "credential" && login?.credentialId) { + const credential = await findUsableCredential(login.credentialId, ownerId); + // Only the login itself comes from a stored credential, never the fields. + if (credential?.username) username = credential.username; + if (credential?.password) password = credential.password; + } + + const fallback = declared.hostLoginFallback ?? []; + if (fallback.includes("username")) username ||= str(host.username); + if (fallback.includes("password")) password ||= str(host.password); + + const secretFields: Record = {}; + for (const [key, value] of Object.entries(login?.secretFields ?? {})) { + secretFields[key] = await openReference(ownerId, value); + } + + return { + authType, + username, + password: password ? await openReference(ownerId, password) : "", + fields: withDeclaredFields(declared, secretFields, login?.fields), + }; +} + +/** + * A shared recipient's login: their own override credential, the owner's + * shared snapshot, or nothing. Never the owner's stored secret. + */ +export async function resolveRecipientProtocolLogin( + host: Record, + userId: string, + declared: DeclaredHostProtocol, +): Promise { + const hostId = Number(host.id); + const { resolveRecipientSharedHostAuthentication } = + await import("../../utils/shared-host-auth-resolver.js"); + const row = await createCurrentHostProtocolAuthRepository().findRow( + hostId, + declared.id, + ); + const ownerFields = row ? summarize(row, null).fields : {}; + + let resolution: Awaited< + ReturnType + >; + try { + resolution = await resolveRecipientSharedHostAuthentication( + { ...host, password: null } as never, + hostId, + userId, + declared.id, + ); + } catch { + resolution = { source: "required" }; + } + + if (resolution.source === "personal-override") { + return { + authType: "credential", + username: str(resolution.credential.username), + password: str(resolution.credential.password), + fields: withDeclaredFields(declared, ownerFields), + }; + } + if (resolution.source === "owner-shared") { + return { + authType: resolution.authType, + username: str(resolution.secret?.username), + password: str(resolution.secret?.password), + fields: withDeclaredFields( + declared, + resolution.secret?.fields, + ownerFields, + ), + }; + } + return { + authType: + resolution.source === "secretless" && row?.authType === "none" + ? "none" + : "direct", + username: "", + password: "", + fields: withDeclaredFields(declared, ownerFields), + }; +} + +/** Credential ids a recipient chose for this host, per protocol. */ +export async function listRecipientOverrideIds( + hostId: number, + userId: string, +): Promise> { + return (await createCurrentSharedHostAuthOverrideRepository().listCredentialIds( + hostId, + userId, + )) as Record; +} + +/** A login in an export or on the sync wire: plaintext, keyed by protocol. */ +export interface PortableProtocolLogin { + authType: string; + credentialId: number | null; + username: string | null; + password: string | null; + fields: Record; +} + +export function toPortableLogins( + logins: HostProtocolLogin[], +): Record { + const out: Record = {}; + for (const login of logins) { + out[login.protocol] = { + authType: login.authType, + credentialId: login.credentialId, + username: login.username, + password: login.password, + fields: { ...login.fields, ...login.secretFields }, + }; + } + return out; +} + +/** + * Reads a login back from an export or the wire. Fields are split by the + * protocol's declaration; an undeclared protocol keeps every field secret. + */ +export function fromPortableLogin( + protocolId: string, + value: unknown, + credentialId: number | null, +): HostProtocolLogin | null { + if (!isObject(value)) return null; + const declared = findHostProtocol(protocolId); + const secret = new Set(declared ? secretFieldKeys(declared) : []); + const plain = new Set(declared ? plainFieldKeys(declared) : []); + const fields: Record = {}; + const secretFields: Record = {}; + if (isObject(value.fields)) { + for (const [key, entry] of Object.entries(value.fields)) { + if (typeof entry !== "string" || !entry) continue; + if (plain.has(key)) fields[key] = entry; + else if (secret.has(key) || !declared) secretFields[key] = entry; + } + } + const authType = asAuthType(value.authType) ?? "direct"; + return { + protocol: protocolId, + authType, + credentialId: authType === "credential" ? credentialId : null, + username: str(value.username) || null, + password: str(value.password) || null, + fields, + secretFields, + }; +} diff --git a/src/backend/hosts/protocol-auth/registry.ts b/src/backend/hosts/protocol-auth/registry.ts new file mode 100644 index 000000000..0855aa81f --- /dev/null +++ b/src/backend/hosts/protocol-auth/registry.ts @@ -0,0 +1,63 @@ +/** + * The host protocols plugins declare in contributes.protocols, read from + * every installed plugin's manifest (enabled or not), so core can store, + * share and sync a protocol's login without the plugin's code running. + */ + +import type { PluginProtocolContribution } from "@termix/plugin-sdk/manifest"; +import { SSH_AUTH_PROTOCOL } from "../../../types/auth-protocols.js"; + +export interface DeclaredHostProtocol extends PluginProtocolContribution { + pluginId: string; + pluginName: string; +} + +let source: () => DeclaredHostProtocol[] = () => []; + +export function setHostProtocolSource( + next: () => DeclaredHostProtocol[], +): void { + source = next; +} + +/** One entry per protocol id; the first plugin to declare an id keeps it. */ +export function listHostProtocols(): DeclaredHostProtocol[] { + const seen = new Set(); + const out: DeclaredHostProtocol[] = []; + for (const protocol of source()) { + if (seen.has(protocol.id)) continue; + seen.add(protocol.id); + out.push(protocol); + } + return out; +} + +export function findHostProtocol( + id: unknown, +): DeclaredHostProtocol | undefined { + if (typeof id !== "string") return undefined; + return listHostProtocols().find((protocol) => protocol.id === id); +} + +export function isHostProtocol(id: unknown): id is string { + return !!findHostProtocol(id); +} + +export function secretFieldKeys( + protocol: PluginProtocolContribution, +): string[] { + return (protocol.credentialFields ?? []) + .filter((field) => field.secret === true) + .map((field) => field.key); +} + +export function plainFieldKeys(protocol: PluginProtocolContribution): string[] { + return (protocol.credentialFields ?? []) + .filter((field) => field.secret !== true) + .map((field) => field.key); +} + +/** "ssh" or a declared protocol: what a shared recipient may override. */ +export function isAuthOverrideProtocol(value: unknown): value is string { + return value === SSH_AUTH_PROTOCOL || isHostProtocol(value); +} diff --git a/src/backend/hosts/protocol-auth/summary.ts b/src/backend/hosts/protocol-auth/summary.ts new file mode 100644 index 000000000..41871eee3 --- /dev/null +++ b/src/backend/hosts/protocol-auth/summary.ts @@ -0,0 +1,43 @@ +/** + * The browser-facing shape of a host's protocol logins. No database access, + * so the host normalizers can use it. + */ + +/** What a browser sees of a login: no secret values, only whether they are set. */ +export interface ProtocolAuthSummary { + authType: string; + credentialId: number | null; + username: string | null; + fields: Record; + hasPassword: boolean; + secretFieldKeys: string[]; +} + +function isObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** + * What a shared recipient may see of each login: never whether secrets are + * set, and at connect level only the auth type. + */ +export function sanitizeProtocolAuthForRecipient( + value: unknown, + permissionLevel: string | undefined, +): Record { + if (!isObject(value)) return {}; + const out: Record = {}; + for (const [protocol, summary] of Object.entries(value)) { + if (!isObject(summary)) continue; + out[protocol] = + permissionLevel === "connect" + ? { authType: summary.authType } + : { + authType: summary.authType, + credentialId: summary.credentialId ?? null, + username: summary.username ?? null, + fields: summary.fields ?? {}, + }; + } + return out; +} diff --git a/src/backend/plugins/ctx-credentials.ts b/src/backend/plugins/ctx-credentials.ts index 497ed5299..5074a4fb8 100644 --- a/src/backend/plugins/ctx-credentials.ts +++ b/src/backend/plugins/ctx-credentials.ts @@ -4,9 +4,10 @@ * to guacd). Needs credentials:read, the one critical capability, and every * call is audited whether it succeeds or not. * - * The protocol credentials themselves stay in core, next to the host, because - * core's sharing model (per-recipient secret snapshots, personal overrides) - * is what decides which ones a shared recipient may use. + * The logins themselves stay in core, next to the host, because core's + * sharing model (per-recipient secret snapshots, personal overrides) decides + * which ones a shared recipient may use. A plugin reads only the protocols + * its own manifest declares in contributes.protocols. * * registerSecretResolver is the other half: a plugin that resolves * "://..." references in a host's secret fields (secret-sources and @@ -21,8 +22,6 @@ import type { PluginCredentials, - PluginHostProtocol, - PluginProtocolTarget, PluginSshKeyCredential, } from "@termix/plugin-sdk/backend"; import ssh2 from "ssh2"; @@ -31,6 +30,7 @@ import { assertCapability, capabilityRefused } from "./permissions.js"; import { getActor } from "./actor.js"; import type { DisposableBag } from "./disposables.js"; import { registerSecretResolver } from "../hosts/connect/secret-resolver-registry.js"; +import { findHostProtocol } from "../hosts/protocol-auth/registry.js"; type AuditFn = ( action: string, @@ -44,8 +44,6 @@ interface Deps { audit: AuditFn; } -const PROTOCOLS: PluginHostProtocol[] = ["rdp", "vnc", "telnet"]; - type HostRow = Record; function str(value: unknown): string { @@ -68,100 +66,6 @@ function parseJumpHosts(value: unknown): Array<{ hostId: number }> { .map((hostId) => ({ hostId })); } -async function resolveOwnerAuth( - host: HostRow, - protocol: PluginHostProtocol, -): Promise { - const credentialId = host[`${protocol}CredentialId`] as number | null; - const authType = - str(host[`${protocol}AuthType`]) || - (credentialId ? "credential" : "direct"); - let username = str(host[`${protocol}User`]); - let password = str(host[`${protocol}Password`]); - - if (authType === "credential" && credentialId) { - const { createCurrentHostResolutionRepository } = - await import("../database/repositories/factory.js"); - const credential = - await createCurrentHostResolutionRepository().findCredentialByIdForUser( - credentialId, - host.userId as string, - ); - // The domain never comes from a stored credential. - if (credential?.username) username = credential.username; - if (credential?.password) password = credential.password; - } - - if (protocol === "rdp") username ||= str(host.username); - password ||= str(host.password); - - return { - authType, - username, - password, - domain: str(host.rdpDomain) || str(host.domain), - }; -} - -async function resolveRecipientAuth( - host: HostRow, - hostId: number, - userId: string, - protocol: PluginHostProtocol, -): Promise { - const { resolveRecipientSharedHostAuthentication } = - await import("../utils/shared-host-auth-resolver.js"); - // Recipients never read the owner's raw secrets, so start from nothing. - const recipientHost = { - ...host, - password: null, - rdpUser: null, - rdpPassword: null, - vncUser: null, - vncPassword: null, - telnetUser: null, - telnetPassword: null, - }; - let resolution: Awaited< - ReturnType - >; - try { - resolution = await resolveRecipientSharedHostAuthentication( - recipientHost as never, - hostId, - userId, - protocol, - ); - } catch { - // Same as before the move: connect without stored credentials. - resolution = { source: "required" }; - } - - const auth = - resolution.source === "personal-override" - ? { ...resolution.credential, domain: null } - : resolution.source === "owner-shared" - ? resolution.secret - : null; - - let authType = "direct"; - if (resolution.source === "personal-override") authType = "credential"; - else if (resolution.source === "owner-shared") authType = resolution.authType; - else if ( - resolution.source === "secretless" && - str(host[`${protocol}AuthType`]) === "none" - ) { - authType = "none"; - } - - return { - authType, - username: str(auth?.username), - password: str(auth?.password), - domain: str(auth?.domain) || str(host.rdpDomain) || str(host.domain), - }; -} - function derivePublicKey( privateKey: string | null | undefined, passphrase: string | null | undefined, @@ -326,8 +230,15 @@ export function createPluginCredentials({ const details = `${protocol} credentials for host ${hostId}`; try { await assertCapability(pluginId, "credentials:read", declared); - if (!PROTOCOLS.includes(protocol)) { - throw new Error(`Unsupported protocol: ${String(protocol)}`); + // Another plugin that declared the same id first owns it. + const own = + manifest.contributes?.protocols?.some( + (entry) => entry.id === protocol, + ) && findHostProtocol(protocol)?.pluginId === pluginId; + if (!own) { + throw new Error( + `Plugin ${pluginId} does not declare protocol "${String(protocol)}" in contributes.protocols`, + ); } } catch (error) { await audit("credentials_read", details, { @@ -378,9 +289,12 @@ export function createPluginCredentials({ } } + const declaredProtocol = findHostProtocol(protocol)!; + const { resolveOwnerProtocolLogin, resolveRecipientProtocolLogin } = + await import("../hosts/protocol-auth/protocol-auth.js"); const auth = shared - ? await resolveRecipientAuth(host, hostId, userId, protocol) - : await resolveOwnerAuth(host, protocol); + ? await resolveRecipientProtocolLogin(host, userId, declaredProtocol) + : await resolveOwnerProtocolLogin(host, declaredProtocol); await audit("credentials_read", details, { success: true }); return { diff --git a/src/backend/plugins/ctx-hosts.ts b/src/backend/plugins/ctx-hosts.ts index 7a85b2036..5e0a49494 100644 --- a/src/backend/plugins/ctx-hosts.ts +++ b/src/backend/plugins/ctx-hosts.ts @@ -76,9 +76,6 @@ export const HOST_SECRET_FIELDS = [ "passphrase", "sudoPassword", "socks5Password", - "rdpPassword", - "vncPassword", - "telnetPassword", "autostartPassword", "autostartKey", "autostartKeyPassword", diff --git a/src/backend/plugins/index.ts b/src/backend/plugins/index.ts index e6b0d5288..1a08e2e3f 100644 --- a/src/backend/plugins/index.ts +++ b/src/backend/plugins/index.ts @@ -19,6 +19,7 @@ import { PluginLoader, type LoadedPlugin } from "./loader.js"; import type { PluginPermissionContribution } from "./manifest.js"; import { invalidatePluginPermissionCache } from "./permissions.js"; import { setSshAuthTypeOwnerSource } from "../hosts/connect/auth-provider-registry.js"; +import { setHostProtocolSource } from "../hosts/protocol-auth/registry.js"; import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js"; import { recordConflict } from "./conflicts.js"; import { setPluginImpersonationCheck } from "../utils/auth-manager.js"; @@ -53,6 +54,17 @@ export function getPluginRuntime(): { loader: PluginLoader } { ), ), ); + // Protocol logins stay readable, shareable and syncable while their + // plugin is off. + setHostProtocolSource(() => + (loader?.list() ?? []).flatMap((plugin) => + (plugin.manifest.contributes?.protocols ?? []).map((protocol) => ({ + ...protocol, + pluginId: plugin.id, + pluginName: plugin.manifest.name, + })), + ), + ); // Lets a host whose auth type belongs to a disabled plugin name it. setSshAuthTypeOwnerSource(() => (loader?.list() ?? []).flatMap((plugin) => diff --git a/src/backend/sync/entities.ts b/src/backend/sync/entities.ts index b2e677765..6e987613d 100644 --- a/src/backend/sync/entities.ts +++ b/src/backend/sync/entities.ts @@ -27,6 +27,10 @@ import { exportHostPluginSettings, importHostPluginSettings, } from "./host-plugin-settings.js"; +import { + exportProtocolLogins, + importProtocolLogins, +} from "./host-protocol-auth.js"; const CREDENTIAL_REFERENCE = { field: "credentialId", @@ -36,21 +40,6 @@ const CREDENTIAL_REFERENCE = { const HOST_REFERENCES = [ CREDENTIAL_REFERENCE, - { - field: "rdpCredentialId", - syncField: "rdpCredentialSyncId", - entityType: "sshCredentials", - }, - { - field: "vncCredentialId", - syncField: "vncCredentialSyncId", - entityType: "sshCredentials", - }, - { - field: "telnetCredentialId", - syncField: "telnetCredentialSyncId", - entityType: "sshCredentials", - }, { field: "parentHostId", syncField: "parentHostSyncId", @@ -161,16 +150,25 @@ export function registerCoreSyncEntities(): void { update: "hosts.edit", delete: "hosts.delete", }, - serialize: async (row) => ({ + serialize: async (row, resolveSyncId) => ({ ...row, pluginSettings: typeof row.id === "number" ? await exportHostPluginSettings(row.id) : {}, + protocolAuth: + typeof row.id === "number" + ? await exportProtocolLogins( + row.id, + String(row.userId), + resolveSyncId, + ) + : {}, }), - afterWrite: async ({ id, userId, wire }) => { + afterWrite: async ({ id, userId, wire, resolveId }) => { if (id === null) return; await importHostPluginSettings(id, wire.pluginSettings); + await importProtocolLogins(id, userId, wire.protocolAuth, resolveId); await dropInvalidParent(userId, id); const { SharedHostSecretsManager } = await import("../utils/shared-host-secrets-manager.js"); @@ -439,11 +437,9 @@ const SHARED_HOST_DROP = [ "id", "userId", "credentialId", - "rdpCredentialId", - "vncCredentialId", - "telnetCredentialId", "parentHostId", "pluginSettings", + "protocolAuth", "quickActions", "createdAt", "updatedAt", diff --git a/src/backend/sync/host-protocol-auth.ts b/src/backend/sync/host-protocol-auth.ts new file mode 100644 index 000000000..09c6c7593 --- /dev/null +++ b/src/backend/sync/host-protocol-auth.ts @@ -0,0 +1,84 @@ +/** + * A host's plugin protocol logins over sync. They travel on the host row as + * `protocolAuth`, keyed by protocol id, with the owner's secrets decrypted + * for the wire like the host's own and a credential named by its syncId. + */ + +import type { SyncRow } from "@termix/plugin-sdk/backend"; +import type { HostProtocolLogin } from "../database/repositories/host-protocol-auth-repository.js"; +import { + fromPortableLogin, + listProtocolLogins, + replaceProtocolLogins, + toPortableLogins, +} from "../hosts/protocol-auth/protocol-auth.js"; +import { syncLogger } from "../utils/logger.js"; + +type ResolveSyncId = (entityType: string, id: number) => Promise; +type ResolveId = (entityType: string, syncId: string) => Promise; + +const CREDENTIALS = "sshCredentials"; + +export async function exportProtocolLogins( + hostId: number, + ownerId: string, + resolveSyncId: ResolveSyncId, +): Promise { + const out: SyncRow = {}; + const portable = toPortableLogins(await listProtocolLogins(hostId, ownerId)); + for (const [protocol, login] of Object.entries(portable)) { + const { credentialId, ...rest } = login; + out[protocol] = { + ...rest, + credentialSyncId: credentialId + ? await resolveSyncId(CREDENTIALS, credentialId) + : null, + }; + } + return out; +} + +/** + * Writes what a synced host carried. A row from a peer that sends no + * protocolAuth leaves the stored logins alone. + */ +export async function importProtocolLogins( + hostId: number, + userId: string, + carried: unknown, + resolveId: ResolveId | undefined, +): Promise { + if (!carried || typeof carried !== "object" || Array.isArray(carried)) { + return; + } + try { + const current = new Map( + (await listProtocolLogins(hostId, userId)).map((login) => [ + login.protocol, + login, + ]), + ); + const logins: HostProtocolLogin[] = []; + for (const [protocol, value] of Object.entries(carried)) { + const syncId = (value as { credentialSyncId?: unknown }) + ?.credentialSyncId; + let credentialId: number | null = null; + if (typeof syncId === "string" && syncId) { + // A credential not here yet keeps whatever the login pointed at. + credentialId = + (resolveId ? await resolveId(CREDENTIALS, syncId) : null) ?? + current.get(protocol)?.credentialId ?? + null; + } + const login = fromPortableLogin(protocol, value, credentialId); + if (login) logins.push(login); + } + await replaceProtocolLogins(userId, hostId, logins); + } catch (error) { + syncLogger.warn("Could not apply a synced host's protocol logins", { + operation: "sync_host_protocol_auth", + hostId, + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/src/backend/sync/store.ts b/src/backend/sync/store.ts index 45c94f7aa..793b6c821 100644 --- a/src/backend/sync/store.ts +++ b/src/backend/sync/store.ts @@ -331,7 +331,13 @@ export async function writeWireRow( const written = await findStoredRow(entity, userId, syncId); const id = typeof written?.id === "number" ? written.id : null; if (entity.afterWrite) { - await entity.afterWrite({ id, userId, wire, created: !current }); + await entity.afterWrite({ + id, + userId, + wire, + created: !current, + resolveId: resolvers.resolveId, + }); } DatabaseSaveTrigger.triggerSave("sync_write"); return { id, created: !current }; diff --git a/src/backend/tests/database/repositories/host-protocol-auth-repository.test.ts b/src/backend/tests/database/repositories/host-protocol-auth-repository.test.ts new file mode 100644 index 000000000..ad42b7e9a --- /dev/null +++ b/src/backend/tests/database/repositories/host-protocol-auth-repository.test.ts @@ -0,0 +1,178 @@ +import crypto from "node:crypto"; +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { + HostProtocolAuthRepository, + decryptProtocolLogin, + protocolAuthRecordId, + type HostProtocolLogin, +} from "../../../database/repositories/host-protocol-auth-repository.js"; +import { FieldCrypto } from "../../../utils/field-crypto.js"; + +const ownerKey = crypto.randomBytes(32); +const otherKey = crypto.randomBytes(32); + +function login(overrides: Partial = {}): HostProtocolLogin { + return { + protocol: "spice", + authType: "direct", + credentialId: null, + username: "viewer", + password: "spice-pass", + fields: { display: "2" }, + secretFields: { ticket: "t-secret" }, + ...overrides, + }; +} + +describe("HostProtocolAuthRepository", () => { + let adapter: TestSqliteDatabase | null = null; + let writes = 0; + + afterEach(async () => { + await adapter?.close(); + adapter = null; + }); + + async function createRepository(): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + await adapter.exec(` + INSERT INTO users (id, username, password_hash) VALUES + ('owner-1', 'owner-1', 'hash'), + ('owner-2', 'owner-2', 'hash'); + INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES + (5, 'owner-1', 'cred-5', 'root', 'password'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES + (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 'password'), + (43, 'owner-1', 'stage', '10.0.0.43', 22, 'root', 'password'), + (44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 'password'); + `); + writes = 0; + return new HostProtocolAuthRepository(context, () => { + writes++; + }); + } + + it("round-trips a login with its secrets encrypted at rest", async () => { + const repository = await createRepository(); + await repository.upsert("owner-1", 42, login(), ownerKey); + + const row = await repository.findRow(42, "spice"); + expect(row).toMatchObject({ + hostId: 42, + userId: "owner-1", + protocol: "spice", + authType: "direct", + username: "viewer", + fields: JSON.stringify({ display: "2" }), + }); + expect(FieldCrypto.isEncrypted(row!.password!)).toBe(true); + expect(row!.password).not.toContain("spice-pass"); + expect(row!.secretFields).not.toContain("t-secret"); + expect(await repository.find(42, "spice", ownerKey)).toEqual(login()); + expect(writes).toBe(1); + }); + + it("updates in place, one row per host and protocol", async () => { + const repository = await createRepository(); + await repository.upsert("owner-1", 42, login(), ownerKey); + await repository.upsert( + "owner-1", + 42, + login({ username: "other", password: null, secretFields: {} }), + ownerKey, + ); + + expect(await repository.listRowsForHost(42)).toHaveLength(1); + expect(await repository.find(42, "spice", ownerKey)).toMatchObject({ + username: "other", + password: null, + secretFields: {}, + }); + }); + + it("opens nothing with another user's key", async () => { + const repository = await createRepository(); + await repository.upsert("owner-1", 42, login(), ownerKey); + + expect(await repository.find(42, "spice", otherKey)).toMatchObject({ + username: "viewer", + password: null, + secretFields: {}, + }); + }); + + it("treats a tampered secret as missing", async () => { + const repository = await createRepository(); + await repository.upsert("owner-1", 42, login(), ownerKey); + const row = (await repository.findRow(42, "spice"))!; + const envelope = JSON.parse(row.password!); + envelope.data = envelope.data.replace(/^./, (c: string) => + c === "0" ? "1" : "0", + ); + + expect( + decryptProtocolLogin( + { ...row, password: JSON.stringify(envelope) }, + ownerKey, + ).password, + ).toBeNull(); + }); + + it("binds each secret to its host and protocol", async () => { + const repository = await createRepository(); + await repository.upsert("owner-1", 42, login(), ownerKey); + const row = (await repository.findRow(42, "spice"))!; + + expect(JSON.parse(row.password!).recordId).toBe( + protocolAuthRecordId(42, "spice"), + ); + expect(() => + FieldCrypto.decryptField(row.password!, ownerKey, "", "secretFields"), + ).toThrow(); + }); + + it("finds the owner's hosts whose login uses a credential", async () => { + const repository = await createRepository(); + await repository.upsert( + "owner-1", + 42, + login({ authType: "credential", credentialId: 5, password: null }), + ownerKey, + ); + await repository.upsert( + "owner-1", + 43, + login({ protocol: "x2go" }), + ownerKey, + ); + + expect(await repository.listHostIdsForCredential("owner-1", 5)).toEqual([ + 42, + ]); + expect(await repository.listHostIdsForCredential("owner-2", 5)).toEqual([]); + expect( + (await repository.listRowsForUser("owner-1")) + .map((row) => row.hostId) + .sort(), + ).toEqual([42, 43]); + }); + + it("deletes one protocol's login", async () => { + const repository = await createRepository(); + await repository.upsert("owner-1", 42, login(), ownerKey); + await repository.upsert( + "owner-1", + 42, + login({ protocol: "x2go" }), + ownerKey, + ); + + expect(await repository.delete(42, "spice")).toBe(true); + expect(await repository.delete(42, "spice")).toBe(false); + expect( + (await repository.listRowsForHost(42)).map((row) => row.protocol), + ).toEqual(["x2go"]); + }); +}); diff --git a/src/backend/tests/database/repositories/host-resolution-repository.test.ts b/src/backend/tests/database/repositories/host-resolution-repository.test.ts index 4be47398f..318567351 100644 --- a/src/backend/tests/database/repositories/host-resolution-repository.test.ts +++ b/src/backend/tests/database/repositories/host-resolution-repository.test.ts @@ -192,9 +192,6 @@ describe("HostResolutionRepository", () => { await expect(repository.findHostUpdateState(1)).resolves.toEqual({ userId: "user-1", credentialId: 7, - rdpCredentialId: null, - vncCredentialId: null, - telnetCredentialId: null, authType: "password", parentHostId: null, folder: null, diff --git a/src/backend/tests/database/repositories/rbac-access-repository.test.ts b/src/backend/tests/database/repositories/rbac-access-repository.test.ts index b2d4069cb..cdb5bbd40 100644 --- a/src/backend/tests/database/repositories/rbac-access-repository.test.ts +++ b/src/backend/tests/database/repositories/rbac-access-repository.test.ts @@ -36,8 +36,8 @@ describe("RbacAccessRepository", () => { INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES (44, 'admin', 'host-44', '10.0.0.45', 22, 'root', 'password'); INSERT INTO ssh_data ( - id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags, auth_type) - VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux', 'password'); + id, user_id, name, ip, port, username, credential_id, folder, tags, auth_type) + VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 'servers', 'linux', 'password'); INSERT INTO host_access ( id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at ) @@ -138,9 +138,6 @@ describe("RbacAccessRepository", () => { { hostAccessId: 2, credentialId: 123, - rdpCredentialId: 124, - vncCredentialId: 125, - telnetCredentialId: 126, hostId: 42, hostOwnerId: "owner-1", }, diff --git a/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts b/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts index 9eeb56803..18349162d 100644 --- a/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts +++ b/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts @@ -31,10 +31,13 @@ describe("SharedHostSecretsRepository", () => { INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES (123, 'user-1', 'cred-123', 'root', 'password'), (124, 'user-1', 'cred-124', 'root', 'password'); - INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id, auth_type) - VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 'password'), - (43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL, 'password'), - (44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL, 'password'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, auth_type) + VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 'password'), + (43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, 'password'), + (44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, 'password'); + INSERT INTO host_protocol_auth (host_id, user_id, protocol, auth_type, credential_id) + VALUES (42, 'owner-1', 'spice', 'credential', 124), + (43, 'owner-1', 'spice', 'direct', NULL); INSERT INTO host_access (id, host_id, user_id, role_id, granted_by) VALUES (1, 42, 'user-1', NULL, 'owner-1'), diff --git a/src/backend/tests/database/routes/host-normalizers.test.ts b/src/backend/tests/database/routes/host-normalizers.test.ts index 89ddd5a0c..29d995029 100644 --- a/src/backend/tests/database/routes/host-normalizers.test.ts +++ b/src/backend/tests/database/routes/host-normalizers.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect } from "vitest"; +import { setHostProtocolSource } from "../../../hosts/protocol-auth/registry.js"; import { applyHostKeyTypeUpdate, containsOwnerPrivateAuthUpdate, @@ -47,13 +48,13 @@ describe("containsOwnerPrivateAuthUpdate", () => { ); }); - it("keeps protocol field definitions isolated", () => { - expect(containsOwnerPrivateAuthUpdate({ rdpCredentialId: 7 }, "rdp")).toBe( - true, - ); - expect(containsOwnerPrivateAuthUpdate({ rdpCredentialId: 7 }, "ssh")).toBe( - false, - ); + it("leaves plugin protocol logins to their own guard", () => { + expect( + containsOwnerPrivateAuthUpdate( + { protocolAuth: { spice: { credentialId: 7 } } }, + "ssh", + ), + ).toBe(false); }); it("allows shared editors to update non-authentication host settings", () => { @@ -171,11 +172,41 @@ describe("normalizeImportedHost", () => { expect(host.enableSsh).toBe(true); }); - it("infers rdp from enableRdp and uses default rdp port", () => { - const host = normalizeImportedHost({ enableRdp: true, ip: "10.0.0.2" }); - expect(host.connectionType).toBe("rdp"); - expect(host.port).toBe(3389); - expect(host.enableSsh).toBe(false); + describe("with a plugin protocol declared", () => { + afterEach(() => setHostProtocolSource(() => [])); + + function declareSpice() { + setHostProtocolSource(() => [ + { + id: "spice", + defaultPort: 5930, + pluginId: "spice-plugin", + pluginName: "Spice", + }, + ]); + } + + it("infers the protocol from its enable flag and uses its default port", () => { + declareSpice(); + const host = normalizeImportedHost({ enableSpice: true, ip: "10.0.0.2" }); + expect(host.connectionType).toBe("spice"); + expect(host.port).toBe(5930); + expect(host.enableSsh).toBe(false); + }); + + it("reads the protocol's own port field", () => { + declareSpice(); + const host = normalizeImportedHost({ + connectionType: "spice", + spicePort: 5999, + }); + expect(host.port).toBe(5999); + }); + + it("ignores the flag of a protocol nobody declares", () => { + const host = normalizeImportedHost({ enableSpice: true }); + expect(host.connectionType).toBe("ssh"); + }); }); it("honors an explicit port over protocol defaults", () => { @@ -267,26 +298,13 @@ describe("stripSensitiveFields", () => { ).toBeUndefined(); }); - it("strips rdp/vnc/telnet passwords and adds their presence flags", () => { - const result = stripSensitiveFields({ - name: "rdp-box", - rdpPassword: "rdp-secret", - vncPassword: "vnc-secret", - telnetPassword: "telnet-secret", - }); - expect(result.rdpPassword).toBeUndefined(); - expect(result.vncPassword).toBeUndefined(); - expect(result.telnetPassword).toBeUndefined(); - expect(result.hasRdpPassword).toBe(true); - expect(result.hasVncPassword).toBe(true); - expect(result.hasTelnetPassword).toBe(true); - }); - - it("marks rdp/vnc/telnet presence flags false when absent", () => { - const result = stripSensitiveFields({ name: "rdp-box" }); - expect(result.hasRdpPassword).toBe(false); - expect(result.hasVncPassword).toBe(false); - expect(result.hasTelnetPassword).toBe(false); + it("leaves protocol login summaries alone for the owner", () => { + const protocolAuth = { + spice: { authType: "direct", hasPassword: true, secretFieldKeys: [] }, + }; + expect(stripSensitiveFields({ protocolAuth }).protocolAuth).toEqual( + protocolAuth, + ); }); }); @@ -352,8 +370,17 @@ describe("sanitizeHostForRecipient", () => { password: "hunter2", key: "PRIVATE", sudoPassword: "sudo", - rdpPassword: "rdp", socks5Password: "socks", + protocolAuth: { + spice: { + authType: "direct", + credentialId: null, + username: "viewer", + fields: { display: "0" }, + hasPassword: true, + secretFieldKeys: ["ticket"], + }, + }, enableSsh: true, enableRdp: true, sshPort: 22, @@ -371,8 +398,15 @@ describe("sanitizeHostForRecipient", () => { expect(result.password).toBeUndefined(); expect(result.key).toBeUndefined(); expect(result.sudoPassword).toBeUndefined(); - expect(result.rdpPassword).toBeUndefined(); expect(result.socks5Password).toBeUndefined(); + expect(result.protocolAuth).toEqual({ + spice: { + authType: "direct", + credentialId: null, + username: "viewer", + fields: { display: "0" }, + }, + }); expect(result.credentialId).toBeUndefined(); expect(result.overrideCredentialUsername).toBeUndefined(); expect(result.terminalConfig).toEqual({ theme: "termix" }); @@ -404,7 +438,6 @@ describe("sanitizeHostForRecipient", () => { { ...sharedHost, permissionLevel: "connect", - rdpAuthType: "none", authOverrides: { ssh: { credentialId: 9, @@ -417,7 +450,7 @@ describe("sanitizeHostForRecipient", () => { ); expect(result.name).toBe("prod"); expect(result.ip).toBe("10.0.0.42"); - expect(result.rdpAuthType).toBe("none"); + expect(result.protocolAuth).toEqual({ spice: { authType: "direct" } }); expect(result.permissionLevel).toBe("connect"); expect(result.shareSshAuth).toBe(true); expect(result.authOverrides).toEqual({ diff --git a/src/backend/tests/database/routes/rbac-host-auth-override.test.ts b/src/backend/tests/database/routes/rbac-host-auth-override.test.ts index 347b3bb27..247b5d89c 100644 --- a/src/backend/tests/database/routes/rbac-host-auth-override.test.ts +++ b/src/backend/tests/database/routes/rbac-host-auth-override.test.ts @@ -263,12 +263,21 @@ describe("shared host authentication override routes", () => { expect(unauthenticatedResponse.status).toBe(401); }); - it("serves every real protocol and rejects invalid protocol names", async () => { - const rdpResponse = await invoke("get", {}, "rdp"); - expect(rdpResponse.status).toBe(200); - expect(rdpResponse.body.protocol).toBe("rdp"); + it("serves any protocol a plugin declares and rejects the rest", async () => { + const { setHostProtocolSource } = + await import("../../../hosts/protocol-auth/registry.js"); + setHostProtocolSource(() => [ + { id: "spice", pluginId: "spice-plugin", pluginName: "Spice" }, + ]); + const declared = await invoke("get", {}, "spice"); + setHostProtocolSource(() => []); + expect(declared.status).toBe(200); + expect(declared.body.protocol).toBe("spice"); expect(state.writes).toEqual([]); + const undeclared = await invoke("get", {}, "spice"); + expect(undeclared.status).toBe(400); + const invalidResponse = await invoke("get", {}, "smtp"); expect(invalidResponse).toEqual({ status: 400, diff --git a/src/backend/tests/fixtures/upgrade/db.sqlite b/src/backend/tests/fixtures/upgrade/db.sqlite index 8ae7b84c7a8fd799e9006ebdf293db39f1da78ce..d3d8f17985acf5607c73e161d0098654f666cb97 100644 GIT binary patch delta 7951 zcmeI1*>7CudB#PFi

ElNO0x)V8d)iEB#Yw=e1@QCvicfDHb@$`K;5(rkf6ONbW`^^T!vFLonH3Ff-suV zoH^%w@AtmX^LyX(=&646RKN4#%fF~^P;x^^Mwq%r>@{XD)06-D+T~|2x1GMf`Rr2d z{m4h3s-&EyCLxfRjP^!4kr`FWdn=;y%21`rm8(}4cCOsG5=i1sOC>o|f$1c@H^MU$2F=`mIXS_1c*qHJt_g;7QXze)061!ykXt)NytPhaD&o{N4Yj5X2W0w!s&Np7qqx0YGYOvnJe|`J>r!Jq~>B0Nw>(=no z#;5-C3m-N*^VgmhX`qhl(~KqAYL=AZF|Z^gl~QuAc#13#CY-5dtVI=M8+l5M z8Z9{WNbX3I*1>osWO2rmlx5UXNezjX5$SVePR59Iq>8(!wXr-)rjedH8R;c=#`+kD zqBMGEt*w5)a^b~VoF85I(~b(YTr%%skcv8HqF_OJ9x`WVO9|R2%VdyQa?Xr5jGOE- z^D21lX{;@UWx|jkCP}Wi*O^e0m8OO!JXhWDuEIeltg19w?$AkQdr%H0}-`Mue|v?SEAc;c<+S4eI+Tlqfi&-6qgyr zYmzXMy6A&W*>Dn!jk-dLlEyoWqga+P(_lEI20uj#IN_AxHai=nRZd@UI^+P(ns**9FAm;1(7H&B(a2rN+E7qO5`4)61kQ+ zx6m*uvH64<<~bL&_!>j4B2i|cgho`nVgc%hTZleITNhv4cJ_%w{-A`MP%Z<~nKJBL z6f{$s9f$am=Z)hLp+R~u=}pd#NN$s~)q}S#be^Wl;g8!r#;zLlMj4@3RF^e5aUOSeG{W+;SnHol#1tbA zER+G+i-4Jc@+VM@go@+Z!a^(xn?w?WtN!I%&A-}ceZSZI_ocIF|FHS9cf$S20lv^X zJv6zqxI5iFuW&n|@U02AveUD7hpa#B6}#(u6J~#+bGST{`){rF_HC3#r&szrH)iJj zM0xi&Yik`6_x4I=ImmLCAMaZqUtJeY?^fNbEsJ4la>91)FNi(UIWk&atX*iIEW3w; zcg47IQ$2HO$aQWzy+7=3(e;N9=KE$Qdh_j#@lo2-{qXEU>vNlXJ9FEUt4p2p(-S!@ z#M1q;r)$`UNc@U_naV6&@?w{Ws+FL3u4xL@CzdXLL%x)*$9N5>BX?Sg7adB?$_-dW6wfwf% zVq_xiOsT${=Lghw!4pS8>|v$p&e3}_Y-ebqqy!x;=jFbsJYMP%C01VSxo^kRN< z|F+Bb7ka`-w~KxIVc+6RJ*z`w-MwS;e1CCpY-45Wwq3YYcd_nC`(R^pZf$p|oCbGy z?u-uQ@o=)VZ*;cY^k#ZS?&_7h6L&aYDBUhp;%I69-q_$&nz^4Q*4ERCiT6sZ)R)V2 z`r%^S@&ii)ZgNK5UEUL&byu@q_wH?vuI!cWhQ3{0(zZ+RVrf?o|LAH<_|j=^9=y}? zej_hE{FirIwi+E!Ex*??+@KN;KX|X@H(sp#??_qG`3(Djqf()yF%IU-p}!CURJ0*3 z8A~{dpIS{|j0vphgjFnhnm~~RB4;eTo(K!I=PAGig+Q%}NQ|hskb{*`rp!?0L=;M4 zRPqK6AZ5Uj@I$6q%!GU6DS;Gas6FM;OOXiVmk^eHV4`5tBVlqZ8km7hkQtRj$SF}z zEqp|H=ZM2`$HF9KMd|=@4KBLkezXIahM*u)A%RnI!O;;mAvH(7K-jZkGQ*8MHaj>& zh#)DWRj}wHJQaWguh7pN{@^Dq|Ma;Eqms)Q;8T>twyo323oYQbQh1~ds8L}9xgk0f z!IVik7m(3eF&GQd4##KJ20 zBrWGD12SbuNoNNqL;w&agp9kQs{-T{F*@$H0GMD+1DZ)1O%O$L1COvfT2SCgEtV2- zWZ*bNSclA*kO^k0B|;QM5XaqC<*40C5s;(+Rxm^gZj~7&mXW5y1dtML!83VOaI11Qo&!SgK@$Z{)W!Pmz)V2Wg-KtBr($^w8R3U&b7 zq1|(=u)E{3!+s?6dO)fm5ap19*4Yc^I7&aWqCQWmPGQS^fmPD*fJKFZ{*A^5YiI@~ zIB8-4S)-GT;xja1a}yWDVe7$)As+r!d+Q%GRnV2oTWB)DF+!@R1BP?LDPsPs#FC@GdS^V1zJ)eejO-7 z0|e1$Y?>(+EFFqH$!rs6NBo`)x(cg-^a!6#3F)XkLVWnrbFI(LSB!VqSTrpHT35Sa zDV=eEcdUyt>X}6L$_V}hRv`(_8irsHVH69vS0iu1p7;@Zk%)YjL=>BN67p<$lv*07 zlBLF>xrgkE*Ccujh_OQU1&>-qzl30CWHh%9$cLTc2Hc&<;nPd4&tGojJpb0!`g)^b zMs>c_`nwHIa4^!^a2Rs1*81h^m7*?HEM&}j32}qATL%@*&=QD-DWKBg75xXa5j%}$ zCPJHvzY$#?8cLHX@(EofbOO1BxCXQ_cuAa~l|<}B=A@8Q^jKDeNCh@C>7bdch)mF# z6%9$z6Q+dr(1QF~427LSeKJTk2UFwe<7$PWDtHUz-Eje%K`JBcDeJS%M02JU^qo?u z4>Rb$ynF#2)4`i-x)HtIJHp0w7@5T3Ty4Ljbi|L{TUH{U!(o5SzE(I#Ix{Z#F!w4HsB zmA2Y^qWXHJ?N@4teWm`ew<~Q|E}fvu=bKv3eWCpvuYT~=rWZ~RKX}yEI7Obc{dK4G zLjyab3xlG&%k4avT)#UFRibH=caKjGmc=bj$llcC$ky)6;$R4}i>m zif^|`xjC{tFtFoG!%KF2e4nr0TNs~zaQ%MiPMquM-|SzLclP>s?u~8kb&aj9l*kC9 zd-KcHKmK;xixsMjWeN!8u?IXEM!X2tQ4K=DP9=u!(K`a2fiv5fVCh77RH1<IRElS5L$h0$rEV@8*cp#_dR7G1$$Ll88yvB9#}aXfT03!oHEN094h58sm%yi56%bS&*6ObkUL$2P4Cm zJb0`NNgWciFzIpz~+2VVv=3$WUjSQB>dgPTREXncAKhU4O6& z*Dv3mxyR>CedHMTgv6}lg6+O4VUy^~vZ<6&v}C^-+_Z~NOu z`g-{9KWTfnqk=Ik1}4_RK*2~pr3lc(sO`vrkMTqTZ85SH7Hkf%;T*kqE{qWr5@|6T zI!-R=A+Qv7>N69tY>O#y5hsXDECyL;xdl1^XBE_3;Sf^r4j3Jj1abq&LZ@*Na5P13 zaH<{p1kA@k;usKWN_;5Nv@Xu|^C$_@DL4uM9N|+a9$>K*;2K6S$pTnl;-Y<~AZ@f8 zfPw@*M*5=7FiaUttRS=KW-zhI0)w0=G5kQzW6V)}ht9zOeZd!iIii6sloq;5z*8FI zG7g@GW&&8O09IgrWkYaiF3<%84D8_g0;6_yLQIt#SMcAcZ5yd|08q@8FuSBcc1SPS zC7%u3;bUB3U@i)CMk9iuJE|5_F^v|195L95eFqm3wfM0>+kpuvm;&ewY%{u;mk^BU z1jE!u+0S|m15uC6p}&IV$LzrZ{J)JZ4}bJg+ox-Sd~o^Vv#y%Lj$Aww`^U2vAJ4Ww zp8dq**-t*6eePhk4{E5lN8@FsNecr0S2D%i$mG|9vs4W1N+_#kY+J+KGQ79B>!coyM{ zMy=i?_?0(%#I>H%(5->tl_ggr6NdYZ@H3m7ju>Kt|KV#kD2H3u6`bYB2GLr9mo1+3 zU^$=`Op4%=m`mX)5g7lhEi4{CsSI#P)C4?g@}6N-058P9f#d?lPGq{6M^F{f$SXX( zvQara7Lu9?jI2B-33alLX?=TnP+y|bDt^^PU=h8IQnh`ulGbUi@uCzfz1S7>_ zFxCSpQUcGy^-r34xDozf3lzq49N=`-2|G$O(t<1S mXb*iDEvEb6N?cW88VATlfNjR}Ees5R3i0rt|E}Y6P5%W=OPFK; delta 7683 zcmeHM$#0zJeHJAymSRRo3nXq)+lr93jwze(zNnih?i4jM;wlmYnWI(<8fYX;8kHQOxIxB* z>$cr8E>tN7M!k{2a7q-#VDgx3-NduW`+&b}2=oS<>22={1esYmu#s zmM0Rq5RoWFq*sbkp}ffsPa%RcA^+sBzVb@DwJ-hj?Z$eK{_A4LSa zto$FpFLivYxvll@);eBqKKWrsYr_v8cl`5L8owO<_>+#_)(ptm)?pl8JN=<@`oq%M zA71?8v-5kQqwCE1^Ze(W@?rIC`}I6L`-A>A>plASx6gj|Qe&ov@1H%jhM%^7^k1I) zxZRV#`MC4aQD^1cKegTNrE^bTswIws3f3wvIWd88DzqVG?k`RveaNI(Fbl58YUYSiPO>GZsE97+4?}N$0V~7R%W57OlSzKg(vQ1tbbTL z_hNIOAD;X3-WqpA7^0;?!%62%$U&ho1ul1urR3zYl$t7H6QLTdk) zZXq#&MX88R#g5`pn<;E;#_@<=rkCIRz01*UJNz#K^G+m1LsC+E5;3r6NfBa!9oQ;^ z`8chu7fRGLx}6DwW6_MW5ThhqTbu}nBbiZbjz}D*kVTM20GhBAC#ee&(`QyQNu!B| z@XRrJsyO30QD+1tUSWrVa4DliP*}nll2zo=d8+hrrPK;=Llp^aiR8sOF;*Z=LSe=1 z2&zHR=Kgw5X%vD2;!<{8$t;P;Dx>nU!}MGGn>9%UXMtm6DhcK_Vax(6J(dYf^DHDI z7<(a7LnA;BhmbHbvza9q2&o^wb*`^bDn~zh>)e08+oppLE1f@nsm<^8?v2j9cE8s* zI?HW-Kdg2>_j1h#V1Lex2Wh=jL|djgCj`gsy~5&;sUn*c6cLg{)Sp#YJF^KJq*P=P zYi#Z&N=arzf|4lxq&)Ya6ZA5I(0Uou z2?cM>ZqECimF)d3RUI*EBhHe8%IYXBAKWMhLG)n17}(I$YP@{!x0-96;C$zLWwVd$ z+`Mn9VsU8xVDiR5-M!LvH&l1Y)R3C?`~B7JZ96vI7$~of&D=9$Y-)e%=KPI{ozioY z)zRJl19$Mo_|^~?S-X1^i~FN=xHZtigYB91ooTNY2R8|gdBx4nspEmJ)EB?pbv?{a zjj7uYe408~pRVSzY>c+QJrgJH4Udl8OnYSv5N3yO?o|fKOsRaZc;kj2zFSe1@$s3( zozNO>WP5Y3%I@D@i+)|}<;o3T8k}4%mUX4x_iESggfRNX%+BcD2M5D^e}LXvY>akq zd9`m~YW-FmQ*$$e`)ieIn!M>JX7yyAn9!4BVQ^q;v_D&kVQ525>^DZE_qGY2-r5+QsD>STBQEQTo4>m` zu)oMlb7pRN?p|qX?bh{591^Y3NZwi4uRNHWUaR(}u(&?Cus2kyFTK+BTK(I7UDv|E z*3OHNB8+2R<2Hw4ZgBg+42L6)2pj)*rse} zMysceZDn9)pl@^N{?3}ZzDl--)`S{gKR&j;qpx4-3SVs$=ixhD@3$k>0dz(pO`5S#01HndO7L_IeC!fnQbRdr06IXKI53$o1%4BVPN0Qk z0AkQe@)$7b=nC46j*%P*1S`)TbQ}uWdxlKjW}Co`fEN&2rqltbnJwnSWeb^{uf>7{ zAzeXM6rACJ;!!5hnYNA>jPOjw0)|rdRw(FIV2%!fd+XP{ETj~` zMXw$94s3=ob)jvH6@;=(t(C+H8wkc%cm^|>D$FCA6d@A?c(a&u4KjjeCp954NRffw zGD^)Hz&+Xk*rq^j*oDwkDJhYFEr>q(lrT$Ac@P2Hn{iAo6{m1w5g-jf!#qM);E&4e zgJLwqU;>^_Yy~plMkRz3`qol<;;TS#UG^+-_}R3)9H(T%l)TnkQU=q6zx)KPP&%)yyICe=rObH4kJJ8I}AiVn8VK)|MCIpE8N6T}&k1PsTT!E!+$^8n8P zkwJUd4U6cape7t0d$&-xjU}UIu(4EfC-HS|Rgg3deBCFa|E7fuT?s zpalLV2cRBM-8QN(MSvGv8SE%bZ$b(aDNU3cN3z5dn}b}tV88(2DxkSJd=EO!gajuBT@oF|l)=MmT`bNhuH7-AV*wem59{2ohpG>=Z3oG-1-x_hN z+brC@U!B{3V@FMF&g_*ZZqMy+ZtwE>+xl+Rj^8dR$hqN#q1F2OcYChX#rJxo+#M~+ z{jwAj^kB9$zg3l`gS}FDt#Yt%W73x_d2maOt(M$g@Hbai*$P0{sKNT5ey``n8vYm5 z0uXQp004lcpbp3mK5$?@E2P1J?M6(9nKwsf?}?y~V{ zVF5M|iIM`^o`EjJ5@HT;OJqviDS8-(%~8CCVO9PV>hge^hb@)>EI3ucl7tmhj0nMt zbON(ZTuX2w;2jv6QL1p|86FK1PwM*P@Av#lt*IZOiJ(@9D6$6!0l*P50*^h9f*{m^ zeRc{IgzmAbkbznu)6pp*oUq6)44M~$fCIc6or0-gJ4oOG-jBmt8bm8XCa`0X0Ugt3 z?Kq}nS`v79Obc;B3P>IeP*Ma+0B7ju<5oHD6obt}7a(!U9CsllfgnTr2$_d0R3oi~ zt3}5N(1J__&7gyWPe4@6Ef!Mo6m3RPf9Lx>3v%$m1YyMHp^%1#pbDpdZZtIQNgE+IkGV=P`=H7Jux8Lh=^8W75{PO+Vn+ub#S2jwwa{od3 z-gI&!1H%)lIyzgqZzk3U=I!m=KU0Ook=q!-GZGST5Mtg;E(9ubJ*8ASoGS`vx56mUQmbnZOb0NEN8Ng<@5-jNc+PasUO#mW8b zKA#Sx7-Fo15`;t`h?o#i6K4wU_7VE8NMR882modTf=gRN-l(&I!PkYbhKfX)5eoek zfruzXh#^P}LEs6Z3YfP9ZW0|>Fp!3pBqVc?ju0JDAyqTrMraxVnTymS3y5A=A(Ao@ zCMabL14_{%`l3|eQ))=teFWPe1c${(^+gF+r4vImlAzgSOK*TzC6f3;WoYE0n6-ru zbC?`;47arQ7c`7~l_Ho+92!1J9}L=732ZtX0Ei8WMFU3o=TY0x_6+G)M5@A$D_@GA z$u5t6^hwX>nt*(G>A9!x=u`Yl`Ql*TkiS ziG}QHt_rUqAw3AFU&8GhmXQnLCI-xuG^tjwp&+HAq{u`dT43IwzY+AnEh)HhxcSzq zR1_hLt6;TUBpaUN<*h)RLNszHWIcE%3J=Br<_xrmoagwZ3&Jjq*R*(rLXZCSA9}yg F@jv^ePIdqQ diff --git a/src/backend/tests/fixtures/upgrade/rows.ts b/src/backend/tests/fixtures/upgrade/rows.ts index 156c66f45..af5db7c85 100644 --- a/src/backend/tests/fixtures/upgrade/rows.ts +++ b/src/backend/tests/fixtures/upgrade/rows.ts @@ -117,6 +117,8 @@ export const ENCRYPTED_COLUMNS: Array<[string, string, string, string]> = [ ["ssh_data", "key_password", "keyPassword", "user_id"], ["ssh_data", "sudo_password", "sudoPassword", "user_id"], ["ssh_data", "rdp_password", "rdpPassword", "user_id"], + ["ssh_data", "vnc_password", "vncPassword", "user_id"], + ["ssh_data", "telnet_password", "telnetPassword", "user_id"], ["ssh_credentials", "password", "password", "user_id"], ["ssh_credentials", "private_key", "privateKey", "user_id"], ["ssh_credentials", "key", "key", "user_id"], @@ -251,6 +253,14 @@ const HOST_ROWS: Row[] = [ telnet_port: 2323, rdp_user: "d4-rdp-user", rdp_password: "d4-rdp-password", + rdp_domain: "D4-DOMAIN", + rdp_auth_type: "direct", + vnc_user: "d4-vnc-user", + vnc_password: "d4-vnc-password", + vnc_auth_type: "direct", + // Telnet through the saved credential, which has no password of its own. + telnet_auth_type: "credential", + telnet_credential_id: 1, rdp_security: "nla", rdp_ignore_cert: 1, guacamole_config: JSON.stringify({ colorDepth: 24 }), diff --git a/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql b/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql index f2f525649..131789b67 100644 --- a/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql +++ b/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql @@ -415,6 +415,34 @@ ALTER TABLE `plugin_settings` MODIFY COLUMN `scope` enum('admin','user','host',' -- ==== core 0062_smooth_speed ==== ALTER TABLE `ssh_data` ADD `ssh_options` text; +-- ==== core 0063_needy_radioactive_man ==== +-- The RDP, VNC and Telnet logins moved out of ssh_data into host_protocol_auth. +-- A boot migration copies them after this runs, so the old ssh_data columns +-- and their foreign keys stay in place, unused, until 3.0.0. +CREATE TABLE `host_protocol_auth` ( + `id` int AUTO_INCREMENT NOT NULL, + `host_id` int NOT NULL, + `user_id` varchar(255) NOT NULL, + `protocol` varchar(255) NOT NULL, + `auth_type` text NOT NULL DEFAULT ('direct'), + `credential_id` int, + `username` text, + `password` text, + `fields` text, + `secret_fields` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `host_protocol_auth_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_host_protocol_auth_host_protocol` UNIQUE(`host_id`,`protocol`) +); +--> statement-breakpoint +ALTER TABLE `shared_host_secrets` ADD `encrypted_fields` text;--> statement-breakpoint +ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_host_protocol_auth_user` ON `host_protocol_auth` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_host_protocol_auth_credential` ON `host_protocol_auth` (`credential_id`); + -- ==== plugin workspaces 0001_adopt_user_workspaces.sql ==== -- workspaces 0001: adopt_user_workspaces -- Generated by termix-plugin migrations. Review before committing. diff --git a/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql b/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql index 61d4ffe4f..9fdc2341b 100644 --- a/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql +++ b/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql @@ -435,6 +435,33 @@ DROP TABLE "sync_tombstones" CASCADE; -- ==== core 0062_curvy_killmonger ==== ALTER TABLE "ssh_data" ADD COLUMN "ssh_options" text; +-- ==== core 0063_giant_starfox ==== +-- The RDP, VNC and Telnet logins moved out of ssh_data into host_protocol_auth. +-- A boot migration copies them after this runs, so the old ssh_data columns +-- and their foreign keys stay in place, unused, until 3.0.0. +CREATE TABLE "host_protocol_auth" ( + "id" serial PRIMARY KEY NOT NULL, + "host_id" integer NOT NULL, + "user_id" varchar(255) NOT NULL, + "protocol" varchar(255) NOT NULL, + "auth_type" text DEFAULT 'direct' NOT NULL, + "credential_id" integer, + "username" text, + "password" text, + "fields" text, + "secret_fields" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +ALTER TABLE "shared_host_secrets" ADD COLUMN "encrypted_fields" text;--> statement-breakpoint +ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_host_protocol_auth_host_protocol" ON "host_protocol_auth" USING btree ("host_id","protocol");--> statement-breakpoint +CREATE INDEX "idx_host_protocol_auth_user" ON "host_protocol_auth" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "idx_host_protocol_auth_credential" ON "host_protocol_auth" USING btree ("credential_id"); + -- ==== plugin workspaces 0001_adopt_user_workspaces.sql ==== -- workspaces 0001: adopt_user_workspaces -- Generated by termix-plugin migrations. Review before committing. diff --git a/src/backend/tests/hosts/protocol-auth/protocol-auth.test.ts b/src/backend/tests/hosts/protocol-auth/protocol-auth.test.ts new file mode 100644 index 000000000..141679652 --- /dev/null +++ b/src/backend/tests/hosts/protocol-auth/protocol-auth.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../../database/db/index.js", () => ({ + getDb: () => null, + getSqlite: () => null, +})); +vi.mock("../../../hosts/usable-credential.js", () => ({ + findUsableCredential: async (id: number, userId: string) => + id === 5 && userId === "importer" ? { id, username: "u" } : null, +})); + +import { + fromPortableLogin, + keepUsableProtocolCredentials, + mergeProtocolLogin, + readProtocolAuthPayload, + toPortableLogins, +} from "../../../hosts/protocol-auth/protocol-auth.js"; +import { + setHostProtocolSource, + type DeclaredHostProtocol, +} from "../../../hosts/protocol-auth/registry.js"; +import type { HostProtocolLogin } from "../../../database/repositories/host-protocol-auth-repository.js"; + +const SPICE: DeclaredHostProtocol = { + id: "spice", + credentialFields: [{ key: "display" }, { key: "ticket", secret: true }], + pluginId: "spice-plugin", + pluginName: "Spice", +}; +const REMOTE_X: DeclaredHostProtocol = { + id: "remote-x", + credentialFields: [{ key: "domain" }], + pluginId: "x-plugin", + pluginName: "X", +}; + +const stored: HostProtocolLogin = { + protocol: "spice", + authType: "direct", + credentialId: null, + username: "viewer", + password: "saved", + fields: { display: "2" }, + secretFields: { ticket: "t-saved" }, +}; + +beforeEach(() => setHostProtocolSource(() => [SPICE, REMOTE_X])); +afterEach(() => setHostProtocolSource(() => [])); + +describe("readProtocolAuthPayload", () => { + it("reads protocolAuth for declared protocols only", () => { + const patch = readProtocolAuthPayload({ + protocolAuth: { + spice: { username: "a" }, + "remote-x": null, + stranger: { username: "b" }, + }, + }); + expect(patch && Object.fromEntries(patch)).toEqual({ + spice: { username: "a" }, + "remote-x": null, + }); + }); + + it("reads the flat fields a 2.8 client sends, named after the protocol", () => { + const patch = readProtocolAuthPayload({ + spiceUser: "viewer", + spicePassword: "pw", + spiceDisplay: "3", + spiceAuthType: "direct", + remoteXUser: "x-user", + remoteXDomain: "CORP", + remoteXCredentialId: 9, + }); + expect(patch && Object.fromEntries(patch)).toEqual({ + spice: { + authType: "direct", + username: "viewer", + password: "pw", + fields: { display: "3" }, + }, + "remote-x": { + credentialId: 9, + username: "x-user", + fields: { domain: "CORP" }, + }, + }); + }); + + it("treats a 2.8 editor's null password as leaving it alone", () => { + const patch = readProtocolAuthPayload({ + spiceAuthType: "direct", + spiceUser: "viewer", + spicePassword: null, + }); + expect(patch?.get("spice")).toEqual({ + authType: "direct", + username: "viewer", + }); + }); + + it("reads a 2.8 editor's all-null protocol as removed", () => { + const patch = readProtocolAuthPayload({ + spiceAuthType: null, + spiceUser: null, + spicePassword: null, + spiceCredentialId: null, + }); + expect(patch?.get("spice")).toBeNull(); + }); + + it("answers null when a write carries no logins", () => { + expect(readProtocolAuthPayload({ name: "host" })).toBeNull(); + }); +}); + +describe("mergeProtocolLogin", () => { + it("keeps what the input leaves out, including secrets", () => { + expect(mergeProtocolLogin("spice", SPICE, stored, {})).toEqual(stored); + }); + + it("keeps a saved password and secret for an empty value", () => { + const next = mergeProtocolLogin("spice", SPICE, stored, { + password: "", + fields: { ticket: "", display: "" }, + }); + expect(next.password).toBe("saved"); + expect(next.secretFields).toEqual({ ticket: "t-saved" }); + // A plain field can be cleared. + expect(next.fields).toEqual({}); + }); + + it("drops the direct login and undeclared fields for a credential", () => { + const next = mergeProtocolLogin("spice", SPICE, stored, { + authType: "credential", + credentialId: "12", + fields: { unknown: "x" }, + }); + expect(next).toMatchObject({ + authType: "credential", + credentialId: 12, + username: null, + password: null, + fields: { display: "2" }, + }); + }); + + it("starts a new login from the input", () => { + expect( + mergeProtocolLogin("spice", SPICE, null, { + username: "new", + password: "pw", + fields: { ticket: "t" }, + }), + ).toEqual({ + protocol: "spice", + authType: "direct", + credentialId: null, + username: "new", + password: "pw", + fields: {}, + secretFields: { ticket: "t" }, + }); + }); + + it("falls back to direct for an auth type it does not know", () => { + expect( + mergeProtocolLogin("spice", SPICE, null, { authType: "magic" }).authType, + ).toBe("direct"); + }); +}); + +describe("portable logins", () => { + it("round-trips through an export, splitting fields by the declaration", () => { + const portable = toPortableLogins([stored]); + expect(portable.spice.fields).toEqual({ display: "2", ticket: "t-saved" }); + expect(fromPortableLogin("spice", portable.spice, null)).toEqual(stored); + }); + + it("keeps every field of an undeclared protocol secret", () => { + expect( + fromPortableLogin("gone", { fields: { display: "1" } }, null) + ?.secretFields, + ).toEqual({ display: "1" }); + }); + + it("drops a credential link a user cannot use", async () => { + const patch = await keepUsableProtocolCredentials( + new Map([ + ["spice", { authType: "credential", credentialId: 5 }], + ["remote-x", { authType: "credential", credentialId: 6 }], + ]), + "importer", + ); + expect(patch.get("spice")).toEqual({ + authType: "credential", + credentialId: 5, + }); + expect(patch.get("remote-x")).toEqual({ + authType: "direct", + credentialId: null, + }); + }); +}); diff --git a/src/backend/tests/hosts/protocol-auth/registry.test.ts b/src/backend/tests/hosts/protocol-auth/registry.test.ts new file mode 100644 index 000000000..916b9cd7f --- /dev/null +++ b/src/backend/tests/hosts/protocol-auth/registry.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + findHostProtocol, + isAuthOverrideProtocol, + listHostProtocols, + plainFieldKeys, + secretFieldKeys, + setHostProtocolSource, + type DeclaredHostProtocol, +} from "../../../hosts/protocol-auth/registry.js"; + +const SPICE: DeclaredHostProtocol = { + id: "spice", + credentialFields: [{ key: "display" }, { key: "ticket", secret: true }], + pluginId: "spice-plugin", + pluginName: "Spice", +}; + +afterEach(() => setHostProtocolSource(() => [])); + +describe("the host protocol registry", () => { + it("knows nothing until a manifest declares it", () => { + expect(listHostProtocols()).toEqual([]); + expect(findHostProtocol("spice")).toBeUndefined(); + }); + + it("keeps the first plugin to declare an id", () => { + setHostProtocolSource(() => [ + SPICE, + { ...SPICE, pluginId: "late", pluginName: "Late" }, + ]); + expect(listHostProtocols()).toHaveLength(1); + expect(findHostProtocol("spice")?.pluginId).toBe("spice-plugin"); + }); + + it("allows overrides for ssh and declared protocols only", () => { + setHostProtocolSource(() => [SPICE]); + expect(isAuthOverrideProtocol("ssh")).toBe(true); + expect(isAuthOverrideProtocol("spice")).toBe(true); + expect(isAuthOverrideProtocol("rdp")).toBe(false); + expect(isAuthOverrideProtocol(7)).toBe(false); + }); + + it("splits declared fields into plain and secret", () => { + expect(plainFieldKeys(SPICE)).toEqual(["display"]); + expect(secretFieldKeys(SPICE)).toEqual(["ticket"]); + }); +}); diff --git a/src/backend/tests/hosts/protocol-auth/summary.test.ts b/src/backend/tests/hosts/protocol-auth/summary.test.ts new file mode 100644 index 000000000..2c17fa357 --- /dev/null +++ b/src/backend/tests/hosts/protocol-auth/summary.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeProtocolAuthForRecipient } from "../../../hosts/protocol-auth/summary.js"; + +const summaries = { + spice: { + authType: "credential", + credentialId: 4, + username: null, + fields: { display: "2" }, + hasPassword: true, + secretFieldKeys: ["ticket"], + }, +}; + +describe("sanitizeProtocolAuthForRecipient", () => { + it("keeps only the auth type at connect level", () => { + expect(sanitizeProtocolAuthForRecipient(summaries, "connect")).toEqual({ + spice: { authType: "credential" }, + }); + }); + + it("never says whether secrets are set, at any level", () => { + for (const level of ["view", "edit", "manage"]) { + expect(sanitizeProtocolAuthForRecipient(summaries, level)).toEqual({ + spice: { + authType: "credential", + credentialId: 4, + username: null, + fields: { display: "2" }, + }, + }); + } + }); + + it("answers an empty map for anything else", () => { + expect(sanitizeProtocolAuthForRecipient(null, "view")).toEqual({}); + expect(sanitizeProtocolAuthForRecipient({ spice: 3 }, "view")).toEqual({}); + }); +}); diff --git a/src/backend/tests/plugins/ctx-credentials.test.ts b/src/backend/tests/plugins/ctx-credentials.test.ts index 04ea3ff76..867a44861 100644 --- a/src/backend/tests/plugins/ctx-credentials.test.ts +++ b/src/backend/tests/plugins/ctx-credentials.test.ts @@ -18,6 +18,8 @@ const state = vi.hoisted(() => ({ savedCredentials: [] as Array>, userCredentials: new Map>>(), corePermissions: new Set(["credentials.view", "credentials.create"]), + logins: new Map>(), + ownerDecrypts: 0, })); vi.mock("../../database/repositories/factory.js", () => ({ @@ -40,6 +42,30 @@ vi.mock("../../database/repositories/factory.js", () => ({ return row; }, }), + createCurrentHostProtocolAuthRepository: () => ({ + find: async (hostId: number, protocol: string) => { + state.ownerDecrypts++; + return state.logins.get(`${hostId}:${protocol}`) ?? null; + }, + findRow: async (hostId: number, protocol: string) => { + const login = state.logins.get(`${hostId}:${protocol}`); + return login + ? { + hostId, + protocol, + authType: login.authType, + credentialId: login.credentialId ?? null, + username: login.username ?? null, + password: "sealed", + fields: JSON.stringify(login.fields ?? {}), + secretFields: "sealed", + } + : null; + }, + }), + createCurrentSharedHostAuthOverrideRepository: () => ({ + listCredentialIds: async () => ({}), + }), createCurrentHostResolutionRepository: () => ({ findHostOwnerId: async (hostId: number) => (state.hosts.get(hostId)?.userId as string) ?? null, @@ -49,6 +75,17 @@ vi.mock("../../database/repositories/factory.js", () => ({ }), })); +vi.mock("../../utils/data-crypto.js", () => ({ + DataCrypto: { + validateUserAccess: () => Buffer.alloc(32), + getUserDataKey: () => Buffer.alloc(32), + }, +})); + +vi.mock("../../hosts/usable-credential.js", () => ({ + findUsableCredential: async (id: number) => state.credentials.get(id) ?? null, +})); + vi.mock("../../utils/audit-logger.js", () => ({ logAudit: async (entry: Record) => { auditEntries.push(entry); @@ -84,6 +121,13 @@ import { } from "../../hosts/connect/secret-resolver-registry.js"; import type { PluginManifest } from "@termix/plugin-sdk/manifest"; import ssh2 from "ssh2"; +import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js"; + +const SPICE = { + id: "spice", + credentialFields: [{ key: "display" }, { key: "ticket", secret: true }], + hostLoginFallback: ["password" as const], +}; function contextFor( capabilities: string[], @@ -114,13 +158,13 @@ const baseHost = { username: "sshuser", password: "ssh-secret", jumpHosts: JSON.stringify([{ hostId: 3 }]), - rdpUser: "admin", - rdpPassword: "rdp-secret", - rdpDomain: "CORP", - rdpAuthType: null, - rdpCredentialId: null, }; +/** The demo plugin declares SPICE; nothing in core knows it. */ +function spiceContext(capabilities: string[]) { + return contextFor(capabilities, { protocols: [SPICE] }); +} + beforeEach(() => { grants.clear(); auditEntries.length = 0; @@ -134,17 +178,32 @@ beforeEach(() => { state.userCredentials.clear(); state.corePermissions = new Set(["credentials.view", "credentials.create"]); state.hosts.set(7, { ...baseHost }); + state.logins.clear(); + state.ownerDecrypts = 0; + state.logins.set("7:spice", { + protocol: "spice", + authType: "direct", + credentialId: null, + username: "admin", + password: "spice-secret", + fields: { display: "2" }, + secretFields: { ticket: "t-123" }, + }); + setHostProtocolSource(() => [ + { ...SPICE, pluginId: "demo", pluginName: "demo" }, + { id: "rival", pluginId: "other", pluginName: "other" }, + ]); resetSecretResolverRegistryForTests(); }); describe("ctx.credentials.resolveHostProtocol", () => { it("refuses without credentials:read and audits the refusal", async () => { grants.set("demo", []); - const ctx = contextFor([]); + const ctx = spiceContext([]); await expect( runAsActor("owner", "request", () => - ctx.credentials.resolveHostProtocol(7, "rdp"), + ctx.credentials.resolveHostProtocol(7, "spice"), ), ).rejects.toThrow(/credentials:read/); expect(auditEntries.at(-1)).toMatchObject({ @@ -153,12 +212,31 @@ describe("ctx.credentials.resolveHostProtocol", () => { }); }); + it("refuses a protocol the plugin does not declare, or another plugin owns", async () => { + grants.set("demo", ["credentials:read"]); + const ctx = contextFor(["credentials:read"], { + protocols: [SPICE, { id: "rival" }], + }); + + await expect( + runAsActor("owner", "request", () => + ctx.credentials.resolveHostProtocol(7, "vnc"), + ), + ).rejects.toThrow(/does not declare/); + await expect( + runAsActor("owner", "request", () => + ctx.credentials.resolveHostProtocol(7, "rival"), + ), + ).rejects.toThrow(/does not declare/); + expect(state.ownerDecrypts).toBe(0); + }); + it("returns the owner's stored login and audits it", async () => { grants.set("demo", ["credentials:read"]); - const ctx = contextFor(["credentials:read"]); + const ctx = spiceContext(["credentials:read"]); const target = await runAsActor("owner", "request", () => - ctx.credentials.resolveHostProtocol(7, "rdp"), + ctx.credentials.resolveHostProtocol(7, "spice"), ); expect(target).toEqual({ host: { @@ -173,8 +251,8 @@ describe("ctx.credentials.resolveHostProtocol", () => { auth: { authType: "direct", username: "admin", - password: "rdp-secret", - domain: "CORP", + password: "spice-secret", + fields: { display: "2", ticket: "t-123" }, }, }); expect(auditEntries.at(-1)).toMatchObject({ @@ -183,23 +261,52 @@ describe("ctx.credentials.resolveHostProtocol", () => { }); }); - it("uses the stored credential in credential mode", async () => { + it("falls back to the host's own password as declared", async () => { grants.set("demo", ["credentials:read"]); - state.hosts.set(7, { - ...baseHost, - vncCredentialId: 11, - vncAuthType: "credential", + state.logins.set("7:spice", { + protocol: "spice", + authType: "direct", + credentialId: null, + username: null, + password: null, + fields: {}, + secretFields: {}, }); - state.credentials.set(11, { username: "viewer", password: "vnc-pass" }); - const ctx = contextFor(["credentials:read"]); + const ctx = spiceContext(["credentials:read"]); const target = await runAsActor("owner", "request", () => - ctx.credentials.resolveHostProtocol(7, "vnc"), + ctx.credentials.resolveHostProtocol(7, "spice"), + ); + expect(target?.auth).toEqual({ + authType: "direct", + username: "", + password: "ssh-secret", + fields: { display: "", ticket: "" }, + }); + }); + + it("uses the stored credential in credential mode", async () => { + grants.set("demo", ["credentials:read"]); + state.logins.set("7:spice", { + protocol: "spice", + authType: "credential", + credentialId: 11, + username: null, + password: null, + fields: { display: "1" }, + secretFields: {}, + }); + state.credentials.set(11, { username: "viewer", password: "cred-pass" }); + const ctx = spiceContext(["credentials:read"]); + + const target = await runAsActor("owner", "request", () => + ctx.credentials.resolveHostProtocol(7, "spice"), ); expect(target?.auth).toMatchObject({ authType: "credential", username: "viewer", - password: "vnc-pass", + password: "cred-pass", + fields: { display: "1" }, }); }); @@ -208,50 +315,56 @@ describe("ctx.credentials.resolveHostProtocol", () => { state.resolution = { source: "owner-shared", authType: "direct", - secret: { username: "shared", password: "snap", domain: null }, + secret: { + username: "shared", + password: "snap", + fields: { ticket: "snap-ticket" }, + }, }; - const ctx = contextFor(["credentials:read"]); + const ctx = spiceContext(["credentials:read"]); const target = await runAsActor("guest", "request", () => - ctx.credentials.resolveHostProtocol(7, "rdp"), + ctx.credentials.resolveHostProtocol(7, "spice"), ); expect(target?.shared).toBe(true); - expect(target?.auth).toMatchObject({ + expect(target?.auth).toEqual({ + authType: "direct", username: "shared", password: "snap", + fields: { display: "2", ticket: "snap-ticket" }, }); - expect(state.resolverCalls[0]).toMatchObject({ - password: null, - rdpUser: null, - rdpPassword: null, - }); + expect(state.resolverCalls[0]).toMatchObject({ password: null }); + // The owner's login is never decrypted for a recipient. + expect(state.ownerDecrypts).toBe(0); }); it("gives a recipient with nothing shared an empty login", async () => { grants.set("demo", ["credentials:read"]); state.resolution = { source: "required" }; - const ctx = contextFor(["credentials:read"]); + const ctx = spiceContext(["credentials:read"]); const target = await runAsActor("guest", "request", () => - ctx.credentials.resolveHostProtocol(7, "telnet"), + ctx.credentials.resolveHostProtocol(7, "spice"), ); expect(target?.auth.username).toBe(""); expect(target?.auth.password).toBe(""); + expect(target?.auth.fields.ticket).toBe(""); + expect(state.ownerDecrypts).toBe(0); }); it("returns null without connect access or for a missing host", async () => { grants.set("demo", ["credentials:read"]); state.canConnect = false; - const ctx = contextFor(["credentials:read"]); + const ctx = spiceContext(["credentials:read"]); await expect( runAsActor("guest", "request", () => - ctx.credentials.resolveHostProtocol(7, "rdp"), + ctx.credentials.resolveHostProtocol(7, "spice"), ), ).resolves.toBeNull(); await expect( runAsActor("owner", "request", () => - ctx.credentials.resolveHostProtocol(99, "rdp"), + ctx.credentials.resolveHostProtocol(99, "spice"), ), ).resolves.toBeNull(); expect(auditEntries.at(-1)).toMatchObject({ success: false }); diff --git a/src/backend/tests/plugins/manifest.test.ts b/src/backend/tests/plugins/manifest.test.ts index 3a93d37d1..30e5e8750 100644 --- a/src/backend/tests/plugins/manifest.test.ts +++ b/src/backend/tests/plugins/manifest.test.ts @@ -759,3 +759,67 @@ describe("contributes.keybindingActions", () => { expect(errors).toMatch(/d/); }); }); + +describe("contributes.protocols", () => { + const withProtocols = (protocols: unknown) => + validateManifest(base({ contributes: { protocols } })); + + it("accepts protocols with credential fields", () => { + expect( + withProtocols([ + { + id: "spice", + credentialFields: [ + { key: "display" }, + { key: "ticket", secret: true }, + ], + defaultPort: 5930, + hostLoginFallback: ["username", "password"], + }, + { id: "x2go" }, + ]), + ).toEqual([]); + }); + + it("refuses core's protocol, duplicates and bad ids", () => { + const errors = withProtocols([ + { id: "ssh" }, + { id: "spice" }, + { id: "spice" }, + { id: "Bad_Id" }, + ]); + expect(errors.join("\n")).toMatch(/"ssh" belongs to core/); + expect(errors.join("\n")).toMatch(/duplicates "spice"/); + expect(errors.join("\n")).toMatch(/\[3\]\.id must be lowercase/); + }); + + it("refuses field keys every login already has, and duplicate keys", () => { + const errors = withProtocols([ + { + id: "spice", + credentialFields: [ + { key: "password" }, + { key: "display" }, + { key: "display", secret: "yes" }, + ], + }, + ]); + expect(errors.join("\n")).toMatch(/"password" is part of every login/); + expect(errors.join("\n")).toMatch(/duplicates "display"/); + expect(errors.join("\n")).toMatch(/secret must be a boolean/); + }); + + it("refuses unknown keys, bad ports and bad fallbacks", () => { + const errors = withProtocols([ + { + id: "spice", + defaultPort: 70000, + hostLoginFallback: ["key"], + extra: true, + }, + ]); + expect(errors.join("\n")).toMatch(/defaultPort must be a port number/); + expect(errors.join("\n")).toMatch(/hostLoginFallback/); + expect(errors.join("\n")).toMatch(/extra/); + }); +}); diff --git a/src/backend/tests/plugins/protocol-auth-fixture.test.ts b/src/backend/tests/plugins/protocol-auth-fixture.test.ts new file mode 100644 index 000000000..177a67f84 --- /dev/null +++ b/src/backend/tests/plugins/protocol-auth-fixture.test.ts @@ -0,0 +1,387 @@ +/** + * A fixture plugin declares a protocol core has never heard of ("spice") and + * gets a per-host login stored, encrypted, shared with a recipient (and + * overridden by them) and resolved, with no core change: only the manifest + * declaration. Runs on a real database with real repositories, sharing + * manager and override service; only keys, permissions and audit are faked. + */ + +import crypto from "node:crypto"; +import { sql } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import { TestSqliteDatabase } from "../database/repositories/test-support.js"; +import type { DatabaseContext } from "../../database/repositories/database-context.js"; + +const state = vi.hoisted(() => ({ + db: null as null | { drizzle: unknown }, + keys: new Map(), + levels: new Map(), +})); + +vi.mock("../../database/db/index.js", () => ({ + getDb: () => state.db!.drizzle, + getSqlite: () => null, +})); +vi.mock("../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { + triggerSave: vi.fn(), + forceSave: vi.fn(async () => {}), + }, +})); +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: vi.fn(async () => {}), + getAuditUsername: async (id: string) => id, + getRequestMeta: () => ({}), +})); +vi.mock("../../utils/data-crypto.js", async (importOriginal) => { + const actual = + await importOriginal(); + const DataCrypto = actual.DataCrypto as unknown as Record; + DataCrypto.getUserDataKey = (userId: string) => + state.keys.get(userId) ?? null; + DataCrypto.validateUserAccess = (userId: string) => { + const key = state.keys.get(userId); + if (!key) throw new Error(`User ${userId} has no data encryption key`); + return key; + }; + return actual; +}); +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + canAccessHost: async (userId: string, _hostId: number) => { + const level = state.levels.get(userId); + return level + ? { hasAccess: true, isShared: true, permissionLevel: level } + : { hasAccess: false }; + }, + hasPermission: async () => true, + }), + }, +})); + +import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js"; +import { + loadProtocolAuthSummaries, + ProtocolAuthWriteError, + readProtocolAuthPayload, + writeProtocolAuth, +} from "../../hosts/protocol-auth/protocol-auth.js"; +import { SharedHostSecretsManager } from "../../utils/shared-host-secrets-manager.js"; +import { SharedHostAuthOverrideService } from "../../utils/shared-host-auth-override-service.js"; +import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js"; +import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; +import { runAsActor } from "../../plugins/actor.js"; +import { sanitizeHostForRecipient } from "../../database/routes/host-normalizers.js"; +import { FieldCrypto } from "../../utils/field-crypto.js"; + +const HOST_ID = 10; +const GRANT_ID = 1; + +const manifest = { + id: "fixture-spice", + name: "Fixture Spice", + version: "1.0.0", + description: "", + author: { name: "test" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=2.9.0", api: "1" }, + capabilities: ["credentials:read"], + contributes: { + protocols: [ + { + id: "spice", + credentialFields: [{ key: "display" }, { key: "ticket", secret: true }], + }, + ], + }, +} as PluginManifest; + +let database: TestSqliteDatabase | null = null; +let context: DatabaseContext; + +async function exec(query: string): Promise { + await context.drizzle.run(sql.raw(query)); +} + +beforeEach(async () => { + database = new TestSqliteDatabase("sqlite"); + context = await database.connect(); + state.db = context; + state.keys = new Map([ + ["owner", crypto.randomBytes(32)], + ["recipient", crypto.randomBytes(32)], + ]); + state.levels = new Map([["recipient", "connect"]]); + for (const statement of [ + `INSERT INTO users (id, username, password_hash) VALUES + ('owner', 'owner', 'x'), ('recipient', 'recipient', 'x')`, + `INSERT INTO ssh_credentials (id, user_id, name, username, password, auth_type) + VALUES (5, 'recipient', 'mine', 'own-user', 'own-pass', 'password'), + (6, 'owner', 'owners', 'cred-user', 'cred-pass', 'password')`, + `INSERT INTO ssh_data (id, user_id, name, ip, port, username, password, auth_type) + VALUES (${HOST_ID}, 'owner', 'desk', '10.0.0.10', 22, 'root', 'ssh-pass', 'password')`, + `INSERT INTO host_access (id, host_id, user_id, granted_by, permission_level) + VALUES (${GRANT_ID}, ${HOST_ID}, 'recipient', 'owner', 'connect')`, + `INSERT INTO plugins (id, name, version, state, manifest_json) + VALUES ('fixture-spice', 'Fixture Spice', '1.0.0', 'enabled', '{}')`, + `INSERT INTO plugin_permission_grants (plugin_id, capability, source) + VALUES ('fixture-spice', 'credentials:read', 'bundled')`, + ]) { + await exec(statement); + } + invalidatePluginPermissionCache(); + // What the loader does with every installed manifest. + setHostProtocolSource(() => + manifest.contributes!.protocols!.map((protocol) => ({ + ...protocol, + pluginId: manifest.id, + pluginName: manifest.name, + })), + ); +}); + +afterEach(async () => { + setHostProtocolSource(() => []); + await database?.close(); + database = null; +}); + +function pluginCtx() { + return createPluginContext( + manifest, + createPluginHandle(manifest.id, { activate: () => {} }), + ); +} + +async function storeLogin(): Promise { + const patch = readProtocolAuthPayload({ + protocolAuth: { + spice: { + authType: "direct", + username: "viewer", + password: "spice-pass", + fields: { display: "2", ticket: "t-secret" }, + }, + unknown: { username: "ignored" }, + }, + }); + await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true }); +} + +async function storedRow(): Promise> { + const rows = (await context.drizzle.all( + sql.raw(`SELECT * FROM host_protocol_auth WHERE host_id = ${HOST_ID}`), + )) as Record[]; + expect(rows).toHaveLength(1); + return rows[0]; +} + +describe("a plugin protocol core does not know", () => { + it("stores the login encrypted with the owner's key, secrets apart", async () => { + await storeLogin(); + const row = await storedRow(); + + expect(row.protocol).toBe("spice"); + expect(row.username).toBe("viewer"); + expect(JSON.parse(row.fields as string)).toEqual({ display: "2" }); + expect(String(row.password)).not.toContain("spice-pass"); + expect(String(row.secret_fields)).not.toContain("t-secret"); + expect( + FieldCrypto.decryptField( + row.password as string, + state.keys.get("owner")!, + "", + "password", + ), + ).toBe("spice-pass"); + expect(() => + FieldCrypto.decryptField( + row.password as string, + state.keys.get("recipient")!, + "", + "password", + ), + ).toThrow(); + + const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]); + expect(summaries.get(HOST_ID)).toEqual({ + spice: { + authType: "direct", + credentialId: null, + username: "viewer", + fields: { display: "2" }, + hasPassword: true, + secretFieldKeys: ["ticket"], + }, + }); + }); + + it("resolves the owner's login for the declaring plugin", async () => { + await storeLogin(); + const target = await runAsActor("owner", "request", () => + pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"), + ); + expect(target?.shared).toBe(false); + expect(target?.auth).toEqual({ + authType: "direct", + username: "viewer", + password: "spice-pass", + fields: { display: "2", ticket: "t-secret" }, + }); + }); + + it("treats a tampered password as missing, never as a value", async () => { + await storeLogin(); + const row = await storedRow(); + const envelope = JSON.parse(row.password as string); + envelope.tag = "0".repeat(envelope.tag.length); + await exec( + `UPDATE host_protocol_auth SET password = '${JSON.stringify(envelope)}' WHERE host_id = ${HOST_ID}`, + ); + + const target = await runAsActor("owner", "request", () => + pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"), + ); + expect(target?.auth.password).toBe(""); + expect(target?.auth.fields.ticket).toBe("t-secret"); + }); + + it("gives a recipient the shared snapshot, not the owner's secret", async () => { + await storeLogin(); + await SharedHostSecretsManager.getInstance().snapshotForUser( + GRANT_ID, + HOST_ID, + "recipient", + "owner", + ); + // The owner's row loses its password without a resync: the recipient + // keeps reading their own copy, never the owner's row. + await exec( + `UPDATE host_protocol_auth SET password = NULL WHERE host_id = ${HOST_ID}`, + ); + + const target = await runAsActor("recipient", "request", () => + pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"), + ); + expect(target?.shared).toBe(true); + expect(target?.auth).toEqual({ + authType: "direct", + username: "viewer", + password: "spice-pass", + fields: { display: "2", ticket: "t-secret" }, + }); + }); + + it("uses the recipient's own override over the shared snapshot", async () => { + await storeLogin(); + await SharedHostSecretsManager.getInstance().snapshotForUser( + GRANT_ID, + HOST_ID, + "recipient", + "owner", + ); + await SharedHostAuthOverrideService.getInstance().setCredentialId( + HOST_ID, + "recipient", + "spice", + 5, + ); + + const target = await runAsActor("recipient", "request", () => + pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"), + ); + expect(target?.auth).toEqual({ + authType: "credential", + username: "own-user", + password: "own-pass", + // The owner's plain fields, never their secret ones. + fields: { display: "2", ticket: "" }, + }); + }); + + it("refuses an override for a protocol nobody declares", async () => { + await expect( + SharedHostAuthOverrideService.getInstance().setCredentialId( + HOST_ID, + "recipient", + "made-up", + 5, + ), + ).rejects.toThrow(/No plugin declares/); + }); + + it("follows a credential the owner points the login at", async () => { + const patch = readProtocolAuthPayload({ + protocolAuth: { spice: { authType: "credential", credentialId: 6 } }, + }); + await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true }); + + const target = await runAsActor("owner", "request", () => + pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"), + ); + expect(target?.auth).toMatchObject({ + authType: "credential", + username: "cred-user", + password: "cred-pass", + }); + }); + + it("lets a shared editor change the login but not its credential", async () => { + await storeLogin(); + const rename = readProtocolAuthPayload({ + protocolAuth: { spice: { username: "renamed", password: "" } }, + }); + await writeProtocolAuth("owner", HOST_ID, rename!, { isOwner: false }); + expect((await storedRow()).username).toBe("renamed"); + // An empty password from an editor who cannot see it keeps it. + const target = await runAsActor("owner", "request", () => + pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"), + ); + expect(target?.auth.password).toBe("spice-pass"); + + const repoint = readProtocolAuthPayload({ + protocolAuth: { spice: { authType: "credential", credentialId: 6 } }, + }); + await expect( + writeProtocolAuth("owner", HOST_ID, repoint!, { isOwner: false }), + ).rejects.toBeInstanceOf(ProtocolAuthWriteError); + const remove = readProtocolAuthPayload({ protocolAuth: { spice: null } }); + await writeProtocolAuth("owner", HOST_ID, remove!, { isOwner: false }); + expect( + (await context.drizzle.all( + sql.raw(`SELECT id FROM host_protocol_auth`), + )) as unknown[], + ).toHaveLength(0); + }); + + it("shows each share level only what it may see", async () => { + await storeLogin(); + const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]); + const host = { + id: HOST_ID, + name: "desk", + ip: "10.0.0.10", + protocolAuth: summaries.get(HOST_ID), + }; + + expect( + sanitizeHostForRecipient({ ...host }, "connect").protocolAuth, + ).toEqual({ spice: { authType: "direct" } }); + for (const level of ["view", "edit", "manage"]) { + expect( + sanitizeHostForRecipient({ ...host }, level).protocolAuth, + level, + ).toEqual({ + spice: { + authType: "direct", + credentialId: null, + username: "viewer", + fields: { display: "2" }, + }, + }); + } + }); +}); diff --git a/src/backend/tests/plugins/security/credential-shapes.test.ts b/src/backend/tests/plugins/security/credential-shapes.test.ts index c991c2788..4cd3962e0 100644 --- a/src/backend/tests/plugins/security/credential-shapes.test.ts +++ b/src/backend/tests/plugins/security/credential-shapes.test.ts @@ -31,9 +31,6 @@ function secretHost(id: number): Record { keyPassword: "s3cret-value", sudoPassword: "s3cret-value", socks5Password: "s3cret-value", - rdpPassword: "s3cret-value", - vncPassword: "s3cret-value", - telnetPassword: "s3cret-value", autostartPassword: "s3cret-value", autostartKey: "s3cret-value", autostartKeyPassword: "s3cret-value", diff --git a/src/backend/tests/plugins/upgrade-fixture.test.ts b/src/backend/tests/plugins/upgrade-fixture.test.ts index cdac16bab..2b4541201 100644 --- a/src/backend/tests/plugins/upgrade-fixture.test.ts +++ b/src/backend/tests/plugins/upgrade-fixture.test.ts @@ -420,11 +420,45 @@ function upgradeChecks(current: () => Booted, bootIndex: number) { environmentVariables: [{ key: "D4", value: "yes" }], }); + // Every 2.8 RDP, VNC and Telnet login reads back through + // host_protocol_auth, the only place core reads them from now. const ctx = await pluginCtx("remote-desktop"); - const target = await asUser(USERS.admin, () => - ctx.credentials.resolveHostProtocol(HOSTS.rdpOnly, "rdp"), + const resolve = (hostId: number, protocol: string) => + asUser(USERS.admin, () => + ctx.credentials.resolveHostProtocol(hostId, protocol), + ); + expect((await resolve(HOSTS.rdpOnly, "rdp"))?.auth).toMatchObject({ + authType: "direct", + username: "d4-rdp-user", + password: "d4-rdp-password", + }); + expect((await resolve(HOSTS.desktop, "rdp"))?.auth).toEqual({ + authType: "direct", + username: "d4-rdp-user", + password: "d4-rdp-password", + fields: { domain: "D4-DOMAIN" }, + }); + expect((await resolve(HOSTS.desktop, "vnc"))?.auth).toMatchObject({ + authType: "direct", + username: "d4-vnc-user", + password: "d4-vnc-password", + }); + expect((await resolve(HOSTS.desktop, "telnet"))?.auth).toMatchObject({ + authType: "credential", + username: "deploy", + }); + + const logins = await selectRows<{ host_id: number; protocol: string }>( + sql`SELECT host_id, protocol FROM host_protocol_auth ORDER BY host_id, protocol`, ); - expect(target?.auth.password).toBe("d4-rdp-password"); + expect( + logins.map((row) => `${Number(row.host_id)}:${row.protocol}`), + ).toEqual([ + `${HOSTS.rdpOnly}:rdp`, + `${HOSTS.desktop}:rdp`, + `${HOSTS.desktop}:telnet`, + `${HOSTS.desktop}:vnc`, + ]); }); it("registers an SSH auth provider for every 2.8 auth type", async () => { diff --git a/src/backend/tests/sync/host-protocol-auth.test.ts b/src/backend/tests/sync/host-protocol-auth.test.ts new file mode 100644 index 000000000..896641771 --- /dev/null +++ b/src/backend/tests/sync/host-protocol-auth.test.ts @@ -0,0 +1,131 @@ +/** + * A host's plugin protocol logins over sync: out with the credential named + * by syncId, back in with it translated to the local id. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + logins: [] as Array>, + replaced: null as null | { ownerId: string; hostId: number; logins: unknown }, +})); + +vi.mock( + "../../hosts/protocol-auth/protocol-auth.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../hosts/protocol-auth/protocol-auth.js") + >(); + return { + ...actual, + listProtocolLogins: async () => state.logins, + replaceProtocolLogins: async ( + ownerId: string, + hostId: number, + logins: unknown, + ) => { + state.replaced = { ownerId, hostId, logins }; + }, + }; + }, +); +vi.mock("../../database/db/index.js", () => ({ + getDb: () => null, + getSqlite: () => null, +})); + +import { + exportProtocolLogins, + importProtocolLogins, +} from "../../sync/host-protocol-auth.js"; +import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js"; + +beforeEach(() => { + state.replaced = null; + state.logins = [ + { + protocol: "spice", + authType: "credential", + credentialId: 4, + username: null, + password: null, + fields: { display: "2" }, + secretFields: { ticket: "t" }, + }, + ]; + setHostProtocolSource(() => [ + { + id: "spice", + credentialFields: [{ key: "display" }, { key: "ticket", secret: true }], + pluginId: "p", + pluginName: "P", + }, + ]); +}); + +describe("host protocol logins over sync", () => { + it("sends each login with its credential as a syncId", async () => { + const wire = await exportProtocolLogins(1, "owner", async (type, id) => + type === "sshCredentials" && id === 4 ? "cred-sync-4" : null, + ); + expect(wire).toEqual({ + spice: { + authType: "credential", + username: null, + password: null, + fields: { display: "2", ticket: "t" }, + credentialSyncId: "cred-sync-4", + }, + }); + }); + + it("writes what arrived with the credential's local id", async () => { + await importProtocolLogins( + 9, + "owner", + { + spice: { + authType: "credential", + fields: { display: "3", ticket: "t2" }, + credentialSyncId: "cred-sync-4", + }, + }, + async (type, syncId) => + type === "sshCredentials" && syncId === "cred-sync-4" ? 40 : null, + ); + expect(state.replaced).toEqual({ + ownerId: "owner", + hostId: 9, + logins: [ + { + protocol: "spice", + authType: "credential", + credentialId: 40, + username: null, + password: null, + fields: { display: "3" }, + secretFields: { ticket: "t2" }, + }, + ], + }); + }); + + it("keeps the current credential while its target has not arrived", async () => { + await importProtocolLogins( + 9, + "owner", + { spice: { authType: "credential", credentialSyncId: "later" } }, + async () => null, + ); + expect( + (state.replaced?.logins as Array<{ credentialId: number }>)[0] + .credentialId, + ).toBe(4); + }); + + it("leaves the logins alone for a peer that sends none", async () => { + await importProtocolLogins(9, "owner", undefined, async () => null); + expect(state.replaced).toBeNull(); + }); +}); diff --git a/src/backend/tests/upgrade/protocol-auth-migration.test.ts b/src/backend/tests/upgrade/protocol-auth-migration.test.ts new file mode 100644 index 000000000..84856d700 --- /dev/null +++ b/src/backend/tests/upgrade/protocol-auth-migration.test.ts @@ -0,0 +1,223 @@ +/** + * The 2.8 RDP, VNC and Telnet logins move out of the ssh_data columns into + * host_protocol_auth. Every shape a 2.8 install could hold must come across + * (encrypted under the column's name, the blob 2.8 copied from `password`, + * plaintext, credential mode, the pre-protocol-columns hosts), a second run + * must change nothing, and a login the user removed afterwards must not come + * back. Runs on SQLite, or on TEST_DIALECT's server. + */ + +import crypto from "node:crypto"; +import { sql } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TestSqliteDatabase } from "../database/repositories/test-support.js"; + +const state = vi.hoisted(() => ({ + db: null as null | { drizzle: unknown }, + keys: new Map(), +})); + +vi.mock("../../database/db/index.js", () => ({ + getDb: () => state.db!.drizzle, + getSqlite: () => null, +})); +vi.mock("../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { + triggerSave: vi.fn(), + forceSave: vi.fn(async () => {}), + }, +})); +vi.mock("../../utils/data-crypto.js", () => ({ + DataCrypto: { + getUserDataKey: (userId: string) => state.keys.get(userId) ?? null, + }, +})); + +import { + copiedMarker, + runProtocolAuthMigration, +} from "../../upgrade/protocol-auth-migration.js"; +import { createCurrentHostProtocolAuthRepository } from "../../database/repositories/factory.js"; +import { FieldCrypto } from "../../utils/field-crypto.js"; + +const ownerKey = crypto.randomBytes(32); + +let database: TestSqliteDatabase | null = null; + +function sealed(value: string, hostId: number, field: string): string { + return FieldCrypto.encryptField(value, ownerKey, String(hostId), field); +} + +function quote(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +beforeEach(async () => { + database = new TestSqliteDatabase(); + state.db = await database.connect(); + state.keys = new Map([["owner", ownerKey]]); + await database.exec(` + INSERT INTO users (id, username, password_hash) VALUES + ('owner', 'owner', 'x'), ('locked', 'locked', 'x'); + INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES + (5, 'owner', 'vnc-cred', 'viewer', 'password'); + INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type, + rdp_user, rdp_password, rdp_domain, rdp_auth_type) + VALUES (1, 'owner', 'ssh', 'win', '10.0.0.1', 22, 'root', 'password', + 'admin', ${quote(sealed("rdp-pass", 1, "rdpPassword"))}, 'CORP', 'direct'); + INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type, + vnc_auth_type, vnc_credential_id) + VALUES (2, 'owner', 'ssh', 'vnc', '10.0.0.2', 22, 'root', 'password', 'credential', 5); + INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type, + telnet_user, telnet_password) + VALUES (3, 'owner', 'ssh', 'switch', '10.0.0.3', 22, 'root', 'password', 'tu', 'tel-plain'); + INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, password, + domain, auth_type) + VALUES (4, 'owner', 'rdp', 'old', '10.0.0.4', 3389, 'old-user', + ${quote(sealed("old-pass", 4, "password"))}, 'OLDDOM', 'password'); + INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type, + rdp_user, rdp_password, rdp_auth_type) + VALUES (5, 'owner', 'ssh', 'copied', '10.0.0.5', 22, 'root', 'password', + 'copied', ${quote(sealed("copied-pass", 5, "password"))}, 'none'); + INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type) + VALUES (6, 'owner', 'ssh', 'plain-ssh', '10.0.0.6', 22, 'root', 'password'); + INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type, + rdp_user) + VALUES (7, 'locked', 'ssh', 'later', '10.0.0.7', 22, 'root', 'password', 'waits'); + `); +}); + +afterEach(async () => { + await database?.close(); + database = null; +}); + +async function logins() { + const repository = createCurrentHostProtocolAuthRepository(); + const out: Record = {}; + for (const hostId of [1, 2, 3, 4, 5, 6]) { + for (const login of await repository.listForHost(hostId, ownerKey)) { + out[`${hostId}:${login.protocol}`] = login; + } + } + return out; +} + +const EXPECTED = { + "1:rdp": { + protocol: "rdp", + authType: "direct", + credentialId: null, + username: "admin", + password: "rdp-pass", + fields: { domain: "CORP" }, + secretFields: {}, + }, + "2:vnc": { + protocol: "vnc", + authType: "credential", + credentialId: 5, + username: null, + password: null, + fields: {}, + secretFields: {}, + }, + "3:telnet": { + protocol: "telnet", + authType: "direct", + credentialId: null, + username: "tu", + password: "tel-plain", + fields: {}, + secretFields: {}, + }, + "4:rdp": { + protocol: "rdp", + authType: "direct", + credentialId: null, + username: "old-user", + password: "old-pass", + fields: { domain: "OLDDOM" }, + secretFields: {}, + }, + "5:rdp": { + protocol: "rdp", + authType: "none", + credentialId: null, + username: null, + password: null, + fields: {}, + secretFields: {}, + }, +}; + +describe("runProtocolAuthMigration", () => { + it("copies every 2.8 login shape, and a second run changes nothing", async () => { + const first = await runProtocolAuthMigration(); + expect(first).toEqual({ copied: 5, pendingUsers: 1 }); + expect(await logins()).toEqual(EXPECTED); + + const second = await runProtocolAuthMigration(); + expect(second).toEqual({ copied: 0, pendingUsers: 1 }); + expect(await logins()).toEqual(EXPECTED); + }); + + it("leaves the legacy columns in place", async () => { + await runProtocolAuthMigration(); + const [row] = await database!.query<{ rdp_user: string }>( + sql`SELECT rdp_user FROM ssh_data WHERE id = 1`, + ); + expect(row.rdp_user).toBe("admin"); + }); + + it("does not bring back a login the user removed", async () => { + await runProtocolAuthMigration(); + await createCurrentHostProtocolAuthRepository().delete(1, "rdp"); + + await runProtocolAuthMigration(); + + expect(await logins()).not.toHaveProperty("1:rdp"); + }); + + it("waits for a locked data key, then copies at that user's login", async () => { + await runProtocolAuthMigration(); + const repository = createCurrentHostProtocolAuthRepository(); + expect(await repository.listRowsForHost(7)).toEqual([]); + + const lockedKey = crypto.randomBytes(32); + state.keys.set("locked", lockedKey); + expect(await runProtocolAuthMigration("locked")).toEqual({ + copied: 1, + pendingUsers: 0, + }); + expect(await repository.find(7, "rdp", lockedKey)).toMatchObject({ + username: "waits", + }); + const [marker] = await database!.query<{ value: string }>( + sql`SELECT value FROM settings WHERE key = ${copiedMarker("locked")}`, + ); + expect(marker.value).toBe("done"); + }); + + it("skips a host that already has a login, as after a partial run", async () => { + await createCurrentHostProtocolAuthRepository().upsert( + "owner", + 1, + { + protocol: "rdp", + authType: "direct", + credentialId: null, + username: "edited", + password: null, + fields: {}, + secretFields: {}, + }, + ownerKey, + ); + + await runProtocolAuthMigration(); + + expect((await logins())["1:rdp"]).toMatchObject({ username: "edited" }); + expect(await logins()).toHaveProperty("3:telnet"); + }); +}); diff --git a/src/backend/tests/utils/shared-host-secrets-manager.test.ts b/src/backend/tests/utils/shared-host-secrets-manager.test.ts index 4785db786..d0a9c298e 100644 --- a/src/backend/tests/utils/shared-host-secrets-manager.test.ts +++ b/src/backend/tests/utils/shared-host-secrets-manager.test.ts @@ -12,9 +12,19 @@ const state = vi.hoisted(() => ({ accessToHost: new Map(), grants: [] as Array>, roleMembers: new Map(), + // hostId -> that host's plugin protocol logins, decrypted + logins: new Map>>(), +})); + +vi.mock("../../hosts/usable-credential.js", () => ({ + findUsableCredential: async (credentialId: number) => + state.credentials.get(credentialId) ?? null, })); vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentHostProtocolAuthRepository: () => ({ + listForHost: async (hostId: number) => state.logins.get(hostId) ?? [], + }), createCurrentHostResolutionRepository: () => ({ findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null, findHostOwnerId: async (hostId: number) => @@ -137,22 +147,20 @@ function baseHost(overrides: Record = {}) { credentialId: null, shareSshAuth: false, enableSsh: true, - enableRdp: false, - enableVnc: false, - enableTelnet: false, - rdpAuthType: null, - vncAuthType: null, - telnetAuthType: null, - rdpCredentialId: null, - vncCredentialId: null, - telnetCredentialId: null, - rdpUser: null, - rdpPassword: null, - rdpDomain: null, - vncUser: null, - vncPassword: null, - telnetUser: null, - telnetPassword: null, + ...overrides, + }; +} + +/** A plugin protocol login as the repository hands it back, decrypted. */ +function login(protocol: string, overrides: Record = {}) { + return { + protocol, + authType: "direct", + credentialId: null, + username: null, + password: null, + fields: {}, + secretFields: {}, ...overrides, }; } @@ -164,6 +172,7 @@ beforeEach(() => { state.accessToHost = new Map([[7, 42]]); state.grants = []; state.roleMembers.clear(); + state.logins.clear(); }); describe("SharedHostSecretsManager", () => { @@ -189,7 +198,7 @@ describe("SharedHostSecretsManager", () => { }); }); - it("snapshots opted-in SSH credential auth alongside enabled non-SSH protocols", async () => { + it("snapshots opted-in SSH credential auth alongside plugin protocol logins", async () => { state.credentials.set(123, { id: 123, userId: "owner", @@ -208,22 +217,24 @@ describe("SharedHostSecretsManager", () => { credentialId: 123, shareSshAuth: true, password: null, - enableRdp: true, - rdpUser: "rdp-admin", - rdpPassword: "rdp-pass", - rdpDomain: "CORP", - enableTelnet: true, - telnetUser: "tel-user", - telnetPassword: "tel-pass", }), ); + state.logins.set(42, [ + login("spice", { + username: "spice-admin", + password: "spice-pass", + fields: { display: "2" }, + secretFields: { ticket: "t-1" }, + }), + login("x2go", { username: "x-user", password: "x-pass" }), + ]); await manager.snapshotForUser(7, 42, "target", "owner"); expect(state.secretRows.map((row) => row.protocol).sort()).toEqual([ - "rdp", + "spice", "ssh", - "telnet", + "x2go", ]); expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({ @@ -234,21 +245,101 @@ describe("SharedHostSecretsManager", () => { keyType: "ssh-ed25519", }); - const rdp = await manager.getSecretForUser(42, "target", "rdp"); - expect(rdp).toMatchObject({ - username: "rdp-admin", - password: "rdp-pass", - domain: "CORP", + const spice = await manager.getSecretForUser(42, "target", "spice"); + expect(spice).toMatchObject({ + username: "spice-admin", + password: "spice-pass", + fields: { display: "2", ticket: "t-1" }, authType: "direct", }); - const telnet = await manager.getSecretForUser(42, "target", "telnet"); - expect(telnet).toMatchObject({ - username: "tel-user", - password: "tel-pass", + const x2go = await manager.getSecretForUser(42, "target", "x2go"); + expect(x2go).toMatchObject({ + username: "x-user", + password: "x-pass", }); }); + it("snapshots a credential-backed protocol login from the credential", async () => { + state.credentials.set(55, { + id: 55, + userId: "owner", + username: "cred-viewer", + password: "cred-pass", + }); + state.hosts.set(42, baseHost()); + state.logins.set(42, [ + login("spice", { + authType: "credential", + credentialId: 55, + fields: { display: "1" }, + }), + ]); + + await manager.snapshotForUser(7, 42, "target", "owner"); + + expect(state.secretRows[0]).toMatchObject({ + protocol: "spice", + sourceType: "credential", + originalCredentialId: 55, + }); + expect(await manager.getSecretForUser(42, "target", "spice")).toMatchObject( + { + authType: "credential", + username: "cred-viewer", + password: "cred-pass", + fields: { display: "1" }, + }, + ); + }); + + it("takes no snapshot of a login that asks at connect time", async () => { + state.hosts.set(42, baseHost()); + state.logins.set(42, [login("spice", { authType: "none" })]); + + await manager.snapshotForUser(7, 42, "target", "owner"); + + expect(state.secretRows).toHaveLength(0); + }); + + it("reads a snapshot taken before 2.9.0 with its domain column", async () => { + const recordId = "shared-7-target-legacy"; + state.secretRows.push({ + id: 1, + hostAccessId: 7, + targetUserId: "target", + protocol: "legacy", + encryptedUsername: FieldCrypto.encryptField( + "admin", + targetDEK, + recordId, + "username", + ), + encryptedAuthType: "direct", + encryptedDomain: FieldCrypto.encryptField( + "CORP", + targetDEK, + recordId, + "domain", + ), + }); + + expect( + await manager.getSecretForUser(42, "target", "legacy"), + ).toMatchObject({ username: "admin", fields: { domain: "CORP" } }); + }); + + it("drops a protocol's snapshot once its login is gone", async () => { + state.hosts.set(42, baseHost()); + state.logins.set(42, [login("spice", { username: "u", password: "p" })]); + await manager.snapshotForUser(7, 42, "target", "owner"); + expect(state.secretRows).toHaveLength(1); + + state.logins.set(42, []); + await manager.snapshotForUser(7, 42, "target", "owner"); + expect(state.secretRows).toHaveLength(0); + }); + it("produces no snapshot rows for secret-less auth types", async () => { state.hosts.set( 42, @@ -283,36 +374,54 @@ describe("SharedHostSecretsManager", () => { }); it("cannot be decrypted with the wrong DEK", async () => { - state.hosts.set( - 42, - baseHost({ - enableRdp: true, - rdpUser: "rdp-admin", - rdpPassword: "rdp-pass", + state.hosts.set(42, baseHost()); + state.logins.set(42, [ + login("spice", { + username: "spice-admin", + password: "spice-pass", + secretFields: { ticket: "t-1" }, }), - ); + ]); await manager.snapshotForUser(7, 42, "target", "owner"); const row = state.secretRows[0]; - expect(() => - FieldCrypto.decryptField( - row.encryptedPassword as string, - ownerDEK, - "shared-7-target-rdp", - "password", - ), - ).toThrow(); + for (const [column, field] of [ + ["encryptedPassword", "password"], + ["encryptedFields", "fields"], + ]) { + expect(() => + FieldCrypto.decryptField( + row[column] as string, + ownerDEK, + "shared-7-target-spice", + field, + ), + ).toThrow(); + } + }); + + it("refuses a tampered snapshot rather than handing it out", async () => { + state.hosts.set(42, baseHost()); + state.logins.set(42, [login("spice", { password: "spice-pass" })]); + await manager.snapshotForUser(7, 42, "target", "owner"); + + const row = state.secretRows[0]; + const envelope = JSON.parse(row.encryptedPassword as string); + envelope.data = envelope.data.replace(/^./, (c: string) => + c === "0" ? "1" : "0", + ); + row.encryptedPassword = JSON.stringify(envelope); + + await expect( + manager.getSecretForUser(42, "target", "spice"), + ).rejects.toThrow(); }); it("resyncHost re-snapshots direct grants and role members", async () => { - state.hosts.set( - 42, - baseHost({ - enableRdp: true, - rdpUser: "rdp-admin", - rdpPassword: "rdp-pass", - }), - ); + state.hosts.set(42, baseHost()); + state.logins.set(42, [ + login("spice", { username: "spice-admin", password: "spice-pass" }), + ]); state.accessToHost = new Map([ [1, 42], [2, 42], @@ -334,29 +443,20 @@ describe("SharedHostSecretsManager", () => { ]); // Owner rotates the non-SSH password; resync updates those copies. - state.hosts.set( - 42, - baseHost({ - enableRdp: true, - rdpUser: "rdp-admin", - rdpPassword: "rotated", - }), - ); + state.logins.set(42, [ + login("spice", { username: "spice-admin", password: "rotated" }), + ]); await manager.resyncHost(42); - const secret = await manager.getSecretForUser(42, "target", "rdp"); + const secret = await manager.getSecretForUser(42, "target", "spice"); expect(secret?.password).toBe("rotated"); }); it("snapshotForRoleMember fans out from role grants", async () => { - state.hosts.set( - 42, - baseHost({ - enableRdp: true, - rdpUser: "rdp-admin", - rdpPassword: "rdp-pass", - }), - ); + state.hosts.set(42, baseHost()); + state.logins.set(42, [ + login("spice", { username: "spice-admin", password: "spice-pass" }), + ]); state.accessToHost = new Map([[2, 42]]); state.grants = [{ id: 2, hostId: 42, userId: null, roleId: 9 }]; @@ -366,7 +466,7 @@ describe("SharedHostSecretsManager", () => { expect(state.secretRows[0]).toMatchObject({ hostAccessId: 2, targetUserId: "member-1", - protocol: "rdp", + protocol: "spice", }); }); }); diff --git a/src/backend/upgrade/protocol-auth-migration.ts b/src/backend/upgrade/protocol-auth-migration.ts new file mode 100644 index 000000000..8e73ef288 --- /dev/null +++ b/src/backend/upgrade/protocol-auth-migration.ts @@ -0,0 +1,241 @@ +/** + * Copies each host's 2.8 RDP, VNC and Telnet logins out of the ssh_data + * columns into host_protocol_auth, re-encrypted with the owner's data key + * under the new table's context. + * + * The columns stay in the database, unused, until 3.0.0. A user's hosts are + * copied once their data key opens (at boot for a system-wrapped key, at the + * next password login for a 2.8 key that only the password opens), and a + * per-user marker then stops a later boot from bringing back a login the + * user removed. A host that already has a login for a protocol is skipped, + * so a run that stopped half way is safe to repeat. + * + * Very old installs kept a remote desktop host's login in username, + * password and domain; 2.8 copied those into the protocol columns on every + * boot, and this does the same for a host whose protocol columns are empty. + */ + +import { sql } from "drizzle-orm"; +import { databaseLogger } from "../utils/logger.js"; +import { DataCrypto } from "../utils/data-crypto.js"; +import { FieldCrypto } from "../utils/field-crypto.js"; +import { + createCurrentHostProtocolAuthRepository, + createCurrentSettingsRepository, +} from "../database/repositories/factory.js"; +import type { HostProtocolLogin } from "../database/repositories/host-protocol-auth-repository.js"; +import { + selectLegacyRows, + selectRows, +} from "../utils/crypto-migration/raw-rows.js"; + +interface LegacyProtocol { + id: string; + user: string; + password: string; + passwordField: string; + credential: string; + authType: string; + domain?: string; +} + +const PROTOCOLS: LegacyProtocol[] = [ + { + id: "rdp", + user: "rdp_user", + password: "rdp_password", + passwordField: "rdpPassword", + credential: "rdp_credential_id", + authType: "rdp_auth_type", + domain: "rdp_domain", + }, + { + id: "vnc", + user: "vnc_user", + password: "vnc_password", + passwordField: "vncPassword", + credential: "vnc_credential_id", + authType: "vnc_auth_type", + }, + { + id: "telnet", + user: "telnet_user", + password: "telnet_password", + passwordField: "telnetPassword", + credential: "telnet_credential_id", + authType: "telnet_auth_type", + }, +]; + +const AUTH_TYPES = new Set(["direct", "credential", "none"]); + +export function copiedMarker(userId: string): string { + return `protocol_auth_copied_v1:${userId}`; +} + +type LegacyRow = Record & { id: number; user_id: string }; + +function text(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +/** + * A 2.8 secret column read raw: plaintext, or an envelope written under the + * field's own name, its column name, or "password" when 2.8 copied the + * host's password blob into it. + */ +export function openLegacySecret( + value: unknown, + dek: Buffer, + recordId: string, + fieldNames: string[], +): string | null { + const raw = typeof value === "string" ? value : ""; + if (!raw) return null; + if (!FieldCrypto.isEncrypted(raw)) return raw; + for (const fieldName of fieldNames) { + try { + return FieldCrypto.decryptField(raw, dek, recordId, fieldName); + } catch { + // Try the next name. + } + } + return null; +} + +/** The login a 2.8 host row held for one protocol, or null for none. */ +export function legacyLogin( + row: LegacyRow, + protocol: LegacyProtocol, + dek: Buffer, + credentialExists: (id: number) => boolean, +): HostProtocolLogin | null { + const recordId = String(row.id); + const storedType = text(row[protocol.authType]); + const credentialValue = Number(row[protocol.credential]); + const credentialId = + Number.isInteger(credentialValue) && + credentialValue > 0 && + credentialExists(credentialValue) + ? credentialValue + : null; + let username = text(row[protocol.user]); + let password = openLegacySecret(row[protocol.password], dek, recordId, [ + protocol.passwordField, + protocol.password, + "password", + ]); + let domain = protocol.domain ? text(row[protocol.domain]) : ""; + const hasOwn = + !!storedType || + !!row[protocol.credential] || + !!username || + !!row[protocol.password] || + !!domain; + + if (!hasOwn) { + if (text(row.connection_type) !== protocol.id) return null; + username = text(row.username); + password = openLegacySecret(row.password, dek, recordId, ["password"]); + domain = protocol.domain ? text(row.domain) : ""; + if (!username && !password && !domain) return null; + } else if (protocol.domain && !domain) { + domain = text(row.domain); + } + + const authType = AUTH_TYPES.has(storedType) + ? storedType + : credentialId + ? "credential" + : "direct"; + const direct = authType === "direct"; + return { + protocol: protocol.id, + authType, + credentialId: authType === "credential" ? credentialId : null, + username: direct ? username || null : null, + password: direct ? password : null, + fields: domain ? { domain } : {}, + secretFields: {}, + }; +} + +export interface ProtocolAuthMigrationResult { + copied: number; + pendingUsers: number; +} + +/** Copies every user's logins, or only `userId`'s after a login opened their key. */ +export async function runProtocolAuthMigration( + userId?: string, +): Promise { + const result: ProtocolAuthMigrationResult = { copied: 0, pendingUsers: 0 }; + const rows = await selectLegacyRows(sql` + SELECT id, user_id, connection_type, username, password, domain, + rdp_user, rdp_password, rdp_domain, rdp_credential_id, rdp_auth_type, + vnc_user, vnc_password, vnc_credential_id, vnc_auth_type, + telnet_user, telnet_password, telnet_credential_id, telnet_auth_type + FROM ssh_data + `); + if (rows.length === 0) return result; + + let credentialIds: Set; + try { + credentialIds = new Set( + ( + await selectRows<{ id: number | string }>( + sql`SELECT id FROM ssh_credentials`, + ) + ).map((row) => Number(row.id)), + ); + } catch { + return result; + } + const settings = createCurrentSettingsRepository(); + const repository = createCurrentHostProtocolAuthRepository(); + + const byUser = new Map(); + for (const row of rows) { + const owner = String(row.user_id); + if (userId && owner !== userId) continue; + byUser.set(owner, [...(byUser.get(owner) ?? []), row]); + } + + for (const [owner, hosts] of byUser) { + try { + if ((await settings.get(copiedMarker(owner))) === "done") continue; + const dek = DataCrypto.getUserDataKey(owner); + if (!dek) { + result.pendingUsers++; + continue; + } + for (const host of hosts) { + const hostId = Number(host.id); + for (const protocol of PROTOCOLS) { + const login = legacyLogin(host, protocol, dek, (id) => + credentialIds.has(id), + ); + if (!login) continue; + if (await repository.findRow(hostId, protocol.id)) continue; + await repository.upsert(owner, hostId, login, dek); + result.copied++; + } + } + await settings.set(copiedMarker(owner), "done"); + } catch (error) { + databaseLogger.warn("Protocol login copy failed for a user", { + operation: "protocol_auth_migration", + userId: owner, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (result.copied > 0) { + databaseLogger.info( + `Moved ${result.copied} remote desktop login(s) into host_protocol_auth`, + { operation: "protocol_auth_migration" }, + ); + } + return result; +} diff --git a/src/backend/utils/field-crypto.ts b/src/backend/utils/field-crypto.ts index fea0a06dc..98ef7d942 100644 --- a/src/backend/utils/field-crypto.ts +++ b/src/backend/utils/field-crypto.ts @@ -25,9 +25,6 @@ class FieldCrypto { "autostartKey", "autostartKeyPassword", "socks5Password", - "rdpPassword", - "vncPassword", - "telnetPassword", ]), ssh_credentials: new Set([ "password", diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts index 2aef744b3..7f4d5f94e 100644 --- a/src/backend/utils/lazy-field-encryption.ts +++ b/src/backend/utils/lazy-field-encryption.ts @@ -11,9 +11,6 @@ export class LazyFieldEncryption { autostart_key: "autostartKey", autostart_key_password: "autostartKeyPassword", socks5_password: "socks5Password", - rdp_password: "rdpPassword", - vnc_password: "vncPassword", - telnet_password: "telnetPassword", private_key: "privateKey", public_key: "publicKey", password_hash: "passwordHash", @@ -28,9 +25,6 @@ export class LazyFieldEncryption { autostartKey: "autostart_key", autostartKeyPassword: "autostart_key_password", socks5Password: "socks5_password", - rdpPassword: "rdp_password", - vncPassword: "vnc_password", - telnetPassword: "telnet_password", privateKey: "private_key", publicKey: "public_key", passwordHash: "password_hash", @@ -97,27 +91,6 @@ export class LazyFieldEncryption { } } - // Guac hosts migrated from single-protocol: rdpPassword/vncPassword/telnetPassword - // columns were populated by copying the encrypted `password` blob. Try decrypting - // under the original field name before giving up. - if ( - fieldName === "rdpPassword" || - fieldName === "vncPassword" || - fieldName === "telnetPassword" - ) { - try { - const decrypted = FieldCrypto.decryptField( - fieldValue, - userKEK, - recordId, - "password", - ); - return decrypted; - } catch { - // not encrypted as "password" either - } - } - const sensitiveFields = [ "totpSecret", "totpBackupCodes", @@ -129,9 +102,6 @@ export class LazyFieldEncryption { "autostartKey", "autostartKeyPassword", "socks5Password", - "rdpPassword", - "vncPassword", - "telnetPassword", "privateKey", "publicKey", "clientSecret", @@ -283,9 +253,6 @@ export class LazyFieldEncryption { autostartKey: "autostart_key", autostartKeyPassword: "autostart_key_password", socks5Password: "socks5_password", - rdpPassword: "rdp_password", - vncPassword: "vnc_password", - telnetPassword: "telnet_password", clientSecret: "client_secret", oidcIdentifier: "oidc_identifier", }; @@ -301,9 +268,6 @@ export class LazyFieldEncryption { "autostartKey", "autostartKeyPassword", "socks5Password", - "rdpPassword", - "vncPassword", - "telnetPassword", ], ssh_credentials: [ "password", diff --git a/src/backend/utils/shared-host-auth-override-service.ts b/src/backend/utils/shared-host-auth-override-service.ts index 532685f5d..1d7caf97a 100644 --- a/src/backend/utils/shared-host-auth-override-service.ts +++ b/src/backend/utils/shared-host-auth-override-service.ts @@ -1,8 +1,5 @@ -import { - AUTH_PROTOCOL_METADATA, - isSupportedAuthOverrideProtocol, - type AuthOverrideProtocol, -} from "../../types/auth-protocols.js"; +import type { AuthOverrideProtocol } from "../../types/auth-protocols.js"; +import { isAuthOverrideProtocol } from "../hosts/protocol-auth/registry.js"; import { createCurrentCredentialRepository, createCurrentSharedHostAuthOverrideRepository, @@ -109,9 +106,9 @@ export class SharedHostAuthOverrideService { } private requireSupportedProtocol(protocol: AuthOverrideProtocol): void { - if (!isSupportedAuthOverrideProtocol(protocol)) { + if (!isAuthOverrideProtocol(protocol)) { throw new SharedHostAuthOverrideServiceError( - `${AUTH_PROTOCOL_METADATA[protocol].label} authentication overrides are not supported yet`, + `No plugin declares the ${protocol} protocol`, 400, ); } diff --git a/src/backend/utils/shared-host-auth-resolver.ts b/src/backend/utils/shared-host-auth-resolver.ts index 8182fed99..34d3b22ce 100644 --- a/src/backend/utils/shared-host-auth-resolver.ts +++ b/src/backend/utils/shared-host-auth-resolver.ts @@ -1,7 +1,5 @@ -import { - isSupportedAuthOverrideProtocol, - type AuthOverrideProtocol, -} from "../../types/auth-protocols.js"; +import type { AuthOverrideProtocol } from "../../types/auth-protocols.js"; +import { isAuthOverrideProtocol } from "../hosts/protocol-auth/registry.js"; import { createCurrentHostResolutionRepository, createCurrentRbacAccessRepository, @@ -37,8 +35,8 @@ export function requiresPersonalHostAuthentication( host: Pick, protocol: AuthOverrideProtocol, ): boolean { - // Owner auth for RDP/VNC/Telnet is snapshotted for every recipient, so only - // SSH, which sits behind shareSshAuth, can leave a recipient without auth. + // Owner auth for a plugin protocol is snapshotted for every recipient, so + // only SSH, which sits behind shareSshAuth, can leave one without auth. if (protocol !== "ssh") return false; if (host.credentialId) return true; ensureCoreSshAuthProviders(); @@ -64,10 +62,8 @@ export async function resolveRecipientSharedHostAuthentication( userId: string, protocol: AuthOverrideProtocol, ): Promise { - if (!isSupportedAuthOverrideProtocol(protocol)) { - throw new Error( - `${protocol.toUpperCase()} shared-host authentication is not implemented`, - ); + if (!isAuthOverrideProtocol(protocol)) { + throw new Error(`No plugin declares the ${String(protocol)} protocol`); } const repository = createCurrentHostResolutionRepository(); diff --git a/src/backend/utils/shared-host-secrets-manager.ts b/src/backend/utils/shared-host-secrets-manager.ts index ce3891476..2ede1ed68 100644 --- a/src/backend/utils/shared-host-secrets-manager.ts +++ b/src/backend/utils/shared-host-secrets-manager.ts @@ -13,6 +13,8 @@ import type { HostResolutionHostRecord } from "../database/repositories/host-res import { DataCrypto } from "./data-crypto.js"; import { FieldCrypto } from "./field-crypto.js"; import { databaseLogger } from "./logger.js"; +import { findUsableCredential } from "../hosts/usable-credential.js"; +import { listProtocolLogins } from "../hosts/protocol-auth/protocol-auth.js"; export interface SharedSecretData { username?: string; @@ -22,6 +24,8 @@ export interface SharedSecretData { keyPassword?: string; keyType?: string; domain?: string; + /** A plugin protocol's declared credential fields. */ + fields?: Record; } interface ProtocolSnapshot { @@ -39,25 +43,38 @@ function snapshotRecordId( return `shared-${hostAccessId}-${targetUserId}-${protocol}`; } -// SSH follows its own switch. The remote desktop protocols' switches are the -// remote-desktop plugin's host settings, and the host editor clears a -// protocol's login when it is switched off, so a snapshot is taken whenever -// login data is present (see collectProtocolSnapshots). -function enabledProtocols( - host: HostResolutionHostRecord, -): Record { +// SSH follows its own switch. A plugin protocol's switch is that plugin's +// host setting, and the editor removes a protocol's login when it is +// switched off, so a snapshot is taken whenever a login is stored. +function sshEnabled(host: HostResolutionHostRecord): boolean { const ct = host.connectionType; - return { - ssh: host.enableSsh !== false && (!ct || ct === "ssh"), - rdp: true, - vnc: true, - telnet: true, - }; + return host.enableSsh !== false && (!ct || ct === "ssh"); } // Per-recipient copies of connection secrets, re-encrypted under the // recipient's DEK. SSH authentication is copied only when the host owner // explicitly opts in; recipient-owned credential overrides remain separate. +/** + * A snapshot's protocol fields. Snapshots taken before 2.9.0 kept a domain + * in its own column and nothing else. + */ +function parseFields( + value: string | undefined, + legacyDomain: string | undefined, +): Record | undefined { + if (value) { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Unreadable fields are left out. + } + } + return legacyDomain ? { domain: legacyDomain } : undefined; +} + class SharedHostSecretsManager { private static instance: SharedHostSecretsManager; @@ -120,6 +137,10 @@ class SharedHostSecretsManager { ), encryptedKeyType: snapshot.data.keyType || null, encryptedDomain: encrypt(snapshot.data.domain, "domain"), + encryptedFields: encrypt( + snapshot.data.fields ? JSON.stringify(snapshot.data.fields) : "", + "fields", + ), }); } @@ -361,10 +382,9 @@ class SharedHostSecretsManager { ownerId: string, ): Promise { const repository = createCurrentHostResolutionRepository(); - const enabled = enabledProtocols(host); const snapshots: ProtocolSnapshot[] = []; - if (enabled.ssh && host.shareSshAuth) { + if (sshEnabled(host) && host.shareSshAuth) { if (host.credentialId) { const credential = await repository.findCredentialByIdForUser( host.credentialId, @@ -405,106 +425,40 @@ class SharedHostSecretsManager { } } - if (enabled.rdp) { - const rdpAuthType = - host.rdpAuthType || (host.rdpCredentialId ? "credential" : "direct"); - if (rdpAuthType === "credential" && host.rdpCredentialId) { - const credential = await repository.findCredentialByIdForUser( - host.rdpCredentialId, + for (const login of await listProtocolLogins(host.id, ownerId)) { + const fields = { ...login.fields, ...login.secretFields }; + const hasFields = Object.keys(fields).length > 0; + if (login.authType === "credential" && login.credentialId) { + const credential = await findUsableCredential( + login.credentialId, ownerId, ); if (credential) { snapshots.push({ - protocol: "rdp", + protocol: login.protocol, sourceType: "credential", - originalCredentialId: host.rdpCredentialId, + originalCredentialId: login.credentialId, data: { username: credential.username || undefined, authType: "credential", password: credential.password || undefined, - domain: host.rdpDomain || undefined, + fields: hasFields ? fields : undefined, }, }); } - } else if (host.rdpUser || host.rdpPassword) { + } else if ( + login.authType === "direct" && + (login.username || login.password || hasFields) + ) { snapshots.push({ - protocol: "rdp", + protocol: login.protocol, sourceType: "inline", originalCredentialId: null, data: { - username: host.rdpUser || undefined, + username: login.username || undefined, authType: "direct", - password: host.rdpPassword || undefined, - domain: host.rdpDomain || undefined, - }, - }); - } - } - - if (enabled.vnc) { - const vncAuthType = - host.vncAuthType || (host.vncCredentialId ? "credential" : "direct"); - if (vncAuthType === "credential" && host.vncCredentialId) { - const credential = await repository.findCredentialByIdForUser( - host.vncCredentialId, - ownerId, - ); - if (credential) { - snapshots.push({ - protocol: "vnc", - sourceType: "credential", - originalCredentialId: host.vncCredentialId, - data: { - username: credential.username || undefined, - authType: "credential", - password: credential.password || undefined, - }, - }); - } - } else if (host.vncUser || host.vncPassword) { - snapshots.push({ - protocol: "vnc", - sourceType: "inline", - originalCredentialId: null, - data: { - username: host.vncUser || undefined, - authType: "direct", - password: host.vncPassword || undefined, - }, - }); - } - } - - if (enabled.telnet) { - const telnetAuthType = - host.telnetAuthType || - (host.telnetCredentialId ? "credential" : "direct"); - if (telnetAuthType === "credential" && host.telnetCredentialId) { - const credential = await repository.findCredentialByIdForUser( - host.telnetCredentialId, - ownerId, - ); - if (credential) { - snapshots.push({ - protocol: "telnet", - sourceType: "credential", - originalCredentialId: host.telnetCredentialId, - data: { - username: credential.username || undefined, - authType: "credential", - password: credential.password || undefined, - }, - }); - } - } else if (host.telnetUser || host.telnetPassword) { - snapshots.push({ - protocol: "telnet", - sourceType: "inline", - originalCredentialId: null, - data: { - username: host.telnetUser || undefined, - authType: "direct", - password: host.telnetPassword || undefined, + password: login.password || undefined, + fields: hasFields ? fields : undefined, }, }); } @@ -527,6 +481,7 @@ class SharedHostSecretsManager { ? FieldCrypto.decryptField(value, userDEK, recordId, fieldName) : undefined; + const domain = decrypt(secret.encryptedDomain, "domain"); return { username: decrypt(secret.encryptedUsername, "username"), authType: secret.encryptedAuthType || "password", @@ -534,7 +489,8 @@ class SharedHostSecretsManager { key: decrypt(secret.encryptedKey, "key"), keyPassword: decrypt(secret.encryptedKeyPassword, "key_password"), keyType: secret.encryptedKeyType || undefined, - domain: decrypt(secret.encryptedDomain, "domain"), + domain, + fields: parseFields(decrypt(secret.encryptedFields, "fields"), domain), }; } } diff --git a/src/backend/utils/swagger.ts b/src/backend/utils/swagger.ts index 13b26576a..38ae8cc66 100644 --- a/src/backend/utils/swagger.ts +++ b/src/backend/utils/swagger.ts @@ -121,7 +121,7 @@ const swaggerOptions: SwaggerJSDocOptions = { }, { name: "Guacamole", - description: "RDP, VNC, and Telnet remote desktop sessions", + description: "Remote desktop sessions", }, { name: "Proxmox", diff --git a/src/backend/utils/user-data-export.ts b/src/backend/utils/user-data-export.ts index 80b8b8a19..252716cc7 100644 --- a/src/backend/utils/user-data-export.ts +++ b/src/backend/utils/user-data-export.ts @@ -1,7 +1,10 @@ import { + createCurrentHostProtocolAuthRepository, createCurrentUserDataExportRepository, createCurrentUserRepository, } from "../database/repositories/factory.js"; +import { decryptProtocolLogin } from "../database/repositories/host-protocol-auth-repository.js"; +import { toPortableLogins } from "../hosts/protocol-auth/protocol-auth.js"; import { readUserPluginRows } from "../plugins/user-data.js"; import { DataCrypto } from "./data-crypto.js"; import { databaseLogger } from "./logger.js"; @@ -59,12 +62,28 @@ class UserDataExport { const exportRepository = createCurrentUserDataExportRepository(); const sshHosts = await exportRepository.listHostsByUserId(userId); - const processedSshHosts = + // Each host's plugin protocol logins ride on it, sealed like the + // host's own secrets unless the export is plaintext. + const logins = new Map>(); + for (const row of await createCurrentHostProtocolAuthRepository().listRowsForUser( + userId, + )) { + const own = logins.get(row.hostId) ?? {}; + own[row.protocol] = + format === "plaintext" && userDataKey + ? toPortableLogins([decryptProtocolLogin(row, userDataKey)])[ + row.protocol + ] + : row; + logins.set(row.hostId, own); + } + const processedSshHosts = ( format === "plaintext" && userDataKey ? sshHosts.map((host) => DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!), ) - : sshHosts; + : sshHosts + ).map((host) => ({ ...host, protocolAuth: logins.get(host.id) ?? {} })); let sshCredentialsData: unknown[] = []; if (includeCredentials) { diff --git a/src/types/auth-protocols.ts b/src/types/auth-protocols.ts index 12879b638..5983c5a65 100644 --- a/src/types/auth-protocols.ts +++ b/src/types/auth-protocols.ts @@ -1,55 +1,8 @@ -export const AUTH_OVERRIDE_PROTOCOLS = ["ssh", "rdp", "vnc", "telnet"] as const; +/** Core's own protocol. Every other one is declared by a plugin. */ +export const SSH_AUTH_PROTOCOL = "ssh"; -export type AuthOverrideProtocol = (typeof AUTH_OVERRIDE_PROTOCOLS)[number]; - -const SUPPORTED_AUTH_OVERRIDE_PROTOCOLS = - AUTH_OVERRIDE_PROTOCOLS satisfies readonly AuthOverrideProtocol[]; - -export const AUTH_PROTOCOL_METADATA = { - ssh: { - label: "SSH", - credentialField: "credentialId", - }, - rdp: { - label: "RDP", - credentialField: "rdpCredentialId", - }, - vnc: { - label: "VNC", - credentialField: "vncCredentialId", - }, - telnet: { - label: "Telnet", - credentialField: "telnetCredentialId", - }, -} as const satisfies Record< - AuthOverrideProtocol, - { - label: string; - credentialField: - | "credentialId" - | "rdpCredentialId" - | "vncCredentialId" - | "telnetCredentialId"; - } ->; - -export function isAuthOverrideProtocol( - value: unknown, -): value is AuthOverrideProtocol { - return ( - typeof value === "string" && - AUTH_OVERRIDE_PROTOCOLS.includes(value as AuthOverrideProtocol) - ); -} - -export function isSupportedAuthOverrideProtocol( - protocol: AuthOverrideProtocol, -): boolean { - return SUPPORTED_AUTH_OVERRIDE_PROTOCOLS.includes( - protocol as (typeof SUPPORTED_AUTH_OVERRIDE_PROTOCOLS)[number], - ); -} +/** "ssh", or a protocol id a plugin declares in contributes.protocols. */ +export type AuthOverrideProtocol = string; export interface HostAuthOverrideState< CredentialId extends number | string = number, diff --git a/src/types/index.ts b/src/types/index.ts index a8bd66a08..a5e729d25 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,8 @@ -import type { HostSshOptions } from "@termix/plugin-sdk/frontend"; +import type { + HostProtocolAuthSummary, + HostProtocolAuthType, + HostSshOptions, +} from "@termix/plugin-sdk/frontend"; import type { Request } from "express"; import type { RefObject } from "react"; import type { HostAuthOverrides } from "./auth-protocols.js"; @@ -15,6 +19,19 @@ export type { export type SSHAuthType = "password" | "key" | "credential" | "none" | "agent" | (string & {}); +export type { HostProtocolAuthSummary, HostProtocolAuthType }; + +/** One plugin protocol's login in a host write. */ +export interface HostProtocolAuthInput { + authType?: HostProtocolAuthType; + credentialId?: number | null; + username?: string | null; + /** Left out to keep the saved password. */ + password?: string | null; + /** Declared credential fields; one left out keeps its value. */ + fields?: Record; +} + export type WebEndpointAccess = "direct" | "tunnel"; export type WebEndpointRender = "external" | "embedded"; @@ -109,23 +126,11 @@ export type Host = { /** "ssh", or the id of the plugin protocol a host without SSH uses. */ connectionType?: string; - domain?: string; enableSsh?: boolean; sshPort?: number; - rdpCredentialId?: number | null; - rdpUser?: string; - rdpPassword?: string; - rdpDomain?: string; - vncCredentialId?: number | null; - vncPassword?: string; - vncUser?: string; - telnetUser?: string; - telnetPassword?: string; - telnetCredentialId?: number | null; - rdpAuthType?: "direct" | "credential" | "none" | null; - vncAuthType?: "direct" | "credential" | null; - telnetAuthType?: "direct" | "credential" | null; + /** Each plugin protocol's login, secrets left out. */ + protocolAuth?: Record; /** * Stable identity across a desktop/server sync pair. `id` is an * autoincrement local to whichever database produced the row, so it cannot @@ -148,9 +153,6 @@ export type Host = { // tell a stored secret from an empty one without receiving it. hasPassword?: boolean; hasSudoPassword?: boolean; - hasRdpPassword?: boolean; - hasVncPassword?: boolean; - hasTelnetPassword?: boolean; isShared?: boolean; authOverrides?: HostAuthOverrides; @@ -228,23 +230,14 @@ export interface HostData { /** "ssh", or the id of the plugin protocol a host without SSH uses. */ connectionType?: string; - domain?: string; enableSsh?: boolean; sshPort?: number; - rdpCredentialId?: number | null; - rdpUser?: string; - rdpPassword?: string; - rdpDomain?: string; - vncCredentialId?: number | null; - vncPassword?: string; - vncUser?: string; - telnetUser?: string; - telnetPassword?: string; - telnetCredentialId?: number | null; - rdpAuthType?: "direct" | "credential" | "none" | null; - vncAuthType?: "direct" | "credential" | null; - telnetAuthType?: "direct" | "credential" | null; + /** + * Plugin protocol logins to write, keyed by protocol id. A key left out + * keeps its login, null removes it, and a field left out keeps its value. + */ + protocolAuth?: Record; /** Desktop only: kept on this device, never synced to the server. */ localOnly?: boolean; } diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index b56ebc11b..970f8294b 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -1,9 +1,11 @@ import type { + HostProtocolAuthSummary, HostSshOptions, HostTerminalConfig, SSHAuthType, } from "./index.js"; import type { HostAuthOverrides } from "./auth-protocols.js"; +import type { QuickConnectLogin } from "@termix/plugin-sdk/frontend"; export type Host = { id: string; @@ -44,6 +46,8 @@ export type Host = { pin?: boolean; /** Quick connect only: core can save this host as-is. */ quickConnectSavable?: boolean; + /** A Quick Connect host's plugin protocol login, never saved. */ + quickConnectLogin?: QuickConnectLogin; sortOrder?: number | null; /** Stable identity across a desktop/server sync pair. */ @@ -80,24 +84,8 @@ export type Host = { sshPort: number; - rdpAuthType?: "direct" | "credential" | "none"; - rdpCredentialId?: string; - rdpUser?: string; - rdpPassword?: string; - hasRdpPassword?: boolean; - domain?: string; - - vncAuthType?: "direct" | "credential"; - vncCredentialId?: string; - vncPassword?: string; - hasVncPassword?: boolean; - vncUser?: string; - - telnetAuthType?: "direct" | "credential"; - telnetCredentialId?: string; - telnetUser?: string; - telnetPassword?: string; - hasTelnetPassword?: boolean; + /** Each plugin protocol's login, secrets left out. */ + protocolAuth?: Record; /** Host-scope plugin settings, keyed by plugin id. Secrets are redacted. */ pluginSettings?: Record>; diff --git a/src/ui/api/admin-user-data-api.ts b/src/ui/api/admin-user-data-api.ts index aa6b7fecb..586543362 100644 --- a/src/ui/api/admin-user-data-api.ts +++ b/src/ui/api/admin-user-data-api.ts @@ -99,14 +99,7 @@ export async function adminDeleteUserHost( export async function adminGetHostPassword( targetUserId: string, hostId: number, - field: - | "password" - | "sudoPassword" - | "rdpPassword" - | "vncPassword" - | "telnetPassword" - | "key" - | "keyPassword" = "password", + field: "password" | "sudoPassword" | "key" | "keyPassword" = "password", ): Promise { try { const response = await sshHostApi.get( diff --git a/src/ui/api/credentials-api.ts b/src/ui/api/credentials-api.ts index e5cfdb237..9f1d711d7 100644 --- a/src/ui/api/credentials-api.ts +++ b/src/ui/api/credentials-api.ts @@ -116,14 +116,7 @@ export async function getSSHHostWithCredentials( export async function getHostPassword( hostId: number, - field: - | "password" - | "sudoPassword" - | "rdpPassword" - | "vncPassword" - | "telnetPassword" - | "key" - | "keyPassword" = "password", + field: "password" | "sudoPassword" | "key" | "keyPassword" = "password", ): Promise { try { const response = await sshHostApi.get( diff --git a/src/ui/lib/connection-origin.ts b/src/ui/lib/connection-origin.ts index 86a534530..7b567a476 100644 --- a/src/ui/lib/connection-origin.ts +++ b/src/ui/lib/connection-origin.ts @@ -10,7 +10,7 @@ interface OriginResolvableHost { /** * Resolves which backend a given host's interactive connection (SSH, Docker - * console, RDP/VNC/Telnet) should dial: the desktop app's embedded local + * console, remote desktop) should dial: the desktop app's embedded local * backend, or the server it is linked to. A plugin whose connection is * always local regardless of this setting (serial: the hardware is * physically attached to this desktop machine) skips this helper entirely diff --git a/src/ui/lib/host-connection-tabs.ts b/src/ui/lib/host-connection-tabs.ts index b2dda94d9..da4d70366 100644 --- a/src/ui/lib/host-connection-tabs.ts +++ b/src/ui/lib/host-connection-tabs.ts @@ -6,8 +6,8 @@ import { } from "@/sidebar/host-contributions"; /** - * Which tab a click on a host opens. The ways to connect (SSH terminal, RDP, - * VNC, Telnet) are host actions that plugins register, so the answer is the + * Which tab a click on a host opens. The ways to connect (the SSH terminal, + * remote desktop) are host actions that plugins register, so the answer is the * highest-priority connect action the host allows, or null when no running * plugin can connect to it. */ diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 7f7e3b7cd..9cc047886 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -664,7 +664,7 @@ export function getRemoteCoreApi(): AxiosInstance { } function initializeApiInstances() { - // Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet + // Host Management API (port 30001) - SSH and plugin protocols hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST"); sshHostApi = hostApi; @@ -675,7 +675,7 @@ function initializeApiInstances() { rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC"); } -// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet +// Host Management API (port 30001) - SSH and plugin protocols export let hostApi: AxiosInstance; // Backward compatibility export let sshHostApi: AxiosInstance; diff --git a/src/ui/sidebar/HostAuthOverrideModal.tsx b/src/ui/sidebar/HostAuthOverrideModal.tsx index c6fefef2b..4f07444c5 100644 --- a/src/ui/sidebar/HostAuthOverrideModal.tsx +++ b/src/ui/sidebar/HostAuthOverrideModal.tsx @@ -17,9 +17,10 @@ import { } from "@/main-axios"; import type { Credential, Host } from "@/types/ui-types"; import { - AUTH_PROTOCOL_METADATA, + SSH_AUTH_PROTOCOL, type AuthOverrideProtocol, } from "@/types/auth-protocols"; +import { authProtocolLabel } from "./host-permissions"; import { mapCredentials } from "./HostManagerData"; import { getConnectedRemoteApi } from "@/lib/remote-server-api"; @@ -44,7 +45,7 @@ export function HostAuthOverrideModal({ const overrideState = host.authOverrides?.[protocol]; const ownerAuthShared = overrideState?.ownerAuthShared ?? - (protocol === "ssh" ? !!host.shareSshAuth : false); + (protocol === SSH_AUTH_PROTOCOL ? !!host.shareSshAuth : false); // A shared host's copy on a linked desktop: its override lives on the // server, against the server's own credentials. const sharedCopySyncId = host.sharedCopy ? (host.syncId ?? null) : null; @@ -127,7 +128,7 @@ export function HostAuthOverrideModal({ {t("hosts.sharing.authOverrideTitleProtocol", { - protocol: AUTH_PROTOCOL_METADATA[protocol].label, + protocol: authProtocolLabel(protocol, t), })} diff --git a/src/ui/sidebar/HostEditor.tsx b/src/ui/sidebar/HostEditor.tsx index 89b716daf..5eefe05be 100644 --- a/src/ui/sidebar/HostEditor.tsx +++ b/src/ui/sidebar/HostEditor.tsx @@ -145,25 +145,6 @@ export function HostEditor({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [host]); - useEffect(() => { - if (!host?.id || form.vncAuthType !== "direct" || form.vncPassword) return; - - let cancelled = false; - const loadVncPassword = adminTargetUserId - ? adminGetHostPassword(adminTargetUserId, Number(host.id), "vncPassword") - : getHostPassword(Number(host.id), "vncPassword"); - loadVncPassword.then((password) => { - if (cancelled || !password) return; - setForm((prev) => - prev.vncPassword ? prev : { ...prev, vncPassword: password }, - ); - }); - - return () => { - cancelled = true; - }; - }, [form.vncAuthType, form.vncPassword, host?.id, adminTargetUserId]); - /** * Writes each plugin's host-scope values through its own route. * diff --git a/src/ui/sidebar/HostEditorData.ts b/src/ui/sidebar/HostEditorData.ts index 026401431..ee76d9fe3 100644 --- a/src/ui/sidebar/HostEditorData.ts +++ b/src/ui/sidebar/HostEditorData.ts @@ -1,6 +1,11 @@ import type { Host } from "@/types/ui-types"; -import type { SSHHostData } from "@/types"; -import type { HostDraft } from "@termix/plugin-sdk/frontend"; +import type { HostProtocolAuthInput, SSHHostData } from "@/types"; +import { + HOST_PROTOCOL_SECRET_KEPT, + type HostDraft, + type HostProtocolAuthForm, + type HostProtocolAuthSummary, +} from "@termix/plugin-sdk/frontend"; import type { HostDefaults } from "@/api/settings-api"; import { listHostProtocols, @@ -44,6 +49,66 @@ export function applyHostDraft( }; } +/** A saved login as the editor form holds it; saved secrets stay behind a placeholder. */ +export function protocolAuthForm( + summaries: Record | undefined, +): Record { + const form: Record = {}; + for (const [protocol, summary] of Object.entries(summaries ?? {})) { + const fields: Record = { ...(summary.fields ?? {}) }; + for (const key of summary.secretFieldKeys ?? []) { + fields[key] = HOST_PROTOCOL_SECRET_KEPT; + } + form[protocol] = { + authType: + summary.authType ?? (summary.credentialId ? "credential" : "direct"), + credentialId: + summary.credentialId != null ? String(summary.credentialId) : "", + username: summary.username ?? "", + password: summary.hasPassword ? HOST_PROTOCOL_SECRET_KEPT : "", + fields, + }; + } + return form; +} + +/** + * The logins a host save sends: one per plugin protocol the editor knows, + * removed when its switch is off. A saved secret left alone is not sent. + */ +export function protocolAuthPayload( + form: Record, + protocols: HostProtocols, +): Record { + const payload: Record = {}; + for (const protocol of listHostProtocols()) { + if (!protocols[protocol.settingKey]) { + payload[protocol.id] = null; + continue; + } + const login = form[protocol.id]; + if (!login) continue; + const direct = login.authType === "direct"; + payload[protocol.id] = { + authType: login.authType, + credentialId: + login.authType === "credential" && login.credentialId + ? Number(login.credentialId) + : null, + username: direct ? login.username || null : null, + ...(direct && login.password !== HOST_PROTOCOL_SECRET_KEPT + ? { password: login.password || null } + : {}), + fields: Object.fromEntries( + Object.entries(login.fields).filter( + ([, value]) => value !== HOST_PROTOCOL_SECRET_KEPT, + ), + ), + }; + } + return payload; +} + export function createHostEditorForm( host: Host | null, defaults?: HostDefaults, @@ -107,32 +172,8 @@ export function createHostEditorForm( portKnockSequence: host?.portKnockSequence ?? ([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]), - rdpCredentialId: host?.rdpCredentialId ?? "", - rdpUser: host?.rdpUser ?? "", - rdpPassword: host?.hasRdpPassword - ? "existing_rdp_password" - : (host?.rdpPassword ?? ""), - domain: host?.domain ?? "", - vncCredentialId: host?.vncCredentialId ?? "", - vncPassword: host?.hasVncPassword - ? "existing_vnc_password" - : (host?.vncPassword ?? ""), - vncUser: host?.vncUser ?? "", - telnetUser: host?.telnetUser ?? "", - telnetPassword: host?.hasTelnetPassword - ? "existing_telnet_password" - : (host?.telnetPassword ?? ""), - telnetCredentialId: - host?.telnetCredentialId != null ? String(host.telnetCredentialId) : "", - rdpAuthType: (host?.rdpAuthType ?? - (host?.rdpCredentialId ? "credential" : "direct")) as - "direct" | "credential" | "none", - vncAuthType: (host?.vncAuthType ?? - (host?.vncCredentialId ? "credential" : "direct")) as - "direct" | "credential", - telnetAuthType: (host?.telnetAuthType ?? - (host?.telnetCredentialId ? "credential" : "direct")) as - "direct" | "credential", + // Each plugin protocol's login, edited by that plugin's host editor tab. + protocolAuth: protocolAuthForm(host?.protocolAuth), statusCheckEnabled: host?.statusCheckEnabled ?? d?.statusCheckEnabled ?? true, statusCheckInterval: (host?.statusCheckInterval ?? null) as number | null, @@ -242,58 +283,7 @@ export function buildHostEditorPayload( enableSsh: protocols.enableSsh, sshPort: Number(form.sshPort), forceKeyboardInteractive: form.forceKeyboardInteractive, - rdpAuthType: protocols.enableRdp ? form.rdpAuthType : null, - rdpCredentialId: - protocols.enableRdp && - form.rdpAuthType === "credential" && - form.rdpCredentialId - ? Number(form.rdpCredentialId) - : null, - rdpUser: - protocols.enableRdp && form.rdpAuthType === "direct" - ? form.rdpUser || null - : null, - rdpPassword: - protocols.enableRdp && - form.rdpAuthType === "direct" && - form.rdpPassword !== "existing_rdp_password" - ? form.rdpPassword || null - : null, - rdpDomain: form.domain || null, - vncAuthType: protocols.enableVnc ? form.vncAuthType : null, - vncCredentialId: - protocols.enableVnc && - form.vncAuthType === "credential" && - form.vncCredentialId - ? Number(form.vncCredentialId) - : null, - vncPassword: - protocols.enableVnc && - form.vncAuthType === "direct" && - form.vncPassword !== "existing_vnc_password" - ? form.vncPassword || null - : null, - vncUser: - protocols.enableVnc && form.vncAuthType === "direct" - ? form.vncUser || null - : null, - telnetAuthType: protocols.enableTelnet ? form.telnetAuthType : null, - telnetCredentialId: - protocols.enableTelnet && - form.telnetAuthType === "credential" && - form.telnetCredentialId - ? Number(form.telnetCredentialId) - : null, - telnetUser: - protocols.enableTelnet && form.telnetAuthType === "direct" - ? form.telnetUser || null - : null, - telnetPassword: - protocols.enableTelnet && - form.telnetAuthType === "direct" && - form.telnetPassword !== "existing_telnet_password" - ? form.telnetPassword || null - : null, + protocolAuth: protocolAuthPayload(form.protocolAuth, protocols), // The editor keeps ids as strings; the API and every backend lookup take // a number, and a string id does not compare equal on Postgres/MySQL. jumpHosts: form.jumpHosts.map((j) => ({ hostId: Number(j.hostId) })), diff --git a/src/ui/sidebar/HostManager.tsx b/src/ui/sidebar/HostManager.tsx index 8825edb67..f184922f6 100644 --- a/src/ui/sidebar/HostManager.tsx +++ b/src/ui/sidebar/HostManager.tsx @@ -417,7 +417,7 @@ export function HostManager({ : makeCredentialTabs(t); // Collapsing while on a now-hidden tab would leave nothing selected. The // SSH group collapses to its own "ssh" tab, everything else to General. - // The top-level strip only lists general/ssh/rdp/vnc/telnet -- the SSH + // The top-level strip only lists general, ssh and protocol tabs -- the SSH // sub-tabs live in the secondary strip, so they count as visible whenever // the SSH group is expanded. const sshSubTabs = makeHostSshSubTabs(t, protocols); diff --git a/src/ui/sidebar/HostManagerData.ts b/src/ui/sidebar/HostManagerData.ts index 304dcf227..0c27438d3 100644 --- a/src/ui/sidebar/HostManagerData.ts +++ b/src/ui/sidebar/HostManagerData.ts @@ -6,9 +6,6 @@ type RawSSHHost = SSHHostWithStatus & { hasKey?: boolean; hasKeyPassword?: boolean; hasSudoPassword?: boolean; - hasRdpPassword?: boolean; - hasVncPassword?: boolean; - hasTelnetPassword?: boolean; }; type HostJumpHost = NonNullable[number]; type RawCredential = { @@ -81,31 +78,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host { sshPort: h.sshPort ?? (h.connectionType === "ssh" || !h.connectionType ? h.port : 22), - rdpAuthType: - (h.rdpAuthType as "direct" | "credential") ?? - (h.rdpCredentialId ? "credential" : "direct"), - rdpCredentialId: - h.rdpCredentialId != null ? String(h.rdpCredentialId) : undefined, - rdpUser: h.rdpUser, - rdpPassword: h.rdpPassword ?? "", - hasRdpPassword: !!host.hasRdpPassword || !!h.rdpPassword, - domain: h.rdpDomain, - vncAuthType: - (h.vncAuthType as "direct" | "credential") ?? - (h.vncCredentialId ? "credential" : "direct"), - vncCredentialId: - h.vncCredentialId != null ? String(h.vncCredentialId) : undefined, - vncPassword: h.vncPassword ?? "", - hasVncPassword: !!host.hasVncPassword || !!h.vncPassword, - vncUser: h.vncUser, - telnetAuthType: - (h.telnetAuthType as "direct" | "credential") ?? - (h.telnetCredentialId ? "credential" : "direct"), - telnetCredentialId: - h.telnetCredentialId != null ? String(h.telnetCredentialId) : undefined, - telnetUser: h.telnetUser, - telnetPassword: h.telnetPassword ?? "", - hasTelnetPassword: !!host.hasTelnetPassword || !!h.telnetPassword, + protocolAuth: h.protocolAuth ?? {}, jumpHosts: (parseJson(h.jumpHosts) ?? []).map((j) => ({ hostId: String(j.hostId ?? j.hostid ?? j), })), @@ -126,18 +99,22 @@ export function sshHostToHost(h: SSHHostWithStatus): Host { isShared: h.isShared ?? false, authOverrides: h.authOverrides ? Object.fromEntries( - Object.entries(h.authOverrides).map(([protocol, state]) => [ - protocol, + Object.entries(h.authOverrides).flatMap(([protocol, state]) => state - ? { - ...state, - credentialId: - state.credentialId != null - ? String(state.credentialId) - : undefined, - } - : state, - ]), + ? [ + [ + protocol, + { + ...state, + credentialId: + state.credentialId != null + ? String(state.credentialId) + : undefined, + }, + ], + ] + : [], + ), ) : undefined, permissionLevel: h.permissionLevel, diff --git a/src/ui/sidebar/host-permissions.ts b/src/ui/sidebar/host-permissions.ts index 30b45f287..59b8d39a8 100644 --- a/src/ui/sidebar/host-permissions.ts +++ b/src/ui/sidebar/host-permissions.ts @@ -1,9 +1,13 @@ import type { Host, SharePermissionLevel } from "@/types/ui-types"; import { - isSupportedAuthOverrideProtocol, + SSH_AUTH_PROTOCOL, type AuthOverrideProtocol, } from "@/types/auth-protocols"; -import { listHostProtocols, protocolEnabled } from "./host-protocols"; +import { + listHostProtocols, + protocolEnabled, + type HostProtocolDef, +} from "./host-protocols"; const LEVEL_RANK: Record = { connect: 1, @@ -32,14 +36,30 @@ export function canDeleteHost(host: Host): boolean { return !host.isShared; } +/** SSH, then every protocol a running plugin registered. */ +export function authOverrideProtocols( + list: HostProtocolDef[] = listHostProtocols(), +): AuthOverrideProtocol[] { + return [SSH_AUTH_PROTOCOL, ...list.map((protocol) => protocol.id)]; +} + +/** How a protocol is named in the override menu and dialog. */ +export function authProtocolLabel( + protocol: AuthOverrideProtocol, + t: (key: string) => string, + list: HostProtocolDef[] = listHostProtocols(), +): string { + if (protocol === SSH_AUTH_PROTOCOL) return "SSH"; + const def = list.find((entry) => entry.id === protocol); + return def ? t(def.titleKey) : protocol; +} + export function canOverrideHostAuth( host: Host, protocol: AuthOverrideProtocol, ): boolean { - if (!host.isShared || !isSupportedAuthOverrideProtocol(protocol)) { - return false; - } - if (protocol === "ssh") return !!host.enableSsh; + if (!host.isShared) return false; + if (protocol === SSH_AUTH_PROTOCOL) return !!host.enableSsh; // Other protocols are switched on in their plugin's host settings. const plugin = listHostProtocols().find((entry) => entry.id === protocol); return !!plugin && protocolEnabled(host.pluginSettings, plugin); diff --git a/src/ui/sidebar/host-protocols.ts b/src/ui/sidebar/host-protocols.ts index d94c7e833..66755770d 100644 --- a/src/ui/sidebar/host-protocols.ts +++ b/src/ui/sidebar/host-protocols.ts @@ -4,7 +4,7 @@ import { byOrderThenId, createRegistry } from "@/lib/registry"; type Icon = ComponentType<{ className?: string; size?: number | string }>; /** - * A connection protocol a plugin adds next to SSH (RDP, VNC). Its on/off + * A connection protocol a plugin adds next to SSH. Its on/off * switch and port are the plugin's own host settings, so core reads them * from host.pluginSettings[pluginId] and never knows the protocol by name. */ diff --git a/src/ui/sidebar/quick-connect-host.ts b/src/ui/sidebar/quick-connect-host.ts index e665d81fa..1c9ce50c7 100644 --- a/src/ui/sidebar/quick-connect-host.ts +++ b/src/ui/sidebar/quick-connect-host.ts @@ -33,19 +33,18 @@ export function isQuickConnectHost(host: Pick): boolean { export function createQuickConnectHost(input: QuickConnectInput): Host { const protocol = input.protocol; if (protocol) { - // Protocol logins are core host fields named after the protocol. - const login = { - [`${protocol.id}AuthType`]: "direct", - [`${protocol.id}User`]: input.username, - [`${protocol.id}Password`]: input.password, - }; return { ...createQuickConnectHost({ ...input, protocol: undefined, port: 22 }), - ...login, + // Never saved, so the login travels in plain text to the protocol's tab. + quickConnectLogin: { + protocol: protocol.id, + username: input.username, + password: input.password, + fields: input.domain ? { domain: input.domain } : {}, + }, port: input.port, enableSsh: false, quickConnectSavable: false, - domain: input.domain, pluginSettings: { [protocol.pluginId]: { [protocol.settingKey]: true, @@ -100,7 +99,7 @@ export function quickConnectHostToPayload(host: Host): SSHHostData { } /** - * Every way to reach the address: connect actions (terminal, RDP, VNC...) + * Every way to reach the address: connect actions (terminal, remote desktop) * by priority, then tools that opted into Quick Connect. Only actions that * open a tab work here, since the host is never saved. */ diff --git a/src/ui/sidebar/tree/HostItem/HostItem.tsx b/src/ui/sidebar/tree/HostItem/HostItem.tsx index 2111c48c2..7e0b1a5bf 100644 --- a/src/ui/sidebar/tree/HostItem/HostItem.tsx +++ b/src/ui/sidebar/tree/HostItem/HostItem.tsx @@ -50,13 +50,11 @@ import { canEditHost, canOverrideHostAuth, canShareHost, + authOverrideProtocols as listAuthOverrideProtocols, + authProtocolLabel, } from "@/sidebar/host-permissions"; import { HostAuthOverrideModal } from "@/sidebar/HostAuthOverrideModal"; -import { - AUTH_OVERRIDE_PROTOCOLS, - AUTH_PROTOCOL_METADATA, - type AuthOverrideProtocol, -} from "@/types/auth-protocols"; +import type { AuthOverrideProtocol } from "@/types/auth-protocols"; import { useStatusColorScheme, getStatusClasses, @@ -323,7 +321,7 @@ export function HostItem({ !alwaysShowTray && !actionsOnly && (trayTrigger === "click" || isTouchOnly); const showPasswordCopy = !host.isShared && canCopyHostPassword(host); const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host); - const authOverrideProtocols = AUTH_OVERRIDE_PROTOCOLS.filter((protocol) => + const authOverrideProtocols = listAuthOverrideProtocols().filter((protocol) => canOverrideHostAuth(host, protocol), ); const [authOverrideProtocol, setAuthOverrideProtocol] = @@ -808,7 +806,7 @@ export function HostItem({ > {t("hosts.sharing.authOverrideActionProtocol", { - protocol: AUTH_PROTOCOL_METADATA[protocol].label, + protocol: authProtocolLabel(protocol, t), })} ))} diff --git a/src/ui/sidebar/tree/SidebarTree.tsx b/src/ui/sidebar/tree/SidebarTree.tsx index 408e6733f..7a85f9362 100644 --- a/src/ui/sidebar/tree/SidebarTree.tsx +++ b/src/ui/sidebar/tree/SidebarTree.tsx @@ -614,17 +614,18 @@ export function SidebarTree({ overrideCredentialUsername: host.overrideCredentialUsername ?? false, enableSsh: host.enableSsh, sshPort: host.sshPort, - rdpUser: host.rdpUser ?? null, - rdpPassword: host.rdpPassword ?? null, - rdpDomain: host.domain ?? null, - vncAuthType: host.vncAuthType ?? null, - vncCredentialId: host.vncCredentialId - ? Number(host.vncCredentialId) - : null, - vncPassword: host.vncPassword ?? null, - vncUser: host.vncUser ?? null, - telnetUser: host.telnetUser ?? null, - telnetPassword: host.telnetPassword ?? null, + // Saved passwords never reach the browser, so a copy starts without them. + protocolAuth: Object.fromEntries( + Object.entries(host.protocolAuth ?? {}).map(([protocol, login]) => [ + protocol, + { + authType: login.authType, + credentialId: login.credentialId ?? null, + username: login.username ?? null, + fields: login.fields ?? {}, + }, + ]), + ), forceKeyboardInteractive: host.forceKeyboardInteractive ?? false, useSocks5: host.useSocks5, socks5Host: host.socks5Host ?? null, diff --git a/src/ui/tests/sidebar/HostAuthOverrideModal.test.tsx b/src/ui/tests/sidebar/HostAuthOverrideModal.test.tsx index 3aa2dbde0..a9caf2a57 100644 --- a/src/ui/tests/sidebar/HostAuthOverrideModal.test.tsx +++ b/src/ui/tests/sidebar/HostAuthOverrideModal.test.tsx @@ -49,7 +49,7 @@ const host = { ownerAuthShared: false, }, }, -} as Host; +} as unknown as Host; beforeEach(() => { api.getCredentials.mockReset(); diff --git a/src/ui/tests/sidebar/HostCredentialList.test.tsx b/src/ui/tests/sidebar/HostCredentialList.test.tsx index 78bf4f28b..12e13dd7e 100644 --- a/src/ui/tests/sidebar/HostCredentialList.test.tsx +++ b/src/ui/tests/sidebar/HostCredentialList.test.tsx @@ -115,7 +115,9 @@ describe("HostCredentialList credential usage", () => { name: "rdp-only", authType: "password", credentialId: undefined, - rdpCredentialId: "1", + protocolAuth: { + rdp: { authType: "credential", credentialId: 1 }, + }, enableSsh: false, }), ], diff --git a/src/ui/tests/sidebar/HostEditorData.test.ts b/src/ui/tests/sidebar/HostEditorData.test.ts index fe4e3402e..8a7bc8b8c 100644 --- a/src/ui/tests/sidebar/HostEditorData.test.ts +++ b/src/ui/tests/sidebar/HostEditorData.test.ts @@ -9,6 +9,7 @@ import { type HostProtocols, } from "../../sidebar/HostEditorData"; import type { Host } from "@/types/ui-types"; +import { HOST_PROTOCOL_SECRET_KEPT } from "@termix/plugin-sdk/frontend"; const sshOnly: HostProtocols = { enableSsh: true, @@ -24,20 +25,6 @@ const rdpOnly: HostProtocols = { enableTelnet: false, }; -const vncOnly: HostProtocols = { - enableSsh: false, - enableRdp: false, - enableVnc: true, - enableTelnet: false, -}; - -const telnetOnly: HostProtocols = { - enableSsh: false, - enableRdp: false, - enableVnc: false, - enableTelnet: true, -}; - describe("omitOwnerSshAuthFromSharedEdit", () => { it("keeps editable host settings but removes all owner SSH authentication fields", () => { const form = createHostEditorForm(null); @@ -308,75 +295,112 @@ describe("plugin host settings", () => { }); }); -describe("RDP/VNC/Telnet password persistence indicator", () => { - it("seeds a sentinel value when the host reports a saved rdp password", () => { - const host = { - hasRdpPassword: true, - rdpAuthType: "direct", - } as Host; +describe("plugin protocol logins", () => { + const spice = { + id: "spice", + pluginId: "spice-plugin", + settingKey: "enableSpice", + defaultPort: 5930, + titleKey: "spice", + icon: () => null, + }; + const spiceOn: HostProtocols = { enableSsh: false, enableSpice: true }; + const spiceOff: HostProtocols = { enableSsh: true, enableSpice: false }; + const saved = { + protocolAuth: { + spice: { + authType: "direct", + credentialId: null, + username: "viewer", + fields: { display: "0" }, + hasPassword: true, + secretFieldKeys: ["ticket"], + }, + }, + } as unknown as Host; - const form = createHostEditorForm(host); + it("holds a saved password and secret fields behind the placeholder", () => { + const form = createHostEditorForm(saved); - expect(form.rdpPassword).toBe("existing_rdp_password"); + expect(form.protocolAuth.spice).toEqual({ + authType: "direct", + credentialId: "", + username: "viewer", + password: HOST_PROTOCOL_SECRET_KEPT, + fields: { display: "0", ticket: HOST_PROTOCOL_SECRET_KEPT }, + }); }); - it("does not send the rdp sentinel back to the backend unchanged", () => { - const host = { hasRdpPassword: true, rdpAuthType: "direct" } as Host; - const form = { ...createHostEditorForm(host) }; - - const payload = buildHostEditorPayload(form, rdpOnly); - - expect(payload.rdpPassword).toBeNull(); + it("leaves unchanged secrets out of the payload", () => { + const dispose = registerHostProtocol(spice); + try { + const payload = buildHostEditorPayload( + createHostEditorForm(saved), + spiceOn, + ); + expect(payload.protocolAuth?.spice).toEqual({ + authType: "direct", + credentialId: null, + username: "viewer", + fields: { display: "0" }, + }); + } finally { + dispose(); + } }); - it("sends a newly typed rdp password", () => { - const host = { hasRdpPassword: true, rdpAuthType: "direct" } as Host; - const form = { - ...createHostEditorForm(host), - rdpPassword: "new-rdp-pass", - }; - - const payload = buildHostEditorPayload(form, rdpOnly); - - expect(payload.rdpPassword).toBe("new-rdp-pass"); + it("sends a newly typed password", () => { + const dispose = registerHostProtocol(spice); + try { + const form = createHostEditorForm(saved); + form.protocolAuth.spice.password = "new-pass"; + const payload = buildHostEditorPayload(form, spiceOn); + expect(payload.protocolAuth?.spice?.password).toBe("new-pass"); + } finally { + dispose(); + } }); - it("seeds a sentinel value when the host reports a saved vnc password", () => { - const host = { hasVncPassword: true, vncAuthType: "direct" } as Host; - const form = createHostEditorForm(host); - - expect(form.vncPassword).toBe("existing_vnc_password"); + it("sends only the credential for a credential login", () => { + const dispose = registerHostProtocol(spice); + try { + const form = createHostEditorForm(saved); + form.protocolAuth.spice = { + ...form.protocolAuth.spice, + authType: "credential", + credentialId: "7", + }; + const login = buildHostEditorPayload(form, spiceOn).protocolAuth?.spice; + expect(login).toMatchObject({ + authType: "credential", + credentialId: 7, + username: null, + }); + expect(login).not.toHaveProperty("password"); + } finally { + dispose(); + } }); - it("does not send the vnc sentinel back to the backend unchanged", () => { - const host = { hasVncPassword: true, vncAuthType: "direct" } as Host; - const form = { ...createHostEditorForm(host) }; - - const payload = buildHostEditorPayload(form, vncOnly); - - expect(payload.vncPassword).toBeNull(); + it("removes the login of a protocol switched off", () => { + const dispose = registerHostProtocol(spice); + try { + const payload = buildHostEditorPayload( + createHostEditorForm(saved), + spiceOff, + ); + expect(payload.protocolAuth).toEqual({ spice: null }); + } finally { + dispose(); + } }); - it("seeds a sentinel value when the host reports a saved telnet password", () => { - const host = { - hasTelnetPassword: true, - telnetAuthType: "direct", - } as Host; - const form = createHostEditorForm(host); - - expect(form.telnetPassword).toBe("existing_telnet_password"); - }); - - it("does not send the telnet sentinel back to the backend unchanged", () => { - const host = { - hasTelnetPassword: true, - telnetAuthType: "direct", - } as Host; - const form = { ...createHostEditorForm(host) }; - - const payload = buildHostEditorPayload(form, telnetOnly); - - expect(payload.telnetPassword).toBeNull(); + it("leaves a protocol no running plugin registered alone", () => { + const payload = buildHostEditorPayload( + createHostEditorForm(saved), + spiceOn, + ); + expect(payload.protocolAuth).toEqual({}); }); }); diff --git a/src/ui/tests/sidebar/quick-connect-host.test.ts b/src/ui/tests/sidebar/quick-connect-host.test.ts index e807603a8..0aaa2b85e 100644 --- a/src/ui/tests/sidebar/quick-connect-host.test.ts +++ b/src/ui/tests/sidebar/quick-connect-host.test.ts @@ -91,12 +91,13 @@ describe("createQuickConnectHost for remote desktop protocols", () => { expect(host).toMatchObject({ enableSsh: false, pluginSettings: { demo: { enableDemo: true, demoPort: 3390 } }, - domain: "CORP", }); - // The login sits on the core fields named after the protocol. - expect(host).toMatchObject({ - "demo-desktopUser": "admin", - "demo-desktopPassword": "pw", + // The unsaved login travels to the protocol's tab as it is. + expect(host.quickConnectLogin).toEqual({ + protocol: "demo-desktop", + username: "admin", + password: "pw", + fields: { domain: "CORP" }, }); });