diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 9b02c2ecd..421584fd6 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -249,6 +249,15 @@ http { proxy_read_timeout 300s; } + location ~ ^/ssh-auth(/.*)?$ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + } + location ~ ^/snippets(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index f12cd8176..21fc58c20 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -229,6 +229,15 @@ http { proxy_read_timeout 300s; } + location ~ ^/ssh-auth(/.*)?$ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + } + location ~ ^/snippets(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/drizzle/mysql/0030_sweet_warlock.sql b/drizzle/mysql/0030_sweet_warlock.sql new file mode 100644 index 000000000..c346763be --- /dev/null +++ b/drizzle/mysql/0030_sweet_warlock.sql @@ -0,0 +1,24 @@ +CREATE TABLE `user_external_identities` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `provider_id` varchar(255) NOT NULL, + `subject` varchar(255) NOT NULL, + `email` text, + `created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `user_external_identities_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_user_external_identities_provider_subject` UNIQUE(`provider_id`,`subject`) +); +--> statement-breakpoint +CREATE TABLE `user_second_factors` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `plugin_id` varchar(255) NOT NULL, + `factor_id` varchar(255) NOT NULL, + `enrolled_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `user_second_factors_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_user_second_factors_user_factor` UNIQUE(`user_id`,`plugin_id`,`factor_id`) +); +--> statement-breakpoint +ALTER TABLE `user_external_identities` ADD CONSTRAINT `user_external_identities_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_second_factors` ADD CONSTRAINT `user_second_factors_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_user_external_identities_user` ON `user_external_identities` (`user_id`); \ No newline at end of file diff --git a/drizzle/mysql/meta/0030_snapshot.json b/drizzle/mysql/meta/0030_snapshot.json new file mode 100644 index 000000000..400ccd025 --- /dev/null +++ b/drizzle/mysql/meta/0030_snapshot.json @@ -0,0 +1,10229 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "aaa5343a-0c28-489a-aab5-f582eefa947c", + "prevId": "b2e69137-0d9e-4e84-8259-6f104a7ce2fb", + "tables": { + "ai_conversations": { + "name": "ai_conversations", + "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 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ai_conversations_user": { + "name": "idx_ai_conversations_user", + "columns": [ + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ai_conversations_user_id_users_id_fk": { + "name": "ai_conversations_user_id_users_id_fk", + "tableFrom": "ai_conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ai_conversations_id": { + "name": "ai_conversations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ai_messages": { + "name": "ai_messages", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('')" + }, + "tool_calls": { + "name": "tool_calls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ai_messages_conversation": { + "name": "idx_ai_messages_conversation", + "columns": [ + "conversation_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ai_messages_conversation_id_ai_conversations_id_fk": { + "name": "ai_messages_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_messages", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ai_messages_id": { + "name": "ai_messages_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ai_proposals": { + "name": "ai_proposals", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{}')" + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ai_proposals_user": { + "name": "idx_ai_proposals_user", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_ai_proposals_conversation": { + "name": "idx_ai_proposals_conversation", + "columns": [ + "conversation_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ai_proposals_conversation_id_ai_conversations_id_fk": { + "name": "ai_proposals_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_proposals", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_proposals_user_id_users_id_fk": { + "name": "ai_proposals_user_id_users_id_fk", + "tableFrom": "ai_proposals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ai_proposals_id": { + "name": "ai_proposals_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ai_providers": { + "name": "ai_providers", + "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_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_prefix": { + "name": "api_key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ai_providers_user_label": { + "name": "idx_ai_providers_user_label", + "columns": [ + "user_id", + "label" + ], + "isUnique": true + } + }, + "foreignKeys": { + "ai_providers_user_id_users_id_fk": { + "name": "ai_providers_user_id_users_id_fk", + "tableFrom": "ai_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ai_providers_id": { + "name": "ai_providers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "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": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "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": {} + }, + "automation_channels": { + "name": "automation_channels", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_channels_pair": { + "name": "idx_automation_channels_pair", + "columns": [ + "automation_id", + "channel_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "automation_channels_automation_id_automations_id_fk": { + "name": "automation_channels_automation_id_automations_id_fk", + "tableFrom": "automation_channels", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_channels_channel_id_notification_channels_id_fk": { + "name": "automation_channels_channel_id_notification_channels_id_fk", + "tableFrom": "automation_channels", + "tableTo": "notification_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_channels_id": { + "name": "automation_channels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "automation_run_steps": { + "name": "automation_run_steps", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "run_id": { + "name": "run_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step_index": { + "name": "step_index", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step_id": { + "name": "step_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step_type": { + "name": "step_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "truncated": { + "name": "truncated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_automation_run_steps_run": { + "name": "idx_automation_run_steps_run", + "columns": [ + "run_id", + "step_index" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_run_steps_run_id_automation_runs_id_fk": { + "name": "automation_run_steps_run_id_automation_runs_id_fk", + "tableFrom": "automation_run_steps", + "tableTo": "automation_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_run_steps_id": { + "name": "automation_run_steps_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_context": { + "name": "trigger_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_runs_automation": { + "name": "idx_automation_runs_automation", + "columns": [ + "automation_id", + "started_at" + ], + "isUnique": false + }, + "idx_automation_runs_user": { + "name": "idx_automation_runs_user", + "columns": [ + "user_id", + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_user_id_users_id_fk": { + "name": "automation_runs_user_id_users_id_fk", + "tableFrom": "automation_runs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_runs_id": { + "name": "automation_runs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "automation_schedules": { + "name": "automation_schedules", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_due_at": { + "name": "next_due_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_schedules_automation": { + "name": "idx_automation_schedules_automation", + "columns": [ + "automation_id" + ], + "isUnique": true + }, + "idx_automation_schedules_due": { + "name": "idx_automation_schedules_due", + "columns": [ + "next_due_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_schedules_automation_id_automations_id_fk": { + "name": "automation_schedules_automation_id_automations_id_fk", + "tableFrom": "automation_schedules", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_schedules_id": { + "name": "automation_schedules_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "automation_trigger_state": { + "name": "automation_trigger_state", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state_key": { + "name": "state_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "breach_started_at": { + "name": "breach_started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_value": { + "name": "last_value", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_observed_state": { + "name": "last_observed_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_automation_trigger_state_key": { + "name": "idx_automation_trigger_state_key", + "columns": [ + "automation_id", + "state_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "automation_trigger_state_automation_id_automations_id_fk": { + "name": "automation_trigger_state_automation_id_automations_id_fk", + "tableFrom": "automation_trigger_state", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_trigger_state_id": { + "name": "automation_trigger_state_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "automations": { + "name": "automations", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "definition": { + "name": "definition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "definition_version": { + "name": "definition_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "concurrency_policy": { + "name": "concurrency_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('skip')" + }, + "max_run_seconds": { + "name": "max_run_seconds", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_automations_user": { + "name": "idx_automations_user", + "columns": [ + "user_id", + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automations_user_id_users_id_fk": { + "name": "automations_user_id_users_id_fk", + "tableFrom": "automations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automations_id": { + "name": "automations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "c2s_tunnel_presets": { + "name": "c2s_tunnel_presets", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "computer_name": { + "name": "computer_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "c2s_tunnel_presets_user_id_users_id_fk": { + "name": "c2s_tunnel_presets_user_id_users_id_fk", + "tableFrom": "c2s_tunnel_presets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "c2s_tunnel_presets_id": { + "name": "c2s_tunnel_presets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "collab_room_members": { + "name": "collab_room_members", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "room_id": { + "name": "room_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_role": { + "name": "room_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('member')" + }, + "added_by": { + "name": "added_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_collab_room_members_room_user": { + "name": "idx_collab_room_members_room_user", + "columns": [ + "room_id", + "user_id" + ], + "isUnique": true + }, + "idx_collab_room_members_user": { + "name": "idx_collab_room_members_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collab_room_members_room_id_collab_rooms_id_fk": { + "name": "collab_room_members_room_id_collab_rooms_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "collab_rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_room_members_user_id_users_id_fk": { + "name": "collab_room_members_user_id_users_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_room_members_added_by_users_id_fk": { + "name": "collab_room_members_added_by_users_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "users", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collab_room_members_id": { + "name": "collab_room_members_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "collab_rooms": { + "name": "collab_rooms", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "persistent": { + "name": "persistent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "presenter_user_id": { + "name": "presenter_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_protocol": { + "name": "stage_protocol", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_host_id": { + "name": "stage_host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_share_id": { + "name": "stage_share_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guest_link_token": { + "name": "guest_link_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_collab_rooms_owner": { + "name": "idx_collab_rooms_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "idx_collab_rooms_guest_token": { + "name": "idx_collab_rooms_guest_token", + "columns": [ + "guest_link_token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "collab_rooms_owner_user_id_users_id_fk": { + "name": "collab_rooms_owner_user_id_users_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_rooms_presenter_user_id_users_id_fk": { + "name": "collab_rooms_presenter_user_id_users_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "users", + "columnsFrom": [ + "presenter_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "collab_rooms_stage_host_id_ssh_data_id_fk": { + "name": "collab_rooms_stage_host_id_ssh_data_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "ssh_data", + "columnsFrom": [ + "stage_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "collab_rooms_stage_share_id_session_shares_id_fk": { + "name": "collab_rooms_stage_share_id_session_shares_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "session_shares", + "columnsFrom": [ + "stage_share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collab_rooms_id": { + "name": "collab_rooms_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "command_history": { + "name": "command_history", + "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 + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "executed_at": { + "name": "executed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_command_history_user_host": { + "name": "idx_command_history_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "command_history_user_id_users_id_fk": { + "name": "command_history_user_id_users_id_fk", + "tableFrom": "command_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "command_history_host_id_ssh_data_id_fk": { + "name": "command_history_host_id_ssh_data_id_fk", + "tableFrom": "command_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "command_history_id": { + "name": "command_history_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": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "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": "varchar(255)", + "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": {} + }, + "dashboard_service_links": { + "name": "dashboard_service_links", + "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 + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dashboard_service_links_id": { + "name": "dashboard_service_links_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "dismissed_alerts": { + "name": "dismissed_alerts", + "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 + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_dismissed_alerts_user_id": { + "name": "idx_dismissed_alerts_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dismissed_alerts_id": { + "name": "dismissed_alerts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "file_manager_pinned": { + "name": "file_manager_pinned", + "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 + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_file_manager_pinned_user": { + "name": "idx_file_manager_pinned_user", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "file_manager_pinned_user_id_users_id_fk": { + "name": "file_manager_pinned_user_id_users_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_pinned_host_id_ssh_data_id_fk": { + "name": "file_manager_pinned_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_manager_pinned_id": { + "name": "file_manager_pinned_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "file_manager_recent": { + "name": "file_manager_recent", + "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 + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened": { + "name": "last_opened", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_file_manager_recent_user": { + "name": "idx_file_manager_recent_user", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "file_manager_recent_user_id_users_id_fk": { + "name": "file_manager_recent_user_id_users_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_recent_host_id_ssh_data_id_fk": { + "name": "file_manager_recent_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_manager_recent_id": { + "name": "file_manager_recent_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "file_manager_shortcuts": { + "name": "file_manager_shortcuts", + "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 + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_file_manager_shortcuts_user": { + "name": "idx_file_manager_shortcuts_user", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "file_manager_shortcuts_user_id_users_id_fk": { + "name": "file_manager_shortcuts_user_id_users_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_shortcuts_host_id_ssh_data_id_fk": { + "name": "file_manager_shortcuts_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_manager_shortcuts_id": { + "name": "file_manager_shortcuts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fleet_inventory": { + "name": "fleet_inventory", + "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 + }, + "os_pretty_name": { + "name": "os_pretty_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kernel": { + "name": "kernel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "architecture": { + "name": "architecture", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uptime_seconds": { + "name": "uptime_seconds", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "package_manager": { + "name": "package_manager", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collected_at": { + "name": "collected_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_fleet_inventory_host": { + "name": "idx_fleet_inventory_host", + "columns": [ + "host_id", + "user_id" + ], + "isUnique": true + }, + "idx_fleet_inventory_user": { + "name": "idx_fleet_inventory_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "fleet_inventory_host_id_ssh_data_id_fk": { + "name": "fleet_inventory_host_id_ssh_data_id_fk", + "tableFrom": "fleet_inventory", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fleet_inventory_user_id_users_id_fk": { + "name": "fleet_inventory_user_id_users_id_fk", + "tableFrom": "fleet_inventory", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fleet_inventory_id": { + "name": "fleet_inventory_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fleet_members": { + "name": "fleet_members", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "fleet_id": { + "name": "fleet_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_fleet_members_fleet_host": { + "name": "idx_fleet_members_fleet_host", + "columns": [ + "fleet_id", + "host_id" + ], + "isUnique": true + }, + "idx_fleet_members_host": { + "name": "idx_fleet_members_host", + "columns": [ + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "fleet_members_fleet_id_fleets_id_fk": { + "name": "fleet_members_fleet_id_fleets_id_fk", + "tableFrom": "fleet_members", + "tableTo": "fleets", + "columnsFrom": [ + "fleet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fleet_members_host_id_ssh_data_id_fk": { + "name": "fleet_members_host_id_ssh_data_id_fk", + "tableFrom": "fleet_members", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fleet_members_id": { + "name": "fleet_members_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fleets": { + "name": "fleets", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tag_rules": { + "name": "tag_rules", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "fleets_user_id_users_id_fk": { + "name": "fleets_user_id_users_id_fk", + "tableFrom": "fleets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fleets_id": { + "name": "fleets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "fleets_sync_id_unique": { + "name": "fleets_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "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": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "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": {} + }, + "homepage_items": { + "name": "homepage_items", + "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_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{}')" + }, + "folder_id": { + "name": "folder_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_homepage_items_user_id": { + "name": "idx_homepage_items_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homepage_items_id": { + "name": "homepage_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "homepage_layouts": { + "name": "homepage_layouts", + "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 + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{}')" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homepage_layouts_id": { + "name": "homepage_layouts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "columns": [ + "user_id" + ] + } + }, + "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": "varchar(255)", + "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_health_checks": { + "name": "host_health_checks", + "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 + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checks": { + "name": "checks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_host_health_checks_user_host": { + "name": "idx_host_health_checks_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_health_checks_user_id_users_id_fk": { + "name": "host_health_checks_user_id_users_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_checks_host_id_ssh_data_id_fk": { + "name": "host_health_checks_host_id_ssh_data_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_health_checks_id": { + "name": "host_health_checks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_health_history": { + "name": "host_health_history", + "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 + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_health_history_user_id_users_id_fk": { + "name": "host_health_history_user_id_users_id_fk", + "tableFrom": "host_health_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_history_host_id_ssh_data_id_fk": { + "name": "host_health_history_host_id_ssh_data_id_fk", + "tableFrom": "host_health_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_health_history_id": { + "name": "host_health_history_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_metrics_history": { + "name": "host_metrics_history", + "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 + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_metrics_history_host_id_ssh_data_id_fk": { + "name": "host_metrics_history_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_metrics_history_id": { + "name": "host_metrics_history_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_metrics_preferences": { + "name": "host_metrics_preferences", + "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 + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_host_metrics_prefs_user_host": { + "name": "idx_host_metrics_prefs_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_metrics_preferences_user_id_users_id_fk": { + "name": "host_metrics_preferences_user_id_users_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_metrics_preferences_host_id_ssh_data_id_fk": { + "name": "host_metrics_preferences_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_metrics_preferences_id": { + "name": "host_metrics_preferences_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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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 + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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 + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_docker": { + "name": "enable_docker", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_web_ui": { + "name": "enable_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_terminal_toolbar": { + "name": "enable_terminal_toolbar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_ai_assistant": { + "name": "enable_ai_assistant", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats_config": { + "name": "stats_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "docker_config": { + "name": "docker_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "web_ui_config": { + "name": "web_ui_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_proxmox": { + "name": "enable_proxmox", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "proxmox_config": { + "name": "proxmox_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_proxmox_stats": { + "name": "enable_proxmox_stats", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "proxmox_stats_config": { + "name": "proxmox_stats_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_config": { + "name": "terminal_config", + "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 + }, + "enable_rdp": { + "name": "enable_rdp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_vnc": { + "name": "enable_vnc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_telnet": { + "name": "enable_telnet", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ssh_port": { + "name": "ssh_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "rdp_port": { + "name": "rdp_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3389 + }, + "vnc_port": { + "name": "vnc_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 5900 + }, + "telnet_port": { + "name": "telnet_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 23 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_security": { + "name": "rdp_security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_ignore_cert": { + "name": "rdp_ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "security": { + "name": "security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignore_cert": { + "name": "ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "guacamole_config": { + "name": "guacamole_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "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 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_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": {} + }, + "network_topology": { + "name": "network_topology", + "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 + }, + "topology": { + "name": "topology", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "network_topology_user_id_users_id_fk": { + "name": "network_topology_user_id_users_id_fk", + "tableFrom": "network_topology", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "network_topology_id": { + "name": "network_topology_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notification_channels": { + "name": "notification_channels", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notification_channels_id": { + "name": "notification_channels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "opkssh_tokens": { + "name": "opkssh_tokens", + "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 + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "opkssh_tokens_id": { + "name": "opkssh_tokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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": {} + }, + "proxmox_node_history": { + "name": "proxmox_node_history", + "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 + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "proxmox_node_history_host_id_ssh_data_id_fk": { + "name": "proxmox_node_history_host_id_ssh_data_id_fk", + "tableFrom": "proxmox_node_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "proxmox_node_history_id": { + "name": "proxmox_node_history_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "proxmox_stats_preferences": { + "name": "proxmox_stats_preferences", + "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 + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_proxmox_stats_prefs_user_host": { + "name": "idx_proxmox_stats_prefs_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "proxmox_stats_preferences_user_id_users_id_fk": { + "name": "proxmox_stats_preferences_user_id_users_id_fk", + "tableFrom": "proxmox_stats_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proxmox_stats_preferences_host_id_ssh_data_id_fk": { + "name": "proxmox_stats_preferences_host_id_ssh_data_id_fk", + "tableFrom": "proxmox_stats_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "proxmox_stats_preferences_id": { + "name": "proxmox_stats_preferences_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": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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": {} + }, + "secret_sources": { + "name": "secret_sources", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('onepassword-connect')" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_secret_sources_user": { + "name": "idx_secret_sources_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "secret_sources_user_id_users_id_fk": { + "name": "secret_sources_user_id_users_id_fk", + "tableFrom": "secret_sources", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "secret_sources_id": { + "name": "secret_sources_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "session_recordings": { + "name": "session_recordings", + "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 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_id": { + "name": "access_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commands": { + "name": "commands", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dangerous_actions": { + "name": "dangerous_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recording_path": { + "name": "recording_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('text')" + }, + "terminated_by_owner": { + "name": "terminated_by_owner", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "termination_reason": { + "name": "termination_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_session_recordings_user_started": { + "name": "idx_session_recordings_user_started", + "columns": [ + "user_id", + "started_at" + ], + "isUnique": false + }, + "idx_session_recordings_host": { + "name": "idx_session_recordings_host", + "columns": [ + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_recordings_host_id_ssh_data_id_fk": { + "name": "session_recordings_host_id_ssh_data_id_fk", + "tableFrom": "session_recordings", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_recordings_user_id_users_id_fk": { + "name": "session_recordings_user_id_users_id_fk", + "tableFrom": "session_recordings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "session_recordings_access_id_host_access_id_fk": { + "name": "session_recordings_access_id_host_access_id_fk", + "tableFrom": "session_recordings", + "tableTo": "host_access", + "columnsFrom": [ + "access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_recordings_id": { + "name": "session_recordings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "session_share_participants": { + "name": "session_share_participants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "share_id": { + "name": "share_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guest_label": { + "name": "guest_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "left_at": { + "name": "left_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_share_participants_share_id_session_shares_id_fk": { + "name": "session_share_participants_share_id_session_shares_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "session_shares", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_share_participants_user_id_users_id_fk": { + "name": "session_share_participants_user_id_users_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_share_participants_id": { + "name": "session_share_participants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "session_shares": { + "name": "session_shares", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_instance_id": { + "name": "tab_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_type": { + "name": "share_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link_token": { + "name": "link_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('read-only')" + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_joined_at": { + "name": "last_joined_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "join_count": { + "name": "join_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_session_shares_session_id": { + "name": "idx_session_shares_session_id", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "idx_session_shares_host_id": { + "name": "idx_session_shares_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_shares_host_id_ssh_data_id_fk": { + "name": "session_shares_host_id_ssh_data_id_fk", + "tableFrom": "session_shares", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_owner_user_id_users_id_fk": { + "name": "session_shares_owner_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_target_user_id_users_id_fk": { + "name": "session_shares_target_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_shares_id": { + "name": "session_shares_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "session_shares_link_token_unique": { + "name": "session_shares_link_token_unique", + "columns": [ + "link_token" + ] + } + }, + "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": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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": {} + }, + "snippet_access": { + "name": "snippet_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "snippet_id": { + "name": "snippet_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": "('view')" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_snippet_access_user_id": { + "name": "idx_snippet_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_snippet_access_snippet_id": { + "name": "idx_snippet_access_snippet_id", + "columns": [ + "snippet_id" + ], + "isUnique": false + }, + "idx_snippet_access_role_id": { + "name": "idx_snippet_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "snippet_access_snippet_id_snippets_id_fk": { + "name": "snippet_access_snippet_id_snippets_id_fk", + "tableFrom": "snippet_access", + "tableTo": "snippets", + "columnsFrom": [ + "snippet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_user_id_users_id_fk": { + "name": "snippet_access_user_id_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_role_id_roles_id_fk": { + "name": "snippet_access_role_id_roles_id_fk", + "tableFrom": "snippet_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_granted_by_users_id_fk": { + "name": "snippet_access_granted_by_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "snippet_access_id": { + "name": "snippet_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "snippet_folders": { + "name": "snippet_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": "varchar(255)", + "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 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "snippet_folders_user_id_users_id_fk": { + "name": "snippet_folders_user_id_users_id_fk", + "tableFrom": "snippet_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "snippet_folders_id": { + "name": "snippet_folders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "snippet_folders_sync_id_unique": { + "name": "snippet_folders_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "snippets": { + "name": "snippets", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "host_filter": { + "name": "host_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_note": { + "name": "is_note", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_snippets_user_id": { + "name": "idx_snippets_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "snippets_user_id_users_id_fk": { + "name": "snippets_user_id_users_id_fk", + "tableFrom": "snippets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "snippets_id": { + "name": "snippets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "snippets_sync_id_unique": { + "name": "snippets_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "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 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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": "varchar(255)", + "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 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sso_providers_id": { + "name": "sso_providers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_tombstones": { + "name": "sync_tombstones", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_tombstones_id": { + "name": "sync_tombstones_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "termix_identities": { + "name": "termix_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 + }, + "handle": { + "name": "handle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identities_id": { + "name": "termix_identities_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "columns": [ + "user_id" + ] + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "columns": [ + "handle" + ] + } + }, + "checkConstraint": {} + }, + "termix_identity_ca": { + "name": "termix_identity_ca", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validity_days": { + "name": "validity_days", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identity_ca_id": { + "name": "termix_identity_ca_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "columns": [ + "identity_id" + ] + } + }, + "checkConstraint": {} + }, + "termix_identity_keys": { + "name": "termix_identity_keys", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('manual')" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identity_keys_id": { + "name": "termix_identity_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "tmux_session_tags": { + "name": "tmux_session_tags", + "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 + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_name": { + "name": "session_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "tmux_session_tags_user_id_users_id_fk": { + "name": "tmux_session_tags_user_id_users_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tmux_session_tags_host_id_ssh_data_id_fk": { + "name": "tmux_session_tags_host_id_ssh_data_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tmux_session_tags_id": { + "name": "tmux_session_tags_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "transfer_recent": { + "name": "transfer_recent", + "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 + }, + "source_host_id": { + "name": "source_host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_host_id": { + "name": "dest_host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path_label": { + "name": "dest_path_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_transfer_recent_user": { + "name": "idx_transfer_recent_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_recent_user_id_users_id_fk": { + "name": "transfer_recent_user_id_users_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_source_host_id_ssh_data_id_fk": { + "name": "transfer_recent_source_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "source_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_dest_host_id_ssh_data_id_fk": { + "name": "transfer_recent_dest_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "dest_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "transfer_recent_id": { + "name": "transfer_recent_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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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 + }, + "ai_assistant_enabled": { + "name": "ai_assistant_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ai_read_only_commands": { + "name": "ai_read_only_commands", + "type": "boolean", + "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 + }, + "rdp_defaults": { + "name": "rdp_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": "varchar(255)", + "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": {} + }, + "user_workspaces": { + "name": "user_workspaces", + "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": "varchar(255)", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('manual')" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{}')" + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_user_workspaces_user_id": { + "name": "idx_user_workspaces_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_workspaces_user_id_users_id_fk": { + "name": "user_workspaces_user_id_users_id_fk", + "tableFrom": "user_workspaces", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_workspaces_id": { + "name": "user_workspaces_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "user_workspaces_sync_id_unique": { + "name": "user_workspaces_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "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')" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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": {} + }, + "vault_profiles": { + "name": "vault_profiles", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vault_profiles_id": { + "name": "vault_profiles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "vault_tokens": { + "name": "vault_tokens", + "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 + }, + "profile_id": { + "name": "profile_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + "user_id", + "profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vault_tokens_id": { + "name": "vault_tokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "webauthn_credentials": { + "name": "webauthn_credentials", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('preferred')" + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "webauthn_credentials_id": { + "name": "webauthn_credentials_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 17a59fb5f..5f7615fc3 100644 --- a/drizzle/mysql/meta/_journal.json +++ b/drizzle/mysql/meta/_journal.json @@ -211,6 +211,13 @@ "when": 1790118361160, "tag": "0029_open_captain_america", "breakpoints": true + }, + { + "idx": 30, + "version": "5", + "when": 1790135163718, + "tag": "0030_sweet_warlock", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/postgres/0031_nifty_sentry.sql b/drizzle/postgres/0031_nifty_sentry.sql new file mode 100644 index 000000000..2983e460b --- /dev/null +++ b/drizzle/postgres/0031_nifty_sentry.sql @@ -0,0 +1,22 @@ +CREATE TABLE "user_external_identities" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "provider_id" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL, + "email" text, + "created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_second_factors" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "plugin_id" varchar(255) NOT NULL, + "factor_id" varchar(255) NOT NULL, + "enrolled_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user_external_identities" ADD CONSTRAINT "user_external_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_second_factors" ADD CONSTRAINT "user_second_factors_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_user_external_identities_provider_subject" ON "user_external_identities" USING btree ("provider_id","subject");--> statement-breakpoint +CREATE INDEX "idx_user_external_identities_user" ON "user_external_identities" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_user_second_factors_user_factor" ON "user_second_factors" USING btree ("user_id","plugin_id","factor_id"); \ No newline at end of file diff --git a/drizzle/postgres/meta/0031_snapshot.json b/drizzle/postgres/meta/0031_snapshot.json new file mode 100644 index 000000000..b25f32407 --- /dev/null +++ b/drizzle/postgres/meta/0031_snapshot.json @@ -0,0 +1,9994 @@ +{ + "id": "d742a01a-6bdb-4dc0-9c65-55845d2ed374", + "prevId": "cddffc1f-93b9-4d09-ab23-85160ff82a47", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_conversations": { + "name": "ai_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ai_conversations_user": { + "name": "idx_ai_conversations_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_conversations_user_id_users_id_fk": { + "name": "ai_conversations_user_id_users_id_fk", + "tableFrom": "ai_conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_messages": { + "name": "ai_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "tool_calls": { + "name": "tool_calls", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ai_messages_conversation": { + "name": "idx_ai_messages_conversation", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_messages_conversation_id_ai_conversations_id_fk": { + "name": "ai_messages_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_messages", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_proposals": { + "name": "ai_proposals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ai_proposals_user": { + "name": "idx_ai_proposals_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_proposals_conversation": { + "name": "idx_ai_proposals_conversation", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_proposals_conversation_id_ai_conversations_id_fk": { + "name": "ai_proposals_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_proposals", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_proposals_user_id_users_id_fk": { + "name": "ai_proposals_user_id_users_id_fk", + "tableFrom": "ai_proposals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_providers": { + "name": "ai_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_prefix": { + "name": "api_key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ai_providers_user_label": { + "name": "idx_ai_providers_user_label", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_providers_user_id_users_id_fk": { + "name": "ai_providers_user_id_users_id_fk", + "tableFrom": "ai_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "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.automation_channels": { + "name": "automation_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_automation_channels_pair": { + "name": "idx_automation_channels_pair", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_channels_automation_id_automations_id_fk": { + "name": "automation_channels_automation_id_automations_id_fk", + "tableFrom": "automation_channels", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_channels_channel_id_notification_channels_id_fk": { + "name": "automation_channels_channel_id_notification_channels_id_fk", + "tableFrom": "automation_channels", + "tableTo": "notification_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_run_steps": { + "name": "automation_run_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "step_index": { + "name": "step_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_type": { + "name": "step_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "truncated": { + "name": "truncated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_automation_run_steps_run": { + "name": "idx_automation_run_steps_run", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_run_steps_run_id_automation_runs_id_fk": { + "name": "automation_run_steps_run_id_automation_runs_id_fk", + "tableFrom": "automation_run_steps", + "tableTo": "automation_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_runs": { + "name": "automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_context": { + "name": "trigger_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_automation_runs_automation": { + "name": "idx_automation_runs_automation", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_automation_runs_user": { + "name": "idx_automation_runs_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_user_id_users_id_fk": { + "name": "automation_runs_user_id_users_id_fk", + "tableFrom": "automation_runs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_schedules": { + "name": "automation_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_due_at": { + "name": "next_due_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_automation_schedules_automation": { + "name": "idx_automation_schedules_automation", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_automation_schedules_due": { + "name": "idx_automation_schedules_due", + "columns": [ + { + "expression": "next_due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_schedules_automation_id_automations_id_fk": { + "name": "automation_schedules_automation_id_automations_id_fk", + "tableFrom": "automation_schedules", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_trigger_state": { + "name": "automation_trigger_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state_key": { + "name": "state_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "breach_started_at": { + "name": "breach_started_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_value": { + "name": "last_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_observed_state": { + "name": "last_observed_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_automation_trigger_state_key": { + "name": "idx_automation_trigger_state_key", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_trigger_state_automation_id_automations_id_fk": { + "name": "automation_trigger_state_automation_id_automations_id_fk", + "tableFrom": "automation_trigger_state", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "definition": { + "name": "definition", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition_version": { + "name": "definition_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "concurrency_policy": { + "name": "concurrency_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skip'" + }, + "max_run_seconds": { + "name": "max_run_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300 + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_automations_user": { + "name": "idx_automations_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automations_user_id_users_id_fk": { + "name": "automations_user_id_users_id_fk", + "tableFrom": "automations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.c2s_tunnel_presets": { + "name": "c2s_tunnel_presets", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "computer_name": { + "name": "computer_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "c2s_tunnel_presets_user_id_users_id_fk": { + "name": "c2s_tunnel_presets_user_id_users_id_fk", + "tableFrom": "c2s_tunnel_presets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collab_room_members": { + "name": "collab_room_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "room_role": { + "name": "room_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "added_by": { + "name": "added_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_collab_room_members_room_user": { + "name": "idx_collab_room_members_room_user", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collab_room_members_user": { + "name": "idx_collab_room_members_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collab_room_members_room_id_collab_rooms_id_fk": { + "name": "collab_room_members_room_id_collab_rooms_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "collab_rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_room_members_user_id_users_id_fk": { + "name": "collab_room_members_user_id_users_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_room_members_added_by_users_id_fk": { + "name": "collab_room_members_added_by_users_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "users", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collab_rooms": { + "name": "collab_rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "persistent": { + "name": "persistent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "presenter_user_id": { + "name": "presenter_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stage_protocol": { + "name": "stage_protocol", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stage_host_id": { + "name": "stage_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stage_share_id": { + "name": "stage_share_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "guest_link_token": { + "name": "guest_link_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_collab_rooms_owner": { + "name": "idx_collab_rooms_owner", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collab_rooms_guest_token": { + "name": "idx_collab_rooms_guest_token", + "columns": [ + { + "expression": "guest_link_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collab_rooms_owner_user_id_users_id_fk": { + "name": "collab_rooms_owner_user_id_users_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_rooms_presenter_user_id_users_id_fk": { + "name": "collab_rooms_presenter_user_id_users_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "users", + "columnsFrom": [ + "presenter_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "collab_rooms_stage_host_id_ssh_data_id_fk": { + "name": "collab_rooms_stage_host_id_ssh_data_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "ssh_data", + "columnsFrom": [ + "stage_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "collab_rooms_stage_share_id_session_shares_id_fk": { + "name": "collab_rooms_stage_share_id_session_shares_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "session_shares", + "columnsFrom": [ + "stage_share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.command_history": { + "name": "command_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_command_history_user_host": { + "name": "idx_command_history_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "command_history_user_id_users_id_fk": { + "name": "command_history_user_id_users_id_fk", + "tableFrom": "command_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "command_history_host_id_ssh_data_id_fk": { + "name": "command_history_host_id_ssh_data_id_fk", + "tableFrom": "command_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "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": "varchar(255)", + "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.dashboard_service_links": { + "name": "dashboard_service_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dismissed_alerts": { + "name": "dismissed_alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_dismissed_alerts_user_id": { + "name": "idx_dismissed_alerts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_manager_pinned": { + "name": "file_manager_pinned", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_file_manager_pinned_user": { + "name": "idx_file_manager_pinned_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_manager_pinned_user_id_users_id_fk": { + "name": "file_manager_pinned_user_id_users_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_pinned_host_id_ssh_data_id_fk": { + "name": "file_manager_pinned_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_manager_recent": { + "name": "file_manager_recent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_opened": { + "name": "last_opened", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_file_manager_recent_user": { + "name": "idx_file_manager_recent_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_manager_recent_user_id_users_id_fk": { + "name": "file_manager_recent_user_id_users_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_recent_host_id_ssh_data_id_fk": { + "name": "file_manager_recent_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_manager_shortcuts": { + "name": "file_manager_shortcuts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_file_manager_shortcuts_user": { + "name": "idx_file_manager_shortcuts_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_manager_shortcuts_user_id_users_id_fk": { + "name": "file_manager_shortcuts_user_id_users_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_shortcuts_host_id_ssh_data_id_fk": { + "name": "file_manager_shortcuts_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fleet_inventory": { + "name": "fleet_inventory", + "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 + }, + "os_pretty_name": { + "name": "os_pretty_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kernel": { + "name": "kernel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "architecture": { + "name": "architecture", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uptime_seconds": { + "name": "uptime_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "package_manager": { + "name": "package_manager", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collected_at": { + "name": "collected_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_fleet_inventory_host": { + "name": "idx_fleet_inventory_host", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_fleet_inventory_user": { + "name": "idx_fleet_inventory_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fleet_inventory_host_id_ssh_data_id_fk": { + "name": "fleet_inventory_host_id_ssh_data_id_fk", + "tableFrom": "fleet_inventory", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fleet_inventory_user_id_users_id_fk": { + "name": "fleet_inventory_user_id_users_id_fk", + "tableFrom": "fleet_inventory", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fleet_members": { + "name": "fleet_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "fleet_id": { + "name": "fleet_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_fleet_members_fleet_host": { + "name": "idx_fleet_members_fleet_host", + "columns": [ + { + "expression": "fleet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_fleet_members_host": { + "name": "idx_fleet_members_host", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fleet_members_fleet_id_fleets_id_fk": { + "name": "fleet_members_fleet_id_fleets_id_fk", + "tableFrom": "fleet_members", + "tableTo": "fleets", + "columnsFrom": [ + "fleet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fleet_members_host_id_ssh_data_id_fk": { + "name": "fleet_members_host_id_ssh_data_id_fk", + "tableFrom": "fleet_members", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fleets": { + "name": "fleets", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag_rules": { + "name": "tag_rules", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "fleets_user_id_users_id_fk": { + "name": "fleets_user_id_users_id_fk", + "tableFrom": "fleets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "fleets_sync_id_unique": { + "name": "fleets_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "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": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "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.homepage_items": { + "name": "homepage_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_homepage_items_user_id": { + "name": "idx_homepage_items_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.homepage_layouts": { + "name": "homepage_layouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "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": "varchar(255)", + "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_health_checks": { + "name": "host_health_checks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checks": { + "name": "checks", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_health_checks_user_host": { + "name": "idx_host_health_checks_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_health_checks_user_id_users_id_fk": { + "name": "host_health_checks_user_id_users_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_checks_host_id_ssh_data_id_fk": { + "name": "host_health_checks_host_id_ssh_data_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_health_history": { + "name": "host_health_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_health_history_user_id_users_id_fk": { + "name": "host_health_history_user_id_users_id_fk", + "tableFrom": "host_health_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_history_host_id_ssh_data_id_fk": { + "name": "host_health_history_host_id_ssh_data_id_fk", + "tableFrom": "host_health_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_metrics_history": { + "name": "host_metrics_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_metrics_history_host_id_ssh_data_id_fk": { + "name": "host_metrics_history_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_metrics_preferences": { + "name": "host_metrics_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_metrics_prefs_user_host": { + "name": "idx_host_metrics_prefs_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_metrics_preferences_user_id_users_id_fk": { + "name": "host_metrics_preferences_user_id_users_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_metrics_preferences_host_id_ssh_data_id_fk": { + "name": "host_metrics_preferences_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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 + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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 + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_docker": { + "name": "enable_docker", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_web_ui": { + "name": "enable_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_terminal_toolbar": { + "name": "enable_terminal_toolbar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_ai_assistant": { + "name": "enable_ai_assistant", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stats_config": { + "name": "stats_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "docker_config": { + "name": "docker_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web_ui_config": { + "name": "web_ui_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_proxmox": { + "name": "enable_proxmox", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "proxmox_config": { + "name": "proxmox_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_proxmox_stats": { + "name": "enable_proxmox_stats", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "proxmox_stats_config": { + "name": "proxmox_stats_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_config": { + "name": "terminal_config", + "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 + }, + "enable_rdp": { + "name": "enable_rdp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_vnc": { + "name": "enable_vnc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_telnet": { + "name": "enable_telnet", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 22 + }, + "rdp_port": { + "name": "rdp_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3389 + }, + "vnc_port": { + "name": "vnc_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5900 + }, + "telnet_port": { + "name": "telnet_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 23 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_security": { + "name": "rdp_security", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_ignore_cert": { + "name": "rdp_ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "security": { + "name": "security", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ignore_cert": { + "name": "ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "guacamole_config": { + "name": "guacamole_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "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 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_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.network_topology": { + "name": "network_topology", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "topology": { + "name": "topology", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "network_topology_user_id_users_id_fk": { + "name": "network_topology_user_id_users_id_fk", + "tableFrom": "network_topology", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channels": { + "name": "notification_channels", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opkssh_tokens": { + "name": "opkssh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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.proxmox_node_history": { + "name": "proxmox_node_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "proxmox_node_history_host_id_ssh_data_id_fk": { + "name": "proxmox_node_history_host_id_ssh_data_id_fk", + "tableFrom": "proxmox_node_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proxmox_stats_preferences": { + "name": "proxmox_stats_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_proxmox_stats_prefs_user_host": { + "name": "idx_proxmox_stats_prefs_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proxmox_stats_preferences_user_id_users_id_fk": { + "name": "proxmox_stats_preferences_user_id_users_id_fk", + "tableFrom": "proxmox_stats_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proxmox_stats_preferences_host_id_ssh_data_id_fk": { + "name": "proxmox_stats_preferences_host_id_ssh_data_id_fk", + "tableFrom": "proxmox_stats_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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.secret_sources": { + "name": "secret_sources", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'onepassword-connect'" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_secret_sources_user": { + "name": "idx_secret_sources_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_sources_user_id_users_id_fk": { + "name": "secret_sources_user_id_users_id_fk", + "tableFrom": "secret_sources", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_recordings": { + "name": "session_recordings", + "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 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_id": { + "name": "access_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "commands": { + "name": "commands", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dangerous_actions": { + "name": "dangerous_actions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recording_path": { + "name": "recording_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "terminated_by_owner": { + "name": "terminated_by_owner", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "termination_reason": { + "name": "termination_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_session_recordings_user_started": { + "name": "idx_session_recordings_user_started", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_recordings_host": { + "name": "idx_session_recordings_host", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_recordings_host_id_ssh_data_id_fk": { + "name": "session_recordings_host_id_ssh_data_id_fk", + "tableFrom": "session_recordings", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_recordings_user_id_users_id_fk": { + "name": "session_recordings_user_id_users_id_fk", + "tableFrom": "session_recordings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "session_recordings_access_id_host_access_id_fk": { + "name": "session_recordings_access_id_host_access_id_fk", + "tableFrom": "session_recordings", + "tableTo": "host_access", + "columnsFrom": [ + "access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_share_participants": { + "name": "session_share_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "share_id": { + "name": "share_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "guest_label": { + "name": "guest_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "left_at": { + "name": "left_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_share_participants_share_id_session_shares_id_fk": { + "name": "session_share_participants_share_id_session_shares_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "session_shares", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_share_participants_user_id_users_id_fk": { + "name": "session_share_participants_user_id_users_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_shares": { + "name": "session_shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "tab_instance_id": { + "name": "tab_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "share_type": { + "name": "share_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "link_token": { + "name": "link_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'read-only'" + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_joined_at": { + "name": "last_joined_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "join_count": { + "name": "join_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_session_shares_session_id": { + "name": "idx_session_shares_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_shares_host_id": { + "name": "idx_session_shares_host_id", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_shares_host_id_ssh_data_id_fk": { + "name": "session_shares_host_id_ssh_data_id_fk", + "tableFrom": "session_shares", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_owner_user_id_users_id_fk": { + "name": "session_shares_owner_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_target_user_id_users_id_fk": { + "name": "session_shares_target_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_shares_link_token_unique": { + "name": "session_shares_link_token_unique", + "nullsNotDistinct": false, + "columns": [ + "link_token" + ] + } + }, + "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": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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.snippet_access": { + "name": "snippet_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "snippet_id": { + "name": "snippet_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": "'view'" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_snippet_access_user_id": { + "name": "idx_snippet_access_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_snippet_access_snippet_id": { + "name": "idx_snippet_access_snippet_id", + "columns": [ + { + "expression": "snippet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_snippet_access_role_id": { + "name": "idx_snippet_access_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "snippet_access_snippet_id_snippets_id_fk": { + "name": "snippet_access_snippet_id_snippets_id_fk", + "tableFrom": "snippet_access", + "tableTo": "snippets", + "columnsFrom": [ + "snippet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_user_id_users_id_fk": { + "name": "snippet_access_user_id_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_role_id_roles_id_fk": { + "name": "snippet_access_role_id_roles_id_fk", + "tableFrom": "snippet_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_granted_by_users_id_fk": { + "name": "snippet_access_granted_by_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snippet_folders": { + "name": "snippet_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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "snippet_folders_user_id_users_id_fk": { + "name": "snippet_folders_user_id_users_id_fk", + "tableFrom": "snippet_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "snippet_folders_sync_id_unique": { + "name": "snippet_folders_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snippets": { + "name": "snippets", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "host_filter": { + "name": "host_filter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_note": { + "name": "is_note", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_snippets_user_id": { + "name": "idx_snippets_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "snippets_user_id_users_id_fk": { + "name": "snippets_user_id_users_id_fk", + "tableFrom": "snippets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "snippets_sync_id_unique": { + "name": "snippets_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "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 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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": "varchar(255)", + "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 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_tombstones": { + "name": "sync_tombstones", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identities": { + "name": "termix_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identity_ca": { + "name": "termix_identity_ca", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "validity_days": { + "name": "validity_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "nullsNotDistinct": false, + "columns": [ + "identity_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identity_keys": { + "name": "termix_identity_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tmux_session_tags": { + "name": "tmux_session_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "session_name": { + "name": "session_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "tmux_session_tags_user_id_users_id_fk": { + "name": "tmux_session_tags_user_id_users_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tmux_session_tags_host_id_ssh_data_id_fk": { + "name": "tmux_session_tags_host_id_ssh_data_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transfer_recent": { + "name": "transfer_recent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_host_id": { + "name": "source_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dest_host_id": { + "name": "dest_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dest_path_label": { + "name": "dest_path_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_transfer_recent_user": { + "name": "idx_transfer_recent_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transfer_recent_user_id_users_id_fk": { + "name": "transfer_recent_user_id_users_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_source_host_id_ssh_data_id_fk": { + "name": "transfer_recent_source_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "source_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_dest_host_id_ssh_data_id_fk": { + "name": "transfer_recent_dest_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "dest_host_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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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 + }, + "ai_assistant_enabled": { + "name": "ai_assistant_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ai_read_only_commands": { + "name": "ai_read_only_commands", + "type": "boolean", + "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 + }, + "rdp_defaults": { + "name": "rdp_defaults", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_macros": { + "name": "terminal_macros", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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.user_workspaces": { + "name": "user_workspaces", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_workspaces_user_id": { + "name": "idx_user_workspaces_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_workspaces_user_id_users_id_fk": { + "name": "user_workspaces_user_id_users_id_fk", + "tableFrom": "user_workspaces", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_workspaces_sync_id_unique": { + "name": "user_workspaces_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "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'" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "public.vault_profiles": { + "name": "vault_profiles", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vault_tokens": { + "name": "vault_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webauthn_credentials": { + "name": "webauthn_credentials", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preferred'" + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 d1cbcf31c..f62ca113d 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1790118360674, "tag": "0030_cynical_callisto", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1790135163099, + "tag": "0031_nifty_sentry", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0027_luxuriant_cargill.sql b/drizzle/sqlite/0027_luxuriant_cargill.sql new file mode 100644 index 000000000..fb7eaeb57 --- /dev/null +++ b/drizzle/sqlite/0027_luxuriant_cargill.sql @@ -0,0 +1,22 @@ +CREATE TABLE `user_external_identities` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `provider_id` text NOT NULL, + `subject` text NOT NULL, + `email` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_user_external_identities_provider_subject` ON `user_external_identities` (`provider_id`,`subject`);--> statement-breakpoint +CREATE INDEX `idx_user_external_identities_user` ON `user_external_identities` (`user_id`);--> statement-breakpoint +CREATE TABLE `user_second_factors` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `plugin_id` text NOT NULL, + `factor_id` text NOT NULL, + `enrolled_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_user_second_factors_user_factor` ON `user_second_factors` (`user_id`,`plugin_id`,`factor_id`); \ No newline at end of file diff --git a/drizzle/sqlite/meta/0027_snapshot.json b/drizzle/sqlite/meta/0027_snapshot.json new file mode 100644 index 000000000..307224e2b --- /dev/null +++ b/drizzle/sqlite/meta/0027_snapshot.json @@ -0,0 +1,9636 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1de881ee-a464-4b27-8084-90a13688bfa8", + "prevId": "50b36b42-89b2-4526-aea9-d9c5363cbacd", + "tables": { + "ai_conversations": { + "name": "ai_conversations", + "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 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "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_ai_conversations_user": { + "name": "idx_ai_conversations_user", + "columns": [ + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ai_conversations_user_id_users_id_fk": { + "name": "ai_conversations_user_id_users_id_fk", + "tableFrom": "ai_conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_messages": { + "name": "ai_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "tool_calls": { + "name": "tool_calls", + "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_ai_messages_conversation": { + "name": "idx_ai_messages_conversation", + "columns": [ + "conversation_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ai_messages_conversation_id_ai_conversations_id_fk": { + "name": "ai_messages_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_messages", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_proposals": { + "name": "ai_proposals", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_summary": { + "name": "result_summary", + "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_ai_proposals_user": { + "name": "idx_ai_proposals_user", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_ai_proposals_conversation": { + "name": "idx_ai_proposals_conversation", + "columns": [ + "conversation_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ai_proposals_conversation_id_ai_conversations_id_fk": { + "name": "ai_proposals_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_proposals", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_proposals_user_id_users_id_fk": { + "name": "ai_proposals_user_id_users_id_fk", + "tableFrom": "ai_proposals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_providers": { + "name": "ai_providers", + "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_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_prefix": { + "name": "api_key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "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_ai_providers_user_label": { + "name": "idx_ai_providers_user_label", + "columns": [ + "user_id", + "label" + ], + "isUnique": true + } + }, + "foreignKeys": { + "ai_providers_user_id_users_id_fk": { + "name": "ai_providers_user_id_users_id_fk", + "tableFrom": "ai_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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": {} + }, + "automation_channels": { + "name": "automation_channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_channels_pair": { + "name": "idx_automation_channels_pair", + "columns": [ + "automation_id", + "channel_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "automation_channels_automation_id_automations_id_fk": { + "name": "automation_channels_automation_id_automations_id_fk", + "tableFrom": "automation_channels", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_channels_channel_id_notification_channels_id_fk": { + "name": "automation_channels_channel_id_notification_channels_id_fk", + "tableFrom": "automation_channels", + "tableTo": "notification_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_run_steps": { + "name": "automation_run_steps", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step_index": { + "name": "step_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step_id": { + "name": "step_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step_type": { + "name": "step_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "truncated": { + "name": "truncated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_automation_run_steps_run": { + "name": "idx_automation_run_steps_run", + "columns": [ + "run_id", + "step_index" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_run_steps_run_id_automation_runs_id_fk": { + "name": "automation_run_steps_run_id_automation_runs_id_fk", + "tableFrom": "automation_run_steps", + "tableTo": "automation_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_context": { + "name": "trigger_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_runs_automation": { + "name": "idx_automation_runs_automation", + "columns": [ + "automation_id", + "started_at" + ], + "isUnique": false + }, + "idx_automation_runs_user": { + "name": "idx_automation_runs_user", + "columns": [ + "user_id", + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_user_id_users_id_fk": { + "name": "automation_runs_user_id_users_id_fk", + "tableFrom": "automation_runs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_schedules": { + "name": "automation_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_due_at": { + "name": "next_due_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_schedules_automation": { + "name": "idx_automation_schedules_automation", + "columns": [ + "automation_id" + ], + "isUnique": true + }, + "idx_automation_schedules_due": { + "name": "idx_automation_schedules_due", + "columns": [ + "next_due_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_schedules_automation_id_automations_id_fk": { + "name": "automation_schedules_automation_id_automations_id_fk", + "tableFrom": "automation_schedules", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_trigger_state": { + "name": "automation_trigger_state", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state_key": { + "name": "state_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "breach_started_at": { + "name": "breach_started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_value": { + "name": "last_value", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_observed_state": { + "name": "last_observed_state", + "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_automation_trigger_state_key": { + "name": "idx_automation_trigger_state_key", + "columns": [ + "automation_id", + "state_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "automation_trigger_state_automation_id_automations_id_fk": { + "name": "automation_trigger_state_automation_id_automations_id_fk", + "tableFrom": "automation_trigger_state", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "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 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "definition": { + "name": "definition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "definition_version": { + "name": "definition_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "concurrency_policy": { + "name": "concurrency_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'skip'" + }, + "max_run_seconds": { + "name": "max_run_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_status": { + "name": "last_run_status", + "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_automations_user": { + "name": "idx_automations_user", + "columns": [ + "user_id", + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automations_user_id_users_id_fk": { + "name": "automations_user_id_users_id_fk", + "tableFrom": "automations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "c2s_tunnel_presets": { + "name": "c2s_tunnel_presets", + "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 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "computer_name": { + "name": "computer_name", + "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": { + "c2s_tunnel_presets_user_id_users_id_fk": { + "name": "c2s_tunnel_presets_user_id_users_id_fk", + "tableFrom": "c2s_tunnel_presets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collab_room_members": { + "name": "collab_room_members", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_role": { + "name": "room_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "added_by": { + "name": "added_by", + "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_collab_room_members_room_user": { + "name": "idx_collab_room_members_room_user", + "columns": [ + "room_id", + "user_id" + ], + "isUnique": true + }, + "idx_collab_room_members_user": { + "name": "idx_collab_room_members_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collab_room_members_room_id_collab_rooms_id_fk": { + "name": "collab_room_members_room_id_collab_rooms_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "collab_rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_room_members_user_id_users_id_fk": { + "name": "collab_room_members_user_id_users_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_room_members_added_by_users_id_fk": { + "name": "collab_room_members_added_by_users_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "users", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collab_rooms": { + "name": "collab_rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "persistent": { + "name": "persistent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "presenter_user_id": { + "name": "presenter_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_protocol": { + "name": "stage_protocol", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_host_id": { + "name": "stage_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_share_id": { + "name": "stage_share_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guest_link_token": { + "name": "guest_link_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_collab_rooms_owner": { + "name": "idx_collab_rooms_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "idx_collab_rooms_guest_token": { + "name": "idx_collab_rooms_guest_token", + "columns": [ + "guest_link_token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "collab_rooms_owner_user_id_users_id_fk": { + "name": "collab_rooms_owner_user_id_users_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_rooms_presenter_user_id_users_id_fk": { + "name": "collab_rooms_presenter_user_id_users_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "users", + "columnsFrom": [ + "presenter_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "collab_rooms_stage_host_id_ssh_data_id_fk": { + "name": "collab_rooms_stage_host_id_ssh_data_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "ssh_data", + "columnsFrom": [ + "stage_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "collab_rooms_stage_share_id_session_shares_id_fk": { + "name": "collab_rooms_stage_share_id_session_shares_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "session_shares", + "columnsFrom": [ + "stage_share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "command_history": { + "name": "command_history", + "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 + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "executed_at": { + "name": "executed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_command_history_user_host": { + "name": "idx_command_history_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "command_history_user_id_users_id_fk": { + "name": "command_history_user_id_users_id_fk", + "tableFrom": "command_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "command_history_host_id_ssh_data_id_fk": { + "name": "command_history_host_id_ssh_data_id_fk", + "tableFrom": "command_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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": {} + }, + "dashboard_service_links": { + "name": "dashboard_service_links", + "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 + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_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": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dismissed_alerts": { + "name": "dismissed_alerts", + "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 + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_dismissed_alerts_user_id": { + "name": "idx_dismissed_alerts_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_manager_pinned": { + "name": "file_manager_pinned", + "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 + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_file_manager_pinned_user": { + "name": "idx_file_manager_pinned_user", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "file_manager_pinned_user_id_users_id_fk": { + "name": "file_manager_pinned_user_id_users_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_pinned_host_id_ssh_data_id_fk": { + "name": "file_manager_pinned_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_manager_recent": { + "name": "file_manager_recent", + "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 + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened": { + "name": "last_opened", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_file_manager_recent_user": { + "name": "idx_file_manager_recent_user", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "file_manager_recent_user_id_users_id_fk": { + "name": "file_manager_recent_user_id_users_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_recent_host_id_ssh_data_id_fk": { + "name": "file_manager_recent_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_manager_shortcuts": { + "name": "file_manager_shortcuts", + "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 + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_file_manager_shortcuts_user": { + "name": "idx_file_manager_shortcuts_user", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "file_manager_shortcuts_user_id_users_id_fk": { + "name": "file_manager_shortcuts_user_id_users_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_shortcuts_host_id_ssh_data_id_fk": { + "name": "file_manager_shortcuts_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "fleet_inventory": { + "name": "fleet_inventory", + "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 + }, + "os_pretty_name": { + "name": "os_pretty_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kernel": { + "name": "kernel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "architecture": { + "name": "architecture", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uptime_seconds": { + "name": "uptime_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "package_manager": { + "name": "package_manager", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collected_at": { + "name": "collected_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_fleet_inventory_host": { + "name": "idx_fleet_inventory_host", + "columns": [ + "host_id", + "user_id" + ], + "isUnique": true + }, + "idx_fleet_inventory_user": { + "name": "idx_fleet_inventory_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "fleet_inventory_host_id_ssh_data_id_fk": { + "name": "fleet_inventory_host_id_ssh_data_id_fk", + "tableFrom": "fleet_inventory", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fleet_inventory_user_id_users_id_fk": { + "name": "fleet_inventory_user_id_users_id_fk", + "tableFrom": "fleet_inventory", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "fleet_members": { + "name": "fleet_members", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "fleet_id": { + "name": "fleet_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_fleet_members_fleet_host": { + "name": "idx_fleet_members_fleet_host", + "columns": [ + "fleet_id", + "host_id" + ], + "isUnique": true + }, + "idx_fleet_members_host": { + "name": "idx_fleet_members_host", + "columns": [ + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "fleet_members_fleet_id_fleets_id_fk": { + "name": "fleet_members_fleet_id_fleets_id_fk", + "tableFrom": "fleet_members", + "tableTo": "fleets", + "columnsFrom": [ + "fleet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fleet_members_host_id_ssh_data_id_fk": { + "name": "fleet_members_host_id_ssh_data_id_fk", + "tableFrom": "fleet_members", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "fleets": { + "name": "fleets", + "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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tag_rules": { + "name": "tag_rules", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_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": { + "fleets_sync_id_unique": { + "name": "fleets_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "fleets_user_id_users_id_fk": { + "name": "fleets_user_id_users_id_fk", + "tableFrom": "fleets", + "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": {} + }, + "homepage_items": { + "name": "homepage_items", + "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_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_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": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_homepage_items_user_id": { + "name": "idx_homepage_items_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "homepage_layouts": { + "name": "homepage_layouts", + "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 + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "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_health_checks": { + "name": "host_health_checks", + "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 + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checks": { + "name": "checks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "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_health_checks_user_host": { + "name": "idx_host_health_checks_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_health_checks_user_id_users_id_fk": { + "name": "host_health_checks_user_id_users_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_checks_host_id_ssh_data_id_fk": { + "name": "host_health_checks_host_id_ssh_data_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_health_history": { + "name": "host_health_history", + "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 + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_health_history_user_id_users_id_fk": { + "name": "host_health_history_user_id_users_id_fk", + "tableFrom": "host_health_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_history_host_id_ssh_data_id_fk": { + "name": "host_health_history_host_id_ssh_data_id_fk", + "tableFrom": "host_health_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_metrics_history": { + "name": "host_metrics_history", + "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 + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_metrics_history_host_id_ssh_data_id_fk": { + "name": "host_metrics_history_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_metrics_preferences": { + "name": "host_metrics_preferences", + "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 + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "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": { + "idx_host_metrics_prefs_user_host": { + "name": "idx_host_metrics_prefs_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_metrics_preferences_user_id_users_id_fk": { + "name": "host_metrics_preferences_user_id_users_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_metrics_preferences_host_id_ssh_data_id_fk": { + "name": "host_metrics_preferences_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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 + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_docker": { + "name": "enable_docker", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_web_ui": { + "name": "enable_web_ui", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_terminal_toolbar": { + "name": "enable_terminal_toolbar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_ai_assistant": { + "name": "enable_ai_assistant", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats_config": { + "name": "stats_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "docker_config": { + "name": "docker_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "web_ui_config": { + "name": "web_ui_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_proxmox": { + "name": "enable_proxmox", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "proxmox_config": { + "name": "proxmox_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_proxmox_stats": { + "name": "enable_proxmox_stats", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "proxmox_stats_config": { + "name": "proxmox_stats_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_config": { + "name": "terminal_config", + "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 + }, + "enable_rdp": { + "name": "enable_rdp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_vnc": { + "name": "enable_vnc", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_telnet": { + "name": "enable_telnet", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "rdp_port": { + "name": "rdp_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3389 + }, + "vnc_port": { + "name": "vnc_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 5900 + }, + "telnet_port": { + "name": "telnet_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 23 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_security": { + "name": "rdp_security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_ignore_cert": { + "name": "rdp_ignore_cert", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "security": { + "name": "security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignore_cert": { + "name": "ignore_cert", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "guacamole_config": { + "name": "guacamole_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "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 + }, + "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" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "network_topology": { + "name": "network_topology", + "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 + }, + "topology": { + "name": "topology", + "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": { + "network_topology_user_id_users_id_fk": { + "name": "network_topology_user_id_users_id_fk", + "tableFrom": "network_topology", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_channels": { + "name": "notification_channels", + "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 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "opkssh_tokens": { + "name": "opkssh_tokens", + "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 + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audience": { + "name": "audience", + "type": "text", + "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_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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": {} + }, + "proxmox_node_history": { + "name": "proxmox_node_history", + "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 + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "proxmox_node_history_host_id_ssh_data_id_fk": { + "name": "proxmox_node_history_host_id_ssh_data_id_fk", + "tableFrom": "proxmox_node_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxmox_stats_preferences": { + "name": "proxmox_stats_preferences", + "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 + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "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": { + "idx_proxmox_stats_prefs_user_host": { + "name": "idx_proxmox_stats_prefs_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "proxmox_stats_preferences_user_id_users_id_fk": { + "name": "proxmox_stats_preferences_user_id_users_id_fk", + "tableFrom": "proxmox_stats_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proxmox_stats_preferences_host_id_ssh_data_id_fk": { + "name": "proxmox_stats_preferences_host_id_ssh_data_id_fk", + "tableFrom": "proxmox_stats_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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": {} + }, + "secret_sources": { + "name": "secret_sources", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'onepassword-connect'" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shared": { + "name": "shared", + "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": { + "idx_secret_sources_user": { + "name": "idx_secret_sources_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "secret_sources_user_id_users_id_fk": { + "name": "secret_sources_user_id_users_id_fk", + "tableFrom": "secret_sources", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_recordings": { + "name": "session_recordings", + "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 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_id": { + "name": "access_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commands": { + "name": "commands", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dangerous_actions": { + "name": "dangerous_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recording_path": { + "name": "recording_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'text'" + }, + "terminated_by_owner": { + "name": "terminated_by_owner", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "termination_reason": { + "name": "termination_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_session_recordings_user_started": { + "name": "idx_session_recordings_user_started", + "columns": [ + "user_id", + "started_at" + ], + "isUnique": false + }, + "idx_session_recordings_host": { + "name": "idx_session_recordings_host", + "columns": [ + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_recordings_host_id_ssh_data_id_fk": { + "name": "session_recordings_host_id_ssh_data_id_fk", + "tableFrom": "session_recordings", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_recordings_user_id_users_id_fk": { + "name": "session_recordings_user_id_users_id_fk", + "tableFrom": "session_recordings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "session_recordings_access_id_host_access_id_fk": { + "name": "session_recordings_access_id_host_access_id_fk", + "tableFrom": "session_recordings", + "tableTo": "host_access", + "columnsFrom": [ + "access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_share_participants": { + "name": "session_share_participants", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guest_label": { + "name": "guest_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "left_at": { + "name": "left_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_share_participants_share_id_session_shares_id_fk": { + "name": "session_share_participants_share_id_session_shares_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "session_shares", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_share_participants_user_id_users_id_fk": { + "name": "session_share_participants_user_id_users_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_shares": { + "name": "session_shares", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_instance_id": { + "name": "tab_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_type": { + "name": "share_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'read-only'" + }, + "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 + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_joined_at": { + "name": "last_joined_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "join_count": { + "name": "join_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "session_shares_link_token_unique": { + "name": "session_shares_link_token_unique", + "columns": [ + "link_token" + ], + "isUnique": true + }, + "idx_session_shares_session_id": { + "name": "idx_session_shares_session_id", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "idx_session_shares_host_id": { + "name": "idx_session_shares_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_shares_host_id_ssh_data_id_fk": { + "name": "session_shares_host_id_ssh_data_id_fk", + "tableFrom": "session_shares", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_owner_user_id_users_id_fk": { + "name": "session_shares_owner_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_target_user_id_users_id_fk": { + "name": "session_shares_target_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "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": {} + }, + "snippet_access": { + "name": "snippet_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "snippet_id": { + "name": "snippet_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": "'view'" + }, + "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_snippet_access_user_id": { + "name": "idx_snippet_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_snippet_access_snippet_id": { + "name": "idx_snippet_access_snippet_id", + "columns": [ + "snippet_id" + ], + "isUnique": false + }, + "idx_snippet_access_role_id": { + "name": "idx_snippet_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "snippet_access_snippet_id_snippets_id_fk": { + "name": "snippet_access_snippet_id_snippets_id_fk", + "tableFrom": "snippet_access", + "tableTo": "snippets", + "columnsFrom": [ + "snippet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_user_id_users_id_fk": { + "name": "snippet_access_user_id_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_role_id_roles_id_fk": { + "name": "snippet_access_role_id_roles_id_fk", + "tableFrom": "snippet_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_granted_by_users_id_fk": { + "name": "snippet_access_granted_by_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "snippet_folders": { + "name": "snippet_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 + }, + "sync_id": { + "name": "sync_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": { + "snippet_folders_sync_id_unique": { + "name": "snippet_folders_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "snippet_folders_user_id_users_id_fk": { + "name": "snippet_folders_user_id_users_id_fk", + "tableFrom": "snippet_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "snippets": { + "name": "snippets", + "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 + }, + "content": { + "name": "content", + "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 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_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" + }, + "host_filter": { + "name": "host_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_note": { + "name": "is_note", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "snippets_sync_id_unique": { + "name": "snippets_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_snippets_user_id": { + "name": "idx_snippets_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "snippets_user_id_users_id_fk": { + "name": "snippets_user_id_users_id_fk", + "tableFrom": "snippets", + "tableTo": "users", + "columnsFrom": [ + "user_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 + }, + "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 + }, + "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": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_tombstones": { + "name": "sync_tombstones", + "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 + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identities": { + "name": "termix_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 + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "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": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "columns": [ + "handle" + ], + "isUnique": true + } + }, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identity_ca": { + "name": "termix_identity_ca", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validity_days": { + "name": "validity_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "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": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "columns": [ + "identity_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identity_keys": { + "name": "termix_identity_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tmux_session_tags": { + "name": "tmux_session_tags", + "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 + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_name": { + "name": "session_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "tmux_session_tags_user_id_users_id_fk": { + "name": "tmux_session_tags_user_id_users_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tmux_session_tags_host_id_ssh_data_id_fk": { + "name": "tmux_session_tags_host_id_ssh_data_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_recent": { + "name": "transfer_recent", + "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 + }, + "source_host_id": { + "name": "source_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_host_id": { + "name": "dest_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path_label": { + "name": "dest_path_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_transfer_recent_user": { + "name": "idx_transfer_recent_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_recent_user_id_users_id_fk": { + "name": "transfer_recent_user_id_users_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_source_host_id_ssh_data_id_fk": { + "name": "transfer_recent_source_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "source_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_dest_host_id_ssh_data_id_fk": { + "name": "transfer_recent_dest_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "dest_host_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 + }, + "ai_assistant_enabled": { + "name": "ai_assistant_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ai_read_only_commands": { + "name": "ai_read_only_commands", + "type": "integer", + "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 + }, + "rdp_defaults": { + "name": "rdp_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": {} + }, + "user_workspaces": { + "name": "user_workspaces", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "sync_id": { + "name": "sync_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" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_workspaces_sync_id_unique": { + "name": "user_workspaces_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_user_workspaces_user_id": { + "name": "idx_user_workspaces_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_workspaces_user_id_users_id_fk": { + "name": "user_workspaces_user_id_users_id_fk", + "tableFrom": "user_workspaces", + "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'" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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": {} + }, + "vault_profiles": { + "name": "vault_profiles", + "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 + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_id": { + "name": "sync_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": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_tokens": { + "name": "vault_tokens", + "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 + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "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": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + "user_id", + "profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webauthn_credentials": { + "name": "webauthn_credentials", + "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 + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'preferred'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 3d2bb5521..0104fae2e 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1790118360159, "tag": "0026_fair_sauron", "breakpoints": true + }, + { + "idx": 27, + "version": "6", + "when": 1790135162496, + "tag": "0027_luxuriant_cargill", + "breakpoints": true } ] } \ No newline at end of file diff --git a/electron/main.cjs b/electron/main.cjs index dcb69b099..4f65748b3 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1674,13 +1674,21 @@ ipcMain.handle( const success = url.searchParams.get("success"); const error = url.searchParams.get("error"); const token = url.searchParams.get("token"); + const secondFactor = url.searchParams.get("second_factor") === "1"; res.writeHead(200, { "Content-Type": "text/html" }); res.end( - `

${success === "true" ? "Authentication successful!" : "Authentication failed."}

You can close this tab and return to Termix.

`, + `

${success === "true" || secondFactor ? "Authentication successful!" : "Authentication failed."}

You can close this tab and return to Termix.

`, ); - if (success === "true") { + if (secondFactor) { + finish({ + success: false, + secondFactor: true, + tempToken: url.searchParams.get("temp_token"), + factors: url.searchParams.get("second_factors"), + }); + } else if (success === "true") { finish({ success: true, token }); } else { finish({ diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index 1295bf529..6297f8937 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -309,6 +309,11 @@ Anonymous shared-session and collab pages have no session. They load only plugins whose manifest sets `contributes.guest: true`, from the public `GET /plugins/public`, and `app.guest` is true there. +The login screen runs a smaller pass before anyone signs in: it activates only +enabled plugins that contribute `loginMethods` or `secondFactors`, listed by +the public `GET /plugins/public-manifest`. They stay active after login, and +the full pass does not load the same bundle twice. See Auth below. + #### Shared modules and the import map A bundle keeps `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`, @@ -360,14 +365,15 @@ boundary and Suspense. | `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns | | `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result | | `registerSshAuthEditor` | An SSH auth method's editor in the host editor | -| `registerLoginMethod`, `registerSecondFactorUI` | Typed now, wired by A8 | +| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) | | `api`, `wsUrl(path)` | axios on `/plugin-api//` and the plugin's WebSocket URL | | `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces | | `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup | Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short name resolves to `.`), `useSettings`, `useHost`, `useHosts`, -`useCurrentUser`, `useTheme`, `useToast`, `usePluginApi`, `useTabs`. The SDK +`useCurrentUser`, `useTheme`, `useToast`, `usePluginApi`, `useTabs`, +`useSshAuthTypes` (every SSH auth type the server knows, with its plugin). The SDK has no runtime dependencies: the hooks delegate to a host bridge core installs. Slots core owns: `terminal.toolbar`, `terminal.dock`, `terminal.overlay` @@ -461,7 +467,153 @@ panel, card, host editor section, settings component or slot, and Core keeps password login, sessions, API keys, trusted proxy login, Electron auto-session and trusted devices, plus the base SSH auth types password, key, stored credential, agent and none. Every other login method, second factor and -SSH auth method is a plugin through `ctx.auth`. **A8.** +SSH auth method is a plugin through `ctx.auth`. + +In 2.9.0, OIDC (and GitHub, Google), LDAP, passkeys, TOTP, OPKSSH, Step-CA, +Vault, Tailscale and Warpgate still live in core. They register through the +same interfaces from `src/backend/auth/legacy-providers.ts` and +`src/ui/auth/legacy-auth-ui.tsx` with `pluginId: "core"`. Phase C moves each +one into its plugin by moving its block out of those two files; D1 deletes them. +Nothing else in core branches on those type names. + +#### One SSH connect pipeline + +Every SSH connection, core's and plugins', goes through +`src/backend/hosts/connect/`: + +1. `resolveHostById` resolves the host for the acting user: RBAC, owner-key + decryption, shared-host overrides and shared agent, and `op://` external + secret references. +2. `buildConnectConfig` builds the ssh2 config: the defaults for the + connection's purpose (terminal, file-manager, tmux, metrics, fleet, docker, + ...), the host key verifier, then the auth provider's `prepare`. +3. `openSshTransport` does port knocking, the Cloudflare Access tunnel, the + jump host chain (every hop through steps 1 and 2) or SOCKS5. +4. `connectHost` connects with a keyboard-interactive handler: a prompt channel + when a person can answer (terminal, file manager, docker), otherwise the + stored password for password prompts and nothing else. + +Keyboard-interactive rounds are classified once (`classifyKeyboardInteractive`): +an interceptor first (Warpgate), then push MFA, TOTP, and plain input. The +transport decides how to ask. + +A host whose `authType` has no provider fails with +`This host uses , which needs the plugin` (the plugin is found +from `contributes.auth.sshAuthTypes`, including disabled plugins), and the host +editor shows the same notice. + +#### SSH auth providers + +```ts +ctx.auth.registerSshAuthProvider({ + type: "corp-ca", // stored in ssh_data.auth_type + labelKey: "authType", + requiresSecret: false, // shared hosts: does a recipient need a secret + needsUserInteraction: true, // a browser sign-in or a person at the keyboard + supportsBackground: false, // can metrics and fleets poll it unattended + credentialType: false, // also offered as a stored credential type + interaction: "corp-ca", // what its outcomes call the browser step + fields: [/* settings fields */], + connectOptions: (host, purpose) => ({/* config overrides */}), + prepare: async (config, host, env) => { + // fill config (env.client is the ssh2 Client, for certificate patching) + return { status: "ready" }; + // or { status: "interaction-required", interaction: "corp-ca", message } + // or { status: "error", code: "missing-secret", message } + }, + onKeyboardInteractive: (round, host) => null, // claim a prompt style + onAuthFailed: (host, env, context) => undefined, // clear caches, retry once + startInteraction: async (request) => {}, // start the browser step +}); +``` + +The type must be listed in `contributes.auth.sshAuthTypes`, and the plugin +needs `auth:provide`. The grant is checked on every `prepare`. `fields` are the +plugin's own host settings (declare them in `contributes.settings.host` too): +the host editor renders them when the plugin registered no editor, and +`prepare` reads them with `ctx.settings.getHost(host.id, key)`. A frontend can +instead draw its own editor with `app.registerSshAuthEditor`. + +A transport that can show a browser step sends `_auth_required` +and later calls the provider's `startInteraction` for `_start_auth`. + +#### Login methods and second factors + +```ts +ctx.auth.registerLoginMethod({ + id: "corp-sso", + labelKey: "signIn", + kind: "redirect", + describe: async () => [{ id: "main", label: "Corp", enabled: true }], + start: async (request, instanceId) => ({ redirectUrl }), + callback: async (request) => ({ + kind: "external", + provider: "corp", + subject: "123", + email, + name, + groups, + isAdmin, + allowedUsers, + returnTo, + rememberMe, + }), +}); +ctx.auth.registerSecondFactor({ + id: "pin", + labelKey: "pin", + isEnrolled: async (userId) => true, + challenge: async (userId) => ({/* for the UI */}), + verify: async (userId, body) => body.pin === "1234", + reset: async (userId) => {}, +}); +await ctx.auth.recordEnrollment(userId, "pin"); // and removeEnrollment +``` + +A method never issues a session. It returns an identity: an existing user id, +or an external identity. Core then: + +1. finds the user through `user_external_identities` (provider, subject), or + provisions one with the core rules: first user is admin, the allowed-users + list is checked on every sign-in, provisioning needs SSO auto-provisioning, + admin approval is a hook that is off today, the admin group and role map + are kept in step; +2. unlocks the data key (the password for password logins); +3. runs second factors: every row in `user_second_factors` plus any factor + that reports itself enrolled. **Fail closed:** a row whose plugin is + disabled or gone refuses the login ("contact an admin"). A trusted device + or a method that already proved a factor (a verified passkey) skips the step; +4. issues the JWT, cookie, trusted device and the `login` audit line. + +Routes, all under `/users/auth`: `GET methods` (public), `GET :methodId/start`, +`GET|POST :methodId/callback`, `POST :methodId/verify`, +`POST second-factor/:factorId/challenge`, `POST second-factor/:factorId/verify`. +A redirect method's provider must send the browser back to +`/users/auth//callback`. The older routes (`/users/login`, +`/users/totp/verify-login`, `/users/oidc/*`, `/users/ldap/login`, +`/users/webauthn/authenticate/verify`) are thin wrappers over the same pipeline. + +An admin clears a user's factors, including orphaned ones, with +`DELETE /users/admin/:userId/second-factors` (audited as +`admin_reset_second_factors`). + +Password login is always a method. The admin "allow password login" setting +still turns it off, but only while another method (an enabled SSO instance, +or trusted proxy login) can sign someone in; otherwise it stays on with a +warning so nobody is locked out. + +#### Login UI before sign-in + +The login screen loads the frontends of enabled plugins that contribute login +methods or second factors from the public `GET /plugins/public-manifest` +(ids, versions, asset info and `contributes.auth` only), before anyone signs +in. `app.registerLoginMethod` draws a button or form for a method the server +lists (props: `instances`, `rememberMe`, `submit`, `startRedirect`, +`complete`); a redirect method with no UI gets one button per instance. +`app.registerSecondFactorUI` draws the challenge (props: `verify`, +`challenge`, `cancel`) and, with `enrollment`, a section in Settings > +Security. At activation before sign-in `app.api` calls fail with 401, so do +nothing there but register. ### 13. Where plugins live @@ -594,6 +746,12 @@ not do. "defaultRoles": ["user"], // system roles only, applied once }, ], + "auth": { + // each needs "auth:provide" in capabilities + "sshAuthTypes": ["corp-ca"], // host auth types this plugin handles + "loginMethods": ["corp-sso"], + "secondFactors": ["pin"], + }, "settings": { "admin": [ { @@ -690,6 +848,10 @@ refuses to register a view the manifest does not declare. `contributes.guest` opts a plugin into anonymous guest pages. Action contributions and slots accept the kinds `button` and `component`. +`contributes.auth` lists the ids a plugin registers through `ctx.auth`. A +register call for an id not listed here throws. Core also reads +`sshAuthTypes` from disabled plugins, to say which plugin a host needs. + `contributes.hostCapability`, `provides`, `requires`, `providesSecret` and `requiresSecret` are carried forward from v1 unchanged because the service and secret registries read them at activation. Phase B reshapes them. @@ -718,7 +880,7 @@ deciding, not the mechanism. | `ssh:connect` | high | Run commands on hosts it is connected to | | `process:spawn` | high | Run programs on the Termix server | | `users:write` | high | Create and change user accounts | -| `auth:provide` | high | Add a login method or second factor | +| `auth:provide` | high | Add a login method, second factor or SSH auth type | | `system:tls` | high | Request and replace the server certificate | | `hosts:write` | medium | Create and change hosts | | `credentials:use` | medium | Connect using a host's stored credentials, without seeing them | @@ -772,12 +934,28 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. | `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** | | `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** | | `ctx.hosts.*` | `hosts:read` / `hosts:write` | B | -| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | B | +| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** | | `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** | | `ctx.notify.*` | `notify:send` | A6 | -| `ctx.auth.*` | `auth:provide` | A8 | +| `ctx.auth.*` | `auth:provide` | **A8** | | `ctx.fetch` | `network:outbound` | B | +`ctx.ssh` was pulled forward from B so plugin transports go through core's +connect pipeline instead of importing ssh2 helpers from core: + +- `connect(hostOrId, { purpose, pool? })` returns `{ client, dispose }`. +- `withConnection(host, { pool, purpose }, fn)` borrows a pooled connection. +- `jumpChain(jumpHosts, { forHost? })` returns a client at the end of a jump + chain, for forwarding to something that is not SSH. +- `prepare(host, { client, purpose })`, `openTransport`, + `classifyKeyboardInteractive` and `autoResponses` are the lower level for a + transport with its own prompt flow (docker's console, host metrics). +- `requiresSecret(authType)` and `supportsBackground(authType)` ask the + provider. + +Each new connection is audited and runs as the current actor; pooled reuse is +not. Connections and pool entries are closed on deactivate. + ### The actor Never comes from plugin code. It is held in `AsyncLocalStorage` and set two diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index 65ab009e1..12a35588e 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -264,6 +264,24 @@ } }, "guest": { "type": "boolean" }, + "auth": { + "type": "object", + "additionalProperties": false, + "properties": { + "sshAuthTypes": { + "type": "array", + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" } + }, + "loginMethods": { + "type": "array", + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" } + }, + "secondFactors": { + "type": "array", + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" } + } + } + }, "panels": { "type": "array", "items": { diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 2a3c1b391..f67be1e3d 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -6,11 +6,11 @@ * description of the contract rather than an implementation a plugin could * reach around. * - * Members marked A3-A8 are not built yet. A plugin written against them today - * will type-check and fail at runtime, which is the intended signal. + * ctx.ssh is the part of step B's host surface that A8 needed early, so plugin + * transports could leave core's internals behind. */ -import type { PluginManifest } from "./manifest.js"; +import type { PluginManifest, PluginSettingsField } from "./manifest.js"; import type { PluginTableDefinition } from "./db.js"; /** @@ -36,6 +36,23 @@ export class PluginCapabilityError extends Error { } } +/** + * Thrown by a login method or second factor to refuse a sign-in with a + * message the user can see. `code` is a stable id the login screen can map to + * its own text; `status` is the HTTP status for form methods. + */ +export class LoginMethodError extends Error { + readonly status: number; + readonly code?: string; + + constructor(message: string, status = 401, code?: string) { + super(message); + this.name = "LoginMethodError"; + this.status = status; + this.code = code; + } +} + export interface PluginLogger { debug: (message: string) => void; info: (message: string) => void; @@ -349,6 +366,373 @@ export interface PluginSettings { readCore: (key: string) => Promise; } +/** + * A host as the SSH pipeline reads it: resolved by core, with secrets filled + * in for the acting user. Plugins get one from core and hand it back; they + * should not build one from scratch. + */ +export interface PluginSshHost { + id: number; + ip: string; + port: number; + username: string; + userId?: string | null; + authType?: string | null; + [key: string]: unknown; +} + +/** Picks keepalive and timeout defaults. "plugin" is the generic one. */ +export type PluginSshPurpose = + | "plugin" + | "docker" + | "docker-console" + | "metrics" + | "proxmox" + | "fleet" + | "remote-desktop" + | "tunnel"; + +export type PluginSshAuthOutcome = + | { status: "ready" } + | { + status: "interaction-required"; + /** Transports send "_auth_required", e.g. "opkssh". */ + interaction: string; + message: string; + /** Extra boolean HTTP transports put in their 401 body. */ + flag?: string; + } + | { + status: "error"; + code: + | "missing-secret" + | "invalid-key" + | "passphrase-required" + | "provider-missing" + | "failed"; + message: string; + } + | { + /** Try once more with these config changes. */ + status: "retry"; + patch: Record; + message: string; + }; + +export interface PluginKeyboardInteractivePrompt { + prompt: string; + echo?: boolean; +} + +export type PluginKeyboardInteractiveDecision = + | { kind: "auto"; responses: string[] } + | { + kind: "warpgate"; + url: string | null; + securityKey: string; + instructions: string; + } + | { kind: "totp"; promptIndex: number } + | { kind: "input"; promptIndex: number; isPush: boolean }; + +export type PluginSshPromptRequest = + | { kind: "totp"; prompt: string; retry: boolean } + | { kind: "input"; prompt: string; echo: boolean; isPush: boolean } + | { + kind: "warpgate"; + url: string | null; + securityKey: string; + instructions: string; + }; + +/** Answers keyboard-interactive prompts. Resolve null to give up. */ +export interface PluginSshPromptChannel { + ask: (request: PluginSshPromptRequest) => Promise; +} + +export interface PluginSshConnectOptions { + purpose?: PluginSshPurpose; + /** Overall time to reach "ready". Defaults to 30s. */ + timeoutMs?: number; + /** Without one, the stored password answers password prompts. */ + prompt?: PluginSshPromptChannel; + /** Config fields to force, e.g. a longer readyTimeout. */ + overrides?: Record; +} + +export interface PluginSshConnection { + /** An ssh2 Client, already "ready". */ + client: Client; + jumpClient: Client | null; + /** Ends the connection and its jump chain. Also runs on deactivate. */ + dispose: () => void; +} + +/** + * SSH through core's one connect pipeline: host resolution, auth providers, + * jump hosts, proxies, host key checks, keepalive and port knocking. + * + * Every call needs ssh:connect and credentials:use, runs as the acting user + * (or the host's owner for background work on a resolved host) and writes an + * audit line per new connection. Connections still open on deactivate are + * closed. + */ +export interface PluginSsh { + connect: ( + host: number | PluginSshHost, + options?: PluginSshConnectOptions, + ) => Promise>; + + /** Borrows a pooled connection. The pool key is `::
`. */ + withConnection: ( + host: number | PluginSshHost, + options: PluginSshConnectOptions & { pool: string }, + fn: (client: Client) => Promise, + ) => Promise; + + /** + * 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). + */ + jumpChain: ( + jumpHosts: Array<{ hostId: number }>, + options?: { + /** Background work: resolve the hops as this host's owner. */ + forHost?: PluginSshHost; + }, + ) => Promise>; + + /** The pool key withConnection would use, for clearing it. */ + poolKey: (pool: string, host: PluginSshHost) => string; + + /** + * Lower level: fills an ssh2 config for a client the plugin drives itself, + * for transports with their own prompt flow. Never connects. + */ + prepare: ( + host: PluginSshHost, + options: { + purpose?: PluginSshPurpose; + client: unknown; + /** Server-side host id when it differs from host.id. */ + serverHostId?: number; + log?: (level: "info" | "warning" | "error", message: string) => void; + }, + ) => Promise<{ + config: Record; + outcome: PluginSshAuthOutcome; + }>; + + /** Lower level: port knocking, jump hosts or proxy; sets config.sock. */ + openTransport: ( + host: PluginSshHost, + config: Record, + ) => Promise<{ jumpClient: unknown | null; via: string }>; + + /** What a keyboard-interactive round is. Pure; no capability needed. */ + classifyKeyboardInteractive: ( + round: { + name: string; + instructions: string; + prompts: PluginKeyboardInteractivePrompt[]; + }, + host: PluginSshHost, + ) => PluginKeyboardInteractiveDecision; + + /** Stored password for password prompts, empty for the rest. */ + autoResponses: ( + prompts: PluginKeyboardInteractivePrompt[], + password: string | null | undefined, + ) => string[]; + + /** Whether an auth type carries a secret (drives "auth_required" flows). */ + requiresSecret: (authType: string) => boolean; + + /** Whether an auth type can connect unattended, for polling. */ + supportsBackground: (authType: string) => boolean; +} + +export interface PluginSshAuthEnv { + /** The ssh2 Client about to connect; certificate auth patches it. */ + client: unknown; + userId: string; + hostId: number; + purpose: string; + interactive: boolean; + log: (level: "info" | "warning" | "error", message: string) => void; +} + +/** + * An SSH auth type. Its `type` is what ssh_data.auth_type stores, and it must + * be listed in the manifest's contributes.auth.sshAuthTypes. + */ +export interface PluginSshAuthProvider { + type: string; + labelKey: string; + descriptionKey?: string; + /** Host and credential editor fields, in the settings field schema. */ + fields?: PluginSettingsField[]; + /** Also offered as a stored credential type. */ + credentialType?: boolean; + /** Needs a browser sign-in or a person at the keyboard. */ + needsUserInteraction?: boolean; + /** Carries a secret, so a shared host needs one resolved per recipient. */ + requiresSecret?: boolean; + /** Can connect unattended. Defaults to true. */ + supportsBackground?: boolean; + /** Interaction name its outcomes use, for startInteraction routing. */ + interaction?: string; + connectOptions?: ( + host: PluginSshHost, + purpose: string, + ) => Record; + /** Fills the ssh2 config for this host. Never connects. */ + prepare: ( + config: Record, + host: PluginSshHost, + env: PluginSshAuthEnv, + ) => Promise; + /** Claims keyboard-interactive rounds for hosts of this type. */ + onKeyboardInteractive?: ( + round: { + name: string; + instructions: string; + prompts: PluginKeyboardInteractivePrompt[]; + }, + host: PluginSshHost, + ) => PluginKeyboardInteractiveDecision | null; + /** Synchronous: decide on a retry before the socket closes. */ + onAuthFailed?: ( + host: PluginSshHost, + env: PluginSshAuthEnv, + context: { + error: Error; + retries: number; + canRetry: boolean; + methodNotAvailable: boolean; + }, + ) => PluginSshAuthOutcome | undefined; + /** Starts the browser step behind an interaction-required outcome. */ + startInteraction?: (request: { + userId: string; + hostId: number; + host: { name?: string | null; ip: string; username: string }; + socket: unknown; + requestOrigin: string; + payload: Record; + }) => Promise; +} + +/** + * Who a login method says the user is. Core finds or provisions the user, + * runs second factors and issues the session; a method never does. + */ +export type PluginVerifiedIdentity = + | { + kind: "user"; + userId: string; + /** The method already proved a second factor (a verified passkey). */ + mfaSatisfied?: boolean; + /** Password logins unlock the user's data key with it. */ + password?: string; + /** Redirect methods: where the browser goes back to. */ + returnTo?: string; + rememberMe?: boolean; + } + | { + kind: "external"; + /** Stable provider id, e.g. an SSO provider row id. */ + provider: string; + /** The provider's id for the user. At most 255 characters. */ + subject: string; + email?: string | null; + name?: string | null; + groups?: string[]; + /** Provider says the user is an admin (admin group, LDAP group). */ + isAdmin?: boolean; + /** Allowed-users list to check before provisioning or signing in. */ + allowedUsers?: string | null; + mfaSatisfied?: boolean; + /** Redirect methods: where the browser goes back to. */ + returnTo?: string; + rememberMe?: boolean; + }; + +/** Request shape a login method sees. Structural, like PluginRequestLike. */ +export interface PluginLoginRequest { + body: Record; + query: Record; + headers: Record; + ip?: string; + [key: string]: unknown; +} + +export interface PluginLoginInstance { + /** Passed back to start/verify as instanceId, e.g. an SSO provider id. */ + id: string; + label: string; + enabled: boolean; +} + +export interface PluginLoginMethod { + id: string; + labelKey: string; + icon?: string; + kind: "redirect" | "form"; + /** Enabled instances, shown on the login screen. No secrets. */ + describe?: () => Promise; + /** Redirect methods: where to send the browser. */ + start?: ( + request: PluginLoginRequest, + instanceId: string | null, + ) => Promise<{ redirectUrl: string }>; + /** Redirect methods: the provider's callback. */ + callback?: (request: PluginLoginRequest) => Promise; + /** Form methods: check what the user typed. */ + verify?: ( + request: PluginLoginRequest, + instanceId: string | null, + ) => Promise; +} + +export interface PluginSecondFactor { + id: string; + labelKey: string; + isEnrolled: (userId: string) => Promise; + /** Anything the client needs before the user answers. */ + challenge?: (userId: string) => Promise; + /** + * True when the answer is right. Return an object to refuse with a specific + * message, for example when the factor had to be reset. + */ + verify: ( + userId: string, + body: Record, + ) => Promise; + /** Removes the user's enrolment. Called by the admin reset. */ + reset?: (userId: string) => Promise; +} + +/** + * Login methods, second factors and SSH auth types. Every register call needs + * auth:provide and a matching contributes.auth entry, and is undone on + * deactivate. Core owns sessions: a method returns an identity and core does + * the rest. + */ +export interface PluginAuth { + registerSshAuthProvider: (provider: PluginSshAuthProvider) => void; + registerLoginMethod: (method: PluginLoginMethod) => void; + registerSecondFactor: (factor: PluginSecondFactor) => void; + /** + * Marks a user as enrolled in one of this plugin's factors. Core keeps the + * row even when the plugin is gone, so it can refuse the login instead of + * skipping the factor. + */ + recordEnrollment: (userId: string, factorId: string) => Promise; + removeEnrollment: (userId: string, factorId: string) => Promise; +} + export interface PluginContext { readonly pluginId: string; readonly manifest: PluginManifest; @@ -365,6 +749,10 @@ export interface PluginContext { readonly rbac: PluginRbac; readonly settings: PluginSettings; readonly disposables: PluginDisposables; + /** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */ + readonly ssh: PluginSsh; + /** Login methods, second factors and SSH auth types. Needs auth:provide. */ + readonly auth: PluginAuth; /** * Runs `fn` with `userId` as the acting user, for background work that has diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index 2928b0d72..d2282c324 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -365,18 +365,58 @@ export interface SshAuthEditorContribution { component?: ComponentType; } -/** A7 declares these; A8 wires them into the login screen. */ +/** What the login screen hands a login method's UI. */ +export interface LoginMethodUIProps { + methodId: string; + /** Enabled instances from the server, e.g. one per SSO provider. */ + instances: Array<{ id: string; label: string }>; + rememberMe: boolean; + disabled: boolean; + /** What the user typed in the username field, when there is one. */ + username?: string; + /** + * Form methods: posts the body to the method's verify endpoint, then the + * login screen finishes the login or shows the second-factor step. + */ + submit: (body: Record, instanceId?: string) => Promise; + /** Redirect methods: sends the browser (or the system browser) away. */ + startRedirect: (instanceId?: string) => Promise; + /** Hands a login response from a request the UI made itself. */ + complete: (response: Record) => Promise; +} + +/** + * A button or form on the login screen. `id` matches the login method the + * backend registered with ctx.auth.registerLoginMethod; the screen shows it + * only while the server reports that method as enabled. + */ export interface LoginMethodContribution { id: string; titleKey: string; icon?: IconComponent; - component: ComponentType>; + component: ComponentType; +} + +/** What the second-factor step hands a factor's UI. */ +export interface SecondFactorUIProps { + factorId: string; + rememberMe: boolean; + disabled: boolean; + /** Sends the user's answer; the login screen finishes or shows the error. */ + verify: (body: Record) => Promise; + /** Whatever the backend factor's challenge() returns. */ + challenge: () => Promise; + cancel: () => void; } export interface SecondFactorContribution { + /** Matches the id the backend registered with ctx.auth.registerSecondFactor. */ id: string; titleKey: string; - component: ComponentType>; + /** The challenge shown after the first login step. */ + component: ComponentType; + /** Enrolment, shown in Settings > Security. */ + enrollment?: ComponentType>; } /** The subset of axios a plugin uses, rooted at /plugin-api//. */ @@ -516,6 +556,18 @@ export interface SettingsState { save: (values: Record) => Promise; } +/** An SSH auth type the server can connect with. */ +export interface SshAuthTypeInfo { + type: string; + /** Core translation key, or the plugin editor's title. */ + labelKey: string; + pluginId: string; + /** Also offered as a stored credential type. */ + credentialType: boolean; + /** Can connect unattended, for polling. */ + supportsBackground: boolean; +} + /** * What core implements behind the hooks. Internal: a plugin never calls this. */ @@ -539,6 +591,7 @@ export interface PluginHostBridge { getApi: (pluginId: string) => PluginApiClient; useTabs: () => TabsApi; invokeAction: (id: string, ...args: unknown[]) => Promise; + useSshAuthTypes: () => { types: SshAuthTypeInfo[]; loaded: boolean }; } let host: PluginHostBridge | null = null; @@ -630,6 +683,17 @@ export function useTabs(): TabsApi { return requireHost().useTabs(); } +/** + * The SSH auth types this server can connect with, core's and every enabled + * plugin's, for pickers such as a default auth type. + */ +export function useSshAuthTypes(): { + types: SshAuthTypeInfo[]; + loaded: boolean; +} { + return requireHost().useSshAuthTypes(); +} + /** * Runs an action another plugin registered, from anywhere in a plugin's code. * Resolves undefined when nobody registered it, so an optional dependency diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index f0874d6f7..984dd1273 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -233,6 +233,20 @@ export interface PluginSettingsContribution { host?: PluginHostSettingsContribution; } +/** + * What a plugin adds to sign-in and SSH auth. Declared so core can tell who + * owns an auth type or factor even while the plugin is disabled, and so the + * login screen knows which bundles it needs before anyone has signed in. + */ +export interface PluginAuthContribution { + /** Values of ssh_data.auth_type this plugin provides. */ + sshAuthTypes?: string[]; + /** Login method ids. */ + loginMethods?: string[]; + /** Second factor ids. */ + secondFactors?: string[]; +} + export interface PluginContributions { tabs?: PluginTabContribution[]; panels?: PluginViewContribution[]; @@ -252,6 +266,7 @@ export interface PluginContributions { * proxmox and web-endpoint ship it today. */ hostCapability?: HostCapabilityContribution | HostCapabilityContribution[]; + auth?: PluginAuthContribution; } export interface PluginManifest { @@ -328,6 +343,7 @@ const ALLOWED_CONTRIBUTES = new Set([ "permissions", "settings", "hostCapability", + "auth", ]); const ALLOWED_SETTINGS_FIELD = [ @@ -684,6 +700,44 @@ function validateContributes( validateActionSlots(contributes.actionSlots, errors); validateSettings(contributes.settings, errors); validateHostCapability(contributes.hostCapability, errors); + validateAuthContribution(contributes.auth, errors); +} + +const AUTH_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/; + +function validateAuthContribution(value: unknown, errors: string[]): void { + if (value === undefined) return; + if (!isPlainObject(value)) { + errors.push('Field "contributes.auth" must be an object'); + return; + } + rejectUnknown( + value, + ["sshAuthTypes", "loginMethods", "secondFactors"], + "contributes.auth", + errors, + ); + for (const key of ["sshAuthTypes", "loginMethods", "secondFactors"]) { + const list = value[key]; + if (list === undefined) continue; + if (!Array.isArray(list)) { + errors.push(`Field "contributes.auth.${key}" must be an array`); + continue; + } + const seen = new Set(); + list.forEach((entry, index) => { + if (typeof entry !== "string" || !AUTH_ID_PATTERN.test(entry)) { + errors.push( + `Field "contributes.auth.${key}[${index}]" must be a lowercase id (letters, digits and dashes)`, + ); + return; + } + if (seen.has(entry)) { + errors.push(`Duplicate "${entry}" in contributes.auth.${key}`); + } + seen.add(entry); + }); + } } function validateSettings(settings: unknown, errors: string[]): void { @@ -1205,6 +1259,18 @@ export function parseManifest(raw: unknown): ParsedManifest { } } + const auth = manifest.contributes?.auth; + const contributesAuth = + (auth?.sshAuthTypes?.length ?? 0) + + (auth?.loginMethods?.length ?? 0) + + (auth?.secondFactors?.length ?? 0) > + 0; + if (contributesAuth && !manifest.capabilities.includes("auth:provide")) { + errors.push( + 'contributes.auth needs the "auth:provide" capability in the capabilities array', + ); + } + for (const secret of manifest.requiresSecret ?? []) { if (secret.plugin === manifest.id) { errors.push( diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index b1818d3ad..1f8c06b1c 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -18,6 +18,12 @@ import type { TermixApp } from "./frontend.js"; import type { PluginManifest } from "./manifest.js"; import type { PluginTableDefinition } from "./db.js"; import type { PluginMiddleware, PluginRouterOptions } from "./backend.js"; +import type { + PluginLoginMethod, + PluginSecondFactor, + PluginSshAuthProvider, + PluginSshHost, +} from "./backend.js"; import type { SyncEntityRegistration } from "./backend.js"; export interface FakeContextOptions { @@ -29,6 +35,16 @@ export interface FakeContextOptions { settings?: Record; /** Seed for the core settings ctx.settings.readCore can reach. */ coreSettings?: Record; + /** What ctx.ssh.connect and withConnection hand back as the client. */ + sshClient?: unknown; +} + +export interface FakeAuthRegistrations { + sshAuthProviders: PluginSshAuthProvider[]; + loginMethods: PluginLoginMethod[]; + secondFactors: PluginSecondFactor[]; + /** ":" for every recorded enrolment. */ + enrollments: Set; } export interface FakePluginContext { @@ -51,6 +67,10 @@ export interface FakePluginContext { settings: Map; /** Core settings readable through ctx.settings.readCore. */ coreSettings: Map; + /** Every ctx.ssh.connect and withConnection call, in order. */ + sshConnections: Array<{ host: number | PluginSshHost; pool?: string }>; + /** Everything registered through ctx.auth. */ + auth: FakeAuthRegistrations; } /** How a settings row is keyed in the doubles. Mirrors the real unique index. */ @@ -93,6 +113,14 @@ export function createFakeContext( Object.entries(options.coreSettings ?? {}), ); const settingsListeners = new Map void>>(); + const sshConnections: FakePluginContext["sshConnections"] = []; + const auth: FakeAuthRegistrations = { + sshAuthProviders: [], + loginMethods: [], + secondFactors: [], + enrollments: new Set(), + }; + const sshClient = options.sshClient ?? {}; let actor = options.actor; for (const [key, value] of Object.entries(options.settings ?? {})) { @@ -267,6 +295,64 @@ export function createFakeContext( disposables, + ssh: { + connect: async (host) => { + sshConnections.push({ host }); + return { + client: sshClient as never, + jumpClient: null, + dispose: () => {}, + }; + }, + withConnection: async (host, connectOptions, fn) => { + sshConnections.push({ host, pool: connectOptions.pool }); + return fn(sshClient as never); + }, + jumpChain: async () => ({ + client: sshClient as never, + jumpClient: null, + dispose: () => {}, + }), + poolKey: (pool, host) => + `${pool}:${host.userId}:${host.ip}:${host.port}:${host.username}`, + prepare: async () => ({ config: {}, outcome: { status: "ready" } }), + openTransport: async () => ({ jumpClient: null, via: "direct" }), + classifyKeyboardInteractive: ({ prompts }, host) => ({ + kind: "auto", + responses: prompts.map((p) => + /password/i.test(p.prompt) && typeof host.password === "string" + ? host.password + : "", + ), + }), + autoResponses: (prompts, password) => + prompts.map((p) => + /password/i.test(p.prompt) && password ? password : "", + ), + requiresSecret: (authType) => + ["password", "key", "credential", "agent"].includes(authType), + supportsBackground: (authType) => + !["none", "opkssh", "stepca"].includes(authType), + }, + + auth: { + registerSshAuthProvider: (provider) => { + auth.sshAuthProviders.push(provider); + }, + registerLoginMethod: (method) => { + auth.loginMethods.push(method); + }, + registerSecondFactor: (factor) => { + auth.secondFactors.push(factor); + }, + recordEnrollment: async (userId, factorId) => { + auth.enrollments.add(`${userId}:${factorId}`); + }, + removeEnrollment: async (userId, factorId) => { + auth.enrollments.delete(`${userId}:${factorId}`); + }, + }, + asUser: async (userId, fn) => { const previous = actor; actor = userId; @@ -291,6 +377,8 @@ export function createFakeContext( httpRouters, settings, coreSettings, + sshConnections, + auth, }; } @@ -307,6 +395,8 @@ export interface MockContextOptions { coreSettings?: Record; /** Acting user returned by currentActor and used by asUser. */ actor?: string; + /** What ctx.ssh.connect and withConnection hand back as the client. */ + sshClient?: unknown; } export interface MockPluginContext extends FakePluginContext { @@ -320,8 +410,9 @@ export interface MockPluginContext extends FakePluginContext { * * The gates on today's ctx surface: kv:own on every ctx.kv call, db:own on * ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a - * topic outside the plugin's own namespace, and settings:read-core on - * ctx.settings.readCore. Reading a plugin's own settings is deliberately + * topic outside the plugin's own namespace, settings:read-core on + * ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh, and + * auth:provide on ctx.auth. Reading a plugin's own settings is deliberately * ungated. As the SDK grows a member, add its gate here in the same shape. */ export function createMockCtx( @@ -335,6 +426,7 @@ export function createMockCtx( actor: options.actor, settings: options.settings, coreSettings: options.coreSettings, + sshClient: options.sshClient, manifest: { capabilities: options.capabilities ?? [], ...options.manifest, @@ -437,6 +529,57 @@ export function createMockCtx( return ctx.settings.readCore(key); }, }, + + ssh: { + ...ctx.ssh, + connect: async (host, connectOptions) => { + require("ssh:connect"); + require("credentials:use"); + return ctx.ssh.connect(host, connectOptions); + }, + withConnection: async (host, connectOptions, fn) => { + require("ssh:connect"); + require("credentials:use"); + return ctx.ssh.withConnection(host, connectOptions, fn); + }, + prepare: async (host, prepareOptions) => { + require("ssh:connect"); + require("credentials:use"); + return ctx.ssh.prepare(host, prepareOptions); + }, + openTransport: async (host, config) => { + require("ssh:connect"); + return ctx.ssh.openTransport(host, config); + }, + jumpChain: async (jumpHosts, chainOptions) => { + require("ssh:connect"); + require("credentials:use"); + return ctx.ssh.jumpChain(jumpHosts, chainOptions); + }, + }, + + auth: { + registerSshAuthProvider: (provider) => { + require("auth:provide"); + ctx.auth.registerSshAuthProvider(provider); + }, + registerLoginMethod: (method) => { + require("auth:provide"); + ctx.auth.registerLoginMethod(method); + }, + registerSecondFactor: (factor) => { + require("auth:provide"); + ctx.auth.registerSecondFactor(factor); + }, + recordEnrollment: async (userId, factorId) => { + require("auth:provide"); + return ctx.auth.recordEnrollment(userId, factorId); + }, + removeEnrollment: async (userId, factorId) => { + require("auth:provide"); + return ctx.auth.removeEnrollment(userId, factorId); + }, + }, }; return { ...base, ctx: gatedCtx, checked }; diff --git a/packages/plugin-sdk/src/testing/setup.ts b/packages/plugin-sdk/src/testing/setup.ts index 366468fd5..fe8d31317 100644 --- a/packages/plugin-sdk/src/testing/setup.ts +++ b/packages/plugin-sdk/src/testing/setup.ts @@ -101,6 +101,16 @@ const offline = () => onReady: () => noop, }), invokeAction: async () => undefined, + useSshAuthTypes: () => ({ + types: ["password", "key", "credential", "agent", "none"].map((type) => ({ + type, + labelKey: type, + pluginId: "core", + credentialType: type === "password" || type === "key", + supportsBackground: type !== "none", + })), + loaded: true, + }), }; afterEach(() => { diff --git a/plugins/ai/src/backend/index.ts b/plugins/ai/src/backend/index.ts index 05cb047e0..56a2a4744 100644 --- a/plugins/ai/src/backend/index.ts +++ b/plugins/ai/src/backend/index.ts @@ -1,8 +1,11 @@ import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { setPluginSsh } from "./ssh.js"; import { startAiService, stopAiService } from "./routes.js"; import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js"; export async function activate(ctx: PluginContext) { + setPluginSsh(ctx.ssh); + ctx.disposables.add(() => setPluginSsh(null)); startAiService(ctx.http.router()); // Offered by reference, not copied: this hands over a resolver, so the key diff --git a/plugins/ai/src/backend/ssh.ts b/plugins/ai/src/backend/ssh.ts new file mode 100644 index 000000000..b7a221543 --- /dev/null +++ b/plugins/ai/src/backend/ssh.ts @@ -0,0 +1,36 @@ +import type { Client } from "ssh2"; +import type { + PluginSsh, + PluginSshConnection, + PluginSshConnectOptions, + PluginSshHost, +} from "@termix/plugin-sdk/backend"; + +let current: PluginSsh | null = null; + +/** Set in activate, cleared on deactivate. */ +export function setPluginSsh(ssh: PluginSsh | null): void { + current = ssh; +} + +export function pluginSsh(): PluginSsh { + if (!current) throw new Error("The plugin is not active"); + return current; +} + +/** withConnection with ssh2's Client type filled in. */ +export function withSshConnection( + host: number | PluginSshHost, + options: PluginSshConnectOptions & { pool: string }, + fn: (client: Client) => Promise, +): Promise { + return pluginSsh().withConnection(host, options, fn); +} + +/** connect with ssh2's Client type filled in. */ +export function connectSsh( + host: number | PluginSshHost, + options?: PluginSshConnectOptions, +): Promise> { + return pluginSsh().connect(host, options); +} diff --git a/plugins/ai/src/backend/tools/executor.ts b/plugins/ai/src/backend/tools/executor.ts index a4bb867e6..dd20cf524 100644 --- a/plugins/ai/src/backend/tools/executor.ts +++ b/plugins/ai/src/backend/tools/executor.ts @@ -7,11 +7,7 @@ import { import { validateDefinition } from "../../../../automations/src/backend/routes.js"; import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js"; import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js"; -import { - createFleetSshFactory, - getFleetPoolKey, -} from "../../../../../src/backend/hosts/ssh-client-factory.js"; -import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js"; +import { withSshConnection } from "../ssh.js"; import { getTool } from "./catalog.js"; /** Approved commands get a bounded window rather than hanging the request. */ @@ -277,9 +273,9 @@ async function runCommandOnHost( command: string, ): Promise<{ output?: string; error?: string }> { try { - const result = await withConnection( - getFleetPoolKey(host as any), - createFleetSshFactory(host as any), + const result = await withSshConnection( + host as any as never, + { pool: "fleet", purpose: "fleet" }, async (client) => execCommand(client, command, COMMAND_TIMEOUT_MS), ); diff --git a/plugins/ai/tests/backend/tools/proposal-executor.test.ts b/plugins/ai/tests/backend/tools/proposal-executor.test.ts index 3e2aad620..796742e46 100644 --- a/plugins/ai/tests/backend/tools/proposal-executor.test.ts +++ b/plugins/ai/tests/backend/tools/proposal-executor.test.ts @@ -34,14 +34,10 @@ vi.mock( execCommand: (...args: unknown[]) => execCommand(...args), }), ); -vi.mock("../../../../../src/backend/hosts/ssh-client-factory.js", () => ({ - createFleetSshFactory: () => () => ({}), - getFleetPoolKey: () => "pool", -})); -vi.mock("../../../../../src/backend/hosts/ssh-connection-pool.js", () => ({ - withConnection: async ( - _key: string, - _factory: unknown, +vi.mock("../../../src/backend/ssh.js", () => ({ + withSshConnection: async ( + _host: unknown, + _options: unknown, run: (client: unknown) => Promise, ) => run({}), })); diff --git a/plugins/automations/src/backend/actions/index.ts b/plugins/automations/src/backend/actions/index.ts index bebd1e974..25b8639ea 100644 --- a/plugins/automations/src/backend/actions/index.ts +++ b/plugins/automations/src/backend/actions/index.ts @@ -7,11 +7,7 @@ import { execElevated, shellSingleQuote, } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js"; -import { - createFleetSshFactory, - getFleetPoolKey, -} from "../../../../../src/backend/hosts/ssh-client-factory.js"; -import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js"; +import { withSshConnection } from "../ssh.js"; import { resolveSnippetCommand } from "../../../../../src/backend/database/routes/snippets-execution.js"; import { createCurrentNotificationChannelRepository, @@ -383,9 +379,9 @@ async function execOnHost( if (timeout <= 0) return { error: "Run deadline exceeded" }; try { - const result = await withConnection( - getFleetPoolKey(sshHost), - createFleetSshFactory(sshHost), + const result = await withSshConnection( + sshHost as never, + { pool: "fleet", purpose: "fleet" }, async (client) => { if (elevated) { return execElevated(client, command, sshHost.sudoPassword, { diff --git a/plugins/automations/src/backend/docker-watcher.ts b/plugins/automations/src/backend/docker-watcher.ts index 719e60f02..672d3dbfc 100644 --- a/plugins/automations/src/backend/docker-watcher.ts +++ b/plugins/automations/src/backend/docker-watcher.ts @@ -3,11 +3,7 @@ import { createCurrentAutomationRepository } from "../../../../src/backend/datab import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js"; import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; -import { - createFleetSshFactory, - getFleetPoolKey, -} from "../../../../src/backend/hosts/ssh-client-factory.js"; -import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js"; +import { withSshConnection } from "./ssh.js"; import { statsLogger } from "../../../../src/backend/utils/logger.js"; import { onDockerEvent } from "./triggers.js"; @@ -154,9 +150,9 @@ async function pollHost(hostId: number, userId: string): Promise { return; } - const result = await withConnection( - getFleetPoolKey(host as never), - createFleetSshFactory(host as never), + const result = await withSshConnection( + host as never as never, + { pool: "fleet", purpose: "fleet" }, (client) => execCommand( client, diff --git a/plugins/automations/src/backend/index.ts b/plugins/automations/src/backend/index.ts index 357ae2219..638751542 100644 --- a/plugins/automations/src/backend/index.ts +++ b/plugins/automations/src/backend/index.ts @@ -1,4 +1,5 @@ import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { setPluginSsh } from "./ssh.js"; import { startAutomationsService, stopAutomationsService } from "./routes.js"; import { startAutomationScheduler, @@ -6,6 +7,8 @@ import { } from "./scheduler.js"; export async function activate(ctx: PluginContext) { + setPluginSsh(ctx.ssh); + ctx.disposables.add(() => setPluginSsh(null)); startAutomationsService( // The webhook route authenticates on its own per-automation token rather // than a session, which is the point of an inbound webhook. diff --git a/plugins/automations/src/backend/ssh.ts b/plugins/automations/src/backend/ssh.ts new file mode 100644 index 000000000..b7a221543 --- /dev/null +++ b/plugins/automations/src/backend/ssh.ts @@ -0,0 +1,36 @@ +import type { Client } from "ssh2"; +import type { + PluginSsh, + PluginSshConnection, + PluginSshConnectOptions, + PluginSshHost, +} from "@termix/plugin-sdk/backend"; + +let current: PluginSsh | null = null; + +/** Set in activate, cleared on deactivate. */ +export function setPluginSsh(ssh: PluginSsh | null): void { + current = ssh; +} + +export function pluginSsh(): PluginSsh { + if (!current) throw new Error("The plugin is not active"); + return current; +} + +/** withConnection with ssh2's Client type filled in. */ +export function withSshConnection( + host: number | PluginSshHost, + options: PluginSshConnectOptions & { pool: string }, + fn: (client: Client) => Promise, +): Promise { + return pluginSsh().withConnection(host, options, fn); +} + +/** connect with ssh2's Client type filled in. */ +export function connectSsh( + host: number | PluginSshHost, + options?: PluginSshConnectOptions, +): Promise> { + return pluginSsh().connect(host, options); +} diff --git a/plugins/docker/src/backend/console.ts b/plugins/docker/src/backend/console.ts index a7f7b7eb0..51cc3aee8 100644 --- a/plugins/docker/src/backend/console.ts +++ b/plugins/docker/src/backend/console.ts @@ -3,20 +3,18 @@ // packages/plugin-sdk/cli/lib/legacy-core-imports.mjs. import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; import { StringDecoder } from "string_decoder"; -import { Client as SSHClient } from "ssh2"; -import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js"; +import type { Client as SSHClient } from "ssh2"; +import type { PluginSshHost } from "@termix/plugin-sdk/backend"; +import { connectSsh } from "./ssh.js"; import { WebSocketServer, WebSocket } from "ws"; import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; -import { createCurrentHostResolutionRepository } from "../../../../src/backend/database/repositories/factory.js"; import { systemLogger } from "../../../../src/backend/utils/logger.js"; import type { SSHHost } from "../../../../src/types/index.js"; -import { applyAgentAuth } from "../../../../src/backend/hosts/terminal-auth-helpers.js"; import { containerCommand, getContainerRuntimeConfig, type ContainerRuntime, } from "./container-runtime.js"; -import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js"; import { hostAddressMismatch, HOST_ADDRESS_MISMATCH_MESSAGE, @@ -151,197 +149,6 @@ async function detectShell( return "sh"; } -async function createJumpHostChain( - jumpHosts: Array<{ hostId: number }>, - userId: string, -): Promise { - if (!jumpHosts || jumpHosts.length === 0) { - return null; - } - - let currentClient: SSHClient | null = null; - const repository = createCurrentHostResolutionRepository(); - - for (let i = 0; i < jumpHosts.length; i++) { - const jumpHostId = jumpHosts[i].hostId; - - const resolvedJumpHost = await repository.findHostById(jumpHostId, userId); - if (!resolvedJumpHost || resolvedJumpHost.userId !== userId) { - throw new Error(`Jump host ${jumpHostId} not found`); - } - - const jumpHost = resolvedJumpHost as unknown as SSHHost; - if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) { - try { - jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts); - } catch (e) { - sshLogger.error("Failed to parse jump hosts", e, { - hostId: jumpHost.id, - }); - jumpHost.jumpHosts = []; - } - } - - let resolvedCredentials: { - password?: string; - sshKey?: string; - keyPassword?: string; - authType?: string; - } = { - password: jumpHost.password, - sshKey: jumpHost.key, - keyPassword: jumpHost.keyPassword, - authType: jumpHost.authType, - }; - - if (jumpHost.credentialId) { - const credential = await repository.findCredentialByIdForUser( - jumpHost.credentialId as number, - userId, - ); - - if (credential) { - resolvedCredentials = { - password: credential.password as string | undefined, - sshKey: (credential.key || credential.privateKey) as - string | undefined, - keyPassword: credential.keyPassword as string | undefined, - authType: credential.authType as string | undefined, - }; - } - } - - const client = new SSHClient(); - - const config: Record = { - host: jumpHost.ip?.replace(/^\[|\]$/g, "") || jumpHost.ip, - port: jumpHost.port || 22, - username: jumpHost.username, - tryKeyboard: resolvedCredentials.authType !== "none", - readyTimeout: 60000, - keepaliveInterval: 30000, - keepaliveCountMax: 120, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - algorithms: { - kex: [ - "curve25519-sha256", - "curve25519-sha256@libssh.org", - "ecdh-sha2-nistp521", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp256", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group18-sha512", - "diffie-hellman-group17-sha512", - "diffie-hellman-group16-sha512", - "diffie-hellman-group15-sha512", - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group-exchange-sha1", - "diffie-hellman-group1-sha1", - ], - serverHostKey: [ - "ssh-ed25519", - "ecdsa-sha2-nistp521", - "ecdsa-sha2-nistp384", - "ecdsa-sha2-nistp256", - "rsa-sha2-512", - "rsa-sha2-256", - "ssh-rsa", - "ssh-dss", - ], - cipher: SSH_ALGORITHMS.cipher, - hmac: [ - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512", - "hmac-sha2-256", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - }; - - if ( - resolvedCredentials.authType === "password" && - resolvedCredentials.password - ) { - config.password = resolvedCredentials.password; - } else if ( - resolvedCredentials.authType === "key" && - resolvedCredentials.sshKey - ) { - const cleanKey = resolvedCredentials.sshKey - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - config.privateKey = Buffer.from(cleanKey, "utf8"); - if (resolvedCredentials.keyPassword) { - config.passphrase = resolvedCredentials.keyPassword; - } - } else if (resolvedCredentials.authType === "agent") { - const result = await applyAgentAuth( - config, - jumpHost.terminalConfig as unknown as - Record | undefined, - ); - if ("error" in result) { - throw new Error(result.error); - } - } - - client.on( - "keyboard-interactive", - ( - _name: string, - _instructions: string, - _lang: string, - prompts: Array<{ prompt: string; echo: boolean }>, - finish: (responses: string[]) => void, - ) => { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password as string; - } - return ""; - }); - finish(responses); - }, - ); - - if (currentClient) { - await new Promise((resolve, reject) => { - currentClient!.forwardOut( - "127.0.0.1", - 0, - jumpHost.ip, - jumpHost.port || 22, - (err, stream) => { - if (err) return reject(err); - config.sock = stream; - resolve(); - }, - ); - }); - } - - if (!config.sock) { - await resolveSshConnectConfigHost(config); - } - - await new Promise((resolve, reject) => { - client.on("ready", () => resolve()); - client.on("error", reject); - client.connect(config); - }); - - currentClient = client; - } - - return currentClient; -} - async function onConsoleConnection( ws: WebSocket, req: import("http").IncomingMessage, @@ -514,81 +321,15 @@ async function onConsoleConnection( return; } - const client = new SSHClient(); - - const config: Record = { - host: resolvedHost.ip?.replace(/^\[|\]$/g, "") || resolvedHost.ip, - port: resolvedHost.port || 22, - username: resolvedHost.username, - tryKeyboard: true, - readyTimeout: 60000, - keepaliveInterval: 30000, - keepaliveCountMax: 120, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - }; - - if (resolvedHost.authType === "password" && resolvedHost.password) { - config.password = resolvedHost.password; - } else if (resolvedHost.authType === "key" && resolvedHost.key) { - const cleanKey = resolvedHost.key - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - config.privateKey = Buffer.from(cleanKey, "utf8"); - if (resolvedHost.keyPassword) { - config.passphrase = resolvedHost.keyPassword; - } - } else if (resolvedHost.authType === "agent") { - const result = await applyAgentAuth( - config, - resolvedHost.terminalConfig as unknown as - Record | undefined, - ); - if ("error" in result) { - ws.send( - JSON.stringify({ - type: "error", - message: result.error, - }), - ); - return; - } - } - - if (resolvedHost.jumpHosts && resolvedHost.jumpHosts.length > 0) { - const jumpClient = await createJumpHostChain( - resolvedHost.jumpHosts, + // The console socket is public and authenticates itself, so the + // acting user is carried on the host rather than the request. + const { client } = await connectSsh( + { + ...(resolvedHost as unknown as PluginSshHost), userId, - ); - if (jumpClient) { - const stream = await new Promise( - (resolve, reject) => { - jumpClient.forwardOut( - "127.0.0.1", - 0, - resolvedHost.ip, - resolvedHost.port || 22, - (err, stream) => { - if (err) return reject(err); - resolve(stream); - }, - ); - }, - ); - config.sock = stream; - } - } - - if (!config.sock) { - await resolveSshConnectConfigHost(config); - } - - await new Promise((resolve, reject) => { - client.on("ready", () => resolve()); - client.on("error", reject); - client.connect(config); - }); + } as PluginSshHost, + { purpose: "docker-console", timeoutMs: 65000 }, + ); const { runtime: containerRuntime } = getContainerRuntimeConfig( resolvedHost.dockerConfig, diff --git a/plugins/docker/src/backend/index.ts b/plugins/docker/src/backend/index.ts index c09856de8..edbc3c06f 100644 --- a/plugins/docker/src/backend/index.ts +++ b/plugins/docker/src/backend/index.ts @@ -1,5 +1,6 @@ import express, { type Router } from "express"; import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { setPluginSsh } from "./ssh.js"; import { logger } from "../../../../src/backend/utils/logger.js"; import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; import { registerDockerContainerRoutes } from "./container-routes.js"; @@ -23,6 +24,8 @@ let authManagerInstance: ReturnType | null = null; export async function activate(ctx: PluginContext) { + setPluginSsh(ctx.ssh); + ctx.disposables.add(() => setPluginSsh(null)); // An express app rather than the router directly, so the existing // register*Routes helpers keep the Application they expect. Core mounts it // at /plugin-api/docker and runs compression, CORS, cookies, auth and body diff --git a/plugins/docker/src/backend/routes.ts b/plugins/docker/src/backend/routes.ts index d9860a15f..4cb3b0477 100644 --- a/plugins/docker/src/backend/routes.ts +++ b/plugins/docker/src/backend/routes.ts @@ -2,10 +2,11 @@ // The plugin build rewrites the prefix to the compiled output path; see // packages/plugin-sdk/cli/lib/legacy-core-imports.mjs. import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; -import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js"; import express from "express"; import axios from "axios"; import { Client as SSHClient } from "ssh2"; +import type { PluginSshHost } from "@termix/plugin-sdk/backend"; +import { pluginSsh } from "./ssh.js"; import { logger } from "../../../../src/backend/utils/logger.js"; import { logAudit, @@ -13,33 +14,23 @@ import { getRequestMeta, } from "../../../../src/backend/utils/audit-logger.js"; import { createCurrentHostRepository } from "../../../../src/backend/database/repositories/factory.js"; -import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js"; import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js"; import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js"; import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; import type { AuthenticatedRequest, - ProxyNode, SSHHost, } from "../../../../src/types/index.js"; -import { - createSocks5Connection, - type SOCKS5Config, -} from "../../../../src/backend/utils/socks5-helper.js"; import type { LogEntry, ConnectionStage, } from "../../../../src/types/connection-log.js"; -import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js"; -import { preparePrivateKeyForSSH2 } from "../../../../src/backend/utils/ssh-key-utils.js"; -import { applyAgentAuth } from "../../../../src/backend/hosts/terminal-auth-helpers.js"; import { containerCommand, getContainerRuntimeConfig, getRuntimeLabel, } from "./container-runtime.js"; -import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js"; import { type SSHSession, sshSessions, @@ -242,200 +233,69 @@ export function registerDockerSshRoutes(app: express.Express): void { delete pendingTOTPSessions[sessionId]; } - const resolvedCredentials: { - password?: string; - sshKey?: string; - keyPassword?: string; - authType?: string; - } = { - password: host.password, - sshKey: host.key, - keyPassword: host.keyPassword, - authType: host.authType, - }; - + const connectTarget = { + ...(host as unknown as PluginSshHost), + id: hostId, + port: host.port || 22, + userId, + // The Docker panel sends its own proxy settings with the request. + useSocks5, + socks5Host, + socks5Port, + socks5Username, + socks5Password, + socks5ProxyChain, + } as PluginSshHost; if (userProvidedPassword) { - resolvedCredentials.password = userProvidedPassword; - resolvedCredentials.authType = "password"; + connectTarget.password = userProvidedPassword; + connectTarget.authType = "password"; } if (userProvidedSshKey) { - resolvedCredentials.sshKey = userProvidedSshKey; - resolvedCredentials.authType = "key"; + connectTarget.key = userProvidedSshKey; + connectTarget.authType = "key"; } if (userProvidedKeyPassword) { - resolvedCredentials.keyPassword = userProvidedKeyPassword; + connectTarget.keyPassword = userProvidedKeyPassword; } + const resolvedPassword = connectTarget.password as string | undefined; + const authType = (connectTarget.authType as string) || "none"; const client = new SSHClient(); - - const config: Record = { - host: host.ip?.replace(/^\[|\]$/g, "") || host.ip, - port: host.port || 22, - username: host.username, - tryKeyboard: true, - keepaliveInterval: - typeof host.terminalConfig?.keepaliveInterval === "number" - ? host.terminalConfig.keepaliveInterval * 1000 - : 60000, - keepaliveCountMax: - typeof host.terminalConfig?.keepaliveCountMax === "number" - ? host.terminalConfig.keepaliveCountMax - : 5, - readyTimeout: 60000, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - hostVerifier: await SSHHostKeyVerifier.createHostVerifier( - hostId, - host.ip, - host.port || 22, - null, - userId, - false, - ), - }; - - if ( - resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale" || - resolvedCredentials.authType === "warpgate" - ) { - // Tailscale SSH, "none", and Warpgate auth: no static credentials - } else if (resolvedCredentials.authType === "password") { - if (resolvedCredentials.password) { - config.password = resolvedCredentials.password; - } - } else if (usesIssuedCertificate(resolvedCredentials.authType)) { - try { - const { getOPKSSHToken } = - await import("../../../../src/backend/hosts/opkssh-auth.js"); - const token = await getOPKSSHToken(userId, hostId); - - if (!token) { - connectionLogs.push( - createConnectionLog( - "error", - "docker_auth", - "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.", - ), - ); - return res.status(401).json({ - error: - "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.", - requiresOPKSSHAuth: true, - connectionLogs, - }); - } - - const { setupOPKSSHCertAuth } = - await import("../../../../src/backend/hosts/opkssh-cert-auth.js"); - await setupOPKSSHCertAuth( - config as import("ssh2").ConnectConfig, - client, - token, - host.username, - ); + const ssh = pluginSsh(); + const prepared = await ssh.prepare(connectTarget, { + purpose: "docker", + client, + log: (level, message) => connectionLogs.push( - createConnectionLog( - "info", - "docker_auth", - "Using OPKSSH certificate authentication", - ), - ); - } catch (opksshError) { - sshLogger.error("OPKSSH authentication error for Docker", { - operation: "docker_connect", - sessionId, - hostId, - error: getErrorMessage(opksshError), - }); - connectionLogs.push( - createConnectionLog( - "error", - "docker_auth", - `OPKSSH authentication failed: ${getErrorMessage(opksshError)}`, - ), - ); - return res.status(500).json({ - error: "OPKSSH authentication failed", + createConnectionLog(level, "docker_auth", message), + ), + }); + const config = prepared.config; + + if (prepared.outcome.status !== "ready") { + const outcome = prepared.outcome; + connectionLogs.push( + createConnectionLog("error", "docker_auth", outcome.message), + ); + if (outcome.status === "interaction-required") { + return res.status(401).json({ + error: outcome.message, + requiresAuthInteraction: outcome.interaction, + ...(outcome.flag ? { [outcome.flag]: true } : {}), connectionLogs, }); } - } else if ( - resolvedCredentials.authType === "key" && - resolvedCredentials.sshKey - ) { - try { - config.privateKey = preparePrivateKeyForSSH2( - resolvedCredentials.sshKey, - resolvedCredentials.keyPassword, - ); - if (resolvedCredentials.keyPassword) { - config.passphrase = resolvedCredentials.keyPassword; - } - } catch (error) { - const message = getErrorMessage(error, "Invalid private key format"); - sshLogger.error("SSH key processing error", error, { - operation: "docker_connect", - sessionId, - hostId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "docker_auth", - `SSH key processing error: ${message}`, - ), - ); - return res.status(400).json({ - error: `SSH key format error: ${message}`, - connectionLogs, - }); - } - } else if (resolvedCredentials.authType === "key") { - sshLogger.error( - "SSH key authentication requested but no key provided", - { - operation: "docker_connect", - sessionId, - hostId, - }, - ); - connectionLogs.push( - createConnectionLog( - "error", - "docker_auth", - "SSH key authentication requested but no key provided", - ), - ); - return res.status(400).json({ - error: "SSH key authentication requested but no key provided", - connectionLogs, - }); - } else if (resolvedCredentials.authType === "agent") { - const result = await applyAgentAuth( - config, - host.terminalConfig as unknown as Record | undefined, - ); - if ("error" in result) { - connectionLogs.push( - createConnectionLog("error", "docker_auth", result.error), - ); - return res.status(400).json({ error: result.error, connectionLogs }); - } - connectionLogs.push( - createConnectionLog( - "info", - "docker_auth", - "Using SSH agent authentication", - ), - ); + return res.status(400).json({ error: outcome.message, connectionLogs }); } + // Auth types with no stored secret answer a password prompt with + // "auth_required" so the panel can ask for credentials. + const credentialless = !ssh.requiresSecret(authType); + let responseSent = false; connectionLogs.push( createConnectionLog("info", "dns", `Resolving DNS for ${host.ip}`), ); - connectionLogs.push( createConnectionLog( "info", @@ -443,36 +303,10 @@ export function registerDockerSshRoutes(app: express.Express): void { `Connecting to ${host.ip}:${host.port || 22}`, ), ); - connectionLogs.push( createConnectionLog("info", "handshake", "Initiating SSH handshake"), ); - if (resolvedCredentials.authType === "password") { - connectionLogs.push( - createConnectionLog("info", "auth", "Authenticating with password"), - ); - } else if (resolvedCredentials.authType === "key") { - connectionLogs.push( - createConnectionLog("info", "auth", "Authenticating with SSH key"), - ); - } else if (resolvedCredentials.authType === "agent") { - connectionLogs.push( - createConnectionLog("info", "auth", "Authenticating with SSH agent"), - ); - } else if ( - resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale" - ) { - connectionLogs.push( - createConnectionLog( - "info", - "auth", - "Attempting keyboard-interactive authentication", - ), - ); - } - client.on("ready", () => { if (responseSent) return; responseSent = true; @@ -630,8 +464,7 @@ export function registerDockerSshRoutes(app: express.Express): void { } if ( - (resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale") && + credentialless && (err.message.includes("authentication") || err.message.includes( "All configured authentication methods failed", @@ -662,388 +495,171 @@ export function registerDockerSshRoutes(app: express.Express): void { } }); + const parkForAnswer = ( + finish: (responses: string[]) => void, + prompts: Array<{ prompt: string; echo: boolean }>, + promptIndex: number, + isWarpgate = false, + ) => { + pendingTOTPSessions[sessionId] = { + client, + finish, + config, + createdAt: Date.now(), + sessionId, + hostId, + ip: host.ip, + port: host.port || 22, + username: host.username, + userId, + prompts, + totpPromptIndex: promptIndex, + resolvedPassword, + totpAttempts: 0, + containerRuntime, + ...(isWarpgate ? { isWarpgate: true } : {}), + }; + }; + client.on( "keyboard-interactive", ( name: string, instructions: string, - instructionsLang: string, + _instructionsLang: string, prompts: Array<{ prompt: string; echo: boolean }>, finish: (responses: string[]) => void, ) => { - const promptTexts = prompts.map((p) => p.prompt); + const decision = ssh.classifyKeyboardInteractive( + { name, instructions, prompts }, + connectTarget, + ); + const autoFinish = () => + finish(ssh.autoResponses(prompts, resolvedPassword)); - const warpgatePattern = /warpgate\s+authentication/i; - const isWarpgate = - warpgatePattern.test(name) || - warpgatePattern.test(instructions) || - promptTexts.some((p) => warpgatePattern.test(p)); - - if (isWarpgate) { - const fullText = `${name}\n${instructions}\n${promptTexts.join("\n")}`; - const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i); - const keyMatch = fullText.match( - /security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i, - ); - - if (urlMatch) { - if (responseSent) return; - responseSent = true; - - pendingTOTPSessions[sessionId] = { - client, - finish, - config, - createdAt: Date.now(), - sessionId, - hostId, - ip: host.ip, - port: host.port || 22, - username: host.username, - userId, - prompts, - totpPromptIndex: -1, - resolvedPassword: resolvedCredentials.password, - totpAttempts: 0, - isWarpgate: true, - containerRuntime, - }; - - connectionLogs.push( - createConnectionLog( - "info", - "docker_auth", - "Warpgate authentication required", - ), - ); - - res.json({ - requires_warpgate: true, - sessionId, - url: urlMatch[0], - securityKey: keyMatch ? keyMatch[1] : "N/A", - connectionLogs, - }); - return; - } + if (decision.kind === "auto") { + finish(decision.responses); + return; } - const totpPromptIndex = prompts.findIndex((p) => - /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( - p.prompt, - ), - ); - - if (totpPromptIndex !== -1) { - if (responseSent) { - const responses = prompts.map((p) => { - if ( - /password/i.test(p.prompt) && - resolvedCredentials.password - ) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - return; - } + if (decision.kind === "warpgate") { + if (responseSent) return; responseSent = true; - - if (pendingTOTPSessions[sessionId]) { - const responses = prompts.map((p) => { - if ( - /password/i.test(p.prompt) && - resolvedCredentials.password - ) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - return; - } - - pendingTOTPSessions[sessionId] = { - client, - finish, - config, - createdAt: Date.now(), - sessionId, - hostId, - ip: host.ip, - port: host.port || 22, - username: host.username, - userId, - prompts, - totpPromptIndex, - resolvedPassword: resolvedCredentials.password, - totpAttempts: 0, - containerRuntime, - }; - + parkForAnswer(finish, prompts, -1, true); connectionLogs.push( createConnectionLog( "info", "docker_auth", - "TOTP verification required", + "Warpgate authentication required", ), ); + res.json({ + requires_warpgate: true, + sessionId, + url: decision.url, + securityKey: decision.securityKey, + connectionLogs, + }); + return; + } + const promptText = prompts[decision.promptIndex].prompt; + const isPasswordPrompt = /password/i.test(promptText); + + if ( + decision.kind === "input" && + !decision.isPush && + !isPasswordPrompt + ) { + autoFinish(); + return; + } + + if (decision.kind === "input" && isPasswordPrompt && credentialless) { + if (responseSent) return; + responseSent = true; + client.end(); + res.json({ status: "auth_required", reason: "no_keyboard" }); + return; + } + + if (responseSent || pendingTOTPSessions[sessionId]) { + autoFinish(); + return; + } + responseSent = true; + parkForAnswer(finish, prompts, decision.promptIndex); + + if (decision.kind === "input" && isPasswordPrompt) { res.json({ requires_totp: true, sessionId, - prompt: prompts[totpPromptIndex].prompt, - connectionLogs, + prompt: promptText, + isPassword: true, }); - } else { - const passwordPromptIndex = prompts.findIndex((p) => - /password/i.test(p.prompt), - ); - - if (resolvedCredentials.authType === "warpgate") { - finish(prompts.map(() => "")); - return; - } - - if ( - (resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale") && - passwordPromptIndex !== -1 - ) { - if (responseSent) return; - responseSent = true; - client.end(); - res.json({ - status: "auth_required", - reason: "no_keyboard", - }); - return; - } - - const hasStoredPassword = - resolvedCredentials.password && - resolvedCredentials.authType !== "none"; - - if (!hasStoredPassword && passwordPromptIndex !== -1) { - if (responseSent) { - const responses = prompts.map((p) => { - if ( - /password/i.test(p.prompt) && - resolvedCredentials.password - ) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - return; - } - responseSent = true; - - if (pendingTOTPSessions[sessionId]) { - const responses = prompts.map((p) => { - if ( - /password/i.test(p.prompt) && - resolvedCredentials.password - ) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - return; - } - - pendingTOTPSessions[sessionId] = { - client, - finish, - config, - createdAt: Date.now(), - sessionId, - hostId, - ip: host.ip, - port: host.port || 22, - username: host.username, - userId, - prompts, - totpPromptIndex: passwordPromptIndex, - resolvedPassword: resolvedCredentials.password, - totpAttempts: 0, - containerRuntime, - }; - - res.json({ - requires_totp: true, - sessionId, - prompt: prompts[passwordPromptIndex].prompt, - isPassword: true, - }); - return; - } - - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); + return; } - }, - ); - const proxyConfig: SOCKS5Config | null = - useSocks5 && - (socks5Host || - (socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0)) - ? { - useSocks5, - socks5Host, - socks5Port, - socks5Username, - socks5Password, - socks5ProxyChain: socks5ProxyChain as ProxyNode[], - } - : null; - - const hasJumpHosts = host.jumpHosts && host.jumpHosts.length > 0; - - if (hasJumpHosts) { - try { - if (proxyConfig) { - connectionLogs.push( - createConnectionLog( - "info", - "proxy", - "Connecting via proxy + jump hosts", - ), - ); - } connectionLogs.push( createConnectionLog( "info", - "jump", - `Connecting via ${host.jumpHosts!.length} jump host(s)`, + "docker_auth", + "TOTP verification required", ), ); - const jumpClient = await createJumpHostChain( - host.jumpHosts as Array<{ hostId: number }>, - userId, - ); - - if (!jumpClient) { - connectionLogs.push( - createConnectionLog( - "error", - "jump", - "Failed to establish jump host chain", - ), - ); - return res.status(500).json({ - error: "Failed to establish jump host chain", - connectionLogs, - }); - } - - jumpClient.forwardOut( - "127.0.0.1", - 0, - host.ip, - host.port || 22, - (err, stream) => { - if (err) { - sshLogger.error("Failed to forward through jump host", err, { - operation: "docker_jump_forward", - sessionId, - hostId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "jump", - `Failed to forward through jump host: ${err.message}`, - ), - ); - jumpClient.end(); - if (!responseSent) { - responseSent = true; - return res.status(500).json({ - error: - "Failed to forward through jump host: " + err.message, - connectionLogs, - }); - } - return; - } - - config.sock = stream; - client.connect(config); - }, - ); - } catch (jumpError) { - sshLogger.error("Jump host connection failed", jumpError, { - operation: "docker_jump_connect", + res.json({ + requires_totp: true, sessionId, - hostId, + prompt: promptText, + connectionLogs, }); - connectionLogs.push( - createConnectionLog( - "error", - "jump", - `Jump host connection failed: ${getErrorMessage(jumpError)}`, - ), - ); - if (!responseSent) { - responseSent = true; - return res.status(500).json({ - error: - "Jump host connection failed: " + getErrorMessage(jumpError), - connectionLogs, - }); - } - return; - } - } else if (proxyConfig) { + }, + ); + + if (host.jumpHosts && host.jumpHosts.length > 0) { + connectionLogs.push( + createConnectionLog( + "info", + "jump", + `Connecting via ${host.jumpHosts.length} jump host(s)`, + ), + ); + } else if (useSocks5) { connectionLogs.push( createConnectionLog("info", "proxy", "Connecting via proxy"), ); - try { - const proxySocket = await createSocks5Connection( - host.ip, - host.port || 22, - proxyConfig, - ); - if (proxySocket) { - config.sock = proxySocket; - } - client.connect(config); - } catch (proxyError) { - sshLogger.error("Proxy connection failed", proxyError, { - operation: "docker_proxy_connect", - sessionId, - hostId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "proxy", - `Proxy connection failed: ${getErrorMessage(proxyError)}`, - ), - ); - if (!responseSent) { - responseSent = true; - return res.status(500).json({ - error: "Proxy connection failed: " + getErrorMessage(proxyError), - connectionLogs, - }); - } - return; - } - } else { - await resolveSshConnectConfigHost(config); - client.connect(config); } + + try { + const transport = await ssh.openTransport(connectTarget, config); + if (transport.jumpClient) { + const jumpClient = transport.jumpClient as SSHClient; + client.on("close", () => jumpClient.end()); + } + } catch (transportError) { + sshLogger.error("Docker SSH transport failed", transportError, { + operation: "docker_transport", + sessionId, + hostId, + }); + connectionLogs.push( + createConnectionLog( + "error", + host.jumpHosts && host.jumpHosts.length > 0 ? "jump" : "proxy", + getErrorMessage(transportError), + ), + ); + if (!responseSent) { + responseSent = true; + return res.status(500).json({ + error: getErrorMessage(transportError), + connectionLogs, + }); + } + return; + } + + client.connect(config); } catch (error) { sshLogger.error("Docker SSH connection error", error, { operation: "docker_connect", diff --git a/plugins/docker/src/backend/ssh.ts b/plugins/docker/src/backend/ssh.ts new file mode 100644 index 000000000..b7a221543 --- /dev/null +++ b/plugins/docker/src/backend/ssh.ts @@ -0,0 +1,36 @@ +import type { Client } from "ssh2"; +import type { + PluginSsh, + PluginSshConnection, + PluginSshConnectOptions, + PluginSshHost, +} from "@termix/plugin-sdk/backend"; + +let current: PluginSsh | null = null; + +/** Set in activate, cleared on deactivate. */ +export function setPluginSsh(ssh: PluginSsh | null): void { + current = ssh; +} + +export function pluginSsh(): PluginSsh { + if (!current) throw new Error("The plugin is not active"); + return current; +} + +/** withConnection with ssh2's Client type filled in. */ +export function withSshConnection( + host: number | PluginSshHost, + options: PluginSshConnectOptions & { pool: string }, + fn: (client: Client) => Promise, +): Promise { + return pluginSsh().withConnection(host, options, fn); +} + +/** connect with ssh2's Client type filled in. */ +export function connectSsh( + host: number | PluginSshHost, + options?: PluginSshConnectOptions, +): Promise> { + return pluginSsh().connect(host, options); +} diff --git a/plugins/fleets/src/backend/index.ts b/plugins/fleets/src/backend/index.ts index 9df692456..7a86375e0 100644 --- a/plugins/fleets/src/backend/index.ts +++ b/plugins/fleets/src/backend/index.ts @@ -1,7 +1,10 @@ import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { setPluginSsh } from "./ssh.js"; import { startFleetsService, stopFleetsService } from "./routes.js"; export async function activate(ctx: PluginContext) { + setPluginSsh(ctx.ssh); + ctx.disposables.add(() => setPluginSsh(null)); startFleetsService(ctx.http.router()); ctx.log.info("Fleets routes mounted at /plugin-api/fleets"); } diff --git a/plugins/fleets/src/backend/routes.ts b/plugins/fleets/src/backend/routes.ts index 93d312fda..c130a3f14 100644 --- a/plugins/fleets/src/backend/routes.ts +++ b/plugins/fleets/src/backend/routes.ts @@ -26,11 +26,7 @@ import { createCurrentUserRepository, } from "../../../../src/backend/database/repositories/factory.js"; import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; -import { - getFleetPoolKey, - createFleetSshFactory, -} from "../../../../src/backend/hosts/ssh-client-factory.js"; -import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js"; +import { withSshConnection } from "./ssh.js"; import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js"; import { @@ -915,9 +911,9 @@ router.post( inputValues && typeof inputValues === "object" ? inputValues : {}, ); - const { stdout, stderr, code } = await withConnection( - getFleetPoolKey(fullHost), - createFleetSshFactory(fullHost), + const { stdout, stderr, code } = await withSshConnection( + fullHost as never, + { pool: "fleet", purpose: "fleet" }, (client) => execCommand(client, resolvedCommand, 60000), ); @@ -1017,9 +1013,9 @@ router.post( return { success: false, error: "Host not found" }; } - await withConnection( - getFleetPoolKey(fullHost), - createFleetSshFactory(fullHost), + await withSshConnection( + fullHost as never, + { pool: "fleet", purpose: "fleet" }, async (client) => { const sftp = await getSftp(client); await sftpWriteFile(sftp, remotePath, file.buffer); @@ -1112,9 +1108,9 @@ router.post( return { success: false, error: "Host not found" }; } - const data = await withConnection( - getFleetPoolKey(fullHost), - createFleetSshFactory(fullHost), + const data = await withSshConnection( + fullHost as never, + { pool: "fleet", purpose: "fleet" }, async (client) => { const sftp = await getSftp(client); return sftpReadFile(sftp, remotePath); @@ -1305,9 +1301,9 @@ router.post( return { success: false, error: "Host not found" }; } - const record = await withConnection( - getFleetPoolKey(fullHost), - createFleetSshFactory(fullHost), + const record = await withSshConnection( + fullHost as never, + { pool: "fleet", purpose: "fleet" }, async (client) => { const platform = await detectPlatform(client); const { stdout } = await execCommand( @@ -1418,9 +1414,9 @@ router.post( return { success: false, error: "Host not found" }; } - return withConnection( - getFleetPoolKey(fullHost), - createFleetSshFactory(fullHost), + return withSshConnection( + fullHost as never, + { pool: "fleet", purpose: "fleet" }, async (client) => { const platform = await detectPlatform(client); // "remove" has no PackageAction counterpart in buildPackageActionCommand diff --git a/plugins/fleets/src/backend/ssh.ts b/plugins/fleets/src/backend/ssh.ts new file mode 100644 index 000000000..b7a221543 --- /dev/null +++ b/plugins/fleets/src/backend/ssh.ts @@ -0,0 +1,36 @@ +import type { Client } from "ssh2"; +import type { + PluginSsh, + PluginSshConnection, + PluginSshConnectOptions, + PluginSshHost, +} from "@termix/plugin-sdk/backend"; + +let current: PluginSsh | null = null; + +/** Set in activate, cleared on deactivate. */ +export function setPluginSsh(ssh: PluginSsh | null): void { + current = ssh; +} + +export function pluginSsh(): PluginSsh { + if (!current) throw new Error("The plugin is not active"); + return current; +} + +/** withConnection with ssh2's Client type filled in. */ +export function withSshConnection( + host: number | PluginSshHost, + options: PluginSshConnectOptions & { pool: string }, + fn: (client: Client) => Promise, +): Promise { + return pluginSsh().withConnection(host, options, fn); +} + +/** connect with ssh2's Client type filled in. */ +export function connectSsh( + host: number | PluginSshHost, + options?: PluginSshConnectOptions, +): Promise> { + return pluginSsh().connect(host, options); +} diff --git a/plugins/fleets/tests/backend/routes.test.ts b/plugins/fleets/tests/backend/routes.test.ts index 0cdb02f66..6d4e5fa29 100644 --- a/plugins/fleets/tests/backend/routes.test.ts +++ b/plugins/fleets/tests/backend/routes.test.ts @@ -60,15 +60,10 @@ vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({ resolveHostById: async (hostId: number) => state.hosts.get(hostId) ?? null, })); -vi.mock("../../../../src/backend/hosts/ssh-client-factory.js", () => ({ - getFleetPoolKey: () => "pool-key", - createFleetSshFactory: () => async () => ({}), -})); - -vi.mock("../../../../src/backend/hosts/ssh-connection-pool.js", () => ({ - withConnection: async ( - _key: string, - _factory: unknown, +vi.mock("../../src/backend/ssh.js", () => ({ + withSshConnection: async ( + _host: unknown, + _options: unknown, fn: (client: unknown) => unknown, ) => fn({}), })); diff --git a/plugins/host-metrics/src/backend/helpers.ts b/plugins/host-metrics/src/backend/helpers.ts index 8dc9bb4da..d6020bed8 100644 --- a/plugins/host-metrics/src/backend/helpers.ts +++ b/plugins/host-metrics/src/backend/helpers.ts @@ -1,5 +1,5 @@ import type { Client } from "ssh2"; -import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js"; +import { pluginSsh } from "./ssh.js"; export type StatsCapableHost = { connectionType?: string; @@ -14,9 +14,7 @@ export type TcpPingStatsConfig = { export function supportsMetrics(host: StatsCapableHost): boolean { const connectionType = host.connectionType || "ssh"; if (connectionType !== "ssh") return false; - if (host.authType === "none" || usesIssuedCertificate(host.authType)) - return false; - return true; + return pluginSsh().supportsBackground(host.authType || "none"); } export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean { diff --git a/plugins/host-metrics/src/backend/index.ts b/plugins/host-metrics/src/backend/index.ts index 7c3dff92d..c177a3481 100644 --- a/plugins/host-metrics/src/backend/index.ts +++ b/plugins/host-metrics/src/backend/index.ts @@ -1,4 +1,5 @@ import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { setPluginSsh } from "./ssh.js"; import { onHostDeleted, onHostLogin, @@ -8,6 +9,8 @@ import { } from "./routes.js"; export async function activate(ctx: PluginContext) { + setPluginSsh(ctx.ssh); + ctx.disposables.add(() => setPluginSsh(null)); await startHostMetricsService(ctx.http.router()); // Core used to POST these to this plugin's own port with an internal auth diff --git a/plugins/host-metrics/src/backend/routes.ts b/plugins/host-metrics/src/backend/routes.ts index 8eec792ef..d98945403 100644 --- a/plugins/host-metrics/src/backend/routes.ts +++ b/plugins/host-metrics/src/backend/routes.ts @@ -1,13 +1,7 @@ import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; -import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js"; import express, { type Router } from "express"; import net from "net"; import { Client, type ConnectConfig } from "ssh2"; -import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js"; -import { - pickResolvedPassword, - pickResolvedUsername, -} from "../../../../src/backend/hosts/credential-username.js"; import { getCurrentSettingValue, createCurrentHostMetricsHistoryRepository, @@ -47,16 +41,7 @@ import { collectPortsMetrics } from "./widgets/ports-collector.js"; import { collectFirewallMetrics } from "./widgets/firewall-collector.js"; import { collectTemperatureMetrics } from "./widgets/temperature-collector.js"; import { collectGpuMetrics, type GpuMetrics } from "./widgets/gpu-collector.js"; -import { - createSocks5Connection, - type SOCKS5Config, -} from "../../../../src/backend/utils/socks5-helper.js"; -import { preparePrivateKeyForSSH2 } from "../../../../src/backend/utils/ssh-key-utils.js"; -import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js"; -import { - connectionPool, - withConnection, -} from "../../../../src/backend/hosts/ssh-connection-pool.js"; +import { connectionPool } from "../../../../src/backend/hosts/ssh-connection-pool.js"; import { registerHostMetricsSettingsRoutes } from "./settings-routes.js"; import { registerHostMetricsViewerRoutes } from "./viewer-routes.js"; import { registerHostMetricsPreferencesRoutes } from "./preferences-routes.js"; @@ -75,10 +60,10 @@ import { } from "./automation-bridge.js"; import { notifyAutomationInternalEvent } from "../../../../src/backend/hosts/automation-events.js"; import { registerManagerRoutes } from "./managers/index.js"; -import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js"; import { AccessDeniedError } from "./managers/route-helpers.js"; import type { ManagerHost } from "./managers/types.js"; -import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js"; +import type { PluginSshHost } from "@termix/plugin-sdk/backend"; +import { pluginSsh, withSshConnection as withPooledSsh } from "./ssh.js"; import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; import { isTcpPingEnabled, @@ -617,10 +602,15 @@ class PollingManager { let isOnline: boolean; if (refreshedHost.jumpHosts && refreshedHost.jumpHosts.length > 0) { - const jumpClient = await createJumpHostChain( - refreshedHost.jumpHosts, - userId, - ); + let jumpClient: Client | null = null; + try { + ({ client: jumpClient } = await pluginSsh().jumpChain( + refreshedHost.jumpHosts, + { forHost: refreshedHost as unknown as PluginSshHost }, + )); + } catch { + jumpClient = null; + } isOnline = jumpClient ? await tcpPingThroughJumpHost( jumpClient, @@ -1139,176 +1129,38 @@ async function fetchHostById( } } +/** + * Resolves through core's host resolver, so shared-host overrides and + * external secret references apply here exactly as in the terminal. + */ async function resolveHostCredentials( host: Record, userId: string, ): Promise { try { - const baseHost: Record = { - id: host.id, - name: host.name, - ip: host.ip, - port: host.port, - username: host.username, - folder: host.folder || "", + const resolved = (await resolveHostById( + host.id as number, + userId, + )) as unknown as Record | null; + if (!resolved) return undefined; + const terminalConfig = resolved.terminalConfig as + Record | undefined; + return { + ...resolved, tags: - typeof host.tags === "string" - ? host.tags - ? host.tags.split(",").filter(Boolean) + typeof resolved.tags === "string" + ? resolved.tags + ? resolved.tags.split(",").filter(Boolean) : [] - : [], - pin: !!host.pin, - authType: host.authType, - enableTerminal: !!host.enableTerminal, - enableTunnel: !!host.enableTunnel, - enableFileManager: !!host.enableFileManager, - defaultPath: host.defaultPath || "/", - tunnelConnections: host.tunnelConnections - ? JSON.parse(host.tunnelConnections as string) - : [], - jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [], - statsConfig: host.statsConfig || undefined, - sudoPassword: (() => { - const top = host.sudoPassword as string | null | undefined; - if (top) return top; - try { - const tc = - typeof host.terminalConfig === "string" - ? JSON.parse(host.terminalConfig as string) - : host.terminalConfig; - return (tc?.sudoPassword as string) || undefined; - } catch { - return undefined; - } - })(), - createdAt: host.createdAt, - updatedAt: host.updatedAt, - userId: host.userId, - useSocks5: !!host.useSocks5, - socks5Host: host.socks5Host || undefined, - socks5Port: host.socks5Port || undefined, - socks5Username: host.socks5Username || undefined, - socks5Password: host.socks5Password || undefined, - socks5ProxyChain: host.socks5ProxyChain - ? JSON.parse(host.socks5ProxyChain as string) - : undefined, + : (resolved.tags ?? []), + sudoPassword: + (resolved.sudoPassword as string | undefined) || + (terminalConfig?.sudoPassword as string | undefined) || + undefined, connectionType: - (host.connectionType as SSHHostWithCredentials["connectionType"]) || + (resolved.connectionType as SSHHostWithCredentials["connectionType"]) || "ssh", - rdpPort: (host.rdpPort as number | undefined) ?? undefined, - vncPort: (host.vncPort as number | undefined) ?? undefined, - telnetPort: (host.telnetPort as number | undefined) ?? undefined, - }; - - if (host.credentialId) { - try { - const ownerId = host.userId; - const isSharedHost = userId !== ownerId; - - if (isSharedHost) { - const { SharedHostSecretsManager } = - await import("../../../../src/backend/utils/shared-host-secrets-manager.js"); - const sharedCred = - await SharedHostSecretsManager.getInstance().getSecretForUser( - host.id as number, - userId, - "ssh", - ); - - if (sharedCred) { - baseHost.credentialId = host.credentialId; - baseHost.authType = sharedCred.authType; - - baseHost.username = pickResolvedUsername( - host.username, - sharedCred.username, - host.overrideCredentialUsername, - ); - - if (sharedCred.password) { - baseHost.password = sharedCred.password; - } - if (sharedCred.key) { - baseHost.key = sharedCred.key; - } - if (sharedCred.keyPassword) { - baseHost.keyPassword = sharedCred.keyPassword; - } - if (sharedCred.keyType) { - baseHost.keyType = sharedCred.keyType; - } - } - } else { - const credential = - await createCurrentHostResolutionRepository().findCredentialByIdForUser( - host.credentialId as number, - userId, - ); - - if (credential) { - baseHost.credentialId = credential.id; - baseHost.authType = - credential.authType || - (credential.password - ? "password" - : credential.key || - (credential as Record).privateKey - ? "key" - : "none"); - - baseHost.username = pickResolvedUsername( - host.username, - credential.username, - host.overrideCredentialUsername, - ); - - baseHost.password = pickResolvedPassword( - host.password, - credential.password, - ); - if ( - credential.key || - (credential as Record).privateKey - ) { - baseHost.key = - credential.key || - ((credential as Record).privateKey as string); - } - if (credential.keyPassword) { - baseHost.keyPassword = credential.keyPassword; - } - if (credential.keyType) { - baseHost.keyType = credential.keyType; - } - } else { - addLegacyCredentials(baseHost, host); - if (baseHost.authType === "credential") { - baseHost.authType = baseHost.password - ? "password" - : baseHost.key - ? "key" - : "none"; - } - } - } - } catch (error) { - statsLogger.warn( - `Failed to resolve credential ${host.credentialId} for host ${host.id}: ${getErrorMessage(error)}`, - ); - addLegacyCredentials(baseHost, host); - if (baseHost.authType === "credential") { - baseHost.authType = baseHost.password - ? "password" - : baseHost.key - ? "key" - : "none"; - } - } - } else { - addLegacyCredentials(baseHost, host); - } - - return baseHost as unknown as SSHHostWithCredentials; + } as unknown as SSHHostWithCredentials; } catch (error) { statsLogger.error( `Failed to resolve host credentials for host ${host.id}: ${getErrorMessage(error)}`, @@ -1317,340 +1169,21 @@ async function resolveHostCredentials( } } -function addLegacyCredentials( - baseHost: Record, - host: Record, -): void { - baseHost.password = host.password || null; - baseHost.key = host.key || null; - baseHost.keyPassword = host.keyPassword || null; - baseHost.keyType = host.keyType; -} - -async function buildSshConfig( - host: SSHHostWithCredentials, -): Promise { - const base: ConnectConfig = { - host: host.ip?.replace(/^\[|\]$/g, "") || host.ip, - port: host.port, - username: host.username, - tryKeyboard: true, - keepaliveInterval: 30000, - keepaliveCountMax: 3, - readyTimeout: 60000, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - hostVerifier: await SSHHostKeyVerifier.createHostVerifier( - host.id, - host.ip, - host.port, - null, - host.userId || "", - false, - ), - env: { - TERM: "xterm-256color", - LANG: "en_US.UTF-8", - LC_ALL: "en_US.UTF-8", - LC_CTYPE: "en_US.UTF-8", - LC_MESSAGES: "en_US.UTF-8", - LC_MONETARY: "en_US.UTF-8", - LC_NUMERIC: "en_US.UTF-8", - LC_TIME: "en_US.UTF-8", - LC_COLLATE: "en_US.UTF-8", - COLORTERM: "truecolor", - }, - algorithms: { - kex: [ - "curve25519-sha256", - "curve25519-sha256@libssh.org", - "ecdh-sha2-nistp521", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp256", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group-exchange-sha1", - "diffie-hellman-group1-sha1", - ], - serverHostKey: [ - "ssh-ed25519", - "ecdsa-sha2-nistp521", - "ecdsa-sha2-nistp384", - "ecdsa-sha2-nistp256", - "rsa-sha2-512", - "rsa-sha2-256", - "ssh-rsa", - "ssh-dss", - ], - cipher: SSH_ALGORITHMS.cipher, - hmac: [ - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512", - "hmac-sha2-256", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - } as ConnectConfig; - - if (host.authType === "password") { - if (!host.password) { - throw new Error(`No password available for host ${host.ip}`); - } - base.password = host.password; - } else if (host.authType === "key") { - if (!host.key) { - throw new Error(`No SSH key available for host ${host.ip}`); - } - - try { - (base as Record).privateKey = preparePrivateKeyForSSH2( - host.key, - host.keyPassword, - ); - - if (host.keyPassword) { - (base as Record).passphrase = host.keyPassword; - } - } catch (keyError) { - statsLogger.error( - `SSH key format error for host ${host.ip}: ${getErrorMessage(keyError)}`, - ); - throw new Error(`Invalid SSH key format for host ${host.ip}`, { - cause: keyError, - }); - } - } else if ( - host.authType === "none" || - host.authType === "tailscale" || - host.authType === "warpgate" - ) { - // no credentials needed - } else if (usesIssuedCertificate(host.authType)) { - // cert auth setup happens in createSshFactory (needs client instance) - } else if (host.authType === "vault") { - // cert auth setup happens in createSshFactory (needs client instance) - } else if (host.authType === "credential") { - if (host.password) { - base.password = host.password; - } else if (host.key) { - const cleanKey = host.key - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - (base as Record).privateKey = Buffer.from( - cleanKey, - "utf8", - ); - if (host.keyPassword) { - (base as Record).passphrase = host.keyPassword; - } - } else { - throw new Error(`Credential for host ${host.ip} could not be resolved`); - } - } else { - throw new Error( - `Unsupported authentication type '${host.authType}' for host ${host.ip}`, - ); - } - - return base; -} - function getPoolKey(host: SSHHostWithCredentials): string { - const socks5Key = host.useSocks5 - ? `:socks5:${host.socks5Host}:${host.socks5Port}` - : ""; - return `stats:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`; -} - -function createSshFactory(host: SSHHostWithCredentials): () => Promise { - return async () => { - const config = await buildSshConfig(host); - const client = new Client(); - - // Set up OPKSSH cert auth if needed (requires client instance) - if (usesIssuedCertificate(host.authType) && host.userId) { - const { getOPKSSHToken } = - await import("../../../../src/backend/hosts/opkssh-auth.js"); - const token = await getOPKSSHToken(host.userId, host.id); - if (!token) { - throw new Error( - "OPKSSH authentication required. Please open a Terminal connection first.", - ); - } - const { setupOPKSSHCertAuth } = - await import("../../../../src/backend/hosts/opkssh-cert-auth.js"); - await setupOPKSSHCertAuth(config, client, token, host.username); - } else if (host.authType === "vault") { - const { setupVaultSshSignerAuth } = - await import("../../../../src/backend/hosts/vault-ssh-connect.js"); - await setupVaultSshSignerAuth(config, client, host); - } - - const proxyConfig: SOCKS5Config | null = - host.useSocks5 && - (host.socks5Host || - (host.socks5ProxyChain && host.socks5ProxyChain.length > 0)) - ? { - useSocks5: host.useSocks5, - socks5Host: host.socks5Host, - socks5Port: host.socks5Port, - socks5Username: host.socks5Username, - socks5Password: host.socks5Password, - socks5ProxyChain: host.socks5ProxyChain, - } - : null; - - const hasJumpHosts = - host.jumpHosts && host.jumpHosts.length > 0 && host.userId; - - let jumpClient: Client | null = null; - if (hasJumpHosts) { - jumpClient = await createJumpHostChain(host.jumpHosts!, host.userId!); - - if (!jumpClient) { - throw new Error("Failed to establish jump host chain"); - } - } else if (proxyConfig) { - try { - const proxySocket = await createSocks5Connection( - host.ip, - host.port, - proxyConfig, - ); - if (proxySocket) { - config.sock = proxySocket; - } - } catch (proxyError) { - throw new Error( - "Proxy connection failed: " + getErrorMessage(proxyError), - { cause: proxyError }, - ); - } - } - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - client.end(); - jumpClient?.end(); - reject(new Error("SSH connection timeout")); - }, 30000); - - client.on("ready", () => { - clearTimeout(timeout); - resolve(client); - }); - - client.on("close", () => { - jumpClient?.end(); - }); - - client.on("error", (err) => { - clearTimeout(timeout); - jumpClient?.end(); - reject(err); - }); - - client.on( - "keyboard-interactive", - ( - _name: string, - _instructions: string, - _instructionsLang: string, - prompts: Array<{ prompt: string; echo: boolean }>, - finish: (responses: string[]) => void, - ) => { - const totpPromptIndex = prompts.findIndex((p) => - /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( - p.prompt, - ), - ); - - if (totpPromptIndex !== -1) { - const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - - pendingTOTPSessions[sessionId] = { - client, - finish, - config, - createdAt: Date.now(), - sessionId, - hostId: host.id, - userId: host.userId!, - prompts: prompts.map((p) => ({ - prompt: p.prompt, - echo: p.echo ?? false, - })), - totpPromptIndex, - resolvedPassword: host.password, - totpAttempts: 0, - }; - - return; - } else if (host.password) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt)) { - return host.password || ""; - } - return ""; - }); - finish(responses); - } else { - finish(prompts.map(() => "")); - } - }, - ); - - if (jumpClient) { - jumpClient.forwardOut( - "127.0.0.1", - 0, - host.ip, - host.port, - (err, stream) => { - if (err) { - clearTimeout(timeout); - jumpClient!.end(); - reject( - new Error( - "Failed to forward through jump host: " + err.message, - ), - ); - return; - } - - config.sock = stream; - client.connect(config); - }, - ); - } else if (config.sock) { - client.connect(config); - } else { - resolveSshConnectConfigHost(config) - .then(() => { - client.connect(config); - }) - .catch((error) => { - clearTimeout(timeout); - reject(error); - }); - return; - } - }); - }; + return pluginSsh().poolKey("stats", host as unknown as PluginSshHost); } async function withSshConnection( host: SSHHostWithCredentials, fn: (client: Client) => Promise, ): Promise { - const key = getPoolKey(host); - const factory = createSshFactory(host); - return withConnection(key, factory, fn); + // Background polling has nobody to answer a TOTP prompt, so the pooled + // connection answers password prompts only and fails fast otherwise. + return withPooledSsh( + host as unknown as PluginSshHost, + { pool: "stats", purpose: "metrics", overrides: { readyTimeout: 60000 } }, + fn, + ); } const proxmoxPollingManager = new ProxmoxPollingManager( @@ -2347,34 +1880,30 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { return res.json({ success: true, connectionLogs }); } - const config = await buildSshConfig(host); const client = new Client(); - - if (usesIssuedCertificate(host.authType) && host.userId) { - const { getOPKSSHToken } = - await import("../../../../src/backend/hosts/opkssh-auth.js"); - const token = await getOPKSSHToken(host.userId, host.id); - if (!token) { - connectionLogs.push( - createConnectionLog( - "error", - "auth", - "OPKSSH authentication required. Please open a Terminal connection first.", - ), - ); + const ssh = pluginSsh(); + const prepared = await ssh.prepare(host as unknown as PluginSshHost, { + purpose: "metrics", + client, + log: (level, message) => + connectionLogs.push(createConnectionLog(level, "auth", message)), + }); + const config = prepared.config as ConnectConfig; + config.readyTimeout = 60000; + if (prepared.outcome.status !== "ready") { + const outcome = prepared.outcome; + connectionLogs.push( + createConnectionLog("error", "auth", outcome.message), + ); + if (outcome.status === "interaction-required") { return res.status(401).json({ - error: "OPKSSH authentication required", - requiresOPKSSHAuth: true, + error: outcome.message, + requiresAuthInteraction: outcome.interaction, + ...(outcome.flag ? { [outcome.flag]: true } : {}), connectionLogs, }); } - const { setupOPKSSHCertAuth } = - await import("../../../../src/backend/hosts/opkssh-cert-auth.js"); - await setupOPKSSHCertAuth(config, client, token, host.username); - } else if (host.authType === "vault") { - const { setupVaultSshSignerAuth } = - await import("../../../../src/backend/hosts/vault-ssh-connect.js"); - await setupVaultSshSignerAuth(config, client, host); + return res.status(400).json({ error: outcome.message, connectionLogs }); } const connectionPromise = new Promise<{ @@ -2396,12 +1925,13 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { client.on( "keyboard-interactive", - (name, instructions, instructionsLang, prompts, finish) => { - const totpPromptIndex = prompts.findIndex((p) => - /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( - p.prompt, - ), + (name, instructions, _instructionsLang, prompts, finish) => { + const decision = ssh.classifyKeyboardInteractive( + { name, instructions, prompts }, + host as unknown as PluginSshHost, ); + const totpPromptIndex = + decision.kind === "totp" ? decision.promptIndex : -1; if (totpPromptIndex !== -1) { const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -2443,13 +1973,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { } return; } else { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && host.password) { - return host.password; - } - return ""; - }); - finish(responses); + finish(ssh.autoResponses(prompts, host.password)); } }, ); @@ -2577,10 +2101,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { } }); - const hasJumpHosts = - host.jumpHosts && host.jumpHosts.length > 0 && host.userId; - - if (hasJumpHosts) { + if (host.jumpHosts && host.jumpHosts.length > 0) { connectionLogs.push( createConnectionLog( "info", @@ -2588,92 +2109,33 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { "Connecting via jump host chain", ), ); - createJumpHostChain(host.jumpHosts!, host.userId!) - .then((jumpClient) => { - jumpClient.forwardOut( - "127.0.0.1", - 0, - host.ip, - host.port, - (err, stream) => { - if (err || !stream) { - if (!isResolved) { - isResolved = true; - clearTimeout(timeout); - reject(err || new Error("Jump host forward failed")); - } - return; - } - config.sock = stream; - delete config.host; - delete config.port; - client.connect(config); - }, - ); - }) - .catch((error) => { - if (!isResolved) { - isResolved = true; - clearTimeout(timeout); - connectionLogs.push( - createConnectionLog( - "error", - "proxy", - `Jump host connection failed: ${getErrorMessage(error)}`, - ), - ); - reject(error); - } - }); - } else if ( - host.useSocks5 && - (host.socks5Host || - (host.socks5ProxyChain && host.socks5ProxyChain.length > 0)) - ) { - connectionLogs.push( - createConnectionLog("info", "proxy", "Connecting via SOCKS5 proxy"), - ); - createSocks5Connection(host.ip, host.port, { - useSocks5: host.useSocks5, - socks5Host: host.socks5Host, - socks5Port: host.socks5Port, - socks5Username: host.socks5Username, - socks5Password: host.socks5Password, - socks5ProxyChain: host.socks5ProxyChain, - }) - .then((socks5Socket) => { - if (socks5Socket) { - config.sock = socks5Socket; - } - client.connect(config); - }) - .catch((error) => { - if (!isResolved) { - isResolved = true; - clearTimeout(timeout); - connectionLogs.push( - createConnectionLog( - "error", - "proxy", - `SOCKS5 proxy connection failed: ${getErrorMessage(error)}`, - ), - ); - reject(error); - } - }); - } else { - resolveSshConnectConfigHost(config) - .then(() => { - client.connect(config); - }) - .catch((error) => { - if (!isResolved) { - isResolved = true; - clearTimeout(timeout); - reject(error); - } - }); } + ssh + .openTransport( + host as unknown as PluginSshHost, + config as Record, + ) + .then((transport) => { + if (transport.jumpClient) { + const jumpClient = transport.jumpClient as Client; + client.on("close", () => jumpClient.end()); + } + client.connect(config); + }) + .catch((error) => { + if (!isResolved) { + isResolved = true; + clearTimeout(timeout); + connectionLogs.push( + createConnectionLog( + "error", + "proxy", + `Connection setup failed: ${getErrorMessage(error)}`, + ), + ); + reject(error); + } + }); }); const result = await connectionPromise; @@ -3093,5 +2555,7 @@ export function shutdown(): void { } pollingManager.destroy(); proxmoxPollingManager.destroy(); - connectionPool.destroy(); + // The pool is core's and shared with tmux and the fleet tools; destroying it + // here broke them until a restart. ctx.ssh drops this plugin's pooled + // connections on deactivate instead. } diff --git a/plugins/host-metrics/src/backend/ssh.ts b/plugins/host-metrics/src/backend/ssh.ts new file mode 100644 index 000000000..b7a221543 --- /dev/null +++ b/plugins/host-metrics/src/backend/ssh.ts @@ -0,0 +1,36 @@ +import type { Client } from "ssh2"; +import type { + PluginSsh, + PluginSshConnection, + PluginSshConnectOptions, + PluginSshHost, +} from "@termix/plugin-sdk/backend"; + +let current: PluginSsh | null = null; + +/** Set in activate, cleared on deactivate. */ +export function setPluginSsh(ssh: PluginSsh | null): void { + current = ssh; +} + +export function pluginSsh(): PluginSsh { + if (!current) throw new Error("The plugin is not active"); + return current; +} + +/** withConnection with ssh2's Client type filled in. */ +export function withSshConnection( + host: number | PluginSshHost, + options: PluginSshConnectOptions & { pool: string }, + fn: (client: Client) => Promise, +): Promise { + return pluginSsh().withConnection(host, options, fn); +} + +/** connect with ssh2's Client type filled in. */ +export function connectSsh( + host: number | PluginSshHost, + options?: PluginSshConnectOptions, +): Promise> { + return pluginSsh().connect(host, options); +} diff --git a/plugins/host-metrics/tests/backend/helpers.test.ts b/plugins/host-metrics/tests/backend/helpers.test.ts index a8c1dcf87..c9d7b0365 100644 --- a/plugins/host-metrics/tests/backend/helpers.test.ts +++ b/plugins/host-metrics/tests/backend/helpers.test.ts @@ -1,5 +1,5 @@ import type { Client } from "ssh2"; -import { describe, it, expect, vi } from "vitest"; +import { afterAll, beforeAll, describe, it, expect, vi } from "vitest"; import { supportsMetrics, isTcpPingEnabled, @@ -7,8 +7,17 @@ import { tcpPingThroughJumpHost, } from "../../src/backend/helpers.js"; import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js"; +import { createFakeContext } from "@termix/plugin-sdk/testing"; +import { setPluginSsh } from "../../src/backend/ssh.js"; describe("supportsMetrics", () => { + beforeAll(() => { + setPluginSsh(createFakeContext().ctx.ssh); + }); + afterAll(() => { + setPluginSsh(null); + }); + it("supports plain ssh hosts", () => { expect( supportsMetrics({ connectionType: "ssh", authType: "password" }), diff --git a/plugins/proxmox/src/backend/index.ts b/plugins/proxmox/src/backend/index.ts index 64bba2ea8..48a5e9801 100644 --- a/plugins/proxmox/src/backend/index.ts +++ b/plugins/proxmox/src/backend/index.ts @@ -1,7 +1,10 @@ import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { setPluginSsh } from "./ssh.js"; import { startProxmoxService, stopProxmoxService } from "./routes.js"; export async function activate(ctx: PluginContext) { + setPluginSsh(ctx.ssh); + ctx.disposables.add(() => setPluginSsh(null)); startProxmoxService(ctx.http.router()); ctx.log.info("Proxmox routes mounted at /plugin-api/proxmox"); } diff --git a/plugins/proxmox/src/backend/routes.ts b/plugins/proxmox/src/backend/routes.ts index d0331e6d0..dcfe9a61f 100644 --- a/plugins/proxmox/src/backend/routes.ts +++ b/plugins/proxmox/src/backend/routes.ts @@ -1,6 +1,6 @@ import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; import express, { type Router } from "express"; -import { Client as SSHClient } from "ssh2"; +import type { Client as SSHClient } from "ssh2"; import { logger } from "../../../../src/backend/utils/logger.js"; import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js"; import { createCurrentHostRepository } from "../../../../src/backend/database/repositories/factory.js"; @@ -9,9 +9,9 @@ import { type AuthenticatedRequest, type SSHHost, } from "../../../../src/types/index.js"; -import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js"; import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; -import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js"; +import type { PluginSshHost } from "@termix/plugin-sdk/backend"; +import { connectSsh } from "./ssh.js"; import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js"; import { parseProxmoxJumpHosts, @@ -309,109 +309,15 @@ async function discoverProxmoxGuestsForHost( const proxmoxCfgRaw = parseJsonObject(host.proxmoxConfig); const config = parseProxmoxConfig(proxmoxCfgRaw); - const resolvedCredentials: { - password?: string; - sshKey?: string; - keyPassword?: string; - authType?: string; - } = { - password: host.password, - sshKey: host.key, - keyPassword: host.keyPassword, - authType: host.authType, - }; - const hostCredentialId = host.credentialId ?? null; - const sshConfig: Record = { - host: host.ip?.replace(/^\[|\]$/g, "") || host.ip, - port: host.port || 22, - username: host.username, - tryKeyboard: false, - readyTimeout: 30000, - hostVerifier: await SSHHostKeyVerifier.createHostVerifier( - parsedHostId, - host.ip, - host.port || 22, - null, - userId, - false, - ), - }; + const { client } = await connectSsh(host as unknown as PluginSshHost, { + purpose: "proxmox", + timeoutMs: 35000, + overrides: { tryKeyboard: false, readyTimeout: 30000 }, + }); - const authType = resolvedCredentials.authType; - if (authType === "key" && resolvedCredentials.sshKey) { - sshConfig.privateKey = resolvedCredentials.sshKey; - if (resolvedCredentials.keyPassword) - sshConfig.passphrase = resolvedCredentials.keyPassword; - } else if (authType === "agent") { - const { applyAgentAuth } = - await import("../../../../src/backend/hosts/terminal-auth-helpers.js"); - const result = await applyAgentAuth( - sshConfig, - host.terminalConfig as unknown as Record | undefined, - ); - if ("error" in result) { - const error = new Error(result.error); - (error as Error & { status?: number }).status = 400; - throw error; - } - } else if (resolvedCredentials.password) { - sshConfig.password = resolvedCredentials.password; - } - - const client = new SSHClient(); try { - await new Promise((resolve, reject) => { - client.on("ready", resolve); - client.on("error", reject); - - // Reuse the shared jump-host chain (same path terminal/metrics use) - // so Proxmox hosts that are only reachable via a jump host work too - // (otherwise the direct connect fails with EHOSTUNREACH). jumpHosts is - // stored as a JSON string on the decrypted record, so parse it first. - let parsedJumpHosts: Array<{ hostId: number }> = []; - try { - const rawJumpHosts = (host as { jumpHosts?: unknown }).jumpHosts; - const parsed = - typeof rawJumpHosts === "string" - ? JSON.parse(rawJumpHosts) - : rawJumpHosts; - if (Array.isArray(parsed)) parsedJumpHosts = parsed; - } catch { - parsedJumpHosts = []; - } - - if (parsedJumpHosts.length > 0) { - createJumpHostChain(parsedJumpHosts, userId) - .then((jumpClient) => { - if (!jumpClient) { - reject(new Error("Jump host chain could not be established")); - return; - } - jumpClient.forwardOut( - "127.0.0.1", - 0, - sshConfig.host as string, - sshConfig.port as number, - (err, stream) => { - if (err || !stream) { - reject(err || new Error("Jump host forward failed")); - return; - } - sshConfig.sock = stream; - delete sshConfig.host; - delete sshConfig.port; - client.connect(sshConfig as import("ssh2").ConnectConfig); - }, - ); - }) - .catch(reject); - } else { - client.connect(sshConfig as import("ssh2").ConnectConfig); - } - }); - proxmoxLogger.info("Proxmox discovery SSH connection established", { operation: "proxmox_discover", hostId: parsedHostId, diff --git a/plugins/proxmox/src/backend/ssh.ts b/plugins/proxmox/src/backend/ssh.ts new file mode 100644 index 000000000..b7a221543 --- /dev/null +++ b/plugins/proxmox/src/backend/ssh.ts @@ -0,0 +1,36 @@ +import type { Client } from "ssh2"; +import type { + PluginSsh, + PluginSshConnection, + PluginSshConnectOptions, + PluginSshHost, +} from "@termix/plugin-sdk/backend"; + +let current: PluginSsh | null = null; + +/** Set in activate, cleared on deactivate. */ +export function setPluginSsh(ssh: PluginSsh | null): void { + current = ssh; +} + +export function pluginSsh(): PluginSsh { + if (!current) throw new Error("The plugin is not active"); + return current; +} + +/** withConnection with ssh2's Client type filled in. */ +export function withSshConnection( + host: number | PluginSshHost, + options: PluginSshConnectOptions & { pool: string }, + fn: (client: Client) => Promise, +): Promise { + return pluginSsh().withConnection(host, options, fn); +} + +/** connect with ssh2's Client type filled in. */ +export function connectSsh( + host: number | PluginSshHost, + options?: PluginSshConnectOptions, +): Promise> { + return pluginSsh().connect(host, options); +} diff --git a/plugins/proxmox/src/frontend/HostProxmoxTab.tsx b/plugins/proxmox/src/frontend/HostProxmoxTab.tsx index 9b9777095..fb44d2575 100644 --- a/plugins/proxmox/src/frontend/HostProxmoxTab.tsx +++ b/plugins/proxmox/src/frontend/HostProxmoxTab.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useTranslation } from "@termix/plugin-sdk/frontend"; +import { useSshAuthTypes, useTranslation } from "@termix/plugin-sdk/frontend"; import { Server } from "lucide-react"; import { Input } from "@/components/input"; import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card"; @@ -20,6 +20,7 @@ export function HostProxmoxTab({ setField: SetHostField; }) { const { t } = useTranslation(); + const sshAuthTypes = useSshAuthTypes(); const [credentials, setCredentials] = useState< { id: number; name: string; username: string | null }[] >([]); @@ -107,14 +108,11 @@ export function HostProxmoxTab({ } className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring" > - - - - - - + {sshAuthTypes.types.map((option) => ( + + ))} setPluginSsh(null)); await startGuacamoleService(); startRemoteDesktopService(ctx.http.router()); diff --git a/plugins/remote-desktop/src/backend/routes.ts b/plugins/remote-desktop/src/backend/routes.ts index db2871a53..b865adddb 100644 --- a/plugins/remote-desktop/src/backend/routes.ts +++ b/plugins/remote-desktop/src/backend/routes.ts @@ -19,7 +19,8 @@ import { createCurrentSettingsRepository, } from "../../../../src/backend/database/repositories/factory.js"; import { resolveGuacdOptions } from "../../../../src/backend/utils/guacd-config.js"; -import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js"; +import type { Client } from "ssh2"; +import { pluginSsh } from "./ssh.js"; import { getGuacSessionByConnectId } from "./guacamole-server.js"; import { logAudit, @@ -545,18 +546,9 @@ router.post( try { // The chain dials the first hop through that hop's own SOCKS5 // settings; the target host's proxy config does not apply to it. - const jumpClient = await createJumpHostChain(jumpHosts, userId); - - if (!jumpClient) { - guacLogger.error( - "Failed to establish jump host chain for guacamole", - undefined, - { operation: "guac_ssh_tunnel_error", hostId }, - ); - return res.status(500).json({ - error: "Failed to establish SSH tunnel to remote host", - }); - } + const { client: jumpClient } = await pluginSsh().jumpChain( + jumpHosts as Array<{ hostId: number }>, + ); const targetHostname = hostname; const targetPort = port; diff --git a/plugins/remote-desktop/src/backend/ssh.ts b/plugins/remote-desktop/src/backend/ssh.ts new file mode 100644 index 000000000..b7a221543 --- /dev/null +++ b/plugins/remote-desktop/src/backend/ssh.ts @@ -0,0 +1,36 @@ +import type { Client } from "ssh2"; +import type { + PluginSsh, + PluginSshConnection, + PluginSshConnectOptions, + PluginSshHost, +} from "@termix/plugin-sdk/backend"; + +let current: PluginSsh | null = null; + +/** Set in activate, cleared on deactivate. */ +export function setPluginSsh(ssh: PluginSsh | null): void { + current = ssh; +} + +export function pluginSsh(): PluginSsh { + if (!current) throw new Error("The plugin is not active"); + return current; +} + +/** withConnection with ssh2's Client type filled in. */ +export function withSshConnection( + host: number | PluginSshHost, + options: PluginSshConnectOptions & { pool: string }, + fn: (client: Client) => Promise, +): Promise { + return pluginSsh().withConnection(host, options, fn); +} + +/** connect with ssh2's Client type filled in. */ +export function connectSsh( + host: number | PluginSshHost, + options?: PluginSshConnectOptions, +): Promise> { + return pluginSsh().connect(host, options); +} diff --git a/plugins/web-endpoint/src/backend/index.ts b/plugins/web-endpoint/src/backend/index.ts index 0e3a16d0d..755c2a8a6 100644 --- a/plugins/web-endpoint/src/backend/index.ts +++ b/plugins/web-endpoint/src/backend/index.ts @@ -1,7 +1,10 @@ import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { setPluginSsh } from "./ssh.js"; import { startWebEndpointService, stopWebEndpointService } from "./routes.js"; export async function activate(ctx: PluginContext) { + setPluginSsh(ctx.ssh); + ctx.disposables.add(() => setPluginSsh(null)); startWebEndpointService(ctx.http.router()); ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint"); } diff --git a/plugins/web-endpoint/src/backend/ssh.ts b/plugins/web-endpoint/src/backend/ssh.ts new file mode 100644 index 000000000..b7a221543 --- /dev/null +++ b/plugins/web-endpoint/src/backend/ssh.ts @@ -0,0 +1,36 @@ +import type { Client } from "ssh2"; +import type { + PluginSsh, + PluginSshConnection, + PluginSshConnectOptions, + PluginSshHost, +} from "@termix/plugin-sdk/backend"; + +let current: PluginSsh | null = null; + +/** Set in activate, cleared on deactivate. */ +export function setPluginSsh(ssh: PluginSsh | null): void { + current = ssh; +} + +export function pluginSsh(): PluginSsh { + if (!current) throw new Error("The plugin is not active"); + return current; +} + +/** withConnection with ssh2's Client type filled in. */ +export function withSshConnection( + host: number | PluginSshHost, + options: PluginSshConnectOptions & { pool: string }, + fn: (client: Client) => Promise, +): Promise { + return pluginSsh().withConnection(host, options, fn); +} + +/** connect with ssh2's Client type filled in. */ +export function connectSsh( + host: number | PluginSshHost, + options?: PluginSshConnectOptions, +): Promise> { + return pluginSsh().connect(host, options); +} diff --git a/scripts/plugin-boundary-allowlist.json b/scripts/plugin-boundary-allowlist.json index d98826ede..1493addc1 100644 --- a/scripts/plugin-boundary-allowlist.json +++ b/scripts/plugin-boundary-allowlist.json @@ -139,7 +139,6 @@ "plugins/docker/src/backend/session-manager.ts", "plugins/fleets/src/backend/routes.ts", "plugins/host-metrics/src/backend/automation-bridge.ts", - "plugins/host-metrics/src/backend/helpers.ts", "plugins/host-metrics/src/backend/history-routes.ts", "plugins/host-metrics/src/backend/managers/cron.ts", "plugins/host-metrics/src/backend/managers/firewall.ts", diff --git a/src/backend/auth/builtin-login-methods.ts b/src/backend/auth/builtin-login-methods.ts new file mode 100644 index 000000000..83d38adf1 --- /dev/null +++ b/src/backend/auth/builtin-login-methods.ts @@ -0,0 +1,116 @@ +/** + * Password login, core's one built-in method. It only proves who the user is; + * the pipeline does second factors and the session. + */ + +import bcrypt from "bcryptjs"; +import { authLogger } from "../utils/logger.js"; +import { loginRateLimiter } from "../utils/login-rate-limiter.js"; +import { isTrustedProxyAuthEnabled } from "../utils/trusted-proxy-auth.js"; +import { createCurrentUserRepository } from "../database/repositories/factory.js"; +import { getPasswordLoginStatus } from "./core-auth.js"; +import { registerLoginMethod, type LoginMethod } from "./registry.js"; +import { LoginMethodError, type VerifiedIdentity } from "./types.js"; + +function nonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +export async function verifyPasswordLogin(request: { + body: Record; + ip?: string; + socket?: { remoteAddress?: string }; +}): Promise { + if (isTrustedProxyAuthEnabled()) { + throw new LoginMethodError( + "Password login is disabled while trusted proxy authentication is enabled", + 403, + ); + } + + const { username, password } = request.body; + const clientIp = request.ip || request.socket?.remoteAddress || "unknown"; + + if (!nonEmpty(username) || !nonEmpty(password)) { + throw new LoginMethodError("Invalid username or password", 400); + } + + const lockStatus = loginRateLimiter.isLocked(clientIp, username); + if (lockStatus.locked) { + authLogger.warn("Login attempt blocked due to rate limiting", { + operation: "user_login_blocked", + username, + ip: clientIp, + remainingTime: lockStatus.remainingTime, + }); + const error = new LoginMethodError( + "Too many login attempts. Please try again later.", + 429, + ); + Object.assign(error, { remainingTime: lockStatus.remainingTime }); + throw error; + } + + if (!(await getPasswordLoginStatus()).allowed) { + throw new LoginMethodError( + "Password authentication is currently disabled", + 403, + ); + } + + const user = await createCurrentUserRepository().findByUsername(username); + if (!user) { + loginRateLimiter.recordFailedAttempt(clientIp, username); + authLogger.warn("Login failed: user not found", { + operation: "user_login", + username, + ip: clientIp, + }); + throw new LoginMethodError("Invalid username or password", 401); + } + + if (user.isOidc && (!user.passwordHash || user.passwordHash.trim() === "")) { + authLogger.warn("OIDC-only user attempted traditional login", { + operation: "user_login", + username, + userId: user.id, + }); + throw new LoginMethodError("This user uses external authentication", 403); + } + + const isMatch = await bcrypt.compare(password, user.passwordHash); + if (!isMatch) { + loginRateLimiter.recordFailedAttempt(clientIp, username); + authLogger.warn("Login failed: incorrect password", { + operation: "user_login", + username, + userId: user.id, + ip: clientIp, + }); + throw new LoginMethodError("Invalid username or password", 401); + } + + return { + kind: "user", + userId: user.id, + password, + rememberMe: !!request.body.rememberMe, + rateLimitUsername: username, + }; +} + +export const passwordLoginMethod: LoginMethod = { + id: "password", + pluginId: "core", + labelKey: "auth.password", + kind: "form", + describe: async () => { + const status = await getPasswordLoginStatus(); + return [{ id: "password", label: "Password", enabled: status.allowed }]; + }, + verify: (request) => verifyPasswordLogin(request as never), +}; + +export function registerPasswordLoginMethod(): () => void { + return registerLoginMethod(passwordLoginMethod); +} diff --git a/src/backend/auth/core-auth.ts b/src/backend/auth/core-auth.ts new file mode 100644 index 000000000..2b8aaf05f --- /dev/null +++ b/src/backend/auth/core-auth.ts @@ -0,0 +1,79 @@ +/** + * Core's own login method (password) and the lockout guard. + */ + +import { getCurrentSettingValue } from "../database/repositories/factory.js"; +import { authLogger } from "../utils/logger.js"; +import { isTrustedProxyAuthEnabled } from "../utils/trusted-proxy-auth.js"; +import { registerPasswordLoginMethod } from "./builtin-login-methods.js"; +import { registerLegacyLoginProviders } from "./legacy-providers.js"; +import { listLoginMethods } from "./registry.js"; + +let registered = false; + +/** Registers core's login methods and factors once. */ +export function ensureCoreLoginProviders(): void { + if (registered) return; + registered = true; + registerPasswordLoginMethod(); + registerLegacyLoginProviders(); +} + +/** Test helper. */ +export function resetCoreLoginProvidersForTests(): void { + registered = false; +} + +/** The admin setting (or ALLOW_PASSWORD_LOGIN), before the lockout guard. */ +export function isPasswordLoginSettingOn(): boolean { + const envVal = process.env.ALLOW_PASSWORD_LOGIN; + if (envVal !== undefined) return envVal.trim().toLowerCase() === "true"; + try { + const value = getCurrentSettingValue("allow_password_login"); + return value ? value === "true" : true; + } catch { + return true; + } +} + +/** + * Whether anyone could still sign in without a password: trusted proxy auth, + * or a login method reporting an enabled instance (an SSO provider). + */ +export async function hasOtherEnabledLoginMethod(): Promise { + if (isTrustedProxyAuthEnabled()) return true; + ensureCoreLoginProviders(); + for (const method of listLoginMethods()) { + if (method.id === "password" || !method.describe) continue; + try { + const instances = await method.describe(); + if (instances.some((instance) => instance.enabled)) return true; + } catch { + // A method that cannot describe itself does not count. + } + } + return false; +} + +export interface PasswordLoginStatus { + allowed: boolean; + /** Turned off by the admin but kept on so nobody is locked out. */ + forced: boolean; +} + +/** + * Password login is off only when the admin turned it off AND another way to + * sign in exists. Otherwise it stays on, with a warning, so that disabling it + * (or losing the only SSO provider) can never lock everyone out. + */ +export async function getPasswordLoginStatus(): Promise { + if (isPasswordLoginSettingOn()) return { allowed: true, forced: false }; + if (await hasOtherEnabledLoginMethod()) { + return { allowed: false, forced: false }; + } + authLogger.warn( + "Password login is disabled but no other login method is enabled; keeping it on", + { operation: "password_login_lockout_guard" }, + ); + return { allowed: true, forced: true }; +} diff --git a/src/backend/auth/legacy-providers.ts b/src/backend/auth/legacy-providers.ts new file mode 100644 index 000000000..67c762596 --- /dev/null +++ b/src/backend/auth/legacy-providers.ts @@ -0,0 +1,393 @@ +/** + * Auth methods that still live in core for 2.9.0, registered through the same + * interfaces a plugin uses. Phase C moves each one into its own plugin by + * relocating its block from here; D1 deletes this file once it is empty. + * + * Nothing outside this file should branch on these type names. + */ + +import { getErrorMessage } from "../utils/error-message.js"; +import { sshLogger } from "../utils/logger.js"; +import { + registerKeyboardInteractiveInterceptor, + registerSshAuthProvider, +} from "../hosts/connect/auth-provider-registry.js"; +import type { + SshAuthProvider, + SshConnectHost, +} from "../hosts/connect/types.js"; +import { + registerLoginMethod, + registerSecondFactor, + type LoginMethod, +} from "./registry.js"; +import { totpSecondFactor } from "./legacy/totp-factor.js"; + +const OPKSSH_REQUIRED_MESSAGE = + "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours."; + +const VAULT_REQUIRED_MESSAGE = + "Vault SSH signer authentication required. Please open a Terminal connection first."; + +const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i; + +/** How long a Tailscale check-mode login may sit waiting for approval. */ +export const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000; + +function issuedCertificateProvider( + type: "opkssh" | "stepca", + labelKey: string, +): SshAuthProvider { + return { + type, + pluginId: "core", + labelKey, + needsUserInteraction: true, + supportsBackground: false, + interaction: "opkssh", + prepare: async (config, host, env) => { + const { getOPKSSHToken } = await import("../hosts/opkssh-auth.js"); + const token = await getOPKSSHToken(env.userId, env.hostId); + if (!token) { + env.log("info", "No valid certificate found, requesting sign-in"); + return { + status: "interaction-required", + interaction: "opkssh", + message: OPKSSH_REQUIRED_MESSAGE, + flag: "requiresOPKSSHAuth", + }; + } + try { + const { setupOPKSSHCertAuth } = + await import("../hosts/opkssh-cert-auth.js"); + await setupOPKSSHCertAuth(config, env.client, token, host.username); + } catch (error) { + return { + status: "error", + code: "failed", + message: "OPKSSH authentication failed: " + getErrorMessage(error), + }; + } + env.log("info", "Using cached SSH certificate"); + return { status: "ready" }; + }, + onAuthFailed: (_host, env, context) => { + if (!AUTH_FAILED_PATTERN.test(context.error.message)) return; + sshLogger.warn("OPKSSH authentication failed - invalidating token", { + operation: "opkssh_auth_failed", + hostId: env.hostId, + userId: env.userId, + }); + void import("../hosts/opkssh-auth.js") + .then(({ invalidateOPKSSHToken }) => + invalidateOPKSSHToken(env.userId, env.hostId, "SSH auth failed"), + ) + .catch(() => {}); + return { + status: "interaction-required", + interaction: "opkssh", + message: + "OPKSSH authentication failed or expired. Please authenticate again.", + }; + }, + startInteraction: async (request) => { + if (type === "stepca") { + const { startStepCaAuth } = await import("../hosts/step-ca-auth.js"); + await startStepCaAuth( + request.userId, + request.hostId, + request.host.username, + request.socket, + request.requestOrigin, + ); + return; + } + const { startOPKSSHAuth } = await import("../hosts/opkssh-auth.js"); + await startOPKSSHAuth( + request.userId, + request.hostId, + request.host.name || request.host.ip, + request.socket, + request.requestOrigin, + ); + }, + }; +} + +function vaultProfileId(host: SshConnectHost): number | undefined { + return host.vaultProfile?.id ?? undefined; +} + +const vaultProvider: SshAuthProvider = { + type: "vault", + pluginId: "core", + labelKey: "hosts.filterAuthVault", + needsUserInteraction: true, + interaction: "vault", + prepare: async (config, host, env) => { + const profileId = vaultProfileId(host); + if (!profileId) { + return { + status: "error", + code: "failed", + message: "Host has no Vault signer profile configured", + }; + } + const { getVaultCert } = await import("../hosts/vault-signer-auth.js"); + const cert = await getVaultCert(env.userId, profileId); + if (!cert) { + env.log( + "info", + "No valid Vault certificate found, requesting authentication", + ); + return { + status: "interaction-required", + interaction: "vault", + message: VAULT_REQUIRED_MESSAGE, + flag: "requiresVaultAuth", + }; + } + try { + const { setupOPKSSHCertAuth } = + await import("../hosts/opkssh-cert-auth.js"); + await setupOPKSSHCertAuth( + config, + env.client, + { privateKey: cert.privateKey, sshCert: cert.sshCert }, + host.username, + ); + } catch (error) { + return { + status: "error", + code: "failed", + message: + "Vault SSH signer authentication failed: " + getErrorMessage(error), + }; + } + env.log("info", "Using cached Vault-signed certificate"); + return { status: "ready" }; + }, + onAuthFailed: (host, env, context) => { + if (!AUTH_FAILED_PATTERN.test(context.error.message)) return; + sshLogger.warn("Vault certificate authentication failed", { + operation: "vault_auth_failed", + hostId: env.hostId, + userId: env.userId, + }); + const profileId = vaultProfileId(host); + if (profileId) { + void import("../hosts/vault-signer-auth.js") + .then(({ deleteVaultCert }) => deleteVaultCert(env.userId, profileId)) + .catch(() => {}); + } + return { + status: "interaction-required", + interaction: "vault", + message: + "Vault authentication failed or expired. Please authenticate again.", + }; + }, + startInteraction: async (request) => { + const { loadVaultProfileForHost, startVaultAuth } = + await import("../hosts/vault-oidc-auth.js"); + const profile = await loadVaultProfileForHost( + request.hostId, + request.userId, + ); + if (!profile) { + throw new Error("No Vault signer profile configured for this host"); + } + await startVaultAuth( + request.userId, + request.hostId, + profile, + request.socket, + request.requestOrigin, + ); + }, +}; + +const tailscaleProvider: SshAuthProvider = { + type: "tailscale", + pluginId: "core", + labelKey: "hosts.filterAuthTailscale", + connectOptions: (_host, purpose) => + purpose === "terminal" + ? { + tryKeyboard: false, + readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS, + // The socket sits idle while a check-mode login is pending. + timeout: TAILSCALE_CHECK_TIMEOUT_MS, + } + : { tryKeyboard: false }, + prepare: async (_config, _host, env) => { + env.log("info", "Using Tailscale SSH"); + return { status: "ready" }; + }, + onAuthFailed: (host, _env, context) => { + const failed = + context.methodNotAvailable || + AUTH_FAILED_PATTERN.test(context.error.message); + if (!failed) return; + + // Tailscale documents the "+password" suffix for clients that mishandle a + // successful "none" reply. The password value is ignored. + if (context.retries === 0 && context.canRetry) { + return { + status: "retry", + message: "Retrying Tailscale SSH in forced password mode", + patch: { + username: `${host.username}+password`, + password: "termix", + tryKeyboard: false, + }, + }; + } + + return { + status: "error", + code: "failed", + message: `Tailscale SSH authentication failed for user "${host.username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${host.username}" user to your identity. If your Tailscale identity maps to a different Unix user, update the username on this host.`, + }; + }, +}; + +const WARPGATE_PATTERN = /warpgate\s+authentication/i; + +let sshRegistered = false; + +export function registerLegacySshAuthProviders(): void { + if (sshRegistered) return; + sshRegistered = true; + + registerSshAuthProvider( + issuedCertificateProvider("opkssh", "hosts.filterAuthOpkssh"), + ); + registerSshAuthProvider( + issuedCertificateProvider("stepca", "hosts.filterAuthStepca"), + ); + registerSshAuthProvider(vaultProvider); + registerSshAuthProvider(tailscaleProvider); + + // Warpgate is a flag on a host, not an auth type: it shows up as a + // keyboard-interactive round with a sign-in URL. + registerKeyboardInteractiveInterceptor({ + id: "warpgate", + pluginId: "core", + detect: ({ name, instructions, prompts }) => { + const texts = prompts.map((p) => p.prompt); + const isWarpgate = + WARPGATE_PATTERN.test(name) || + WARPGATE_PATTERN.test(instructions) || + texts.some((p) => WARPGATE_PATTERN.test(p)); + if (!isWarpgate) return null; + + const fullText = `${name}\n${instructions}\n${texts.join("\n")}`; + const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i); + if (!urlMatch) return null; + const keyMatch = fullText.match( + /security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i, + ); + return { + kind: "warpgate", + url: urlMatch[0], + securityKey: keyMatch ? keyMatch[1] : "N/A", + instructions, + }; + }, + // Warpgate asks for the password before its browser round; answer it + // silently so the only thing the user sees is the Warpgate dialog. + autoAnswerPasswords: (host) => host.useWarpgate === true, + }); +} + +// Login methods and second factors ------------------------------------------- + +async function listPublicProviders(types: string[]) { + const { listPublicSsoProviders } = + await import("../database/routes/sso-provider-routes.js"); + return (await listPublicSsoProviders()).filter((provider) => + types.includes(provider.type), + ); +} + +const oidcLoginMethod: LoginMethod = { + id: "oidc", + pluginId: "core", + labelKey: "auth.loginWithSso", + icon: "key-round", + kind: "redirect", + describe: async () => + (await listPublicProviders(["oidc", "github", "google"])).map( + (provider) => ({ + id: String(provider.id), + label: provider.name, + enabled: true, + }), + ), + start: async (request, instanceId) => { + const { startOidcLogin } = await import("./legacy/oidc-login.js"); + // Instance "0" is the provider synthesized from environment variables. + const result = await startOidcLogin(request as never, { + providerId: instanceId && instanceId !== "0" ? instanceId : undefined, + }); + return { redirectUrl: result.auth_url }; + }, + callback: async (request) => { + const { handleOidcCallback } = await import("./legacy/oidc-login.js"); + return handleOidcCallback(request as never); + }, +}; + +const ldapLoginMethod: LoginMethod = { + id: "ldap", + pluginId: "core", + labelKey: "auth.loginWithLdap", + icon: "server", + kind: "form", + describe: async () => + (await listPublicProviders(["ldap"])).map((provider) => ({ + id: String(provider.id), + label: provider.name, + enabled: true, + })), + verify: async (request, instanceId) => { + const { verifyLdapLogin } = await import("./legacy/ldap-login.js"); + const body = { ...request.body }; + if (instanceId && !body.providerId) body.providerId = Number(instanceId); + return verifyLdapLogin({ body, ip: request.ip }); + }, +}; + +// Passkeys prove the user as well as possession, so a passkey login can +// stand in for the second factor. No describe: a passkey only helps a user +// who already registered one, so it never counts for the lockout guard. +const passkeyLoginMethod: LoginMethod = { + id: "passkey", + pluginId: "core", + labelKey: "auth.loginWithPasskey", + icon: "fingerprint", + kind: "form", + verify: async (request) => { + const { verifyPasskeyLogin } = + await import("../database/routes/user-webauthn-routes.js"); + return verifyPasskeyLogin(request as never); + }, +}; + +let loginRegistered = false; + +export function registerLegacyLoginProviders(): void { + if (loginRegistered) return; + loginRegistered = true; + registerLoginMethod(oidcLoginMethod); + registerLoginMethod(ldapLoginMethod); + registerLoginMethod(passkeyLoginMethod); + registerSecondFactor(totpSecondFactor); +} + +/** Test helper. */ +export function resetLegacyProvidersForTests(): void { + sshRegistered = false; + loginRegistered = false; +} diff --git a/src/backend/auth/legacy/ldap-login.ts b/src/backend/auth/legacy/ldap-login.ts new file mode 100644 index 000000000..9e9eb77b7 --- /dev/null +++ b/src/backend/auth/legacy/ldap-login.ts @@ -0,0 +1,227 @@ +/** + * LDAP sign-in as a form login method. Moved out of the LDAP route so the + * pipeline issues the session; Phase C moves it into the LDAP plugin. + */ + +import ldap from "ldapjs"; +import type { LDAPProviderConfig } from "../../../types/index.js"; +import { authLogger } from "../../utils/logger.js"; +import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; +import { createCurrentSsoProviderRepository } from "../../database/repositories/factory.js"; +import { loadProviderConfig } from "../../database/routes/user-oidc-utils.js"; +import { LoginMethodError, type VerifiedIdentity } from "../types.js"; + +export function ldapEscapeFilter(value: string): string { + return value.replace( + /[\\*()\x00]/g, + (c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`, + ); +} + +function createLDAPClient( + host: string, + port: number, + useTLS: boolean, +): ldap.Client { + const url = `${useTLS ? "ldaps" : "ldap"}://${host}:${port}`; + return ldap.createClient({ + url, + tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined, + }); +} + +function ldapBind( + client: ldap.Client, + dn: string, + password: string, +): Promise { + return new Promise((resolve, reject) => { + client.bind(dn, password, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +function ldapSearch( + client: ldap.Client, + base: string, + filter: string, + attributes: string[], +): Promise { + return new Promise((resolve, reject) => { + const entries: ldap.SearchEntry[] = []; + client.search(base, { filter, attributes, scope: "sub" }, (err, res) => { + if (err) return reject(err); + res.on("searchEntry", (entry) => entries.push(entry)); + res.on("error", reject); + res.on("end", () => resolve(entries)); + }); + }); +} + +function ldapUnbind(client: ldap.Client): void { + try { + client.unbind(); + } catch { + // best effort + } +} + +function firstValue(entry: ldap.SearchEntry, key: string): string { + const attr = entry.attributes.find((a) => a.type === key); + if (!attr) return ""; + return Array.isArray(attr.values) ? attr.values[0] : String(attr.values); +} + +export async function verifyLdapLogin(request: { + body: Record; + ip?: string; +}): Promise { + const { providerId, username, password, rememberMe } = request.body as { + providerId?: number; + username?: string; + password?: string; + rememberMe?: boolean; + }; + + if (!providerId || !username || !password) { + throw new LoginMethodError( + "providerId, username, and password are required", + 400, + ); + } + + // Same limits as password login: an LDAP bind is a password guess. + const clientIp = request.ip || "unknown"; + const rateLimitKey = `ldap:${providerId}:${username}`; + const lockStatus = loginRateLimiter.isLocked(clientIp, rateLimitKey); + if (lockStatus.locked) { + const error = new LoginMethodError( + "Too many login attempts. Please try again later.", + 429, + ); + Object.assign(error, { remainingTime: lockStatus.remainingTime }); + throw error; + } + + const provider = + await createCurrentSsoProviderRepository().findById(providerId); + if (!provider || provider.type !== "ldap" || !provider.enabled) { + throw new LoginMethodError("LDAP provider not found", 404); + } + + const providerResult = await loadProviderConfig(providerId); + if (!providerResult) { + throw new LoginMethodError("LDAP provider not found", 404); + } + const config = providerResult.config as unknown as LDAPProviderConfig; + if ( + !config.host || + !config.bindDN || + !config.userSearchBase || + !config.userSearchFilter + ) { + throw new LoginMethodError("LDAP provider is misconfigured", 500); + } + + const serviceClient = createLDAPClient( + config.host, + config.port || 389, + config.useTLS || false, + ); + try { + await ldapBind(serviceClient, config.bindDN, config.bindPassword); + + const filter = config.userSearchFilter.replace( + /\{\{username\}\}/g, + ldapEscapeFilter(username), + ); + const entries = await ldapSearch( + serviceClient, + config.userSearchBase, + filter, + [ + config.usernameAttribute || "uid", + config.displayNameAttribute || "cn", + "mail", + "email", + ], + ); + + if (entries.length === 0) { + loginRateLimiter.recordFailedAttempt(clientIp, rateLimitKey); + authLogger.warn("LDAP user not found", { + operation: "ldap_login", + username, + }); + throw new LoginMethodError("Invalid username or password", 401); + } + + const userEntry = entries[0]; + const userDN = userEntry.dn.toString(); + const ldapIdentifier = + firstValue(userEntry, config.usernameAttribute || "uid") || username; + const displayName = + firstValue(userEntry, config.displayNameAttribute || "cn") || username; + const email = + firstValue(userEntry, "mail") || firstValue(userEntry, "email") || ""; + + const userClient = createLDAPClient( + config.host, + config.port || 389, + config.useTLS || false, + ); + try { + await ldapBind(userClient, userDN, password); + } catch { + loginRateLimiter.recordFailedAttempt(clientIp, rateLimitKey); + authLogger.warn("LDAP bind failed - wrong password", { + operation: "ldap_login", + ldapIdentifier, + }); + throw new LoginMethodError("Invalid username or password", 401); + } finally { + ldapUnbind(userClient); + } + + let isAdmin: boolean | undefined; + if (config.adminGroup && config.groupSearchBase) { + isAdmin = false; + try { + const groupEntries = await ldapSearch( + serviceClient, + config.groupSearchBase, + `(member=${ldapEscapeFilter(userDN)})`, + ["cn", "dn"], + ); + isAdmin = groupEntries.some( + (group) => + firstValue(group, "cn") === config.adminGroup || + group.dn.toString() === config.adminGroup, + ); + } catch (groupErr) { + authLogger.warn("LDAP group check failed", { + operation: "ldap_group_check", + error: groupErr, + }); + } + } + + return { + kind: "external", + provider: String(providerId), + subject: ldapIdentifier, + email: email || null, + name: displayName, + isAdmin, + allowedUsers: config.allowedUsers ?? null, + legacyIdentifier: `ldap:${providerId}:${ldapIdentifier}`, + ssoProviderId: providerId, + rememberMe: !!rememberMe, + rateLimitUsername: rateLimitKey, + }; + } finally { + ldapUnbind(serviceClient); + } +} diff --git a/src/backend/auth/legacy/oidc-login.ts b/src/backend/auth/legacy/oidc-login.ts new file mode 100644 index 000000000..aa265eded --- /dev/null +++ b/src/backend/auth/legacy/oidc-login.ts @@ -0,0 +1,518 @@ +/** + * OIDC, GitHub and Google sign-in, moved out of the users routes so they can + * register as a login method. Phase C moves this into the OIDC plugin. + * + * start builds the provider's authorize URL; callback turns the provider's + * answer into a verified identity. Neither touches sessions. + */ + +import crypto from "crypto"; +import type { Request } from "express"; +import { nanoid } from "nanoid"; +import { authLogger } from "../../utils/logger.js"; +import { getRequestOriginWithForceHTTPS } from "../../utils/request-origin.js"; +import { getDesktopOidcCallbackUrl } from "../../utils/oidc-desktop-callback.js"; +import { createCurrentSettingsRepository } from "../../database/repositories/factory.js"; +import { + OIDCTokenFormatError, + buildFetchOptions, + extractOidcGroupsFromSources, + loadProviderConfig, + parseOidcRoleMap, + resolveOidcMappedRoles, + verifyOIDCToken, +} from "../../database/routes/user-oidc-utils.js"; +import { LoginMethodError, type VerifiedIdentity } from "../types.js"; + +/** An error raised after the return address is known redirects there. */ +export class RedirectLoginError extends LoginMethodError { + constructor( + message: string, + readonly returnTo: string, + status = 400, + code?: string, + ) { + super(message, status, code); + this.name = "RedirectLoginError"; + } +} + +/** Provider id used for OIDC identities that came from env configuration. */ +export const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc"; + +function nonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +// RFC 7636 PKCE: 43-128 char unreserved-character string. +function generatePkceCodeVerifier(): string { + return crypto.randomBytes(64).toString("base64url"); +} + +function generatePkceCodeChallenge(verifier: string): string { + return crypto.createHash("sha256").update(verifier).digest("base64url"); +} + +export async function deleteOIDCStateSettings(state: string): Promise { + const settingsRepository = createCurrentSettingsRepository(); + await settingsRepository.delete(`oidc_state_${state}`); + await settingsRepository.delete(`oidc_backend_callback_${state}`); + await settingsRepository.delete(`oidc_frontend_origin_${state}`); + await settingsRepository.delete(`oidc_remember_me_${state}`); + await settingsRepository.delete(`oidc_provider_${state}`); + await settingsRepository.delete(`oidc_pkce_verifier_${state}`); +} + +export async function startOidcLogin( + req: Request, + overrides: { providerId?: string | null } = {}, +): Promise<{ auth_url: string; state: string; nonce: string }> { + const { rememberMe, desktopCallbackPort, appCallbackUrl } = req.query; + const providerIdStr = overrides.providerId ?? req.query.providerId; + const origin = getRequestOriginWithForceHTTPS(req); + const basePath = (process.env.BASE_PATH || "").replace(/\/+$/, ""); + const backendCallbackUri = `${origin}${basePath}/users/oidc/callback`; + + const resolvedProviderId = providerIdStr + ? parseInt(providerIdStr as string, 10) + : null; + const providerResult = await loadProviderConfig( + resolvedProviderId || undefined, + ); + if (!providerResult) { + throw new LoginMethodError("OIDC not configured", 404); + } + const { config, providerDbId } = providerResult; + const state = nanoid(); + const nonce = nanoid(); + + const referer = req.get("Referer"); + let frontendOrigin: string; + if (desktopCallbackPort) { + frontendOrigin = getDesktopOidcCallbackUrl(desktopCallbackPort); + if (!frontendOrigin) { + throw new LoginMethodError("Invalid desktop callback port", 400); + } + } else if (typeof appCallbackUrl === "string" && appCallbackUrl) { + let callbackUrl: URL; + try { + callbackUrl = new URL(appCallbackUrl); + } catch { + throw new LoginMethodError("Invalid app callback URL", 400); + } + if (callbackUrl.protocol !== "termix-mobile:") { + throw new LoginMethodError("Unsupported app callback URL", 400); + } + frontendOrigin = callbackUrl.toString(); + } else if (referer) { + const refererUrl = new URL(referer); + frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`; + } else { + frontendOrigin = origin; + } + + const codeVerifier = generatePkceCodeVerifier(); + const codeChallenge = generatePkceCodeChallenge(codeVerifier); + + const settingsRepository = createCurrentSettingsRepository(); + await settingsRepository.set(`oidc_state_${state}`, nonce); + await settingsRepository.set( + `oidc_backend_callback_${state}`, + backendCallbackUri, + ); + await settingsRepository.set(`oidc_frontend_origin_${state}`, frontendOrigin); + await settingsRepository.set( + `oidc_remember_me_${state}`, + rememberMe === "true" ? "true" : "false", + ); + await settingsRepository.set(`oidc_pkce_verifier_${state}`, codeVerifier); + if (providerDbId != null) { + await settingsRepository.set( + `oidc_provider_${state}`, + String(providerDbId), + ); + } + + const authUrl = new URL(config.authorization_url); + authUrl.searchParams.set("client_id", config.client_id); + authUrl.searchParams.set("redirect_uri", backendCallbackUri); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", config.scopes); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("nonce", nonce); + authUrl.searchParams.set("code_challenge", codeChallenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + + return { auth_url: authUrl.toString(), state, nonce }; +} + +type ProviderConfig = NonNullable< + Awaited> +>["config"]; + +async function githubIdentity( + code: string, + state: string, + config: ProviderConfig, + providerDbId: number | null, + backendCallbackUri: string, + frontendOrigin: string, + rememberMe: boolean, +): Promise { + const fetchOptions = buildFetchOptions(config.ca_cert); + const tokenResponse = await fetch(config.token_url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.client_id, + client_secret: config.client_secret, + code, + redirect_uri: backendCallbackUri, + }), + ...fetchOptions, + }); + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text(); + authLogger.error("GitHub token exchange failed", { + operation: "github_token_exchange_failed", + status: tokenResponse.status, + errorResponse: errorText, + }); + throw new LoginMethodError("Failed to exchange authorization code", 400); + } + + const tokenData = (await tokenResponse.json()) as Record; + await deleteOIDCStateSettings(state); + + const userInfoResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + Accept: "application/json", + "User-Agent": "Termix", + }, + ...fetchOptions, + }); + if (!userInfoResponse.ok) { + throw new LoginMethodError("Failed to get GitHub user information", 400); + } + const userInfo = (await userInfoResponse.json()) as Record; + + const emailResponse = await fetch("https://api.github.com/user/emails", { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + Accept: "application/json", + "User-Agent": "Termix", + }, + }); + if (emailResponse.ok) { + const emails = (await emailResponse.json()) as Array<{ + email: string; + primary: boolean; + verified: boolean; + }>; + const primary = emails.find((e) => e.primary && e.verified); + if (primary) userInfo.email = primary.email; + } + + const subject = String(userInfo.id ?? userInfo.login); + const legacyIdentifier = `github:${providerDbId}:${subject}`; + return { + kind: "external", + provider: String(providerDbId ?? LEGACY_OIDC_PROVIDER_ID), + subject, + email: (userInfo.email as string | undefined) ?? null, + name: ((userInfo.name || userInfo.login) as string) || legacyIdentifier, + allowedUsers: config.allowed_users ?? null, + legacyIdentifier, + ssoProviderId: providerDbId, + returnTo: frontendOrigin, + rememberMe, + }; +} + +function getNestedValue(obj: Record, path: string): unknown { + if (!path || !obj) return null; + return path + .split(".") + .reduce( + (current, key) => (current as Record)?.[key], + obj, + ); +} + +/** + * The provider's callback. Throws RedirectLoginError once the return address + * is known, and a plain LoginMethodError (answered as JSON) before that. + */ +export async function handleOidcCallback( + req: Request, +): Promise { + const { code, state } = req.query; + if (!nonEmpty(code) || !nonEmpty(state)) { + throw new LoginMethodError("Code and state are required", 400); + } + + const settingsRepository = createCurrentSettingsRepository(); + const backendCallbackUri = await settingsRepository.get( + `oidc_backend_callback_${state}`, + ); + const frontendOrigin = await settingsRepository.get( + `oidc_frontend_origin_${state}`, + ); + const rememberMe = + (await settingsRepository.get(`oidc_remember_me_${state}`)) === "true"; + const codeVerifier = await settingsRepository.get( + `oidc_pkce_verifier_${state}`, + ); + + if (!backendCallbackUri || !frontendOrigin) { + throw new LoginMethodError( + "Invalid state parameter - redirect URIs not found", + 400, + ); + } + + try { + const storedNonce = await settingsRepository.get(`oidc_state_${state}`); + if (!storedNonce) { + throw new LoginMethodError("Invalid state parameter", 400); + } + + const storedProviderId = await settingsRepository.get( + `oidc_provider_${state}`, + ); + const callbackProviderId = storedProviderId + ? parseInt(storedProviderId, 10) + : null; + const providerResult = await loadProviderConfig( + callbackProviderId || undefined, + ); + if (!providerResult) { + throw new LoginMethodError("OIDC not configured", 500); + } + const { config, providerType, providerDbId } = providerResult; + await settingsRepository.delete(`oidc_provider_${state}`); + + // GitHub does not issue OIDC id_tokens; it has its own token exchange. + if (providerType === "github") { + return await githubIdentity( + code, + state, + config, + providerDbId ?? null, + backendCallbackUri, + frontendOrigin, + rememberMe, + ); + } + + const caCert = config.ca_cert; + const fetchOptions = buildFetchOptions(caCert); + const tokenResponse = await fetch(config.token_url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.client_id, + client_secret: config.client_secret, + code, + redirect_uri: backendCallbackUri, + ...(codeVerifier ? { code_verifier: codeVerifier } : {}), + }), + ...fetchOptions, + }); + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text(); + authLogger.error("OIDC token exchange failed", { + operation: "oidc_token_exchange_failed", + status: tokenResponse.status, + statusText: tokenResponse.statusText, + backendCallbackUri, + frontendOrigin, + errorResponse: errorText, + }); + throw new LoginMethodError("Failed to exchange authorization code", 400); + } + + const tokenData = (await tokenResponse.json()) as Record; + await deleteOIDCStateSettings(state); + + let userInfo: Record | null = null; + const claimSources: Record[] = []; + const userInfoUrls: string[] = []; + + const normalizedIssuerUrl = config.issuer_url.endsWith("/") + ? config.issuer_url.slice(0, -1) + : config.issuer_url; + const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, ""); + + try { + const discoveryResponse = await fetch( + `${normalizedIssuerUrl}/.well-known/openid-configuration`, + fetchOptions, + ); + if (discoveryResponse.ok) { + const discovery = (await discoveryResponse.json()) as Record< + string, + unknown + >; + if (discovery.userinfo_endpoint) { + userInfoUrls.push(discovery.userinfo_endpoint as string); + } + } + } catch (discoveryError) { + authLogger.error(`OIDC discovery failed: ${discoveryError}`); + } + + if (config.userinfo_url) userInfoUrls.unshift(config.userinfo_url); + userInfoUrls.push( + `${baseUrl}/userinfo/`, + `${baseUrl}/userinfo`, + `${normalizedIssuerUrl}/userinfo/`, + `${normalizedIssuerUrl}/userinfo`, + `${baseUrl}/oauth2/userinfo/`, + `${baseUrl}/oauth2/userinfo`, + `${normalizedIssuerUrl}/oauth2/userinfo/`, + `${normalizedIssuerUrl}/oauth2/userinfo`, + ); + + if (tokenData.id_token) { + try { + userInfo = await verifyOIDCToken( + tokenData.id_token as string, + config.issuer_url, + config.client_id, + caCert, + ); + if (userInfo.nonce !== storedNonce) { + authLogger.warn("OIDC ID token nonce mismatch", { + operation: "oidc_nonce_mismatch", + providerId: callbackProviderId, + }); + throw new LoginMethodError("Invalid OIDC token nonce", 401); + } + claimSources.push(userInfo); + } catch (error) { + // A token that is not a JWS carries no claims we could trust; fall + // through to userinfo. Signature and claim failures still reject. + if (!(error instanceof OIDCTokenFormatError)) throw error; + userInfo = null; + authLogger.warn( + "OIDC ID token cannot be verified, falling back to userinfo endpoint", + { + operation: "oidc_id_token_unverifiable", + providerId: callbackProviderId, + reason: error.message, + }, + ); + } + } + + if (tokenData.access_token) { + for (const userInfoUrl of userInfoUrls) { + try { + const response = await fetch(userInfoUrl, { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + ...fetchOptions, + }); + if (response.ok) { + const fetched = (await response.json()) as Record; + claimSources.push(fetched); + userInfo = { ...userInfo, ...fetched }; + break; + } + authLogger.error( + `Userinfo endpoint ${userInfoUrl} failed with status: ${response.status}`, + ); + } catch (error) { + authLogger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error); + } + } + } + + if (!userInfo) { + authLogger.error("Failed to get user information from all sources"); + throw new LoginMethodError("Failed to get user information", 400); + } + + const identifier = (getNestedValue(userInfo, config.identifier_path) || + userInfo[config.identifier_path] || + userInfo.sub || + userInfo.email || + userInfo.preferred_username) as string; + const name = (getNestedValue(userInfo, config.name_path) || + userInfo[config.name_path] || + userInfo.name || + userInfo.given_name || + identifier) as string; + + if (!identifier) { + throw new LoginMethodError( + `User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(", ")}`, + 400, + ); + } + + let isAdmin: boolean | undefined; + if (config.admin_group) { + const groups = extractOidcGroupsFromSources( + claimSources, + config.group_claim, + ); + isAdmin = groups.includes(config.admin_group); + } + + let roleSync: { desired: string[]; managed: string[] } | undefined; + try { + const roleMap = parseOidcRoleMap( + config.role_map ?? process.env.OIDC_ROLE_MAP, + ); + if (roleMap.size > 0) { + const groups = extractOidcGroupsFromSources( + claimSources, + config.group_claim, + ); + const { desired, managed } = resolveOidcMappedRoles(groups, roleMap); + roleSync = { desired: [...desired], managed: [...managed] }; + } + } catch (roleMapError) { + authLogger.error("Failed to read the OIDC role map", roleMapError, { + operation: "oidc_role_map_sync_failed", + }); + } + + return { + kind: "external", + provider: String(providerDbId ?? LEGACY_OIDC_PROVIDER_ID), + subject: identifier, + email: (userInfo.email as string | undefined) ?? null, + name, + isAdmin, + allowedUsers: config.allowed_users ?? null, + legacyIdentifier: identifier, + ssoProviderId: providerDbId ?? null, + oidcSub: typeof userInfo.sub === "string" ? userInfo.sub : null, + oidcSid: typeof userInfo.sid === "string" ? userInfo.sid : null, + roleSync, + returnTo: frontendOrigin, + rememberMe, + }; + } catch (error) { + if (error instanceof LoginMethodError) throw error; + authLogger.error("OIDC callback failed", error); + throw new RedirectLoginError( + "OIDC authentication failed", + frontendOrigin, + 500, + ); + } +} diff --git a/src/backend/auth/legacy/totp-factor.ts b/src/backend/auth/legacy/totp-factor.ts new file mode 100644 index 000000000..f7428a586 --- /dev/null +++ b/src/backend/auth/legacy/totp-factor.ts @@ -0,0 +1,128 @@ +/** + * TOTP as a second factor. Enrolment stays in the TOTP routes; this is the + * login half. Phase C moves both into the TOTP plugin. + */ + +import speakeasy from "speakeasy"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { FieldCrypto } from "../../utils/field-crypto.js"; +import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; +import { + createCurrentUserAuthRepository, + createCurrentUserRepository, +} from "../../database/repositories/factory.js"; +import type { SecondFactor } from "../registry.js"; + +export const TOTP_FACTOR_ID = "totp"; + +/** + * Checks a backup code and burns it. Codes are stored encrypted under the + * user's data key when it was available at enrolment, so both forms are read. + */ +async function consumeBackupCode( + userId: string, + storedCodes: string | null, + code: string, + userDataKey: Buffer, +): Promise { + if (!storedCodes) return false; + const raw = LazyFieldEncryption.safeGetFieldValue( + storedCodes, + userDataKey, + userId, + "totpBackupCodes", + ); + let codes: unknown = []; + try { + codes = raw ? JSON.parse(raw) : []; + } catch { + codes = []; + } + if (!Array.isArray(codes)) return false; + const index = codes.indexOf(code); + if (index === -1) return false; + + codes.splice(index, 1); + await createCurrentUserRepository().update(userId, { + totpBackupCodes: FieldCrypto.encryptField( + JSON.stringify(codes), + userDataKey, + userId, + "totpBackupCodes", + ), + }); + return true; +} + +export const totpSecondFactor: SecondFactor = { + id: TOTP_FACTOR_ID, + pluginId: "core", + labelKey: "auth.totpFactor", + + isEnrolled: async (userId) => { + const user = await createCurrentUserRepository().findById(userId); + return !!user?.totpEnabled; + }, + + verify: async (userId, body) => { + const code = String(body.totp_code ?? body.code ?? "").trim(); + if (!code) return false; + + const user = await createCurrentUserRepository().findById(userId); + if (!user?.totpEnabled || !user.totpSecret) { + return { ok: false, error: "TOTP not enabled for this user" }; + } + + const userDataKey = AuthManager.getInstance().getUserDataKey(userId); + if (!userDataKey) { + return { + ok: false, + error: "Session expired - please log in again", + code: "SESSION_EXPIRED", + }; + } + + const secret = LazyFieldEncryption.safeGetFieldValue( + user.totpSecret, + userDataKey, + userId, + "totp_secret", + ); + if (!secret) { + // The secret was encrypted under a key a password reset replaced. + await createCurrentUserRepository().update(userId, { + totpEnabled: false, + totpSecret: null, + totpBackupCodes: null, + }); + await createCurrentUserAuthRepository().removeSecondFactor( + userId, + "core", + TOTP_FACTOR_ID, + ); + return { + ok: false, + error: + "TOTP has been disabled due to password reset. Please set up TOTP again.", + }; + } + + const verified = speakeasy.totp.verify({ + secret, + encoding: "base32", + token: code, + window: 2, + }); + if (verified) return true; + + return consumeBackupCode(userId, user.totpBackupCodes, code, userDataKey); + }, + + reset: async (userId) => { + await createCurrentUserRepository().update(userId, { + totpEnabled: false, + totpSecret: null, + totpBackupCodes: null, + }); + }, +}; diff --git a/src/backend/auth/login-pipeline.ts b/src/backend/auth/login-pipeline.ts new file mode 100644 index 000000000..677b00bb9 --- /dev/null +++ b/src/backend/auth/login-pipeline.ts @@ -0,0 +1,564 @@ +/** + * Every login runs through here: + * + * method -> verified identity -> find or provision the user -> unlock the + * data key -> second factors (fail closed) -> session + * + * A method never mints a session. It says who the user is and core does the + * rest, so password, passkey, OIDC, LDAP and any plugin method get the same + * second factors, the same cookie and the same audit line. + */ + +import crypto from "crypto"; +import type { Request, Response } from "express"; +import { AuthManager } from "../utils/auth-manager.js"; +import { loginRateLimiter } from "../utils/login-rate-limiter.js"; +import { authLogger } from "../utils/logger.js"; +import { + generateDeviceFingerprint, + getDeviceId, + parseUserAgent, +} from "../utils/user-agent-parser.js"; +import { logAudit, getRequestMeta } from "../utils/audit-logger.js"; +import { + createCurrentUserAuthRepository, + createCurrentUserRepository, +} from "../database/repositories/factory.js"; +import type { UserRecord } from "../database/repositories/user-repository.js"; +import { ensureCoreLoginProviders } from "./core-auth.js"; +import { findOrProvisionExternalUser } from "./provisioning.js"; +import { + getSecondFactor, + listSecondFactors, + type SecondFactor, +} from "./registry.js"; +import { + issueSession, + sendSession, + syncSharedCredentialsForUserRoles, +} from "./session-issuer.js"; +import { + LoginMethodError, + type PendingLogin, + type VerifiedIdentity, +} from "./types.js"; + +const PENDING_TTL_MS = 10 * 60 * 1000; +export const PENDING_LOGIN_COOKIE = "termix_pending_login"; + +const pendingLogins = new Map(); + +function prunePending(now = Date.now()): void { + for (const [key, pending] of pendingLogins) { + if (now - pending.createdAt > PENDING_TTL_MS) pendingLogins.delete(key); + } +} + +function pendingKey(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +/** Test helper. */ +export function resetPendingLoginsForTests(): void { + pendingLogins.clear(); +} + +export interface SecondFactorState { + /** Factors the user must pass, all with an active provider. */ + required: SecondFactor[]; + /** Enrolled in a factor whose plugin is disabled or missing. */ + blocked: { pluginId: string; factorId: string }[]; +} + +/** + * Which second factors apply to a user. A factor recorded in + * user_second_factors whose plugin is not running blocks the login: skipping + * it would turn "disable the plugin" into "remove everyone's 2FA". + */ +export async function evaluateSecondFactors( + userId: string, +): Promise { + ensureCoreLoginProviders(); + const rows = + await createCurrentUserAuthRepository().listSecondFactors(userId); + const required = new Map(); + const blocked: SecondFactorState["blocked"] = []; + + for (const row of rows) { + const factor = getSecondFactor(row.pluginId, row.factorId); + if (!factor) { + blocked.push({ pluginId: row.pluginId, factorId: row.factorId }); + continue; + } + required.set(`${factor.pluginId}:${factor.id}`, factor); + } + + // A factor can also report enrolment itself, for data from before + // user_second_factors existed. + for (const factor of listSecondFactors()) { + const key = `${factor.pluginId}:${factor.id}`; + if (required.has(key)) continue; + try { + if (await factor.isEnrolled(userId)) required.set(key, factor); + } catch (error) { + authLogger.warn("Second factor enrolment check failed", { + operation: "second_factor_enrolment_check", + factorId: factor.id, + error, + }); + } + } + + return { required: [...required.values()], blocked }; +} + +async function isTrustedDevice(req: Request, userId: string): Promise { + const deviceInfo = parseUserAgent(req); + const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req)); + if (!fingerprint) return false; + const trusted = await AuthManager.getInstance().isTrustedDevice( + userId, + fingerprint, + ); + if (trusted) { + authLogger.info("Second factor bypassed for trusted device", { + operation: "totp_bypass", + userId, + deviceFingerprint: fingerprint, + }); + } + return trusted; +} + +type DeviceType = ReturnType["type"]; + +async function resolveUser( + identity: VerifiedIdentity, + deviceType: DeviceType, +): Promise { + if (identity.kind === "external") { + return findOrProvisionExternalUser(identity, deviceType); + } + const user = await createCurrentUserRepository().findById(identity.userId); + if (!user) + throw new LoginMethodError("User not found", 404, "user_not_found"); + return user; +} + +/** + * Unlocks the user's data key. A password login migrates and opens the key + * with the password; everything else uses the server-held wrapping. + */ +async function unlockUser( + user: UserRecord, + identity: VerifiedIdentity, + deviceType: DeviceType, +): Promise { + const authManager = AuthManager.getInstance(); + + if (identity.kind === "external" || user.isOidc) { + try { + await authManager.authenticateOIDCUser(user.id, deviceType); + } catch (error) { + authLogger.error("Failed to set up external user encryption", error, { + operation: "external_user_encryption_setup_failed", + userId: user.id, + }); + } + return; + } + + const ok = identity.password + ? await authManager.authenticateUser(user.id, identity.password, deviceType) + : await authManager.authenticateWebAuthnUser(user.id, deviceType); + if (!ok) { + throw new LoginMethodError( + identity.password + ? "Incorrect password" + : (identity.unlockError ?? "This account could not be unlocked"), + 401, + "unlock_failed", + ); + } +} + +export interface LoginContext { + methodId: string; + rememberMe: boolean; + /** Username the rate limiter counted, cleared on success. */ + rateLimitUsername?: string; +} + +export type LoginResult = + | { + kind: "session"; + token: string; + maxAge: number; + body: Record; + } + | { + kind: "second-factor"; + tempToken: string; + factors: Array<{ id: string; pluginId: string; labelKey: string }>; + rememberMe: boolean; + }; + +/** + * Takes a verified identity to either a session or a second-factor step. + * Throws LoginMethodError for anything the user should be told. + */ +export async function runLogin( + req: Request, + identity: VerifiedIdentity, + context: LoginContext, +): Promise { + const deviceType = parseUserAgent(req).type; + const user = await resolveUser(identity, deviceType); + + await unlockUser(user, identity, deviceType); + await syncSharedCredentialsForUserRoles( + user.id, + `${context.methodId}_role_shared_credentials`, + ); + + const factors = await evaluateSecondFactors(user.id); + if (factors.blocked.length > 0) { + authLogger.warn("Login refused: an enrolled second factor is unavailable", { + operation: "second_factor_unavailable", + userId: user.id, + factors: factors.blocked, + }); + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId: user.id, + username: user.username, + action: "login_blocked_second_factor", + resourceType: "session", + details: JSON.stringify({ unavailable: factors.blocked }), + ipAddress, + userAgent, + success: false, + errorMessage: "Enrolled second factor unavailable", + }); + throw new LoginMethodError( + "Your account uses a second factor that is not available right now. Contact an admin to reset it.", + 403, + "second_factor_unavailable", + ); + } + + const ssoClaims = { + ssoProviderId: identity.ssoProviderId ?? null, + oidcSub: identity.oidcSub ?? null, + oidcSid: identity.oidcSid ?? null, + }; + + if ( + factors.required.length > 0 && + !identity.mfaSatisfied && + !(await isTrustedDevice(req, user.id)) + ) { + const tempToken = await AuthManager.getInstance().generateJWTToken( + user.id, + { pendingTOTP: true, expiresIn: "10m" }, + ); + prunePending(); + pendingLogins.set(pendingKey(tempToken), { + userId: user.id, + methodId: context.methodId, + rememberMe: context.rememberMe, + ...ssoClaims, + createdAt: Date.now(), + }); + return { + kind: "second-factor", + tempToken, + rememberMe: context.rememberMe, + factors: factors.required.map((factor) => ({ + id: factor.id, + pluginId: factor.pluginId, + labelKey: factor.labelKey, + })), + }; + } + + const session = await issueSession(req, user, { + methodId: context.methodId, + rememberMe: context.rememberMe, + rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername, + longLivedForApps: identity.kind === "external", + ...ssoClaims, + }); + return { kind: "session", ...session }; +} + +/** JSON form of a login result, the shape the login screen expects. */ +export function secondFactorBody( + result: Extract, +): Record { + return { + success: true, + requires_totp: true, + requires_second_factor: true, + temp_token: result.tempToken, + rememberMe: result.rememberMe, + second_factors: result.factors, + }; +} + +/** Runs a form login and writes the response. */ +export async function respondWithLogin( + req: Request, + res: Response, + identity: VerifiedIdentity, + context: LoginContext, +): Promise { + const result = await runLogin(req, identity, context); + if (result.kind === "second-factor") { + return res.json(secondFactorBody(result)); + } + return sendSession(req, res, result); +} + +export function sendLoginError(res: Response, error: unknown): Response { + if (error instanceof LoginMethodError) { + const remainingTime = (error as { remainingTime?: number }).remainingTime; + return res.status(error.status).json({ + error: error.message, + ...(error.code ? { code: error.code } : {}), + ...(remainingTime !== undefined ? { remainingTime } : {}), + }); + } + authLogger.error("Login failed", error); + return res.status(500).json({ error: "Login failed" }); +} + +export interface PendingLoginLookup { + userId: string; + token: string; + pending: PendingLogin | null; +} + +/** Checks a pending token from the body or the redirect cookie. */ +export async function readPendingLogin( + req: Request, +): Promise { + const token = + (typeof req.body?.temp_token === "string" && req.body.temp_token) || + (req.cookies?.[PENDING_LOGIN_COOKIE] as string | undefined); + if (!token) return null; + const decoded = await AuthManager.getInstance().verifyJWTToken(token); + if (!decoded || !decoded.pendingTOTP) return null; + prunePending(); + const pending = pendingLogins.get(pendingKey(token)) ?? null; + return { userId: decoded.userId, token, pending }; +} + +export function consumePendingLogin(token: string): void { + pendingLogins.delete(pendingKey(token)); +} + +/** + * Finishes a login that stopped for a second factor. The pending token comes + * from the body, or from the cookie a redirect method set. + */ +export async function verifySecondFactorAndRespond( + req: Request, + res: Response, + factorId: string, +): Promise { + const lookup = await readPendingLogin(req); + if (!lookup) { + return res.status(401).json({ error: "Invalid temporary token" }); + } + + const user = await createCurrentUserRepository().findById(lookup.userId); + if (!user) return res.status(404).json({ error: "User not found" }); + + const lockStatus = loginRateLimiter.isTOTPLocked(user.id); + if (lockStatus.locked) { + authLogger.warn("Second factor blocked due to rate limiting", { + operation: "totp_verify_blocked", + userId: user.id, + remainingTime: lockStatus.remainingTime, + }); + return res.status(429).json({ + error: `Rate limited: Too many TOTP verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`, + remainingTime: lockStatus.remainingTime, + code: "TOTP_RATE_LIMITED", + }); + } + loginRateLimiter.recordFailedTOTPAttempt(user.id); + + const factors = await evaluateSecondFactors(user.id); + if (factors.blocked.length > 0) { + return res.status(403).json({ + error: + "Your account uses a second factor that is not available right now. Contact an admin to reset it.", + code: "second_factor_unavailable", + }); + } + const factor = factors.required.find( + (candidate) => candidate.id === factorId, + ); + if (!factor) { + return res + .status(400) + .json({ error: "That second factor is not enabled for this user" }); + } + + const result = await factor.verify(user.id, req.body ?? {}); + if (result !== true) { + if (result && typeof result === "object") { + return res.status(result.code === "SESSION_EXPIRED" ? 401 : 400).json({ + error: result.error, + ...(result.code ? { code: result.code } : {}), + }); + } + authLogger.warn("Second factor verification failed", { + operation: "totp_verify_failed", + userId: user.id, + factorId, + }); + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId: user.id, + username: user.username, + action: "login_second_factor_failed", + resourceType: "session", + details: JSON.stringify({ factor: factorId }), + ipAddress, + userAgent, + success: false, + }); + return res.status(401).json({ + error: "Invalid TOTP code", + remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(user.id), + }); + } + loginRateLimiter.resetTOTPAttempts(user.id); + + const rememberMe = + typeof req.body?.rememberMe === "boolean" + ? req.body.rememberMe + : (lookup.pending?.rememberMe ?? false); + + if (rememberMe) { + const deviceInfo = parseUserAgent(req); + const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req)); + if (fingerprint) { + await AuthManager.getInstance().addTrustedDevice( + user.id, + fingerprint, + deviceInfo.type, + deviceInfo.deviceInfo, + ); + authLogger.info("Device automatically trusted via Remember Me", { + operation: "totp_auto_trust", + userId: user.id, + deviceType: deviceInfo.type, + }); + } + } + + const pending = lookup.pending; + const session = await issueSession(req, user, { + methodId: pending?.methodId ?? "password", + rememberMe, + ssoProviderId: pending?.ssoProviderId ?? null, + oidcSub: pending?.oidcSub ?? null, + oidcSid: pending?.oidcSid ?? null, + }); + + consumePendingLogin(lookup.token); + res.clearCookie( + PENDING_LOGIN_COOKIE, + AuthManager.getInstance().getClearCookieOptions(req), + ); + return sendSession(req, res, session); +} + +/** + * Finishes a redirect login (OIDC and friends): back to where the browser + * came from, with a cookie, or with the token in the URL for the desktop and + * mobile app callbacks that cannot read one. + */ +export async function respondWithRedirectLogin( + req: Request, + res: Response, + identity: VerifiedIdentity, + context: LoginContext, + isTokenCallback: (url: string) => boolean, +): Promise { + const returnTo = identity.returnTo; + if (!returnTo) { + res.status(500).json({ error: "Login method did not say where to return" }); + return; + } + const authManager = AuthManager.getInstance(); + + let result: LoginResult; + try { + result = await runLogin(req, identity, context); + } catch (error) { + redirectWithLoginError(res, returnTo, error); + return; + } + + const url = new URL(returnTo); + const tokenCallback = isTokenCallback(returnTo); + res.clearCookie("jwt", authManager.getClearCookieOptions(req)); + + if (result.kind === "second-factor") { + url.searchParams.set("second_factor", "1"); + url.searchParams.set( + "second_factors", + result.factors.map((factor) => factor.id).join(","), + ); + if (tokenCallback) { + url.searchParams.set("temp_token", result.tempToken); + res.redirect(url.toString()); + return; + } + res + .cookie( + PENDING_LOGIN_COOKIE, + result.tempToken, + authManager.getSecureCookieOptions(req, PENDING_TTL_MS), + ) + .redirect(url.toString()); + return; + } + + url.searchParams.set("success", "true"); + if (tokenCallback) { + url.searchParams.set("token", result.token); + res.redirect(url.toString()); + return; + } + res + .cookie( + "jwt", + result.token, + authManager.getSecureCookieOptions(req, result.maxAge), + ) + .redirect(url.toString()); +} + +export function redirectWithLoginError( + res: Response, + returnTo: string, + error: unknown, +): void { + if (!(error instanceof LoginMethodError)) { + authLogger.error("Redirect login failed", error); + } + const url = new URL(returnTo); + url.searchParams.set( + "error", + error instanceof LoginMethodError + ? (error.code ?? error.message) + : "OIDC authentication failed", + ); + res.redirect(url.toString()); +} diff --git a/src/backend/auth/provisioning.ts b/src/backend/auth/provisioning.ts new file mode 100644 index 000000000..ae698ab6a --- /dev/null +++ b/src/backend/auth/provisioning.ts @@ -0,0 +1,329 @@ +/** + * Finds or creates the user behind an external sign-in (OIDC, LDAP, a + * plugin's provider). The rules used to be copied into every SSO route; they + * live here once so every login method gets the same ones: + * + * - the allowed-users list is checked on every sign-in, new user or not + * - the first user ever becomes admin and skips the list + * - anyone else is only created when SSO auto-provisioning is on + * - an admin group, when the provider reports one, keeps admin in step + * - a mapped role set, when the provider reports one, keeps roles in step + */ + +import { nanoid } from "nanoid"; +import { authLogger } from "../utils/logger.js"; +import { AuthManager } from "../utils/auth-manager.js"; +import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js"; +import { PermissionManager } from "../utils/permission-manager.js"; +import { + createCurrentRoleRepository, + createCurrentSettingsRepository, + createCurrentUserAuthRepository, + createCurrentUserRepository, +} from "../database/repositories/factory.js"; +import type { UserRecord } from "../database/repositories/user-repository.js"; +import { isOIDCUserAllowed } from "../database/routes/user-oidc-utils.js"; +import { LoginMethodError, type VerifiedIdentity } from "./types.js"; + +type ExternalIdentity = Extract; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +/** SSO auto-provisioning: the admin setting, or OIDC_ALLOW_REGISTRATION. */ +export async function isExternalProvisioningAllowed(): Promise { + try { + if ( + await createCurrentSettingsRepository().getBoolean( + "oidc_auto_provision", + false, + ) + ) { + return true; + } + } catch { + // fall through to the environment + } + return ( + (process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === "true" + ); +} + +/** + * Whether a newly provisioned user must wait for an admin. There is no + * approval queue yet, so nobody does; a plugin or a later release can change + * that here without touching any login method. + */ +export async function isApprovalRequired( + _identity: ExternalIdentity, +): Promise { + return false; +} + +function isAllowed(identity: ExternalIdentity): boolean { + if (!identity.allowedUsers) return true; + const email = identity.email ?? undefined; + return ( + isOIDCUserAllowed(identity.allowedUsers, identity.subject, email) || + (!!identity.legacyIdentifier && + isOIDCUserAllowed( + identity.allowedUsers, + identity.legacyIdentifier, + email, + )) + ); +} + +async function syncAdmin( + user: UserRecord, + isAdmin: boolean, +): Promise { + if (!!user.isAdmin === isAdmin) return user; + const updated = + (await createCurrentUserRepository().update(user.id, { isAdmin })) ?? user; + try { + await createCurrentRoleRepository().switchUserRoleName({ + userId: user.id, + addRoleName: isAdmin ? "admin" : "user", + removeRoleName: isAdmin ? "user" : "admin", + grantedBy: user.id, + }); + } catch { + // Roles follow on the next sign-in; the admin flag itself is saved. + } + authLogger.info("Admin status synced from the identity provider", { + operation: "external_admin_sync", + userId: user.id, + isAdmin, + }); + return updated; +} + +/** + * Adds and removes the roles a provider's group map manages. Roles assigned + * by hand, and roles outside the map, are never touched. + */ +export async function applyRoleSync( + userId: string, + roleSync: { desired: string[]; managed: string[] }, +): Promise { + const desired = new Set(roleSync.desired); + const managed = new Set(roleSync.managed); + const roleRepository = createCurrentRoleRepository(); + const currentRoles = await roleRepository.listUserRoles(userId); + const currentNames = new Set(currentRoles.map((role) => role.roleName)); + + const toAdd = [...desired].filter((name) => !currentNames.has(name)); + const toRemove = currentRoles.filter( + (role) => managed.has(role.roleName) && !desired.has(role.roleName), + ); + + for (const roleName of toAdd) { + const assigned = await roleRepository.assignRoleNameToUser({ + userId, + roleName, + grantedBy: userId, + }); + if (!assigned) { + authLogger.warn("Role map references a role that does not exist", { + operation: "external_role_map_missing_role", + userId, + roleName, + }); + } + } + for (const role of toRemove) { + await roleRepository.removeRoleFromUser(userId, role.roleId); + } + + if (toAdd.length > 0 || toRemove.length > 0) { + authLogger.info("Roles synced from provider group membership", { + operation: "external_role_map_sync", + userId, + added: toAdd, + removed: toRemove.map((role) => role.roleName), + }); + PermissionManager.getInstance().invalidateUserPermissionCache(userId); + } +} + +async function findLinkedUser( + identity: ExternalIdentity, +): Promise { + const identities = createCurrentUserAuthRepository(); + const users = createCurrentUserRepository(); + + const linked = await identities.findIdentity( + identity.provider, + identity.subject, + ); + if (linked) { + const user = await users.findById(linked.userId); + if (user) return user; + } + + // Accounts from before user_external_identities matched on the raw + // identifier column; link them the first time they sign in. + if (identity.legacyIdentifier) { + const legacy = await users.findByOidcIdentifier(identity.legacyIdentifier); + if (legacy) { + await identities.linkIdentity({ + userId: legacy.id, + providerId: identity.provider, + subject: identity.subject, + email: identity.email, + }); + return legacy; + } + } + return null; +} + +async function createUser( + identity: ExternalIdentity, + deviceType: string, +): Promise { + const users = createCurrentUserRepository(); + const id = nanoid(); + const created = await users.createFirstSsoUser({ + id, + username: identity.name || identity.subject, + passwordHash: "", + isAdmin: !!identity.isAdmin, + isOidc: true, + oidcIdentifier: + identity.legacyIdentifier ?? `${identity.provider}:${identity.subject}`, + ssoProviderId: identity.ssoProviderId ?? null, + }); + + try { + const assigned = await createCurrentRoleRepository().assignRoleNameToUser({ + userId: id, + roleName: created.user.isAdmin ? "admin" : "user", + grantedBy: id, + }); + if (!assigned) { + authLogger.warn("Default role not found for a provisioned user", { + operation: "external_default_role", + userId: id, + }); + } + } catch (roleError) { + authLogger.error("Failed to assign a default role", roleError, { + operation: "external_default_role", + userId: id, + }); + } + + try { + const sessionDurationMs = + deviceType === "desktop" || deviceType === "mobile" + ? THIRTY_DAYS_MS + : ONE_DAY_MS; + await AuthManager.getInstance().registerOIDCUser(id, sessionDurationMs); + } catch (encryptionError) { + await users.delete(id); + authLogger.error( + "Failed to set up a provisioned user's encryption, user removed", + encryptionError, + { operation: "external_user_create_failed", userId: id }, + ); + throw new LoginMethodError( + "Failed to setup user security - user creation cancelled", + 500, + "setup_failed", + ); + } + + await createCurrentUserAuthRepository().linkIdentity({ + userId: id, + providerId: identity.provider, + subject: identity.subject, + email: identity.email, + }); + + try { + await DatabaseSaveTrigger.forceSave("external_user_create"); + } catch (saveError) { + authLogger.error("Failed to persist a provisioned user", saveError, { + operation: "external_user_create_save_failed", + userId: id, + }); + } + + return created.user; +} + +export async function findOrProvisionExternalUser( + identity: ExternalIdentity, + deviceType: string, +): Promise { + let user = await findLinkedUser(identity); + + if (!user) { + const isFirstUser = (await createCurrentUserRepository().countAll()) === 0; + if (!isFirstUser) { + if (!isAllowed(identity)) { + authLogger.warn("External user not in the allowed list", { + operation: "external_user_not_allowed", + provider: identity.provider, + }); + throw new LoginMethodError("User not allowed", 403, "user_not_allowed"); + } + if (!(await isExternalProvisioningAllowed())) { + authLogger.warn("External sign-in refused, provisioning is off", { + operation: "external_registration_disabled", + provider: identity.provider, + }); + throw new LoginMethodError( + "Registration is disabled", + 403, + "registration_disabled", + ); + } + if (await isApprovalRequired(identity)) { + throw new LoginMethodError( + "Your account is waiting for an admin to approve it", + 403, + "approval_required", + ); + } + } + user = await createUser(identity, deviceType); + } else { + if (!isAllowed(identity)) { + authLogger.warn("External user not in the allowed list", { + operation: "external_user_not_allowed_existing", + provider: identity.provider, + userId: user.id, + }); + throw new LoginMethodError("User not allowed", 403, "user_not_allowed"); + } + + // A user who also has a password picked their own username; leave it. + const isDualAuth = !!user.passwordHash && user.passwordHash.trim() !== ""; + if (!isDualAuth && identity.name && user.username !== identity.name) { + user = + (await createCurrentUserRepository().update(user.id, { + username: identity.name, + })) ?? user; + } + + if (identity.isAdmin !== undefined) { + user = await syncAdmin(user, identity.isAdmin); + } + } + + if (identity.roleSync) { + try { + await applyRoleSync(user.id, identity.roleSync); + } catch (roleSyncError) { + authLogger.error("Failed to sync roles from provider", roleSyncError, { + operation: "external_role_map_sync_failed", + userId: user.id, + }); + } + } + + return user; +} diff --git a/src/backend/auth/registry.ts b/src/backend/auth/registry.ts new file mode 100644 index 000000000..1a9fe371e --- /dev/null +++ b/src/backend/auth/registry.ts @@ -0,0 +1,69 @@ +/** + * Login methods and second factors, from core and from plugins. + * + * Password login is registered by core and always present. Everything else + * (OIDC, LDAP, passkeys, TOTP, ...) registers here, from a plugin through + * ctx.auth or, in 2.9.0, from legacy-providers.ts. + */ + +import type { + PluginLoginMethod, + PluginSecondFactor, +} from "@termix/plugin-sdk/backend"; + +export type LoginMethod = PluginLoginMethod & { pluginId: string }; +export type SecondFactor = PluginSecondFactor & { pluginId: string }; + +const loginMethods = new Map(); +const secondFactors = new Map(); + +function register( + map: Map, + kind: string, + entry: T, +): () => void { + const existing = map.get(entry.id); + if (existing && existing.pluginId !== entry.pluginId) { + throw new Error( + `${kind} "${entry.id}" is already provided by ${existing.pluginId}`, + ); + } + map.set(entry.id, entry); + return () => { + if (map.get(entry.id) === entry) map.delete(entry.id); + }; +} + +export function registerLoginMethod(method: LoginMethod): () => void { + return register(loginMethods, "Login method", method); +} + +export function getLoginMethod(id: string): LoginMethod | undefined { + return loginMethods.get(id); +} + +export function listLoginMethods(): LoginMethod[] { + return [...loginMethods.values()]; +} + +export function registerSecondFactor(factor: SecondFactor): () => void { + return register(secondFactors, "Second factor", factor); +} + +export function getSecondFactor( + pluginId: string, + factorId: string, +): SecondFactor | undefined { + const factor = secondFactors.get(factorId); + return factor && factor.pluginId === pluginId ? factor : undefined; +} + +export function listSecondFactors(): SecondFactor[] { + return [...secondFactors.values()]; +} + +/** Test helper. */ +export function resetAuthRegistryForTests(): void { + loginMethods.clear(); + secondFactors.clear(); +} diff --git a/src/backend/auth/second-factor-admin.ts b/src/backend/auth/second-factor-admin.ts new file mode 100644 index 000000000..dd39de3b6 --- /dev/null +++ b/src/backend/auth/second-factor-admin.ts @@ -0,0 +1,86 @@ +/** + * Admin view and reset of a user's second factors. Covers factors whose + * plugin is disabled or gone, which is exactly when a user gets locked out + * and an admin has to step in. + */ + +import { + createCurrentTrustedDeviceRepository, + createCurrentUserAuthRepository, +} from "../database/repositories/factory.js"; +import { authLogger } from "../utils/logger.js"; +import { ensureCoreLoginProviders } from "./core-auth.js"; +import { getSecondFactor, listSecondFactors } from "./registry.js"; + +export interface UserSecondFactorSummary { + pluginId: string; + factorId: string; + labelKey: string | null; + /** False when the plugin behind it is not running. */ + available: boolean; +} + +export async function listUserSecondFactors( + userId: string, +): Promise { + ensureCoreLoginProviders(); + const rows = + await createCurrentUserAuthRepository().listSecondFactors(userId); + const summaries: UserSecondFactorSummary[] = rows.map((row) => { + const factor = getSecondFactor(row.pluginId, row.factorId); + return { + pluginId: row.pluginId, + factorId: row.factorId, + labelKey: factor?.labelKey ?? null, + available: !!factor, + }; + }); + + for (const factor of listSecondFactors()) { + if ( + summaries.some( + (summary) => + summary.pluginId === factor.pluginId && + summary.factorId === factor.id, + ) + ) { + continue; + } + if (await factor.isEnrolled(userId).catch(() => false)) { + summaries.push({ + pluginId: factor.pluginId, + factorId: factor.id, + labelKey: factor.labelKey, + available: true, + }); + } + } + return summaries; +} + +/** + * Removes every factor: runs each running factor's own reset (so TOTP clears + * its secret), drops the enrolment rows including orphaned ones, and forgets + * trusted devices so an old "remember me" cannot skip the new factor. + */ +export async function resetUserSecondFactors( + userId: string, +): Promise> { + const factors = await listUserSecondFactors(userId); + for (const summary of factors) { + const factor = getSecondFactor(summary.pluginId, summary.factorId); + if (!factor?.reset) continue; + try { + await factor.reset(userId); + } catch (error) { + authLogger.error("Second factor reset failed", error, { + operation: "admin_reset_second_factor", + userId, + factorId: summary.factorId, + }); + } + } + await createCurrentUserAuthRepository().clearSecondFactors(userId); + await createCurrentTrustedDeviceRepository().deleteByUserId(userId); + return factors.map(({ pluginId, factorId }) => ({ pluginId, factorId })); +} diff --git a/src/backend/auth/session-issuer.ts b/src/backend/auth/session-issuer.ts new file mode 100644 index 000000000..222cee919 --- /dev/null +++ b/src/backend/auth/session-issuer.ts @@ -0,0 +1,162 @@ +/** + * The one place a login becomes a session: JWT, cookie, response body, + * audit line and the automation event. Every login method ends here, so + * they all behave the same way. + */ + +import type { Request, Response } from "express"; +import { AuthManager } from "../utils/auth-manager.js"; +import { authLogger } from "../utils/logger.js"; +import { loginRateLimiter } from "../utils/login-rate-limiter.js"; +import { logAudit, getRequestMeta } from "../utils/audit-logger.js"; +import { parseUserAgent } from "../utils/user-agent-parser.js"; +import { notifyAutomationInternalEvent } from "../hosts/automation-events.js"; +import { createCurrentSettingsRepository } from "../database/repositories/factory.js"; +import type { UserRecord } from "../database/repositories/user-repository.js"; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +export function isNativeAppRequest(req: Request): boolean { + return ( + (req.get("User-Agent") || "").startsWith("Termix-Mobile/") || + req.get("X-Electron-App") === "true" + ); +} + +/** Cookie lifetime: 30 days when remembered, else session_timeout_hours. */ +export async function sessionCookieMaxAge( + rememberMe: boolean, +): Promise { + if (rememberMe) return THIRTY_DAYS_MS; + const value = await createCurrentSettingsRepository().get( + "session_timeout_hours", + ); + const hours = value ? parseInt(value, 10) || 24 : 24; + return hours * 60 * 60 * 1000; +} + +export async function syncSharedCredentialsForUserRoles( + userId: string, + operation: string, +): Promise { + try { + const { SharedHostSecretsManager } = + await import("../utils/shared-host-secrets-manager.js"); + await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId); + const { SharedCredentialSecretsManager } = + await import("../utils/shared-credential-secrets-manager.js"); + await SharedCredentialSecretsManager.getInstance().snapshotForUserRoles( + userId, + ); + } catch (error) { + authLogger.warn("Failed to sync role shared host secrets", { + operation, + userId, + error, + }); + } +} + +export interface IssueSessionOptions { + methodId: string; + rememberMe: boolean; + /** Username the rate limiter counted attempts under, to clear them. */ + rateLimitUsername?: string; + ssoProviderId?: number | null; + oidcSub?: string | null; + oidcSid?: string | null; + /** SSO logins keep desktop and mobile apps signed in for 30 days. */ + longLivedForApps?: boolean; +} + +export interface IssuedSession { + token: string; + maxAge: number; + body: Record; +} + +/** + * Mints the JWT and writes the audit line. The caller decides how the token + * reaches the client: a JSON body with a cookie, or a redirect. + */ +export async function issueSession( + req: Request, + user: UserRecord, + options: IssueSessionOptions, +): Promise { + const authManager = AuthManager.getInstance(); + const deviceInfo = parseUserAgent(req); + + const token = await authManager.generateJWTToken(user.id, { + rememberMe: options.rememberMe, + deviceType: deviceInfo.type, + deviceInfo: deviceInfo.deviceInfo, + oidcSub: options.oidcSub ?? null, + oidcSid: options.oidcSid ?? null, + ssoProviderId: options.ssoProviderId ?? null, + }); + + if (options.rateLimitUsername) { + const clientIp = req.ip || req.socket?.remoteAddress || "unknown"; + loginRateLimiter.resetAttempts(clientIp, options.rateLimitUsername); + } + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId: user.id, + username: user.username, + action: "login", + resourceType: "session", + details: JSON.stringify({ method: options.methodId }), + ipAddress, + userAgent, + success: true, + }); + notifyAutomationInternalEvent("user_login", user.id, undefined, { + username: user.username, + ipAddress, + }); + + authLogger.success("User login successful", { + operation: "user_login_complete", + userId: user.id, + username: user.username, + method: options.methodId, + }); + + const maxAge = + options.longLivedForApps && + (deviceInfo.type === "desktop" || deviceInfo.type === "mobile") + ? THIRTY_DAYS_MS + : await sessionCookieMaxAge(options.rememberMe); + + return { + token, + maxAge, + body: { + success: true, + is_admin: !!user.isAdmin, + username: user.username, + userId: user.id, + is_oidc: !!user.isOidc, + totp_enabled: !!user.totpEnabled, + ...(isNativeAppRequest(req) ? { token } : {}), + }, + }; +} + +/** Sets the cookie and sends the body. */ +export function sendSession( + req: Request, + res: Response, + session: IssuedSession, +): Response { + const authManager = AuthManager.getInstance(); + return res + .cookie( + "jwt", + session.token, + authManager.getSecureCookieOptions(req, session.maxAge), + ) + .json(session.body); +} diff --git a/src/backend/auth/types.ts b/src/backend/auth/types.ts new file mode 100644 index 000000000..a618a305c --- /dev/null +++ b/src/backend/auth/types.ts @@ -0,0 +1,37 @@ +import type { PluginVerifiedIdentity } from "@termix/plugin-sdk/backend"; + +export { LoginMethodError } from "@termix/plugin-sdk/backend"; + +/** Extra fields core's own methods may set. Plugins never see these. */ +interface CoreIdentityExtras { + /** + * The identifier string the user row carried before user_external_identities + * existed ("ldap::", "github::" or a bare OIDC + * subject). Found users get their identity row linked lazily; new users keep + * the column filled so older code that reads it still works. + */ + legacyIdentifier?: string; + /** SSO provider row the session came from, for back-channel logout. */ + ssoProviderId?: number | null; + oidcSub?: string | null; + oidcSid?: string | null; + /** Provider group to role mapping, applied on every login. */ + roleSync?: { desired: string[]; managed: string[] }; + /** Shown when the user's data key cannot be unlocked by this method. */ + unlockError?: string; + /** Username the login rate limiter counted, cleared on success. */ + rateLimitUsername?: string; +} + +export type VerifiedIdentity = PluginVerifiedIdentity & CoreIdentityExtras; + +/** What a login needs to remember while it waits for a second factor. */ +export interface PendingLogin { + userId: string; + methodId: string; + rememberMe: boolean; + ssoProviderId?: number | null; + oidcSub?: string | null; + oidcSid?: string | null; + createdAt: number; +} diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 24deab594..8e0dd754b 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -9,6 +9,7 @@ import userRoutes from "./routes/users.js"; import hostRoutes from "./routes/host.js"; import alertRoutes from "./routes/alerts.js"; import credentialsRoutes from "./routes/credentials.js"; +import sshAuthRoutes from "./routes/ssh-auth-routes.js"; import snippetsRoutes from "./routes/snippets.js"; import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js"; import terminalRoutes from "./routes/terminal.js"; @@ -27,7 +28,7 @@ import vaultRoutes from "./routes/vault.js"; import secretSourceRoutes from "./routes/secret-sources.js"; import notificationChannelsRoutes from "./routes/notification-channels-routes.js"; import syncRoutes from "./routes/sync.js"; -import pluginApiRoutes from "./routes/plugin-api-routes.js"; +import { mountPluginApi } from "./routes/plugin-api-routes.js"; import { attachPluginWebSockets } from "../plugins/ws.js"; import pluginRoutes from "./routes/plugins.js"; import { createPluginAssetsRouter } from "../plugins/assets.js"; @@ -1762,6 +1763,7 @@ app.use("/users", userRoutes); app.use("/host", hostRoutes); app.use("/alerts", alertRoutes); app.use("/credentials", credentialsRoutes); +app.use("/ssh-auth", sshAuthRoutes); app.use("/snippets", snippetsRoutes); app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes); app.use("/terminal", terminalRoutes); @@ -1785,7 +1787,7 @@ app.use( "/plugin-assets", createPluginAssetsRouter((id) => getPluginRuntime().loader.get(id)), ); -app.use("/plugin-api", authenticateJWT, pluginApiRoutes); +mountPluginApi(app); const frontendDistPaths = [ path.join(__dirname, "../../../dist"), diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 0270965ff..e1aacca37 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -269,6 +269,29 @@ async function initializeCompleteDatabase(): Promise { FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS user_external_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + subject TEXT NOT NULL, + email TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (provider_id, subject), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_user_external_identities_user ON user_external_identities (user_id); + + CREATE TABLE IF NOT EXISTS user_second_factors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + plugin_id TEXT NOT NULL, + factor_id TEXT NOT NULL, + enrolled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, plugin_id, factor_id), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS webauthn_credentials ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts index 902712377..de90b1ced 100644 --- a/src/backend/database/db/schema.mysql.ts +++ b/src/backend/database/db/schema.mysql.ts @@ -120,6 +120,60 @@ export const trustedDevices = mysqlTable( (table) => [index("idx_trusted_devices_user_id").on(table.userId)], ); +/** + * A sign-in identity from an external provider (OIDC, LDAP, GitHub, ...). + * Replaces identifier strings like "ldap::" on users. + */ +export const userExternalIdentities = mysqlTable( + "user_external_identities", + { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + providerId: varchar("provider_id", { length: 255 }).notNull(), + /** The provider's id for the user, at most 255 characters. */ + subject: varchar("subject", { length: 255 }).notNull(), + email: text("email"), + createdAt: varchar("created_at", { length: 255 }) + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + (table) => [ + uniqueIndex("idx_user_external_identities_provider_subject").on( + table.providerId, + table.subject, + ), + index("idx_user_external_identities_user").on(table.userId), + ], +); + +/** + * Which second factors a user enrolled in, by plugin. Kept when the plugin is + * disabled or removed, so login fails closed instead of skipping the factor. + */ +export const userSecondFactors = mysqlTable( + "user_second_factors", + { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + pluginId: varchar("plugin_id", { length: 255 }).notNull(), + factorId: varchar("factor_id", { length: 255 }).notNull(), + enrolledAt: text("enrolled_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + (table) => [ + uniqueIndex("idx_user_second_factors_user_factor").on( + table.userId, + table.pluginId, + table.factorId, + ), + ], +); + export const webauthnCredentials = mysqlTable("webauthn_credentials", { id: varchar("id", { length: 255 }).primaryKey(), userId: varchar("user_id", { length: 255 }) diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts index 2efd0ac9d..ca658e463 100644 --- a/src/backend/database/db/schema.pg.ts +++ b/src/backend/database/db/schema.pg.ts @@ -121,6 +121,60 @@ export const trustedDevices = pgTable( (table) => [index("idx_trusted_devices_user_id").on(table.userId)], ); +/** + * A sign-in identity from an external provider (OIDC, LDAP, GitHub, ...). + * Replaces identifier strings like "ldap::" on users. + */ +export const userExternalIdentities = pgTable( + "user_external_identities", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + providerId: varchar("provider_id", { length: 255 }).notNull(), + /** The provider's id for the user, at most 255 characters. */ + subject: varchar("subject", { length: 255 }).notNull(), + email: text("email"), + createdAt: varchar("created_at", { length: 255 }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [ + uniqueIndex("idx_user_external_identities_provider_subject").on( + table.providerId, + table.subject, + ), + index("idx_user_external_identities_user").on(table.userId), + ], +); + +/** + * Which second factors a user enrolled in, by plugin. Kept when the plugin is + * disabled or removed, so login fails closed instead of skipping the factor. + */ +export const userSecondFactors = pgTable( + "user_second_factors", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + pluginId: varchar("plugin_id", { length: 255 }).notNull(), + factorId: varchar("factor_id", { length: 255 }).notNull(), + enrolledAt: text("enrolled_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [ + uniqueIndex("idx_user_second_factors_user_factor").on( + table.userId, + table.pluginId, + table.factorId, + ), + ], +); + export const webauthnCredentials = pgTable("webauthn_credentials", { id: varchar("id", { length: 255 }).primaryKey(), userId: varchar("user_id", { length: 255 }) diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 85af39619..5e5c10c37 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -111,6 +111,60 @@ export const trustedDevices = sqliteTable( (table) => [index("idx_trusted_devices_user_id").on(table.userId)], ); +/** + * A sign-in identity from an external provider (OIDC, LDAP, GitHub, ...). + * Replaces identifier strings like "ldap::" on users. + */ +export const userExternalIdentities = sqliteTable( + "user_external_identities", + { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + providerId: text("provider_id").notNull(), + /** The provider's id for the user, at most 255 characters. */ + subject: text("subject").notNull(), + email: text("email"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [ + uniqueIndex("idx_user_external_identities_provider_subject").on( + table.providerId, + table.subject, + ), + index("idx_user_external_identities_user").on(table.userId), + ], +); + +/** + * Which second factors a user enrolled in, by plugin. Kept when the plugin is + * disabled or removed, so login fails closed instead of skipping the factor. + */ +export const userSecondFactors = sqliteTable( + "user_second_factors", + { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + pluginId: text("plugin_id").notNull(), + factorId: text("factor_id").notNull(), + enrolledAt: text("enrolled_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [ + uniqueIndex("idx_user_second_factors_user_factor").on( + table.userId, + table.pluginId, + table.factorId, + ), + ], +); + export const webauthnCredentials = sqliteTable("webauthn_credentials", { id: text("id").primaryKey(), userId: text("user_id") diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index 2e0748cf6..c931e4266 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -37,6 +37,7 @@ import { PluginStorageRepository } from "./plugin-storage-repository.js"; import { PluginSettingsRepository } from "./plugin-settings-repository.js"; import { PluginMigrationRepository } from "./plugin-migration-repository.js"; import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js"; +import { UserAuthRepository } from "./user-auth-repository.js"; import { PluginRegistryRepository } from "./plugin-registry-repository.js"; import { PluginInstallCountRepository } from "./plugin-install-count-repository.js"; import { RbacAccessRepository } from "./rbac-access-repository.js"; @@ -380,6 +381,13 @@ export function createCurrentPluginRepository(): PluginRepository { ); } +export function createCurrentUserAuthRepository(): UserAuthRepository { + return new UserAuthRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("user_auth_repository_write"), + ); +} + export function createCurrentPluginPermissionGrantRepository(): PluginPermissionGrantRepository { return new PluginPermissionGrantRepository( createCurrentRepositoryContext(), diff --git a/src/backend/database/repositories/user-auth-repository.ts b/src/backend/database/repositories/user-auth-repository.ts new file mode 100644 index 000000000..2508ee4dc --- /dev/null +++ b/src/backend/database/repositories/user-auth-repository.ts @@ -0,0 +1,169 @@ +import crypto from "crypto"; +import { and, eq } from "drizzle-orm"; +import { userExternalIdentities, userSecondFactors } from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; + +export type UserExternalIdentityRecord = + typeof userExternalIdentities.$inferSelect; +export type UserSecondFactorRecord = typeof userSecondFactors.$inferSelect; + +/** Subjects are indexed, so they stay within the portable key length. */ +export const MAX_EXTERNAL_SUBJECT_LENGTH = 255; + +/** + * Keeps a subject within the index length. Longer ones are hashed, which is + * stable, so the same provider answer always finds the same row. + */ +export function normalizeExternalSubject(subject: string): string { + if (subject.length <= MAX_EXTERNAL_SUBJECT_LENGTH) return subject; + return `sha256:${crypto.createHash("sha256").update(subject).digest("hex")}`; +} + +/** External sign-in identities and second-factor enrolments. */ +export class UserAuthRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findIdentity( + providerId: string, + rawSubject: string, + ): Promise { + const subject = normalizeExternalSubject(rawSubject); + const rows = await this.context.drizzle + .select() + .from(userExternalIdentities) + .where( + and( + eq(userExternalIdentities.providerId, providerId), + eq(userExternalIdentities.subject, subject), + ), + ) + .limit(1); + return rows[0] ?? null; + } + + async listIdentitiesForUser( + userId: string, + ): Promise { + return this.context.drizzle + .select() + .from(userExternalIdentities) + .where(eq(userExternalIdentities.userId, userId)); + } + + async listAllIdentities(): Promise { + return this.context.drizzle.select().from(userExternalIdentities); + } + + /** Links or moves an identity to a user. Idempotent. */ + async linkIdentity(input: { + userId: string; + providerId: string; + subject: string; + email?: string | null; + }): Promise { + const subject = normalizeExternalSubject(input.subject); + const existing = await this.findIdentity(input.providerId, subject); + if (existing) { + if (existing.userId === input.userId) return; + await this.context.drizzle + .update(userExternalIdentities) + .set({ userId: input.userId, email: input.email ?? existing.email }) + .where(eq(userExternalIdentities.id, existing.id)); + } else { + await this.context.drizzle.insert(userExternalIdentities).values({ + userId: input.userId, + providerId: input.providerId, + subject, + email: input.email ?? null, + createdAt: new Date().toISOString(), + }); + } + await this.onWrite?.(); + } + + async unlinkIdentitiesForUser(userId: string): Promise { + const result = await this.context.drizzle + .delete(userExternalIdentities) + .where(eq(userExternalIdentities.userId, userId)); + const affected = rowsAffected(result); + if (affected > 0) await this.onWrite?.(); + return affected; + } + + async moveIdentities(fromUserId: string, toUserId: string): Promise { + await this.context.drizzle + .update(userExternalIdentities) + .set({ userId: toUserId }) + .where(eq(userExternalIdentities.userId, fromUserId)); + await this.onWrite?.(); + } + + async listSecondFactors(userId: string): Promise { + return this.context.drizzle + .select() + .from(userSecondFactors) + .where(eq(userSecondFactors.userId, userId)); + } + + async listAllSecondFactors(): Promise { + return this.context.drizzle.select().from(userSecondFactors); + } + + async recordSecondFactor( + userId: string, + pluginId: string, + factorId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(userSecondFactors) + .where( + and( + eq(userSecondFactors.userId, userId), + eq(userSecondFactors.pluginId, pluginId), + eq(userSecondFactors.factorId, factorId), + ), + ) + .limit(1); + if (rows[0]) return; + await this.context.drizzle.insert(userSecondFactors).values({ + userId, + pluginId, + factorId, + enrolledAt: new Date().toISOString(), + }); + await this.onWrite?.(); + } + + async removeSecondFactor( + userId: string, + pluginId: string, + factorId: string, + ): Promise { + const result = await this.context.drizzle + .delete(userSecondFactors) + .where( + and( + eq(userSecondFactors.userId, userId), + eq(userSecondFactors.pluginId, pluginId), + eq(userSecondFactors.factorId, factorId), + ), + ); + const affected = rowsAffected(result) > 0; + if (affected) await this.onWrite?.(); + return affected; + } + + async clearSecondFactors(userId: string): Promise { + const result = await this.context.drizzle + .delete(userSecondFactors) + .where(eq(userSecondFactors.userId, userId)); + const affected = rowsAffected(result); + if (affected > 0) await this.onWrite?.(); + return affected; + } +} diff --git a/src/backend/database/routes/auth-routes.ts b/src/backend/database/routes/auth-routes.ts new file mode 100644 index 000000000..52706955b --- /dev/null +++ b/src/backend/database/routes/auth-routes.ts @@ -0,0 +1,314 @@ +/** + * The generic login surface: every registered login method and second factor + * is reachable here, core's and plugins' alike. The older per-method routes + * (/users/login, /users/oidc/*, /users/ldap/login, ...) stay as thin wrappers + * so existing clients keep working. + */ + +import type { Request, Response, Router } from "express"; +import { authLogger } from "../../utils/logger.js"; +import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js"; +import { ensureCoreLoginProviders } from "../../auth/core-auth.js"; +import { + readPendingLogin, + redirectWithLoginError, + respondWithLogin, + respondWithRedirectLogin, + sendLoginError, + verifySecondFactorAndRespond, + evaluateSecondFactors, +} from "../../auth/login-pipeline.js"; +import { getLoginMethod, listLoginMethods } from "../../auth/registry.js"; +import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js"; + +export interface PublicLoginMethod { + id: string; + pluginId: string; + kind: "redirect" | "form"; + labelKey: string; + icon?: string; + instances: Array<{ id: string; label: string }>; +} + +/** What the login screen may know: ids, labels and enabled instances. */ +export async function listPublicLoginMethods(): Promise { + ensureCoreLoginProviders(); + const methods: PublicLoginMethod[] = []; + for (const method of listLoginMethods()) { + let instances: Array<{ id: string; label: string }> = []; + if (method.describe) { + try { + instances = (await method.describe()) + .filter((instance) => instance.enabled) + .map((instance) => ({ id: instance.id, label: instance.label })); + } catch (error) { + authLogger.warn("Login method could not describe itself", { + operation: "login_method_describe", + methodId: method.id, + error, + }); + continue; + } + if (instances.length === 0) continue; + } + methods.push({ + id: method.id, + pluginId: method.pluginId, + kind: method.kind, + labelKey: method.labelKey, + icon: method.icon, + instances, + }); + } + return methods; +} + +function instanceParam(req: Request): string | null { + const value = req.query.instance ?? req.body?.instanceId; + return typeof value === "string" && value ? value : null; +} + +function methodOr404(req: Request, res: Response) { + ensureCoreLoginProviders(); + const method = getLoginMethod(String(req.params.methodId)); + if (!method) { + res.status(404).json({ error: "Unknown login method" }); + return null; + } + return method; +} + +export function registerAuthRoutes(router: Router): void { + /** + * @openapi + * /users/auth/methods: + * get: + * summary: List login methods + * description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only. + * tags: + * - Auth + * responses: + * 200: + * description: Login methods. + */ + router.get("/auth/methods", async (_req, res) => { + try { + res.json({ methods: await listPublicLoginMethods() }); + } catch (error) { + authLogger.error("Failed to list login methods", error); + res.status(500).json({ error: "Failed to list login methods" }); + } + }); + + /** + * @openapi + * /users/auth/second-factor/{factorId}/challenge: + * post: + * summary: Start a second factor + * description: Returns whatever the factor needs the client to have before the user answers, for a login waiting on a second factor. + * tags: + * - Auth + * parameters: + * - in: path + * name: factorId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Challenge data. + * 401: + * description: No pending login. + * 404: + * description: Factor not enabled for this user. + */ + router.post("/auth/second-factor/:factorId/challenge", async (req, res) => { + try { + const lookup = await readPendingLogin(req); + if (!lookup) { + return res.status(401).json({ error: "Invalid temporary token" }); + } + const { required } = await evaluateSecondFactors(lookup.userId); + const factor = required.find( + (candidate) => candidate.id === req.params.factorId, + ); + if (!factor) { + return res.status(404).json({ error: "Second factor not enabled" }); + } + res.json({ + challenge: (await factor.challenge?.(lookup.userId)) ?? null, + }); + } catch (error) { + sendLoginError(res, error); + } + }); + + /** + * @openapi + * /users/auth/second-factor/{factorId}/verify: + * post: + * summary: Finish a second factor + * description: Checks the user's answer for a login waiting on a second factor and issues the session. The pending token comes from temp_token in the body or the pending-login cookie. + * tags: + * - Auth + * parameters: + * - in: path + * name: factorId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Login successful. + * 401: + * description: Wrong answer or no pending login. + * 403: + * description: An enrolled factor is unavailable. + * 429: + * description: Too many attempts. + */ + router.post("/auth/second-factor/:factorId/verify", async (req, res) => { + try { + await verifySecondFactorAndRespond(req, res, String(req.params.factorId)); + } catch (error) { + sendLoginError(res, error); + } + }); + + /** + * @openapi + * /users/auth/{methodId}/start: + * get: + * summary: Start a redirect login + * description: Returns the URL to send the browser to for a redirect login method, such as an SSO provider. + * tags: + * - Auth + * parameters: + * - in: path + * name: methodId + * required: true + * schema: + * type: string + * - in: query + * name: instance + * schema: + * type: string + * responses: + * 200: + * description: Redirect URL. + * 404: + * description: Unknown login method. + */ + router.get("/auth/:methodId/start", async (req, res) => { + const method = methodOr404(req, res); + if (!method) return; + if (method.kind !== "redirect" || !method.start) { + return res.status(400).json({ error: "Not a redirect login method" }); + } + try { + res.json(await method.start(req as never, instanceParam(req))); + } catch (error) { + sendLoginError(res, error); + } + }); + + const callback = async (req: Request, res: Response) => { + const method = methodOr404(req, res); + if (!method) return; + if (!method.callback) { + return res.status(400).json({ error: "Not a redirect login method" }); + } + let identity: VerifiedIdentity; + try { + identity = await method.callback(req as never); + } catch (error) { + const returnTo = (error as { returnTo?: string }).returnTo; + if (returnTo) return redirectWithLoginError(res, returnTo, error); + return sendLoginError(res, error); + } + await respondWithRedirectLogin( + req, + res, + identity, + { methodId: method.id, rememberMe: !!identity.rememberMe }, + isOidcTokenCallback, + ); + }; + + /** + * @openapi + * /users/auth/{methodId}/callback: + * get: + * summary: Redirect login callback + * description: Where a redirect login method's provider sends the browser back. Redirects to the app with a session, a second-factor step, or an error. + * tags: + * - Auth + * parameters: + * - in: path + * name: methodId + * required: true + * schema: + * type: string + * responses: + * 302: + * description: Back to the app. + * post: + * summary: Redirect login callback (form post) + * description: Same as the GET form, for providers that post their answer. + * tags: + * - Auth + * parameters: + * - in: path + * name: methodId + * required: true + * schema: + * type: string + * responses: + * 302: + * description: Back to the app. + */ + router.get("/auth/:methodId/callback", callback); + router.post("/auth/:methodId/callback", callback); + + /** + * @openapi + * /users/auth/{methodId}/verify: + * post: + * summary: Form login + * description: Checks what the user typed for a form login method, then issues a session or asks for a second factor. + * tags: + * - Auth + * parameters: + * - in: path + * name: methodId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Login successful, or a second factor is required. + * 401: + * description: Wrong credentials. + * 404: + * description: Unknown login method. + */ + router.post("/auth/:methodId/verify", async (req, res) => { + const method = methodOr404(req, res); + if (!method) return; + if (method.kind !== "form" || !method.verify) { + return res.status(400).json({ error: "Not a form login method" }); + } + try { + const identity = await method.verify(req as never, instanceParam(req)); + await respondWithLogin(req, res, identity, { + methodId: method.id, + rememberMe: !!identity.rememberMe || !!req.body?.rememberMe, + }); + } catch (error) { + if (!(error instanceof LoginMethodError)) { + authLogger.error("Form login failed", error, { methodId: method.id }); + } + sendLoginError(res, error); + } + }); +} diff --git a/src/backend/database/routes/credential-deploy-routes.ts b/src/backend/database/routes/credential-deploy-routes.ts index 306abed20..aef470fe7 100644 --- a/src/backend/database/routes/credential-deploy-routes.ts +++ b/src/backend/database/routes/credential-deploy-routes.ts @@ -4,28 +4,56 @@ import type { CredentialBackend, } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; -import ssh2Pkg from "ssh2"; +import type { Client as SshClient } from "ssh2"; import { createCurrentHostResolutionRepository } from "../repositories/factory.js"; -import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; +import { connectHost } from "../../hosts/connect/connect-host.js"; +import type { SshConnectHost } from "../../hosts/connect/types.js"; +import { resolveHostById } from "../../hosts/host-resolver.js"; -const { Client } = ssh2Pkg; +function describeDeployError(err: unknown): string { + const message = getErrorMessage(err, "Connection failed"); + if (message.includes("All configured authentication methods failed")) { + return "Authentication failed. Please check your credentials and ensure the SSH service is running."; + } + if (message.includes("ENOTFOUND") || message.includes("ENOENT")) { + return "Could not resolve hostname or connect to server."; + } + if (message.includes("ECONNREFUSED")) { + return "Connection refused. The server may not be running or the port may be incorrect."; + } + if (message.includes("ETIMEDOUT")) { + return "Connection timed out. Check your network connection and server availability."; + } + if ( + message.includes("authentication failed") || + message.includes("Permission denied") + ) { + return "Authentication failed. Please check your username and password/key."; + } + if (message === "SSH connection timeout") return "Connection timeout"; + return message; +} async function deploySSHKeyToHost( - hostConfig: Record, + host: SshConnectHost, + userId: string, credData: CredentialBackend, ): Promise<{ success: boolean; message?: string; error?: string }> { const publicKey = credData.publicKey as string; + + let conn: SshClient; + try { + ({ client: conn } = await connectHost(host, { + userId, + purpose: "credential-deploy", + timeoutMs: 120000, + })); + } catch (error) { + return { success: false, error: describeDeployError(error) }; + } + return new Promise((resolve) => { - const conn = new Client(); - - const connectionTimeout = setTimeout(() => { - conn.destroy(); - resolve({ success: false, error: "Connection timeout" }); - }, 120000); - - conn.on("ready", async () => { - clearTimeout(connectionTimeout); - + void (async () => { try { await new Promise((resolveCmd, rejectCmd) => { const cmdTimeout = setTimeout(() => { @@ -238,121 +266,7 @@ async function deploySSHKeyToHost( error: getErrorMessage(error, "Deployment failed"), }); } - }); - - conn.on("error", (err) => { - clearTimeout(connectionTimeout); - let errorMessage = err.message; - - if ( - err.message.includes("All configured authentication methods failed") - ) { - errorMessage = - "Authentication failed. Please check your credentials and ensure the SSH service is running."; - } else if ( - err.message.includes("ENOTFOUND") || - err.message.includes("ENOENT") - ) { - errorMessage = "Could not resolve hostname or connect to server."; - } else if (err.message.includes("ECONNREFUSED")) { - errorMessage = - "Connection refused. The server may not be running or the port may be incorrect."; - } else if (err.message.includes("ETIMEDOUT")) { - errorMessage = - "Connection timed out. Check your network connection and server availability."; - } else if ( - err.message.includes("authentication failed") || - err.message.includes("Permission denied") - ) { - errorMessage = - "Authentication failed. Please check your username and password/key."; - } - - resolve({ success: false, error: errorMessage }); - }); - - try { - const connectionConfig: Record = { - host: hostConfig.ip, - port: hostConfig.port || 22, - username: hostConfig.username, - readyTimeout: 60000, - keepaliveInterval: 30000, - keepaliveCountMax: 3, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - algorithms: { - kex: [ - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group1-sha1", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group-exchange-sha1", - "ecdh-sha2-nistp256", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp521", - ], - cipher: [ - "aes128-ctr", - "aes192-ctr", - "aes256-ctr", - "aes128-gcm@openssh.com", - "aes256-gcm@openssh.com", - "aes128-cbc", - "aes192-cbc", - "aes256-cbc", - "3des-cbc", - ], - hmac: [ - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256", - "hmac-sha2-512", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - }; - - if (hostConfig.authType === "password" && hostConfig.password) { - connectionConfig.password = hostConfig.password; - } else if (hostConfig.authType === "key" && hostConfig.privateKey) { - try { - const privateKey = hostConfig.privateKey as string; - connectionConfig.privateKey = preparePrivateKeyForSSH2( - privateKey, - hostConfig.keyPassword as string | undefined, - ); - - if (hostConfig.keyPassword) { - connectionConfig.passphrase = hostConfig.keyPassword; - } - } catch (keyError) { - clearTimeout(connectionTimeout); - resolve({ - success: false, - error: `Invalid SSH key format: ${getErrorMessage(keyError)}`, - }); - return; - } - } else { - clearTimeout(connectionTimeout); - resolve({ - success: false, - error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`, - }); - return; - } - - conn.connect(connectionConfig); - } catch (error) { - clearTimeout(connectionTimeout); - resolve({ - success: false, - error: getErrorMessage(error, "Connection failed"), - }); - } + })(); }); } @@ -456,6 +370,8 @@ export function registerCredentialDeployRoutes( error: "Public key is required for deployment", }); } + // Writing authorized_keys is an owner action, so ownership is checked + // before the shared resolver fills in the credentials. const hostData = await repository.findHostByIdForUser( targetHostId, userId, @@ -468,57 +384,19 @@ export function registerCredentialDeployRoutes( }); } - const hostConfig = { - ip: hostData.ip, - port: hostData.port, - username: hostData.username, - authType: hostData.authType, - password: hostData.password, - privateKey: hostData.key, - keyPassword: hostData.keyPassword, - }; - - if (hostData.authType === "credential" && hostData.credentialId) { - const userId = (req as AuthenticatedRequest).userId; - if (!userId) { - return res.status(400).json({ - success: false, - error: "Authentication required for credential resolution", - }); - } - - try { - const hostCredential = await repository.findCredentialByIdForUser( - hostData.credentialId as number, - userId, - ); - - if (hostCredential) { - hostConfig.authType = hostCredential.authType; - hostConfig.username = hostCredential.username; - - if (hostCredential.authType === "password") { - hostConfig.password = hostCredential.password; - } else if (hostCredential.authType === "key") { - hostConfig.privateKey = - hostCredential.privateKey || hostCredential.key; - hostConfig.keyPassword = hostCredential.keyPassword; - } - } else { - return res.status(400).json({ - success: false, - error: "Host credential not found", - }); - } - } catch { - return res.status(500).json({ - success: false, - error: "Failed to resolve host credentials", - }); - } + const resolvedHost = await resolveHostById(targetHostId, userId); + if (!resolvedHost) { + return res.status(400).json({ + success: false, + error: "Host credential not found", + }); } - const deployResult = await deploySSHKeyToHost(hostConfig, credData); + const deployResult = await deploySSHKeyToHost( + resolvedHost as unknown as SshConnectHost, + userId, + credData, + ); if (deployResult.success) { res.json({ diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 31193bfa9..c72726fdb 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -1,5 +1,7 @@ import { getErrorMessage } from "../../utils/error-message.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; +import { listCredentialTypes } from "../../hosts/connect/auth-provider-registry.js"; +import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js"; import express, { type Request, type Response } from "express"; import { authLogger } from "../../utils/logger.js"; import { PermissionManager } from "../../utils/permission-manager.js"; @@ -23,6 +25,12 @@ import { createCurrentSyncTombstoneRepository, } from "../repositories/factory.js"; +/** Built-in password and key, plus any type a plugin offers for credentials. */ +function getCredentialTypes(): string[] { + ensureCoreSshAuthProviders(); + return listCredentialTypes(); +} + const router = express.Router(); function isNonEmptyString(val: unknown): val is string { @@ -110,16 +118,17 @@ router.post( return res.status(400).json({ error: "Name is required" }); } - if (!["password", "key"].includes(authType)) { + const credentialTypes = getCredentialTypes(); + if (!credentialTypes.includes(authType)) { authLogger.warn("Invalid auth type provided", { operation: "credential_create", userId, name, authType, }); - return res - .status(400) - .json({ error: 'Auth type must be "password" or "key"' }); + return res.status(400).json({ + error: `Auth type must be one of: ${credentialTypes.join(", ")}`, + }); } try { @@ -659,8 +668,14 @@ router.put( } if (updateData.username !== undefined) updateFields.username = updateData.username?.trim() || null; - if (updateData.authType !== undefined) + if (updateData.authType !== undefined) { + if (!getCredentialTypes().includes(updateData.authType)) { + return res.status(400).json({ + error: `Auth type must be one of: ${getCredentialTypes().join(", ")}`, + }); + } updateFields.authType = updateData.authType; + } if (updateData.keyType !== undefined) updateFields.keyType = updateData.keyType; diff --git a/src/backend/database/routes/ldap-auth-routes.ts b/src/backend/database/routes/ldap-auth-routes.ts index b7cbf7fc4..fed98a363 100644 --- a/src/backend/database/routes/ldap-auth-routes.ts +++ b/src/backend/database/routes/ldap-auth-routes.ts @@ -1,76 +1,6 @@ import type { Router } from "express"; -import type { LDAPProviderConfig } from "../../../types/index.js"; -import { nanoid } from "nanoid"; -import { authLogger } from "../../utils/logger.js"; -import { AuthManager } from "../../utils/auth-manager.js"; -import { parseUserAgent } from "../../utils/user-agent-parser.js"; -import { isOIDCUserAllowed, loadProviderConfig } from "./user-oidc-utils.js"; -import ldap from "ldapjs"; -import { - createCurrentRoleRepository, - createCurrentSettingsRepository, - createCurrentSsoProviderRepository, - createCurrentUserRepository, -} from "../repositories/factory.js"; - -const authManager = AuthManager.getInstance(); - -function ldapEscapeFilter(value: string): string { - return value.replace( - /[\\*()\x00]/g, - (c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`, - ); -} - -function createLDAPClient( - host: string, - port: number, - useTLS: boolean, -): ldap.Client { - const url = `${useTLS ? "ldaps" : "ldap"}://${host}:${port}`; - return ldap.createClient({ - url, - tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined, - }); -} - -function ldapBind( - client: ldap.Client, - dn: string, - password: string, -): Promise { - return new Promise((resolve, reject) => { - client.bind(dn, password, (err) => { - if (err) reject(err); - else resolve(); - }); - }); -} - -function ldapSearch( - client: ldap.Client, - base: string, - filter: string, - attributes: string[], -): Promise { - return new Promise((resolve, reject) => { - const entries: ldap.SearchEntry[] = []; - client.search(base, { filter, attributes, scope: "sub" }, (err, res) => { - if (err) return reject(err); - res.on("searchEntry", (entry) => entries.push(entry)); - res.on("error", reject); - res.on("end", () => resolve(entries)); - }); - }); -} - -function ldapUnbind(client: ldap.Client): void { - try { - client.unbind(); - } catch { - /* best effort */ - } -} +import { verifyLdapLogin } from "../../auth/legacy/ldap-login.js"; +import { respondWithLogin, sendLoginError } from "../../auth/login-pipeline.js"; export function registerLDAPAuthRoutes(router: Router): void { /** @@ -78,7 +8,7 @@ export function registerLDAPAuthRoutes(router: Router): void { * /users/ldap/login: * post: * summary: LDAP login - * description: Authenticates a user against an LDAP server. + * description: Authenticates a user against an LDAP server, then runs the same second factors and session issuing as every other login. * tags: * - SSO * requestBody: @@ -98,312 +28,27 @@ export function registerLDAPAuthRoutes(router: Router): void { * type: boolean * responses: * 200: - * description: Login successful. + * description: Login successful, or a second factor is required. * 400: * description: Missing fields. * 401: * description: Invalid credentials. * 403: - * description: User not allowed. + * description: User not allowed or registration disabled. * 404: * description: Provider not found. + * 429: + * description: Too many login attempts. */ router.post("/ldap/login", async (req, res) => { - const { providerId, username, password, rememberMe } = req.body as { - providerId: number; - username: string; - password: string; - rememberMe?: boolean; - }; - - if (!providerId || !username || !password) { - return res - .status(400) - .json({ error: "providerId, username, and password are required" }); - } - try { - const provider = - await createCurrentSsoProviderRepository().findById(providerId); - if (!provider || provider.type !== "ldap" || !provider.enabled) { - return res.status(404).json({ error: "LDAP provider not found" }); - } - } catch (err) { - authLogger.error("Failed to load LDAP provider", err); - return res.status(500).json({ error: "Failed to load LDAP provider" }); - } - - const providerResult = await loadProviderConfig(providerId); - if (!providerResult) { - return res.status(404).json({ error: "LDAP provider not found" }); - } - const config = providerResult.config as unknown as LDAPProviderConfig; - - if ( - !config.host || - !config.bindDN || - !config.userSearchBase || - !config.userSearchFilter - ) { - return res.status(500).json({ error: "LDAP provider is misconfigured" }); - } - - const serviceClient = createLDAPClient( - config.host, - config.port || 389, - config.useTLS || false, - ); - try { - await ldapBind(serviceClient, config.bindDN, config.bindPassword); - - const filter = config.userSearchFilter.replace( - /\{\{username\}\}/g, - ldapEscapeFilter(username), - ); - const attrList = [ - config.usernameAttribute || "uid", - config.displayNameAttribute || "cn", - "mail", - "email", - ]; - const entries = await ldapSearch( - serviceClient, - config.userSearchBase, - filter, - attrList, - ); - - if (entries.length === 0) { - authLogger.warn("LDAP user not found", { - operation: "ldap_login", - username, - }); - return res.status(401).json({ error: "Invalid username or password" }); - } - - const userEntry = entries[0]; - const userDN = userEntry.dn.toString(); - - const getAttr = (key: string): string => { - const attr = userEntry.attributes.find((a) => a.type === key); - return attr - ? Array.isArray(attr.values) - ? attr.values[0] - : String(attr.values) - : ""; - }; - - const ldapIdentifier = - getAttr(config.usernameAttribute || "uid") || username; - const displayName = - getAttr(config.displayNameAttribute || "cn") || username; - const email = getAttr("mail") || getAttr("email") || ""; - - if (config.allowedUsers) { - if ( - !isOIDCUserAllowed( - config.allowedUsers, - ldapIdentifier, - email || undefined, - ) - ) { - authLogger.warn("LDAP user not in allowed list", { - operation: "ldap_user_not_allowed", - ldapIdentifier, - }); - return res.status(403).json({ error: "User not allowed" }); - } - } - - // Re-bind with user's own credentials to verify password - const userClient = createLDAPClient( - config.host, - config.port || 389, - config.useTLS || false, - ); - try { - await ldapBind(userClient, userDN, password); - } catch { - authLogger.warn("LDAP bind failed - wrong password", { - operation: "ldap_login", - ldapIdentifier, - }); - return res.status(401).json({ error: "Invalid username or password" }); - } finally { - ldapUnbind(userClient); - } - - // Admin group check - let isAdmin = false; - if (config.adminGroup && config.groupSearchBase) { - try { - const groupFilter = `(member=${ldapEscapeFilter(userDN)})`; - const groupEntries = await ldapSearch( - serviceClient, - config.groupSearchBase, - groupFilter, - ["cn", "dn"], - ); - isAdmin = groupEntries.some((g) => { - const cn = g.attributes.find((a) => a.type === "cn"); - const cnVal = cn - ? Array.isArray(cn.values) - ? cn.values[0] - : String(cn.values) - : ""; - return ( - cnVal === config.adminGroup || - g.dn.toString() === config.adminGroup - ); - }); - } catch (groupErr) { - authLogger.warn("LDAP group check failed", { - operation: "ldap_group_check", - error: groupErr, - }); - } - } - - ldapUnbind(serviceClient); - - const deviceInfo = parseUserAgent(req); - const oidcIdentifier = `ldap:${providerId}:${ldapIdentifier}`; - const userRepository = createCurrentUserRepository(); - - let existingUser = - await userRepository.findByOidcIdentifier(oidcIdentifier); - - let userId: string; - if (!existingUser) { - let autoProvision = false; - try { - autoProvision = await createCurrentSettingsRepository().getBoolean( - "oidc_auto_provision", - false, - ); - } catch { - /* */ - } - if (!autoProvision) - autoProvision = - (process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === - "true"; - - const isFirst = (await userRepository.countAll()) === 0; - - if (!isFirst && !autoProvision) { - return res.status(403).json({ error: "Registration is disabled" }); - } - - userId = nanoid(); - const createdUser = await userRepository.createFirstSsoUser({ - id: userId, - username: displayName, - passwordHash: "", - isAdmin, - isOidc: true, - oidcIdentifier, - ssoProviderId: providerId, - }); - const isFirstFinal = createdUser.isFirstUser; - - try { - const defaultRoleName = isFirstFinal || isAdmin ? "admin" : "user"; - await createCurrentRoleRepository().assignRoleNameToUser({ - userId, - roleName: defaultRoleName, - grantedBy: userId, - }); - } catch { - /* */ - } - - try { - const sessionDurationMs = - deviceInfo.type === "desktop" || deviceInfo.type === "mobile" - ? 30 * 24 * 60 * 60 * 1000 - : 24 * 60 * 60 * 1000; - await authManager.registerOIDCUser(userId, sessionDurationMs); - } catch (encryptionError) { - await userRepository.delete(userId); - authLogger.error( - "Failed to setup LDAP user encryption", - encryptionError, - ); - return res - .status(500) - .json({ error: "Failed to setup user security" }); - } - - existingUser = await userRepository.findById(userId); - if (!existingUser) { - throw new Error("Created LDAP user could not be loaded"); - } - } else { - userId = existingUser.id; - - // Sync admin status from group membership - if (config.adminGroup && !!existingUser.isAdmin !== isAdmin) { - existingUser = - (await userRepository.update(userId, { isAdmin })) ?? existingUser; - try { - const newRoleName = isAdmin ? "admin" : "user"; - const oldRoleName = isAdmin ? "user" : "admin"; - await createCurrentRoleRepository().switchUserRoleName({ - userId, - addRoleName: newRoleName, - removeRoleName: oldRoleName, - grantedBy: userId, - }); - } catch { - /* non-fatal */ - } - } - - // Update display name if not dual-auth - const isDualAuth = - existingUser.passwordHash && existingUser.passwordHash.trim() !== ""; - if (!isDualAuth && existingUser.username !== displayName) { - existingUser = - (await userRepository.update(userId, { username: displayName })) ?? - existingUser; - } - } - - const userRecord = existingUser; - try { - await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type); - } catch { - /* */ - } - - const token = await authManager.generateJWTToken(userRecord.id, { - deviceType: deviceInfo.type, - deviceInfo: deviceInfo.deviceInfo, - rememberMe: rememberMe || false, + const identity = await verifyLdapLogin({ body: req.body, ip: req.ip }); + await respondWithLogin(req, res, identity, { + methodId: "ldap", + rememberMe: !!req.body?.rememberMe, }); - - authLogger.success("LDAP login successful", { - operation: "ldap_login_complete", - userId: userRecord.id, - username: userRecord.username, - }); - - const maxAge = - deviceInfo.type === "desktop" || deviceInfo.type === "mobile" - ? 30 * 24 * 60 * 60 * 1000 - : rememberMe - ? 30 * 24 * 60 * 60 * 1000 - : 24 * 60 * 60 * 1000; - - res.clearCookie("jwt", authManager.getClearCookieOptions(req)); - return res - .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) - .json({ success: true, message: "Login successful" }); - } catch (err) { - ldapUnbind(serviceClient); - authLogger.error("LDAP login failed", err); - return res.status(500).json({ error: "LDAP authentication failed" }); + } catch (error) { + sendLoginError(res, error); } }); } diff --git a/src/backend/database/routes/plugin-api-routes.ts b/src/backend/database/routes/plugin-api-routes.ts index 974b09f0d..18d144a8b 100644 --- a/src/backend/database/routes/plugin-api-routes.ts +++ b/src/backend/database/routes/plugin-api-routes.ts @@ -69,4 +69,13 @@ router.use("/:pluginId", (req: Request, res: Response, next) => { pluginRouter(req, res, next); }); +/** + * Mounts the dispatcher. No auth in front of it: each plugin router runs + * core auth itself and skips it only for the paths it declared public, so a + * webhook or OIDC callback a plugin serves can actually be reached. + */ +export function mountPluginApi(app: express.Express): void { + app.use("/plugin-api", router); +} + export default router; diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index f8c491fb7..cdadd822e 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -104,6 +104,72 @@ router.get("/public", async (_req: Request, res: Response) => { } }); +/** + * @openapi + * /plugins/public-manifest: + * get: + * summary: List the plugin frontends the login screen needs + * description: > + * No auth. The login screen draws login methods and second-factor steps + * that plugins provide, before anyone has signed in. Returns only + * enabled plugins that contribute login methods or second factors, with + * the fields the browser loader needs and the ids they contribute. + * Nothing operational: no capabilities, grants, settings or errors. + * tags: + * - Plugins + * responses: + * 200: + * description: Login-capable plugins. + */ +router.get("/public-manifest", async (_req: Request, res: Response) => { + try { + res.json(await listPreLoginPlugins()); + } catch (error) { + databaseLogger.error( + "Failed to list login plugins", + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_list_public_manifest" }, + ); + res.status(500).json({ error: "Failed to list plugins" }); + } +}); + +/** Enabled plugins with login or second-factor UI, without admin data. */ +export async function listPreLoginPlugins(): Promise< + Array> +> { + const records = await createCurrentPluginRepository().listAll(); + const { loader } = getPluginRuntime(); + return records.flatMap((record) => { + if (record.state !== "enabled") return []; + const loaded = loader.get(record.id); + if (loaded?.state !== "active") return []; + let manifest: PluginManifest; + try { + manifest = JSON.parse(record.manifestJson) as PluginManifest; + } catch { + return []; + } + const auth = manifest?.contributes?.auth; + const loginMethods = auth?.loginMethods ?? []; + const secondFactors = auth?.secondFactors ?? []; + if (loginMethods.length === 0 && secondFactors.length === 0) return []; + return [ + { + id: record.id, + name: record.name, + version: record.version, + enabled: true, + state: loaded.state, + contributes: { auth: { loginMethods, secondFactors } }, + dependencies: manifest.dependencies ?? {}, + optionalDependencies: manifest.optionalDependencies ?? {}, + ...describePluginFrontend(loaded), + }, + ]; + }); +} + /** * @openapi * /plugins: diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index 3c95ab601..8929623e8 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -4,7 +4,8 @@ import express, { type Request, type Response } from "express"; import { authLogger, databaseLogger } from "../../utils/logger.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { AuthManager } from "../../utils/auth-manager.js"; -import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; +import { connectHost } from "../../hosts/connect/connect-host.js"; +import type { SshConnectHost } from "../../hosts/connect/types.js"; import { extractSnippetReorderUpdates } from "./snippets-reorder.js"; import { createSnippetExecutionResult, @@ -586,22 +587,12 @@ router.post( .json({ error: "Notes cannot be executed on a host" }); } - const { Client } = await import("ssh2"); const host = await resolveHostById(parseInt(hostId), userId); if (!host) { return res.status(404).json({ error: "Host not found" }); } - const password = host.password; - const privateKey = host.key; - const passphrase = host.keyPassword; - const authType = host.authType; - - const conn = new Client(); - let output = ""; - let errorOutput = ""; - const resolvedCommand = resolveSnippetCommand( snippet.content, { @@ -613,6 +604,13 @@ router.post( inputValues && typeof inputValues === "object" ? inputValues : {}, ); + const { client: conn } = await connectHost( + host as unknown as SshConnectHost, + { userId, purpose: "snippet", timeoutMs: 30000 }, + ); + let output = ""; + let errorOutput = ""; + const executePromise = new Promise<{ success: boolean; output: string; @@ -621,130 +619,42 @@ router.post( const timeoutMs = getSnippetExecutionTimeoutMs(); let timeout: NodeJS.Timeout | undefined; - conn.on("ready", () => { - conn.exec(resolvedCommand, (err, stream) => { - if (err) { - clearTimeout(timeout); - conn.end(); - return reject(err); - } - - if (timeoutMs) { - timeout = setTimeout(() => { - conn.end(); - reject( - new Error(`Command execution timeout (${timeoutMs / 1000}s)`), - ); - }, timeoutMs); - } - - stream.on("close", (exitCode: number | null) => { - clearTimeout(timeout); - conn.end(); - resolve( - createSnippetExecutionResult(exitCode, output, errorOutput), - ); - }); - - stream.on("data", (data: Buffer) => { - output += data.toString(); - }); - - stream.stderr.on("data", (data: Buffer) => { - errorOutput += data.toString(); - }); - }); - }); - conn.on("error", (err) => { clearTimeout(timeout); reject(err); }); - const config: Record = { - host: host.ip, - port: host.port, - username: host.username, - tryKeyboard: true, - keepaliveInterval: 30000, - keepaliveCountMax: 3, - readyTimeout: 30000, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - timeout: 30000, - env: { - TERM: "xterm-256color", - LANG: "en_US.UTF-8", - LC_ALL: "en_US.UTF-8", - LC_CTYPE: "en_US.UTF-8", - LC_MESSAGES: "en_US.UTF-8", - LC_MONETARY: "en_US.UTF-8", - LC_NUMERIC: "en_US.UTF-8", - LC_TIME: "en_US.UTF-8", - LC_COLLATE: "en_US.UTF-8", - COLORTERM: "truecolor", - }, - algorithms: { - kex: [ - "curve25519-sha256", - "curve25519-sha256@libssh.org", - "ecdh-sha2-nistp521", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp256", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group-exchange-sha1", - "diffie-hellman-group1-sha1", - ], - serverHostKey: [ - "ssh-ed25519", - "ecdsa-sha2-nistp521", - "ecdsa-sha2-nistp384", - "ecdsa-sha2-nistp256", - "rsa-sha2-512", - "rsa-sha2-256", - "ssh-rsa", - "ssh-dss", - ], - cipher: SSH_ALGORITHMS.cipher, - hmac: [ - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512", - "hmac-sha2-256", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - }; - - if (authType === "password" && password) { - config.password = password; - } else if (authType === "key" && privateKey) { - const cleanKey = (privateKey as string) - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - config.privateKey = Buffer.from(cleanKey, "utf8"); - if (passphrase) { - config.passphrase = passphrase; + conn.exec(resolvedCommand, (err, stream) => { + if (err) { + conn.end(); + return reject(err); } - } else if (password) { - config.password = password; - } else if (privateKey) { - const cleanKey = (privateKey as string) - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - config.privateKey = Buffer.from(cleanKey, "utf8"); - if (passphrase) { - config.passphrase = passphrase; - } - } - conn.connect(config); + if (timeoutMs) { + timeout = setTimeout(() => { + conn.end(); + reject( + new Error(`Command execution timeout (${timeoutMs / 1000}s)`), + ); + }, timeoutMs); + } + + stream.on("close", (exitCode: number | null) => { + clearTimeout(timeout); + conn.end(); + resolve( + createSnippetExecutionResult(exitCode, output, errorOutput), + ); + }); + + stream.on("data", (data: Buffer) => { + output += data.toString(); + }); + + stream.stderr.on("data", (data: Buffer) => { + errorOutput += data.toString(); + }); + }); }); const result = await executePromise; diff --git a/src/backend/database/routes/ssh-auth-routes.ts b/src/backend/database/routes/ssh-auth-routes.ts new file mode 100644 index 000000000..066726041 --- /dev/null +++ b/src/backend/database/routes/ssh-auth-routes.ts @@ -0,0 +1,78 @@ +import express, { type Request, type Response } from "express"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { + listSshAuthProviders, + listSshAuthTypeOwners, +} from "../../hosts/connect/auth-provider-registry.js"; +import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js"; + +const router = express.Router(); +const authenticateJWT = AuthManager.getInstance().createAuthMiddleware(); + +export interface SshAuthProviderSummary { + type: string; + labelKey: string; + descriptionKey?: string; + pluginId: string; + fields: unknown[]; + credentialType: boolean; + needsUserInteraction: boolean; + supportsBackground: boolean; + available: boolean; + /** Set when the type belongs to a plugin that is disabled or missing. */ + missingPlugin?: { id: string; name: string }; +} + +export function summarizeSshAuthProviders(): SshAuthProviderSummary[] { + ensureCoreSshAuthProviders(); + const summaries: SshAuthProviderSummary[] = listSshAuthProviders().map( + (provider) => ({ + type: provider.type, + labelKey: provider.labelKey, + descriptionKey: provider.descriptionKey, + pluginId: provider.pluginId, + fields: provider.fields ?? [], + credentialType: !!provider.credentialType, + needsUserInteraction: !!provider.needsUserInteraction, + supportsBackground: provider.supportsBackground !== false, + available: true, + }), + ); + + const known = new Set(summaries.map((summary) => summary.type)); + for (const owner of listSshAuthTypeOwners()) { + if (known.has(owner.type)) continue; + summaries.push({ + type: owner.type, + labelKey: "", + pluginId: owner.pluginId, + fields: [], + credentialType: false, + needsUserInteraction: false, + supportsBackground: false, + available: false, + missingPlugin: { id: owner.pluginId, name: owner.pluginName }, + }); + } + return summaries; +} + +/** + * @openapi + * /ssh-auth/providers: + * get: + * summary: List SSH auth types + * description: Every SSH auth type the server can connect with, from core and from plugins, plus types declared by plugins that are disabled so the host editor can explain them. + * tags: + * - SSH Auth + * responses: + * 200: + * description: Auth type list. + * 401: + * description: Not authenticated. + */ +router.get("/providers", authenticateJWT, (_req: Request, res: Response) => { + res.json({ providers: summarizeSshAuthProviders() }); +}); + +export default router; diff --git a/src/backend/database/routes/sso-provider-routes.ts b/src/backend/database/routes/sso-provider-routes.ts index b2c5663f8..985a8bc89 100644 --- a/src/backend/database/routes/sso-provider-routes.ts +++ b/src/backend/database/routes/sso-provider-routes.ts @@ -94,6 +94,26 @@ function applyProviderDefaults( return config; } +/** Enabled providers as the login screen sees them: no secrets. */ +export async function listPublicSsoProviders(): Promise< + Array<{ id: number; name: string; type: string; displayOrder: number }> +> { + const envConfig = getOIDCConfigFromEnv(); + if (envConfig && isOIDCEnvOverrideEnabled()) { + return [{ id: 0, name: "SSO", type: "oidc", displayOrder: 0 }]; + } + + const providers = + await createCurrentSsoProviderRepository().listEnabledPublic(); + + // With no provider rows, synthesize one from env vars so SSO login stays + // available when it is configured purely through the environment. + if (providers.length === 0 && envConfig) { + providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 }); + } + return providers; +} + export function registerSSOProviderRoutes(router: Router): void { const requireAdmin = authManager.createAdminMiddleware(); @@ -111,25 +131,7 @@ export function registerSSOProviderRoutes(router: Router): void { */ router.get("/sso-providers", async (_req, res) => { try { - const envConfig = getOIDCConfigFromEnv(); - if (envConfig && isOIDCEnvOverrideEnabled()) { - return res.json([ - { id: 0, name: "SSO", type: "oidc", displayOrder: 0 }, - ]); - } - - const providers = - await createCurrentSsoProviderRepository().listEnabledPublic(); - - // If no DB providers exist, synthesize one from env vars so SSO login - // remains available when configured purely via environment variables. - if (providers.length === 0) { - if (envConfig) { - providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 }); - } - } - - res.json(providers); + res.json(await listPublicSsoProviders()); } catch (err) { authLogger.error("Failed to list SSO providers", err); res.status(500).json({ error: "Failed to list SSO providers" }); diff --git a/src/backend/database/routes/user-admin-routes.ts b/src/backend/database/routes/user-admin-routes.ts index fc7250fdf..7ea85d77b 100644 --- a/src/backend/database/routes/user-admin-routes.ts +++ b/src/backend/database/routes/user-admin-routes.ts @@ -9,8 +9,13 @@ import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { createCurrentRoleRepository, + createCurrentUserAuthRepository, createCurrentUserRepository, } from "../repositories/factory.js"; +import { + listUserSecondFactors, + resetUserSecondFactors, +} from "../../auth/second-factor-admin.js"; import type { UserRecord, UserRepository, @@ -730,6 +735,11 @@ export function registerUserAdminRoutes( totpEnabled: false, totpBackupCodes: null, }); + await createCurrentUserAuthRepository().removeSecondFactor( + targetUser.id, + "core", + "totp", + ); try { await DatabaseSaveTrigger.forceSave("admin_disable_totp"); @@ -760,6 +770,126 @@ export function registerUserAdminRoutes( } }); + /** + * @openapi + * /users/admin/{userId}/second-factors: + * get: + * summary: List a user's second factors (admin only) + * description: Every second factor the user is enrolled in, and whether the plugin behind it is running. + * tags: + * - Users + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Enrolled factors. + * 403: + * description: Admin access required. + * 404: + * description: User not found. + */ + router.get( + "/admin/:userId/second-factors", + authenticateJWT, + async (req, res) => { + const adminId = (req as AuthenticatedRequest).userId; + try { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(adminId); + if (!adminUser?.isAdmin) { + return res.status(403).json({ error: "Admin access required" }); + } + const targetUser = await userRepository.findById( + String(req.params.userId), + ); + if (!targetUser) { + return res.status(404).json({ error: "User not found" }); + } + res.json({ factors: await listUserSecondFactors(targetUser.id) }); + } catch (err) { + authLogger.error("Failed to list second factors", err); + res.status(500).json({ error: "Failed to list second factors" }); + } + }, + ); + + /** + * @openapi + * /users/admin/{userId}/second-factors: + * delete: + * summary: Reset a user's second factors (admin only) + * description: Removes every second factor the user enrolled in, including ones whose plugin is disabled or missing, and forgets their trusted devices. The user can sign in with their first factor and enrol again. + * tags: + * - Users + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Second factors reset. + * 403: + * description: Admin access required. + * 404: + * description: User not found. + */ + router.delete( + "/admin/:userId/second-factors", + authenticateJWT, + async (req, res) => { + const adminId = (req as AuthenticatedRequest).userId; + try { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(adminId); + if (!adminUser?.isAdmin) { + return res.status(403).json({ error: "Admin access required" }); + } + const targetUser = await userRepository.findById( + String(req.params.userId), + ); + if (!targetUser) { + return res.status(404).json({ error: "User not found" }); + } + + const removed = await resetUserSecondFactors(targetUser.id); + + try { + await DatabaseSaveTrigger.forceSave("admin_reset_second_factors"); + } catch (saveError) { + authLogger.error("Failed to persist second factor reset", saveError, { + operation: "admin_reset_second_factors_save_failed", + userId: targetUser.id, + }); + } + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId: adminId, + username: adminUser.username ?? adminId, + action: "admin_reset_second_factors", + resourceType: "user", + resourceId: targetUser.id, + resourceName: targetUser.username, + details: JSON.stringify({ removed }), + ipAddress, + userAgent, + success: true, + }); + + res.json({ message: "Second factors reset", removed }); + } catch (err) { + authLogger.error("Failed to reset second factors", err); + res.status(500).json({ error: "Failed to reset second factors" }); + } + }, + ); + /** * @openapi * /users/admin/export/{userId}: diff --git a/src/backend/database/routes/user-oidc-account-routes.ts b/src/backend/database/routes/user-oidc-account-routes.ts index 7f72eb529..cce1bcccf 100644 --- a/src/backend/database/routes/user-oidc-account-routes.ts +++ b/src/backend/database/routes/user-oidc-account-routes.ts @@ -4,7 +4,10 @@ import type { RequestHandler, Router } from "express"; import { AuthManager } from "../../utils/auth-manager.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { authLogger } from "../../utils/logger.js"; -import { createCurrentUserRepository } from "../repositories/factory.js"; +import { + createCurrentUserRepository, + createCurrentUserAuthRepository, +} from "../repositories/factory.js"; import { deleteUserAndRelatedData } from "./delete-user-data.js"; type UserOidcAccountRoutesDeps = { @@ -120,6 +123,12 @@ export function registerUserOidcAccountRoutes( scopes: oidcUser.scopes || "openid email profile", }); + // Identities hang off the user row and would go with it. + await createCurrentUserAuthRepository().moveIdentities( + oidcUserId, + targetUser.id, + ); + await authManager.revokeAllUserSessions(oidcUserId); authManager.logoutUser(oidcUserId); @@ -265,6 +274,9 @@ export function registerUserOidcAccountRoutes( namePath: "", scopes: "openid email profile", }); + await createCurrentUserAuthRepository().unlinkIdentitiesForUser( + targetUser.id, + ); try { await DatabaseSaveTrigger.forceSave("unlink_oidc_explicit_save"); diff --git a/src/backend/database/routes/user-totp-routes.ts b/src/backend/database/routes/user-totp-routes.ts index 6e055c117..a702dda4f 100644 --- a/src/backend/database/routes/user-totp-routes.ts +++ b/src/backend/database/routes/user-totp-routes.ts @@ -1,6 +1,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Router } from "express"; import bcrypt from "bcryptjs"; +import crypto from "crypto"; import QRCode from "qrcode"; import speakeasy from "speakeasy"; import { AuthManager } from "../../utils/auth-manager.js"; @@ -8,16 +9,14 @@ import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { FieldCrypto } from "../../utils/field-crypto.js"; import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; import { authLogger } from "../../utils/logger.js"; -import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js"; + +import { isPasswordLoginSettingOn } from "../../auth/core-auth.js"; +import { verifySecondFactorAndRespond } from "../../auth/login-pipeline.js"; +import { TOTP_FACTOR_ID } from "../../auth/legacy/totp-factor.js"; import { - generateDeviceFingerprint, - getDeviceId, - parseUserAgent, -} from "../../utils/user-agent-parser.js"; -import { + createCurrentUserAuthRepository, createCurrentSessionRepository, - createCurrentSettingsRepository, createCurrentTrustedDeviceRepository, createCurrentUserRepository, } from "../repositories/factory.js"; @@ -31,6 +30,17 @@ interface UserTotpRoutesDeps { isNativeAppRequest: NativeAppRequestChecker; } +const BACKUP_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +/** Eight characters from a CSPRNG; Math.random is not fit for secrets. */ +export function generateBackupCode(): string { + let code = ""; + for (let i = 0; i < 8; i++) { + code += BACKUP_CODE_ALPHABET[crypto.randomInt(BACKUP_CODE_ALPHABET.length)]; + } + return code; +} + export async function verifyTotpReauth( userRecord: UserRecord, credential: string, @@ -245,11 +255,7 @@ export function registerUserTotpRoutes( } try { - const passwordLoginAllowed = - await createCurrentSettingsRepository().getBoolean( - "allow_password_login", - true, - ); + const passwordLoginAllowed = isPasswordLoginSettingOn(); if (!passwordLoginAllowed) { return res.status(409).json({ error: @@ -291,9 +297,7 @@ export function registerUserTotpRoutes( return res.status(401).json({ error: "Invalid TOTP code" }); } - const backupCodes = Array.from({ length: 8 }, () => - Math.random().toString(36).substring(2, 10).toUpperCase(), - ); + const backupCodes = Array.from({ length: 8 }, () => generateBackupCode()); const backupCodesJson = JSON.stringify(backupCodes); const storedBackupCodes = userDataKey @@ -309,6 +313,11 @@ export function registerUserTotpRoutes( totpEnabled: true, totpBackupCodes: storedBackupCodes, }); + await createCurrentUserAuthRepository().recordSecondFactor( + userId, + "core", + TOTP_FACTOR_ID, + ); await createCurrentSessionRepository().revokeAllForUser( userId, @@ -419,6 +428,11 @@ export function registerUserTotpRoutes( totpSecret: null, totpBackupCodes: null, }); + await createCurrentUserAuthRepository().removeSecondFactor( + userId, + "core", + TOTP_FACTOR_ID, + ); authLogger.info("Two-factor authentication disabled", { operation: "totp_disable", userId, @@ -503,9 +517,7 @@ export function registerUserTotpRoutes( .json({ error: "Incorrect password or invalid TOTP code" }); } - const backupCodes = Array.from({ length: 8 }, () => - Math.random().toString(36).substring(2, 10).toUpperCase(), - ); + const backupCodes = Array.from({ length: 8 }, () => generateBackupCode()); const backupCodesJson = JSON.stringify(backupCodes); const storedBackupCodes = userDataKey @@ -560,180 +572,13 @@ export function registerUserTotpRoutes( * description: TOTP verification failed. */ router.post("/totp/verify-login", async (req, res) => { - const { temp_token, totp_code, rememberMe } = req.body; - - if (!temp_token || !totp_code) { + if (!req.body?.temp_token || !req.body?.totp_code) { return res .status(400) .json({ error: "Token and TOTP code are required" }); } - try { - const decoded = await authManager.verifyJWTToken(temp_token); - if (!decoded || !decoded.pendingTOTP) { - return res.status(401).json({ error: "Invalid temporary token" }); - } - - const userRecord = await createCurrentUserRepository().findById( - decoded.userId, - ); - if (!userRecord) { - return res.status(404).json({ error: "User not found" }); - } - - const lockStatus = loginRateLimiter.isTOTPLocked(userRecord.id); - if (lockStatus.locked) { - authLogger.warn("TOTP verification blocked due to rate limiting", { - operation: "totp_verify_blocked", - userId: userRecord.id, - remainingTime: lockStatus.remainingTime, - }); - return res.status(429).json({ - error: `Rate limited: Too many TOTP verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`, - remainingTime: lockStatus.remainingTime, - code: "TOTP_RATE_LIMITED", - }); - } - - loginRateLimiter.recordFailedTOTPAttempt(userRecord.id); - - if (!userRecord.totpEnabled || !userRecord.totpSecret) { - return res - .status(400) - .json({ error: "TOTP not enabled for this user" }); - } - - const userDataKey = authManager.getUserDataKey(userRecord.id); - if (!userDataKey) { - return res.status(401).json({ - error: "Session expired - please log in again", - code: "SESSION_EXPIRED", - }); - } - - const totpSecret = LazyFieldEncryption.safeGetFieldValue( - userRecord.totpSecret, - userDataKey, - userRecord.id, - "totp_secret", - ); - - if (!totpSecret) { - await createCurrentUserRepository().update(userRecord.id, { - totpEnabled: false, - totpSecret: null, - totpBackupCodes: null, - }); - - return res.status(400).json({ - error: - "TOTP has been disabled due to password reset. Please set up TOTP again.", - }); - } - - const verified = speakeasy.totp.verify({ - secret: totpSecret, - encoding: "base32", - token: totp_code, - window: 2, - }); - - if (!verified) { - let backupCodes = []; - try { - backupCodes = userRecord.totpBackupCodes - ? JSON.parse(userRecord.totpBackupCodes) - : []; - } catch { - backupCodes = []; - } - - if (!Array.isArray(backupCodes)) { - backupCodes = []; - } - - const backupIndex = backupCodes.indexOf(totp_code); - - if (backupIndex === -1) { - authLogger.warn("TOTP verification failed - invalid code", { - operation: "totp_verify_failed", - userId: userRecord.id, - remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts( - userRecord.id, - ), - }); - return res.status(401).json({ - error: "Invalid TOTP code", - remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts( - userRecord.id, - ), - }); - } - - backupCodes.splice(backupIndex, 1); - await createCurrentUserRepository().update(userRecord.id, { - totpBackupCodes: JSON.stringify(backupCodes), - }); - } - - loginRateLimiter.resetTOTPAttempts(userRecord.id); - - const deviceInfo = parseUserAgent(req); - - if (rememberMe) { - const deviceFingerprint = generateDeviceFingerprint( - deviceInfo, - getDeviceId(req), - ); - if (deviceFingerprint) { - await authManager.addTrustedDevice( - userRecord.id, - deviceFingerprint, - deviceInfo.type, - deviceInfo.deviceInfo, - ); - authLogger.info("Device automatically trusted via Remember Me", { - operation: "totp_auto_trust", - userId: userRecord.id, - deviceType: deviceInfo.type, - }); - } - } - - const token = await authManager.generateJWTToken(userRecord.id, { - rememberMe: !!rememberMe, - deviceType: deviceInfo.type, - deviceInfo: deviceInfo.deviceInfo, - }); - - authLogger.success("TOTP verification successful", { - operation: "totp_verify_success", - userId: userRecord.id, - deviceType: deviceInfo.type, - deviceInfo: deviceInfo.deviceInfo, - }); - - const response: Record = { - success: true, - is_admin: !!userRecord.isAdmin, - username: userRecord.username, - userId: userRecord.id, - is_oidc: !!userRecord.isOidc, - totp_enabled: !!userRecord.totpEnabled, - ...(isNativeAppRequest(req) ? { token } : {}), - }; - - const timeoutValue = await createCurrentSettingsRepository().get( - "session_timeout_hours", - ); - const timeoutHours = timeoutValue ? parseInt(timeoutValue, 10) || 24 : 24; - const maxAge = rememberMe - ? 30 * 24 * 60 * 60 * 1000 - : timeoutHours * 60 * 60 * 1000; - - return res - .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) - .json(response); + await verifySecondFactorAndRespond(req, res, TOTP_FACTOR_ID); } catch (err) { authLogger.error("TOTP verification failed", err); return res.status(500).json({ error: "TOTP verification failed" }); diff --git a/src/backend/database/routes/user-webauthn-routes.ts b/src/backend/database/routes/user-webauthn-routes.ts index 848606a0e..5720413f6 100644 --- a/src/backend/database/routes/user-webauthn-routes.ts +++ b/src/backend/database/routes/user-webauthn-routes.ts @@ -17,17 +17,14 @@ import { nanoid } from "nanoid"; import type { AuthenticatedRequest } from "../../../types/index.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { authLogger } from "../../utils/logger.js"; -import { - generateDeviceFingerprint, - getDeviceId, - parseUserAgent, -} from "../../utils/user-agent-parser.js"; + import { createCurrentUserRepository, createCurrentWebauthnCredentialRepository, - getCurrentSettingValue, } from "../repositories/factory.js"; import type { WebauthnCredentialRecord } from "../repositories/webauthn-credential-repository.js"; +import { respondWithLogin, sendLoginError } from "../../auth/login-pipeline.js"; +import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js"; type UserVerification = "discouraged" | "preferred" | "required"; type NativeAppRequestChecker = (req: Request) => boolean; @@ -130,6 +127,82 @@ function getCredentialForVerification( }; } +/** + * Checks a passkey assertion and says who signed in. A passkey that verified + * the user (PIN or biometric) counts as the second factor too. + */ +export async function verifyPasskeyLogin( + req: Request, +): Promise { + const challenge = takeChallenge( + authenticationChallenges, + req.body?.challengeId, + ); + if (!challenge) { + throw new LoginMethodError("Authentication challenge expired", 400); + } + + const response = req.body?.response as AuthenticationResponseJSON | undefined; + if (!response?.id) { + throw new LoginMethodError("Invalid passkey response", 400); + } + + const credential = + await createCurrentWebauthnCredentialRepository().findByCredentialId( + response.id, + ); + if (!credential) { + throw new LoginMethodError("Passkey not recognized", 401); + } + if (challenge.userId && challenge.userId !== credential.userId) { + throw new LoginMethodError("Passkey not recognized", 401); + } + + let verification: Awaited>; + try { + verification = await verifyAuthenticationResponse({ + response, + expectedChallenge: challenge.challenge, + expectedOrigin: challenge.origin, + expectedRPID: challenge.rpID, + credential: getCredentialForVerification(credential), + requireUserVerification: challenge.userVerification === "required", + advancedFIDOConfig: { userVerification: challenge.userVerification }, + }); + } catch (error) { + authLogger.warn("WebAuthn authentication failed", { + operation: "webauthn_auth_verify", + credentialId: credential.id, + userId: credential.userId, + error: getErrorMessage(error, "Unknown"), + }); + throw new LoginMethodError("Passkey authentication failed", 401); + } + + if (!verification.verified) { + throw new LoginMethodError("Passkey authentication failed", 401); + } + + await createCurrentWebauthnCredentialRepository().updateAuthState( + credential.id, + { + counter: verification.authenticationInfo.newCounter, + backedUp: verification.authenticationInfo.credentialBackedUp, + deviceType: verification.authenticationInfo.credentialDeviceType, + lastUsedAt: new Date().toISOString(), + }, + ); + + return { + kind: "user", + userId: credential.userId, + mfaSatisfied: verification.authenticationInfo.userVerified === true, + rememberMe: !!req.body?.rememberMe, + unlockError: + "Passkey cannot unlock this account. Log in with password and register the passkey again.", + }; +} + export function registerUserWebAuthnRoutes( router: Router, { authenticateJWT, authManager, isNativeAppRequest }: WebAuthnRoutesDeps, @@ -412,140 +485,14 @@ export function registerUserWebAuthnRoutes( * description: Passkey not recognized or authentication failed. */ router.post("/webauthn/authenticate/verify", async (req, res) => { - const challenge = takeChallenge( - authenticationChallenges, - req.body?.challengeId, - ); - if (!challenge) { - return res - .status(400) - .json({ error: "Authentication challenge expired" }); - } - - const response = req.body?.response as - AuthenticationResponseJSON | undefined; - if (!response?.id) { - return res.status(400).json({ error: "Invalid passkey response" }); - } - - const credential = - await createCurrentWebauthnCredentialRepository().findByCredentialId( - response.id, - ); - - if (!credential) { - return res.status(401).json({ error: "Passkey not recognized" }); - } - - if (challenge.userId && challenge.userId !== credential.userId) { - return res.status(401).json({ error: "Passkey not recognized" }); - } - try { - const verification = await verifyAuthenticationResponse({ - response, - expectedChallenge: challenge.challenge, - expectedOrigin: challenge.origin, - expectedRPID: challenge.rpID, - credential: getCredentialForVerification(credential), - requireUserVerification: challenge.userVerification === "required", - advancedFIDOConfig: { - userVerification: challenge.userVerification, - }, - }); - - if (!verification.verified) { - return res.status(401).json({ error: "Passkey authentication failed" }); - } - - const userRecord = await createCurrentUserRepository().findById( - credential.userId, - ); - if (!userRecord) { - return res.status(404).json({ error: "User not found" }); - } - - const deviceInfo = parseUserAgent(req); - const authenticated = await authManager.authenticateWebAuthnUser( - userRecord.id, - deviceInfo.type, - ); - - if (!authenticated) { - return res.status(401).json({ - error: - "Passkey cannot unlock this account. Log in with password and register the passkey again.", - }); - } - - await createCurrentWebauthnCredentialRepository().updateAuthState( - credential.id, - { - counter: verification.authenticationInfo.newCounter, - backedUp: verification.authenticationInfo.credentialBackedUp, - deviceType: verification.authenticationInfo.credentialDeviceType, - lastUsedAt: new Date().toISOString(), - }, - ); - - if ( - userRecord.totpEnabled && - verification.authenticationInfo.userVerified !== true - ) { - const deviceFingerprint = generateDeviceFingerprint( - deviceInfo, - getDeviceId(req), - ); - const isTrusted = deviceFingerprint - ? await authManager.isTrustedDevice(userRecord.id, deviceFingerprint) - : false; - - if (!isTrusted) { - const tempToken = await authManager.generateJWTToken(userRecord.id, { - pendingTOTP: true, - expiresIn: "10m", - }); - return res.json({ - success: true, - requires_totp: true, - temp_token: tempToken, - rememberMe: !!req.body?.rememberMe, - }); - } - } - - const token = await authManager.generateJWTToken(userRecord.id, { + const identity = await verifyPasskeyLogin(req); + await respondWithLogin(req, res, identity, { + methodId: "passkey", rememberMe: !!req.body?.rememberMe, - deviceType: deviceInfo.type, - deviceInfo: deviceInfo.deviceInfo, - }); - - const timeoutSetting = getCurrentSettingValue("session_timeout_hours"); - const timeoutHours = timeoutSetting - ? parseInt(timeoutSetting, 10) || 24 - : 24; - const maxAge = req.body?.rememberMe - ? 30 * 24 * 60 * 60 * 1000 - : timeoutHours * 60 * 60 * 1000; - - res.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)); - res.json({ - success: true, - is_admin: !!userRecord.isAdmin, - username: userRecord.username, - userId: userRecord.id, - is_oidc: !!userRecord.isOidc, - totp_enabled: !!userRecord.totpEnabled, - ...(isNativeAppRequest(req) ? { token } : {}), }); } catch (error) { - authLogger.warn("WebAuthn authentication failed", { - operation: "webauthn_auth_verify", - credentialId: credential.id, - userId: credential.userId, - error: getErrorMessage(error, "Unknown"), - }); - res.status(401).json({ error: "Passkey authentication failed" }); + sendLoginError(res, error); } }); diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 815f128d7..fb105da8f 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1,23 +1,13 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express, { type Request, type Response } from "express"; import bcrypt from "bcryptjs"; -import crypto from "crypto"; import { nanoid } from "nanoid"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { DataCrypto } from "../../utils/data-crypto.js"; -import { - getDeviceId, - parseUserAgent, - generateDeviceFingerprint, -} from "../../utils/user-agent-parser.js"; -import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; -import { getRequestOriginWithForceHTTPS } from "../../utils/request-origin.js"; -import { - getDesktopOidcCallbackUrl, - isOidcTokenCallback, -} from "../../utils/oidc-desktop-callback.js"; +import { parseUserAgent } from "../../utils/user-agent-parser.js"; +import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js"; import { deleteUserAndRelatedData } from "./delete-user-data.js"; import { isLoopbackRequest, @@ -29,14 +19,7 @@ import { shouldShowDonationModal } from "./donation-modal-utils.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { getOIDCConfigFromEnv, - isOIDCUserAllowed, - OIDCTokenFormatError, - verifyOIDCToken, - extractOidcGroupsFromSources, - parseOidcRoleMap, - resolveOidcMappedRoles, loadProviderConfig, - buildFetchOptions, resolveProviderByIssuer, validateLogoutToken, } from "./user-oidc-utils.js"; @@ -55,8 +38,8 @@ import { registerUserAdminRoutes } from "./user-admin-routes.js"; import { registerUserDataAccessRoutes } from "./user-data-access-routes.js"; import { registerSSOProviderRoutes } from "./sso-provider-routes.js"; import { registerLDAPAuthRoutes } from "./ldap-auth-routes.js"; +import { registerAuthRoutes } from "./auth-routes.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; -import { notifyAutomationInternalEvent } from "../../hosts/automation-events.js"; import { createCurrentSettingsRepository, getCurrentSettingValue, @@ -72,6 +55,25 @@ import { resolveTrustedProxyRoles, } from "../../utils/trusted-proxy-auth.js"; +import { getPasswordLoginStatus } from "../../auth/core-auth.js"; +import { verifyPasswordLogin } from "../../auth/builtin-login-methods.js"; +import { + redirectWithLoginError, + respondWithLogin, + respondWithRedirectLogin, + sendLoginError, +} from "../../auth/login-pipeline.js"; +import { + handleOidcCallback, + RedirectLoginError, + startOidcLogin, +} from "../../auth/legacy/oidc-login.js"; +import { LoginMethodError } from "../../auth/types.js"; +import { + isNativeAppRequest, + syncSharedCredentialsForUserRoles, +} from "../../auth/session-issuer.js"; + const authManager = AuthManager.getInstance(); const router = express.Router(); @@ -85,28 +87,6 @@ router.use((req, res, next) => { next(); }); -async function syncSharedCredentialsForUserRoles( - userId: string, - operation: string, -) { - try { - const { SharedHostSecretsManager } = - await import("../../utils/shared-host-secrets-manager.js"); - await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId); - const { SharedCredentialSecretsManager } = - await import("../../utils/shared-credential-secrets-manager.js"); - await SharedCredentialSecretsManager.getInstance().snapshotForUserRoles( - userId, - ); - } catch (error) { - authLogger.warn("Failed to sync role shared host secrets", { - operation, - userId, - error, - }); - } -} - function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } @@ -122,17 +102,6 @@ function isRegistrationAllowed(): boolean { } } -function isPasswordLoginAllowed(): boolean { - const envVal = process.env.ALLOW_PASSWORD_LOGIN; - if (envVal !== undefined) return envVal.trim().toLowerCase() === "true"; - try { - const value = getCurrentSettingValue("allow_password_login"); - return value ? value === "true" : true; - } catch { - return true; - } -} - function isPasswordResetAllowed(): boolean { const envVal = process.env.ALLOW_PASSWORD_RESET; if (envVal !== undefined) return envVal.trim().toLowerCase() === "true"; @@ -150,13 +119,6 @@ function getOidcSilentLoginDefaultFromEnv(): boolean | undefined { return envVal.trim().toLowerCase() === "true"; } -function isNativeAppRequest(req: Request): boolean { - return ( - (req.get("User-Agent") || "").startsWith("Termix-Mobile/") || - req.get("X-Electron-App") === "true" - ); -} - async function findCurrentUser(userId: string): Promise { return createCurrentUserRepository().findById(userId); } @@ -166,25 +128,6 @@ async function requireCurrentAdmin(userId: string): Promise { return user?.isAdmin ? user : null; } -// RFC 7636 PKCE: 43-128 char unreserved-character string. -function generatePkceCodeVerifier(): string { - return crypto.randomBytes(64).toString("base64url"); -} - -function generatePkceCodeChallenge(verifier: string): string { - return crypto.createHash("sha256").update(verifier).digest("base64url"); -} - -async function deleteOIDCStateSettings(state: string): Promise { - const settingsRepository = createCurrentSettingsRepository(); - await settingsRepository.delete(`oidc_state_${state}`); - await settingsRepository.delete(`oidc_backend_callback_${state}`); - await settingsRepository.delete(`oidc_frontend_origin_${state}`); - await settingsRepository.delete(`oidc_remember_me_${state}`); - await settingsRepository.delete(`oidc_provider_${state}`); - await settingsRepository.delete(`oidc_pkce_verifier_${state}`); -} - const authenticateJWT = authManager.createAuthMiddleware(); const requireAdmin = authManager.createAdminMiddleware(); @@ -676,92 +619,11 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => { */ router.get("/oidc/authorize", async (req, res) => { try { - const { - rememberMe, - desktopCallbackPort, - appCallbackUrl, - providerId: providerIdStr, - } = req.query; - const origin = getRequestOriginWithForceHTTPS(req); - const basePath = (process.env.BASE_PATH || "").replace(/\/+$/, ""); - const backendCallbackUri = `${origin}${basePath}/users/oidc/callback`; - - const resolvedProviderId = providerIdStr - ? parseInt(providerIdStr as string, 10) - : null; - const providerResult = await loadProviderConfig( - resolvedProviderId || undefined, - ); - if (!providerResult) { - return res.status(404).json({ error: "OIDC not configured" }); - } - const { config, providerDbId } = providerResult; - const state = nanoid(); - const nonce = nanoid(); - - const referer = req.get("Referer"); - let frontendOrigin; - if (desktopCallbackPort) { - frontendOrigin = getDesktopOidcCallbackUrl(desktopCallbackPort); - if (!frontendOrigin) { - return res.status(400).json({ error: "Invalid desktop callback port" }); - } - } else if (typeof appCallbackUrl === "string" && appCallbackUrl) { - let callbackUrl: URL; - try { - callbackUrl = new URL(appCallbackUrl); - } catch { - return res.status(400).json({ error: "Invalid app callback URL" }); - } - if (callbackUrl.protocol !== "termix-mobile:") { - return res.status(400).json({ error: "Unsupported app callback URL" }); - } - frontendOrigin = callbackUrl.toString(); - } else if (referer) { - const refererUrl = new URL(referer); - frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`; - } else { - frontendOrigin = origin; - } - - const codeVerifier = generatePkceCodeVerifier(); - const codeChallenge = generatePkceCodeChallenge(codeVerifier); - - const settingsRepository = createCurrentSettingsRepository(); - await settingsRepository.set(`oidc_state_${state}`, nonce); - await settingsRepository.set( - `oidc_backend_callback_${state}`, - backendCallbackUri, - ); - await settingsRepository.set( - `oidc_frontend_origin_${state}`, - frontendOrigin, - ); - await settingsRepository.set( - `oidc_remember_me_${state}`, - rememberMe === "true" ? "true" : "false", - ); - await settingsRepository.set(`oidc_pkce_verifier_${state}`, codeVerifier); - - if (providerDbId != null) { - await settingsRepository.set( - `oidc_provider_${state}`, - String(providerDbId), - ); - } - - const authUrl = new URL(config.authorization_url); - authUrl.searchParams.set("client_id", config.client_id); - authUrl.searchParams.set("redirect_uri", backendCallbackUri); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("scope", config.scopes); - authUrl.searchParams.set("state", state); - authUrl.searchParams.set("nonce", nonce); - authUrl.searchParams.set("code_challenge", codeChallenge); - authUrl.searchParams.set("code_challenge_method", "S256"); - - res.json({ auth_url: authUrl.toString(), state, nonce }); + res.json(await startOidcLogin(req)); } catch (err) { + if (err instanceof LoginMethodError) { + return res.status(err.status).json({ error: err.message }); + } authLogger.error("Failed to generate OIDC auth URL", err); res.status(500).json({ error: "Failed to generate authorization URL" }); } @@ -782,811 +644,43 @@ router.get("/oidc/authorize", async (req, res) => { * description: Code and state are required. */ router.get("/oidc/callback", async (req, res) => { - const { code, state } = req.query; - - if (!isNonEmptyString(code) || !isNonEmptyString(state)) { - return res.status(400).json({ error: "Code and state are required" }); - } - - const settingsRepository = createCurrentSettingsRepository(); - const storedBackendCallback = await settingsRepository.get( - `oidc_backend_callback_${state}`, - ); - const storedFrontendOrigin = await settingsRepository.get( - `oidc_frontend_origin_${state}`, - ); - const storedRememberMeValue = await settingsRepository.get( - `oidc_remember_me_${state}`, - ); - const storedCodeVerifier = await settingsRepository.get( - `oidc_pkce_verifier_${state}`, - ); - - if (!storedBackendCallback || !storedFrontendOrigin) { - return res - .status(400) - .json({ error: "Invalid state parameter - redirect URIs not found" }); - } - - const backendCallbackUri = storedBackendCallback; - const frontendOrigin = storedFrontendOrigin; - const storedRememberMe = storedRememberMeValue === "true"; - + let identity; try { - const storedNonce = await settingsRepository.get(`oidc_state_${state}`); - if (!storedNonce) { - return res.status(400).json({ error: "Invalid state parameter" }); + identity = await handleOidcCallback(req); + } catch (error) { + if (error instanceof RedirectLoginError) { + return redirectWithLoginError(res, error.returnTo, error); } - - const storedProviderId = await settingsRepository.get( - `oidc_provider_${state}`, - ); - const callbackProviderId = storedProviderId - ? parseInt(storedProviderId, 10) - : null; - - const providerResult = await loadProviderConfig( - callbackProviderId || undefined, - ); - if (!providerResult) { - return res.status(500).json({ error: "OIDC not configured" }); - } - const { - config, - providerType: callbackProviderType, - providerDbId: callbackProviderDbId, - } = providerResult; - - await settingsRepository.delete(`oidc_provider_${state}`); - - const caCert = config.ca_cert; - const fetchOptions = buildFetchOptions(caCert); - - // GitHub does not issue OIDC id_tokens; handle its token exchange separately - if (callbackProviderType === "github") { - const ghTokenResponse = await fetch(config.token_url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: config.client_id, - client_secret: config.client_secret, - code: code, - redirect_uri: backendCallbackUri, - }), - ...fetchOptions, - }); - - if (!ghTokenResponse.ok) { - const errorText = await ghTokenResponse.text(); - authLogger.error("GitHub token exchange failed", { - operation: "github_token_exchange_failed", - status: ghTokenResponse.status, - errorResponse: errorText, - }); - return res - .status(400) - .json({ error: "Failed to exchange authorization code" }); - } - - const ghTokenData = (await ghTokenResponse.json()) as Record< - string, - unknown - >; - await deleteOIDCStateSettings(state); - - const ghUserInfoResponse = await fetch("https://api.github.com/user", { - headers: { - Authorization: `Bearer ${ghTokenData.access_token}`, - Accept: "application/json", - "User-Agent": "Termix", - }, - ...fetchOptions, - }); - if (!ghUserInfoResponse.ok) { - return res - .status(400) - .json({ error: "Failed to get GitHub user information" }); - } - const ghUserInfo = (await ghUserInfoResponse.json()) as Record< - string, - unknown - >; - - const emailRes = await fetch("https://api.github.com/user/emails", { - headers: { - Authorization: `Bearer ${ghTokenData.access_token}`, - Accept: "application/json", - "User-Agent": "Termix", - }, - }); - if (emailRes.ok) { - const emails = (await emailRes.json()) as Array<{ - email: string; - primary: boolean; - verified: boolean; - }>; - const primary = emails.find((e) => e.primary && e.verified); - if (primary) ghUserInfo.email = primary.email; - } - - const ghIdentifier = `github:${callbackProviderDbId}:${String(ghUserInfo.id ?? ghUserInfo.login)}`; - const ghName = (ghUserInfo.name || - ghUserInfo.login || - ghIdentifier) as string; - const deviceInfo = parseUserAgent(req); - const userRepository = createCurrentUserRepository(); - - let ghUserRecord = - await userRepository.findByOidcIdentifier(ghIdentifier); - if (!ghUserRecord) { - const isFirstUser = (await userRepository.countAll()) === 0; - - if (!isFirstUser && config.allowed_users) { - const email = ghUserInfo.email as string | undefined; - if (!isOIDCUserAllowed(config.allowed_users, ghIdentifier, email)) { - const redirectUrl = new URL(frontendOrigin); - redirectUrl.searchParams.set("error", "user_not_allowed"); - return res.redirect(redirectUrl.toString()); - } - } - - let ghAutoProvision = false; - try { - ghAutoProvision = await settingsRepository.getBoolean( - "oidc_auto_provision", - false, - ); - } catch { - /* */ - } - if (!ghAutoProvision) - ghAutoProvision = - (process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === - "true"; - - if (!isFirstUser && !ghAutoProvision) { - const redirectUrl = new URL(frontendOrigin); - redirectUrl.searchParams.set("error", "registration_disabled"); - return res.redirect(redirectUrl.toString()); - } - - const ghId = nanoid(); - const createdUser = await userRepository.createFirstLocalUser({ - id: ghId, - username: ghName, - passwordHash: "", - isOidc: true, - oidcIdentifier: ghIdentifier, - ssoProviderId: callbackProviderDbId, - }); - ghUserRecord = createdUser.user; - - try { - const defaultRoleName = createdUser.isFirstUser ? "admin" : "user"; - await createCurrentRoleRepository().assignRoleNameToUser({ - userId: ghId, - roleName: defaultRoleName, - grantedBy: ghId, - }); - } catch { - /* */ - } - - try { - const sessionDurationMs = - deviceInfo.type === "desktop" || deviceInfo.type === "mobile" - ? 30 * 24 * 60 * 60 * 1000 - : 24 * 60 * 60 * 1000; - await authManager.registerOIDCUser(ghId, sessionDurationMs); - } catch { - await userRepository.delete(ghId); - return res.status(500).json({ - error: "Failed to setup user security - user creation cancelled", - }); - } - } - - try { - await authManager.authenticateOIDCUser( - ghUserRecord.id, - deviceInfo.type, - ); - } catch { - /* */ - } - await syncSharedCredentialsForUserRoles( - ghUserRecord.id, - "github_oidc_role_shared_credentials", - ); - const ghToken = await authManager.generateJWTToken(ghUserRecord.id, { - deviceType: deviceInfo.type, - deviceInfo: deviceInfo.deviceInfo, - rememberMe: storedRememberMe, - }); - const ghRedirectUrl = new URL(frontendOrigin); - ghRedirectUrl.searchParams.set("success", "true"); - const ghIsTokenCallback = isOidcTokenCallback(frontendOrigin); - const ghMaxAge = - deviceInfo.type === "desktop" || deviceInfo.type === "mobile" - ? 30 * 24 * 60 * 60 * 1000 - : storedRememberMe - ? 30 * 24 * 60 * 60 * 1000 - : 24 * 60 * 60 * 1000; - res.clearCookie("jwt", authManager.getClearCookieOptions(req)); - if (ghIsTokenCallback) { - ghRedirectUrl.searchParams.set("token", ghToken); - return res.redirect(ghRedirectUrl.toString()); - } - return res - .cookie( - "jwt", - ghToken, - authManager.getSecureCookieOptions(req, ghMaxAge), - ) - .redirect(ghRedirectUrl.toString()); - } - - const tokenResponse = await fetch(config.token_url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: config.client_id, - client_secret: config.client_secret, - code: code, - redirect_uri: backendCallbackUri, - ...(storedCodeVerifier ? { code_verifier: storedCodeVerifier } : {}), - }), - ...fetchOptions, - }); - - if (!tokenResponse.ok) { - const errorText = await tokenResponse.text(); - authLogger.error("OIDC token exchange failed", { - operation: "oidc_token_exchange_failed", - status: tokenResponse.status, - statusText: tokenResponse.statusText, - backendCallbackUri, - frontendOrigin, - errorResponse: errorText, - }); - return res - .status(400) - .json({ error: "Failed to exchange authorization code" }); - } - - const tokenData = (await tokenResponse.json()) as Record; - - await deleteOIDCStateSettings(state); - - let userInfo: Record = null; - const oidcClaimSources: Record[] = []; - const userInfoUrls: string[] = []; - - const normalizedIssuerUrl = config.issuer_url.endsWith("/") - ? config.issuer_url.slice(0, -1) - : config.issuer_url; - const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, ""); - - try { - const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; - const discoveryResponse = await fetch(discoveryUrl, fetchOptions); - if (discoveryResponse.ok) { - const discovery = (await discoveryResponse.json()) as Record< - string, - unknown - >; - if (discovery.userinfo_endpoint) { - userInfoUrls.push(discovery.userinfo_endpoint as string); - } - } - } catch (discoveryError) { - authLogger.error(`OIDC discovery failed: ${discoveryError}`); - } - - if (config.userinfo_url) { - userInfoUrls.unshift(config.userinfo_url); - } - - userInfoUrls.push( - `${baseUrl}/userinfo/`, - `${baseUrl}/userinfo`, - `${normalizedIssuerUrl}/userinfo/`, - `${normalizedIssuerUrl}/userinfo`, - `${baseUrl}/oauth2/userinfo/`, - `${baseUrl}/oauth2/userinfo`, - `${normalizedIssuerUrl}/oauth2/userinfo/`, - `${normalizedIssuerUrl}/oauth2/userinfo`, - ); - - if (tokenData.id_token) { - try { - userInfo = await verifyOIDCToken( - tokenData.id_token as string, - config.issuer_url, - config.client_id, - caCert, - ); - - const expectedNonce = storedNonce; - if (userInfo.nonce !== expectedNonce) { - authLogger.warn("OIDC ID token nonce mismatch", { - operation: "oidc_nonce_mismatch", - providerId: callbackProviderId, - }); - return res.status(401).json({ error: "Invalid OIDC token nonce" }); - } - oidcClaimSources.push(userInfo); - } catch (error) { - // A token we cannot parse as a JWS carries no claims we could trust, so - // fall through to the userinfo endpoint instead of failing the login. - // Signature and claim failures still reject: those are real rejections. - if (!(error instanceof OIDCTokenFormatError)) throw error; - - userInfo = null; - authLogger.warn( - "OIDC ID token cannot be verified, falling back to userinfo endpoint", - { - operation: "oidc_id_token_unverifiable", - providerId: callbackProviderId, - reason: error.message, - }, - ); - } - } - - if (tokenData.access_token) { - for (const userInfoUrl of userInfoUrls) { - try { - const userInfoResponse = await fetch(userInfoUrl, { - headers: { - Authorization: `Bearer ${tokenData.access_token}`, - }, - ...fetchOptions, - }); - - if (userInfoResponse.ok) { - const fetchedUserInfo = (await userInfoResponse.json()) as Record< - string, - unknown - >; - oidcClaimSources.push(fetchedUserInfo); - userInfo = { ...userInfo, ...fetchedUserInfo }; - break; - } else { - authLogger.error( - `Userinfo endpoint ${userInfoUrl} failed with status: ${userInfoResponse.status}`, - ); - } - } catch (error) { - authLogger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error); - continue; - } - } - } - - if (!userInfo) { - authLogger.error("Failed to get user information from all sources"); - authLogger.error(`Tried userinfo URLs: ${userInfoUrls.join(", ")}`); - authLogger.error(`Token data keys: ${Object.keys(tokenData).join(", ")}`); - authLogger.error(`Has id_token: ${!!tokenData.id_token}`); - authLogger.error(`Has access_token: ${!!tokenData.access_token}`); - return res.status(400).json({ error: "Failed to get user information" }); - } - - const getNestedValue = ( - obj: Record, - path: string, - ): unknown => { - if (!path || !obj) return null; - return path.split(".").reduce((current, key) => current?.[key], obj); - }; - - const identifier = (getNestedValue(userInfo, config.identifier_path) || - userInfo[config.identifier_path] || - userInfo.sub || - userInfo.email || - userInfo.preferred_username) as string; - - const name = (getNestedValue(userInfo, config.name_path) || - userInfo[config.name_path] || - userInfo.name || - userInfo.given_name || - identifier) as string; - - if (!identifier) { - authLogger.error( - `Identifier not found at path: ${config.identifier_path}`, - ); - authLogger.error(`Available fields: ${Object.keys(userInfo).join(", ")}`); - return res.status(400).json({ - error: `User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(", ")}`, - }); - } - - const deviceInfo = parseUserAgent(req); - const userRepository = createCurrentUserRepository(); - let userRecord = await userRepository.findByOidcIdentifier(identifier); - - let isFirstUser = false; - if (!userRecord) { - isFirstUser = (await userRepository.countAll()) === 0; - - if (!isFirstUser && config.allowed_users) { - const email = userInfo.email as string | undefined; - if (!isOIDCUserAllowed(config.allowed_users, identifier, email)) { - authLogger.warn("OIDC user not in allowed list", { - operation: "oidc_user_not_allowed", - identifier, - email, - }); - const redirectUrl = new URL(frontendOrigin); - redirectUrl.searchParams.set("error", "user_not_allowed"); - return res.redirect(redirectUrl.toString()); - } - } - - let oidcAutoProvision = false; - try { - oidcAutoProvision = await settingsRepository.getBoolean( - "oidc_auto_provision", - false, - ); - } catch { - // fall through to env var check - } - - if (!oidcAutoProvision) { - oidcAutoProvision = - (process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === - "true"; - } - - if (!isFirstUser && !oidcAutoProvision) { - authLogger.warn( - "OIDC user attempted to register but auto-provisioning is disabled", - { - operation: "oidc_registration_disabled", - identifier, - name, - }, - ); - const redirectUrl = new URL(frontendOrigin); - redirectUrl.searchParams.set("error", "registration_disabled"); - return res.redirect(redirectUrl.toString()); - } - - const id = nanoid(); - const createdUser = await userRepository.createFirstLocalUser({ - id, - username: name, - passwordHash: "", - isOidc: true, - oidcIdentifier: identifier, - ssoProviderId: callbackProviderDbId, - }); - isFirstUser = createdUser.isFirstUser; - userRecord = createdUser.user; - - try { - const defaultRoleName = isFirstUser ? "admin" : "user"; - const assigned = - await createCurrentRoleRepository().assignRoleNameToUser({ - userId: id, - roleName: defaultRoleName, - grantedBy: id, - }); - - if (!assigned) { - authLogger.warn( - "Default role not found during OIDC user registration", - { - operation: "assign_default_role_oidc", - userId: id, - roleName: defaultRoleName, - }, - ); - } - } catch (roleError) { - authLogger.error( - "Failed to assign default role to OIDC user", - roleError, - { - operation: "assign_default_role_oidc", - userId: id, - }, - ); - } - - try { - const sessionDurationMs = - deviceInfo.type === "desktop" || deviceInfo.type === "mobile" - ? 30 * 24 * 60 * 60 * 1000 - : 24 * 60 * 60 * 1000; - await authManager.registerOIDCUser(id, sessionDurationMs); - } catch (encryptionError) { - await userRepository.delete(id); - authLogger.error( - "Failed to setup OIDC user encryption, user creation rolled back", - encryptionError, - { - operation: "oidc_user_create_encryption_failed", - userId: id, - }, - ); - return res.status(500).json({ - error: "Failed to setup user security - user creation cancelled", - }); - } - - try { - await DatabaseSaveTrigger.forceSave("oidc_user_create_explicit_save"); - } catch (saveError) { - authLogger.error("Failed to persist OIDC user to disk", saveError, { - operation: "oidc_user_create_save_failed", - userId: id, - }); - } - } else { - if (config.allowed_users) { - const email = userInfo.email as string | undefined; - if (!isOIDCUserAllowed(config.allowed_users, identifier, email)) { - authLogger.warn("OIDC user not in allowed list (existing user)", { - operation: "oidc_user_not_allowed_existing", - identifier, - email, - userId: userRecord.id, - }); - const redirectUrl = new URL(frontendOrigin); - redirectUrl.searchParams.set("error", "user_not_allowed"); - return res.redirect(redirectUrl.toString()); - } - } - - const isDualAuth = - userRecord.passwordHash && userRecord.passwordHash.trim() !== ""; - - if (!isDualAuth) { - userRecord = - (await userRepository.update(userRecord.id, { username: name })) ?? - userRecord; - } - } - - // Sync admin status based on OIDC group membership - if (config.admin_group) { - const groups = extractOidcGroupsFromSources( - oidcClaimSources, - config.group_claim, - ); - - authLogger.info( - `Evaluating OIDC admin group sync. parsedGroups: ${JSON.stringify(groups)}, configuredAdminGroup: ${config.admin_group}, groupClaim: ${config.group_claim || "(default)"}, availableUserInfoKeys: ${Object.keys(userInfo).join(",")}`, - { - operation: "oidc_admin_group_sync_eval", - userId: userRecord.id, - }, - ); - - const shouldBeAdmin = groups.includes(config.admin_group); - if (!!userRecord.isAdmin !== shouldBeAdmin) { - authLogger.info("Syncing admin status based on OIDC group membership", { - operation: "oidc_admin_group_sync", - userId: userRecord.id, - group: config.admin_group, - isAdmin: shouldBeAdmin, - }); - userRecord = - (await userRepository.update(userRecord.id, { - isAdmin: shouldBeAdmin, - })) ?? userRecord; - try { - const newRoleName = shouldBeAdmin ? "admin" : "user"; - const oldRoleName = shouldBeAdmin ? "user" : "admin"; - await createCurrentRoleRepository().switchUserRoleName({ - userId: userRecord.id, - addRoleName: newRoleName, - removeRoleName: oldRoleName, - grantedBy: userRecord.id, - }); - } catch { - /* non-fatal */ - } - authLogger.info("OIDC admin status synced", { - operation: "oidc_admin_group_sync", - userId: userRecord.id, - group: config.admin_group, - isAdmin: shouldBeAdmin, - }); - } - } - - // Sync RBAC roles from provider group membership (OIDC_ROLE_MAP). - // - // This is what makes environment-scoped access work without hand-assigning - // roles: map a provider group to a Termix role, grant that role access to a - // set of hosts once, and membership follows the identity provider. - // - // Only roles named in the map are touched. Roles assigned by hand, and the - // admin/user pair maintained by the admin-group sync above, are never - // removed here — otherwise this would fight that block on every login. - // - // Non-fatal by design: a role-sync failure must not block a valid login. - try { - const roleMap = parseOidcRoleMap( - config.role_map ?? process.env.OIDC_ROLE_MAP, - ); - - if (roleMap.size > 0) { - const groups = extractOidcGroupsFromSources( - oidcClaimSources, - config.group_claim, - ); - const { desired, managed } = resolveOidcMappedRoles(groups, roleMap); - - const roleRepository = createCurrentRoleRepository(); - const currentRoles = await roleRepository.listUserRoles(userRecord.id); - const currentNames = new Set(currentRoles.map((r) => r.roleName)); - - const toAdd = [...desired].filter((name) => !currentNames.has(name)); - const toRemove = currentRoles.filter( - (r) => managed.has(r.roleName) && !desired.has(r.roleName), - ); - - authLogger.info( - `Evaluating OIDC role map sync. parsedGroups: ${JSON.stringify(groups)}, desiredRoles: ${JSON.stringify([...desired])}, managedRoles: ${JSON.stringify([...managed])}, groupClaim: ${config.group_claim || "(default)"}`, - { - operation: "oidc_role_map_sync_eval", - userId: userRecord.id, - }, - ); - - for (const roleName of toAdd) { - const assigned = await roleRepository.assignRoleNameToUser({ - userId: userRecord.id, - roleName, - grantedBy: userRecord.id, - }); - if (!assigned) { - authLogger.warn( - "OIDC role map references a role that does not exist", - { - operation: "oidc_role_map_missing_role", - userId: userRecord.id, - roleName, - }, - ); - } - } - - for (const role of toRemove) { - await roleRepository.removeRoleFromUser(userRecord.id, role.roleId); - } - - if (toAdd.length > 0 || toRemove.length > 0) { - authLogger.info("OIDC roles synced from group membership", { - operation: "oidc_role_map_sync", - userId: userRecord.id, - added: toAdd, - removed: toRemove.map((r) => r.roleName), - }); - // Host access is resolved through cached role permissions; drop the - // cache so the new roles apply to this session immediately. - PermissionManager.getInstance().invalidateUserPermissionCache( - userRecord.id, - ); - } - } - } catch (roleSyncError) { - authLogger.error("Failed to sync OIDC roles", roleSyncError, { - operation: "oidc_role_map_sync_failed", - userId: userRecord.id, - }); - } - - try { - await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type); - } catch (setupError) { - authLogger.error("Failed to setup OIDC user encryption", setupError, { - operation: "oidc_user_encryption_setup_failed", - userId: userRecord.id, - }); - } - - await syncSharedCredentialsForUserRoles( - userRecord.id, - "oidc_role_shared_credentials", - ); - - const oidcSub = typeof userInfo.sub === "string" ? userInfo.sub : null; - const oidcSid = typeof userInfo.sid === "string" ? userInfo.sid : null; - - const token = await authManager.generateJWTToken(userRecord.id, { - deviceType: deviceInfo.type, - deviceInfo: deviceInfo.deviceInfo, - rememberMe: storedRememberMe, - oidcSub, - oidcSid, - ssoProviderId: callbackProviderDbId ?? null, - }); - - authLogger.success("OIDC login successful", { - operation: "oidc_login_complete", - userId: userRecord.id, - username: userRecord.username, - }); - - const redirectUrl = new URL(frontendOrigin); - redirectUrl.searchParams.set("success", "true"); - - const isTokenCallback = isOidcTokenCallback(frontendOrigin); - - const maxAge = - deviceInfo.type === "desktop" || deviceInfo.type === "mobile" - ? 30 * 24 * 60 * 60 * 1000 - : storedRememberMe - ? 30 * 24 * 60 * 60 * 1000 - : 24 * 60 * 60 * 1000; - - res.clearCookie("jwt", authManager.getClearCookieOptions(req)); - - if (isTokenCallback) { - redirectUrl.searchParams.set("token", token); - return res.redirect(redirectUrl.toString()); - } - - return res - .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) - .redirect(redirectUrl.toString()); - } catch (err) { - authLogger.error("OIDC callback failed", err); - - const redirectUrl = new URL(frontendOrigin); - redirectUrl.searchParams.set("error", "OIDC authentication failed"); - - res.redirect(redirectUrl.toString()); + return sendLoginError(res, error); } + await respondWithRedirectLogin( + req, + res, + identity, + { methodId: "oidc", rememberMe: !!identity.rememberMe }, + isOidcTokenCallback, + ); }); /** * @openapi - * /users/login: + * /users/proxy-login: * post: - * summary: User login - * description: Authenticates a user and returns a JWT. + * summary: Trusted proxy login + * description: Signs in the user named by a trusted reverse proxy's headers. Only answers requests from a configured proxy address. * tags: * - Users - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * username: - * type: string - * password: - * type: string * responses: * 200: * description: Login successful. - * 400: - * description: Invalid username or password. * 401: - * description: Invalid username or password. + * description: Proxy headers missing. * 403: - * description: Password authentication is currently disabled. - * 429: - * description: Too many login attempts. - * 500: - * description: Login failed. + * description: Not a trusted proxy, or the user is not allowed. + * 409: + * description: Trusted proxy login conflicts with OIDC or 2FA. + * 503: + * description: Trusted proxy authentication is misconfigured. */ router.post("/proxy-login", async (req, res) => { let config; @@ -1754,206 +848,52 @@ router.post("/proxy-login", async (req, res) => { } }); +/** + * @openapi + * /users/login: + * post: + * summary: User login + * description: Authenticates a user and returns a JWT. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * username: + * type: string + * password: + * type: string + * responses: + * 200: + * description: Login successful. + * 400: + * description: Invalid username or password. + * 401: + * description: Invalid username or password. + * 403: + * description: Password authentication is currently disabled. + * 429: + * description: Too many login attempts. + * 500: + * description: Login failed. + */ router.post("/login", async (req, res) => { - if (isTrustedProxyAuthEnabled()) { - return res.status(403).json({ - error: - "Password login is disabled while trusted proxy authentication is enabled", - }); - } - const { username, password, rememberMe } = req.body; - const clientIp = req.ip || req.socket.remoteAddress || "unknown"; authLogger.info("User login request received", { operation: "user_login_request", - username, + username: req.body?.username, }); - - if (!isNonEmptyString(username) || !isNonEmptyString(password)) { - authLogger.warn("Invalid traditional login attempt", { - operation: "user_login", - hasUsername: !!username, - hasPassword: !!password, - }); - return res.status(400).json({ error: "Invalid username or password" }); - } - - const lockStatus = loginRateLimiter.isLocked(clientIp, username); - if (lockStatus.locked) { - authLogger.warn("Login attempt blocked due to rate limiting", { - operation: "user_login_blocked", - username, - ip: clientIp, - remainingTime: lockStatus.remainingTime, - }); - return res.status(429).json({ - error: "Too many login attempts. Please try again later.", - remainingTime: lockStatus.remainingTime, - }); - } - - if (!isPasswordLoginAllowed()) { - return res - .status(403) - .json({ error: "Password authentication is currently disabled" }); - } - try { - const userRecord = - await createCurrentUserRepository().findByUsername(username); - - if (!userRecord) { - loginRateLimiter.recordFailedAttempt(clientIp, username); - authLogger.warn(`Login failed: user not found`, { - operation: "user_login", - username, - ip: clientIp, - remainingAttempts: loginRateLimiter.getRemainingAttempts( - clientIp, - username, - ), - }); - return res.status(401).json({ error: "Invalid username or password" }); - } - - if ( - userRecord.isOidc && - (!userRecord.passwordHash || userRecord.passwordHash.trim() === "") - ) { - authLogger.warn("OIDC-only user attempted traditional login", { - operation: "user_login", - username, - userId: userRecord.id, - }); - return res - .status(403) - .json({ error: "This user uses external authentication" }); - } - - const isMatch = await bcrypt.compare(password, userRecord.passwordHash); - if (!isMatch) { - loginRateLimiter.recordFailedAttempt(clientIp, username); - authLogger.warn(`Login failed: incorrect password`, { - operation: "user_login", - username, - userId: userRecord.id, - ip: clientIp, - remainingAttempts: loginRateLimiter.getRemainingAttempts( - clientIp, - username, - ), - }); - return res.status(401).json({ error: "Invalid username or password" }); - } - - const deviceInfo = parseUserAgent(req); - - let authenticated = false; - if (userRecord.isOidc) { - authenticated = await authManager.authenticateOIDCUser( - userRecord.id, - deviceInfo.type, - ); - } else { - authenticated = await authManager.authenticateUser( - userRecord.id, - password, - deviceInfo.type, - ); - } - - if (!authenticated) { - return res.status(401).json({ error: "Incorrect password" }); - } - - await syncSharedCredentialsForUserRoles( - userRecord.id, - "login_role_shared_credentials", - ); - - if (userRecord.totpEnabled) { - const deviceFingerprint = generateDeviceFingerprint( - deviceInfo, - getDeviceId(req), - ); - - const isTrusted = deviceFingerprint - ? await authManager.isTrustedDevice(userRecord.id, deviceFingerprint) - : false; - - if (isTrusted) { - authLogger.info("TOTP bypassed for trusted device", { - operation: "totp_bypass", - userId: userRecord.id, - deviceFingerprint, - }); - } else { - const tempToken = await authManager.generateJWTToken(userRecord.id, { - pendingTOTP: true, - expiresIn: "10m", - }); - return res.json({ - success: true, - requires_totp: true, - temp_token: tempToken, - rememberMe: !!rememberMe, - }); - } - } - - const token = await authManager.generateJWTToken(userRecord.id, { - rememberMe: !!rememberMe, - deviceType: deviceInfo.type, - deviceInfo: deviceInfo.deviceInfo, + const identity = await verifyPasswordLogin(req); + await respondWithLogin(req, res, identity, { + methodId: "password", + rememberMe: !!req.body?.rememberMe, }); - - loginRateLimiter.resetAttempts(clientIp, username); - - const payload = await authManager.verifyJWTToken(token); - authLogger.success("User login successful", { - operation: "user_login_complete", - userId: userRecord.id, - username, - sessionId: payload?.sessionId, - }); - - const { ipAddress: loginIp, userAgent: loginUa } = getRequestMeta(req); - await logAudit({ - userId: userRecord.id, - username, - action: "login", - resourceType: "session", - ipAddress: loginIp, - userAgent: loginUa, - success: true, - }); - - notifyAutomationInternalEvent("user_login", userRecord.id, undefined, { - username, - ipAddress: loginIp, - }); - - const response: Record = { - success: true, - is_admin: !!userRecord.isAdmin, - username: userRecord.username, - ...(isNativeAppRequest(req) ? { token } : {}), - }; - - const sessionTimeoutHoursValue = - await createCurrentSettingsRepository().get("session_timeout_hours"); - const timeoutHours = sessionTimeoutHoursValue - ? parseInt(sessionTimeoutHoursValue, 10) || 24 - : 24; - const maxAge = rememberMe - ? 30 * 24 * 60 * 60 * 1000 - : timeoutHours * 60 * 60 * 1000; - - return res - .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) - .json(response); - } catch (err) { - authLogger.error("Failed to log in user", err); - return res.status(500).json({ error: "Login failed" }); + } catch (error) { + sendLoginError(res, error); } }); @@ -2589,7 +1529,8 @@ router.patch( */ router.get("/password-login-allowed", async (req, res) => { try { - res.json({ allowed: isPasswordLoginAllowed() }); + const status = await getPasswordLoginStatus(); + res.json({ allowed: status.allowed, forced: status.forced }); } catch (err) { authLogger.error("Failed to get password login allowed", err); res.status(500).json({ error: "Failed to get password login allowed" }); @@ -3066,5 +2007,6 @@ registerBrandingRoutes(router, requireAdmin); registerSSOProviderRoutes(router); registerLDAPAuthRoutes(router); +registerAuthRoutes(router); export default router; diff --git a/src/backend/hosts/auth-manager.ts b/src/backend/hosts/auth-manager.ts deleted file mode 100644 index 411e644d9..000000000 --- a/src/backend/hosts/auth-manager.ts +++ /dev/null @@ -1,452 +0,0 @@ -import type { WebSocket } from "ws"; -import { resolveExternalSecretRefs } from "./external-secrets.js"; -import { sshLogger, authLogger } from "../utils/logger.js"; -import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js"; -interface ResolvedCredentials { - username: string; - password?: string; - key?: Buffer; - keyPassword?: string; - authType?: string; - certPublicKey?: string; -} - -interface HostConfig { - id: number; - ip: string; - port: number; - username: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - authType?: string; - useWarpgate?: boolean; - credentialId?: number; - userId?: string; - forceKeyboardInteractive?: boolean; -} - -interface AuthContext { - userId: string; - ws: WebSocket; - hostId: number; - isKeyboardInteractive: boolean; - keyboardInteractiveResponded: boolean; - keyboardInteractiveFinish: ((responses: string[]) => void) | null; - totpPromptSent: boolean; - warpgateAuthPromptSent: boolean; - totpTimeout: NodeJS.Timeout | null; - warpgateAuthTimeout: NodeJS.Timeout | null; - totpAttempts: number; -} - -export class SSHAuthManager { - public context: AuthContext; - - constructor(context: AuthContext) { - this.context = context; - } - - async resolveCredentials( - hostConfig: HostConfig, - ): Promise { - let resolvedCredentials: ResolvedCredentials = { - username: hostConfig.username, - authType: hostConfig.authType || "none", - }; - - if (hostConfig.credentialId) { - const cred = - await createCurrentHostResolutionRepository().findCredentialByIdForUser( - hostConfig.credentialId, - this.context.userId, - ); - - if (cred) { - // Credentials may hold secret references instead of secrets. - await resolveExternalSecretRefs( - cred as unknown as Record, - this.context.userId, - ); - resolvedCredentials = { - username: (cred.username as string) || hostConfig.username, - password: (cred.password as string) || undefined, - key: - cred.key || cred.privateKey - ? Buffer.from((cred.key || cred.privateKey) as string) - : undefined, - keyPassword: (cred.keyPassword as string) || undefined, - authType: (cred.authType as string) || "none", - certPublicKey: (cred.certPublicKey as string) || undefined, - }; - } - } else { - if (hostConfig.password) { - resolvedCredentials.password = hostConfig.password; - resolvedCredentials.authType = "password"; - } - - if (hostConfig.key) { - resolvedCredentials.key = Buffer.from(hostConfig.key, "utf8"); - resolvedCredentials.authType = "key"; - - if (hostConfig.keyPassword) { - resolvedCredentials.keyPassword = hostConfig.keyPassword; - } - } - } - - return resolvedCredentials; - } - - handleKeyboardInteractive( - name: string, - instructions: string, - instructionsLang: string, - prompts: Array<{ prompt: string; echo: boolean }>, - finish: (responses: string[]) => void, - resolvedCredentials: ResolvedCredentials, - hostConfig?: HostConfig, - ): void { - this.context.isKeyboardInteractive = true; - const promptTexts = prompts.map((p) => p.prompt); - - const warpgatePattern = /warpgate\s+authentication/i; - const isWarpgate = - warpgatePattern.test(name) || - warpgatePattern.test(instructions) || - promptTexts.some((p) => warpgatePattern.test(p)); - - if (isWarpgate) { - this.handleWarpgateAuth(name, instructions, promptTexts, finish); - return; - } - - // FortiToken-style prompt: asks the user to type a code OR the literal - // word "push" (e.g. "Enter your Token or type 'push' to receive a push - // notification:"). Unlike JumpCloud/DUO's confirm-only push, this needs a - // text field, so it must not fall into the empty-answerable push pattern - // below. Checked first since its text also contains "push notification". - const fortiTokenPattern = /type\s+['"]?push['"]?/i; - const isFortiTokenPrompt = promptTexts.some((p) => - fortiTokenPattern.test(p), - ); - - // JumpCloud Protect / DUO-style push MFA: a menu choice ("Choose [1] Push, - // or [2] TOTP:") followed by an empty-answerable confirm ("Press enter to - // send Push request:"). Checked before the TOTP regex because the menu - // prompt's own text ("...or [2] TOTP:") would otherwise match it and get - // misrouted into the numeric-code flow. - const pushPromptPattern = - /choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i; - const isPushPrompt = - !isFortiTokenPrompt && promptTexts.some((p) => pushPromptPattern.test(p)); - - if (isPushPrompt) { - sshLogger.info("Push/menu MFA prompt detected", { - operation: "ssh_keyboard_interactive_push", - hostId: this.context.hostId, - }); - this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig); - return; - } - - const totpPromptIndex = prompts.findIndex((p) => - /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( - p.prompt, - ), - ); - - if (totpPromptIndex !== -1) { - this.handleTotpAuth( - prompts, - totpPromptIndex, - finish, - resolvedCredentials, - ); - return; - } - - this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig); - } - - private handleWarpgateAuth( - name: string, - instructions: string, - promptTexts: string[], - finish: (responses: string[]) => void, - ): void { - const fullText = `${name}\n${instructions}\n${promptTexts.join("\n")}`; - - const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i); - const keyMatch = fullText.match( - /security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i, - ); - - if (urlMatch) { - this.context.keyboardInteractiveFinish = () => { - finish([""]); - }; - - this.context.warpgateAuthPromptSent = true; - - this.sendLog("auth", "info", "Warpgate authentication required"); - - this.context.ws.send( - JSON.stringify({ - type: "warpgate_auth_required", - url: urlMatch[0], - securityKey: keyMatch ? keyMatch[1] : "N/A", - instructions: instructions, - }), - ); - - this.context.warpgateAuthTimeout = setTimeout(() => { - if (this.context.keyboardInteractiveFinish) { - this.context.keyboardInteractiveFinish = null; - this.context.warpgateAuthPromptSent = false; - sshLogger.warn("Warpgate authentication timeout", { - operation: "warpgate_timeout", - hostId: this.context.hostId, - }); - this.context.ws.send( - JSON.stringify({ - type: "error", - message: "Warpgate authentication timeout. Please reconnect.", - }), - ); - } - }, 300000); - } - } - - private handleTotpAuth( - prompts: Array<{ prompt: string; echo: boolean }>, - totpPromptIndex: number, - finish: (responses: string[]) => void, - resolvedCredentials: ResolvedCredentials, - ): void { - if (this.context.totpPromptSent) { - sshLogger.warn("TOTP prompt asked again - invalid code", { - operation: "ssh_keyboard_interactive_totp_retry", - hostId: this.context.hostId, - }); - authLogger.warn("TOTP verification failed for SSH session", { - operation: "terminal_totp_failed", - userId: this.context.userId, - hostId: this.context.hostId, - }); - - this.sendLog("auth", "warning", "Invalid TOTP code"); - - this.context.ws.send( - JSON.stringify({ - type: "totp_retry", - }), - ); - return; - } - - this.context.totpPromptSent = true; - this.context.keyboardInteractiveResponded = true; - - this.context.keyboardInteractiveFinish = (totpResponses: string[]) => { - const totpCode = (totpResponses[0] || "").trim(); - - const responses = prompts.map((p, index) => { - if (index === totpPromptIndex) { - return totpCode; - } - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - - finish(responses); - }; - - if (this.context.totpTimeout) { - clearTimeout(this.context.totpTimeout); - } - - this.context.totpTimeout = setTimeout(() => { - if (this.context.keyboardInteractiveFinish) { - this.context.keyboardInteractiveFinish = null; - this.context.totpPromptSent = false; - sshLogger.warn("TOTP prompt timeout", { - operation: "totp_timeout", - hostId: this.context.hostId, - }); - this.context.ws.send( - JSON.stringify({ - type: "error", - message: "TOTP verification timeout. Please reconnect.", - }), - ); - } - }, 180000); - - this.sendLog("auth", "info", "TOTP verification required"); - authLogger.info("TOTP verification prompt sent to client", { - operation: "terminal_totp_prompt", - userId: this.context.userId, - hostId: this.context.hostId, - }); - - this.context.ws.send( - JSON.stringify({ - type: "totp_required", - prompt: prompts[totpPromptIndex].prompt, - }), - ); - } - - private handlePasswordAuth( - prompts: Array<{ prompt: string; echo: boolean }>, - finish: (responses: string[]) => void, - resolvedCredentials: ResolvedCredentials, - hostConfig?: HostConfig, - ): void { - // For Warpgate hosts: auto-answer password prompts silently using stored credentials. - // Warpgate sends a password prompt before its browser-verification round; we must - // not show a UI prompt here -- the WarpgateDialog handles user interaction later. - if (hostConfig?.useWarpgate) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password as string; - } - return ""; - }); - finish(responses); - return; - } - - const hasStoredPassword = - resolvedCredentials.password && resolvedCredentials.authType !== "none"; - - const passwordPromptIndex = prompts.findIndex((p) => - /password/i.test(p.prompt), - ); - - // Find the first prompt we can't auto-answer. This handles DUO/PAM challenges - // that don't say "password" (e.g. "Passcode or option (1-N):"). - const firstUnansweredIndex = prompts.findIndex((p) => { - if (/password/i.test(p.prompt) && hasStoredPassword) return false; - return true; - }); - - if (firstUnansweredIndex !== -1) { - if (this.context.keyboardInteractiveResponded) { - return; - } - this.context.keyboardInteractiveResponded = true; - - const promptIndex = - passwordPromptIndex !== -1 && !hasStoredPassword - ? passwordPromptIndex - : firstUnansweredIndex; - - const pushPromptPattern = - /choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i; - const isPushPrompt = pushPromptPattern.test(prompts[promptIndex].prompt); - - this.context.keyboardInteractiveFinish = (userResponses: string[]) => { - const userInput = (userResponses[0] || "").trim(); - - const responses = prompts.map((p, index) => { - if (index === promptIndex) { - return userInput; - } - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - - finish(responses); - }; - - if (this.context.totpTimeout) { - clearTimeout(this.context.totpTimeout); - } - - this.context.totpTimeout = setTimeout( - () => { - if (this.context.keyboardInteractiveFinish) { - this.context.keyboardInteractiveFinish = null; - this.context.keyboardInteractiveResponded = false; - sshLogger.warn("Password prompt timeout", { - operation: "password_timeout", - hostId: this.context.hostId, - }); - this.context.ws.send( - JSON.stringify({ - type: "error", - message: "Password verification timeout. Please reconnect.", - }), - ); - } - }, - isPushPrompt ? 300000 : 180000, - ); - - this.sendLog("auth", "info", "Password authentication required"); - - this.context.ws.send( - JSON.stringify({ - type: "password_required", - prompt: prompts[promptIndex].prompt, - echo: prompts[promptIndex].echo, - }), - ); - return; - } - - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - - finish(responses); - } - - sendLog( - stage: string, - level: string, - message: string, - details?: Record, - ): void { - this.context.ws.send( - JSON.stringify({ - type: "connection_log", - data: { - stage, - level, - message, - details, - }, - }), - ); - } - - cleanup(): void { - if (this.context.totpTimeout) { - clearTimeout(this.context.totpTimeout); - this.context.totpTimeout = null; - } - - if (this.context.warpgateAuthTimeout) { - clearTimeout(this.context.warpgateAuthTimeout); - this.context.warpgateAuthTimeout = null; - } - - this.context.keyboardInteractiveFinish = null; - this.context.totpPromptSent = false; - this.context.warpgateAuthPromptSent = false; - this.context.keyboardInteractiveResponded = false; - } -} diff --git a/src/backend/hosts/connect/auth-provider-registry.ts b/src/backend/hosts/connect/auth-provider-registry.ts new file mode 100644 index 000000000..e9160b195 --- /dev/null +++ b/src/backend/hosts/connect/auth-provider-registry.ts @@ -0,0 +1,122 @@ +/** + * Every SSH auth type the server knows how to connect with. + * + * Core registers password, key, credential, agent and none. The rest come from + * plugins through ctx.auth.registerSshAuthProvider, or for 2.9.0 from + * src/backend/auth/legacy-providers.ts. A type with no provider fails the + * connect with a message naming the plugin that would provide it. + */ + +import type { + KeyboardInteractiveInterceptor, + SshAuthProvider, +} from "./types.js"; + +const providers = new Map(); +const interceptors = new Map(); + +export interface SshAuthTypeOwner { + pluginId: string; + pluginName: string; +} + +/** + * Which plugin declares which auth type, from every manifest on disk + * including disabled ones. Set by the plugin runtime. + */ +let ownerSource: () => Array = () => []; + +export function setSshAuthTypeOwnerSource( + source: () => Array, +): void { + ownerSource = source; +} + +export function listSshAuthTypeOwners(): Array< + SshAuthTypeOwner & { type: string } +> { + return ownerSource(); +} + +export function findSshAuthTypeOwner(type: string): SshAuthTypeOwner | null { + const owner = ownerSource().find((entry) => entry.type === type); + return owner + ? { pluginId: owner.pluginId, pluginName: owner.pluginName } + : null; +} + +export class SshAuthProviderMissingError extends Error { + readonly code = "SSH_AUTH_PROVIDER_MISSING"; + constructor( + readonly authType: string, + readonly owner: SshAuthTypeOwner | null, + ) { + super( + owner + ? `This host uses ${authType}, which needs the ${owner.pluginName} plugin` + : `This host uses ${authType}, which no enabled plugin provides`, + ); + this.name = "SshAuthProviderMissingError"; + } +} + +export function registerSshAuthProvider(provider: SshAuthProvider): () => void { + const existing = providers.get(provider.type); + if (existing && existing.pluginId !== provider.pluginId) { + throw new Error( + `SSH auth type "${provider.type}" is already provided by ${existing.pluginId}`, + ); + } + providers.set(provider.type, provider); + return () => { + if (providers.get(provider.type) === provider) { + providers.delete(provider.type); + } + }; +} + +export function getSshAuthProvider(type: string): SshAuthProvider | undefined { + return providers.get(type); +} + +/** Throws SshAuthProviderMissingError when nothing provides the type. */ +export function requireSshAuthProvider(type: string): SshAuthProvider { + const provider = providers.get(type); + if (!provider) { + throw new SshAuthProviderMissingError(type, findSshAuthTypeOwner(type)); + } + return provider; +} + +export function listSshAuthProviders(): SshAuthProvider[] { + return [...providers.values()]; +} + +/** Types a stored credential may use. */ +export function listCredentialTypes(): string[] { + return listSshAuthProviders() + .filter((provider) => provider.credentialType) + .map((provider) => provider.type); +} + +export function registerKeyboardInteractiveInterceptor( + interceptor: KeyboardInteractiveInterceptor, +): () => void { + interceptors.set(interceptor.id, interceptor); + return () => { + if (interceptors.get(interceptor.id) === interceptor) { + interceptors.delete(interceptor.id); + } + }; +} + +export function listKeyboardInteractiveInterceptors(): KeyboardInteractiveInterceptor[] { + return [...interceptors.values()]; +} + +/** Test helper. */ +export function resetSshAuthRegistryForTests(): void { + providers.clear(); + interceptors.clear(); + ownerSource = () => []; +} diff --git a/src/backend/hosts/connect/build-connect-config.ts b/src/backend/hosts/connect/build-connect-config.ts new file mode 100644 index 000000000..b50ef2280 --- /dev/null +++ b/src/backend/hosts/connect/build-connect-config.ts @@ -0,0 +1,291 @@ +import type { Client } from "ssh2"; +import type { WebSocket } from "ws"; +import { buildSSHAlgorithms } from "../../utils/ssh-algorithms.js"; +import { SSHHostKeyVerifier } from "../host-key-verifier.js"; +import { resolveSshKeepalive } from "../ssh-keepalive.js"; +import { + SshAuthProviderMissingError, + requireSshAuthProvider, +} from "./auth-provider-registry.js"; +import { ensureCoreSshAuthProviders } from "./core-providers.js"; +import type { + MutableConnectConfig, + SshAuthEnv, + SshAuthLog, + SshAuthOutcome, + SshAuthProvider, + SshConnectHost, + SshConnectPurpose, +} from "./types.js"; + +interface PurposeDefaults { + keepaliveIntervalMs: number; + keepaliveCountMax: number; + /** Let terminalConfig.keepalive* override the defaults. */ + hostKeepalive: boolean; + readyTimeout: number; + /** Socket idle timeout, when the transport set one. */ + socketTimeout?: number; + env: Record | null; +} + +const TERMINAL_ENV: Record = { + TERM: "xterm-256color", + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + LC_CTYPE: "en_US.UTF-8", + LC_MESSAGES: "en_US.UTF-8", + LC_MONETARY: "en_US.UTF-8", + LC_NUMERIC: "en_US.UTF-8", + LC_TIME: "en_US.UTF-8", + LC_COLLATE: "en_US.UTF-8", + COLORTERM: "truecolor", +}; + +const BASIC_ENV: Record = { + TERM: "xterm-256color", + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", +}; + +// The values each transport used before it moved onto the pipeline. +const PURPOSE_DEFAULTS: Record = { + terminal: { + keepaliveIntervalMs: 30000, + keepaliveCountMax: 5, + hostKeepalive: true, + readyTimeout: 120000, + socketTimeout: 120000, + env: TERMINAL_ENV, + }, + "file-manager": { + keepaliveIntervalMs: 60000, + keepaliveCountMax: 5, + hostKeepalive: true, + readyTimeout: 60000, + env: TERMINAL_ENV, + }, + "file-transfer": { + keepaliveIntervalMs: 30000, + keepaliveCountMax: 120, + hostKeepalive: false, + readyTimeout: 60000, + env: null, + }, + tmux: { + keepaliveIntervalMs: 30000, + keepaliveCountMax: 3, + hostKeepalive: false, + readyTimeout: 60000, + env: null, + }, + metrics: { + keepaliveIntervalMs: 30000, + keepaliveCountMax: 3, + hostKeepalive: false, + readyTimeout: 30000, + env: BASIC_ENV, + }, + fleet: { + keepaliveIntervalMs: 30000, + keepaliveCountMax: 3, + hostKeepalive: false, + readyTimeout: 30000, + env: BASIC_ENV, + }, + tunnel: { + keepaliveIntervalMs: 30000, + keepaliveCountMax: 3, + hostKeepalive: true, + readyTimeout: 60000, + env: null, + }, + docker: { + keepaliveIntervalMs: 60000, + keepaliveCountMax: 5, + hostKeepalive: true, + readyTimeout: 60000, + env: TERMINAL_ENV, + }, + "docker-console": { + keepaliveIntervalMs: 30000, + keepaliveCountMax: 120, + hostKeepalive: false, + readyTimeout: 60000, + env: null, + }, + proxmox: { + keepaliveIntervalMs: 30000, + keepaliveCountMax: 3, + hostKeepalive: false, + readyTimeout: 20000, + env: null, + }, + snippet: { + keepaliveIntervalMs: 30000, + keepaliveCountMax: 3, + hostKeepalive: false, + readyTimeout: 30000, + env: null, + }, + "credential-deploy": { + keepaliveIntervalMs: 30000, + keepaliveCountMax: 3, + hostKeepalive: false, + readyTimeout: 30000, + env: null, + }, + "jump-host": { + keepaliveIntervalMs: 30000, + keepaliveCountMax: 3, + hostKeepalive: false, + readyTimeout: 60000, + env: null, + }, + "remote-desktop": { + keepaliveIntervalMs: 30000, + keepaliveCountMax: 3, + hostKeepalive: false, + readyTimeout: 60000, + env: null, + }, + plugin: { + keepaliveIntervalMs: 30000, + keepaliveCountMax: 3, + hostKeepalive: false, + readyTimeout: 30000, + env: BASIC_ENV, + }, +}; + +export function getPurposeDefaults( + purpose: SshConnectPurpose, +): PurposeDefaults { + return PURPOSE_DEFAULTS[purpose]; +} + +export interface BuildConnectConfigOptions { + /** Acting user: host key trust, certificates and tokens are per user. */ + userId: string; + purpose: SshConnectPurpose; + client: Client; + /** Server-side host id when it differs from host.id (sync-id lookups). */ + serverHostId?: number; + /** Terminal socket for the changed-host-key prompt. */ + hostKeySocket?: WebSocket | null; + interactive?: boolean; + log?: SshAuthLog; + /** Applied after the purpose defaults and before auth is prepared. */ + overrides?: Partial; +} + +export interface BuiltConnectConfig { + config: MutableConnectConfig; + provider: SshAuthProvider | null; + outcome: SshAuthOutcome; + env: SshAuthEnv; +} + +const noopLog: SshAuthLog = () => {}; + +export function stripIpv6Brackets(ip: string): string { + return ip?.replace(/^\[|\]$/g, "") || ip; +} + +/** + * Builds the ssh2 config for one host: base settings for the purpose, the + * host verifier, then the auth provider's prepare step. Never connects. + */ +export async function buildConnectConfig( + host: SshConnectHost, + options: BuildConnectConfigOptions, +): Promise { + ensureCoreSshAuthProviders(); + + const defaults = PURPOSE_DEFAULTS[options.purpose]; + const authType = host.authType || "none"; + const serverHostId = options.serverHostId ?? host.id; + const isJumpHost = options.purpose === "jump-host"; + const terminalConfig = (host.terminalConfig ?? undefined) as + Record | undefined; + + const keepalive = defaults.hostKeepalive + ? resolveSshKeepalive( + terminalConfig?.keepaliveInterval as number | undefined, + terminalConfig?.keepaliveCountMax as number | undefined, + defaults.keepaliveIntervalMs, + defaults.keepaliveCountMax, + ) + : { + keepaliveInterval: defaults.keepaliveIntervalMs, + keepaliveCountMax: defaults.keepaliveCountMax, + }; + + const preloaded = serverHostId + ? await SSHHostKeyVerifier.preloadHostData(serverHostId) + : null; + + const config: MutableConnectConfig = { + host: stripIpv6Brackets(host.ip), + port: host.port || 22, + username: host.username, + // A jump hop with no auth never answered prompts before either. + tryKeyboard: isJumpHost ? authType !== "none" : true, + ...keepalive, + readyTimeout: defaults.readyTimeout, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + hostVerifier: await SSHHostKeyVerifier.createHostVerifier( + serverHostId, + host.ip, + host.port || 22, + options.hostKeySocket ?? null, + options.userId, + isJumpHost, + preloaded, + ), + algorithms: buildSSHAlgorithms( + terminalConfig?.allowLegacyAlgorithms !== false, + ), + }; + if (defaults.env) config.env = { ...defaults.env }; + if (defaults.socketTimeout) config.timeout = defaults.socketTimeout; + + const env: SshAuthEnv = { + client: options.client, + userId: options.userId, + hostId: serverHostId, + purpose: options.purpose, + interactive: options.interactive ?? false, + log: options.log ?? noopLog, + }; + + let provider: SshAuthProvider; + try { + provider = requireSshAuthProvider(authType); + } catch (error) { + if (error instanceof SshAuthProviderMissingError) { + return { + config, + provider: null, + env, + outcome: { + status: "error", + code: "provider-missing", + message: error.message, + }, + }; + } + throw error; + } + + if (provider.connectOptions) { + Object.assign(config, provider.connectOptions(host, options.purpose)); + } + if (options.overrides) { + Object.assign(config, options.overrides); + } + + const outcome = await provider.prepare(config, host, env); + return { config, provider, outcome, env }; +} diff --git a/src/backend/hosts/connect/builtin-providers.ts b/src/backend/hosts/connect/builtin-providers.ts new file mode 100644 index 000000000..4f7cb57bf --- /dev/null +++ b/src/backend/hosts/connect/builtin-providers.ts @@ -0,0 +1,213 @@ +/** + * The SSH auth types core always provides: password, key, credential, agent + * and none. Everything else is a plugin (or, in 2.9.0, a legacy provider). + */ + +import ssh2 from "ssh2"; +import { getErrorMessage } from "../../utils/error-message.js"; +import { + isPrivateKeyPassphraseError, + preparePrivateKeyForSSH2, +} from "../../utils/ssh-key-utils.js"; +import { applyAgentAuth } from "../terminal-auth-helpers.js"; +import { registerSshAuthProvider } from "./auth-provider-registry.js"; +import type { + MutableConnectConfig, + SshAuthEnv, + SshAuthOutcome, + SshAuthProvider, + SshConnectHost, +} from "./types.js"; + +function keyText(key: SshConnectHost["key"]): string { + if (!key) return ""; + return Buffer.isBuffer(key) ? key.toString("utf8") : key; +} + +async function preparePassword( + config: MutableConnectConfig, + host: SshConnectHost, + env: SshAuthEnv, +): Promise { + if (!host.password) { + return { + status: "error", + code: "missing-secret", + message: "Password authentication requested but no password provided", + }; + } + // Forced keyboard-interactive answers the password prompt from the same + // value instead of offering it up front. + if (!host.forceKeyboardInteractive) { + config.password = host.password; + } + env.log("info", "Using password authentication"); + return { status: "ready" }; +} + +async function prepareKey( + config: MutableConnectConfig, + host: SshConnectHost, + env: SshAuthEnv, +): Promise { + const key = keyText(host.key); + if (!key.trim()) { + return { + status: "error", + code: "missing-secret", + message: "SSH key authentication requested but no key provided", + }; + } + + const passphrase = host.keyPassword || undefined; + try { + config.privateKey = preparePrivateKeyForSSH2(key, passphrase); + } catch (error) { + if (isPrivateKeyPassphraseError(error)) { + return { + status: "error", + code: "passphrase-required", + message: + "The SSH key is encrypted. Please enter the passphrase to unlock it.", + }; + } + return { + status: "error", + code: "invalid-key", + message: `SSH key format error: ${getErrorMessage(error, "Invalid private key format")}`, + }; + } + + // A key that parses in our own helper can still be encrypted with no + // passphrase given; ssh2 only notices at connect time otherwise. + const parsed = ssh2.utils.parseKey(config.privateKey as Buffer, passphrase); + if (parsed instanceof Error) { + if (isPrivateKeyPassphraseError(parsed)) { + return { + status: "error", + code: "passphrase-required", + message: + "The SSH key is encrypted. Please enter the passphrase to unlock it.", + }; + } + return { + status: "error", + code: "invalid-key", + message: `SSH key format error: ${parsed.message}`, + }; + } + + if (passphrase) config.passphrase = passphrase; + // Lets a server that wants key then password (or password only) still work. + if (host.password) config.password = host.password; + + const certPublicKey = host.certPublicKey; + if (certPublicKey && certPublicKey.trim()) { + try { + const { setupCACertAuth } = await import("../opkssh-cert-auth.js"); + await setupCACertAuth( + config, + env.client, + config.privateKey as Buffer, + certPublicKey, + host.username, + passphrase, + ); + env.log("info", "Using SSH key authentication with CA certificate"); + return { status: "ready" }; + } catch (certError) { + env.log( + "warning", + `CA certificate setup failed, falling back to key only: ${getErrorMessage(certError)}`, + ); + } + } + + env.log("info", "Using SSH key authentication"); + return { status: "ready" }; +} + +export const passwordProvider: SshAuthProvider = { + type: "password", + pluginId: "core", + labelKey: "hosts.filterAuthPassword", + credentialType: true, + requiresSecret: true, + prepare: preparePassword, +}; + +export const keyProvider: SshAuthProvider = { + type: "key", + pluginId: "core", + labelKey: "hosts.filterAuthKey", + credentialType: true, + requiresSecret: true, + prepare: prepareKey, +}; + +/** + * The resolver expands a credential into key or password before connect, so + * this only runs when that expansion found something odd, like an unresolved + * reference. It behaves like whichever secret is present. + */ +export const credentialProvider: SshAuthProvider = { + type: "credential", + pluginId: "core", + labelKey: "hosts.filterAuthCredential", + requiresSecret: true, + prepare: async (config, host, env) => { + if (keyText(host.key).trim()) return prepareKey(config, host, env); + if (host.password) return preparePassword(config, host, env); + return { + status: "error", + code: "missing-secret", + message: "The stored credential for this host could not be resolved", + }; + }, +}; + +export const agentProvider: SshAuthProvider = { + type: "agent", + pluginId: "core", + labelKey: "hosts.filterAuthAgent", + requiresSecret: true, + prepare: async (config, host, env) => { + const result = await applyAgentAuth( + config, + host.terminalConfig as Record | undefined, + ); + if ("error" in result) { + return { status: "error", code: "failed", message: result.error }; + } + env.log("info", `SSH agent configured (socket: ${result.socketPath})`); + return { status: "ready" }; + }, +}; + +export const noneProvider: SshAuthProvider = { + type: "none", + pluginId: "core", + labelKey: "hosts.filterAuthNone", + // Keyboard-interactive only: a background poll would fire a live prompt + // (a RADIUS or Duo push) with nobody there to answer it. + supportsBackground: false, + prepare: async (_config, _host, env) => { + env.log("info", "Using keyboard-interactive authentication"); + return { status: "ready" }; + }, +}; + +export const BUILTIN_SSH_AUTH_PROVIDERS: readonly SshAuthProvider[] = [ + passwordProvider, + keyProvider, + credentialProvider, + agentProvider, + noneProvider, +]; + +export function registerBuiltinSshAuthProviders(): () => void { + const disposers = BUILTIN_SSH_AUTH_PROVIDERS.map((provider) => + registerSshAuthProvider(provider), + ); + return () => disposers.forEach((dispose) => dispose()); +} diff --git a/src/backend/hosts/connect/connect-host.ts b/src/backend/hosts/connect/connect-host.ts new file mode 100644 index 000000000..f5ef530dd --- /dev/null +++ b/src/backend/hosts/connect/connect-host.ts @@ -0,0 +1,208 @@ +/** + * connectHost: the one way core and plugins open an SSH connection. + * + * Resolve the host (shared overrides, external secrets and owner decryption + * all happen in resolveHostById), build the config through the host's auth + * provider, open the transport, then connect with a keyboard-interactive + * handler that either asks through a prompt channel or fills in the stored + * password. + */ + +import { Client } from "ssh2"; +import { withConnection } from "../ssh-connection-pool.js"; +import { resolveHostById } from "../host-resolver.js"; +import { buildConnectConfig } from "./build-connect-config.js"; +import { + createAutoKeyboardInteractiveHandler, + createPromptKeyboardInteractiveHandler, + type KeyboardInteractiveListener, +} from "./keyboard-interactive.js"; +import { openSshTransport, type OpenTransportOptions } from "./transport.js"; +import type { + MutableConnectConfig, + SshAuthLog, + SshAuthOutcome, + SshConnectHost, + SshConnectPurpose, + SshPromptChannel, +} from "./types.js"; + +export class SshConnectError extends Error { + readonly code: string; + constructor(readonly outcome: Exclude) { + super(outcome.message); + this.name = "SshConnectError"; + this.code = + outcome.status === "error" + ? outcome.code + : outcome.status === "interaction-required" + ? `${outcome.interaction}-required` + : "retry"; + } +} + +export class SshHostNotFoundError extends Error { + constructor(readonly hostId: number) { + super("Host not found or access denied"); + this.name = "SshHostNotFoundError"; + } +} + +export interface ConnectHostOptions { + /** Acting user. Access and credentials are resolved for them. */ + userId: string; + purpose: SshConnectPurpose; + /** Ask a person for keyboard-interactive answers. Without it, auto-fill. */ + prompt?: SshPromptChannel; + /** Replaces the keyboard-interactive handler entirely. */ + keyboardInteractive?: KeyboardInteractiveListener; + /** Config fields to force, e.g. a longer readyTimeout. */ + overrides?: Partial; + /** Overall timeout for reaching "ready". Defaults to 30s. */ + timeoutMs?: number; + /** Use this client instead of a new one. */ + client?: Client; + transport?: OpenTransportOptions; + log?: SshAuthLog; +} + +export interface SshConnection { + client: Client; + jumpClient: Client | null; + host: SshConnectHost; + config: MutableConnectConfig; + /** Ends the connection and its jump chain. Safe to call twice. */ + dispose: () => void; +} + +export async function resolveConnectHost( + target: number | SshConnectHost, + userId: string, +): Promise { + if (typeof target !== "number") return target; + const host = await resolveHostById(target, userId); + if (!host) throw new SshHostNotFoundError(target); + return host as unknown as SshConnectHost; +} + +export async function connectHost( + target: number | SshConnectHost, + options: ConnectHostOptions, +): Promise { + const host = await resolveConnectHost(target, options.userId); + const client = options.client ?? new Client(); + + const { config, provider, outcome, env } = await buildConnectConfig(host, { + userId: options.userId, + purpose: options.purpose, + client, + hostKeySocket: options.prompt?.hostKeySocket ?? null, + interactive: !!options.prompt, + log: options.log, + overrides: options.overrides, + }); + if (outcome.status !== "ready") { + throw new SshConnectError(outcome); + } + + const { jumpClient } = await openSshTransport(host, config, { + log: options.log, + ...options.transport, + }); + + const keyboardInteractive = + options.keyboardInteractive ?? + (options.prompt + ? createPromptKeyboardInteractiveHandler(host, options.prompt) + : createAutoKeyboardInteractiveHandler(host)); + + let disposed = false; + const dispose = () => { + if (disposed) return; + disposed = true; + try { + client.end(); + } catch { + // already closed + } + jumpClient?.end(); + }; + + await new Promise((resolve, reject) => { + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + dispose(); + reject(new Error("SSH connection timeout")); + }, options.timeoutMs ?? 30000); + + client.on("ready", () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(); + }); + + client.on("error", (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + jumpClient?.end(); + const authFailed = /All configured authentication methods failed/i.test( + error.message, + ); + if (authFailed && provider?.onAuthFailed) { + try { + provider.onAuthFailed(host, env, { + error, + retries: 0, + canRetry: false, + methodNotAvailable: false, + }); + } catch { + // cleanup hooks never mask the connect error + } + } + reject(error); + }); + + client.on("close", () => { + jumpClient?.end(); + }); + + client.on("keyboard-interactive", keyboardInteractive); + client.connect(config); + }); + + return { client, jumpClient, host, config, dispose }; +} + +/** Pool key for a host and purpose, matching the keys the transports used. */ +export function getConnectionPoolKey( + prefix: string, + host: SshConnectHost, +): string { + const socks5Key = host.useSocks5 + ? `:socks5:${host.socks5Host}:${host.socks5Port}` + : ""; + return `${prefix}:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`; +} + +/** + * Runs fn on a pooled connection. The pool calls the factory only when it + * has no idle connection for the key. + */ +export async function withHostConnection( + poolKey: string, + target: number | SshConnectHost, + options: ConnectHostOptions, + fn: (client: Client) => Promise, +): Promise { + const host = await resolveConnectHost(target, options.userId); + return withConnection( + poolKey, + async () => (await connectHost(host, options)).client, + fn, + ); +} diff --git a/src/backend/hosts/connect/core-providers.ts b/src/backend/hosts/connect/core-providers.ts new file mode 100644 index 000000000..42db2cafc --- /dev/null +++ b/src/backend/hosts/connect/core-providers.ts @@ -0,0 +1,20 @@ +import { registerLegacySshAuthProviders } from "../../auth/legacy-providers.js"; +import { registerBuiltinSshAuthProviders } from "./builtin-providers.js"; + +let registered = false; + +/** + * Registers core's providers once. Called lazily by the pipeline so tests and + * scripts get the same set as a running server without a start-up hook. + */ +export function ensureCoreSshAuthProviders(): void { + if (registered) return; + registered = true; + registerBuiltinSshAuthProviders(); + registerLegacySshAuthProviders(); +} + +/** Test helper, pairs with resetSshAuthRegistryForTests. */ +export function resetCoreSshAuthProvidersForTests(): void { + registered = false; +} diff --git a/src/backend/hosts/connect/keyboard-interactive.ts b/src/backend/hosts/connect/keyboard-interactive.ts new file mode 100644 index 000000000..814c96a6a --- /dev/null +++ b/src/backend/hosts/connect/keyboard-interactive.ts @@ -0,0 +1,200 @@ +/** + * One classifier for keyboard-interactive rounds, shared by every transport. + * + * It only decides what a round is. Answering is up to the transport: the + * terminal asks over its socket, the file manager parks the connection, and + * background work fills in the stored password and nothing else. + */ + +import { listKeyboardInteractiveInterceptors } from "./auth-provider-registry.js"; +import type { + KeyboardInteractiveDecision, + KeyboardInteractivePrompt, + SshConnectHost, + SshPromptChannel, +} from "./types.js"; + +const PASSWORD_PATTERN = /password/i; + +// FortiToken asks for a code OR the word "push", so it needs a text field and +// must not be treated as a confirm-only push. Checked before the push pattern +// because its text also says "push notification". +const FORTI_TOKEN_PATTERN = /type\s+['"]?push['"]?/i; + +// JumpCloud Protect / DUO: a menu choice, then an empty-answerable confirm. +// Checked before TOTP because "...or [2] TOTP:" would match that pattern. +export const PUSH_PROMPT_PATTERN = + /choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i; + +const TOTP_PATTERN = + /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i; + +export interface KeyboardInteractiveRound { + name: string; + instructions: string; + prompts: KeyboardInteractivePrompt[]; +} + +/** Stored password for every password prompt, empty for the rest. */ +export function autoResponses( + prompts: KeyboardInteractivePrompt[], + password: string | null | undefined, +): string[] { + return prompts.map((p) => + PASSWORD_PATTERN.test(p.prompt) && password ? password : "", + ); +} + +/** Puts the user's answer at one index and auto-fills the rest. */ +export function responsesWithAnswer( + prompts: KeyboardInteractivePrompt[], + answerIndex: number, + answer: string, + password: string | null | undefined, +): string[] { + return prompts.map((p, index) => { + if (index === answerIndex) return answer; + if (PASSWORD_PATTERN.test(p.prompt) && password) return password; + return ""; + }); +} + +function shouldAutoAnswerPasswords(host: SshConnectHost): boolean { + return listKeyboardInteractiveInterceptors().some( + (interceptor) => interceptor.autoAnswerPasswords?.(host) === true, + ); +} + +export function classifyKeyboardInteractive( + round: KeyboardInteractiveRound, + host: SshConnectHost, +): KeyboardInteractiveDecision { + for (const interceptor of listKeyboardInteractiveInterceptors()) { + const decision = interceptor.detect(round, host); + if (decision) return decision; + } + + const { prompts } = round; + const texts = prompts.map((p) => p.prompt); + const isFortiToken = texts.some((p) => FORTI_TOKEN_PATTERN.test(p)); + const isPush = + !isFortiToken && texts.some((p) => PUSH_PROMPT_PATTERN.test(p)); + + if (!isPush) { + const totpIndex = prompts.findIndex((p) => TOTP_PATTERN.test(p.prompt)); + if (totpIndex !== -1) return { kind: "totp", promptIndex: totpIndex }; + } + + if (shouldAutoAnswerPasswords(host)) { + return { kind: "auto", responses: autoResponses(prompts, host.password) }; + } + + const hasStoredPassword = !!host.password && host.authType !== "none"; + const passwordIndex = prompts.findIndex((p) => + PASSWORD_PATTERN.test(p.prompt), + ); + // DUO/PAM challenges often don't say "password" at all. + const firstUnanswered = prompts.findIndex( + (p) => !(PASSWORD_PATTERN.test(p.prompt) && hasStoredPassword), + ); + + if (firstUnanswered === -1) { + return { kind: "auto", responses: autoResponses(prompts, host.password) }; + } + + const promptIndex = + passwordIndex !== -1 && !hasStoredPassword + ? passwordIndex + : firstUnanswered; + return { + kind: "input", + promptIndex, + isPush: PUSH_PROMPT_PATTERN.test(prompts[promptIndex].prompt), + }; +} + +export type KeyboardInteractiveListener = ( + name: string, + instructions: string, + lang: string, + prompts: KeyboardInteractivePrompt[], + finish: (responses: string[]) => void, +) => void; + +/** + * The handler for connections with nobody to ask: the stored password goes + * to password prompts, everything else gets an empty answer. That is what + * every background path did before the pipeline. + */ +export function createAutoKeyboardInteractiveHandler( + host: SshConnectHost, +): KeyboardInteractiveListener { + return (_name, _instructions, _lang, prompts, finish) => { + finish(autoResponses(prompts, host.password)); + }; +} + +/** + * The handler for connections with a prompt channel. One question per round; + * a second TOTP round means the last code was wrong. + */ +export function createPromptKeyboardInteractiveHandler( + host: SshConnectHost, + channel: SshPromptChannel, +): KeyboardInteractiveListener { + let totpAsked = false; + + return (name, instructions, _lang, prompts, finish) => { + const decision = classifyKeyboardInteractive( + { name, instructions, prompts }, + host, + ); + + const ask = async (): Promise => { + switch (decision.kind) { + case "auto": + return decision.responses; + case "warpgate": { + await channel.ask({ + kind: "warpgate", + url: decision.url, + securityKey: decision.securityKey, + instructions: decision.instructions, + }); + return prompts.map(() => ""); + } + case "totp": { + const answer = await channel.ask({ + kind: "totp", + prompt: prompts[decision.promptIndex].prompt, + retry: totpAsked, + }); + totpAsked = true; + return responsesWithAnswer( + prompts, + decision.promptIndex, + (answer ?? "").trim(), + host.password, + ); + } + case "input": { + const prompt = prompts[decision.promptIndex]; + const answer = await channel.ask({ + kind: "input", + prompt: prompt.prompt, + echo: prompt.echo, + isPush: decision.isPush, + }); + return responsesWithAnswer( + prompts, + decision.promptIndex, + (answer ?? "").trim(), + host.password, + ); + } + } + }; + + ask().then(finish, () => finish(prompts.map(() => ""))); + }; +} diff --git a/src/backend/hosts/connect/terminal-prompt.ts b/src/backend/hosts/connect/terminal-prompt.ts new file mode 100644 index 000000000..5f2e5cf59 --- /dev/null +++ b/src/backend/hosts/connect/terminal-prompt.ts @@ -0,0 +1,277 @@ +/** + * Keyboard-interactive answering over the terminal WebSocket. + * + * The classifier decides what a round is; this keeps the terminal's message + * protocol (totp_required, totp_retry, password_required, + * warpgate_auth_required) and its timeouts exactly as the client expects. + */ + +import type { WebSocket } from "ws"; +import { sshLogger, authLogger } from "../../utils/logger.js"; +import { ensureCoreSshAuthProviders } from "./core-providers.js"; +import { + classifyKeyboardInteractive, + responsesWithAnswer, +} from "./keyboard-interactive.js"; +import type { KeyboardInteractivePrompt, SshConnectHost } from "./types.js"; + +interface ResolvedCredentials { + username: string; + password?: string; + authType?: string; +} + +interface HostFlags { + useWarpgate?: boolean; + [key: string]: unknown; +} + +interface AuthContext { + userId: string; + ws: WebSocket; + hostId: number; + isKeyboardInteractive: boolean; + keyboardInteractiveResponded: boolean; + keyboardInteractiveFinish: ((responses: string[]) => void) | null; + totpPromptSent: boolean; + warpgateAuthPromptSent: boolean; + totpTimeout: NodeJS.Timeout | null; + warpgateAuthTimeout: NodeJS.Timeout | null; + totpAttempts: number; +} + +const TOTP_TIMEOUT_MS = 180000; +const PUSH_TIMEOUT_MS = 300000; +const WARPGATE_TIMEOUT_MS = 300000; + +export class SSHAuthManager { + public context: AuthContext; + + constructor(context: AuthContext) { + this.context = context; + } + + handleKeyboardInteractive( + name: string, + instructions: string, + _instructionsLang: string, + prompts: KeyboardInteractivePrompt[], + finish: (responses: string[]) => void, + resolvedCredentials: ResolvedCredentials, + hostConfig?: HostFlags, + ): void { + ensureCoreSshAuthProviders(); + this.context.isKeyboardInteractive = true; + + const host = { + ...(hostConfig ?? {}), + id: this.context.hostId, + ip: "", + port: 22, + username: resolvedCredentials.username, + password: resolvedCredentials.password, + authType: resolvedCredentials.authType, + } as SshConnectHost; + + const decision = classifyKeyboardInteractive( + { name, instructions, prompts }, + host, + ); + + switch (decision.kind) { + case "auto": + finish(decision.responses); + return; + case "warpgate": + this.handleWarpgate(decision, finish); + return; + case "totp": + this.handleTotp(prompts, decision.promptIndex, finish, host.password); + return; + case "input": + this.handleInput( + prompts, + decision.promptIndex, + decision.isPush, + finish, + host.password, + ); + return; + } + } + + private handleWarpgate( + decision: { url: string | null; securityKey: string; instructions: string }, + finish: (responses: string[]) => void, + ): void { + this.context.keyboardInteractiveFinish = () => { + finish([""]); + }; + this.context.warpgateAuthPromptSent = true; + this.sendLog("auth", "info", "Warpgate authentication required"); + this.context.ws.send( + JSON.stringify({ + type: "warpgate_auth_required", + url: decision.url, + securityKey: decision.securityKey, + instructions: decision.instructions, + }), + ); + + this.context.warpgateAuthTimeout = setTimeout(() => { + if (this.context.keyboardInteractiveFinish) { + this.context.keyboardInteractiveFinish = null; + this.context.warpgateAuthPromptSent = false; + sshLogger.warn("Warpgate authentication timeout", { + operation: "warpgate_timeout", + hostId: this.context.hostId, + }); + this.context.ws.send( + JSON.stringify({ + type: "error", + message: "Warpgate authentication timeout. Please reconnect.", + }), + ); + } + }, WARPGATE_TIMEOUT_MS); + } + + private handleTotp( + prompts: KeyboardInteractivePrompt[], + promptIndex: number, + finish: (responses: string[]) => void, + password: string | undefined, + ): void { + if (this.context.totpPromptSent) { + sshLogger.warn("TOTP prompt asked again - invalid code", { + operation: "ssh_keyboard_interactive_totp_retry", + hostId: this.context.hostId, + }); + authLogger.warn("TOTP verification failed for SSH session", { + operation: "terminal_totp_failed", + userId: this.context.userId, + hostId: this.context.hostId, + }); + this.sendLog("auth", "warning", "Invalid TOTP code"); + this.context.ws.send(JSON.stringify({ type: "totp_retry" })); + return; + } + + this.context.totpPromptSent = true; + this.context.keyboardInteractiveResponded = true; + this.context.keyboardInteractiveFinish = (answers: string[]) => { + finish( + responsesWithAnswer( + prompts, + promptIndex, + (answers[0] || "").trim(), + password, + ), + ); + }; + + this.armTimeout(TOTP_TIMEOUT_MS, "totp"); + this.sendLog("auth", "info", "TOTP verification required"); + authLogger.info("TOTP verification prompt sent to client", { + operation: "terminal_totp_prompt", + userId: this.context.userId, + hostId: this.context.hostId, + }); + this.context.ws.send( + JSON.stringify({ + type: "totp_required", + prompt: prompts[promptIndex].prompt, + }), + ); + } + + private handleInput( + prompts: KeyboardInteractivePrompt[], + promptIndex: number, + isPush: boolean, + finish: (responses: string[]) => void, + password: string | undefined, + ): void { + if (this.context.keyboardInteractiveResponded) return; + this.context.keyboardInteractiveResponded = true; + + this.context.keyboardInteractiveFinish = (answers: string[]) => { + finish( + responsesWithAnswer( + prompts, + promptIndex, + (answers[0] || "").trim(), + password, + ), + ); + }; + + this.armTimeout(isPush ? PUSH_TIMEOUT_MS : TOTP_TIMEOUT_MS, "password"); + this.sendLog("auth", "info", "Password authentication required"); + this.context.ws.send( + JSON.stringify({ + type: "password_required", + prompt: prompts[promptIndex].prompt, + echo: prompts[promptIndex].echo, + }), + ); + } + + private armTimeout(ms: number, kind: "totp" | "password"): void { + if (this.context.totpTimeout) clearTimeout(this.context.totpTimeout); + this.context.totpTimeout = setTimeout(() => { + if (!this.context.keyboardInteractiveFinish) return; + this.context.keyboardInteractiveFinish = null; + if (kind === "totp") { + this.context.totpPromptSent = false; + } else { + this.context.keyboardInteractiveResponded = false; + } + sshLogger.warn( + `${kind === "totp" ? "TOTP" : "Password"} prompt timeout`, + { + operation: `${kind}_timeout`, + hostId: this.context.hostId, + }, + ); + this.context.ws.send( + JSON.stringify({ + type: "error", + message: + kind === "totp" + ? "TOTP verification timeout. Please reconnect." + : "Password verification timeout. Please reconnect.", + }), + ); + }, ms); + } + + sendLog( + stage: string, + level: string, + message: string, + details?: Record, + ): void { + this.context.ws.send( + JSON.stringify({ + type: "connection_log", + data: { stage, level, message, details }, + }), + ); + } + + cleanup(): void { + if (this.context.totpTimeout) { + clearTimeout(this.context.totpTimeout); + this.context.totpTimeout = null; + } + if (this.context.warpgateAuthTimeout) { + clearTimeout(this.context.warpgateAuthTimeout); + this.context.warpgateAuthTimeout = null; + } + this.context.keyboardInteractiveFinish = null; + this.context.totpPromptSent = false; + this.context.warpgateAuthPromptSent = false; + this.context.keyboardInteractiveResponded = false; + } +} diff --git a/src/backend/hosts/connect/transport.ts b/src/backend/hosts/connect/transport.ts new file mode 100644 index 000000000..52f2689cf --- /dev/null +++ b/src/backend/hosts/connect/transport.ts @@ -0,0 +1,196 @@ +/** + * How the bytes get to a host: port knocking, a Cloudflare Access tunnel, a + * jump host chain or a SOCKS5 proxy, in that order. Sets config.sock and + * returns the jump client so the caller can close it with the connection. + */ + +import type { Client } from "ssh2"; +import { getErrorMessage } from "../../utils/error-message.js"; +import { logger } from "../../utils/logger.js"; +import { + createSocks5Connection, + type SOCKS5Config, +} from "../../utils/socks5-helper.js"; +import type { ProxyNode } from "../../../types/index.js"; +import { createJumpHostChain } from "../jump-host-chain.js"; +import { resolveSshConnectConfigHost } from "../ssh-dns.js"; +import { performPortKnocking } from "../terminal-auth-helpers.js"; +import type { + MutableConnectConfig, + SshAuthLog, + SshConnectHost, +} from "./types.js"; + +export function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null { + const chain = Array.isArray(host.socks5ProxyChain) + ? (host.socks5ProxyChain as ProxyNode[]) + : []; + if (!host.useSocks5 || (!host.socks5Host && chain.length === 0)) return null; + return { + useSocks5: host.useSocks5, + socks5Host: host.socks5Host ?? undefined, + socks5Port: host.socks5Port ?? undefined, + socks5Username: host.socks5Username ?? undefined, + socks5Password: host.socks5Password ?? undefined, + socks5ProxyChain: chain, + }; +} + +export class SshTransportError extends Error { + constructor( + message: string, + readonly stage: "cloudflare" | "jump-host" | "jump-forward" | "proxy", + options?: { cause?: unknown }, + ) { + super(message, options); + this.name = "SshTransportError"; + } +} + +export interface OpenTransportOptions { + /** Knock before connecting when the host has a sequence. Default true. */ + portKnock?: boolean; + /** Use terminalConfig.cfAccessClient* when present. Default true. */ + cloudflare?: boolean; + /** Resolve DNS up front for a direct connection. Default true. */ + resolveDns?: boolean; + log?: SshAuthLog; +} + +export interface OpenedTransport { + jumpClient: Client | null; + via: "direct" | "proxy" | "jump" | "cloudflare"; +} + +function forwardThrough( + jumpClient: Client, + host: SshConnectHost, +): Promise> { + return new Promise((resolve, reject) => { + jumpClient.forwardOut( + "127.0.0.1", + 0, + host.ip, + host.port || 22, + (err, stream) => { + if (err) { + reject( + new SshTransportError( + "Failed to forward through jump host: " + err.message, + "jump-forward", + { cause: err }, + ), + ); + return; + } + resolve(stream); + }, + ); + }); +} + +export async function openSshTransport( + host: SshConnectHost, + config: MutableConnectConfig, + options: OpenTransportOptions = {}, +): Promise { + const log = options.log ?? (() => {}); + + if ( + options.portKnock !== false && + Array.isArray(host.portKnockSequence) && + host.portKnockSequence.length > 0 + ) { + try { + await performPortKnocking(host.ip, host.portKnockSequence); + } catch { + logger.warn("Port knocking failed, attempting connection anyway", { + operation: "port_knock", + hostId: host.id, + }); + } + } + + let via: OpenedTransport["via"] = "direct"; + + const terminalConfig = (host.terminalConfig ?? {}) as Record; + if ( + options.cloudflare !== false && + terminalConfig.cfAccessClientId && + terminalConfig.cfAccessClientSecret + ) { + try { + const WebSocket = (await import("ws")).default; + const { createWebSocketDuplex, waitForWebSocketOpen } = + await import("../cloudflare-websocket.js"); + const cfHostname = (terminalConfig.cfTunnelHostname as string) || host.ip; + const cfWs = new WebSocket( + `wss://${cfHostname}/cdn-cgi/access/ssh-connect`, + { + headers: { + "CF-Access-Client-Id": terminalConfig.cfAccessClientId as string, + "CF-Access-Client-Secret": + terminalConfig.cfAccessClientSecret as string, + }, + }, + ); + await waitForWebSocketOpen(cfWs, 30000); + config.sock = createWebSocketDuplex( + cfWs, + ) as unknown as typeof config.sock; + via = "cloudflare"; + log("info", "Connected via Cloudflare Tunnel"); + } catch (error) { + throw new SshTransportError( + "Cloudflare tunnel connection failed: " + getErrorMessage(error), + "cloudflare", + { cause: error }, + ); + } + } + + const jumpUserId = host.userId || ""; + if (host.jumpHosts && host.jumpHosts.length > 0 && jumpUserId) { + const jumpClient = await createJumpHostChain(host.jumpHosts, jumpUserId); + if (!jumpClient) { + throw new SshTransportError( + "Failed to connect through jump hosts", + "jump-host", + ); + } + try { + config.sock = await forwardThrough(jumpClient, host); + } catch (error) { + jumpClient.end(); + throw error; + } + return { jumpClient, via: "jump" }; + } + + const proxyConfig = getHostSocks5Config(host); + if (proxyConfig && via === "direct") { + try { + const proxySocket = await createSocks5Connection( + host.ip, + host.port || 22, + proxyConfig, + ); + if (proxySocket) { + config.sock = proxySocket; + via = "proxy"; + } + } catch (error) { + throw new SshTransportError( + "Proxy connection failed: " + getErrorMessage(error), + "proxy", + { cause: error }, + ); + } + } + + if (via === "direct" && options.resolveDns !== false) { + await resolveSshConnectConfigHost(config); + } + + return { jumpClient: null, via }; +} diff --git a/src/backend/hosts/connect/types.ts b/src/backend/hosts/connect/types.ts new file mode 100644 index 000000000..39360d694 --- /dev/null +++ b/src/backend/hosts/connect/types.ts @@ -0,0 +1,255 @@ +import type { Client, ConnectConfig } from "ssh2"; +import type { WebSocket } from "ws"; + +/** + * What a connection is for. Picks keepalive and timeout defaults so every + * transport keeps the numbers it had before the pipeline existed. + */ +export type SshConnectPurpose = + | "terminal" + | "file-manager" + | "file-transfer" + | "tmux" + | "metrics" + | "fleet" + | "tunnel" + | "docker" + | "docker-console" + | "proxmox" + | "snippet" + | "credential-deploy" + | "jump-host" + | "remote-desktop" + | "plugin"; + +/** The resolved host fields the pipeline reads. */ +export interface SshConnectHost { + id: number; + ip: string; + port: number; + username: string; + userId?: string | null; + authType?: string | null; + password?: string | null; + key?: string | Buffer | null; + keyPassword?: string | null; + keyType?: string | null; + certPublicKey?: string | null; + useWarpgate?: boolean | null; + forceKeyboardInteractive?: boolean | null; + vaultProfile?: { id?: number | null } | null; + terminalConfig?: Record | null; + jumpHosts?: Array<{ hostId: number }> | null; + useSocks5?: boolean | null; + socks5Host?: string | null; + socks5Port?: number | null; + socks5Username?: string | null; + socks5Password?: string | null; + socks5ProxyChain?: unknown; + portKnockSequence?: Array<{ + port: number; + protocol?: string; + delay?: number; + }> | null; + [key: string]: unknown; +} + +export type MutableConnectConfig = ConnectConfig & Record; + +export type SshAuthLog = ( + level: "info" | "warning" | "error", + message: string, +) => void; + +export interface SshAuthEnv { + /** The client about to connect; certificate auth patches it. */ + client: Client; + /** The acting user. Certificates and tokens are cached per user. */ + userId: string; + /** Server-side host id, used for per-host caches. */ + hostId: number; + purpose: SshConnectPurpose; + /** True when a person can answer prompts or finish a browser sign-in. */ + interactive: boolean; + log: SshAuthLog; +} + +/** + * What preparing auth produced. + * + * `interaction-required` means a browser step has to happen first; the + * transport turns `interaction` into its own message, for example + * `opkssh_auth_required` over the terminal socket. + */ +export type SshAuthOutcome = + | { status: "ready" } + | { + status: "interaction-required"; + interaction: string; + message: string; + /** Extra boolean the HTTP transports set in their 401 body. */ + flag?: string; + } + | { + status: "error"; + code: + | "missing-secret" + | "invalid-key" + | "passphrase-required" + | "provider-missing" + | "failed"; + message: string; + } + | { + /** Try once more with these config changes. Used by Tailscale. */ + status: "retry"; + patch: Partial; + message: string; + }; + +export interface SshAuthFailureContext { + error: Error; + /** How many retries this connection already made. */ + retries: number; + /** A tunnelled socket cannot be reused for a retry. */ + canRetry: boolean; + /** The server refused keyboard-interactive or none. */ + methodNotAvailable: boolean; +} + +export interface SshInteractionRequest { + userId: string; + hostId: number; + /** Host row as stored, not resolved. */ + host: { name?: string | null; ip: string; username: string }; + socket: WebSocket; + requestOrigin: string; + /** Anything the client sent with the start message. */ + payload: Record; +} + +export interface SshAuthField { + key: string; + type: + | "boolean" + | "string" + | "number" + | "select" + | "secret" + | "textarea" + | "custom"; + labelKey?: string; + descriptionKey?: string; + placeholderKey?: string; + default?: unknown; + options?: Array<{ value: string; labelKey: string }>; + component?: string; +} + +export interface SshAuthProvider { + /** Stored in ssh_data.auth_type. */ + type: string; + /** "core" for built-ins and the legacy providers A8 keeps in core. */ + pluginId: string; + labelKey: string; + descriptionKey?: string; + /** Editor fields, rendered by core unless the frontend registered an editor. */ + fields?: SshAuthField[]; + /** Also offered as a stored credential type. */ + credentialType?: boolean; + /** Needs a browser sign-in or a person at the keyboard. */ + needsUserInteraction?: boolean; + /** Carries a secret, so a shared host needs one resolved for the recipient. */ + requiresSecret?: boolean; + /** + * Can connect with nobody watching, for polling and batch work. Defaults to + * true; false for types that need a person or a fresh browser sign-in. + */ + supportsBackground?: boolean; + /** Overrides applied to the base config before prepare runs. */ + connectOptions?: ( + host: SshConnectHost, + purpose: SshConnectPurpose, + ) => Partial; + /** The interaction name this provider's outcomes use, e.g. "opkssh". */ + interaction?: string; + /** + * Starts the browser step behind an `interaction-required` outcome, for + * transports that can show one (the terminal today). + */ + startInteraction?: (request: SshInteractionRequest) => Promise; + prepare: ( + config: MutableConnectConfig, + host: SshConnectHost, + env: SshAuthEnv, + ) => Promise; + /** + * Called when the server rejected auth. Synchronous on purpose: transports + * decide on a retry before the socket's close event arrives. Cache cleanup + * inside runs in the background. + */ + onAuthFailed?: ( + host: SshConnectHost, + env: SshAuthEnv, + context: SshAuthFailureContext, + ) => SshAuthOutcome | undefined; +} + +export interface KeyboardInteractivePrompt { + prompt: string; + echo?: boolean; +} + +/** What to do with one keyboard-interactive round. */ +export type KeyboardInteractiveDecision = + | { kind: "auto"; responses: string[] } + | { + kind: "warpgate"; + url: string | null; + securityKey: string; + instructions: string; + } + | { kind: "totp"; promptIndex: number } + | { kind: "input"; promptIndex: number; isPush: boolean }; + +/** + * Interceptors run before the built-in classifier. A provider that owns a + * prompt style (Warpgate today) returns a decision; everyone else returns null. + */ +export interface KeyboardInteractiveInterceptor { + id: string; + pluginId: string; + detect: ( + round: { + name: string; + instructions: string; + prompts: KeyboardInteractivePrompt[]; + }, + host: SshConnectHost, + ) => KeyboardInteractiveDecision | null; + /** Answer password prompts silently instead of asking the user. */ + autoAnswerPasswords?: (host: SshConnectHost) => boolean; +} + +/** + * How a transport asks a person something mid-connect. + * + * `ask` resolves with the typed answer, or null when the user gave up or the + * transport has nobody to ask. The terminal answers over its WebSocket, the + * file manager parks the connection and answers over HTTP. + */ +export interface SshPromptChannel { + ask: (request: SshPromptRequest) => Promise; + /** Terminal socket used for the host key prompt, when there is one. */ + hostKeySocket?: WebSocket | null; +} + +export type SshPromptRequest = + | { kind: "totp"; prompt: string; retry: boolean } + | { kind: "input"; prompt: string; echo: boolean; isPush: boolean } + | { + kind: "warpgate"; + url: string | null; + securityKey: string; + instructions: string; + }; diff --git a/src/backend/hosts/file-manager/ca-cert-auth.ts b/src/backend/hosts/file-manager/ca-cert-auth.ts deleted file mode 100644 index 552b4a8b7..000000000 --- a/src/backend/hosts/file-manager/ca-cert-auth.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { getErrorMessage } from "../../utils/error-message.js"; -import type { Client as SSHClient, ConnectConfig } from "ssh2"; -import { fileLogger } from "../../utils/logger.js"; - -/** - * Attaches a user-managed CA-signed certificate to an SFTP connection, if the - * host carries one. - * - * The terminal has always done this. The file manager never did: it imports - * and calls `setupOPKSSHCertAuth`, but `setupCACertAuth` — the sibling helper - * for user-managed `-cert.pub` files — had no call site here at all. A host - * whose key is paired with a CA-signed certificate therefore authenticated in - * a terminal and failed in the file manager, while OPKSSH certificates worked - * in both. That asymmetry is the whole bug. - * - * `cert_public_key` is a column on `ssh_data` but is not part of the shared - * `Host` type, so callers pass whichever record they hold and it is read off - * structurally — the same way the terminal reads it. - * - * A certificate that cannot be applied is logged and skipped rather than - * failing the connection: the private key alone may still be accepted, which - * is exactly what happened before any of this was wired up. - */ -export async function applyCACertIfPresent( - config: Record, - client: SSHClient, - privateKey: Buffer | string, - source: { certPublicKey?: string | null }, - username: string, - passphrase?: string, -): Promise { - const certPublicKey = source.certPublicKey; - if (!certPublicKey || !certPublicKey.trim()) return; - - try { - const { setupCACertAuth } = await import("../opkssh-cert-auth.js"); - await setupCACertAuth( - config as ConnectConfig, - client, - privateKey, - certPublicKey, - username, - passphrase, - ); - } catch (certError) { - fileLogger.warn("CA certificate setup failed, continuing with key only", { - operation: "sftp_ca_cert_auth_failed", - error: getErrorMessage(certError), - }); - } -} diff --git a/src/backend/hosts/file-manager/index.ts b/src/backend/hosts/file-manager/index.ts index a5b6a6541..d4b5b7edc 100644 --- a/src/backend/hosts/file-manager/index.ts +++ b/src/backend/hosts/file-manager/index.ts @@ -1,5 +1,4 @@ import { getErrorMessage } from "../../utils/error-message.js"; -import { usesIssuedCertificate } from "../issued-certificate-auth.js"; import express from "express"; import { logAudit, @@ -10,25 +9,15 @@ import { createCorsMiddleware } from "../../utils/cors-config.js"; import { createCompressionMiddleware } from "../../utils/compression-config.js"; import cookieParser from "cookie-parser"; import axios from "axios"; -import ssh2Pkg, { Client as SSHClient, type ConnectConfig } from "ssh2"; -import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; +import { Client as SSHClient } from "ssh2"; import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js"; import { fileLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; -import { - type AuthenticatedRequest, - type ProxyNode, - type SSHHost, -} from "../../../types/index.js"; -import { - createSocks5Connection, - type SOCKS5Config, -} from "../../utils/socks5-helper.js"; +import { type AuthenticatedRequest } from "../../../types/index.js"; import type { LogEntry, ConnectionStage, } from "../../../types/connection-log.js"; -import { SSHHostKeyVerifier } from "../host-key-verifier.js"; import { resolveHostById, resolveHostBySyncId } from "../host-resolver.js"; import { startHostTransfer, @@ -43,11 +32,7 @@ import { } from "./transfer-engine.js"; import { registerFileContentRoutes } from "./content-routes.js"; import { createConnectionLog } from "../connection-log.js"; -import { createJumpHostChain, JumpHostChainError } from "../jump-host-chain.js"; -import { - isPrivateKeyPassphraseError, - preparePrivateKeyForSSH2, -} from "../../utils/ssh-key-utils.js"; +import { JumpHostChainError } from "../jump-host-chain.js"; import { ChannelOpenSerializer, execChannel, @@ -57,8 +42,6 @@ import { } from "./session.js"; import { registerFileListingRoutes } from "./list-routes.js"; import { registerFileOperationRoutes } from "./operation-routes.js"; -import { resolveSshConnectConfigHost } from "../ssh-dns.js"; -import { resolveSshKeepalive } from "../ssh-keepalive.js"; import { hostAddressMismatch, HostAddressMismatchError, @@ -67,8 +50,19 @@ import { } from "../host-identity.js"; import { registerFileDownloadRoutes } from "./download-routes.js"; import { registerFileActionRoutes } from "./action-routes.js"; -import { applyAgentAuth } from "../terminal-auth-helpers.js"; -import { applyCACertIfPresent } from "./ca-cert-auth.js"; +import { buildConnectConfig } from "../connect/build-connect-config.js"; +import { connectHost } from "../connect/connect-host.js"; +import { getSshAuthProvider } from "../connect/auth-provider-registry.js"; +import { + autoResponses, + classifyKeyboardInteractive, +} from "../connect/keyboard-interactive.js"; +import { + getHostSocks5Config, + openSshTransport, + SshTransportError, +} from "../connect/transport.js"; +import type { SshConnectHost } from "../connect/types.js"; import { listenOnServicePort } from "../../utils/service-listen.js"; /** @@ -213,219 +207,6 @@ function resolveBrowseHostId( return Number.isFinite(parsed) ? parsed : undefined; } -async function buildDedicatedTransferConnectConfig( - host: SSHHost, - userId: string, - client: SSHClient, -): Promise> { - const { ip, port, username } = host; - const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(host.id); - const config: Record = { - host: ip?.replace(/^\[|\]$/g, "") || ip, - port, - username, - tryKeyboard: true, - keepaliveInterval: 30000, - keepaliveCountMax: 120, - readyTimeout: 60000, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 5000, - hostVerifier: await SSHHostKeyVerifier.createHostVerifier( - host.id, - ip, - port, - null, - userId, - false, - preloadedHostData, - ), - env: { - TERM: "xterm-256color", - LANG: "en_US.UTF-8", - LC_ALL: "en_US.UTF-8", - }, - algorithms: { - kex: [ - "curve25519-sha256", - "curve25519-sha256@libssh.org", - "ecdh-sha2-nistp521", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp256", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group-exchange-sha1", - "diffie-hellman-group1-sha1", - ], - serverHostKey: [ - "ssh-ed25519", - "ecdsa-sha2-nistp521", - "ecdsa-sha2-nistp384", - "ecdsa-sha2-nistp256", - "rsa-sha2-512", - "rsa-sha2-256", - "ssh-rsa", - "ssh-dss", - ], - cipher: SSH_ALGORITHMS.cipher, - hmac: [ - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512", - "hmac-sha2-256", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - }; - await resolveSshConnectConfigHost(config); - - const authType = host.authType; - - if (authType === "key" && host.key?.trim()) { - const cleanKey = host.key - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - config.privateKey = Buffer.from(cleanKey, "utf8"); - if (host.keyPassword) config.passphrase = host.keyPassword; - - await applyCACertIfPresent( - config, - client, - config.privateKey as Buffer, - host as { certPublicKey?: string | null }, - username, - host.keyPassword, - ); - } else if (authType === "password") { - if (!host.password) { - throw new Error("Password required for transfer connection"); - } - config.password = host.password; - } else if (usesIssuedCertificate(authType)) { - const { getOPKSSHToken } = await import("../opkssh-auth.js"); - const token = await getOPKSSHToken(userId, host.id); - if (!token) { - throw new Error( - "OPKSSH authentication required. Open a Terminal connection to this host first.", - ); - } - const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js"); - await setupOPKSSHCertAuth( - config as import("ssh2").ConnectConfig, - client, - token, - username, - ); - } else if (authType === "agent") { - const result = await applyAgentAuth( - config, - host.terminalConfig as unknown as Record | undefined, - ); - if ("error" in result) { - throw new Error(result.error); - } - } else if (authType !== "none" && authType !== "tailscale") { - throw new Error(`Unsupported auth type for transfer: ${authType}`); - } - - return config; -} - -function attachDedicatedKeyboardInteractive( - client: SSHClient, - host: SSHHost, -): void { - client.on( - "keyboard-interactive", - ( - _name: string, - _instructions: string, - _instructionsLang: string, - prompts: Array<{ prompt: string; echo: boolean }>, - finish: (responses: string[]) => void, - ) => { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && host.password) { - return host.password; - } - return ""; - }); - finish(responses); - }, - ); -} - -async function startDedicatedTransferConnect( - client: SSHClient, - config: Record, - host: SSHHost, - userId: string, -): Promise { - const proxyConfig: SOCKS5Config | null = - host.useSocks5 && - (host.socks5Host || - (host.socks5ProxyChain && host.socks5ProxyChain.length > 0)) - ? { - useSocks5: host.useSocks5, - socks5Host: host.socks5Host, - socks5Port: host.socks5Port, - socks5Username: host.socks5Username, - socks5Password: host.socks5Password, - socks5ProxyChain: host.socks5ProxyChain, - } - : null; - - const jumpHosts = host.jumpHosts; - const hasJumpHosts = jumpHosts && jumpHosts.length > 0; - - if (hasJumpHosts) { - const jumpClient = await createJumpHostChain(jumpHosts, userId); - if (!jumpClient) { - throw new Error("Failed to connect through jump hosts for transfer"); - } - - await new Promise((resolve, reject) => { - jumpClient.forwardOut( - "127.0.0.1", - 0, - host.ip, - host.port, - (err, stream) => { - if (err) { - jumpClient.end(); - reject( - new Error( - `Failed to forward through jump host for transfer: ${err.message}`, - ), - ); - return; - } - config.sock = stream; - client.connect(config); - resolve(); - }, - ); - }); - return; - } - - if (proxyConfig) { - const proxySocket = await createSocks5Connection( - host.ip, - host.port, - proxyConfig, - ); - if (proxySocket) { - config.sock = proxySocket; - } - } - - client.connect(config); -} - async function openDedicatedTransferSession( browseSessionId: string, dedicatedSessionId: string, @@ -464,14 +245,6 @@ async function openDedicatedTransferSession( return existingSession; } - const client = new SSHClient(); - attachDedicatedKeyboardInteractive(client, host); - const config = await buildDedicatedTransferConnectConfig( - host, - userId, - client, - ); - fileLogger.info("Opening dedicated transfer SSH session", { operation: "transfer_ssh_connect", transferId, @@ -483,26 +256,12 @@ async function openDedicatedTransferSession( username: host.username, }); - await new Promise((resolve, reject) => { - const connectTimeout = setTimeout(() => { - client.end(); - reject(new Error("Transfer SSH connection timed out")); - }, 60000); - - const fail = (err: Error) => { - clearTimeout(connectTimeout); - reject(err); - }; - - client.once("ready", () => { - clearTimeout(connectTimeout); - resolve(); - }); - client.once("error", fail); - - void startDedicatedTransferConnect(client, config, host, userId).catch( - fail, - ); + const { client } = await connectHost(host as unknown as SshConnectHost, { + userId, + purpose: "file-transfer", + timeoutMs: 60000, + overrides: { tcpKeepAliveInitialDelay: 5000 }, + transport: { cloudflare: false }, }); const session: SSHSession = { @@ -829,8 +588,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { sudoPassword: undefined as string | undefined, certPublicKey: undefined as string | undefined, }; - let hostKeepaliveInterval: number | undefined; - let hostKeepaliveCountMax: number | undefined; let resolvedIp = ip; let resolvedPort = port; let resolvedUsername = username; @@ -867,8 +624,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }; resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as Record | undefined; - hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval; - hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax; resolvedScpLegacy = resolvedHost.scpLegacy ?? false; resolvedVaultProfileId = (resolvedHost.vaultProfile as { id?: number } | undefined)?.id ?? @@ -939,8 +694,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }; resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as Record | undefined; - hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval; - hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax; resolvedScpLegacy = resolvedHost.scpLegacy ?? false; resolvedVaultProfileId = (resolvedHost.vaultProfile as { id?: number } | undefined)?.id ?? @@ -997,283 +750,17 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { serverHostId = resolveServerHostId(hostId, resolvedHost) ?? hostId; } - const preloadedHostData = - await SSHHostKeyVerifier.preloadHostData(serverHostId); - const keepalive = resolveSshKeepalive( - hostKeepaliveInterval, - hostKeepaliveCountMax, - 60000, - 5, - ); - const config: Record = { - host: resolvedIp?.replace(/^\[|\]$/g, "") || resolvedIp, - port: resolvedPort, - username: resolvedUsername, - tryKeyboard: true, - ...keepalive, - readyTimeout: 60000, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - hostVerifier: await SSHHostKeyVerifier.createHostVerifier( - serverHostId, - resolvedIp, - resolvedPort, - null, - userId, - false, - preloadedHostData, - ), - env: { - TERM: "xterm-256color", - LANG: "en_US.UTF-8", - LC_ALL: "en_US.UTF-8", - LC_CTYPE: "en_US.UTF-8", - LC_MESSAGES: "en_US.UTF-8", - LC_MONETARY: "en_US.UTF-8", - LC_NUMERIC: "en_US.UTF-8", - LC_TIME: "en_US.UTF-8", - LC_COLLATE: "en_US.UTF-8", - COLORTERM: "truecolor", - }, - algorithms: { - kex: [ - "curve25519-sha256", - "curve25519-sha256@libssh.org", - "ecdh-sha2-nistp521", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp256", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group-exchange-sha1", - "diffie-hellman-group1-sha1", - ], - serverHostKey: [ - "ssh-ed25519", - "ecdsa-sha2-nistp521", - "ecdsa-sha2-nistp384", - "ecdsa-sha2-nistp256", - "rsa-sha2-512", - "rsa-sha2-256", - "ssh-rsa", - "ssh-dss", - ], - cipher: SSH_ALGORITHMS.cipher, - hmac: [ - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512", - "hmac-sha2-256", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - }; - - if ( - resolvedCredentials.authType === "key" && - resolvedCredentials.sshKey && - resolvedCredentials.sshKey.trim() - ) { - try { - config.privateKey = preparePrivateKeyForSSH2( - resolvedCredentials.sshKey, - resolvedCredentials.keyPassword, - ); - - const parsedKey = ssh2Pkg.utils.parseKey( - config.privateKey as Buffer, - resolvedCredentials.keyPassword, - ); - if (parsedKey instanceof Error) throw parsedKey; - - if (resolvedCredentials.keyPassword) - config.passphrase = resolvedCredentials.keyPassword; - - await applyCACertIfPresent( - config, - client, - config.privateKey as Buffer, - resolvedCredentials, - resolvedUsername, - resolvedCredentials.keyPassword, - ); - - connectionLogs.push( - createConnectionLog( - "info", - "sftp_auth", - resolvedCredentials.certPublicKey?.trim() - ? "Using SSH key authentication with CA certificate" - : "Using SSH key authentication", - ), - ); - } catch (keyError) { - if (isPrivateKeyPassphraseError(keyError)) { - return res.json({ status: "passphrase_required", connectionLogs }); - } - - fileLogger.error("SSH key format error for file manager", { - operation: "file_connect", - sessionId, - hostId, - error: keyError.message, - }); - connectionLogs.push( - createConnectionLog( - "error", - "sftp_auth", - `Invalid SSH key format: ${keyError.message}`, - ), - ); - return res - .status(400) - .json({ error: "Invalid SSH key format", connectionLogs }); - } - } else if (resolvedCredentials.authType === "password") { - if (!resolvedCredentials.password) { - connectionLogs.push( - createConnectionLog( - "error", - "sftp_auth", - "Password required for password authentication", - ), - ); - return res.status(400).json({ - error: "Password required for password authentication", - connectionLogs, - }); - } - - config.password = resolvedCredentials.password; - connectionLogs.push( - createConnectionLog("info", "sftp_auth", "Using password authentication"), - ); - } else if (usesIssuedCertificate(resolvedCredentials.authType)) { - try { - const { getOPKSSHToken } = await import("../opkssh-auth.js"); - const token = await getOPKSSHToken(userId, serverHostId); - - if (!token) { - connectionLogs.push( - createConnectionLog( - "error", - "sftp_auth", - "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.", - ), - ); - return res.status(401).json({ - error: - "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.", - requiresOPKSSHAuth: true, - connectionLogs, - }); - } - - const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js"); - await setupOPKSSHCertAuth( - config as import("ssh2").ConnectConfig, - client, - token, - username, - ); - connectionLogs.push( - createConnectionLog( - "info", - "sftp_auth", - "Using OPKSSH certificate authentication", - ), - ); - } catch (opksshError) { - fileLogger.error("OPKSSH authentication error for file manager", { - operation: "file_connect", - sessionId, - hostId, - error: getErrorMessage(opksshError), - }); - connectionLogs.push( - createConnectionLog( - "error", - "sftp_auth", - `OPKSSH authentication failed: ${getErrorMessage(opksshError)}`, - ), - ); - return res.status(500).json({ - error: "OPKSSH authentication failed", - connectionLogs, - }); - } - } else if (resolvedCredentials.authType === "vault") { - try { - const { setupVaultSshSignerAuth } = - await import("../vault-ssh-connect.js"); - await setupVaultSshSignerAuth(config as ConnectConfig, client, { - id: serverHostId, - username: resolvedUsername, - userId, - vaultProfile: { id: resolvedVaultProfileId }, - }); - connectionLogs.push( - createConnectionLog( - "info", - "sftp_auth", - "Using cached Vault-signed certificate", - ), - ); - } catch (vaultError) { - const message = getErrorMessage(vaultError); - fileLogger.warn("Vault authentication unavailable for file manager", { - operation: "file_connect_vault_auth", - sessionId, - hostId, - error: message, - }); - connectionLogs.push(createConnectionLog("error", "sftp_auth", message)); - return res.status(401).json({ - error: message, - requiresVaultAuth: true, - connectionLogs, - }); - } - } else if (resolvedCredentials.authType === "agent") { - const result = await applyAgentAuth(config, resolvedTerminalConfig); - if ("error" in result) { - connectionLogs.push( - createConnectionLog("error", "sftp_auth", result.error), - ); - return res.status(400).json({ error: result.error, connectionLogs }); - } - connectionLogs.push( - createConnectionLog( - "info", - "sftp_auth", - "Using SSH agent authentication", - ), - ); - } else if ( - resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale" || - resolvedCredentials.authType === "warpgate" - ) { - connectionLogs.push( - createConnectionLog( - "info", - "sftp_auth", - "Using keyboard-interactive authentication", - ), - ); - } else { + const effectiveAuthType = + resolvedCredentials.authType || + (resolvedCredentials.sshKey + ? "key" + : resolvedCredentials.password + ? "password" + : undefined); + if (!effectiveAuthType) { fileLogger.warn( "No valid authentication method provided for file manager", - { - operation: "file_connect", - sessionId, - hostId, - authType: resolvedCredentials.authType, - hasPassword: !!resolvedCredentials.password, - hasKey: !!resolvedCredentials.sshKey, - }, + { operation: "file_connect", sessionId, hostId }, ); connectionLogs.push( createConnectionLog( @@ -1288,6 +775,76 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }); } + const connectTarget: SshConnectHost = { + id: serverHostId, + ip: resolvedIp, + port: resolvedPort, + username: resolvedUsername, + userId, + authType: effectiveAuthType, + password: resolvedCredentials.password, + key: resolvedCredentials.sshKey, + keyPassword: resolvedCredentials.keyPassword, + certPublicKey: resolvedCredentials.certPublicKey, + terminalConfig: resolvedTerminalConfig ?? null, + vaultProfile: resolvedVaultProfileId + ? { id: resolvedVaultProfileId } + : null, + jumpHosts: resolvedJumpHosts, + useSocks5: resolvedUseSocks5, + socks5Host: resolvedSocks5Host, + socks5Port: resolvedSocks5Port, + socks5Username: resolvedSocks5Username, + socks5Password: resolvedSocks5Password, + socks5ProxyChain: resolvedSocks5ProxyChain, + }; + + const built = await buildConnectConfig(connectTarget, { + userId, + purpose: "file-manager", + client, + serverHostId, + log: (level, message) => + connectionLogs.push(createConnectionLog(level, "sftp_auth", message)), + }); + const config = built.config; + + if (built.outcome.status !== "ready") { + const outcome = built.outcome; + if (outcome.status === "error" && outcome.code === "passphrase-required") { + return res.json({ status: "passphrase_required", connectionLogs }); + } + fileLogger.warn("File manager SSH auth could not be prepared", { + operation: "file_connect", + sessionId, + hostId, + authType: effectiveAuthType, + error: outcome.message, + }); + connectionLogs.push( + createConnectionLog("error", "sftp_auth", outcome.message), + ); + if (outcome.status === "interaction-required") { + return res.status(401).json({ + error: outcome.message, + requiresAuthInteraction: outcome.interaction, + ...(outcome.flag ? { [outcome.flag]: true } : {}), + connectionLogs, + }); + } + return res.status(400).json({ + error: + outcome.status === "error" && outcome.code === "invalid-key" + ? "Invalid SSH key format" + : outcome.message, + connectionLogs, + }); + } + + // Auth types without a stored secret send a password prompt back to the + // client as "auth_required" so it can ask for credentials and reconnect. + const credentialless = !getSshAuthProvider(effectiveAuthType)?.requiresSecret; + let responseSent = false; connectionLogs.push( @@ -1529,8 +1086,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { connectionLogs, }); } else if ( - (resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale") && + credentialless && (err.message.includes("authentication") || err.message.includes("All configured authentication methods failed")) ) { @@ -1557,407 +1113,182 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { cleanupSession(sessionId); }); + const parkForAnswer = ( + finish: (responses: string[]) => void, + prompts: Array<{ prompt: string; echo: boolean }>, + promptIndex: number, + isWarpgate = false, + ) => { + pendingTOTPSessions[sessionId] = { + client, + finish, + config, + createdAt: Date.now(), + sessionId, + hostId, + ip, + port, + username, + userId, + prompts, + totpPromptIndex: promptIndex, + resolvedPassword: resolvedCredentials.password, + totpAttempts: 0, + ...(isWarpgate ? { isWarpgate: true } : {}), + }; + }; + client.on( "keyboard-interactive", ( name: string, instructions: string, - instructionsLang: string, + _instructionsLang: string, prompts: Array<{ prompt: string; echo: boolean }>, finish: (responses: string[]) => void, ) => { - const promptTexts = prompts.map((p) => p.prompt); + const decision = classifyKeyboardInteractive( + { name, instructions, prompts }, + connectTarget, + ); + const autoFinish = () => + finish(autoResponses(prompts, resolvedCredentials.password)); - const warpgatePattern = /warpgate\s+authentication/i; - const isWarpgate = - warpgatePattern.test(name) || - warpgatePattern.test(instructions) || - promptTexts.some((p) => warpgatePattern.test(p)); - - if (isWarpgate) { - const fullText = `${name}\n${instructions}\n${promptTexts.join("\n")}`; - const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i); - const keyMatch = fullText.match( - /security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i, - ); - - if (urlMatch) { - if (responseSent) return; - responseSent = true; - - connectionLogs.push( - createConnectionLog( - "info", - "sftp_auth", - "Warpgate authentication required", - { url: urlMatch[0] }, - ), - ); - - pendingTOTPSessions[sessionId] = { - client, - finish, - config, - createdAt: Date.now(), - sessionId, - hostId, - ip, - port, - username, - userId, - prompts, - totpPromptIndex: -1, - resolvedPassword: resolvedCredentials.password, - totpAttempts: 0, - isWarpgate: true, - }; - - res.json({ - requires_warpgate: true, - sessionId, - url: urlMatch[0], - securityKey: keyMatch ? keyMatch[1] : "N/A", - connectionLogs, - }); - return; - } + if (decision.kind === "auto") { + finish(decision.responses); + return; } - const totpPromptIndex = prompts.findIndex((p) => - /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( - p.prompt, - ), - ); - - if (totpPromptIndex !== -1) { - if (responseSent) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - return; - } + if (decision.kind === "warpgate") { + if (responseSent) return; responseSent = true; - - if (pendingTOTPSessions[sessionId]) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - return; - } - connectionLogs.push( createConnectionLog( "info", "sftp_auth", - "TOTP verification required", - { prompt: prompts[totpPromptIndex].prompt }, + "Warpgate authentication required", + { url: decision.url }, ), ); - - pendingTOTPSessions[sessionId] = { - client, - finish, - config, - createdAt: Date.now(), + parkForAnswer(finish, prompts, -1, true); + res.json({ + requires_warpgate: true, sessionId, - hostId, - ip, - port, - username, - userId, - prompts, - totpPromptIndex, - resolvedPassword: resolvedCredentials.password, - totpAttempts: 0, - }; + url: decision.url, + securityKey: decision.securityKey, + connectionLogs, + }); + return; + } + const promptIndex = decision.promptIndex; + const promptText = prompts[promptIndex].prompt; + const isPasswordPrompt = /password/i.test(promptText); + + if (decision.kind === "input" && !decision.isPush && !isPasswordPrompt) { + autoFinish(); + return; + } + + if (decision.kind === "input" && isPasswordPrompt && credentialless) { + if (responseSent) return; + responseSent = true; + client.end(); + res.json({ status: "auth_required", reason: "no_keyboard" }); + return; + } + + if (responseSent || pendingTOTPSessions[sessionId]) { + autoFinish(); + return; + } + responseSent = true; + parkForAnswer(finish, prompts, promptIndex); + + if (decision.kind === "input" && isPasswordPrompt) { res.json({ requires_totp: true, sessionId, - prompt: prompts[totpPromptIndex].prompt, - connectionLogs, + prompt: promptText, + isPassword: true, }); - } else { - const hasStoredPassword = - resolvedCredentials.password && - resolvedCredentials.authType !== "none" && - resolvedCredentials.authType !== "tailscale" && - resolvedCredentials.authType !== "warpgate"; - - const passwordPromptIndex = prompts.findIndex((p) => - /password/i.test(p.prompt), - ); - - if (resolvedCredentials.authType === "warpgate") { - finish(prompts.map(() => "")); - return; - } - - if ( - (resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale") && - passwordPromptIndex !== -1 - ) { - if (responseSent) return; - responseSent = true; - - client.end(); - - res.json({ - status: "auth_required", - reason: "no_keyboard", - }); - return; - } - - if (!hasStoredPassword && passwordPromptIndex !== -1) { - if (responseSent) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - return; - } - responseSent = true; - - if (pendingTOTPSessions[sessionId]) { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - finish(responses); - return; - } - - pendingTOTPSessions[sessionId] = { - client, - finish, - config, - createdAt: Date.now(), - sessionId, - hostId, - ip, - port, - username, - userId, - prompts, - totpPromptIndex: passwordPromptIndex, - resolvedPassword: resolvedCredentials.password, - totpAttempts: 0, - }; - - res.json({ - requires_totp: true, - sessionId, - prompt: prompts[passwordPromptIndex].prompt, - isPassword: true, - }); - return; - } - - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && resolvedCredentials.password) { - return resolvedCredentials.password; - } - return ""; - }); - - finish(responses); + return; } + + connectionLogs.push( + createConnectionLog("info", "sftp_auth", "TOTP verification required", { + prompt: promptText, + }), + ); + res.json({ + requires_totp: true, + sessionId, + prompt: promptText, + connectionLogs, + }); }, ); - const proxyConfig: SOCKS5Config | null = - resolvedUseSocks5 && - (resolvedSocks5Host || - (resolvedSocks5ProxyChain && - (resolvedSocks5ProxyChain as ProxyNode[]).length > 0)) - ? { - useSocks5: resolvedUseSocks5, - socks5Host: resolvedSocks5Host, - socks5Port: resolvedSocks5Port, - socks5Username: resolvedSocks5Username, - socks5Password: resolvedSocks5Password, - socks5ProxyChain: resolvedSocks5ProxyChain as ProxyNode[], - } - : null; - - const hasJumpHosts = - resolvedJumpHosts && resolvedJumpHosts.length > 0 && userId; - - if (hasJumpHosts) { - try { - if (proxyConfig) { - connectionLogs.push( - createConnectionLog( - "info", - "proxy", - "Connecting via proxy + jump hosts", - ), - ); - } + if (connectTarget.jumpHosts && connectTarget.jumpHosts.length > 0) { + if (getHostSocks5Config(connectTarget)) { connectionLogs.push( createConnectionLog( "info", - "jump", - `Connecting via ${resolvedJumpHosts.length} jump host(s)`, + "proxy", + "Connecting via proxy + jump hosts", ), ); - const jumpClient = await createJumpHostChain(resolvedJumpHosts, userId); - - if (!jumpClient) { - fileLogger.error("Failed to establish jump host chain", { - operation: "file_jump_chain", - sessionId, - hostId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "jump", - "Failed to establish jump host chain", - ), - ); - return res.status(500).json({ - error: "Failed to connect through jump hosts", - connectionLogs, - }); - } - - let forwardOutDone = false; - const forwardOutTimeout = setTimeout(() => { - if (!forwardOutDone) { - forwardOutDone = true; - fileLogger.error("Timeout waiting for jump host forwardOut", { - operation: "file_jump_forward_timeout", - sessionId, - hostId, - ip, - port, - }); - connectionLogs.push( - createConnectionLog( - "error", - "jump", - "Timed out waiting for jump host tunnel to target host", - ), - ); - jumpClient.end(); - res.status(500).json({ - error: "Jump host tunnel timed out", - connectionLogs, - }); - } - }, 30000); - - jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => { - if (forwardOutDone) return; - forwardOutDone = true; - clearTimeout(forwardOutTimeout); - - if (err) { - fileLogger.error("Failed to forward through jump host", err, { - operation: "file_jump_forward", - sessionId, - hostId, - ip, - port, - }); - connectionLogs.push( - createConnectionLog( - "error", - "jump", - `Failed to forward through jump host: ${err.message}`, - ), - ); - jumpClient.end(); - return res.status(500).json({ - error: "Failed to forward through jump host: " + err.message, - connectionLogs, - }); - } - - config.sock = stream; - client.connect(config); - }); - } catch (error) { - fileLogger.error("Jump host error", error, { - operation: "file_jump_host", - sessionId, - hostId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "jump", - `Jump host error: ${getErrorMessage(error)}`, - ), - ); - return res.status(500).json({ - error: - error instanceof JumpHostChainError - ? `Failed to connect through jump hosts: ${error.message}` - : "Failed to connect through jump hosts", - connectionLogs, - }); } - } else if (proxyConfig) { + connectionLogs.push( + createConnectionLog( + "info", + "jump", + `Connecting via ${connectTarget.jumpHosts.length} jump host(s)`, + ), + ); + } else if (getHostSocks5Config(connectTarget)) { connectionLogs.push( createConnectionLog("info", "proxy", "Connecting via proxy", { - proxyHost: socks5Host, - proxyPort: socks5Port || 1080, + proxyHost: resolvedSocks5Host, + proxyPort: resolvedSocks5Port || 1080, }), ); - try { - const proxySocket = await createSocks5Connection(ip, port, proxyConfig); - if (proxySocket) { - connectionLogs.push( - createConnectionLog( - "success", - "proxy", - "Proxy connected successfully", - ), - ); - config.sock = proxySocket; - } - client.connect(config); - } catch (proxyError) { - fileLogger.error("Proxy connection failed", proxyError, { - operation: "proxy_connect", - sessionId, - hostId, - proxyHost: socks5Host, - proxyPort: socks5Port || 1080, - }); - connectionLogs.push( - createConnectionLog( - "error", - "proxy", - `Proxy connection failed: ${getErrorMessage(proxyError)}`, - ), - ); - return res.status(500).json({ - error: "Proxy connection failed: " + getErrorMessage(proxyError), - connectionLogs, - }); - } - } else { - await resolveSshConnectConfigHost(config); - client.connect(config); } + + try { + const transport = await openSshTransport(connectTarget, config); + if (transport.via === "proxy") { + connectionLogs.push( + createConnectionLog("success", "proxy", "Proxy connected successfully"), + ); + } + if (transport.jumpClient) { + const jumpClient = transport.jumpClient; + client.on("close", () => jumpClient.end()); + } + } catch (error) { + fileLogger.error("File manager transport failed", error, { + operation: "file_transport", + sessionId, + hostId, + }); + const stage = + error instanceof SshTransportError && error.stage === "proxy" + ? "proxy" + : "jump"; + connectionLogs.push( + createConnectionLog("error", stage, getErrorMessage(error)), + ); + return res.status(500).json({ + error: + error instanceof JumpHostChainError + ? `Failed to connect through jump hosts: ${error.message}` + : getErrorMessage(error), + connectionLogs, + }); + } + + client.connect(config); }); /** diff --git a/src/backend/hosts/issued-certificate-auth.ts b/src/backend/hosts/issued-certificate-auth.ts deleted file mode 100644 index b5dccdc55..000000000 --- a/src/backend/hosts/issued-certificate-auth.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Auth types whose SSH certificate is issued on demand through a browser - * sign-in and cached per user and host (opkssh_tokens). They share the - * whole connect path; only the issuing flow differs. - */ -export const ISSUED_CERTIFICATE_AUTH_TYPES = ["opkssh", "stepca"] as const; - -export function usesIssuedCertificate(authType: string | null | undefined) { - return (ISSUED_CERTIFICATE_AUTH_TYPES as readonly string[]).includes( - authType ?? "", - ); -} diff --git a/src/backend/hosts/jump-host-chain.ts b/src/backend/hosts/jump-host-chain.ts index f4167a3cf..4dd5ae41a 100644 --- a/src/backend/hosts/jump-host-chain.ts +++ b/src/backend/hosts/jump-host-chain.ts @@ -1,12 +1,11 @@ import { Client as SSHClient } from "ssh2"; import { fileLogger } from "../utils/logger.js"; import { createSocks5Connection } from "../utils/socks5-helper.js"; -import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; -import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; import { getErrorMessage } from "../utils/error-message.js"; -import { SSHHostKeyVerifier } from "./host-key-verifier.js"; import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; -import { applyAgentAuth } from "./terminal-auth-helpers.js"; +import { buildConnectConfig } from "./connect/build-connect-config.js"; +import { createAutoKeyboardInteractiveHandler } from "./connect/keyboard-interactive.js"; +import type { SshConnectHost } from "./connect/types.js"; import { resolveHostById } from "./host-resolver.js"; type JumpHostConfig = { @@ -114,15 +113,6 @@ export async function createJumpHostChain( const jumpClient = new SSHClient(); clients.push(jumpClient); - const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier( - jumpHostConfig.id, - jumpHostConfig.ip, - jumpHostConfig.port || 22, - null, - userId, - true, - ); - let lastError: Error | null = null; // eslint-disable-next-line no-async-promise-executor @@ -167,100 +157,28 @@ export async function createJumpHostChain( resolve(false); }); - const connectConfig: Record = { - host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip, - port: jumpHostConfig.port || 22, - username: jumpHostConfig.username, - tryKeyboard: jumpHostConfig.authType !== "none", - readyTimeout: readyTimeoutMs, - hostVerifier: jumpHostVerifier, - algorithms: { - kex: [ - "curve25519-sha256", - "curve25519-sha256@libssh.org", - "ecdh-sha2-nistp521", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp256", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group18-sha512", - "diffie-hellman-group17-sha512", - "diffie-hellman-group16-sha512", - "diffie-hellman-group15-sha512", - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group-exchange-sha1", - "diffie-hellman-group1-sha1", - ], - serverHostKey: [ - "ssh-ed25519", - "ecdsa-sha2-nistp521", - "ecdsa-sha2-nistp384", - "ecdsa-sha2-nistp256", - "rsa-sha2-512", - "rsa-sha2-256", - "ssh-rsa", - "ssh-dss", - ], - cipher: SSH_ALGORITHMS.cipher, - hmac: [ - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512", - "hmac-sha2-256", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - }; - - if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { - connectConfig.password = jumpHostConfig.password; - } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { - try { - connectConfig.privateKey = preparePrivateKeyForSSH2( - jumpHostConfig.key, - jumpHostConfig.keyPassword, - ); - } catch (keyError) { - clearTimeout(timeout); - lastError = new Error( - `Jump host ${i + 1}/${totalHops} key error: ${getErrorMessage(keyError, "Invalid private key format")}`, - ); - resolve(false); - return; - } - if (jumpHostConfig.keyPassword) { - connectConfig.passphrase = jumpHostConfig.keyPassword; - } - } else if (jumpHostConfig.authType === "agent") { - const result = await applyAgentAuth( - connectConfig, - jumpHostConfig.terminalConfig as - Record | undefined, + // Each hop goes through the same pipeline as the target, so hops get + // every auth type, not just password, key and agent. + const built = await buildConnectConfig( + jumpHostConfig as unknown as SshConnectHost, + { userId, purpose: "jump-host", client: jumpClient }, + ); + if (built.outcome.status !== "ready") { + clearTimeout(timeout); + lastError = new Error( + `Jump host ${i + 1}/${totalHops}: ${built.outcome.message}`, ); - if ("error" in result) { - throw new Error(result.error); - } + resolve(false); + return; } + const connectConfig = built.config; + connectConfig.readyTimeout = readyTimeoutMs; jumpClient.on( "keyboard-interactive", - ( - _name: string, - _instructions: string, - _lang: string, - prompts: Array<{ prompt: string; echo: boolean }>, - finish: (responses: string[]) => void, - ) => { - const responses = prompts.map((p) => { - if (/password/i.test(p.prompt) && jumpHostConfig.password) { - return jumpHostConfig.password as string; - } - return ""; - }); - finish(responses); - }, + createAutoKeyboardInteractiveHandler( + jumpHostConfig as unknown as SshConnectHost, + ), ); if (currentClient) { diff --git a/src/backend/hosts/ssh-client-factory.ts b/src/backend/hosts/ssh-client-factory.ts index 60eccef75..c5afc340a 100644 --- a/src/backend/hosts/ssh-client-factory.ts +++ b/src/backend/hosts/ssh-client-factory.ts @@ -1,224 +1,45 @@ -import { getErrorMessage } from "../utils/error-message.js"; -import { Client, type ConnectConfig } from "ssh2"; +import type { Client } from "ssh2"; import type { SSHHost } from "../../types/index.js"; -import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; -import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; import { - createSocks5Connection, - type SOCKS5Config, -} from "../utils/socks5-helper.js"; -import { SSHHostKeyVerifier } from "./host-key-verifier.js"; -import { createJumpHostChain } from "./jump-host-chain.js"; -import { resolveSshConnectConfigHost } from "./ssh-dns.js"; + connectHost, + getConnectionPoolKey, + SshConnectError, +} from "./connect/connect-host.js"; +import { buildConnectConfig } from "./connect/build-connect-config.js"; +import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js"; /** - * Non-interactive SSH connection helpers shared by fleet execution. - * - * Mirrors buildSshConfig/createSshFactory/getPoolKey in - * plugins/host-metrics/src/backend/routes.ts, - * minus the keyboard-interactive TOTP prompt handling (metrics/terminal-only) - * and OPKSSH/Vault cert setup (not supported as fleet auth for v1 - those flows - * need an interactive browser step that has no place in a batch fleet run). + * Non-interactive SSH connections for background work (fleets, automations, + * AI tools). A thin wrapper over the connect pipeline, kept so the pool keys + * and call sites stay the same. */ export function getFleetPoolKey(host: SSHHost): string { - const socks5Key = host.useSocks5 - ? `:socks5:${host.socks5Host}:${host.socks5Port}` - : ""; - return `fleet:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`; + return getConnectionPoolKey("fleet", host as unknown as SshConnectHost); } export async function buildFleetSshConfig( host: SSHHost, -): Promise { - const base: ConnectConfig = { - host: host.ip?.replace(/^\[|\]$/g, "") || host.ip, - port: host.port, - username: host.username, - keepaliveInterval: 30000, - keepaliveCountMax: 3, - readyTimeout: 30000, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - hostVerifier: await SSHHostKeyVerifier.createHostVerifier( - host.id, - host.ip, - host.port, - null, - host.userId || "", - false, - ), - env: { - TERM: "xterm-256color", - LANG: "en_US.UTF-8", - LC_ALL: "en_US.UTF-8", - }, - algorithms: { - kex: [ - "curve25519-sha256", - "curve25519-sha256@libssh.org", - "ecdh-sha2-nistp521", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp256", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group-exchange-sha1", - "diffie-hellman-group1-sha1", - ], - serverHostKey: [ - "ssh-ed25519", - "ecdsa-sha2-nistp521", - "ecdsa-sha2-nistp384", - "ecdsa-sha2-nistp256", - "rsa-sha2-512", - "rsa-sha2-256", - "ssh-rsa", - "ssh-dss", - ], - cipher: SSH_ALGORITHMS.cipher, - hmac: [ - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512", - "hmac-sha2-256", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - } as ConnectConfig; - - const authType = host.authType; - - if (authType === "password" || authType === "credential") { - if (!host.password) { - throw new Error(`No password available for host ${host.ip}`); - } - base.password = host.password; - } else if (authType === "key") { - if (!host.key) { - throw new Error(`No SSH key available for host ${host.ip}`); - } - (base as Record).privateKey = preparePrivateKeyForSSH2( - host.key, - host.keyPassword, - ); - if (host.keyPassword) { - (base as Record).passphrase = host.keyPassword; - } - } else if (authType === "none" || authType === "tailscale") { - // no credentials needed - } else { - throw new Error( - `Unsupported authentication type '${authType}' for fleet execution on host ${host.ip}`, - ); + client?: Client, +): Promise { + const { Client: SshClient } = await import("ssh2"); + const built = await buildConnectConfig(host as unknown as SshConnectHost, { + userId: host.userId || "", + purpose: "fleet", + client: client ?? new SshClient(), + }); + if (built.outcome.status !== "ready") { + throw new SshConnectError(built.outcome); } - - return base; + return built.config; } export function createFleetSshFactory(host: SSHHost): () => Promise { return async () => { - const config = await buildFleetSshConfig(host); - const client = new Client(); - - const proxyConfig: SOCKS5Config | null = - host.useSocks5 && - (host.socks5Host || - (host.socks5ProxyChain && host.socks5ProxyChain.length > 0)) - ? { - useSocks5: host.useSocks5, - socks5Host: host.socks5Host, - socks5Port: host.socks5Port, - socks5Username: host.socks5Username, - socks5Password: host.socks5Password, - socks5ProxyChain: host.socks5ProxyChain, - } - : null; - - const hasJumpHosts = - host.jumpHosts && host.jumpHosts.length > 0 && host.userId; - - let jumpClient: Client | null = null; - if (hasJumpHosts) { - jumpClient = await createJumpHostChain(host.jumpHosts!, host.userId!); - if (!jumpClient) { - throw new Error("Failed to establish jump host chain"); - } - } else if (proxyConfig) { - try { - const proxySocket = await createSocks5Connection( - host.ip, - host.port, - proxyConfig, - ); - if (proxySocket) { - config.sock = proxySocket; - } - } catch (proxyError) { - throw new Error( - "Proxy connection failed: " + getErrorMessage(proxyError), - { cause: proxyError }, - ); - } - } - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - client.end(); - jumpClient?.end(); - reject(new Error("SSH connection timeout")); - }, 30000); - - client.on("ready", () => { - clearTimeout(timeout); - resolve(client); - }); - - client.on("close", () => { - jumpClient?.end(); - }); - - client.on("error", (err) => { - clearTimeout(timeout); - jumpClient?.end(); - reject(err); - }); - - if (jumpClient) { - jumpClient.forwardOut( - "127.0.0.1", - 0, - host.ip, - host.port, - (err, stream) => { - if (err) { - clearTimeout(timeout); - jumpClient!.end(); - reject( - new Error( - "Failed to forward through jump host: " + err.message, - ), - ); - return; - } - config.sock = stream; - client.connect(config); - }, - ); - } else if (config.sock) { - client.connect(config); - } else { - resolveSshConnectConfigHost(config) - .then(() => { - client.connect(config); - }) - .catch((error) => { - clearTimeout(timeout); - reject(error); - }); - } + const connection = await connectHost(host as unknown as SshConnectHost, { + userId: host.userId || "", + purpose: "fleet", }); + return connection.client; }; } diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts index e554e6662..4f49c58e6 100644 --- a/src/backend/hosts/terminal/index.ts +++ b/src/backend/hosts/terminal/index.ts @@ -1,6 +1,5 @@ import { getErrorMessage } from "../../utils/error-message.js"; import { getAuditUsername } from "../../utils/audit-logger.js"; -import { usesIssuedCertificate } from "../issued-certificate-auth.js"; import { collabRoomHub } from "../collab/room-hub.js"; import type { SessionShareRecord } from "../../database/repositories/session-share-repository.js"; import { createCurrentCollabRoomRepository } from "../../database/repositories/factory.js"; @@ -18,21 +17,26 @@ import ssh2Pkg, { type PseudoTtyOptions, } from "ssh2"; const { Client, utils: ssh2Utils } = ssh2Pkg; -import { buildSSHAlgorithms } from "../../utils/ssh-algorithms.js"; import axios from "axios"; import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js"; import { sshLogger, authLogger } from "../../utils/logger.js"; import { logAudit } from "../../utils/audit-logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; +import { SSHAuthManager } from "../connect/terminal-prompt.js"; +import { buildConnectConfig } from "../connect/build-connect-config.js"; import { - createSocks5Connection, - type SOCKS5Config, -} from "../../utils/socks5-helper.js"; -import { SSHAuthManager } from "../auth-manager.js"; -import type { ProxyNode } from "../../../types/index.js"; -import { SSHHostKeyVerifier } from "../host-key-verifier.js"; -import { createJumpHostChain, JumpHostChainError } from "../jump-host-chain.js"; + getSshAuthProvider, + listSshAuthProviders, +} from "../connect/auth-provider-registry.js"; +import { ensureCoreSshAuthProviders } from "../connect/core-providers.js"; +import { getHostSocks5Config, openSshTransport } from "../connect/transport.js"; +import type { + SshAuthEnv, + SshAuthProvider, + SshConnectHost, +} from "../connect/types.js"; +import { JumpHostChainError } from "../jump-host-chain.js"; import { parseTailscaleCheckBanner, isTailscaleCheckCompleteBanner, @@ -50,17 +54,11 @@ import { attachOrCreateTmuxSession, waitForTmuxSession, } from "../tmux/helper.js"; -import { - MemoryAgent, - performPortKnocking, - applyAgentAuth, -} from "../terminal-auth-helpers.js"; +import { MemoryAgent } from "../terminal-auth-helpers.js"; import { isWindowsSftpPath, sftpPathToLocalPath } from "../transfer-paths.js"; -import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; import { dispatchLoginEvent } from "../../utils/login-event-dispatch.js"; import { getClientIp } from "../../utils/request-origin.js"; import { isRetriableDnsError, resolveHostForSshConnect } from "../ssh-dns.js"; -import { resolveSshKeepalive } from "../ssh-keepalive.js"; import { hostAddressMismatch, HOST_ADDRESS_MISMATCH_MESSAGE, @@ -69,12 +67,10 @@ import { resolveServerJumpHosts, } from "../host-identity.js"; import { extractWebSocketToken } from "../../utils/ws-auth.js"; -import { - createWebSocketDuplex, - waitForWebSocketOpen, -} from "../cloudflare-websocket.js"; import { hostSessionStatus } from "../host-session-status.js"; +class SshInteractionUnavailableError extends Error {} + interface ConnectToHostData { cols: number; rows: number; @@ -94,6 +90,7 @@ interface ConnectToHostData { credentialId?: number; userId?: string; forceKeyboardInteractive?: boolean; + useWarpgate?: boolean; jumpHosts?: Array<{ hostId: number }>; useSocks5?: boolean; socks5Host?: string; @@ -1112,52 +1109,7 @@ wss.on("connection", async (ws: WebSocket, req) => { case "opkssh_start_auth": { const opksshData = data as { hostId: number }; try { - const { startOPKSSHAuth } = await import("../opkssh-auth.js"); - const { getRequestOrigin } = - await import("../../utils/request-origin.js"); - const host = - await createCurrentHostResolutionRepository().findHostById( - opksshData.hostId, - userId, - ); - if (!host) { - sshLogger.error( - `Host ${opksshData.hostId} not found for OPKSSH auth`, - { - operation: "opkssh_start_auth_host_not_found", - userId, - hostId: opksshData.hostId, - }, - ); - ws.send( - JSON.stringify({ - type: "opkssh_error", - requestId: "", - error: "Host not found", - }), - ); - break; - } - const hostname = host.name || host.ip; - const requestOrigin = getRequestOrigin(req); - if (host.authType === "stepca") { - const { startStepCaAuth } = await import("../step-ca-auth.js"); - await startStepCaAuth( - userId, - opksshData.hostId, - host.username, - ws, - requestOrigin, - ); - break; - } - await startOPKSSHAuth( - userId, - opksshData.hostId, - hostname, - ws, - requestOrigin, - ); + await startAuthInteraction("opkssh", opksshData.hostId, data); } catch (error) { sshLogger.error("Failed to start OPKSSH auth", error, { operation: "opkssh_start_auth_error", @@ -1168,7 +1120,10 @@ wss.on("connection", async (ws: WebSocket, req) => { JSON.stringify({ type: "opkssh_error", requestId: "", - error: "Failed to start OPKSSH authentication", + error: + error instanceof SshInteractionUnavailableError + ? error.message + : "Failed to start OPKSSH authentication", }), ); } @@ -1245,32 +1200,7 @@ wss.on("connection", async (ws: WebSocket, req) => { case "vault_start_auth": { const vaultData = data as { hostId: number }; try { - const { loadVaultProfileForHost, startVaultAuth } = - await import("../vault-oidc-auth.js"); - const { getRequestOrigin } = - await import("../../utils/request-origin.js"); - const profile = await loadVaultProfileForHost( - vaultData.hostId, - userId, - ); - if (!profile) { - ws.send( - JSON.stringify({ - type: "vault_error", - hostId: vaultData.hostId, - error: "No Vault signer profile configured for this host", - }), - ); - break; - } - const requestOrigin = getRequestOrigin(req); - await startVaultAuth( - userId, - vaultData.hostId, - profile, - ws, - requestOrigin, - ); + await startAuthInteraction("vault", vaultData.hostId, data); } catch (error) { sshLogger.error("Failed to start Vault auth", error, { operation: "vault_start_auth_error", @@ -1534,6 +1464,53 @@ wss.on("connection", async (ws: WebSocket, req) => { } }); + /** + * Starts the browser sign-in behind an "_auth_required" + * message. The provider for the host's auth type wins; otherwise the first + * provider that owns this interaction (Vault attaches to any auth type). + */ + async function startAuthInteraction( + interaction: string, + hostId: number, + payload: unknown, + ): Promise { + ensureCoreSshAuthProviders(); + const host = await createCurrentHostResolutionRepository().findHostById( + hostId, + userId, + ); + if (!host) throw new SshInteractionUnavailableError("Host not found"); + + const own = getSshAuthProvider(host.authType as string); + const provider = + own?.interaction === interaction && own.startInteraction + ? own + : listSshAuthProviders().find( + (candidate) => + candidate.interaction === interaction && + candidate.startInteraction, + ); + if (!provider?.startInteraction) { + throw new SshInteractionUnavailableError( + `No enabled provider handles ${interaction} sign-in`, + ); + } + + const { getRequestOrigin } = await import("../../utils/request-origin.js"); + await provider.startInteraction({ + userId, + hostId, + host: { + name: host.name as string | null, + ip: host.ip as string, + username: host.username as string, + }, + socket: ws, + requestOrigin: getRequestOrigin(req), + payload: (payload ?? {}) as Record, + }); + } + async function handleConnectToHost(data: ConnectToHostData) { const { hostConfig, initialPath, executeCommand, tmuxAttachSession } = data; const { @@ -1660,8 +1637,11 @@ wss.on("connection", async (ws: WebSocket, req) => { let connectionTimeout = setTimeout(onConnectionTimeout, 120000); let tailscaleCheckPending = false; - let tailscaleForcePasswordAttempted = false; - let isTailscaleRetrying = false; + let authRetries = 0; + let isAuthRetrying = false; + let connectProvider: SshAuthProvider | null = null; + let connectEnv: SshAuthEnv | null = null; + let connectTarget: SshConnectHost | null = null; let clearOnlineStatus: (() => void) | null = null; let resolvedHostData: @@ -1974,7 +1954,7 @@ wss.on("connection", async (ws: WebSocket, req) => { sshConn.on("ready", () => { clearOnlineStatus ??= hostSessionStatus.register(serverHostId); clearTimeout(connectionTimeout); - isTailscaleRetrying = false; + isAuthRetrying = false; if (tailscaleCheckPending) { tailscaleCheckPending = false; ws.send( @@ -2530,103 +2510,31 @@ wss.on("connection", async (ws: WebSocket, req) => { keyboardInteractiveResponded, }); - if ( - usesIssuedCertificate(resolvedCredentials.authType) && - err.message.includes("All configured authentication methods failed") - ) { - sshLogger.warn("OPKSSH authentication failed - invalidating token", { - operation: "opkssh_auth_failed", - hostId: id, - userId, - error: err.message, - }); - - (async () => { - try { - const { invalidateOPKSSHToken } = await import("../opkssh-auth.js"); - await invalidateOPKSSHToken(userId, id, "SSH auth failed"); - } catch (invalidateError) { - sshLogger.error("Failed to invalidate OPKSSH token", { - operation: "opkssh_token_invalidation_error", - userId, - hostId: id, - error: invalidateError, - }); - } - })(); + // The provider decides what an auth failure means: clear a cached + // certificate and ask for a new sign-in, retry once, or give up with + // its own message. + const authFollowUp = + connectProvider?.onAuthFailed && connectEnv && connectTarget + ? connectProvider.onAuthFailed(connectTarget, connectEnv, { + error: err, + retries: authRetries, + canRetry: !connectConfig.sock && !tailscaleCheckPending, + methodNotAvailable: authMethodNotAvailable, + }) + : undefined; + if (authFollowUp?.status === "interaction-required") { if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); - - sendLog( - "auth", - "error", - "OPKSSH certificate authentication failed. Please authenticate again.", - ); - + sendLog("auth", "error", authFollowUp.message); ws.send( JSON.stringify({ - type: "opkssh_auth_required", + type: `${authFollowUp.interaction}_auth_required`, hostId: id, - message: - "OPKSSH authentication failed or expired. Please authenticate again.", - }), - ); - return; - } - - if ( - resolvedCredentials.authType === "vault" && - err.message.includes("All configured authentication methods failed") - ) { - sshLogger.warn("Vault certificate authentication failed", { - operation: "vault_auth_failed", - hostId: id, - userId, - error: err.message, - }); - - (async () => { - try { - const profileId = ( - resolvedHostData?.vaultProfile as { id?: number } | undefined - )?.id; - if (profileId) { - const { deleteVaultCert } = - await import("../vault-signer-auth.js"); - await deleteVaultCert(userId, profileId); - } - } catch (invalidateError) { - sshLogger.error("Failed to invalidate Vault certificate", { - operation: "vault_cert_invalidation_error", - userId, - hostId: id, - error: invalidateError, - }); - } - })(); - - if (currentSessionId) { - sessionManager.destroySession(currentSessionId); - currentSessionId = null; - } - cleanupAuthState(connectionTimeout); - - sendLog( - "auth", - "error", - "Vault certificate authentication failed. Please authenticate again.", - ); - - ws.send( - JSON.stringify({ - type: "vault_auth_required", - hostId: id, - message: - "Vault authentication failed or expired. Please authenticate again.", + message: authFollowUp.message, }), ); return; @@ -2657,71 +2565,39 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - // Tailscale documents the "+password" username suffix as the workaround for - // clients that mishandle a successful reply to auth type "none". It routes - // through PasswordCallback into the same check-mode flow, and the password - // value is ignored. Retry once before reporting an auth failure. - // Skipped when tunnelled: connectConfig.sock is a one-shot stream that - // cannot be reused for a second connect. - if ( - resolvedCredentials.authType === "tailscale" && - !tailscaleForcePasswordAttempted && - !tailscaleCheckPending && - !connectConfig.sock && - (authMethodNotAvailable || - err.message.includes("All configured authentication methods failed")) - ) { - tailscaleForcePasswordAttempted = true; - - sendLog( - "auth", - "info", - "Retrying Tailscale SSH in forced password mode", - ); - sshLogger.info("Retrying Tailscale SSH with +password suffix", { - operation: "tailscale_force_password_retry", + if (authFollowUp?.status === "retry") { + authRetries++; + sendLog("auth", "info", authFollowUp.message); + sshLogger.info("Retrying SSH auth with provider changes", { + operation: "ssh_auth_retry", hostId: id, userId, - username, }); clearTimeout(connectionTimeout); connectionTimeout = setTimeout( onConnectionTimeout, - TAILSCALE_CHECK_TIMEOUT_MS, + (connectConfig.readyTimeout as number | undefined) ?? 120000, ); - connectConfig.username = `${username}+password`; - connectConfig.password = "termix"; - connectConfig.tryKeyboard = false; + Object.assign(connectConfig, authFollowUp.patch); // ssh2's connect() ends an open socket and reconnects on close, keeping // every listener attached, so the same client can be reused here. - isTailscaleRetrying = true; + isAuthRetrying = true; sshConn.connect(connectConfig); return; } - if ( - resolvedCredentials.authType === "tailscale" && - (authMethodNotAvailable || - err.message.includes("All configured authentication methods failed")) - ) { - sendLog( - "auth", - "error", - `Tailscale SSH authentication failed for user "${username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${username}" user to your identity (check tailscale.com/s/ssh for the check/action ACL syntax). If your Tailscale identity maps to a different Unix user, update the username on this host.`, - ); + if (authFollowUp?.status === "error") { + sendLog("auth", "error", authFollowUp.message); if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); ws.send( - JSON.stringify({ - type: "error", - message: `Tailscale SSH authentication failed for user "${username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${username}" user to your identity. If your Tailscale identity maps to a different Unix user, update the username on this host.`, - }), + JSON.stringify({ type: "error", message: authFollowUp.message }), ); return; } @@ -2865,7 +2741,7 @@ wss.on("connection", async (ws: WebSocket, req) => { sshConn.on("close", () => { // The +password retry ends the socket before reconnecting; that close is // part of the retry, not a disconnect. - if (isTailscaleRetrying) { + if (isAuthRetrying) { return; } @@ -2981,276 +2857,14 @@ wss.on("connection", async (ws: WebSocket, req) => { }, ); - const hostKeepaliveInterval = hostConfig.terminalConfig?.keepaliveInterval; - const hostKeepaliveCountMax = hostConfig.terminalConfig?.keepaliveCountMax; - const keepalive = resolveSshKeepalive( - hostKeepaliveInterval, - hostKeepaliveCountMax, - 30000, - 5, - ); - - // Pre-fetch the stored host key before connect so the verifier callback - // runs synchronously during SSH key exchange, avoiding LoginGraceTime - // expiry on slow connections (especially through jump host tunnels). - const preloadedHostData = - await SSHHostKeyVerifier.preloadHostData(serverHostId); - - const connectConfig: Record = { - host: connectHost, - port, - username, - tryKeyboard: resolvedCredentials.authType !== "tailscale", - ...keepalive, - readyTimeout: - resolvedCredentials.authType === "tailscale" - ? TAILSCALE_CHECK_TIMEOUT_MS - : 120000, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - // The socket sits idle while a Tailscale check-mode login is pending. - timeout: - resolvedCredentials.authType === "tailscale" - ? TAILSCALE_CHECK_TIMEOUT_MS - : 120000, - hostVerifier: await SSHHostKeyVerifier.createHostVerifier( - serverHostId, - ip, - port, - ws, - userId, - false, - preloadedHostData, - ), - env: { - TERM: "xterm-256color", - LANG: "en_US.UTF-8", - LC_ALL: "en_US.UTF-8", - LC_CTYPE: "en_US.UTF-8", - LC_MESSAGES: "en_US.UTF-8", - LC_MONETARY: "en_US.UTF-8", - LC_NUMERIC: "en_US.UTF-8", - LC_TIME: "en_US.UTF-8", - LC_COLLATE: "en_US.UTF-8", - COLORTERM: "truecolor", - }, - algorithms: buildSSHAlgorithms( - hostConfig.terminalConfig?.allowLegacyAlgorithms !== false, - ), - }; - - if ( - resolvedCredentials.authType === "none" || - resolvedCredentials.authType === "tailscale" - ) { - // Tailscale SSH and "none": no static credentials needed - } else if (resolvedCredentials.authType === "password") { - if (!resolvedCredentials.password) { - sshLogger.error( - "Password authentication requested but no password provided", - ); - ws.send( - JSON.stringify({ - type: "error", - message: - "Password authentication requested but no password provided", - }), - ); - return; - } - - if (!hostConfig.forceKeyboardInteractive) { - connectConfig.password = resolvedCredentials.password; - } - sendLog("auth", "info", "Using password authentication"); - } else if ( - resolvedCredentials.authType === "key" && - resolvedCredentials.key - ) { - sendLog("auth", "info", "Using SSH key authentication"); - try { - connectConfig.privateKey = preparePrivateKeyForSSH2( - resolvedCredentials.key, - resolvedCredentials.keyPassword, - ); - - if (resolvedCredentials.keyPassword) { - connectConfig.passphrase = resolvedCredentials.keyPassword; - } - - if (resolvedCredentials.password) { - connectConfig.password = resolvedCredentials.password; - } - - // Apply CA-signed certificate if one is stored in the credential - if ( - resolvedCredentials.certPublicKey && - resolvedCredentials.certPublicKey.trim() - ) { - try { - const { setupCACertAuth } = await import("../opkssh-cert-auth.js"); - await setupCACertAuth( - connectConfig, - sshConn, - connectConfig.privateKey as Buffer, - resolvedCredentials.certPublicKey, - username, - resolvedCredentials.keyPassword, - ); - sendLog("auth", "info", "CA certificate authentication configured"); - sshLogger.info("CA cert auth configured", { - operation: "ca_cert_auth_configured", - userId, - hostId: id, - }); - } catch (certError) { - sendLog( - "auth", - "warning", - "CA certificate setup failed – falling back to key-only auth", - ); - sshLogger.warn("CA cert auth setup failed", { - operation: "ca_cert_auth_setup_failed", - userId, - hostId: id, - error: getErrorMessage(certError, String(certError)), - }); - } - } - } catch (keyError) { - const message = getErrorMessage(keyError, "Invalid private key format"); - sshLogger.error("SSH key format error: " + message); - ws.send( - JSON.stringify({ - type: "error", - message: `SSH key format error: ${message}`, - }), - ); - return; - } - } else if (resolvedCredentials.authType === "key") { - sendLog( - "auth", - "error", - "SSH key authentication requested but no key provided", - ); - sshLogger.error("SSH key authentication requested but no key provided"); - ws.send( - JSON.stringify({ - type: "error", - message: "SSH key authentication requested but no key provided", - }), - ); - return; - } else if (usesIssuedCertificate(resolvedCredentials.authType)) { - sendLog("auth", "info", "Using issued SSH certificate authentication"); - try { - const { getOPKSSHToken } = await import("../opkssh-auth.js"); - const token = await getOPKSSHToken(userId, id); - - if (!token) { - sendLog( - "auth", - "info", - "No valid certificate found, requesting sign-in", - ); - ws.send( - JSON.stringify({ - type: "opkssh_auth_required", - hostId: id, - }), - ); - return; - } - - sendLog("auth", "info", "Using cached SSH certificate"); - - const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js"); - await setupOPKSSHCertAuth(connectConfig, sshConn, token, username); - } catch (opksshError) { - sshLogger.error("OPKSSH authentication error", opksshError, { - operation: "opkssh_auth_error", - userId, - hostId: id, - }); - ws.send( - JSON.stringify({ - type: "error", - message: - "OPKSSH authentication failed: " + getErrorMessage(opksshError), - }), - ); - return; - } - } else if (resolvedCredentials.authType === "vault") { - sendLog("auth", "info", "Using Vault SSH signer authentication"); - try { - const vaultProfile = resolvedHostData?.vaultProfile as - { id: number } | undefined; - if (!vaultProfile?.id) { - throw new Error("Host has no Vault signer profile configured"); - } - - const { getVaultCert } = await import("../vault-signer-auth.js"); - const cert = await getVaultCert(userId, vaultProfile.id); - - if (!cert) { - sendLog( - "auth", - "info", - "No valid Vault certificate found, requesting authentication", - ); - ws.send( - JSON.stringify({ - type: "vault_auth_required", - hostId: id, - }), - ); - return; - } - - sendLog("auth", "info", "Using cached Vault-signed certificate"); - - const { setupOPKSSHCertAuth } = await import("../opkssh-cert-auth.js"); - await setupOPKSSHCertAuth( - connectConfig, - sshConn, - { privateKey: cert.privateKey, sshCert: cert.sshCert }, - username, - ); - } catch (vaultError) { - sshLogger.error("Vault SSH signer authentication error", vaultError, { - operation: "vault_auth_error", - userId, - hostId: id, - }); - ws.send( - JSON.stringify({ - type: "error", - message: - "Vault SSH signer authentication failed: " + - getErrorMessage(vaultError), - }), - ); - return; - } - } else if (resolvedCredentials.authType === "agent") { - sendLog("auth", "info", "Using SSH agent authentication"); - const result = await applyAgentAuth( - connectConfig, - hostConfig.terminalConfig as Record | undefined, - ); - if ("error" in result) { - ws.send(JSON.stringify({ type: "error", message: result.error })); - return; - } - sendLog( - "auth", - "info", - `SSH agent configured (socket: ${result.socketPath})`, - ); - } else { - sendLog("auth", "info", "Using keyboard-interactive authentication"); + const effectiveAuthType = + resolvedCredentials.authType || + (resolvedCredentials.key + ? "key" + : resolvedCredentials.password + ? "password" + : undefined); + if (!effectiveAuthType) { sshLogger.error("No valid authentication method provided"); ws.send( JSON.stringify({ @@ -3261,6 +2875,88 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } + connectTarget = { + ...(resolvedHostData ?? {}), + id: serverHostId ?? id, + ip, + port, + username, + userId: hostConfig.userId, + authType: effectiveAuthType, + password: resolvedCredentials.password, + key: resolvedCredentials.key, + keyPassword: resolvedCredentials.keyPassword, + keyType: resolvedCredentials.keyType, + certPublicKey: resolvedCredentials.certPublicKey, + forceKeyboardInteractive: hostConfig.forceKeyboardInteractive, + useWarpgate: hostConfig.useWarpgate, + terminalConfig: hostConfig.terminalConfig, + jumpHosts: hostConfig.jumpHosts, + useSocks5: hostConfig.useSocks5, + socks5Host: hostConfig.socks5Host, + socks5Port: hostConfig.socks5Port, + socks5Username: hostConfig.socks5Username, + socks5Password: hostConfig.socks5Password, + socks5ProxyChain: hostConfig.socks5ProxyChain, + portKnockSequence: hostConfig.portKnockSequence, + } as SshConnectHost; + + const built = await buildConnectConfig(connectTarget, { + userId, + purpose: "terminal", + client: sshConn, + serverHostId, + hostKeySocket: ws, + interactive: true, + log: (level, message) => sendLog("auth", level, message), + }); + const connectConfig = built.config; + // DNS was resolved above, and skipped when jump hosts do it instead. + connectConfig.host = connectHost; + connectProvider = built.provider; + connectEnv = built.env; + + if (built.outcome.status !== "ready") { + const outcome = built.outcome; + if (outcome.status === "interaction-required") { + ws.send( + JSON.stringify({ + type: `${outcome.interaction}_auth_required`, + hostId: id, + }), + ); + return; + } + if ( + outcome.status === "error" && + outcome.code === "passphrase-required" + ) { + sendLog( + "auth", + "error", + "SSH key is encrypted but no passphrase was provided", + ); + isAwaitingAuthCredentials = true; + cleanupAuthState(connectionTimeout); + ws.send( + JSON.stringify({ + type: "passphrase_required", + message: outcome.message, + }), + ); + return; + } + sshLogger.error("SSH auth could not be prepared", { + operation: "terminal_auth_prepare", + hostId: id, + authType: effectiveAuthType, + error: outcome.message, + }); + sendLog("auth", "error", outcome.message); + ws.send(JSON.stringify({ type: "error", message: outcome.message })); + return; + } + if (hostConfig.terminalConfig?.agentForwarding) { if (connectConfig.privateKey) { try { @@ -3279,10 +2975,7 @@ wss.on("connection", async (ws: WebSocket, req) => { hostId: id, }); } - } else if ( - resolvedCredentials.authType === "agent" && - connectConfig.agent - ) { + } else if (connectConfig.agent) { connectConfig.agentForward = true; sendLog( "auth", @@ -3292,229 +2985,63 @@ wss.on("connection", async (ws: WebSocket, req) => { } } - if ( - hostConfig.portKnockSequence && - hostConfig.portKnockSequence.length > 0 - ) { - try { - sshLogger.info( - `Port knocking ${hostConfig.ip} (${hostConfig.portKnockSequence.length} ports)`, - { operation: "port_knock", hostId: hostConfig.id }, - ); - await performPortKnocking(hostConfig.ip, hostConfig.portKnockSequence); - } catch { - sshLogger.warn("Port knocking failed, attempting connection anyway", { - operation: "port_knock", - hostId: hostConfig.id, - }); - } - } + const viaProxy = !!getHostSocks5Config(connectTarget); - const proxyConfig: SOCKS5Config | null = - hostConfig.useSocks5 && - (hostConfig.socks5Host || - (hostConfig.socks5ProxyChain && - (hostConfig.socks5ProxyChain as ProxyNode[]).length > 0)) - ? { - useSocks5: hostConfig.useSocks5, - socks5Host: hostConfig.socks5Host, - socks5Port: hostConfig.socks5Port, - socks5Username: hostConfig.socks5Username, - socks5Password: hostConfig.socks5Password, - socks5ProxyChain: hostConfig.socks5ProxyChain as ProxyNode[], - } - : null; - - const hasJumpHosts = - hostConfig.jumpHosts && - hostConfig.jumpHosts.length > 0 && - hostConfig.userId; - - // Cloudflare Tunnel: connect via WebSocket proxy - const cfConfig = hostConfig.terminalConfig as - Record | undefined; - if (cfConfig?.cfAccessClientId && cfConfig?.cfAccessClientSecret) { - try { - const WebSocket = (await import("ws")).default; - const cfHostname = (cfConfig.cfTunnelHostname as string) || ip; - const wsUrl = `wss://${cfHostname}/cdn-cgi/access/ssh-connect`; - const cfWs = new WebSocket(wsUrl, { - headers: { - "CF-Access-Client-Id": cfConfig.cfAccessClientId as string, - "CF-Access-Client-Secret": cfConfig.cfAccessClientSecret as string, - }, - }); - - await waitForWebSocketOpen(cfWs, 30000); - - const duplexStream = createWebSocketDuplex(cfWs); - - connectConfig.sock = - duplexStream as unknown as typeof connectConfig.sock; - sendLog("handshake", "info", "Connected via Cloudflare Tunnel"); - } catch (cfError) { - sshLogger.error("Cloudflare tunnel connection failed", cfError, { - operation: "cf_tunnel_connect", - hostId: id, - }); - ws.send( - JSON.stringify({ - type: "error", - message: - "Cloudflare tunnel connection failed: " + - getErrorMessage(cfError), - }), - ); - cleanupAuthState(connectionTimeout); - return; - } - } - - if (hasJumpHosts) { - try { - const jumpClient = await createJumpHostChain( - hostConfig.jumpHosts!, - hostConfig.userId!, - ); - - if (!jumpClient) { - sshLogger.error("Failed to establish jump host chain"); - ws.send( - JSON.stringify({ - type: "error", - message: "Failed to connect through jump hosts", - }), - ); - if (currentSessionId) { - sessionManager.destroySession(currentSessionId); - currentSessionId = null; - } - cleanupAuthState(connectionTimeout); - return; - } - lastJumpClient = jumpClient; - - jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => { - if (err) { - sshLogger.error("Failed to forward through jump host", err, { - operation: "ssh_jump_forward", - hostId: id, - ip, - port, - }); - ws.send( - JSON.stringify({ - type: "error", - message: "Failed to forward through jump host: " + err.message, - }), - ); - jumpClient.end(); - if (currentSessionId) { - sessionManager.destroySession(currentSessionId); - currentSessionId = null; - } - cleanupAuthState(connectionTimeout); - return; - } - - connectConfig.sock = stream; - sendLog( - "handshake", - "info", - "Starting SSH session through jump host" + - (proxyConfig ? " (via proxy)" : ""), - ); - sendLog("auth", "info", `Authenticating as ${username}`); - sshLogger.info("Initiating SSH connection", { - operation: "terminal_ssh_connect_attempt", - sessionId, - userId, - hostId: id, - ip, - port, - username, - authType: resolvedCredentials.authType, - viaProxy: !!proxyConfig, - }); - sshConn.connect(connectConfig); - }); - } catch (error) { - sshLogger.error("Jump host error", error, { - operation: "ssh_jump_host", - hostId: id, - }); - ws.send( - JSON.stringify({ - type: "error", - message: - error instanceof JumpHostChainError - ? `Failed to connect through jump hosts: ${error.message}` - : "Failed to connect through jump hosts", - }), - ); - if (currentSessionId) { - sessionManager.destroySession(currentSessionId); - currentSessionId = null; - } - cleanupAuthState(connectionTimeout); - return; - } - } else if (proxyConfig) { - try { - const proxySocket = await createSocks5Connection(ip, port, proxyConfig); - if (proxySocket) { - connectConfig.sock = proxySocket; - } - } catch (proxyError) { - sshLogger.error("Proxy connection failed", proxyError, { - operation: "proxy_connect", - hostId: id, - proxyHost: hostConfig.socks5Host, - proxyPort: hostConfig.socks5Port || 1080, - }); - ws.send( - JSON.stringify({ - type: "error", - message: "Proxy connection failed: " + getErrorMessage(proxyError), - }), - ); - if (currentSessionId) { - sessionManager.destroySession(currentSessionId); - currentSessionId = null; - } - cleanupAuthState(connectionTimeout); - return; - } - sendLog("handshake", "info", "Starting SSH session (via proxy)"); - sendLog("auth", "info", `Authenticating as ${username}`); - sshLogger.info("Initiating SSH connection", { - operation: "terminal_ssh_connect_attempt", - sessionId, - userId, + let transport: Awaited>; + try { + transport = await openSshTransport(connectTarget, connectConfig, { + // Resolved above, or deliberately left to the jump host. + resolveDns: false, + log: (level, message) => sendLog("handshake", level, message), + }); + } catch (error) { + sshLogger.error("SSH transport failed", error, { + operation: "ssh_transport", hostId: id, ip, port, - username, - authType: resolvedCredentials.authType, - viaProxy: true, }); - sshConn.connect(connectConfig); - } else { - sendLog("handshake", "info", "Starting SSH session"); - sendLog("auth", "info", `Authenticating as ${username}`); - - sshLogger.info("Initiating SSH connection", { - operation: "terminal_ssh_connect_attempt", - sessionId, - userId, - hostId: id, - ip, - port, - username, - authType: resolvedCredentials.authType, - }); - sshConn.connect(connectConfig); + ws.send( + JSON.stringify({ + type: "error", + message: + error instanceof JumpHostChainError + ? `Failed to connect through jump hosts: ${error.message}` + : getErrorMessage(error), + }), + ); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); + return; } + lastJumpClient = transport.jumpClient; + + sendLog( + "handshake", + "info", + transport.via === "jump" + ? "Starting SSH session through jump host" + + (viaProxy ? " (via proxy)" : "") + : transport.via === "proxy" + ? "Starting SSH session (via proxy)" + : "Starting SSH session", + ); + sendLog("auth", "info", `Authenticating as ${username}`); + sshLogger.info("Initiating SSH connection", { + operation: "terminal_ssh_connect_attempt", + sessionId, + userId, + hostId: id, + ip, + port, + username, + authType: resolvedCredentials.authType, + via: transport.via, + }); + sshConn.connect(connectConfig); } function handleResize(data: ResizeData) { diff --git a/src/backend/hosts/tmux/auth-utils.ts b/src/backend/hosts/tmux/auth-utils.ts deleted file mode 100644 index 726b1f8d1..000000000 --- a/src/backend/hosts/tmux/auth-utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { SSHHost } from "../../../types/index.js"; - -export function getTmuxAuthBehavior(authType: SSHHost["authType"]): { - credentialless: boolean; - tryKeyboard: boolean; -} { - return { - credentialless: authType === "none" || authType === "tailscale", - tryKeyboard: authType !== "tailscale", - }; -} diff --git a/src/backend/hosts/tmux/index.ts b/src/backend/hosts/tmux/index.ts index ef7e07955..e583d5c36 100644 --- a/src/backend/hosts/tmux/index.ts +++ b/src/backend/hosts/tmux/index.ts @@ -1,7 +1,9 @@ import { getErrorMessage } from "../../utils/error-message.js"; import express from "express"; import cookieParser from "cookie-parser"; -import { Client, type ConnectConfig } from "ssh2"; +import type { Client } from "ssh2"; +import { connectHost, getConnectionPoolKey } from "../connect/connect-host.js"; +import type { SshConnectHost } from "../connect/types.js"; import { createCorsMiddleware } from "../../utils/cors-config.js"; import { createCompressionMiddleware } from "../../utils/compression-config.js"; import { AuthManager } from "../../utils/auth-manager.js"; @@ -12,19 +14,9 @@ import { } from "../../database/repositories/factory.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; import { sshLogger } from "../../utils/logger.js"; -import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; -import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; -import { SSHHostKeyVerifier } from "../host-key-verifier.js"; import { resolveHostById, checkHostAccess } from "../host-resolver.js"; import type { HostAction } from "../../utils/permission-manager.js"; -import { createJumpHostChain } from "../jump-host-chain.js"; -import { applyAgentAuth } from "../terminal-auth-helpers.js"; -import { - createSocks5Connection, - type SOCKS5Config, -} from "../../utils/socks5-helper.js"; import { withConnection } from "../ssh-connection-pool.js"; -import { resolveSshConnectConfigHost } from "../ssh-dns.js"; import { execCommand, tmuxCommand } from "./helper.js"; import { SEP, @@ -42,7 +34,6 @@ import { type PaneMetrics, } from "./monitor-helpers.js"; import type { SSHHost, AuthenticatedRequest } from "../../../types/index.js"; -import { getTmuxAuthBehavior } from "./auth-utils.js"; import { listenOnServicePort } from "../../utils/service-listen.js"; const PANE_ID_RE = /^%\d+$/; @@ -59,179 +50,23 @@ interface TmuxSessionOverview extends TmuxSessionSummary { tags: string[]; } -// SSH connection (lean variant of the per-module pattern used by server-stats -// and docker; jump hosts and SOCKS5 reuse the shared helpers) - -async function buildSshConfig(host: SSHHost): Promise { - const authBehavior = getTmuxAuthBehavior(host.authType); - const base: ConnectConfig = { - host: (host.ip || "").replace(/^\[|\]$/g, ""), - port: host.port, - username: host.username, - tryKeyboard: authBehavior.tryKeyboard, - keepaliveInterval: 30000, - keepaliveCountMax: 3, - readyTimeout: 60000, - hostVerifier: await SSHHostKeyVerifier.createHostVerifier( - host.id, - host.ip, - host.port, - null, - host.userId || "", - false, - ), - algorithms: SSH_ALGORITHMS, - } as ConnectConfig; - - if (host.authType === "password") { - if (!host.password) { - throw new Error(`No password available for host ${host.ip}`); - } - base.password = host.password; - } else if (host.authType === "key") { - if (!host.key) { - throw new Error(`No valid SSH key available for host ${host.ip}`); - } - (base as Record).privateKey = preparePrivateKeyForSSH2( - host.key, - host.keyPassword, - ); - if (host.keyPassword) { - (base as Record).passphrase = host.keyPassword; - } - } else if (authBehavior.credentialless) { - // no credentials needed - } else if (host.authType === "vault") { - // cert auth setup happens in connectToHost (needs client instance) - } else if (host.authType === "agent") { - const result = await applyAgentAuth( - base as Record, - host.terminalConfig as unknown as Record | undefined, - ); - if ("error" in result) { - throw new Error(result.error); - } - } else { - // opkssh and other interactive flows are not supported by this module - throw new Error( - `Authentication type '${host.authType}' is not supported by the tmux monitor. Open a terminal connection instead.`, - ); - } - - return base; -} +// SSH connection export function connectToHost(host: SSHHost): () => Promise { return async () => { - const config = await buildSshConfig(host); - const client = new Client(); - - if (host.authType === "vault") { - const { setupVaultSshSignerAuth } = - await import("../vault-ssh-connect.js"); - await setupVaultSshSignerAuth(config, client, host); - } - - const proxyConfig: SOCKS5Config | null = - host.useSocks5 && - (host.socks5Host || - (host.socks5ProxyChain && host.socks5ProxyChain.length > 0)) - ? { - useSocks5: host.useSocks5, - socks5Host: host.socks5Host, - socks5Port: host.socks5Port, - socks5Username: host.socks5Username, - socks5Password: host.socks5Password, - socks5ProxyChain: host.socks5ProxyChain, - } - : null; - - let jumpClient: Client | null = null; - if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) { - jumpClient = await createJumpHostChain(host.jumpHosts, host.userId); - if (!jumpClient) { - throw new Error("Failed to establish jump host chain"); - } - } else if (proxyConfig) { - const proxySocket = await createSocks5Connection( - host.ip, - host.port, - proxyConfig, - ); - if (proxySocket) { - config.sock = proxySocket; - } - } - - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - client.end(); - jumpClient?.end(); - reject(new Error("SSH connection timeout")); - }, 30000); - - client.on("ready", () => { - clearTimeout(timeout); - resolve(client); - }); - client.on("error", (err) => { - clearTimeout(timeout); - jumpClient?.end(); - reject(err); - }); - client.on( - "keyboard-interactive", - (_name, _instructions, _lang, prompts, finish) => { - finish( - prompts.map((p) => - /password/i.test(p.prompt) ? host.password || "" : "", - ), - ); - }, - ); - - if (jumpClient) { - jumpClient.forwardOut( - "127.0.0.1", - 0, - host.ip, - host.port, - (err, stream) => { - if (err) { - clearTimeout(timeout); - jumpClient!.end(); - reject( - new Error( - "Failed to forward through jump host: " + err.message, - ), - ); - return; - } - config.sock = stream; - client.connect(config); - }, - ); - } else if (config.sock) { - client.connect(config); - } else { - resolveSshConnectConfigHost(config) - .then(() => { - client.connect(config); - }) - .catch((error) => { - clearTimeout(timeout); - reject(error); - }); - } + const connection = await connectHost(host as unknown as SshConnectHost, { + userId: host.userId || "", + purpose: "tmux", }); + return connection.client; }; } function getPoolKey(host: SSHHost): string { - const socks5Key = host.useSocks5 - ? `:socks5:${host.socks5Host}:${host.socks5Port}` - : ""; - return `tmux-monitor:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`; + return getConnectionPoolKey( + "tmux-monitor", + host as unknown as SshConnectHost, + ); } async function withHostConnection( diff --git a/src/backend/hosts/tunnel/c2s-relay.ts b/src/backend/hosts/tunnel/c2s-relay.ts index 7fb3a125f..965431a95 100644 --- a/src/backend/hosts/tunnel/c2s-relay.ts +++ b/src/backend/hosts/tunnel/c2s-relay.ts @@ -5,7 +5,7 @@ import { createSocks5Connection } from "../../utils/socks5-helper.js"; import { tunnelLogger } from "../../utils/logger.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { - applyAuthOptions, + applyTunnelAuth, bindForwardIn, connectClient, forwardOut, @@ -163,13 +163,28 @@ async function connectC2SSourceClient( algorithms: getManagedTunnelAlgorithms(), }; - applyAuthOptions(connOptions, { - password: tunnelConfig.sourcePassword, - sshKey: tunnelConfig.sourceSSHKey, - keyPassword: tunnelConfig.sourceKeyPassword, - keyType: tunnelConfig.sourceKeyType, - authMethod: tunnelConfig.sourceAuthMethod, - }); + const client = new Client(); + const authOutcome = await applyTunnelAuth( + connOptions, + { + password: tunnelConfig.sourcePassword, + sshKey: tunnelConfig.sourceSSHKey, + keyPassword: tunnelConfig.sourceKeyPassword, + keyType: tunnelConfig.sourceKeyType, + authMethod: tunnelConfig.sourceAuthMethod, + }, + { + client, + userId: tunnelConfig.requestingUserId || tunnelConfig.sourceUserId, + hostId: tunnelConfig.sourceHostId, + username: tunnelConfig.sourceUsername, + ip: tunnelConfig.sourceIP, + port: tunnelConfig.sourceSSHPort, + }, + ); + if (authOutcome.status !== "ready") { + throw new Error(authOutcome.message); + } if ( tunnelConfig.useSocks5 && @@ -194,7 +209,7 @@ async function connectC2SSourceClient( } } - return connectClient(connOptions, tunnelConfig.name, "source"); + return connectClient(connOptions, tunnelConfig.name, "source", client); } async function handleC2SRemoteRelayOpen( diff --git a/src/backend/hosts/tunnel/manager.ts b/src/backend/hosts/tunnel/manager.ts index 8220647ce..1e81dfa42 100644 --- a/src/backend/hosts/tunnel/manager.ts +++ b/src/backend/hosts/tunnel/manager.ts @@ -23,9 +23,8 @@ import { SystemCrypto } from "../../utils/system-crypto.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { createSocks5Connection } from "../../utils/socks5-helper.js"; import { withConnection } from "../ssh-connection-pool.js"; -import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; import { - applyAuthOptions, + applyTunnelAuth, bindForwardIn, connectClient, forwardOut, @@ -659,8 +658,29 @@ export async function connectEndpointThroughSource( algorithms: getManagedTunnelAlgorithms(), }; - applyAuthOptions(endpointOptions, endpointCredentials); - return connectClient(endpointOptions, tunnelConfig.name, "endpoint"); + const endpointClient = new Client(); + const authOutcome = await applyTunnelAuth( + endpointOptions, + endpointCredentials, + { + client: endpointClient, + userId: tunnelConfig.endpointUserId, + hostId: null, + username: tunnelConfig.endpointUsername, + ip: tunnelConfig.endpointIP, + port: tunnelConfig.endpointSSHPort, + }, + ); + if (authOutcome.status !== "ready") { + endpointSock.destroy(); + throw new Error(authOutcome.message); + } + return connectClient( + endpointOptions, + tunnelConfig.name, + "endpoint", + endpointClient, + ); } export function resolveS2SLocalTargetHost(tunnelConfig: TunnelConfig): string { @@ -1500,51 +1520,24 @@ export async function connectSSHTunnel( }, }; - if ( - resolvedSourceCredentials.authMethod === "key" && - resolvedSourceCredentials.sshKey - ) { - try { - connOptions.privateKey = preparePrivateKeyForSSH2( - resolvedSourceCredentials.sshKey, - resolvedSourceCredentials.keyPassword, - ); - } catch (error) { - const message = getErrorMessage(error, "Invalid SSH key format"); - tunnelLogger.error( - `Invalid SSH key format for tunnel '${tunnelName}': ${message}`, - undefined, - { - operation: "tunnel_invalid_ssh_key_format", - tunnelName, - sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`, - keyType: resolvedSourceCredentials.keyType, - }, - ); - broadcastTunnelStatus(tunnelName, { - connected: false, - status: CONNECTION_STATES.FAILED, - reason: message, - }); - tunnelConnecting.delete(tunnelName); - return; - } - - if (resolvedSourceCredentials.keyPassword) { - connOptions.passphrase = resolvedSourceCredentials.keyPassword; - } - if ( - resolvedSourceCredentials.keyType && - resolvedSourceCredentials.keyType !== "auto" - ) { - connOptions.privateKeyType = resolvedSourceCredentials.keyType; - } - } else if (resolvedSourceCredentials.authMethod === "key") { + const sourceAuth = await applyTunnelAuth( + connOptions, + resolvedSourceCredentials, + { + client: conn, + userId: effectiveUserId, + hostId: tunnelConfig.sourceHostId, + username: tunnelConfig.sourceUsername, + ip: tunnelConfig.sourceIP, + port: tunnelConfig.sourceSSHPort, + }, + ); + if (sourceAuth.status !== "ready") { tunnelLogger.error( - `SSH key authentication requested but no key provided for tunnel '${tunnelName}'`, + `SSH auth could not be prepared for tunnel '${tunnelName}': ${sourceAuth.message}`, undefined, { - operation: "tunnel_ssh_key_missing", + operation: "tunnel_auth_prepare_failed", tunnelName, sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`, authMethod: resolvedSourceCredentials.authMethod, @@ -1553,12 +1546,10 @@ export async function connectSSHTunnel( broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, - reason: "SSH key authentication requested but no key provided", + reason: sourceAuth.message, }); tunnelConnecting.delete(tunnelName); return; - } else { - connOptions.password = resolvedSourceCredentials.password; } const finalStatus = connectionStatus.get(tunnelName); @@ -1694,23 +1685,6 @@ export async function killRemoteTunnelByMarker( } } - if ( - resolvedSourceCredentials.authMethod === "key" && - resolvedSourceCredentials.sshKey - ) { - try { - preparePrivateKeyForSSH2( - resolvedSourceCredentials.sshKey, - resolvedSourceCredentials.keyPassword, - ); - } catch (error) { - callback( - error instanceof Error ? error : new Error("Invalid SSH key format"), - ); - return; - } - } - const poolKey = `tunnel:${tunnelConfig.sourceUserId}:${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}:${tunnelConfig.sourceUsername}`; const factory = async (): Promise => { @@ -1758,25 +1732,21 @@ export async function killRemoteTunnelByMarker( }, }; - if ( - resolvedSourceCredentials.authMethod === "key" && - resolvedSourceCredentials.sshKey - ) { - connOptions.privateKey = preparePrivateKeyForSSH2( - resolvedSourceCredentials.sshKey, - resolvedSourceCredentials.keyPassword, - ); - if (resolvedSourceCredentials.keyPassword) { - connOptions.passphrase = resolvedSourceCredentials.keyPassword; - } - if ( - resolvedSourceCredentials.keyType && - resolvedSourceCredentials.keyType !== "auto" - ) { - connOptions.privateKeyType = resolvedSourceCredentials.keyType; - } - } else { - connOptions.password = resolvedSourceCredentials.password; + const conn = new Client(); + const killAuth = await applyTunnelAuth( + connOptions, + resolvedSourceCredentials, + { + client: conn, + userId: tunnelConfig.requestingUserId || tunnelConfig.sourceUserId, + hostId: tunnelConfig.sourceHostId, + username: tunnelConfig.sourceUsername, + ip: tunnelConfig.sourceIP, + port: tunnelConfig.sourceSSHPort, + }, + ); + if (killAuth.status !== "ready") { + throw new Error(killAuth.message); } if ( @@ -1827,7 +1797,6 @@ export async function killRemoteTunnelByMarker( } return new Promise((resolve, reject) => { - const conn = new Client(); conn.on("ready", () => resolve(conn)); conn.on("error", (err) => reject(err)); conn.connect(connOptions); diff --git a/src/backend/hosts/tunnel/ssh-primitives.ts b/src/backend/hosts/tunnel/ssh-primitives.ts index 96d2abbfc..c4bc03e5f 100644 --- a/src/backend/hosts/tunnel/ssh-primitives.ts +++ b/src/backend/hosts/tunnel/ssh-primitives.ts @@ -2,6 +2,9 @@ import { Client, type ClientChannel } from "ssh2"; import type { Duplex } from "stream"; import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; import { tunnelLogger } from "../../utils/logger.js"; +import { getSshAuthProvider } from "../connect/auth-provider-registry.js"; +import { ensureCoreSshAuthProviders } from "../connect/core-providers.js"; +import type { MutableConnectConfig, SshAuthOutcome } from "../connect/types.js"; export function getManagedTunnelAlgorithms() { return { @@ -40,40 +43,90 @@ export function getManagedTunnelAlgorithms() { }; } -export function applyAuthOptions( +export interface TunnelCredentials { + password?: string; + sshKey?: string; + keyPassword?: string; + keyType?: string; + authMethod?: string; + certPublicKey?: string | null; + terminalConfig?: Record | null; + vaultProfile?: { id?: number | null } | null; +} + +/** + * Puts a tunnel side's auth into its connect options through the same + * providers every other transport uses, so tunnels accept every auth type. + */ +export async function applyTunnelAuth( connOptions: Record, - credentials: { - password?: string; - sshKey?: string; - keyPassword?: string; - keyType?: string; - authMethod?: string; + credentials: TunnelCredentials, + env: { + client: Client; + userId?: string | null; + hostId?: number | null; + username: string; + ip: string; + port: number; }, -): void { - if (credentials.authMethod === "key" && credentials.sshKey) { - const cleanKey = credentials.sshKey - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - connOptions.privateKey = Buffer.from(cleanKey, "utf8"); - if (credentials.keyPassword) { - connOptions.passphrase = credentials.keyPassword; - } - if (credentials.keyType && credentials.keyType !== "auto") { - connOptions.privateKeyType = credentials.keyType; - } - } else { - connOptions.password = credentials.password; +): Promise { + ensureCoreSshAuthProviders(); + const authType = + credentials.authMethod || (credentials.sshKey ? "key" : "password"); + const provider = getSshAuthProvider(authType); + if (!provider) { + return { + status: "error", + code: "provider-missing", + message: `Unsupported authentication type '${authType}' for tunnels`, + }; } + + const outcome = await provider.prepare( + connOptions as MutableConnectConfig, + { + id: env.hostId ?? 0, + ip: env.ip, + port: env.port, + username: env.username, + userId: env.userId, + authType, + password: credentials.password, + key: credentials.sshKey, + keyPassword: credentials.keyPassword, + keyType: credentials.keyType, + certPublicKey: credentials.certPublicKey, + terminalConfig: credentials.terminalConfig ?? null, + vaultProfile: credentials.vaultProfile ?? null, + }, + { + client: env.client, + userId: env.userId ?? "", + hostId: env.hostId ?? 0, + purpose: "tunnel", + interactive: false, + log: () => {}, + }, + ); + + if ( + outcome.status === "ready" && + connOptions.privateKey && + credentials.keyType && + credentials.keyType !== "auto" + ) { + connOptions.privateKeyType = credentials.keyType; + } + return outcome; } export function connectClient( connOptions: Record, tunnelName: string, role: "source" | "endpoint", + client: Client = new Client(), ): Promise { return new Promise((resolve, reject) => { - const client = new Client(); let settled = false; client.once("ready", () => { settled = true; diff --git a/src/backend/hosts/vault-ssh-connect.ts b/src/backend/hosts/vault-ssh-connect.ts deleted file mode 100644 index 61637e852..000000000 --- a/src/backend/hosts/vault-ssh-connect.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Client, ConnectConfig } from "ssh2"; - -type VaultAuthHost = { - id: number; - username: string; - userId?: string | null; - vaultProfile?: { id?: number | null } | null; -}; - -export async function setupVaultSshSignerAuth( - config: ConnectConfig, - client: Client, - host: VaultAuthHost, -): Promise { - if (!host.userId) { - throw new Error("Vault SSH signer authentication requires a user session"); - } - - const vaultProfileId = host.vaultProfile?.id; - if (!vaultProfileId) { - throw new Error("Host has no Vault signer profile configured"); - } - - const { getVaultCert } = await import("./vault-signer-auth.js"); - const cert = await getVaultCert(host.userId, vaultProfileId); - if (!cert) { - throw new Error( - "Vault SSH signer authentication required. Please open a Terminal connection first.", - ); - } - - const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js"); - await setupOPKSSHCertAuth( - config, - client, - { privateKey: cert.privateKey, sshCert: cert.sshCert }, - host.username, - ); -} diff --git a/src/backend/plugins/ctx-ssh-auth.ts b/src/backend/plugins/ctx-ssh-auth.ts new file mode 100644 index 000000000..77f663940 --- /dev/null +++ b/src/backend/plugins/ctx-ssh-auth.ts @@ -0,0 +1,412 @@ +/** + * ctx.ssh and ctx.auth. + * + * ctx.ssh is a thin, capability-checked door onto core's connect pipeline. + * ctx.auth registers login methods, second factors and SSH auth types into + * core's registries, scoped to the plugin's manifest and its disposable bag. + */ + +import type { + PluginAuth, + PluginSsh, + PluginSshConnectOptions, + PluginSshHost, +} from "@termix/plugin-sdk/backend"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import { PluginCapabilityError } from "@termix/plugin-sdk/backend"; +import { assertCapability } from "./permissions.js"; +import { getActor } from "./actor.js"; +import type { DisposableBag } from "./disposables.js"; +import { + getSshAuthProvider, + registerKeyboardInteractiveInterceptor, + registerSshAuthProvider, +} from "../hosts/connect/auth-provider-registry.js"; +import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interactive.js"; +import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js"; +import { registerLoginMethod, registerSecondFactor } from "../auth/registry.js"; +import type { + MutableConnectConfig, + SshAuthProvider, + SshConnectHost, + SshConnectPurpose, +} from "../hosts/connect/types.js"; + +type AuditFn = ( + action: string, + details: string, + outcome: { success: boolean; errorMessage?: string }, +) => Promise; + +interface Deps { + manifest: PluginManifest; + bag: DisposableBag; + audit: AuditFn; +} + +const PLUGIN_PURPOSES = new Set([ + "plugin", + "docker", + "docker-console", + "metrics", + "proxmox", + "fleet", + "remote-desktop", + "tunnel", +]); + +function purposeOf(options?: PluginSshConnectOptions): SshConnectPurpose { + const purpose = (options?.purpose ?? "plugin") as SshConnectPurpose; + return PLUGIN_PURPOSES.has(purpose) ? purpose : "plugin"; +} + +/** + * The user a connection runs as: the request's actor, or for background work + * on a resolved host, that host's owner. A bare host id with no actor has + * nobody to resolve it for. + */ +function actingUser(host: number | PluginSshHost): string { + const actor = getActor(); + if (actor) return actor; + if (typeof host === "object" && host.userId) return host.userId; + throw new Error( + "ctx.ssh needs an acting user: call it inside a request, ctx.asUser, or pass a resolved host", + ); +} + +function describeHost(host: number | PluginSshHost): string { + return typeof host === "number" ? `host ${host}` : `host ${host.id}`; +} + +export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { + const pluginId = manifest.id; + const declared = manifest.capabilities; + const open = new Set<() => void>(); + const poolKeys = new Set(); + + bag.add(async () => { + for (const dispose of [...open]) dispose(); + open.clear(); + if (poolKeys.size > 0) { + const { connectionPool } = + await import("../hosts/ssh-connection-pool.js"); + for (const key of poolKeys) connectionPool.clearKeyConnections(key); + poolKeys.clear(); + } + }, "SSH connections"); + + const checkSsh = async (withCredentials: boolean) => { + await assertCapability(pluginId, "ssh:connect", declared); + if (withCredentials) { + await assertCapability(pluginId, "credentials:use", declared); + } + }; + + /** Checks, connects and audits one new connection. */ + const connectOnce = async ( + host: number | PluginSshHost, + options?: PluginSshConnectOptions, + ) => { + try { + await checkSsh(true); + } catch (error) { + await audit("ssh_connect", describeHost(host), { + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + throw error; + } + + const userId = actingUser(host); + const { connectHost } = await import("../hosts/connect/connect-host.js"); + try { + const connection = await connectHost(host as number | SshConnectHost, { + userId, + purpose: purposeOf(options), + timeoutMs: options?.timeoutMs, + prompt: options?.prompt, + overrides: options?.overrides as Partial, + }); + await audit("ssh_connect", describeHost(host), { success: true }); + + const dispose = () => { + open.delete(dispose); + connection.dispose(); + }; + open.add(dispose); + connection.client.once("close", () => open.delete(dispose)); + return { ...connection, dispose }; + } catch (error) { + await audit("ssh_connect", describeHost(host), { + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }; + + const poolKey = (pool: string, host: PluginSshHost) => + `${pool}:${host.userId}:${host.ip}:${host.port}:${host.username}${ + host.useSocks5 ? `:socks5:${host.socks5Host}:${host.socks5Port}` : "" + }`; + + return { + connect: async (host, options) => { + const connection = await connectOnce(host, options); + return { + client: connection.client as never, + jumpClient: connection.jumpClient as never, + dispose: connection.dispose, + }; + }, + + withConnection: async (host, options, fn) => { + await checkSsh(true); + const { resolveConnectHost } = + await import("../hosts/connect/connect-host.js"); + const resolved = await resolveConnectHost( + host as number | SshConnectHost, + actingUser(host), + ); + const key = poolKey(options.pool, resolved as PluginSshHost); + poolKeys.add(key); + const { withConnection } = + await import("../hosts/ssh-connection-pool.js"); + return withConnection( + key, + async () => + (await connectOnce(resolved as PluginSshHost, options)).client, + (client) => fn(client as never), + ); + }, + + jumpChain: async (jumpHosts, chainOptions) => { + await checkSsh(true); + const userId = actingUser(chainOptions?.forHost ?? 0); + const { createJumpHostChain } = + await import("../hosts/jump-host-chain.js"); + const client = await createJumpHostChain(jumpHosts, userId); + const details = `jump chain of ${jumpHosts.length} hop(s)`; + if (!client) { + await audit("ssh_jump_chain", details, { + success: false, + errorMessage: "Jump host chain could not be established", + }); + throw new Error("Jump host chain could not be established"); + } + await audit("ssh_jump_chain", details, { success: true }); + const dispose = () => { + open.delete(dispose); + client.end(); + }; + open.add(dispose); + client.once("close", () => open.delete(dispose)); + return { client: client as never, jumpClient: null, dispose }; + }, + + poolKey, + + prepare: async (host, options) => { + await checkSsh(true); + const { buildConnectConfig } = + await import("../hosts/connect/build-connect-config.js"); + const built = await buildConnectConfig(host as SshConnectHost, { + userId: actingUser(host), + purpose: purposeOf(options), + client: options.client as never, + serverHostId: options.serverHostId, + log: options.log, + }); + await audit("ssh_prepare", describeHost(host), { + success: built.outcome.status === "ready", + errorMessage: + built.outcome.status === "ready" ? undefined : built.outcome.message, + }); + return { config: built.config, outcome: built.outcome }; + }, + + openTransport: async (host, config) => { + await checkSsh(false); + const { openSshTransport } = + await import("../hosts/connect/transport.js"); + const opened = await openSshTransport( + host as SshConnectHost, + config as MutableConnectConfig, + ); + if (opened.jumpClient) { + const jumpClient = opened.jumpClient; + const dispose = () => { + open.delete(dispose); + jumpClient.end(); + }; + open.add(dispose); + jumpClient.once("close", () => open.delete(dispose)); + } + return opened; + }, + + classifyKeyboardInteractive: (round, host) => { + ensureCoreSshAuthProviders(); + return classifyKeyboardInteractive(round, host as SshConnectHost); + }, + + autoResponses: (prompts, password) => + prompts.map((p) => + /password/i.test(p.prompt) && password ? password : "", + ), + + requiresSecret: (authType) => { + ensureCoreSshAuthProviders(); + return !!getSshAuthProvider(authType)?.requiresSecret; + }, + + supportsBackground: (authType) => { + ensureCoreSshAuthProviders(); + const provider = getSshAuthProvider(authType); + return !!provider && provider.supportsBackground !== false; + }, + }; +} + +export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth { + const pluginId = manifest.id; + const declared = manifest.capabilities; + const contributes = manifest.contributes?.auth ?? {}; + + // Registration is synchronous so a plugin can call it inline in activate, + // like ctx.http.router. The declaration is checked here; the grant is + // checked on every call into what was registered. + const requireDeclared = ( + list: string[] | undefined, + id: string, + field: string, + ) => { + if (!declared.includes("auth:provide")) { + throw new PluginCapabilityError(pluginId, "auth:provide"); + } + if (!list?.includes(id)) { + throw new Error( + `Plugin ${pluginId} cannot register "${id}": it is not listed in contributes.auth.${field}`, + ); + } + }; + + const granted = () => assertCapability(pluginId, "auth:provide", declared); + + const record = (action: string, id: string) => + void audit(action, `${pluginId} registered ${id}`, { success: true }); + + return { + registerSshAuthProvider: (provider) => { + requireDeclared(contributes.sshAuthTypes, provider.type, "sshAuthTypes"); + const wrapped: SshAuthProvider = { + ...(provider as unknown as SshAuthProvider), + pluginId, + prepare: async (config, host, env) => { + await granted(); + return provider.prepare( + config, + host as PluginSshHost, + env as never, + ) as never; + }, + }; + ensureCoreSshAuthProviders(); + const disposers = [registerSshAuthProvider(wrapped)]; + if (provider.onKeyboardInteractive) { + const detect = provider.onKeyboardInteractive; + disposers.push( + registerKeyboardInteractiveInterceptor({ + id: `${pluginId}:${provider.type}`, + pluginId, + detect: (round, host) => + host.authType === provider.type + ? (detect(round, host as PluginSshHost) as never) + : null, + }), + ); + } + bag.add( + () => disposers.forEach((dispose) => dispose()), + `SSH auth type "${provider.type}"`, + ); + record("auth_register_ssh", provider.type); + }, + + registerLoginMethod: (method) => { + requireDeclared(contributes.loginMethods, method.id, "loginMethods"); + { + const dispose = registerLoginMethod({ + ...method, + pluginId, + start: method.start + ? async (...args) => { + await granted(); + return method.start!(...args); + } + : undefined, + callback: method.callback + ? async (...args) => { + await granted(); + return method.callback!(...args); + } + : undefined, + verify: method.verify + ? async (...args) => { + await granted(); + return method.verify!(...args); + } + : undefined, + }); + bag.add(dispose, `login method "${method.id}"`); + } + record("auth_register_login", method.id); + }, + + registerSecondFactor: (factor) => { + requireDeclared(contributes.secondFactors, factor.id, "secondFactors"); + { + const dispose = registerSecondFactor({ + ...factor, + pluginId, + verify: async (userId, body) => { + await granted(); + return factor.verify(userId, body); + }, + }); + bag.add(dispose, `second factor "${factor.id}"`); + } + record("auth_register_factor", factor.id); + }, + + recordEnrollment: async (userId, factorId) => { + requireDeclared(contributes.secondFactors, factorId, "secondFactors"); + await granted(); + const { createCurrentUserAuthRepository } = + await import("../database/repositories/factory.js"); + await createCurrentUserAuthRepository().recordSecondFactor( + userId, + pluginId, + factorId, + ); + await audit("auth_factor_enrolled", `${factorId} for ${userId}`, { + success: true, + }); + }, + + removeEnrollment: async (userId, factorId) => { + requireDeclared(contributes.secondFactors, factorId, "secondFactors"); + await granted(); + const { createCurrentUserAuthRepository } = + await import("../database/repositories/factory.js"); + await createCurrentUserAuthRepository().removeSecondFactor( + userId, + pluginId, + factorId, + ); + await audit("auth_factor_removed", `${factorId} for ${userId}`, { + success: true, + }); + }, + }; +} diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index 5674c8373..f57ce2777 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -45,6 +45,7 @@ import { } from "@termix/plugin-sdk/backend"; import type { PluginTableDefinition } from "@termix/plugin-sdk/db"; import * as syncRegistry from "./sync-registry.js"; +import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js"; export type { PluginModule }; @@ -160,6 +161,11 @@ export function createPluginContext( handle: PluginHandle, ): PluginContext { const pluginId = manifest.id; + const auditCall = ( + action: string, + details: string, + outcome: { success: boolean; errorMessage?: string }, + ) => writeAudit(manifest, { action, details: () => details }, outcome); // Forced: a plugin cannot log as another plugin. const logContext = { operation: `plugin:${pluginId}` }; const declared = manifest.capabilities; @@ -566,9 +572,14 @@ export function createPluginContext( }, disposables: { - add: (dispose) => handle.bag.add(dispose, "plugin resource"), + add: (dispose) => { + handle.bag.add(dispose, "plugin resource"); + }, }, + ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }), + auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }), + /** * Background work acts as a named user. Always audited, because "this ran * as someone" is exactly the thing an operator needs to be able to see. diff --git a/src/backend/plugins/disposables.ts b/src/backend/plugins/disposables.ts index 38bc83922..12c3eb4cf 100644 --- a/src/backend/plugins/disposables.ts +++ b/src/backend/plugins/disposables.ts @@ -27,14 +27,19 @@ export class DisposableBag { constructor(private readonly pluginId: string) {} - add(dispose: Disposer, label = "disposable"): void { + /** Returns a function that drops the entry without running it. */ + add(dispose: Disposer, label = "disposable"): () => void { if (this.disposed) { // Registering after teardown means the resource would never be cleaned // up. Dispose it immediately rather than silently leaking it. void this.run({ dispose, label }); - return; + return () => {}; } - this.entries.push({ dispose, label }); + const entry = { dispose, label }; + this.entries.push(entry); + return () => { + this.entries = this.entries.filter((candidate) => candidate !== entry); + }; } get size(): number { diff --git a/src/backend/plugins/index.ts b/src/backend/plugins/index.ts index 69115a0a8..33b4d814a 100644 --- a/src/backend/plugins/index.ts +++ b/src/backend/plugins/index.ts @@ -14,6 +14,7 @@ import { setPluginEnabledCheck, unregisterPluginHttp } from "./http.js"; 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"; let loader: PluginLoader | null = null; @@ -26,6 +27,16 @@ export function getPluginRuntime(): { loader: PluginLoader } { setPluginEnabledCheck( (pluginId) => loader?.get(pluginId)?.state === "active", ); + // Lets a host whose auth type belongs to a disabled plugin name it. + setSshAuthTypeOwnerSource(() => + (loader?.list() ?? []).flatMap((plugin) => + (plugin.manifest.contributes?.auth?.sshAuthTypes ?? []).map((type) => ({ + type, + pluginId: plugin.id, + pluginName: plugin.manifest.name, + })), + ), + ); } return { loader }; } diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 45c802022..223072139 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -265,6 +265,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { await import("./utils/crypto-migration/channel-config-encryption.js"); await runChannelConfigEncryptionMigration(); + const { runExternalIdentityMigration } = + await import("./utils/crypto-migration/external-identity-migration.js"); + await runExternalIdentityMigration(); + if (process.env.ELECTRON_EMBEDDED === "true") { await provisionLocalDesktopUserIfNeeded(); } diff --git a/src/backend/tests/auth/auth-test-helpers.ts b/src/backend/tests/auth/auth-test-helpers.ts new file mode 100644 index 000000000..cad0aad53 --- /dev/null +++ b/src/backend/tests/auth/auth-test-helpers.ts @@ -0,0 +1,267 @@ +import { vi } from "vitest"; + +export interface FakeUser { + id: string; + username: string; + passwordHash: string; + isAdmin: boolean; + isOidc: boolean; + oidcIdentifier?: string | null; + ssoProviderId?: number | null; + totpEnabled?: boolean; + totpSecret?: string | null; + totpBackupCodes?: string | null; +} + +/** In-memory stand-ins for the repositories the login pipeline touches. */ +export function createAuthState() { + return { + users: new Map(), + identities: [] as Array<{ + id: number; + userId: string; + providerId: string; + subject: string; + email: string | null; + }>, + factors: [] as Array<{ + userId: string; + pluginId: string; + factorId: string; + }>, + roles: [] as Array<{ userId: string; roleName: string }>, + settings: new Map(), + audits: [] as Array>, + trusted: new Set(), + trustedAdded: [] as string[], + pendingTokens: new Map(), + }; +} + +export type AuthState = ReturnType; + +export function fakeFactory(state: AuthState) { + return { + createCurrentUserRepository: () => ({ + findById: async (id: string) => state.users.get(id) ?? null, + findByUsername: async (username: string) => + [...state.users.values()].find((u) => u.username === username) ?? null, + findByOidcIdentifier: async (identifier: string) => + [...state.users.values()].find( + (u) => u.oidcIdentifier === identifier, + ) ?? null, + countAll: async () => state.users.size, + listAll: async () => [...state.users.values()], + createFirstSsoUser: async (user: FakeUser) => { + const isFirstUser = state.users.size === 0; + const created = { + ...user, + isAdmin: isFirstUser || !!user.isAdmin, + }; + state.users.set(user.id, created); + return { user: created, isFirstUser }; + }, + update: async (id: string, changes: Partial) => { + const user = state.users.get(id); + if (!user) return null; + Object.assign(user, changes); + return user; + }, + delete: async (id: string) => state.users.delete(id), + }), + createCurrentUserAuthRepository: () => ({ + findIdentity: async (providerId: string, subject: string) => + state.identities.find( + (row) => row.providerId === providerId && row.subject === subject, + ) ?? null, + linkIdentity: async (input: { + userId: string; + providerId: string; + subject: string; + email?: string | null; + }) => { + const existing = state.identities.find( + (row) => + row.providerId === input.providerId && + row.subject === input.subject, + ); + if (existing) { + existing.userId = input.userId; + return; + } + state.identities.push({ + id: state.identities.length + 1, + userId: input.userId, + providerId: input.providerId, + subject: input.subject, + email: input.email ?? null, + }); + }, + listSecondFactors: async (userId: string) => + state.factors.filter((row) => row.userId === userId), + recordSecondFactor: async ( + userId: string, + pluginId: string, + factorId: string, + ) => { + if ( + !state.factors.some( + (row) => + row.userId === userId && + row.pluginId === pluginId && + row.factorId === factorId, + ) + ) { + state.factors.push({ userId, pluginId, factorId }); + } + }, + removeSecondFactor: async ( + userId: string, + pluginId: string, + factorId: string, + ) => { + state.factors = state.factors.filter( + (row) => + !( + row.userId === userId && + row.pluginId === pluginId && + row.factorId === factorId + ), + ); + return true; + }, + clearSecondFactors: async (userId: string) => { + state.factors = state.factors.filter((row) => row.userId !== userId); + return 1; + }, + }), + createCurrentRoleRepository: () => ({ + assignRoleNameToUser: async (input: { + userId: string; + roleName: string; + }) => { + state.roles.push({ userId: input.userId, roleName: input.roleName }); + return true; + }, + switchUserRoleName: async (input: { + userId: string; + addRoleName: string; + removeRoleName: string; + }) => { + state.roles = state.roles.filter( + (role) => + !( + role.userId === input.userId && + role.roleName === input.removeRoleName + ), + ); + state.roles.push({ userId: input.userId, roleName: input.addRoleName }); + }, + listUserRoles: async (userId: string) => + state.roles + .filter((role) => role.userId === userId) + .map((role, index) => ({ roleId: index, roleName: role.roleName })), + removeRoleFromUser: async () => true, + }), + createCurrentSettingsRepository: () => ({ + get: async (key: string) => state.settings.get(key) ?? null, + getBoolean: async (key: string, fallback: boolean) => + state.settings.has(key) ? state.settings.get(key) === "true" : fallback, + set: async (key: string, value: string) => { + state.settings.set(key, value); + }, + delete: async (key: string) => state.settings.delete(key), + }), + getCurrentSettingValue: (key: string) => state.settings.get(key) ?? null, + createCurrentTrustedDeviceRepository: () => ({ + deleteByUserId: async () => {}, + }), + createCurrentSsoProviderRepository: () => ({ + listEnabledPublic: async () => [], + }), + }; +} + +export function fakeAuthManager(state: AuthState) { + let tokenCount = 0; + return { + generateJWTToken: vi.fn( + async (userId: string, options: { pendingTOTP?: boolean } = {}) => { + if (!options.pendingTOTP) return `jwt-${++tokenCount}`; + const token = `pending-${++tokenCount}`; + state.pendingTokens.set(token, userId); + return token; + }, + ), + verifyJWTToken: vi.fn(async (token: string) => { + const userId = state.pendingTokens.get(token); + return userId ? { userId, pendingTOTP: true } : null; + }), + authenticateUser: vi.fn(async () => true), + authenticateOIDCUser: vi.fn(async () => true), + authenticateWebAuthnUser: vi.fn(async () => true), + registerOIDCUser: vi.fn(async () => {}), + isTrustedDevice: vi.fn(async (userId: string, fingerprint: string) => + state.trusted.has(`${userId}:${fingerprint}`), + ), + addTrustedDevice: vi.fn(async (userId: string, fingerprint: string) => { + state.trustedAdded.push(`${userId}:${fingerprint}`); + }), + getSecureCookieOptions: vi.fn((_req: unknown, maxAge: number) => ({ + httpOnly: true, + maxAge, + })), + getClearCookieOptions: vi.fn(() => ({ httpOnly: true })), + getUserDataKey: vi.fn(() => Buffer.alloc(32, 7)), + }; +} + +export function fakeRequest(overrides: Record = {}) { + const headers: Record = { + "user-agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/120.0", + "x-termix-device-id": "a".repeat(64), + ...(overrides.headers as Record | undefined), + }; + return { + body: {}, + query: {}, + params: {}, + cookies: {}, + ip: "10.0.0.1", + socket: { remoteAddress: "10.0.0.1" }, + ...overrides, + headers, + get: (name: string) => headers[name.toLowerCase()], + } as Record & { headers: Record }; +} + +export function fakeResponse() { + const res = { + statusCode: 200, + body: undefined as unknown, + cookies: [] as Array<{ name: string; value: string; options: unknown }>, + cleared: [] as string[], + redirectedTo: null as string | null, + status(code: number) { + res.statusCode = code; + return res; + }, + json(body: unknown) { + res.body = body; + return res; + }, + cookie(name: string, value: string, options: unknown) { + res.cookies.push({ name, value, options }); + return res; + }, + clearCookie(name: string) { + res.cleared.push(name); + return res; + }, + redirect(url: string) { + res.redirectedTo = url; + return res; + }, + }; + return res; +} diff --git a/src/backend/tests/auth/legacy-methods.test.ts b/src/backend/tests/auth/legacy-methods.test.ts new file mode 100644 index 000000000..b16f06800 --- /dev/null +++ b/src/backend/tests/auth/legacy-methods.test.ts @@ -0,0 +1,383 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createAuthState, + fakeAuthManager, + fakeRequest, + fakeResponse, + type AuthState, +} from "./auth-test-helpers.js"; + +const h = vi.hoisted(() => ({ + state: null as unknown as AuthState, + manager: null as unknown as ReturnType< + typeof import("./auth-test-helpers.js").fakeAuthManager + >, + providerConfig: null as unknown, + providerType: "oidc", + idTokenClaims: null as unknown, + ldap: { + users: {} as Record< + string, + { dn: string; password: string; attrs: Record } + >, + adminDns: [] as string[], + }, + ssoProvider: { id: 4, type: "ldap", enabled: true }, +})); + +vi.mock("../../database/repositories/factory.js", async () => { + const helpers = await import("./auth-test-helpers.js"); + const names = [ + ...Object.keys(helpers.fakeFactory(helpers.createAuthState())), + ]; + const mocked = Object.fromEntries( + names.map((name) => [ + name, + (...args: unknown[]) => + ( + helpers.fakeFactory(h.state) as Record< + string, + (...a: unknown[]) => unknown + > + )[name](...args), + ]), + ); + return { + ...mocked, + createCurrentSsoProviderRepository: () => ({ + findById: async () => h.ssoProvider, + listEnabledPublic: async () => [], + }), + }; +}); +vi.mock("../../utils/auth-manager.js", () => ({ + AuthManager: { getInstance: () => h.manager }, +})); +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (entry: Record) => { + h.state.audits.push(entry); + }, + getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }), +})); +vi.mock("../../hosts/automation-events.js", () => ({ + notifyAutomationInternalEvent: vi.fn(), +})); +vi.mock("../../utils/logger.js", () => { + const log = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + debug: vi.fn(), + }; + return { authLogger: log, databaseLogger: log, sshLogger: log, logger: log }; +}); +vi.mock("../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) }, +})); +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }), + }, +})); +vi.mock("../../utils/shared-host-secrets-manager.js", () => ({ + SharedHostSecretsManager: { + getInstance: () => ({ snapshotForUserRoles: async () => {} }), + }, +})); +vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({ + SharedCredentialSecretsManager: { + getInstance: () => ({ snapshotForUserRoles: async () => {} }), + }, +})); +vi.mock("../../database/routes/user-oidc-utils.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../database/routes/user-oidc-utils.js") + >(); + return { + ...actual, + loadProviderConfig: async () => + h.providerConfig + ? { + config: h.providerConfig, + providerType: h.providerType, + providerDbId: 3, + } + : null, + verifyOIDCToken: async () => h.idTokenClaims, + }; +}); +vi.mock("ldapjs", () => { + function createClient() { + let bound: string | null = null; + return { + bind: (dn: string, password: string, cb: (err?: Error) => void) => { + if (dn === "cn=service") { + bound = dn; + return cb(); + } + const user = Object.values(h.ldap.users).find((u) => u.dn === dn); + if (user && user.password === password) { + bound = dn; + return cb(); + } + cb(new Error("invalid credentials")); + }, + search: ( + base: string, + options: { filter: string }, + cb: (err: Error | null, res: unknown) => void, + ) => { + const listeners: Record void> = {}; + const res = { + on: (event: string, fn: (value?: unknown) => void) => { + listeners[event] = fn; + }, + }; + cb(null, res); + queueMicrotask(() => { + if (base === "ou=groups") { + for (const dn of h.ldap.adminDns) { + if (options.filter.includes(dn)) { + listeners.searchEntry?.({ + dn: { toString: () => "cn=admins,ou=groups" }, + attributes: [{ type: "cn", values: ["admins"] }], + }); + } + } + } else { + const match = /uid=([^)]+)/.exec(options.filter); + const user = match ? h.ldap.users[match[1]] : undefined; + if (user) { + listeners.searchEntry?.({ + dn: { toString: () => user.dn }, + attributes: Object.entries(user.attrs).map(([type, value]) => ({ + type, + values: [value], + })), + }); + } + } + listeners.end?.(); + }); + void bound; + }, + unbind: () => {}, + }; + } + return { default: { createClient }, createClient }; +}); + +const { handleOidcCallback } = await import("../../auth/legacy/oidc-login.js"); +const { verifyLdapLogin } = await import("../../auth/legacy/ldap-login.js"); +const { respondWithLogin, respondWithRedirectLogin, runLogin } = + await import("../../auth/login-pipeline.js"); + +beforeEach(() => { + h.state = createAuthState(); + h.manager = fakeAuthManager(h.state); + h.providerType = "oidc"; + h.providerConfig = { + client_id: "client", + client_secret: "secret", + issuer_url: "https://idp.example", + authorization_url: "https://idp.example/auth", + token_url: "https://idp.example/token", + identifier_path: "sub", + name_path: "name", + scopes: "openid", + admin_group: "termix-admins", + group_claim: "groups", + }; + h.idTokenClaims = { + sub: "sub-1", + sid: "sid-1", + name: "Alice", + email: "alice@example.com", + nonce: "nonce-1", + groups: ["termix-admins"], + }; + for (const [key, value] of Object.entries({ + oidc_backend_callback_state1: "https://termix.example/users/oidc/callback", + oidc_frontend_origin_state1: "https://termix.example", + oidc_remember_me_state1: "false", + oidc_state_state1: "nonce-1", + oidc_provider_state1: "3", + })) { + h.state.settings.set(key, value); + } + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url === "https://idp.example/token") { + return new Response( + JSON.stringify({ id_token: "jwt", access_token: "at" }), + ); + } + return new Response("{}", { status: 404 }); + }), + ); + h.ldap.users = { + bob: { + dn: "uid=bob,ou=people", + password: "hunter2", + attrs: { uid: "bob", cn: "Bob Builder", mail: "bob@example.com" }, + }, + }; + h.ldap.adminDns = []; +}); + +describe("OIDC through the pipeline", () => { + it("turns the callback into an identity with the old identifier and claims", async () => { + const identity = await handleOidcCallback( + fakeRequest({ query: { code: "c", state: "state1" } }) as never, + ); + expect(identity).toMatchObject({ + kind: "external", + provider: "3", + subject: "sub-1", + legacyIdentifier: "sub-1", + name: "Alice", + isAdmin: true, + oidcSub: "sub-1", + oidcSid: "sid-1", + ssoProviderId: 3, + returnTo: "https://termix.example", + }); + // The one-time state is consumed. + expect(h.state.settings.has("oidc_state_state1")).toBe(false); + }); + + it("signs in and redirects with a cookie, like the old callback", async () => { + const req = fakeRequest({ query: { code: "c", state: "state1" } }); + const identity = await handleOidcCallback(req as never); + const res = fakeResponse(); + await respondWithRedirectLogin( + req as never, + res as never, + identity, + { methodId: "oidc", rememberMe: false }, + () => false, + ); + expect(res.redirectedTo).toBe("https://termix.example/?success=true"); + expect(res.cleared).toContain("jwt"); + expect(res.cookies[0]).toMatchObject({ name: "jwt" }); + const [user] = [...h.state.users.values()]; + expect(user).toMatchObject({ oidcIdentifier: "sub-1", isAdmin: true }); + expect(h.state.audits).toContainEqual( + expect.objectContaining({ action: "login", userId: user.id }), + ); + }); + + it("rejects a nonce mismatch without a session", async () => { + h.idTokenClaims = { ...(h.idTokenClaims as object), nonce: "other" }; + await expect( + handleOidcCallback( + fakeRequest({ query: { code: "c", state: "state1" } }) as never, + ), + ).rejects.toMatchObject({ status: 401 }); + }); + + it("namespaces GitHub users the way they were stored", async () => { + h.providerType = "github"; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url === "https://idp.example/token") { + return new Response(JSON.stringify({ access_token: "at" })); + } + if (url === "https://api.github.com/user") { + return new Response(JSON.stringify({ id: 42, login: "octo" })); + } + return new Response("[]"); + }), + ); + const identity = await handleOidcCallback( + fakeRequest({ query: { code: "c", state: "state1" } }) as never, + ); + expect(identity).toMatchObject({ + provider: "3", + subject: "42", + legacyIdentifier: "github:3:42", + name: "octo", + }); + }); +}); + +describe("LDAP through the pipeline", () => { + const providerConfig = { + host: "ldap.example", + bindDN: "cn=service", + bindPassword: "svc", + userSearchBase: "ou=people", + userSearchFilter: "(uid={{username}})", + groupSearchBase: "ou=groups", + adminGroup: "admins", + }; + + it("binds, finds the user and signs in with the old identifier", async () => { + h.providerConfig = providerConfig; + h.ldap.adminDns = ["uid=bob,ou=people"]; + const req = fakeRequest({ + body: { providerId: 4, username: "bob", password: "hunter2" }, + headers: { "x-electron-app": "true" }, + }); + const identity = await verifyLdapLogin({ + body: req.body as never, + ip: "10.0.0.1", + }); + expect(identity).toMatchObject({ + provider: "4", + subject: "bob", + legacyIdentifier: "ldap:4:bob", + isAdmin: true, + name: "Bob Builder", + }); + + const res = fakeResponse(); + await respondWithLogin(req as never, res as never, identity, { + methodId: "ldap", + rememberMe: false, + }); + expect(res.cookies[0]).toMatchObject({ name: "jwt" }); + // Native clients now get the token for LDAP too. + expect(res.body).toMatchObject({ + success: true, + token: expect.any(String), + }); + }); + + it("rejects a wrong password", async () => { + h.providerConfig = providerConfig; + await expect( + verifyLdapLogin({ + body: { providerId: 4, username: "bob", password: "wrong" }, + ip: "10.0.0.2", + }), + ).rejects.toMatchObject({ status: 401 }); + }); + + it("runs TOTP for an LDAP user who enrolled", async () => { + h.providerConfig = providerConfig; + h.state.users.set("u-bob", { + id: "u-bob", + username: "Bob Builder", + passwordHash: "", + isAdmin: false, + isOidc: true, + oidcIdentifier: "ldap:4:bob", + totpEnabled: true, + totpSecret: "JBSWY3DPEHPK3PXP", + }); + const identity = await verifyLdapLogin({ + body: { providerId: 4, username: "bob", password: "hunter2" }, + ip: "10.0.0.3", + }); + const result = await runLogin(fakeRequest() as never, identity, { + methodId: "ldap", + rememberMe: false, + }); + expect(result.kind).toBe("second-factor"); + }); +}); diff --git a/src/backend/tests/auth/login-pipeline.test.ts b/src/backend/tests/auth/login-pipeline.test.ts new file mode 100644 index 000000000..a989da9ac --- /dev/null +++ b/src/backend/tests/auth/login-pipeline.test.ts @@ -0,0 +1,729 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import bcrypt from "bcryptjs"; +import speakeasy from "speakeasy"; +import { + createAuthState, + fakeAuthManager, + fakeRequest, + fakeResponse, + type AuthState, +} from "./auth-test-helpers.js"; + +const h = vi.hoisted(() => ({ + state: null as unknown as AuthState, + manager: null as unknown as ReturnType< + typeof import("./auth-test-helpers.js").fakeAuthManager + >, + trustedProxy: false, +})); + +vi.mock("../../database/repositories/factory.js", async () => { + const helpers = await import("./auth-test-helpers.js"); + const names = Object.keys(helpers.fakeFactory(helpers.createAuthState())); + return Object.fromEntries( + names.map((name) => [ + name, + (...args: unknown[]) => + ( + helpers.fakeFactory(h.state) as Record< + string, + (...a: unknown[]) => unknown + > + )[name](...args), + ]), + ); +}); +vi.mock("../../utils/auth-manager.js", () => ({ + AuthManager: { getInstance: () => h.manager }, +})); +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (entry: Record) => { + h.state.audits.push(entry); + }, + getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }), +})); +vi.mock("../../hosts/automation-events.js", () => ({ + notifyAutomationInternalEvent: vi.fn(), +})); +vi.mock("../../utils/logger.js", () => { + const log = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + debug: vi.fn(), + }; + return { + authLogger: log, + databaseLogger: log, + sshLogger: log, + logger: log, + systemLogger: log, + }; +}); +vi.mock("../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) }, +})); +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }), + }, +})); +vi.mock("../../utils/trusted-proxy-auth.js", () => ({ + isTrustedProxyAuthEnabled: () => h.trustedProxy, +})); +vi.mock("../../utils/shared-host-secrets-manager.js", () => ({ + SharedHostSecretsManager: { + getInstance: () => ({ snapshotForUserRoles: async () => {} }), + }, +})); +vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({ + SharedCredentialSecretsManager: { + getInstance: () => ({ snapshotForUserRoles: async () => {} }), + }, +})); + +const { + runLogin, + respondWithLogin, + respondWithRedirectLogin, + verifySecondFactorAndRespond, + resetPendingLoginsForTests, +} = await import("../../auth/login-pipeline.js"); +const { verifyPasswordLogin } = + await import("../../auth/builtin-login-methods.js"); +const { getPasswordLoginStatus } = await import("../../auth/core-auth.js"); +const { registerLoginMethod, registerSecondFactor } = + await import("../../auth/registry.js"); +const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js"); + +const PASSWORD = "correct-horse"; +const totpSecret = speakeasy.generateSecret({ name: "test" }).base32; + +function addUser( + overrides: Partial = {}, +) { + const user = { + id: "u1", + username: "alice", + passwordHash: bcrypt.hashSync(PASSWORD, 4), + isAdmin: false, + isOidc: false, + totpEnabled: false, + ...overrides, + }; + h.state.users.set(user.id, user); + return user; +} + +beforeEach(() => { + h.state = createAuthState(); + h.manager = fakeAuthManager(h.state); + h.trustedProxy = false; + resetPendingLoginsForTests(); + loginRateLimiter.resetAttempts("10.0.0.1", "alice"); + loginRateLimiter.resetTOTPAttempts("u1"); + delete process.env.ALLOW_PASSWORD_LOGIN; +}); + +async function passwordLogin(body: Record, headers = {}) { + const req = fakeRequest({ body, headers }); + const res = fakeResponse(); + const identity = await verifyPasswordLogin(req as never); + await respondWithLogin(req as never, res as never, identity, { + methodId: "password", + rememberMe: !!body.rememberMe, + }); + return res; +} + +describe("password login through the pipeline", () => { + it("issues the same JWT, cookie, audit and body the old route did", async () => { + addUser({ isAdmin: true }); + const res = await passwordLogin({ username: "alice", password: PASSWORD }); + + expect(h.manager.authenticateUser).toHaveBeenCalledWith( + "u1", + PASSWORD, + "web", + ); + expect(h.manager.generateJWTToken).toHaveBeenCalledWith( + "u1", + expect.objectContaining({ + rememberMe: false, + deviceType: "web", + deviceInfo: expect.any(String), + }), + ); + // Session timeout default: 24 hours. + expect(res.cookies).toEqual([ + { + name: "jwt", + value: "jwt-1", + options: { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 }, + }, + ]); + expect(res.body).toMatchObject({ + success: true, + is_admin: true, + username: "alice", + }); + expect(res.body).not.toHaveProperty("token"); + expect(h.state.audits).toEqual([ + expect.objectContaining({ + userId: "u1", + username: "alice", + action: "login", + resourceType: "session", + success: true, + }), + ]); + }); + + it("remembers the session for 30 days and follows session_timeout_hours otherwise", async () => { + addUser(); + const remembered = await passwordLogin({ + username: "alice", + password: PASSWORD, + rememberMe: true, + }); + expect((remembered.cookies[0].options as { maxAge: number }).maxAge).toBe( + 30 * 24 * 60 * 60 * 1000, + ); + + h.state.settings.set("session_timeout_hours", "2"); + const short = await passwordLogin({ + username: "alice", + password: PASSWORD, + }); + expect((short.cookies[0].options as { maxAge: number }).maxAge).toBe( + 2 * 60 * 60 * 1000, + ); + }); + + it("returns the token in the body for the desktop and mobile apps", async () => { + addUser(); + const res = await passwordLogin( + { username: "alice", password: PASSWORD }, + { "x-electron-app": "true" }, + ); + expect(res.body).toMatchObject({ token: "jwt-1" }); + }); + + it("rejects a wrong password with the old status and message", async () => { + addUser(); + await expect( + verifyPasswordLogin( + fakeRequest({ body: { username: "alice", password: "nope" } }) as never, + ), + ).rejects.toMatchObject({ + status: 401, + message: "Invalid username or password", + }); + expect(h.state.audits).toEqual([]); + }); + + it("refuses a user whose data key does not open with the password", async () => { + addUser(); + h.manager.authenticateUser.mockResolvedValueOnce(false); + const req = fakeRequest({ + body: { username: "alice", password: PASSWORD }, + }); + const identity = await verifyPasswordLogin(req as never); + await expect( + runLogin(req as never, identity, { + methodId: "password", + rememberMe: false, + }), + ).rejects.toMatchObject({ status: 401, message: "Incorrect password" }); + }); +}); + +describe("second factors", () => { + function enrolTotp() { + addUser({ + totpEnabled: true, + totpSecret, + totpBackupCodes: JSON.stringify(["BACKUP01"]), + }); + h.state.factors.push({ userId: "u1", pluginId: "core", factorId: "totp" }); + } + + it("stops for TOTP with the pending token the old route returned", async () => { + enrolTotp(); + const res = await passwordLogin({ username: "alice", password: PASSWORD }); + expect(res.cookies).toEqual([]); + expect(res.body).toMatchObject({ + success: true, + requires_totp: true, + temp_token: "pending-1", + rememberMe: false, + second_factors: [ + expect.objectContaining({ id: "totp", pluginId: "core" }), + ], + }); + expect(h.manager.generateJWTToken).toHaveBeenCalledWith("u1", { + pendingTOTP: true, + expiresIn: "10m", + }); + expect(h.state.audits).toEqual([]); + }); + + it("finishes the login with a valid code and trusts the device when remembered", async () => { + enrolTotp(); + const first = await passwordLogin({ + username: "alice", + password: PASSWORD, + }); + const tempToken = (first.body as { temp_token: string }).temp_token; + + const req = fakeRequest({ + body: { + temp_token: tempToken, + totp_code: speakeasy.totp({ secret: totpSecret, encoding: "base32" }), + rememberMe: true, + }, + }); + const res = fakeResponse(); + await verifySecondFactorAndRespond(req as never, res as never, "totp"); + + expect(res.statusCode).toBe(200); + expect(res.cookies[0]).toMatchObject({ name: "jwt" }); + expect(res.body).toMatchObject({ + success: true, + username: "alice", + totp_enabled: true, + }); + expect(h.state.trustedAdded).toHaveLength(1); + expect(h.state.audits.at(-1)).toMatchObject({ action: "login" }); + }); + + it("accepts a backup code once, stored encrypted", async () => { + enrolTotp(); + const first = await passwordLogin({ + username: "alice", + password: PASSWORD, + }); + const tempToken = (first.body as { temp_token: string }).temp_token; + const res = fakeResponse(); + await verifySecondFactorAndRespond( + fakeRequest({ + body: { temp_token: tempToken, totp_code: "BACKUP01" }, + }) as never, + res as never, + "totp", + ); + expect(res.statusCode).toBe(200); + // Re-encrypted after use, and the code is gone. + expect(h.state.users.get("u1")!.totpBackupCodes).not.toContain("BACKUP01"); + }); + + it("rejects a wrong code without a session", async () => { + enrolTotp(); + const first = await passwordLogin({ + username: "alice", + password: PASSWORD, + }); + const res = fakeResponse(); + await verifySecondFactorAndRespond( + fakeRequest({ + body: { + temp_token: (first.body as { temp_token: string }).temp_token, + totp_code: "000000", + }, + }) as never, + res as never, + "totp", + ); + expect(res.statusCode).toBe(401); + expect(res.cookies).toEqual([]); + }); + + it("skips the factor on a trusted device", async () => { + enrolTotp(); + h.manager.isTrustedDevice.mockResolvedValueOnce(true); + const res = await passwordLogin({ username: "alice", password: PASSWORD }); + expect(res.cookies[0]).toMatchObject({ name: "jwt" }); + }); + + it("fails closed when an enrolled factor's plugin is gone", async () => { + addUser(); + h.state.factors.push({ + userId: "u1", + pluginId: "gone", + factorId: "yubikey", + }); + const req = fakeRequest({ + body: { username: "alice", password: PASSWORD }, + }); + const identity = await verifyPasswordLogin(req as never); + await expect( + runLogin(req as never, identity, { + methodId: "password", + rememberMe: false, + }), + ).rejects.toMatchObject({ status: 403, code: "second_factor_unavailable" }); + expect(h.manager.generateJWTToken).not.toHaveBeenCalled(); + expect(h.state.audits).toEqual([ + expect.objectContaining({ + action: "login_blocked_second_factor", + success: false, + }), + ]); + }); + + it("runs a plugin's second factor", async () => { + addUser(); + h.state.factors.push({ + userId: "u1", + pluginId: "fixture", + factorId: "pin", + }); + const dispose = registerSecondFactor({ + id: "pin", + pluginId: "fixture", + labelKey: "fixture.pin", + isEnrolled: async () => true, + verify: async (_userId, body) => body.pin === "1234", + }); + try { + const first = await passwordLogin({ + username: "alice", + password: PASSWORD, + }); + expect(first.body).toMatchObject({ + second_factors: [expect.objectContaining({ id: "pin" })], + }); + const res = fakeResponse(); + await verifySecondFactorAndRespond( + fakeRequest({ + body: { + temp_token: (first.body as { temp_token: string }).temp_token, + pin: "1234", + }, + }) as never, + res as never, + "pin", + ); + expect(res.statusCode).toBe(200); + } finally { + dispose(); + } + }); + + it("lets a method that already proved a factor skip the step", async () => { + enrolTotp(); + const result = await runLogin( + fakeRequest() as never, + { kind: "user", userId: "u1", mfaSatisfied: true }, + { methodId: "passkey", rememberMe: false }, + ); + expect(result.kind).toBe("session"); + }); +}); + +describe("external identities", () => { + const external = { + kind: "external" as const, + provider: "3", + subject: "sub-1", + email: "a@example.com", + name: "Alice", + legacyIdentifier: "sub-1", + ssoProviderId: 3, + }; + + it("provisions the first user as admin and links the identity", async () => { + const result = await runLogin(fakeRequest() as never, external, { + methodId: "oidc", + rememberMe: false, + }); + expect(result.kind).toBe("session"); + const [user] = [...h.state.users.values()]; + expect(user).toMatchObject({ + username: "Alice", + isAdmin: true, + isOidc: true, + }); + expect(h.state.identities).toEqual([ + expect.objectContaining({ + userId: user.id, + providerId: "3", + subject: "sub-1", + }), + ]); + expect(h.state.roles).toContainEqual({ + userId: user.id, + roleName: "admin", + }); + }); + + it("refuses a new user when provisioning is off, and one outside the allowed list", async () => { + addUser({ id: "someone", username: "someone" }); + await expect( + runLogin(fakeRequest() as never, external, { + methodId: "oidc", + rememberMe: false, + }), + ).rejects.toMatchObject({ code: "registration_disabled" }); + + h.state.settings.set("oidc_auto_provision", "true"); + await expect( + runLogin( + fakeRequest() as never, + { ...external, allowedUsers: "@other.org" }, + { methodId: "oidc", rememberMe: false }, + ), + ).rejects.toMatchObject({ code: "user_not_allowed" }); + }); + + it("re-checks the allowed list for existing users", async () => { + addUser({ id: "u9", username: "Alice", isOidc: true, passwordHash: "" }); + h.state.identities.push({ + id: 1, + userId: "u9", + providerId: "3", + subject: "sub-1", + email: null, + }); + await expect( + runLogin( + fakeRequest() as never, + { ...external, allowedUsers: "@other.org" }, + { methodId: "oidc", rememberMe: false }, + ), + ).rejects.toMatchObject({ code: "user_not_allowed" }); + }); + + it("finds an account from before the identity table by its old identifier", async () => { + addUser({ + id: "old", + username: "ldap-bob", + isOidc: true, + passwordHash: "", + oidcIdentifier: "ldap:4:bob", + }); + const result = await runLogin( + fakeRequest() as never, + { + kind: "external", + provider: "4", + subject: "bob", + name: "Bob", + legacyIdentifier: "ldap:4:bob", + ssoProviderId: 4, + }, + { methodId: "ldap", rememberMe: false }, + ); + expect(result.kind).toBe("session"); + expect(h.state.users.size).toBe(1); + expect(h.state.identities).toEqual([ + expect.objectContaining({ + userId: "old", + providerId: "4", + subject: "bob", + }), + ]); + }); + + it("keeps admin in step with the provider's admin group", async () => { + addUser({ + id: "u9", + username: "Alice", + isOidc: true, + passwordHash: "", + isAdmin: true, + }); + h.state.identities.push({ + id: 1, + userId: "u9", + providerId: "3", + subject: "sub-1", + email: null, + }); + await runLogin( + fakeRequest() as never, + { ...external, isAdmin: false }, + { methodId: "oidc", rememberMe: false }, + ); + expect(h.state.users.get("u9")!.isAdmin).toBe(false); + }); + + it("runs enrolled second factors for SSO users too", async () => { + addUser({ + id: "u9", + username: "Alice", + isOidc: true, + passwordHash: "", + totpEnabled: true, + totpSecret, + }); + h.state.identities.push({ + id: 1, + userId: "u9", + providerId: "3", + subject: "sub-1", + email: null, + }); + const result = await runLogin(fakeRequest() as never, external, { + methodId: "oidc", + rememberMe: false, + }); + expect(result.kind).toBe("second-factor"); + }); +}); + +describe("redirect logins", () => { + const identity = { + kind: "external" as const, + provider: "3", + subject: "sub-1", + name: "Alice", + returnTo: "https://termix.example", + ssoProviderId: 3, + oidcSub: "sub-1", + oidcSid: "sid-1", + }; + + it("sets the cookie and redirects with success, carrying the SSO claims", async () => { + const res = fakeResponse(); + await respondWithRedirectLogin( + fakeRequest() as never, + res as never, + identity, + { methodId: "oidc", rememberMe: false }, + () => false, + ); + expect(res.redirectedTo).toBe("https://termix.example/?success=true"); + expect(res.cookies[0]).toMatchObject({ name: "jwt" }); + expect(h.manager.generateJWTToken).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + oidcSub: "sub-1", + oidcSid: "sid-1", + ssoProviderId: 3, + }), + ); + }); + + it("puts the token in the URL for app callbacks", async () => { + const res = fakeResponse(); + await respondWithRedirectLogin( + fakeRequest() as never, + res as never, + { ...identity, returnTo: "http://localhost:4567/oidc-callback" }, + { methodId: "oidc", rememberMe: false }, + () => true, + ); + expect(res.redirectedTo).toMatch(/success=true/); + expect(res.redirectedTo).toMatch(/token=jwt-/); + expect(res.cookies).toEqual([]); + }); + + it("sends a second-factor step back with a pending cookie", async () => { + addUser({ + id: "u9", + username: "Alice", + isOidc: true, + passwordHash: "", + totpEnabled: true, + totpSecret, + }); + h.state.identities.push({ + id: 1, + userId: "u9", + providerId: "3", + subject: "sub-1", + email: null, + }); + const res = fakeResponse(); + await respondWithRedirectLogin( + fakeRequest() as never, + res as never, + identity, + { methodId: "oidc", rememberMe: false }, + () => false, + ); + expect(res.redirectedTo).toContain("second_factor=1"); + expect(res.redirectedTo).toContain("second_factors=totp"); + expect(res.cookies[0]).toMatchObject({ name: "termix_pending_login" }); + + // The pending login keeps the SSO claims for the session. + const verify = fakeResponse(); + await verifySecondFactorAndRespond( + fakeRequest({ + cookies: { termix_pending_login: res.cookies[0].value }, + body: { + totp_code: speakeasy.totp({ secret: totpSecret, encoding: "base32" }), + }, + }) as never, + verify as never, + "totp", + ); + expect(verify.statusCode).toBe(200); + expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith( + "u9", + expect.objectContaining({ oidcSid: "sid-1", ssoProviderId: 3 }), + ); + }); + + it("redirects provisioning refusals with the old error codes", async () => { + addUser({ id: "someone", username: "someone" }); + const res = fakeResponse(); + await respondWithRedirectLogin( + fakeRequest() as never, + res as never, + identity, + { methodId: "oidc", rememberMe: false }, + () => false, + ); + expect(res.redirectedTo).toBe( + "https://termix.example/?error=registration_disabled", + ); + }); +}); + +describe("lockout guard", () => { + it("keeps password login on, flagged forced, when nothing else can sign anyone in", async () => { + h.state.settings.set("allow_password_login", "false"); + expect(await getPasswordLoginStatus()).toEqual({ + allowed: true, + forced: true, + }); + }); + + it("honours the setting when another method is enabled", async () => { + h.state.settings.set("allow_password_login", "false"); + const dispose = registerLoginMethod({ + id: "fixture-sso", + pluginId: "fixture", + labelKey: "fixture", + kind: "redirect", + describe: async () => [{ id: "1", label: "Fixture", enabled: true }], + }); + try { + expect(await getPasswordLoginStatus()).toEqual({ + allowed: false, + forced: false, + }); + await expect( + verifyPasswordLogin( + fakeRequest({ + body: { username: "alice", password: PASSWORD }, + }) as never, + ), + ).rejects.toMatchObject({ status: 403 }); + } finally { + dispose(); + } + }); + + it("counts trusted proxy login as another way in", async () => { + h.state.settings.set("allow_password_login", "false"); + h.trustedProxy = true; + expect(await getPasswordLoginStatus()).toEqual({ + allowed: false, + forced: false, + }); + }); +}); diff --git a/src/backend/tests/database/routes/plugins.test.ts b/src/backend/tests/database/routes/plugins.test.ts index a943fcd2c..348e5c0b7 100644 --- a/src/backend/tests/database/routes/plugins.test.ts +++ b/src/backend/tests/database/routes/plugins.test.ts @@ -31,6 +31,8 @@ const state = vi.hoisted(() => ({ }[], // Which users have admin.plugins.manage, keyed by userId. managers: new Set(["admin-1"]), + // Plugins the loader reports as running. + active: new Set(), })); vi.mock("../../../utils/logger.js", () => ({ @@ -93,7 +95,18 @@ vi.mock("../../../utils/permission-manager.js", () => ({ vi.mock("../../../plugins/index.js", () => ({ getPluginRuntime: () => ({ - loader: { list: () => [], get: () => undefined }, + loader: { + list: () => [], + get: (id: string) => + state.active.has(id) + ? { + id, + state: "active", + dir: "/nonexistent/plugin", + manifest: { id, locales: "locales" }, + } + : undefined, + }, }), activatePlugin: vi.fn(), deactivatePlugin: vi.fn(), @@ -166,6 +179,7 @@ describe("plugins route", () => { state.plugins = new Map(); state.grants = []; state.managers = new Set(["admin-1"]); + state.active = new Set(); const app = express(); app.use(express.json()); @@ -226,6 +240,94 @@ describe("plugins route", () => { }); }); + describe("GET /plugins/public-manifest", () => { + it("lists only running plugins with login or second-factor UI, and nothing sensitive", async () => { + state.plugins.set( + "login-plugin", + makePlugin({ + id: "login-plugin", + manifestJson: JSON.stringify({ + capabilities: ["auth:provide", "credentials:read"], + contributes: { + auth: { loginMethods: ["corp-sso"], sshAuthTypes: ["corp"] }, + settings: { admin: [{ key: "clientSecret", type: "secret" }] }, + permissions: [{ name: "manage" }], + }, + }), + }), + ); + state.plugins.set( + "factor-plugin", + makePlugin({ + id: "factor-plugin", + manifestJson: JSON.stringify({ + contributes: { auth: { secondFactors: ["pin"] } }, + }), + }), + ); + state.plugins.set( + "stopped-login", + makePlugin({ + id: "stopped-login", + manifestJson: JSON.stringify({ + contributes: { auth: { loginMethods: ["other"] } }, + }), + }), + ); + state.plugins.set( + "disabled-login", + makePlugin({ + id: "disabled-login", + state: "disabled", + manifestJson: JSON.stringify({ + contributes: { auth: { loginMethods: ["x"] } }, + }), + }), + ); + state.plugins.set("sample-plugin", makePlugin()); + state.active = new Set([ + "login-plugin", + "factor-plugin", + "disabled-login", + "sample-plugin", + ]); + + const res = await fetch(`${baseUrl}/plugins/public-manifest`); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.map((plugin: { id: string }) => plugin.id)).toEqual([ + "login-plugin", + "factor-plugin", + ]); + expect(body[0].contributes).toEqual({ + auth: { loginMethods: ["corp-sso"], secondFactors: [] }, + }); + for (const plugin of body) { + expect(Object.keys(plugin).sort()).toEqual( + [ + "assetVersion", + "contributes", + "css", + "dependencies", + "enabled", + "frontend", + "id", + "locales", + "name", + "optionalDependencies", + "state", + "version", + ].sort(), + ); + } + const raw = JSON.stringify(body); + expect(raw).not.toContain("clientSecret"); + expect(raw).not.toContain("credentials:read"); + expect(raw).not.toContain("sshAuthTypes"); + }); + }); + it("lists a plugin's declared and granted capabilities", async () => { state.plugins.set("sample-plugin", makePlugin()); state.grants.push({ diff --git a/src/backend/tests/database/routes/totp-disable-route.test.ts b/src/backend/tests/database/routes/totp-disable-route.test.ts index 66b233367..a0a349364 100644 --- a/src/backend/tests/database/routes/totp-disable-route.test.ts +++ b/src/backend/tests/database/routes/totp-disable-route.test.ts @@ -9,6 +9,10 @@ vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentUserRepository: () => ({ findById, update: userUpdate }), createCurrentTrustedDeviceRepository: () => ({}), createCurrentUserSessionRepository: () => ({}), + createCurrentUserAuthRepository: () => ({ + removeSecondFactor: vi.fn().mockResolvedValue(true), + recordSecondFactor: vi.fn().mockResolvedValue(undefined), + }), })); vi.mock("../../../utils/logger.js", () => ({ diff --git a/src/backend/tests/database/routes/user-admin-routes.test.ts b/src/backend/tests/database/routes/user-admin-routes.test.ts index 4f523738f..c9b2a6954 100644 --- a/src/backend/tests/database/routes/user-admin-routes.test.ts +++ b/src/backend/tests/database/routes/user-admin-routes.test.ts @@ -17,6 +17,8 @@ const state = vi.hoisted(() => ({ unlockedUsers: new Set(), updates: [] as { id: string; changes: Record }[], auditCalls: [] as Record[], + secondFactors: [] as { userId: string; pluginId: string; factorId: string }[], + trustedDevicesCleared: [] as string[], })); vi.mock("../../../database/db/index.js", () => ({ db: {} })); @@ -89,6 +91,38 @@ vi.mock("../../../database/repositories/factory.js", () => ({ switchUserRoleName: async () => {}, assignRoleNameToUser: async () => {}, }), + createCurrentUserAuthRepository: () => ({ + listSecondFactors: async (userId: string) => + state.secondFactors.filter((row) => row.userId === userId), + removeSecondFactor: async ( + userId: string, + pluginId: string, + factorId: string, + ) => { + state.secondFactors = state.secondFactors.filter( + (row) => + !( + row.userId === userId && + row.pluginId === pluginId && + row.factorId === factorId + ), + ); + return true; + }, + clearSecondFactors: async (userId: string) => { + const before = state.secondFactors.length; + state.secondFactors = state.secondFactors.filter( + (row) => row.userId !== userId, + ); + return before - state.secondFactors.length; + }, + }), + createCurrentTrustedDeviceRepository: () => ({ + deleteByUserId: async (userId: string) => { + state.trustedDevicesCleared.push(userId); + }, + }), + getCurrentSettingValue: () => null, })); const { registerUserAdminRoutes } = @@ -180,6 +214,8 @@ async function invoke( beforeEach(() => { state.currentUserId = "admin1"; + state.secondFactors = []; + state.trustedDevicesCleared = []; state.users = new Map([ [ "admin1", @@ -356,3 +392,64 @@ describe("GET /admin/export/:userId", () => { expect(res.statusCode).toBe(403); }); }); + +describe("DELETE /admin/:userId/second-factors", () => { + it("clears every factor, including one whose plugin is gone, and audits", async () => { + state.secondFactors = [ + { userId: "target1", pluginId: "core", factorId: "totp" }, + { userId: "target1", pluginId: "gone-plugin", factorId: "yubikey" }, + { userId: "locked1", pluginId: "core", factorId: "totp" }, + ]; + const res = await invoke("delete", "/admin/:userId/second-factors", { + params: { userId: "target1" }, + }); + expect(res.statusCode).toBe(200); + expect(state.secondFactors).toEqual([ + { userId: "locked1", pluginId: "core", factorId: "totp" }, + ]); + expect(state.trustedDevicesCleared).toContain("target1"); + // TOTP's own reset cleared its secret. + expect( + state.updates.find((u) => u.id === "target1")?.changes, + ).toMatchObject({ totpEnabled: false, totpSecret: null }); + const audit = state.auditCalls.find( + (c) => c.action === "admin_reset_second_factors", + ); + expect(audit).toMatchObject({ resourceId: "target1", success: true }); + expect(JSON.parse(audit!.details as string).removed).toEqual( + expect.arrayContaining([ + { pluginId: "gone-plugin", factorId: "yubikey" }, + ]), + ); + }); + + it("lists factors with their availability", async () => { + state.secondFactors = [ + { userId: "target1", pluginId: "gone-plugin", factorId: "yubikey" }, + ]; + const res = await invoke("get", "/admin/:userId/second-factors", { + params: { userId: "target1" }, + }); + expect(res.statusCode).toBe(200); + expect((res.jsonBody as { factors: unknown[] }).factors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ factorId: "yubikey", available: false }), + ]), + ); + }); + + it("403s when the caller is not an admin", async () => { + state.currentUserId = "target1"; + const res = await invoke("delete", "/admin/:userId/second-factors", { + params: { userId: "locked1" }, + }); + expect(res.statusCode).toBe(403); + }); + + it("404s for an unknown target", async () => { + const res = await invoke("delete", "/admin/:userId/second-factors", { + params: { userId: "ghost" }, + }); + expect(res.statusCode).toBe(404); + }); +}); diff --git a/src/backend/tests/database/routes/user-webauthn-routes.test.ts b/src/backend/tests/database/routes/user-webauthn-routes.test.ts index 2b155aff2..9612df3d5 100644 --- a/src/backend/tests/database/routes/user-webauthn-routes.test.ts +++ b/src/backend/tests/database/routes/user-webauthn-routes.test.ts @@ -6,6 +6,24 @@ const mocks = vi.hoisted(() => ({ verify: vi.fn(), user: vi.fn(), update: vi.fn(), + manager: { + authenticateWebAuthnUser: vi.fn(), + authenticateOIDCUser: vi.fn(), + isTrustedDevice: vi.fn(), + generateJWTToken: vi.fn(), + getSecureCookieOptions: vi.fn(), + getClearCookieOptions: vi.fn(), + }, +})); +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { getInstance: () => mocks.manager }, +})); +vi.mock("../../../utils/audit-logger.js", () => ({ + logAudit: vi.fn(), + getRequestMeta: () => ({ ipAddress: "", userAgent: "" }), +})); +vi.mock("../../../hosts/automation-events.js", () => ({ + notifyAutomationInternalEvent: vi.fn(), })); vi.mock("@simplewebauthn/server", () => ({ generateAuthenticationOptions: vi @@ -17,6 +35,10 @@ vi.mock("@simplewebauthn/server", () => ({ })); vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentUserRepository: () => ({ findById: mocks.user }), + createCurrentUserAuthRepository: () => ({ + listSecondFactors: async () => [], + }), + createCurrentSettingsRepository: () => ({ get: async () => null }), createCurrentWebauthnCredentialRepository: () => ({ findByCredentialId: vi.fn().mockResolvedValue({ id: 1, @@ -30,7 +52,16 @@ vi.mock("../../../database/repositories/factory.js", () => ({ }), getCurrentSettingValue: vi.fn(), })); -vi.mock("../../../utils/logger.js", () => ({ authLogger: { warn: vi.fn() } })); +vi.mock("../../../utils/logger.js", () => { + const log = { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), + debug: vi.fn(), + }; + return { authLogger: log, sshLogger: log, logger: log, databaseLogger: log }; +}); vi.mock("../../../utils/user-agent-parser.js", () => ({ parseUserAgent: () => ({ type: "browser", deviceInfo: "test" }), generateDeviceFingerprint: () => "device", @@ -40,12 +71,7 @@ const { registerUserWebAuthnRoutes } = await import("../../../database/routes/user-webauthn-routes.js"); type Handler = (req: Request, res: Response) => Promise; const handlers = new Map(); -const manager = { - authenticateWebAuthnUser: vi.fn().mockResolvedValue(true), - isTrustedDevice: vi.fn().mockResolvedValue(false), - generateJWTToken: vi.fn().mockResolvedValue("token"), - getSecureCookieOptions: vi.fn().mockReturnValue({ httpOnly: true }), -}; +const manager = mocks.manager; registerUserWebAuthnRoutes( { post: (path: string, ...callbacks: Handler[]) => @@ -62,6 +88,7 @@ registerUserWebAuthnRoutes( function response() { const res = { json: vi.fn(), status: vi.fn(), cookie: vi.fn() }; res.status.mockReturnValue(res); + res.cookie.mockReturnValue(res); return res; } async function login(userVerification: string) { @@ -78,13 +105,21 @@ async function login(userVerification: string) { await handlers.get("/webauthn/authenticate/verify")!( { body: { challengeId, response: { id: "key" }, rememberMe: true }, - } as Request, + get: () => undefined, + headers: {}, + socket: {}, + } as unknown as Request, res as unknown as Response, ); return res; } beforeEach(() => { vi.clearAllMocks(); + manager.authenticateWebAuthnUser.mockResolvedValue(true); + manager.authenticateOIDCUser.mockResolvedValue(true); + manager.generateJWTToken.mockResolvedValue("token"); + manager.getSecureCookieOptions.mockReturnValue({ httpOnly: true }); + manager.getClearCookieOptions.mockReturnValue({}); mocks.user.mockResolvedValue({ id: "user", username: "alice", @@ -134,7 +169,11 @@ describe("WebAuthn second factor policy", () => { expect(res.cookie).not.toHaveBeenCalled(); }); it("allows users without TOTP to log in with a possession-only key", async () => { - mocks.user.mockResolvedValue({ id: "user", totpEnabled: false }); + mocks.user.mockResolvedValue({ + id: "user", + username: "alice", + totpEnabled: false, + }); expect((await login("discouraged")).cookie).toHaveBeenCalled(); }); }); diff --git a/src/backend/tests/hosts/connect/pipeline-test-helpers.ts b/src/backend/tests/hosts/connect/pipeline-test-helpers.ts new file mode 100644 index 000000000..f115abcab --- /dev/null +++ b/src/backend/tests/hosts/connect/pipeline-test-helpers.ts @@ -0,0 +1,44 @@ +import { EventEmitter } from "node:events"; +import { vi } from "vitest"; + +/** Stands in for ssh2's Client: records connect() and lets a test drive events. */ +export class FakeSshClient extends EventEmitter { + connectConfig: Record | null = null; + ended = false; + forwardOut = vi.fn( + ( + _srcIp: string, + _srcPort: number, + _dstIp: string, + _dstPort: number, + cb: (err: Error | undefined, stream: unknown) => void, + ) => cb(undefined, { fakeStream: true }), + ); + + constructor() { + super(); + FakeSshClient.instances.push(this); + } + + connect(config: Record) { + this.connectConfig = config; + const behaviour = FakeSshClient.nextBehaviour.shift() ?? "ready"; + queueMicrotask(() => { + if (behaviour === "ready") this.emit("ready"); + else if (behaviour === "auth-fail") + this.emit( + "error", + new Error("All configured authentication methods failed"), + ); + else if (behaviour === "hang") return; + }); + return this; + } + + end() { + this.ended = true; + } + + static nextBehaviour: Array<"ready" | "auth-fail" | "hang"> = []; + static instances: FakeSshClient[] = []; +} diff --git a/src/backend/tests/hosts/connect/pipeline.test.ts b/src/backend/tests/hosts/connect/pipeline.test.ts new file mode 100644 index 000000000..634ac0adb --- /dev/null +++ b/src/backend/tests/hosts/connect/pipeline.test.ts @@ -0,0 +1,687 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ssh2 from "ssh2"; +import { FakeSshClient } from "./pipeline-test-helpers.js"; + +vi.mock("ssh2", async (importOriginal) => { + const actual = await importOriginal(); + const { FakeSshClient: Fake } = await import("./pipeline-test-helpers.js"); + return { + ...actual, + default: { + ...(actual as unknown as { default: object }).default, + Client: Fake, + }, + Client: Fake, + }; +}); + +const mocks = vi.hoisted(() => ({ + verifier: () => {}, + createHostVerifier: vi.fn(), + getOPKSSHToken: vi.fn(), + invalidateOPKSSHToken: vi.fn(), + setupOPKSSHCertAuth: vi.fn(), + setupCACertAuth: vi.fn(), + getVaultCert: vi.fn(), + deleteVaultCert: vi.fn(), + applyAgentAuth: vi.fn(), + performPortKnocking: vi.fn(), + createJumpHostChain: vi.fn(), + createSocks5Connection: vi.fn(), + resolveHostById: vi.fn(), +})); + +vi.mock("../../../utils/logger.js", () => { + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + return { logger: log, sshLogger: log, authLogger: log, fileLogger: log }; +}); +vi.mock("../../../hosts/host-key-verifier.js", () => ({ + SSHHostKeyVerifier: { + preloadHostData: async () => null, + createHostVerifier: mocks.createHostVerifier, + }, +})); +vi.mock("../../../hosts/opkssh-auth.js", () => ({ + getOPKSSHToken: mocks.getOPKSSHToken, + invalidateOPKSSHToken: mocks.invalidateOPKSSHToken, +})); +vi.mock("../../../hosts/opkssh-cert-auth.js", () => ({ + setupOPKSSHCertAuth: mocks.setupOPKSSHCertAuth, + setupCACertAuth: mocks.setupCACertAuth, +})); +vi.mock("../../../hosts/vault-signer-auth.js", () => ({ + getVaultCert: mocks.getVaultCert, + deleteVaultCert: mocks.deleteVaultCert, +})); +vi.mock("../../../hosts/terminal-auth-helpers.js", () => ({ + applyAgentAuth: mocks.applyAgentAuth, + performPortKnocking: mocks.performPortKnocking, +})); +vi.mock("../../../hosts/jump-host-chain.js", () => ({ + createJumpHostChain: mocks.createJumpHostChain, +})); +vi.mock("../../../utils/socks5-helper.js", () => ({ + createSocks5Connection: mocks.createSocks5Connection, +})); +vi.mock("../../../hosts/ssh-dns.js", () => ({ + resolveSshConnectConfigHost: async (config: unknown) => config, +})); +vi.mock("../../../hosts/host-resolver.js", () => ({ + resolveHostById: mocks.resolveHostById, +})); +vi.mock("../../../hosts/ssh-connection-pool.js", () => ({ + withConnection: async ( + _key: string, + factory: () => Promise, + fn: (client: unknown) => Promise, + ) => fn(await factory()), +})); + +import { + buildConnectConfig, + getPurposeDefaults, +} from "../../../hosts/connect/build-connect-config.js"; +import { + connectHost, + getConnectionPoolKey, + SshConnectError, + withHostConnection, +} from "../../../hosts/connect/connect-host.js"; +import { + getSshAuthProvider, + listCredentialTypes, + registerSshAuthProvider, + setSshAuthTypeOwnerSource, +} from "../../../hosts/connect/auth-provider-registry.js"; +import { ensureCoreSshAuthProviders } from "../../../hosts/connect/core-providers.js"; +import { TAILSCALE_CHECK_TIMEOUT_MS } from "../../../auth/legacy-providers.js"; +import type { SshConnectHost } from "../../../hosts/connect/types.js"; + +const plainKey = ssh2.utils.generateKeyPairSync("ed25519").private; +const encryptedKey = ssh2.utils.generateKeyPairSync("ed25519", { + passphrase: "secret", + cipher: "aes256-cbc", +}).private; + +function host(overrides: Partial = {}): SshConnectHost { + return { + id: 7, + ip: "10.0.0.7", + port: 22, + username: "root", + userId: "owner-1", + authType: "password", + password: "hunter2", + ...overrides, + }; +} + +async function build( + target: SshConnectHost, + purpose: Parameters[1]["purpose"] = "terminal", +) { + return buildConnectConfig(target, { + userId: "user-1", + purpose, + client: new FakeSshClient() as never, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + FakeSshClient.nextBehaviour = []; + FakeSshClient.instances = []; + mocks.createHostVerifier.mockResolvedValue(mocks.verifier); + mocks.applyAgentAuth.mockImplementation(async (config) => { + config.agent = "agent-object"; + return { socketPath: "/tmp/agent.sock" }; + }); + mocks.setupOPKSSHCertAuth.mockImplementation(async (config) => { + config.authHandler = "cert-handler"; + }); + mocks.setupCACertAuth.mockImplementation(async (config) => { + config.authHandler = "ca-handler"; + }); +}); + +describe("buildConnectConfig per auth type", () => { + it("password puts the password in the config", async () => { + const { config, outcome } = await build(host()); + expect(outcome).toEqual({ status: "ready" }); + expect(config).toMatchObject({ + host: "10.0.0.7", + port: 22, + username: "root", + password: "hunter2", + tryKeyboard: true, + keepaliveInterval: 30000, + keepaliveCountMax: 5, + readyTimeout: 120000, + }); + expect(config.hostVerifier).toBe(mocks.verifier); + }); + + it("password leaves the password out when keyboard-interactive is forced", async () => { + const { config, outcome } = await build( + host({ forceKeyboardInteractive: true }), + ); + expect(outcome.status).toBe("ready"); + expect(config.password).toBeUndefined(); + }); + + it("password without a password is an error", async () => { + const { outcome } = await build(host({ password: null })); + expect(outcome).toMatchObject({ status: "error", code: "missing-secret" }); + }); + + it("key sets privateKey, passphrase and keeps a password fallback", async () => { + const { config, outcome } = await build( + host({ + authType: "key", + key: encryptedKey, + keyPassword: "secret", + password: "fallback", + }), + ); + expect(outcome.status).toBe("ready"); + expect(Buffer.isBuffer(config.privateKey)).toBe(true); + expect(config.passphrase).toBe("secret"); + expect(config.password).toBe("fallback"); + expect(mocks.setupCACertAuth).not.toHaveBeenCalled(); + }); + + it("key with a CA certificate grafts the certificate", async () => { + const { config } = await build( + host({ + authType: "key", + key: plainKey, + password: null, + certPublicKey: "ssh-ed25519-cert-v01@openssh.com AAAA", + }), + ); + expect(mocks.setupCACertAuth).toHaveBeenCalledOnce(); + expect(config.authHandler).toBe("ca-handler"); + }); + + it("key with a failing CA certificate falls back to key only", async () => { + mocks.setupCACertAuth.mockRejectedValueOnce(new Error("bad cert")); + const { outcome } = await build( + host({ authType: "key", key: plainKey, certPublicKey: "junk" }), + ); + expect(outcome.status).toBe("ready"); + }); + + it("an encrypted key without its passphrase asks for one", async () => { + const { outcome } = await build( + host({ authType: "key", key: encryptedKey }), + ); + expect(outcome).toMatchObject({ + status: "error", + code: "passphrase-required", + }); + }); + + it("a broken key is an invalid-key error", async () => { + const { outcome } = await build( + host({ authType: "key", key: "not a key" }), + ); + expect(outcome).toMatchObject({ status: "error", code: "invalid-key" }); + }); + + it("credential behaves like whichever secret the resolver left", async () => { + const keyed = await build( + host({ authType: "credential", key: plainKey, password: null }), + ); + expect(Buffer.isBuffer(keyed.config.privateKey)).toBe(true); + + const passworded = await build(host({ authType: "credential" })); + expect(passworded.config.password).toBe("hunter2"); + + const empty = await build(host({ authType: "credential", password: null })); + expect(empty.outcome).toMatchObject({ code: "missing-secret" }); + }); + + it("agent hands the config to the agent helper", async () => { + const { config, outcome } = await build( + host({ + authType: "agent", + password: null, + terminalConfig: { agentSocketPath: "/tmp/agent.sock" }, + }), + ); + expect(outcome.status).toBe("ready"); + expect(config.agent).toBe("agent-object"); + expect(mocks.applyAgentAuth).toHaveBeenCalledWith(config, { + agentSocketPath: "/tmp/agent.sock", + }); + }); + + it("agent errors are reported", async () => { + mocks.applyAgentAuth.mockResolvedValueOnce({ error: "no socket" }); + const { outcome } = await build(host({ authType: "agent" })); + expect(outcome).toEqual({ + status: "error", + code: "failed", + message: "no socket", + }); + }); + + it("none adds no credentials", async () => { + const { config, outcome } = await build( + host({ authType: "none", password: null }), + ); + expect(outcome.status).toBe("ready"); + expect(config.password).toBeUndefined(); + expect(config.privateKey).toBeUndefined(); + expect(config.tryKeyboard).toBe(true); + }); + + it("opkssh without a cached token asks for a sign-in", async () => { + mocks.getOPKSSHToken.mockResolvedValueOnce(null); + const { outcome } = await build(host({ authType: "opkssh" })); + expect(outcome).toMatchObject({ + status: "interaction-required", + interaction: "opkssh", + }); + expect(mocks.getOPKSSHToken).toHaveBeenCalledWith("user-1", 7); + }); + + it("opkssh and stepca use the cached certificate", async () => { + for (const authType of ["opkssh", "stepca"]) { + mocks.getOPKSSHToken.mockResolvedValueOnce({ + privateKey: "k", + sshCert: "c", + }); + const { config, outcome } = await build(host({ authType })); + expect(outcome.status).toBe("ready"); + expect(config.authHandler).toBe("cert-handler"); + } + expect(mocks.setupOPKSSHCertAuth).toHaveBeenCalledTimes(2); + }); + + it("vault needs a profile, then a cached certificate", async () => { + const noProfile = await build(host({ authType: "vault" })); + expect(noProfile.outcome).toMatchObject({ status: "error" }); + + mocks.getVaultCert.mockResolvedValueOnce(null); + const noCert = await build( + host({ authType: "vault", vaultProfile: { id: 3 } }), + ); + // The file manager turns the flag into requiresVaultAuth in its 401. + expect(noCert.outcome).toMatchObject({ + status: "interaction-required", + interaction: "vault", + flag: "requiresVaultAuth", + }); + + mocks.getVaultCert.mockResolvedValueOnce({ + privateKey: "k", + sshCert: "c", + }); + const ready = await build( + host({ authType: "vault", vaultProfile: { id: 3 } }), + ); + expect(ready.outcome.status).toBe("ready"); + expect(mocks.getVaultCert).toHaveBeenLastCalledWith("user-1", 3); + }); + + it("tailscale turns off keyboard-interactive, with long timeouts in the terminal", async () => { + const terminal = await build(host({ authType: "tailscale" })); + expect(terminal.config).toMatchObject({ + tryKeyboard: false, + readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS, + timeout: TAILSCALE_CHECK_TIMEOUT_MS, + }); + + const fleet = await build(host({ authType: "tailscale" }), "fleet"); + expect(fleet.config.tryKeyboard).toBe(false); + expect(fleet.config.readyTimeout).toBe(30000); + }); + + it("tailscale retries once with +password, then reports the failure", async () => { + ensureCoreSshAuthProviders(); + const provider = getSshAuthProvider("tailscale")!; + const env = {} as never; + const failure = new Error("All configured authentication methods failed"); + + const first = provider.onAuthFailed!(host({ authType: "tailscale" }), env, { + error: failure, + retries: 0, + canRetry: true, + methodNotAvailable: false, + }); + expect(first).toMatchObject({ + status: "retry", + patch: { username: "root+password", tryKeyboard: false }, + }); + + const second = provider.onAuthFailed!( + host({ authType: "tailscale" }), + env, + { + error: failure, + retries: 1, + canRetry: true, + methodNotAvailable: false, + }, + ); + expect(second).toMatchObject({ status: "error" }); + }); + + it("a type nobody provides names the plugin that would", async () => { + setSshAuthTypeOwnerSource(() => [ + { type: "made-up", pluginId: "made", pluginName: "Made Up" }, + ]); + const { outcome } = await build(host({ authType: "made-up" })); + expect(outcome).toEqual({ + status: "error", + code: "provider-missing", + message: "This host uses made-up, which needs the Made Up plugin", + }); + setSshAuthTypeOwnerSource(() => []); + }); + + it("jump hops skip keyboard-interactive for auth type none", async () => { + const { config } = await build( + host({ authType: "none", password: null }), + "jump-host", + ); + expect(config.tryKeyboard).toBe(false); + expect(mocks.createHostVerifier).toHaveBeenCalledWith( + 7, + "10.0.0.7", + 22, + null, + "user-1", + true, + null, + ); + }); +}); + +describe("purpose defaults", () => { + it("keeps each transport's old keepalive numbers", () => { + expect(getPurposeDefaults("terminal")).toMatchObject({ + keepaliveIntervalMs: 30000, + keepaliveCountMax: 5, + }); + expect(getPurposeDefaults("file-manager")).toMatchObject({ + keepaliveIntervalMs: 60000, + keepaliveCountMax: 5, + }); + expect(getPurposeDefaults("fleet")).toMatchObject({ + keepaliveIntervalMs: 30000, + keepaliveCountMax: 3, + }); + }); + + it("host keepalive settings apply where the transport honoured them", async () => { + const withSettings = host({ + terminalConfig: { keepaliveInterval: 10, keepaliveCountMax: 2 }, + }); + const terminal = await build(withSettings, "terminal"); + expect(terminal.config.keepaliveInterval).toBe(10000); + const fleet = await build(withSettings, "fleet"); + expect(fleet.config.keepaliveInterval).toBe(30000); + }); +}); + +describe("provider registry", () => { + it("registers and removes a plugin provider", async () => { + ensureCoreSshAuthProviders(); + const dispose = registerSshAuthProvider({ + type: "fixture", + pluginId: "fixture-plugin", + labelKey: "fixture.label", + credentialType: true, + prepare: async (config) => { + config.password = "from-plugin"; + return { status: "ready" }; + }, + }); + expect(listCredentialTypes()).toContain("fixture"); + const { config } = await build(host({ authType: "fixture" })); + expect(config.password).toBe("from-plugin"); + + dispose(); + expect(getSshAuthProvider("fixture")).toBeUndefined(); + expect(listCredentialTypes()).not.toContain("fixture"); + const { outcome } = await build(host({ authType: "fixture" })); + expect(outcome).toMatchObject({ code: "provider-missing" }); + }); + + it("refuses to let a second plugin take over a type", () => { + ensureCoreSshAuthProviders(); + expect(() => + registerSshAuthProvider({ + type: "password", + pluginId: "someone-else", + labelKey: "x", + prepare: async () => ({ status: "ready" }), + }), + ).toThrow(/already provided by core/); + }); + + it("offers password and key as credential types", () => { + ensureCoreSshAuthProviders(); + expect(listCredentialTypes()).toEqual( + expect.arrayContaining(["password", "key"]), + ); + }); +}); + +describe("connectHost", () => { + it("resolves the host for the acting user and connects", async () => { + mocks.resolveHostById.mockResolvedValueOnce(host()); + const connection = await connectHost(7, { + userId: "user-1", + purpose: "fleet", + }); + expect(mocks.resolveHostById).toHaveBeenCalledWith(7, "user-1"); + const client = connection.client as unknown as FakeSshClient; + expect(client.connectConfig).toMatchObject({ password: "hunter2" }); + connection.dispose(); + expect(client.ended).toBe(true); + }); + + it("uses a shared-override resolution exactly as the resolver returned it", async () => { + mocks.resolveHostById.mockResolvedValueOnce( + host({ authType: "key", key: plainKey, password: null, username: "me" }), + ); + const connection = await connectHost(7, { + userId: "recipient", + purpose: "fleet", + }); + const config = (connection.client as unknown as FakeSshClient) + .connectConfig!; + expect(config.username).toBe("me"); + expect(Buffer.isBuffer(config.privateKey)).toBe(true); + expect(config.password).toBeUndefined(); + }); + + it("fails for an unknown or forbidden host", async () => { + mocks.resolveHostById.mockResolvedValueOnce(null); + await expect( + connectHost(9, { userId: "user-1", purpose: "fleet" }), + ).rejects.toThrow("Host not found or access denied"); + }); + + it("throws the auth outcome instead of connecting", async () => { + mocks.getOPKSSHToken.mockResolvedValueOnce(null); + const attempt = connectHost(host({ authType: "opkssh" }), { + userId: "user-1", + purpose: "tmux", + }); + await expect(attempt).rejects.toBeInstanceOf(SshConnectError); + await expect( + connectHost(host({ authType: "opkssh" }), { + userId: "user-1", + purpose: "tmux", + }).catch((error) => error.code), + ).resolves.toBe("opkssh-required"); + }); + + it("goes through the jump chain and forwards to the target", async () => { + const jumpClient = new FakeSshClient(); + mocks.createJumpHostChain.mockResolvedValueOnce(jumpClient); + const connection = await connectHost( + host({ jumpHosts: [{ hostId: 2 }, { hostId: 3 }] }), + { userId: "user-1", purpose: "fleet" }, + ); + expect(mocks.createJumpHostChain).toHaveBeenCalledWith( + [{ hostId: 2 }, { hostId: 3 }], + "owner-1", + ); + expect(jumpClient.forwardOut).toHaveBeenCalledWith( + "127.0.0.1", + 0, + "10.0.0.7", + 22, + expect.any(Function), + ); + const config = (connection.client as unknown as FakeSshClient) + .connectConfig!; + expect(config.sock).toEqual({ fakeStream: true }); + connection.dispose(); + expect(jumpClient.ended).toBe(true); + }); + + it("uses a SOCKS5 proxy when the host has one", async () => { + mocks.createSocks5Connection.mockResolvedValueOnce({ proxied: true }); + const connection = await connectHost( + host({ useSocks5: true, socks5Host: "proxy", socks5Port: 1080 }), + { userId: "user-1", purpose: "fleet" }, + ); + expect(mocks.createSocks5Connection).toHaveBeenCalledWith( + "10.0.0.7", + 22, + expect.objectContaining({ socks5Host: "proxy", socks5Port: 1080 }), + ); + expect( + (connection.client as unknown as FakeSshClient).connectConfig!.sock, + ).toEqual({ proxied: true }); + }); + + it("knocks before connecting when the host has a sequence", async () => { + await connectHost( + host({ portKnockSequence: [{ port: 7000 }, { port: 8000 }] }), + { userId: "user-1", purpose: "fleet" }, + ); + expect(mocks.performPortKnocking).toHaveBeenCalledWith("10.0.0.7", [ + { port: 7000 }, + { port: 8000 }, + ]); + }); + + it("auto-fills the stored password in keyboard-interactive with nobody to ask", async () => { + FakeSshClient.nextBehaviour = ["hang"]; + const attempt = connectHost(host(), { userId: "user-1", purpose: "fleet" }); + await vi.waitFor(() => { + const last = FakeSshClient.instances.at(-1)!; + expect(last.connectConfig).not.toBeNull(); + }); + const client = FakeSshClient.instances.at(-1)!; + const finish = vi.fn(); + client.emit( + "keyboard-interactive", + "", + "", + "", + [ + { prompt: "Password: ", echo: false }, + { prompt: "Something else: ", echo: true }, + ], + finish, + ); + expect(finish).toHaveBeenCalledWith(["hunter2", ""]); + client.emit("ready"); + await expect(attempt).resolves.toMatchObject({ client }); + }); + + it("asks through the prompt channel when there is one", async () => { + FakeSshClient.nextBehaviour = ["hang"]; + const ask = vi.fn().mockResolvedValue("123456"); + const attempt = connectHost(host(), { + userId: "user-1", + purpose: "file-manager", + prompt: { ask }, + }); + await vi.waitFor(() => + expect(FakeSshClient.instances.at(-1)!.connectConfig).not.toBeNull(), + ); + const client = FakeSshClient.instances.at(-1)!; + const finish = vi.fn(); + client.emit( + "keyboard-interactive", + "", + "", + "", + [ + { prompt: "Password: ", echo: false }, + { prompt: "Verification code: ", echo: true }, + ], + finish, + ); + await vi.waitFor(() => expect(finish).toHaveBeenCalled()); + expect(ask).toHaveBeenCalledWith({ + kind: "totp", + prompt: "Verification code: ", + retry: false, + }); + expect(finish).toHaveBeenCalledWith(["hunter2", "123456"]); + client.emit("ready"); + await attempt; + }); + + it("times out when the server never answers", async () => { + FakeSshClient.nextBehaviour = ["hang"]; + await expect( + connectHost(host(), { + userId: "user-1", + purpose: "fleet", + timeoutMs: 20, + }), + ).rejects.toThrow("SSH connection timeout"); + }); + + it("lets the provider react to an auth failure", async () => { + FakeSshClient.nextBehaviour = ["auth-fail"]; + mocks.getOPKSSHToken.mockResolvedValueOnce({ + privateKey: "k", + sshCert: "c", + }); + await expect( + connectHost(host({ authType: "opkssh" }), { + userId: "user-1", + purpose: "fleet", + }), + ).rejects.toThrow(/All configured authentication methods failed/); + await vi.waitFor(() => + expect(mocks.invalidateOPKSSHToken).toHaveBeenCalledWith( + "user-1", + 7, + "SSH auth failed", + ), + ); + }); + + it("pools under the old key shape", async () => { + expect(getConnectionPoolKey("fleet", host())).toBe( + "fleet:owner-1:10.0.0.7:22:root", + ); + expect( + getConnectionPoolKey( + "stats", + host({ useSocks5: true, socks5Host: "p", socks5Port: 1 }), + ), + ).toBe("stats:owner-1:10.0.0.7:22:root:socks5:p:1"); + + const result = await withHostConnection( + "fleet:x", + host(), + { userId: "user-1", purpose: "fleet" }, + async (client) => (client as unknown as FakeSshClient).connectConfig, + ); + expect(result).toMatchObject({ password: "hunter2" }); + }); +}); diff --git a/src/backend/tests/hosts/auth-manager.test.ts b/src/backend/tests/hosts/connect/terminal-prompt.test.ts similarity index 97% rename from src/backend/tests/hosts/auth-manager.test.ts rename to src/backend/tests/hosts/connect/terminal-prompt.test.ts index 7bfefe6d7..512465512 100644 --- a/src/backend/tests/hosts/auth-manager.test.ts +++ b/src/backend/tests/hosts/connect/terminal-prompt.test.ts @@ -1,17 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("../../database/repositories/factory.js", () => ({ +vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentHostResolutionRepository: () => ({ findCredentialByIdForUser: async () => null, }), })); -vi.mock("../../utils/logger.js", () => ({ +vi.mock("../../../utils/logger.js", () => ({ sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, authLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); -import { SSHAuthManager } from "../../hosts/auth-manager.js"; +import { SSHAuthManager } from "../../../hosts/connect/terminal-prompt.js"; function createManager() { const sent: Record[] = []; diff --git a/src/backend/tests/hosts/file-manager/ca-cert-auth.test.ts b/src/backend/tests/hosts/file-manager/ca-cert-auth.test.ts deleted file mode 100644 index d5cc80397..000000000 --- a/src/backend/tests/hosts/file-manager/ca-cert-auth.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const setupCACertAuth = vi.fn(); -const warn = vi.fn(); - -vi.mock("../../../hosts/opkssh-cert-auth.js", () => ({ - setupCACertAuth: (...args: unknown[]) => setupCACertAuth(...args), -})); - -vi.mock("../../../utils/logger.js", () => ({ - fileLogger: { warn, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, -})); - -const { applyCACertIfPresent } = - await import("../../../hosts/file-manager/ca-cert-auth.js"); - -/** - * `setupCACertAuth` had no call site in the file manager at all, while its - * sibling `setupOPKSSHCertAuth` had two. So a host whose key is paired with a - * user-managed CA-signed certificate authenticated in the terminal and failed - * over SFTP, and OPKSSH certificates — going through the other helper — worked - * in both places. - */ -describe("applyCACertIfPresent", () => { - const client = {} as never; - const key = Buffer.from("private-key"); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("attaches a certificate the host carries", async () => { - const config: Record = {}; - - await applyCACertIfPresent( - config, - client, - key, - { certPublicKey: "ssh-rsa-cert-v01@openssh.com AAAA" }, - "root", - "passphrase", - ); - - expect(setupCACertAuth).toHaveBeenCalledWith( - config, - client, - key, - "ssh-rsa-cert-v01@openssh.com AAAA", - "root", - "passphrase", - ); - }); - - it("does nothing when there is no certificate", async () => { - // Most hosts. Touching the connection here would change key-only auth. - for (const certPublicKey of [undefined, null, "", " "]) { - await applyCACertIfPresent( - {}, - client, - key, - { certPublicKey }, - "root", - undefined, - ); - } - - expect(setupCACertAuth).not.toHaveBeenCalled(); - }); - - it("leaves the connection usable when the certificate is unusable", async () => { - // The private key on its own may still be accepted — which is what - // happened before this was wired up. Failing the connection here would - // turn a working setup into a broken one. - setupCACertAuth.mockRejectedValueOnce(new Error("bad cert format")); - - await expect( - applyCACertIfPresent( - {}, - client, - key, - { certPublicKey: "garbage" }, - "root", - undefined, - ), - ).resolves.toBeUndefined(); - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining("CA certificate setup failed"), - expect.objectContaining({ operation: "sftp_ca_cert_auth_failed" }), - ); - }); -}); diff --git a/src/backend/tests/hosts/file-manager/vault-auth.test.ts b/src/backend/tests/hosts/file-manager/vault-auth.test.ts deleted file mode 100644 index b4f1fb6e7..000000000 --- a/src/backend/tests/hosts/file-manager/vault-auth.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { describe, expect, it } from "vitest"; - -describe("file manager Vault authentication", () => { - it("configures the cached Vault certificate before connecting", () => { - const source = fs.readFileSync( - path.resolve(__dirname, "../../../hosts/file-manager/index.ts"), - "utf8", - ); - const vaultBranch = source.indexOf( - 'resolvedCredentials.authType === "vault"', - ); - const agentBranch = source.indexOf( - 'resolvedCredentials.authType === "agent"', - vaultBranch, - ); - const body = source.slice(vaultBranch, agentBranch); - - expect(vaultBranch).toBeGreaterThan(-1); - expect(agentBranch).toBeGreaterThan(vaultBranch); - expect(body).toContain("setupVaultSshSignerAuth"); - expect(body).toContain("requiresVaultAuth: true"); - }); -}); diff --git a/src/backend/tests/hosts/tmux/auth-utils.test.ts b/src/backend/tests/hosts/tmux/auth-utils.test.ts deleted file mode 100644 index 23488c864..000000000 --- a/src/backend/tests/hosts/tmux/auth-utils.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getTmuxAuthBehavior } from "../../../hosts/tmux/auth-utils.js"; - -describe("getTmuxAuthBehavior", () => { - it("uses credentialless non-interactive authentication for Tailscale SSH", () => { - expect(getTmuxAuthBehavior("tailscale")).toEqual({ - credentialless: true, - tryKeyboard: false, - }); - }); - - it("preserves keyboard-interactive fallback for none authentication", () => { - expect(getTmuxAuthBehavior("none")).toEqual({ - credentialless: true, - tryKeyboard: true, - }); - }); - - it("does not treat password authentication as credentialless", () => { - expect(getTmuxAuthBehavior("password")).toEqual({ - credentialless: false, - tryKeyboard: true, - }); - }); -}); diff --git a/src/backend/tests/plugins/ctx-ssh-auth.test.ts b/src/backend/tests/plugins/ctx-ssh-auth.test.ts new file mode 100644 index 000000000..265d64a6d --- /dev/null +++ b/src/backend/tests/plugins/ctx-ssh-auth.test.ts @@ -0,0 +1,253 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + granted: new Set(), + connects: [] as Array<{ target: unknown; options: Record }>, + factors: [] as Array<{ userId: string; pluginId: string; factorId: string }>, + actor: undefined as string | undefined, +})); + +vi.mock("../../utils/logger.js", () => { + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + return { pluginLogger: log, sshLogger: log, logger: log, authLogger: log }; +}); +vi.mock("../../plugins/permissions.js", async () => { + const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend"); + return { + assertCapability: async ( + pluginId: string, + capability: string, + declared: readonly string[], + ) => { + if (!declared.includes(capability) || !h.granted.has(capability)) { + throw new PluginCapabilityError(pluginId, capability); + } + }, + }; +}); +vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor })); +vi.mock("../../hosts/connect/connect-host.js", () => ({ + connectHost: async (target: unknown, options: Record) => { + h.connects.push({ target, options }); + const client = Object.assign(new EventEmitter(), { end: vi.fn() }); + return { client, jumpClient: null, dispose: vi.fn(() => client.end()) }; + }, +})); +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentUserAuthRepository: () => ({ + recordSecondFactor: async ( + userId: string, + pluginId: string, + factorId: string, + ) => { + h.factors.push({ userId, pluginId, factorId }); + }, + removeSecondFactor: async () => true, + }), +})); + +const { createPluginAuth, createPluginSsh } = + await import("../../plugins/ctx-ssh-auth.js"); +const { DisposableBag } = await import("../../plugins/disposables.js"); +const { getSshAuthProvider } = + await import("../../hosts/connect/auth-provider-registry.js"); +const { getLoginMethod, getSecondFactor } = + await import("../../auth/registry.js"); +const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend"); + +function manifest(capabilities: string[], auth: Record = {}) { + return { + id: "fixture", + name: "Fixture", + version: "1.0.0", + capabilities, + contributes: { auth }, + } as never; +} + +beforeEach(() => { + h.granted = new Set(); + h.connects = []; + h.factors = []; + h.actor = "user-1"; +}); + +describe("ctx.ssh", () => { + it("refuses without ssh:connect and credentials:use, and audits the refusal", async () => { + const audit = vi.fn(async () => {}); + const ssh = createPluginSsh({ + manifest: manifest(["ssh:connect"]), + bag: new DisposableBag("fixture"), + audit, + }); + h.granted = new Set(["ssh:connect"]); + await expect(ssh.connect(7)).rejects.toBeInstanceOf(PluginCapabilityError); + expect(audit).toHaveBeenCalledWith( + "ssh_connect", + "host 7", + expect.objectContaining({ success: false }), + ); + expect(h.connects).toEqual([]); + }); + + it("connects as the acting user through the pipeline and audits", async () => { + const audit = vi.fn(async () => {}); + const ssh = createPluginSsh({ + manifest: manifest(["ssh:connect", "credentials:use"]), + bag: new DisposableBag("fixture"), + audit, + }); + h.granted = new Set(["ssh:connect", "credentials:use"]); + await ssh.connect(7, { purpose: "docker" }); + expect(h.connects).toEqual([ + { + target: 7, + options: expect.objectContaining({ + userId: "user-1", + purpose: "docker", + }), + }, + ]); + expect(audit).toHaveBeenCalledWith("ssh_connect", "host 7", { + success: true, + }); + }); + + it("runs background work on a resolved host as its owner", async () => { + h.actor = undefined; + h.granted = new Set(["ssh:connect", "credentials:use"]); + const ssh = createPluginSsh({ + manifest: manifest(["ssh:connect", "credentials:use"]), + bag: new DisposableBag("fixture"), + audit: vi.fn(async () => {}), + }); + await ssh.connect({ + id: 3, + ip: "h", + port: 22, + username: "u", + userId: "owner", + }); + expect(h.connects[0].options).toMatchObject({ userId: "owner" }); + await expect(ssh.connect(3)).rejects.toThrow(/acting user/); + }); + + it("closes open connections when the plugin is disposed", async () => { + h.granted = new Set(["ssh:connect", "credentials:use"]); + const bag = new DisposableBag("fixture"); + const ssh = createPluginSsh({ + manifest: manifest(["ssh:connect", "credentials:use"]), + bag, + audit: vi.fn(async () => {}), + }); + const connection = await ssh.connect(7); + await bag.disposeAll(); + expect( + (connection.client as { end: ReturnType }).end, + ).toHaveBeenCalled(); + }); +}); + +describe("ctx.auth", () => { + it("needs auth:provide and a contributes.auth entry to register anything", () => { + const auth = createPluginAuth({ + manifest: manifest([], { sshAuthTypes: ["corp"] }), + bag: new DisposableBag("fixture"), + audit: vi.fn(async () => {}), + }); + expect(() => + auth.registerSshAuthProvider({ + type: "corp", + labelKey: "corp", + prepare: async () => ({ status: "ready" }), + }), + ).toThrow(PluginCapabilityError); + + const declared = createPluginAuth({ + manifest: manifest(["auth:provide"], { sshAuthTypes: ["corp"] }), + bag: new DisposableBag("fixture"), + audit: vi.fn(async () => {}), + }); + expect(() => + declared.registerSshAuthProvider({ + type: "other", + labelKey: "other", + prepare: async () => ({ status: "ready" }), + }), + ).toThrow(/contributes\.auth\.sshAuthTypes/); + }); + + it("registers an SSH auth type, checks the grant on use and removes it on dispose", async () => { + const bag = new DisposableBag("fixture"); + const auth = createPluginAuth({ + manifest: manifest(["auth:provide"], { sshAuthTypes: ["corp"] }), + bag, + audit: vi.fn(async () => {}), + }); + auth.registerSshAuthProvider({ + type: "corp", + labelKey: "corp", + prepare: async (config) => { + config.password = "from-corp"; + return { status: "ready" }; + }, + }); + const provider = getSshAuthProvider("corp")!; + expect(provider.pluginId).toBe("fixture"); + + await expect( + provider.prepare({} as never, {} as never, {} as never), + ).rejects.toBeInstanceOf(PluginCapabilityError); + + h.granted = new Set(["auth:provide"]); + const config: Record = {}; + await provider.prepare(config as never, {} as never, {} as never); + expect(config.password).toBe("from-corp"); + + await bag.disposeAll(); + expect(getSshAuthProvider("corp")).toBeUndefined(); + }); + + it("registers login methods and second factors under the plugin, and records enrolment", async () => { + h.granted = new Set(["auth:provide"]); + const bag = new DisposableBag("fixture"); + const audit = vi.fn(async () => {}); + const auth = createPluginAuth({ + manifest: manifest(["auth:provide"], { + loginMethods: ["corp-sso"], + secondFactors: ["pin"], + }), + bag, + audit, + }); + auth.registerLoginMethod({ + id: "corp-sso", + labelKey: "corp", + kind: "redirect", + start: async () => ({ redirectUrl: "https://corp" }), + }); + auth.registerSecondFactor({ + id: "pin", + labelKey: "pin", + isEnrolled: async () => true, + verify: async () => true, + }); + expect(getLoginMethod("corp-sso")?.pluginId).toBe("fixture"); + expect(getSecondFactor("fixture", "pin")).toBeDefined(); + + await auth.recordEnrollment("user-1", "pin"); + expect(h.factors).toEqual([ + { userId: "user-1", pluginId: "fixture", factorId: "pin" }, + ]); + expect(audit).toHaveBeenCalledWith( + "auth_factor_enrolled", + "pin for user-1", + { success: true }, + ); + + await bag.disposeAll(); + expect(getLoginMethod("corp-sso")).toBeUndefined(); + expect(getSecondFactor("fixture", "pin")).toBeUndefined(); + }); +}); diff --git a/src/backend/tests/plugins/http.test.ts b/src/backend/tests/plugins/http.test.ts index 5a7ee065b..85dbe15c9 100644 --- a/src/backend/tests/plugins/http.test.ts +++ b/src/backend/tests/plugins/http.test.ts @@ -69,8 +69,8 @@ vi.mock("../../plugins/permissions.js", () => ({ vi.mock("../../utils/audit-logger.js", () => ({ logAudit: vi.fn() })); const http = await import("../../plugins/http.js"); -const pluginApi = (await import("../../database/routes/plugin-api-routes.js")) - .default; +const { mountPluginApi } = + await import("../../database/routes/plugin-api-routes.js"); function manifest(overrides: Record = {}) { return { @@ -86,9 +86,9 @@ let server: Server | null = null; async function startServer(): Promise { const app = express(); - // Exactly how database.ts mounts it: no auth in front, because the plugin - // router brings its own. - app.use("/plugin-api", pluginApi); + // The same mount database.ts uses. It used to put core auth in front of the + // whole prefix, which made every public plugin route unreachable. + mountPluginApi(app); return new Promise((resolve) => { server = app.listen(0, "127.0.0.1", () => { diff --git a/src/backend/tests/utils/crypto-migration/external-identity-migration.test.ts b/src/backend/tests/utils/crypto-migration/external-identity-migration.test.ts new file mode 100644 index 000000000..2936a0646 --- /dev/null +++ b/src/backend/tests/utils/crypto-migration/external-identity-migration.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createAuthState, + type AuthState, +} from "../../auth/auth-test-helpers.js"; + +const h = vi.hoisted(() => ({ state: null as unknown as AuthState })); + +vi.mock("../../../database/repositories/factory.js", async () => { + const helpers = await import("../../auth/auth-test-helpers.js"); + const names = Object.keys(helpers.fakeFactory(helpers.createAuthState())); + return Object.fromEntries( + names.map((name) => [ + name, + (...args: unknown[]) => + ( + helpers.fakeFactory(h.state) as Record< + string, + (...a: unknown[]) => unknown + > + )[name](...args), + ]), + ); +}); +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +const { runExternalIdentityMigration, parseLegacyIdentifier } = + await import("../../../utils/crypto-migration/external-identity-migration.js"); + +function user(id: string, extra: Record) { + h.state.users.set(id, { + id, + username: id, + passwordHash: "", + isAdmin: false, + isOidc: true, + ...extra, + }); +} + +beforeEach(() => { + h.state = createAuthState(); +}); + +describe("parseLegacyIdentifier", () => { + it("splits the prefixed forms and keeps bare subjects on their provider", () => { + expect(parseLegacyIdentifier("ldap:4:bob", null)).toEqual({ + providerId: "4", + subject: "bob", + }); + expect(parseLegacyIdentifier("github:7:42", null)).toEqual({ + providerId: "7", + subject: "42", + }); + expect(parseLegacyIdentifier("ldap:4:cn=a:b", null)).toEqual({ + providerId: "4", + subject: "cn=a:b", + }); + expect(parseLegacyIdentifier("sub-123", 3)).toEqual({ + providerId: "3", + subject: "sub-123", + }); + expect(parseLegacyIdentifier("sub-123", null)).toEqual({ + providerId: "legacy-oidc", + subject: "sub-123", + }); + expect(parseLegacyIdentifier("github:null:42", null)).toEqual({ + providerId: "legacy-oidc", + subject: "42", + }); + }); +}); + +describe("runExternalIdentityMigration", () => { + it("moves every identifier and TOTP enrolment without touching the old data", async () => { + user("ldap-user", { oidcIdentifier: "ldap:4:bob", ssoProviderId: 4 }); + user("gh-user", { oidcIdentifier: "github:7:42", ssoProviderId: 7 }); + user("oidc-user", { + oidcIdentifier: "sub-123", + ssoProviderId: 3, + totpEnabled: true, + }); + user("env-user", { oidcIdentifier: "someone@example.com" }); + user("local", { + isOidc: false, + passwordHash: "hash", + totpEnabled: true, + }); + + const result = await runExternalIdentityMigration(); + + expect(result).toEqual({ identities: 4, factors: 2, skipped: 0 }); + expect( + h.state.identities.map(({ userId, providerId, subject }) => ({ + userId, + providerId, + subject, + })), + ).toEqual([ + { userId: "ldap-user", providerId: "4", subject: "bob" }, + { userId: "gh-user", providerId: "7", subject: "42" }, + { userId: "oidc-user", providerId: "3", subject: "sub-123" }, + { + userId: "env-user", + providerId: "legacy-oidc", + subject: "someone@example.com", + }, + ]); + expect(h.state.factors).toEqual([ + { userId: "oidc-user", pluginId: "core", factorId: "totp" }, + { userId: "local", pluginId: "core", factorId: "totp" }, + ]); + // Lossless: the old column and flag stay. + expect(h.state.users.get("ldap-user")!.oidcIdentifier).toBe("ldap:4:bob"); + expect(h.state.users.get("local")!.totpEnabled).toBe(true); + }); + + it("is idempotent", async () => { + user("ldap-user", { oidcIdentifier: "ldap:4:bob", totpEnabled: true }); + await runExternalIdentityMigration(); + const second = await runExternalIdentityMigration(); + expect(second).toEqual({ identities: 0, factors: 0, skipped: 2 }); + expect(h.state.identities).toHaveLength(1); + expect(h.state.factors).toHaveLength(1); + }); +}); diff --git a/src/backend/utils/crypto-migration/external-identity-migration.ts b/src/backend/utils/crypto-migration/external-identity-migration.ts new file mode 100644 index 000000000..67946caeb --- /dev/null +++ b/src/backend/utils/crypto-migration/external-identity-migration.ts @@ -0,0 +1,121 @@ +/** + * Moves sign-in identities and 2FA enrolment into their own tables. + * + * users.oidc_identifier packed provider and subject into one string: + * "ldap::", "github::", or a bare OIDC subject + * whose provider was users.sso_provider_id. Each becomes a + * user_external_identities row. users.totp_enabled becomes a + * user_second_factors row for core's TOTP factor, so a login can tell a user + * has a factor even after the plugin that runs it is gone. + * + * Lossless: the old column and flag are left as they are. Idempotent: rows + * that already exist are skipped, so it runs on every boot. + */ + +import { databaseLogger } from "../logger.js"; +import { + createCurrentUserAuthRepository, + createCurrentUserRepository, +} from "../../database/repositories/factory.js"; + +/** Matches LEGACY_OIDC_PROVIDER_ID in auth/legacy/oidc-login.ts. */ +const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc"; + +export interface ExternalIdentityMigrationResult { + identities: number; + factors: number; + skipped: number; +} + +/** Splits a stored identifier into provider and subject. */ +export function parseLegacyIdentifier( + identifier: string, + ssoProviderId: number | null | undefined, +): { providerId: string; subject: string } | null { + if (!identifier) return null; + + const prefixed = /^(ldap|github):([^:]+):(.+)$/.exec(identifier); + if (prefixed) { + const provider = prefixed[2]; + return { + providerId: + provider === "null" || provider === "undefined" + ? LEGACY_OIDC_PROVIDER_ID + : provider, + subject: prefixed[3], + }; + } + + return { + providerId: + ssoProviderId !== null && ssoProviderId !== undefined + ? String(ssoProviderId) + : LEGACY_OIDC_PROVIDER_ID, + subject: identifier, + }; +} + +export async function runExternalIdentityMigration(): Promise { + const result: ExternalIdentityMigrationResult = { + identities: 0, + factors: 0, + skipped: 0, + }; + + try { + const users = await createCurrentUserRepository().listAll(); + const auth = createCurrentUserAuthRepository(); + + for (const user of users) { + if (user.oidcIdentifier) { + const parsed = parseLegacyIdentifier( + user.oidcIdentifier, + user.ssoProviderId, + ); + if (parsed) { + const existing = await auth.findIdentity( + parsed.providerId, + parsed.subject, + ); + if (existing) { + result.skipped++; + } else { + await auth.linkIdentity({ + userId: user.id, + providerId: parsed.providerId, + subject: parsed.subject, + }); + result.identities++; + } + } + } + + if (user.totpEnabled) { + const enrolled = await auth.listSecondFactors(user.id); + if ( + enrolled.some( + (row) => row.pluginId === "core" && row.factorId === "totp", + ) + ) { + result.skipped++; + } else { + await auth.recordSecondFactor(user.id, "core", "totp"); + result.factors++; + } + } + } + + if (result.identities > 0 || result.factors > 0) { + databaseLogger.info("Moved sign-in identities and 2FA enrolment", { + operation: "external_identity_migration", + ...result, + }); + } + } catch (error) { + databaseLogger.error("External identity migration failed", error, { + operation: "external_identity_migration", + }); + } + + return result; +} diff --git a/src/backend/utils/shared-host-auth-resolver.ts b/src/backend/utils/shared-host-auth-resolver.ts index b5a76288b..1a2e8f1ae 100644 --- a/src/backend/utils/shared-host-auth-resolver.ts +++ b/src/backend/utils/shared-host-auth-resolver.ts @@ -12,6 +12,8 @@ import type { HostResolutionCredentialRecord, HostResolutionHostRecord, } from "../database/repositories/host-resolution-repository.js"; +import { getSshAuthProvider } from "../hosts/connect/auth-provider-registry.js"; +import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js"; import { SharedHostSecretsManager, type SharedSecretData, @@ -38,13 +40,9 @@ export function requiresPersonalHostAuthentication( // Owner auth for RDP/VNC/Telnet is snapshotted for every recipient, so only // SSH, which sits behind shareSshAuth, can leave a recipient without auth. if (protocol !== "ssh") return false; - return ( - !!host.credentialId || - host.authType === "password" || - host.authType === "key" || - host.authType === "credential" || - host.authType === "agent" - ); + if (host.credentialId) return true; + ensureCoreSshAuthProviders(); + return !!getSshAuthProvider(host.authType ?? "none")?.requiresSecret; } /** Whether the owner's auth for this protocol is available to recipients. */ diff --git a/src/types/credential-sidebar-preferences.ts b/src/types/credential-sidebar-preferences.ts index bd474a940..735c49f6d 100644 --- a/src/types/credential-sidebar-preferences.ts +++ b/src/types/credential-sidebar-preferences.ts @@ -27,7 +27,8 @@ export type CredentialTrayTrigger = "always" | "hover" | "click" | "actionsOnly"; export interface CredentialSidebarFilterState { - type: ("password" | "key")[]; + /** Credential type ids; plugins can add their own. */ + type: string[]; tags: string[]; } @@ -60,7 +61,7 @@ const TRAY_TRIGGERS: CredentialTrayTrigger[] = [ "click", "actionsOnly", ]; -const FILTER_TYPE: CredentialSidebarFilterState["type"] = ["password", "key"]; +const TYPE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/; export function defaultCredentialSidebarPreferences(): CredentialSidebarPreferences { return { @@ -112,7 +113,9 @@ export function sanitizeCredentialSidebarPreferences( const filtersObj = (obj.filters ?? {}) as Record; const filters: CredentialSidebarFilterState = { - type: sanitizeEnumArray(filtersObj.type, FILTER_TYPE), + type: sanitizeStringArray(filtersObj.type).filter((value) => + TYPE_PATTERN.test(value), + ), tags: sanitizeStringArray(filtersObj.tags), }; diff --git a/src/types/host-sidebar-preferences.ts b/src/types/host-sidebar-preferences.ts index 6cab4d43f..9aa1cb854 100644 --- a/src/types/host-sidebar-preferences.ts +++ b/src/types/host-sidebar-preferences.ts @@ -34,9 +34,8 @@ export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly"; export interface HostSidebarFilterState { status: ("online" | "offline" | "pinned")[]; - authType: ( - "password" | "key" | "credential" | "none" | "opkssh" | "stepca" - )[]; + /** SSH auth type ids; plugins add their own. */ + authType: string[]; protocol: ("ssh" | "rdp" | "vnc" | "telnet")[]; features: ("terminal" | "fileManager" | "tunnel" | "docker")[]; tags: string[]; @@ -94,14 +93,8 @@ const FILTER_STATUS: HostSidebarFilterState["status"] = [ "offline", "pinned", ]; -const FILTER_AUTH_TYPE: HostSidebarFilterState["authType"] = [ - "password", - "key", - "credential", - "none", - "opkssh", - "stepca", -]; +/** Same shape as a manifest's contributes.auth.sshAuthTypes entries. */ +const AUTH_TYPE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/; const FILTER_PROTOCOL: HostSidebarFilterState["protocol"] = [ "ssh", "rdp", @@ -178,7 +171,9 @@ export function sanitizeHostSidebarPreferences( const filtersObj = (obj.filters ?? {}) as Record; const filters: HostSidebarFilterState = { status: sanitizeEnumArray(filtersObj.status, FILTER_STATUS), - authType: sanitizeEnumArray(filtersObj.authType, FILTER_AUTH_TYPE), + authType: sanitizeStringArray(filtersObj.authType).filter((value) => + AUTH_TYPE_PATTERN.test(value), + ), protocol: sanitizeEnumArray(filtersObj.protocol, FILTER_PROTOCOL), features: sanitizeEnumArray(filtersObj.features, FILTER_FEATURES), tags: sanitizeStringArray(filtersObj.tags), diff --git a/src/ui/api/auth-methods-api.ts b/src/ui/api/auth-methods-api.ts new file mode 100644 index 000000000..1fef531df --- /dev/null +++ b/src/ui/api/auth-methods-api.ts @@ -0,0 +1,169 @@ +import { + authApi, + handleApiError, + markUserAuthenticated, + type AuthResponse, +} from "@/main-axios"; + +export interface PublicLoginMethod { + id: string; + pluginId: string; + kind: "redirect" | "form"; + labelKey: string; + icon?: string; + instances: Array<{ id: string; label: string }>; +} + +export interface SecondFactorRef { + id: string; + pluginId: string; + labelKey: string; +} + +export type LoginResponse = AuthResponse & { + requires_second_factor?: boolean; + second_factors?: SecondFactorRef[]; + userId?: string; +}; + +function remember(data: LoginResponse): LoginResponse { + if (data?.token) localStorage.setItem("jwt", data.token); + if (data?.success && !data.requires_totp) markUserAuthenticated(); + return data; +} + +/** Enabled login methods for the login screen. Public. */ +export async function getLoginMethods(): Promise { + try { + const response = await authApi.get("/users/auth/methods"); + return Array.isArray(response.data?.methods) ? response.data.methods : []; + } catch { + return []; + } +} + +export async function submitLoginMethod( + methodId: string, + body: Record, + instanceId?: string, +): Promise { + try { + const response = await authApi.post( + `/users/auth/${encodeURIComponent(methodId)}/verify`, + { ...body, instanceId }, + ); + return remember(response.data); + } catch (error) { + throw handleApiError(error, "login"); + } +} + +export async function startLoginRedirect( + methodId: string, + params: { + instanceId?: string; + rememberMe?: boolean; + desktopCallbackPort?: number; + }, +): Promise { + try { + const response = await authApi.get( + `/users/auth/${encodeURIComponent(methodId)}/start`, + { + params: { + instance: params.instanceId, + rememberMe: params.rememberMe, + desktopCallbackPort: params.desktopCallbackPort, + }, + }, + ); + return response.data?.redirectUrl as string; + } catch (error) { + throw handleApiError(error, "start login"); + } +} + +/** + * Answers a second factor. With no temp token the server reads the pending + * login cookie a redirect method left. + */ +export async function verifySecondFactor( + factorId: string, + body: Record, +): Promise { + try { + const response = await authApi.post( + `/users/auth/second-factor/${encodeURIComponent(factorId)}/verify`, + body, + ); + return remember(response.data); + } catch (error) { + throw handleApiError(error, "verify second factor"); + } +} + +export async function challengeSecondFactor( + factorId: string, + tempToken?: string, +): Promise { + const response = await authApi.post( + `/users/auth/second-factor/${encodeURIComponent(factorId)}/challenge`, + { temp_token: tempToken }, + ); + return response.data?.challenge ?? null; +} + +export interface UserSecondFactorSummary { + pluginId: string; + factorId: string; + labelKey: string | null; + available: boolean; +} + +export async function getUserSecondFactors( + userId: string, +): Promise { + try { + const response = await authApi.get( + `/users/admin/${encodeURIComponent(userId)}/second-factors`, + ); + return response.data?.factors ?? []; + } catch (error) { + handleApiError(error, "list second factors"); + return []; + } +} + +export async function resetUserSecondFactors(userId: string): Promise { + try { + await authApi.delete( + `/users/admin/${encodeURIComponent(userId)}/second-factors`, + ); + } catch (error) { + throw handleApiError(error, "reset second factors"); + } +} + +export interface SshAuthProviderSummary { + type: string; + labelKey: string; + descriptionKey?: string; + pluginId: string; + fields: Array>; + credentialType: boolean; + needsUserInteraction: boolean; + supportsBackground: boolean; + available: boolean; + missingPlugin?: { id: string; name: string }; +} + +export async function getSshAuthProviders(): Promise { + try { + const response = await authApi.get("/ssh-auth/providers"); + return Array.isArray(response.data?.providers) + ? response.data.providers + : []; + } catch { + return []; + } +} diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 487fc76fe..0f380a8ee 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -12,9 +12,6 @@ import { ArrowLeft, Shield, CheckCircle2, - ChevronDown, - ChevronUp, - Fingerprint, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { @@ -28,8 +25,6 @@ import { initiatePasswordReset, verifyPasswordResetCode, completePasswordReset, - getOIDCAuthorizeUrl, - verifyTOTPLogin, isElectron, getCurrentToken, getOidcSilentLoginDefault, @@ -40,9 +35,19 @@ import { getEmbeddedServerFailure, type EmbeddedServerFailure, } from "@/lib/embedded-server-status"; -import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api"; -import { isPasskeySupported, loginWithPasskey } from "@/api/webauthn-api"; -import type { SSOProviderPublic } from "@/types/index"; +import { + challengeSecondFactor, + getLoginMethods, + startLoginRedirect, + submitLoginMethod, + verifySecondFactor, + type LoginResponse, + type PublicLoginMethod, + type SecondFactorRef, +} from "@/api/auth-methods-api"; +import { useLoginMethods, useSecondFactors } from "@/plugin-host/auth-registry"; +import { ensureLegacyAuthUI } from "./legacy-auth-ui"; +import { startPreLoginPlugins } from "@/plugin-host/loader"; import { Checkbox } from "@/components/checkbox"; import { useBranding } from "@/contexts/BrandingContext"; import { @@ -94,7 +99,22 @@ const LANGUAGES = [ { code: "vi", label: "Tiếng Việt" }, ]; +ensureLegacyAuthUI(); + const STORAGE_KEY = "termix_auth"; + +/** Methods drawn by the login form itself rather than the external tab. */ +const INLINE_METHODS = new Set(["password", "passkey"]); + +const DEFAULT_SECOND_FACTORS: SecondFactorRef[] = [ + { id: "totp", pluginId: "core", labelKey: "auth.twoFactorAuth" }, +]; + +function parseSecondFactorIds(value: string | null): SecondFactorRef[] { + const ids = (value ?? "").split(",").filter(Boolean); + if (ids.length === 0) return DEFAULT_SECOND_FACTORS; + return ids.map((id) => ({ id, pluginId: "", labelKey: "" })); +} const DESKTOP_MANUAL_LOGOUT_KEY = "termix_desktop_manual_logout"; export function getStoredAuth(): { @@ -237,16 +257,6 @@ export function Auth({ onLogin }: AuthProps) { const localDesktopAuth = isElectron() && !isInElectronWebView(); const [view, setView] = useState("login"); const [loading, setLoading] = useState(false); - const [providerLoading, setProviderLoading] = useState< - Record - >({}); - const [passkeySupported] = useState(() => { - try { - return isPasskeySupported(); - } catch { - return false; - } - }); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -259,9 +269,15 @@ export function Auth({ onLogin }: AuthProps) { } }); - const [totpCode, setTotpCode] = useState(""); - const [totpTempToken, setTotpTempToken] = useState(""); - const totpInputRef = useRef(null); + // An empty token means the server holds the pending login in a cookie, + // which is how a redirect login hands over to the second factor step. + const [pendingToken, setPendingToken] = useState(""); + const [secondFactors, setSecondFactors] = useState( + DEFAULT_SECOND_FACTORS, + ); + const [activeFactorId, setActiveFactorId] = useState("totp"); + const loginMethodUIs = useLoginMethods(); + const secondFactorUIs = useSecondFactors(); const [resetStep, setResetStep] = useState("email"); const [resetCode, setResetCode] = useState(""); @@ -283,11 +299,12 @@ export function Auth({ onLogin }: AuthProps) { const [registrationAllowed, setRegistrationAllowed] = useState(true); const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true); const [passwordResetAllowed, setPasswordResetAllowed] = useState(true); - const [ssoProviders, setSsoProviders] = useState([]); - const [ssoProvidersLoaded, setSsoProvidersLoaded] = useState(false); - const [expandedLdapId, setExpandedLdapId] = useState(null); - const [ldapUsername, setLdapUsername] = useState(""); - const [ldapPassword, setLdapPassword] = useState(""); + const [authMethods, setAuthMethods] = useState([]); + const [authMethodsLoaded, setAuthMethodsLoaded] = useState(false); + const externalMethods = authMethods.filter( + (method) => !INLINE_METHODS.has(method.id), + ); + const passkeyMethod = authMethods.find((method) => method.id === "passkey"); const silentSigninHandledRef = useRef(false); const proxySigninHandledRef = useRef(false); const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false); @@ -353,11 +370,15 @@ export function Auth({ onLogin }: AuthProps) { if (!event.data || typeof event.data !== "object") return; if (event.data.type !== "OIDC_SYSTEM_BROWSER_AUTH_RESULT") return; - const providerId = - typeof event.data.providerId === "number" ? event.data.providerId : -1; if (event.data.success) return; - - setProviderLoading((prev) => ({ ...prev, [providerId]: false })); + setLoading(false); + if (event.data.secondFactor) { + enterSecondFactorStep( + event.data.tempToken ?? "", + parseSecondFactorIds(event.data.factors ?? null), + ); + return; + } toast.error(event.data.error || t("errors.failedOidcLogin")); }; @@ -368,7 +389,7 @@ export function Auth({ onLogin }: AuthProps) { useEffect(() => { if (localDesktopAuth) { - setSsoProvidersLoaded(true); + setAuthMethodsLoaded(true); setOidcSilentLoginDefaultLoaded(true); return; } @@ -381,10 +402,12 @@ export function Auth({ onLogin }: AuthProps) { getPasswordResetAllowed() .then((allowed) => setPasswordResetAllowed(allowed)) .catch(() => setPasswordResetAllowed(false)); - getSSOProviders() - .then((providers) => setSsoProviders(providers || [])) - .catch(() => setSsoProviders([])) - .finally(() => setSsoProvidersLoaded(true)); + // Plugins that draw login or second-factor UI load before sign-in. + void startPreLoginPlugins().catch(() => {}); + getLoginMethods() + .then((methods) => setAuthMethods(methods)) + .catch(() => setAuthMethods([])) + .finally(() => setAuthMethodsLoaded(true)); getOidcSilentLoginDefault() .then((res) => setOidcSilentLoginDefault(res.enabled)) .catch(() => {}) @@ -514,30 +537,35 @@ export function Auth({ onLogin }: AuthProps) { ]); useEffect(() => { - if (view === "totp" && totpInputRef.current) totpInputRef.current.focus(); - }, [view]); - - useEffect(() => { - setLdapUsername(""); - setLdapPassword(""); - }, [expandedLdapId]); - - useEffect(() => { - if (!ssoProvidersLoaded) return; - if (!passwordLoginAllowed && ssoProviders.length > 0 && view === "login") { + if (!authMethodsLoaded) return; + if ( + !passwordLoginAllowed && + externalMethods.length > 0 && + view === "login" + ) { setView("external"); } - }, [ssoProvidersLoaded, passwordLoginAllowed, ssoProviders.length, view]); + }, [authMethodsLoaded, passwordLoginAllowed, externalMethods.length, view]); useEffect(() => { const urlParams = new URLSearchParams(window.location.search); const success = urlParams.get("success"); const error = urlParams.get("error"); + if (urlParams.get("second_factor") === "1") { + enterSecondFactorStep( + urlParams.get("temp_token") ?? "", + parseSecondFactorIds(urlParams.get("second_factors")), + ); + window.history.replaceState({}, document.title, window.location.pathname); + return; + } if (error) { if (error === "registration_disabled") toast.error(t("messages.registrationDisabled")); else if (error === "user_not_allowed") toast.error(t("messages.userNotAllowed")); + else if (error === "second_factor_unavailable") + toast.error(t("auth.secondFactorUnavailable")); else toast.error(`${t("errors.oidcAuthFailed")}: ${error}`); window.history.replaceState({}, document.title, window.location.pathname); return; @@ -631,8 +659,7 @@ export function Auth({ onLogin }: AuthProps) { setNewPassword(""); setConfirmNewPassword(""); setResetTempToken(""); - setTotpCode(""); - setTotpTempToken(""); + setPendingToken(""); } function switchView(v: AuthView) { @@ -649,6 +676,86 @@ export function Auth({ onLogin }: AuthProps) { setDesktopAutoSessionDone(null); } + function enterSecondFactorStep( + tempToken: string, + factors: SecondFactorRef[] = DEFAULT_SECOND_FACTORS, + ) { + const list = factors.length > 0 ? factors : DEFAULT_SECOND_FACTORS; + setPendingToken(tempToken); + setSecondFactors(list); + setActiveFactorId(list[0].id); + setLoading(false); + setView("totp"); + } + + function showLoginError(err: unknown, fallbackKey: string) { + const error = err as { + message?: string; + response?: { data?: { error?: string; code?: string } }; + }; + if (error?.response?.data?.code === "SESSION_EXPIRED") { + setView("login"); + toast.error(t("errors.sessionExpired")); + return; + } + if (error?.response?.data?.code === "second_factor_unavailable") { + toast.error(t("auth.secondFactorUnavailable")); + return; + } + toast.error( + error?.response?.data?.error || error?.message || t(fallbackKey), + ); + } + + /** + * Every login ends here: the second-factor step, a hand-off to the mobile + * or desktop shell, or signing in this window. + */ + async function finishLogin( + res: LoginResponse, + source: string, + options: { fallbackUsername?: string; successKey?: string } = {}, + ) { + if (res?.requires_totp || res?.requires_second_factor) { + enterSecondFactorStep(res.temp_token ?? "", res.second_factors); + return; + } + if (!res?.success) throw new Error(t("errors.loginFailed")); + if (isInMobileWebView()) { + // Native-app requests get the JWT in the login response body. + const token = res?.token ?? ""; + (window as ExtendedWindow).ReactNativeWebView?.postMessage( + JSON.stringify({ type: "AUTH_SUCCESS", token }), + ); + setWebviewAuthSuccess(true); + return; + } + if (isInElectronWebView()) { + // The iframe never sends X-Electron-App, so the JWT only lands in an + // HttpOnly cookie on this origin. Read it back for the parent window. + const token = res?.token ?? (await getCurrentToken()); + window.parent.postMessage( + { + type: "AUTH_SUCCESS", + source, + platform: "desktop", + token: token ?? null, + timestamp: Date.now(), + }, + "*", + ); + setWebviewAuthSuccess(true); + return; + } + const meRes = await getUserInfo(); + const name = + meRes.username || res.username || options.fallbackUsername || ""; + storeAuth(name); + clearDesktopManualLogout(); + toast.success(t(options.successKey ?? "messages.loginSuccess")); + onLogin(name, meRes.userId || undefined, !!meRes.is_admin); + } + async function handleLogin(e: React.FormEvent) { e.preventDefault(); if (!username.trim()) { @@ -658,127 +765,11 @@ export function Auth({ onLogin }: AuthProps) { setLoading(true); try { const res = await loginUser(username.trim(), password, rememberMe); - if (res.requires_totp) { - setTotpTempToken(res.temp_token); - setView("totp"); - return; - } - if (!res?.success) throw new Error(t("errors.loginFailed")); - if (isInMobileWebView()) { - // Native-app requests get the JWT in the login response body. - const token = res?.token ?? ""; - (window as ExtendedWindow).ReactNativeWebView?.postMessage( - JSON.stringify({ type: "AUTH_SUCCESS", token }), - ); - setWebviewAuthSuccess(true); - return; - } - if (isInElectronWebView()) { - // The iframe's login request never carries the X-Electron-App header - // (only the top-level Electron renderer's axios instances do), so the - // backend never includes the JWT in the login response body -- it - // only lands in an HttpOnly cookie scoped to this iframe's origin. - // Read it back via /users/me/token, same as the mobile-webview OIDC - // callback below does, so the parent window can persist it. - const token = res?.token ?? (await getCurrentToken()); - window.parent.postMessage( - { - type: "AUTH_SUCCESS", - source: "auth_component", - platform: "desktop", - token: token ?? null, - timestamp: Date.now(), - }, - "*", - ); - setWebviewAuthSuccess(true); - return; - } - const meRes = await getUserInfo(); - storeAuth(meRes.username || username.trim()); - clearDesktopManualLogout(); - toast.success(t("messages.loginSuccess")); - onLogin( - meRes.username || username.trim(), - meRes.userId || undefined, - !!meRes.is_admin, - ); + await finishLogin(res, "auth_component", { + fallbackUsername: username.trim(), + }); } catch (err: unknown) { - const error = err as { - message?: string; - response?: { data?: { error?: string } }; - }; - toast.error( - error?.response?.data?.error || - error?.message || - t("errors.unknownError"), - ); - } finally { - setLoading(false); - } - } - - async function handlePasskeyLogin() { - setLoading(true); - try { - const res = await loginWithPasskey( - username.trim() || undefined, - rememberMe, - ); - if (res.requires_totp) { - setTotpTempToken(res.temp_token ?? ""); - setView("totp"); - return; - } - if (!res?.success) throw new Error(t("auth.passkeyLoginFailed")); - if (isInMobileWebView()) { - const token = res?.token ?? ""; - (window as ExtendedWindow).ReactNativeWebView?.postMessage( - JSON.stringify({ type: "AUTH_SUCCESS", token }), - ); - setWebviewAuthSuccess(true); - return; - } - if (isInElectronWebView()) { - // Same as handleLogin: the iframe never sends X-Electron-App, so read - // the JWT back from the cookie that was just set. - const token = res?.token ?? (await getCurrentToken()); - window.parent.postMessage( - { - type: "AUTH_SUCCESS", - source: "passkey_auth_component", - platform: "desktop", - token: token ?? null, - timestamp: Date.now(), - }, - "*", - ); - setWebviewAuthSuccess(true); - return; - } - const meRes = await getUserInfo(); - storeAuth(meRes.username || res.username || ""); - toast.success(t("messages.loginSuccess")); - onLogin( - meRes.username || res.username || "", - meRes.userId || undefined, - !!meRes.is_admin, - ); - } catch (err: unknown) { - const error = err as { - name?: string; - message?: string; - response?: { data?: { error?: string } }; - }; - // Closing or cancelling the browser prompt is not a failure worth a toast. - if (error?.name === "NotAllowedError" || error?.name === "AbortError") { - return; - } - toast.error( - error?.response?.data?.error || - error?.message || - t("auth.passkeyLoginFailed"), - ); + showLoginError(err, "errors.unknownError"); } finally { setLoading(false); } @@ -802,124 +793,58 @@ export function Auth({ onLogin }: AuthProps) { try { await registerUser(username.trim(), password); const res = await loginUser(username.trim(), password, rememberMe); - if (res.requires_totp) { - setTotpTempToken(res.temp_token); - setView("totp"); - return; - } - if (isInMobileWebView()) { - // Native-app requests get the JWT in the login response body. - const token = res?.token ?? ""; - (window as ExtendedWindow).ReactNativeWebView?.postMessage( - JSON.stringify({ type: "AUTH_SUCCESS", token }), - ); - setWebviewAuthSuccess(true); - return; - } - if (isInElectronWebView()) { - // Registration inside the Remote Sync iframe must hand off to the - // parent window the same way handleLogin does -- otherwise this - // component's own onLogin() below fires on the iframe's own, - // independent copy of the app, rendering the full remote AppShell - // inside the small login dialog instead of closing it. - const token = res?.token ?? (await getCurrentToken()); - window.parent.postMessage( - { - type: "AUTH_SUCCESS", - source: "auth_component", - platform: "desktop", - token: token ?? null, - timestamp: Date.now(), - }, - "*", - ); - setWebviewAuthSuccess(true); - return; - } - const meRes = await getUserInfo(); - storeAuth(meRes.username || username.trim()); - clearDesktopManualLogout(); - toast.success(t("messages.registrationSuccess")); - onLogin( - meRes.username || username.trim(), - meRes.userId || undefined, - !!meRes.is_admin, - ); + await finishLogin(res, "auth_component", { + fallbackUsername: username.trim(), + successKey: "messages.registrationSuccess", + }); } catch (err: unknown) { - const error = err as { - message?: string; - response?: { data?: { error?: string } }; - }; - toast.error( - error?.response?.data?.error || - error?.message || - t("errors.unknownError"), - ); + showLoginError(err, "errors.unknownError"); } finally { setLoading(false); } } - async function handleTOTP(e: React.FormEvent) { - e.preventDefault(); - if (totpCode.length !== 6) { - toast.error(t("auth.enterCode")); - return; - } + /** Form login for any method: LDAP, a plugin's form, ... */ + async function submitMethod( + methodId: string, + body: Record, + instanceId?: string, + ) { setLoading(true); try { - const res = await verifyTOTPLogin(totpTempToken, totpCode, rememberMe); - if (!res?.success) throw new Error(t("errors.loginFailed")); - if (isInMobileWebView()) { - // Native-app requests get the JWT in the login response body. - const token = res?.token ?? ""; - (window as ExtendedWindow).ReactNativeWebView?.postMessage( - JSON.stringify({ type: "AUTH_SUCCESS", token }), - ); - setWebviewAuthSuccess(true); - return; - } - if (isInElectronWebView()) { - // See the equivalent branch in handleLogin: the iframe never sends - // X-Electron-App, so the JWT never lands in the response body here - // either -- read it back from the HttpOnly cookie that was just set. - const token = res?.token ?? (await getCurrentToken()); - window.parent.postMessage( - { - type: "AUTH_SUCCESS", - source: "totp_auth_component", - platform: "desktop", - token: token ?? null, - timestamp: Date.now(), - }, - "*", - ); - setWebviewAuthSuccess(true); - return; - } - storeAuth(res.username || username); - clearDesktopManualLogout(); - toast.success(t("messages.loginSuccess")); - onLogin( - res.username || username, - res.userId || undefined, - !!res.is_admin, + const res = await submitLoginMethod( + methodId, + { ...body, rememberMe }, + instanceId, ); + await finishLogin(res, "method_auth_component"); } catch (err: unknown) { - const error = err as { - message?: string; - response?: { data?: { error?: string; code?: string } }; - }; - if (error?.response?.data?.code === "SESSION_EXPIRED") { - setView("login"); - toast.error(t("errors.sessionExpired")); - return; - } - toast.error( - error?.response?.data?.error || - error?.message || - t("errors.invalidTotpCode"), - ); + showLoginError(err, "errors.loginFailed"); + } finally { + setLoading(false); + } + } + + async function completeMethodResponse( + response: Record, + source = "method_auth_component", + ) { + await finishLogin(response as LoginResponse, source); + } + + async function verifyActiveFactor(body: Record) { + setLoading(true); + try { + const res = await verifySecondFactor(activeFactorId, { + ...body, + rememberMe, + ...(pendingToken ? { temp_token: pendingToken } : {}), + }); + await finishLogin(res, "totp_auth_component", { + fallbackUsername: username, + }); + } catch (err: unknown) { + showLoginError(err, "errors.invalidTotpCode"); } finally { setLoading(false); } @@ -1004,22 +929,22 @@ export function Auth({ onLogin }: AuthProps) { } } - const handleOIDCLogin = useCallback( - async (providerId?: number) => { - const loadingKey = providerId ?? -1; - setProviderLoading((prev) => ({ ...prev, [loadingKey]: true })); + /** + * Sends the browser to a redirect login method. Inside the desktop app + * the system browser does it instead, so captcha stages render and the + * callback comes back to a local port. + */ + const startRedirect = useCallback( + async (methodId: string, instanceId?: string) => { + setLoading(true); try { + const callbackPort = 17832 + Math.floor(Math.random() * 100); if (isInElectronWebView()) { - // Inside the Electron iframe: delegate OIDC to the parent window so - // the system browser opens instead of navigating the iframe (which - // would break captcha stages like Cloudflare Turnstile). - const callbackPort = 17832 + Math.floor(Math.random() * 100); - const authResponse = await getOIDCAuthorizeUrl( + const authUrl = await startLoginRedirect(methodId, { + instanceId, rememberMe, - callbackPort, - providerId, - ); - const { auth_url: authUrl } = authResponse; + desktopCallbackPort: callbackPort, + }); if (!authUrl) throw new Error(t("errors.invalidAuthUrl")); window.parent.postMessage( { @@ -1027,7 +952,7 @@ export function Auth({ onLogin }: AuthProps) { source: "oidc_request", authUrl, callbackPort, - providerId: loadingKey, + providerId: -1, }, "*", ); @@ -1044,18 +969,19 @@ export function Auth({ onLogin }: AuthProps) { success: boolean; token?: string; error?: string; + secondFactor?: boolean; + tempToken?: string; + factors?: string; }>; }; } ).electronAPI; if (electronAPI?.oidcSystemBrowserAuth) { - const callbackPort = 17832 + Math.floor(Math.random() * 100); - const authResponse = await getOIDCAuthorizeUrl( + const authUrl = await startLoginRedirect(methodId, { + instanceId, rememberMe, - callbackPort, - providerId, - ); - const { auth_url: authUrl } = authResponse; + desktopCallbackPort: callbackPort, + }); if (!authUrl) throw new Error(t("errors.invalidAuthUrl")); const result = await electronAPI.oidcSystemBrowserAuth( authUrl, @@ -1066,76 +992,75 @@ export function Auth({ onLogin }: AuthProps) { window.location.reload(); return; } + if (result.secondFactor) { + enterSecondFactorStep( + result.tempToken ?? "", + parseSecondFactorIds(result.factors ?? null), + ); + return; + } throw new Error(result.error || "Authentication failed"); } } - const authResponse = await getOIDCAuthorizeUrl( + const authUrl = await startLoginRedirect(methodId, { + instanceId, rememberMe, - undefined, - providerId, - ); - const { auth_url: authUrl } = authResponse; + }); if (!authUrl || authUrl === "undefined") throw new Error(t("errors.invalidAuthUrl")); window.location.replace(authUrl); } catch (err: unknown) { - const error = err as { - message?: string; - response?: { data?: { error?: string } }; - }; - toast.error( - error?.response?.data?.error || - error?.message || - t("errors.failedOidcLogin"), - ); - setProviderLoading((prev) => ({ ...prev, [loadingKey]: false })); + showLoginError(err, "errors.failedOidcLogin"); + setLoading(false); } }, + // eslint-disable-next-line react-hooks/exhaustive-deps [rememberMe, t], ); - const handleLDAPLogin = useCallback( - async (providerId: number) => { - if (!ldapUsername.trim() || !ldapPassword) { - toast.error(t("errors.requiredField")); - return; - } - setProviderLoading((prev) => ({ ...prev, [providerId]: true })); - try { - await ldapLogin( - providerId, - ldapUsername.trim(), - ldapPassword, - rememberMe, - ); - const meRes = await getUserInfo(); - storeAuth(meRes.username || ""); - clearDesktopManualLogout(); - toast.success(t("messages.loginSuccess")); - onLogin( - meRes.username || "", - meRes.userId || undefined, - !!meRes.is_admin, - ); - } catch (err: unknown) { - const error = err as { - response?: { data?: { error?: string } }; - message?: string; - }; - toast.error( - error?.response?.data?.error || - error?.message || - t("auth.ldapLoginFailed"), - ); - } finally { - setProviderLoading((prev) => ({ ...prev, [providerId]: false })); - } - }, - [ldapUsername, ldapPassword, rememberMe, onLogin, t], - ); + /** Draws a login method with the UI its plugin (or core) registered. */ + function renderLoginMethod(method: PublicLoginMethod) { + const ui = loginMethodUIs.find((candidate) => candidate.id === method.id); + const props = { + methodId: method.id, + instances: method.instances, + rememberMe, + disabled: loading, + username, + submit: (body: Record, instanceId?: string) => + submitMethod(method.id, body, instanceId), + startRedirect: (instanceId?: string) => + startRedirect(method.id, instanceId), + complete: (response: Record) => + completeMethodResponse( + response, + method.id === "passkey" + ? "passkey_auth_component" + : "method_auth_component", + ).catch((err) => showLoginError(err, "errors.loginFailed")), + }; + if (ui) { + const Component = ui.component; + return ; + } + // A redirect method needs no UI of its own: one button per instance. + if (method.kind === "redirect") { + return method.instances.map((instance) => ( + + )); + } + return null; + } useEffect(() => { - if (!ssoProvidersLoaded || silentSigninHandledRef.current) return; + if (!authMethodsLoaded || silentSigninHandledRef.current) return; if (!oidcSilentLoginDefaultLoaded) return; const urlTriggered = shouldTriggerSilentSignin(window.location.search); @@ -1152,11 +1077,11 @@ export function Auth({ onLogin }: AuthProps) { silentSigninHandledRef.current = true; - const oidcProvider = ssoProviders.find( - (p) => p.type === "oidc" || p.type === "github" || p.type === "google", + const redirectMethod = externalMethods.find( + (method) => method.kind === "redirect" && method.instances.length > 0, ); - if (oidcProvider && !isElectron()) { - handleOIDCLogin(oidcProvider.id); + if (redirectMethod && !isElectron()) { + void startRedirect(redirectMethod.id, redirectMethod.instances[0].id); return; } @@ -1164,9 +1089,9 @@ export function Auth({ onLogin }: AuthProps) { toast.info(t("errors.silentSigninOidcUnavailable")); } }, [ - handleOIDCLogin, - ssoProvidersLoaded, - ssoProviders, + startRedirect, + authMethodsLoaded, + externalMethods, t, oidcSilentLoginDefault, oidcSilentLoginDefaultLoaded, @@ -1366,7 +1291,7 @@ export function Auth({ onLogin }: AuthProps) { { id: "external", label: t("auth.external"), - show: ssoProviders.length > 0, + show: externalMethods.length > 0, }, ]; @@ -1415,40 +1340,54 @@ export function Auth({ onLogin }: AuthProps) { {t("auth.enterCode")}

-
- - - setTotpCode(e.target.value.replace(/\D/g, "")) - } + {secondFactors.length > 1 && ( +
+ {secondFactors.map((factor) => { + const ui = secondFactorUIs.find( + (candidate) => candidate.id === factor.id, + ); + return ( + + ); + })} +
+ )} + {(() => { + const ui = secondFactorUIs.find( + (candidate) => candidate.id === activeFactorId, + ); + if (!ui) { + return ( +

+ {t("auth.secondFactorNoUI")} +

+ ); + } + const Component = ui.component; + return ( + + challengeSecondFactor( + activeFactorId, + pendingToken || undefined, + ) + } + cancel={() => switchView("login")} /> -
- - -
+ ); + })()} )} @@ -1648,113 +1587,10 @@ export function Auth({ onLogin }: AuthProps) {
- {ssoProviders.map((provider) => { - const isLoading = !!providerLoading[provider.id]; - - if (provider.type === "ldap") { - const isExpanded = expandedLdapId === provider.id; - return ( -
- - {isExpanded && ( -
{ - e.preventDefault(); - handleLDAPLogin(provider.id); - }} - className="flex flex-col gap-3 p-3 border-t border-border" - > - - - setLdapUsername(e.target.value) - } - disabled={isLoading} - autoFocus - /> - - - - - -
- )} -
- ); - } - - return ( - - ); - })} - {passkeySupported && ( - + {externalMethods.map((method) => + renderLoginMethod(method), )} + {passkeyMethod && renderLoginMethod(passkeyMethod)}
)} @@ -1822,20 +1658,7 @@ export function Auth({ onLogin }: AuthProps) { )} - {passkeySupported && ( - - )} + {passkeyMethod && renderLoginMethod(passkeyMethod)} )} diff --git a/src/ui/auth/ElectronLoginForm.tsx b/src/ui/auth/ElectronLoginForm.tsx index 97ff8cdc7..3e107a74a 100644 --- a/src/ui/auth/ElectronLoginForm.tsx +++ b/src/ui/auth/ElectronLoginForm.tsx @@ -29,6 +29,8 @@ interface SaveRemoteSyncJwtResult { const AUTH_MESSAGE_SOURCES = new Set([ "auth_component", "totp_auth_component", + "passkey_auth_component", + "method_auth_component", "oidc_callback", ]); @@ -135,6 +137,9 @@ export function ElectronLoginForm({ const sendResultToIframe = (result: { success: boolean; error?: string; + secondFactor?: boolean; + tempToken?: string; + factors?: string; }) => { iframeRef.current?.contentWindow?.postMessage( { @@ -156,6 +161,9 @@ export function ElectronLoginForm({ success: boolean; token?: string; error?: string; + secondFactor?: boolean; + tempToken?: string; + factors?: string; }>; }; } @@ -175,6 +183,16 @@ export function ElectronLoginForm({ await handleAuthSuccess(result.token); return; } + // The login stopped for a second factor; the iframe asks for it. + if (result.secondFactor) { + sendResultToIframe({ + success: false, + secondFactor: true, + tempToken: result.tempToken, + factors: result.factors, + }); + return; + } sendResultToIframe({ success: false, error: result.error || t("errors.failedOidcLogin"), diff --git a/src/ui/auth/legacy-auth-ui.tsx b/src/ui/auth/legacy-auth-ui.tsx new file mode 100644 index 000000000..412203d22 --- /dev/null +++ b/src/ui/auth/legacy-auth-ui.tsx @@ -0,0 +1,382 @@ +/** + * Login and second-factor UI that still lives in core for 2.9.0 (OIDC, LDAP, + * passkeys, TOTP), registered through the same registries a plugin uses. + * Phase C moves each into its plugin by moving its block out of this file; + * D1 deletes the file once it is empty. + */ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { ChevronDown, ChevronUp, Fingerprint } from "lucide-react"; +import type { + LoginMethodUIProps, + SecondFactorUIProps, +} from "@termix/plugin-sdk/frontend"; +import { Button } from "@/components/button"; +import { Input } from "@/components/input"; +import { + registerLoginMethod, + registerSecondFactor, + registerSshAuthEditor, +} from "@/plugin-host/auth-registry"; +import { isPasskeySupported, loginWithPasskey } from "@/api/webauthn-api"; + +const primaryButton = + "w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"; + +function OidcLoginButtons({ + instances, + disabled, + startRedirect, +}: LoginMethodUIProps) { + const { t } = useTranslation(); + const [loadingId, setLoadingId] = useState(null); + return ( + <> + {instances.map((instance) => ( + + ))} + + ); +} + +function LdapLoginForms({ instances, disabled, submit }: LoginMethodUIProps) { + const { t } = useTranslation(); + const [expandedId, setExpandedId] = useState(null); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setUsername(""); + setPassword(""); + }, [expandedId]); + + return ( + <> + {instances.map((instance) => { + const expanded = expandedId === instance.id; + return ( +
+ + {expanded && ( +
{ + event.preventDefault(); + if (!username.trim() || !password) { + toast.error(t("errors.requiredField")); + return; + } + setLoading(true); + try { + await submit( + { username: username.trim(), password }, + instance.id, + ); + } finally { + setLoading(false); + } + }} + className="flex flex-col gap-3 p-3 border-t border-border" + > + + + +
+ )} +
+ ); + })} + + ); +} + +function PasskeyLoginButton({ + rememberMe, + disabled, + complete, + username, +}: LoginMethodUIProps) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [supported] = useState(() => { + try { + return isPasskeySupported(); + } catch { + return false; + } + }); + if (!supported) return null; + return ( + + ); +} + +/** Six-digit codes, or a backup code of up to eight letters and digits. */ +export function normalizeTotpInput(value: string): string { + return value + .replace(/[^a-zA-Z0-9]/g, "") + .toUpperCase() + .slice(0, 8); +} + +export function isValidTotpInput(value: string): boolean { + return /^[A-Z0-9]{6,8}$/.test(value); +} + +function TotpChallenge({ disabled, verify, cancel }: SecondFactorUIProps) { + const { t } = useTranslation(); + const [code, setCode] = useState(""); + const [loading, setLoading] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + return ( +
{ + event.preventDefault(); + if (!isValidTotpInput(code)) { + toast.error(t("auth.enterCode")); + return; + } + setLoading(true); + try { + await verify({ totp_code: code }); + } finally { + setLoading(false); + } + }} + className="flex flex-col gap-4" + > + +

+ {t("auth.backupCodeHint")} +

+ + +
+ ); +} + +function CertificateAuthInfo({ + labelKey, + descriptionKey, + docsUrl, +}: { + labelKey: string; + descriptionKey: string; + docsUrl: string; +}) { + const { t } = useTranslation(); + return ( +
+
+ + {t(labelKey)} + + + {t("hosts.docsLink")} + +
+

{t(descriptionKey)}

+
+ ); +} + +function OpksshAuthEditor() { + return ( + + ); +} + +function StepCaAuthEditor() { + return ( + + ); +} + +let registered = false; + +export function ensureLegacyAuthUI(): void { + if (registered) return; + registered = true; + registerLoginMethod({ + id: "oidc", + pluginId: "core", + titleKey: "auth.loginWithSso", + component: OidcLoginButtons, + }); + registerLoginMethod({ + id: "ldap", + pluginId: "core", + titleKey: "auth.loginWithLdap", + component: LdapLoginForms, + }); + registerLoginMethod({ + id: "passkey", + pluginId: "core", + titleKey: "auth.signInWithPasskey", + component: PasskeyLoginButton, + }); + registerSshAuthEditor({ + id: "opkssh", + pluginId: "core", + titleKey: "hosts.filterAuthOpkssh", + component: OpksshAuthEditor, + }); + registerSshAuthEditor({ + id: "stepca", + pluginId: "core", + titleKey: "hosts.filterAuthStepca", + component: StepCaAuthEditor, + }); + registerSecondFactor({ + id: "totp", + pluginId: "core", + titleKey: "auth.twoFactorAuth", + component: TotpChallenge, + }); +} + +/** Test helper. */ +export function resetLegacyAuthUIForTests(): void { + registered = false; +} diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index f567e5fb0..9adbbdddd 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useCallback, useEffect } from "react"; +import { useSshAuthProviders } from "@/hooks/useSshAuthProviders"; import { useIsMobile } from "@/hooks/use-mobile"; import { Button } from "@/components/button"; import { Card } from "@/components/card"; @@ -1380,6 +1381,9 @@ export function DashboardTab({ Map >(new Map()); const viewerSessionsRef = useRef>(new Map()); + const sshAuthProviders = useSshAuthProviders(); + const sshAuthProvidersRef = useRef(sshAuthProviders); + sshAuthProvidersRef.current = sshAuthProviders; const statusCheckHosts = hosts.filter(isStatusCheckEnabled); const fetchMetrics = useCallback(async (hostList: Host[]) => { @@ -1399,12 +1403,9 @@ export function DashboardTab({ const hostId = Number(host.id); const knownStatus = statuses?.[hostId]?.status; if (knownStatus === "offline") return null; - if ( - host.authType === "none" || - host.authType === "opkssh" || - host.authType === "stepca" - ) - return null; + // Types that need a person or a browser sign-in cannot be polled. + const authOption = sshAuthProvidersRef.current.find(host.authType); + if (authOption && !authOption.supportsBackground) return null; try { const existing = newSessions.get(hostId); diff --git a/src/ui/hooks/useSshAuthProviders.ts b/src/ui/hooks/useSshAuthProviders.ts new file mode 100644 index 000000000..b2a5b0bb7 --- /dev/null +++ b/src/ui/hooks/useSshAuthProviders.ts @@ -0,0 +1,94 @@ +import { useEffect, useMemo, useState } from "react"; +import { + getSshAuthProviders, + type SshAuthProviderSummary, +} from "@/api/auth-methods-api"; +import { useSshAuthEditors } from "@/plugin-host/auth-registry"; + +/** Shown until the server answers, so the editor does not jump. */ +const BUILTIN_TYPES = ["password", "key", "credential", "agent", "none"]; + +function builtinSummary(type: string): SshAuthProviderSummary { + return { + type, + labelKey: `hosts.filterAuth${type.charAt(0).toUpperCase()}${type.slice(1)}`, + pluginId: "core", + fields: [], + credentialType: type === "password" || type === "key", + needsUserInteraction: false, + supportsBackground: type !== "none", + available: true, + }; +} + +let cache: Promise | null = null; + +/** Drops the cached list, e.g. after a plugin was switched on or off. */ +export function invalidateSshAuthProviders(): void { + cache = null; +} + +export interface SshAuthTypeOption extends SshAuthProviderSummary { + /** Plugin-registered editor title, when there is one. */ + editorTitleKey?: string; +} + +/** + * Every SSH auth type the server can connect with, merged with the editors + * plugins registered in this browser. Types whose plugin is off come back + * with available: false and missingPlugin set. + */ +export function useSshAuthProviders(): { + providers: SshAuthTypeOption[]; + loaded: boolean; + find: (type: string | null | undefined) => SshAuthTypeOption | undefined; +} { + const editors = useSshAuthEditors(); + const [summaries, setSummaries] = useState( + null, + ); + + useEffect(() => { + let cancelled = false; + cache ??= getSshAuthProviders(); + cache.then((list) => { + if (!cancelled) setSummaries(list); + }); + return () => { + cancelled = true; + }; + }, []); + + return useMemo(() => { + const base = + summaries && summaries.length > 0 + ? summaries + : BUILTIN_TYPES.map(builtinSummary); + const byType = new Map( + base.map((summary) => [summary.type, { ...summary }]), + ); + for (const editor of editors) { + const existing = byType.get(editor.id); + if (existing) { + existing.editorTitleKey = editor.titleKey; + } else { + // An editor with no server provider yet: the plugin's backend may + // still be starting. Offer it; connecting will say what is missing. + byType.set(editor.id, { + ...builtinSummary(editor.id), + labelKey: editor.titleKey, + pluginId: editor.pluginId ?? "", + credentialType: false, + editorTitleKey: editor.titleKey, + }); + } + } + const providers = [...byType.values()]; + return { + providers, + loaded: summaries !== null, + find: (type) => + type ? providers.find((provider) => provider.type === type) : undefined, + }; + }, [summaries, editors]); +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 881d72219..f55072ff5 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1568,7 +1568,13 @@ "webUiErrorLabelDuplicate": "Each endpoint needs a different name.", "webUiErrorPortRange": "Endpoint {{row}} needs a port between 1 and 65535.", "webUiErrorPathInvalid": "Endpoint {{row}} has an invalid path. Use a path like /admin.", - "copyViewUrlAction": "Copy {{name}} link" + "copyViewUrlAction": "Copy {{name}} link", + "filterAuthAgent": "SSH Agent", + "filterAuthVault": "Vault", + "filterAuthTailscale": "Tailscale", + "authTypeNeedsPlugin": "This host uses {{type}}, which needs the {{plugin}} plugin. Turn it on to connect.", + "authTypeUnknown": "This host uses {{type}}, which no enabled plugin provides.", + "authFieldsAfterSave": "Save the host first, then fill in these fields." }, "commandPalette": { "searchPlaceholder": "Search hosts, commands, or settings...", @@ -2507,7 +2513,14 @@ "signInWithPasskey": "Sign in with passkey", "passkeyLoginFailed": "Passkey login failed", "attemptsRemaining": "{{count}} attempts remaining", - "confirmResetDataWipe": "This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and snippets. Continue?" + "confirmResetDataWipe": "This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and snippets. Continue?", + "loginWithSso": "Sign in with SSO", + "loginWithLdap": "Sign in with LDAP", + "password": "Password", + "totpFactor": "Authenticator code", + "backupCodeHint": "You can also enter one of your backup codes.", + "secondFactorUnavailable": "Your account uses a second factor that is not available right now. Contact an admin to reset it.", + "secondFactorNoUI": "This second factor cannot be shown on this screen. Contact an admin." }, "hostKey": { "verifyNewHost": "Verify SSH Host Key", @@ -3217,7 +3230,15 @@ "pluginPermissionGranted": "Granted", "pluginPermissionNotGranted": "Not granted", "pluginGrantFailed": "Failed to grant the capability", - "pluginRevokeFailed": "Failed to revoke the capability" + "pluginRevokeFailed": "Failed to revoke the capability", + "secondFactorsSectionTitle": "Second factors", + "secondFactorsNone": "No second factors enrolled", + "secondFactorUnavailable": "The {{plugin}} plugin is off", + "resetSecondFactors": "Reset second factors", + "resetSecondFactorsConfirm": "Remove every second factor for {{username}}? They can sign in with their password or SSO and enrol again.", + "secondFactorsResetSuccess": "Second factors reset", + "secondFactorsResetFailed": "Failed to reset second factors", + "passwordLoginForced": "Password login is turned off, but no other login method is enabled, so it stays on for now. Nobody gets locked out." }, "newUi": { "sidebar": { @@ -4008,7 +4029,7 @@ }, "auth:provide": { "title": "Handle sign in", - "consequence": "It can add a login method or a second factor." + "consequence": "It can add a login method, a second factor or an SSH sign-in type." }, "system:tls": { "title": "Manage certificates", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 8eafcfc69..470a72010 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1850,7 +1850,11 @@ export async function getRegistrationAllowed(): Promise<{ allowed: boolean }> { } } -export async function getPasswordLoginAllowed(): Promise<{ allowed: boolean }> { +export async function getPasswordLoginAllowed(): Promise<{ + allowed: boolean; + /** Turned off by an admin but kept on because nothing else can sign in. */ + forced?: boolean; +}> { try { const response = await authApi.get("/users/password-login-allowed"); return response.data; diff --git a/src/ui/plugin-host/app.ts b/src/ui/plugin-host/app.ts index ff3e75f91..1b1400dcf 100644 --- a/src/ui/plugin-host/app.ts +++ b/src/ui/plugin-host/app.ts @@ -320,7 +320,7 @@ export function createPluginApp( ...method, titleKey: key(method.titleKey), pluginId, - component: scoped(method.component), + component: scoped(method.component) as never, }), ); }, @@ -331,7 +331,10 @@ export function createPluginApp( ...factor, titleKey: key(factor.titleKey), pluginId, - component: scoped(factor.component), + component: scoped(factor.component) as never, + enrollment: factor.enrollment + ? (scoped(factor.enrollment) as never) + : undefined, }), ); }, diff --git a/src/ui/plugin-host/auth-registry.ts b/src/ui/plugin-host/auth-registry.ts index c1a5e9f7d..7c83a445d 100644 --- a/src/ui/plugin-host/auth-registry.ts +++ b/src/ui/plugin-host/auth-registry.ts @@ -1,14 +1,19 @@ import type { ComponentType } from "react"; +import type { + LoginMethodUIProps, + SecondFactorUIProps, +} from "@termix/plugin-sdk/frontend"; import { createRegistry } from "@/lib/registry"; /** * Auth UI plugins contribute. * - * SSH auth editors are live: the host editor lists registered auth types next - * to the core ones (password, key, credential, agent, none) and renders the - * plugin's editor when one is picked. Login methods and second factors are - * declared here so the app object is complete; A8 wires them into the login - * screen. + * SSH auth editors render in the host and credential editors when their auth + * type is picked. Login methods render on the login screen for methods the + * server reports as enabled; second factors render in the step after a first + * login and, when they bring one, in Settings > Security for enrolment. + * Core's own (OIDC, LDAP, passkeys, TOTP) register from + * src/ui/auth/legacy-auth-ui.tsx until they move into plugins. */ export interface SshAuthEditorDef { /** The authType value stored on the host. */ @@ -28,14 +33,15 @@ export interface LoginMethodDef { pluginId?: string; titleKey: string; icon?: ComponentType<{ className?: string }>; - component: ComponentType>; + component: ComponentType; } export interface SecondFactorDef { id: string; pluginId?: string; titleKey: string; - component: ComponentType>; + component: ComponentType; + enrollment?: ComponentType>; } const sshAuthEditors = createRegistry(); @@ -47,9 +53,11 @@ export const getSshAuthEditor = sshAuthEditors.get; export const useSshAuthEditors = sshAuthEditors.useList; export const registerLoginMethod = loginMethods.register; +export const getLoginMethodUI = loginMethods.get; export const useLoginMethods = loginMethods.useList; export const registerSecondFactor = secondFactors.register; +export const getSecondFactorUI = secondFactors.get; export const useSecondFactors = secondFactors.useList; export function resetAuthRegistries(): void { diff --git a/src/ui/plugin-host/bridge.ts b/src/ui/plugin-host/bridge.ts index 387da4413..c5ae54dfa 100644 --- a/src/ui/plugin-host/bridge.ts +++ b/src/ui/plugin-host/bridge.ts @@ -24,6 +24,7 @@ import { usePluginScope } from "./scope"; import { knownPluginIds } from "./plugin-store"; import { tabsApi, useShellHosts } from "./shell-bridge"; import { invokeAction } from "@/shell/action-registry"; +import { useSshAuthProviders } from "@/hooks/useSshAuthProviders"; /** * Core permission groups, mirroring RESERVED_PERMISSION_PREFIXES in the SDK @@ -243,6 +244,22 @@ export const pluginHostBridge: PluginHostBridge = { useTabs: () => tabsApi, invokeAction: (id, ...args) => invokeAction(id, ...args), + + useSshAuthTypes: () => { + const { providers, loaded } = useSshAuthProviders(); + return { + loaded, + types: providers + .filter((option) => option.available) + .map((option) => ({ + type: option.type, + labelKey: option.editorTitleKey ?? option.labelKey, + pluginId: option.pluginId, + credentialType: option.credentialType, + supportsBackground: option.supportsBackground, + })), + }; + }, }; /** Installs the bridge. Idempotent. */ diff --git a/src/ui/plugin-host/loader.ts b/src/ui/plugin-host/loader.ts index 61cc8e426..550b1458b 100644 --- a/src/ui/plugin-host/loader.ts +++ b/src/ui/plugin-host/loader.ts @@ -350,6 +350,35 @@ async function fetchGuestPlugins(): Promise { return Array.isArray(body) ? (body as PluginSummary[]) : []; } +async function fetchPreLoginPlugins(): Promise { + const response = await fetch(getBackendUrl("/plugins/public-manifest")); + if (!response.ok) return []; + const body = (await response.json()) as unknown; + return Array.isArray(body) ? (body as PluginSummary[]) : []; +} + +/** + * Loads the plugins the login screen needs (login methods and second + * factors) before anyone has signed in. After sign-in the full runtime + * reconciles, keeping these active when their bundle has not changed. + */ +export async function startPreLoginPlugins(): Promise { + installPluginHostBridge(); + let list: PluginSummary[]; + try { + list = await fetchPreLoginPlugins(); + } catch { + return; + } + if (list.length === 0) return; + setPluginLocaleResolver((namespace, _language, file) => { + const summary = getPluginRecord(namespace)?.summary; + if (!summary) return Promise.resolve(null); + return currentDeps().loadLocale(summary, file); + }); + await syncPlugins(list); +} + /** * Starts the runtime. Idempotent. `guest` is for anonymous pages (shared * session links): only plugins declaring contributes.guest load, from the diff --git a/src/ui/sidebar/AdminSecondFactorsSection.tsx b/src/ui/sidebar/AdminSecondFactorsSection.tsx new file mode 100644 index 000000000..11aa6e6a3 --- /dev/null +++ b/src/ui/sidebar/AdminSecondFactorsSection.tsx @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { ShieldOff } from "lucide-react"; +import { Button } from "@/components/button"; +import { + getUserSecondFactors, + resetUserSecondFactors, + type UserSecondFactorSummary, +} from "@/api/auth-methods-api"; + +/** + * Every second factor a user enrolled in, including ones whose plugin is off + * (which is what locks a user out), with a reset for all of them. + */ +export function AdminSecondFactorsSection({ + userId, + username, + heading, + requestConfirm, + onReset, +}: { + userId: string; + username: string; + heading: ReactNode; + requestConfirm: (message: string, onConfirm: () => void) => void; + onReset: () => void; +}) { + const { t } = useTranslation(); + const [factors, setFactors] = useState([]); + const [working, setWorking] = useState(false); + + const load = useCallback(() => { + getUserSecondFactors(userId) + .then(setFactors) + .catch(() => setFactors([])); + }, [userId]); + + useEffect(() => { + load(); + }, [load]); + + async function reset() { + setWorking(true); + try { + await resetUserSecondFactors(userId); + toast.success(t("admin.secondFactorsResetSuccess")); + onReset(); + load(); + } catch { + toast.error(t("admin.secondFactorsResetFailed")); + } finally { + setWorking(false); + } + } + + return ( +
+ {heading} + {factors.length === 0 ? ( + + {t("admin.secondFactorsNone")} + + ) : ( +
+ {factors.map((factor) => ( +
+ + {factor.labelKey + ? t(factor.labelKey, { defaultValue: factor.factorId }) + : factor.factorId} + + {!factor.available && ( + + {t("admin.secondFactorUnavailable", { + plugin: factor.pluginId, + })} + + )} +
+ ))} +
+ )} + {factors.length > 0 && ( + + )} +
+ ); +} diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index 0cbf000d5..d5a812d7f 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -157,6 +157,7 @@ export function AdminSettingsPanel({ const [manageUser, setManageUser] = useState(null); const [allowRegistration, setAllowRegistration] = useState(true); const [allowPasswordLogin, setAllowPasswordLogin] = useState(true); + const [passwordLoginForced, setPasswordLoginForced] = useState(false); const [allowPasswordReset, setAllowPasswordReset] = useState(true); const [sessionTimeout, setSessionTimeout] = useState("24"); const [terminalTimeout, setTerminalTimeout] = useState("30"); @@ -425,8 +426,13 @@ export function AdminSettingsPanel({ ]); if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed); - if (pwLogin.status === "fulfilled") - setAllowPasswordLogin(pwLogin.value.allowed); + if (pwLogin.status === "fulfilled") { + // When forced on, the setting itself is off; show that and warn. + setPasswordLoginForced(!!pwLogin.value.forced); + setAllowPasswordLogin( + pwLogin.value.forced ? false : pwLogin.value.allowed, + ); + } if (oidcProv.status === "fulfilled") setOidcAutoProvision(oidcProv.value.enabled); if (oidcSilent.status === "fulfilled") { @@ -1223,6 +1229,7 @@ export function AdminSettingsPanel({ allowRegistration={allowRegistration} handleToggleRegistration={handleToggleRegistration} allowPasswordLogin={allowPasswordLogin} + passwordLoginForced={passwordLoginForced && !allowPasswordLogin} handleTogglePasswordLogin={handleTogglePasswordLogin} oidcAutoProvision={oidcAutoProvision} handleToggleOidcAutoProvision={handleToggleOidcAutoProvision} diff --git a/src/ui/sidebar/AdminSettingsSections.tsx b/src/ui/sidebar/AdminSettingsSections.tsx index ff0ccbb59..bdac60158 100644 --- a/src/ui/sidebar/AdminSettingsSections.tsx +++ b/src/ui/sidebar/AdminSettingsSections.tsx @@ -43,6 +43,7 @@ type GeneralSettingsSectionProps = { allowRegistration: boolean; handleToggleRegistration: () => void; allowPasswordLogin: boolean; + passwordLoginForced?: boolean; handleTogglePasswordLogin: () => void; oidcAutoProvision: boolean; handleToggleOidcAutoProvision: () => void; @@ -93,6 +94,7 @@ export function AdminGeneralSettingsSection({ allowRegistration, handleToggleRegistration, allowPasswordLogin, + passwordLoginForced, handleTogglePasswordLogin, oidcAutoProvision, handleToggleOidcAutoProvision, @@ -285,6 +287,11 @@ export function AdminGeneralSettingsSection({ onToggle={handleTogglePasswordLogin} />
+ {passwordLoginForced && ( +

+ {t("admin.passwordLoginForced")} +

+ )} + + setConfirmDialog({ message, onConfirm }) + } + onReset={() => { + setTotpEnabled(false); + onTotpDisabled(); + }} + /> + {/* Roles */}
{sectionHeading(t("admin.userRoles"))} diff --git a/src/ui/sidebar/CredentialsPanel.tsx b/src/ui/sidebar/CredentialsPanel.tsx index 5c61c5fff..76e1d28cb 100644 --- a/src/ui/sidebar/CredentialsPanel.tsx +++ b/src/ui/sidebar/CredentialsPanel.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useSshAuthProviders } from "@/hooks/useSshAuthProviders"; import { useTranslation } from "react-i18next"; import { ArrowUpDown, @@ -36,6 +37,7 @@ export function CredentialsPanel({ active?: boolean; }) { const { t } = useTranslation(); + const sshAuthProviders = useSshAuthProviders(); const { preferences: sidebarPrefs, update: updateSidebarPrefs } = useCredentialSidebarPreferences(); const [search, setSearch] = useState(""); @@ -233,18 +235,23 @@ export function CredentialsPanel({ {t("credentials.filterTypeGroup")} - {(["password", "key"] as const).map((val) => ( - handleFilterToggle("type", val)} - onSelect={(e) => e.preventDefault()} - > - {t( - `credentials.filterType${val.charAt(0).toUpperCase() + val.slice(1)}`, - )} - - ))} + {sshAuthProviders.providers + .filter((option) => option.credentialType) + .map((option) => ( + + handleFilterToggle("type", option.type) + } + onSelect={(e) => e.preventDefault()} + > + {t( + `credentials.filterType${option.type.charAt(0).toUpperCase() + option.type.slice(1)}`, + { defaultValue: t(option.labelKey) }, + )} + + ))} {allTags.length > 0 && ( <> diff --git a/src/ui/sidebar/HostEditor.tsx b/src/ui/sidebar/HostEditor.tsx index 6fd01e168..6c7f6c73a 100644 --- a/src/ui/sidebar/HostEditor.tsx +++ b/src/ui/sidebar/HostEditor.tsx @@ -90,6 +90,10 @@ import { makeHostTabs, } from "./HostManagerTabs"; import { useSshAuthEditors } from "@/plugin-host/auth-registry"; +import { useSshAuthProviders } from "@/hooks/useSshAuthProviders"; +import { SshAuthProviderFields } from "./SshAuthProviderFields"; +import type { PluginSettingsField } from "@termix/plugin-sdk/manifest"; +import { ensureLegacyAuthUI } from "@/auth/legacy-auth-ui"; import { SecretReferenceHint, SecretSourceManager, @@ -102,17 +106,7 @@ import { const CUSTOM_FONT_OPTION = "__custom__"; -/** The SSH auth types core itself handles. Plugins add the rest. */ -const CORE_AUTH_METHODS = [ - "password", - "key", - "credential", - "vault", - "none", - "opkssh", - "stepca", - "agent", -]; +ensureLegacyAuthUI(); export function HostEditor({ host, @@ -373,10 +367,27 @@ export function HostEditor({ (editor) => editor.id === authMethod, ); const ActiveAuthEditor = activeAuthEditor?.component; + const sshAuthProviders = useSshAuthProviders(); const authEditorLabel = (method: string) => { - const editor = sshAuthEditors.find((item) => item.id === method); - return editor ? t(editor.titleKey) : method; + const option = sshAuthProviders.find(method); + if (option?.editorTitleKey) return t(option.editorTitleKey); + if (option?.labelKey) return t(option.labelKey, { defaultValue: method }); + return method; }; + const selectableAuthMethods = sshAuthProviders.providers + .filter((option) => option.available) + .map((option) => option.type); + const currentAuthOption = sshAuthProviders.find(authMethod); + // The host uses a type nothing here provides (its plugin is off). + const missingAuthNotice = + sshAuthProviders.loaded && authMethod && !currentAuthOption?.available + ? currentAuthOption?.missingPlugin + ? t("hosts.authTypeNeedsPlugin", { + type: authMethod, + plugin: currentAuthOption.missingPlugin.name, + }) + : t("hosts.authTypeUnknown", { type: authMethod }) + : null; const availableCredentials = quickCreatedCredential && !credentials.some( @@ -611,10 +622,7 @@ export function HostEditor({ role="radiogroup" aria-label={t("hosts.authenticationMethod")} > - {[ - ...CORE_AUTH_METHODS, - ...sshAuthEditors.map((editor) => editor.id), - ].map((m) => ( + {selectableAuthMethods.map((m) => (
@@ -669,26 +682,6 @@ export function HostEditor({ {t("hosts.oidcUsernameHint")}

)} - {authMethod === "stepca" && ( -
-
- - {t("hosts.stepcaLabel")} - - - {t("hosts.docsLink")} - -
-

- {t("hosts.stepcaDesc")} -

-
- )} {activeAuthEditor?.hintKey && (

{t(activeAuthEditor.hintKey)} @@ -1056,26 +1049,18 @@ export function HostEditor({ )}

- {authMethod === "opkssh" && ( -
-
- - {t("hosts.opksshLabel")} - - - {t("hosts.docsLink")} - -
-

- {t("hosts.opksshDesc")} -

-
- )} + {!ActiveAuthEditor && + currentAuthOption?.available && + currentAuthOption.pluginId !== "core" && + currentAuthOption.fields.length > 0 && ( + + )} {ActiveAuthEditor && ( {t("hosts.filterAuthGroup")} - {( - [ - "password", - "key", - "credential", - "none", - "opkssh", - "stepca", - ] as const - ).map((val) => ( + {sshAuthProviders.providers.map((option) => ( - handleFilterToggle("authType", val) + handleFilterToggle("authType", option.type) } onSelect={(e) => e.preventDefault()} > - {t( - `hosts.filterAuth${val.charAt(0).toUpperCase() + val.slice(1)}`, - )} + {option.editorTitleKey + ? t(option.editorTitleKey) + : t(option.labelKey, { defaultValue: option.type })} ))} diff --git a/src/ui/sidebar/PluginSecondFactorEnrollment.tsx b/src/ui/sidebar/PluginSecondFactorEnrollment.tsx new file mode 100644 index 000000000..e5453fadd --- /dev/null +++ b/src/ui/sidebar/PluginSecondFactorEnrollment.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from "react-i18next"; +import { useSecondFactors } from "@/plugin-host/auth-registry"; + +/** + * Enrolment sections second-factor plugins register. Core's own TOTP and + * passkey sections sit above this until they move into plugins. + */ +export function PluginSecondFactorEnrollment() { + const { t } = useTranslation(); + const factors = useSecondFactors().filter( + (factor) => factor.enrollment && factor.pluginId !== "core", + ); + if (factors.length === 0) return null; + return ( +
+ {factors.map((factor) => { + const Enrollment = factor.enrollment!; + return ( +
+ + {t(factor.titleKey)} + + +
+ ); + })} +
+ ); +} diff --git a/src/ui/sidebar/SshAuthProviderFields.tsx b/src/ui/sidebar/SshAuthProviderFields.tsx new file mode 100644 index 000000000..36d2820ed --- /dev/null +++ b/src/ui/sidebar/SshAuthProviderFields.tsx @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { PluginSettingsField } from "@termix/plugin-sdk/manifest"; +import { SettingsFieldRow } from "@/settings/SettingsFields"; +import { + getPluginHostSettings, + updatePluginHostSettings, + PluginSettingsValidationError, +} from "@/api/plugins-api"; + +/** + * Fields an SSH auth type declares, for a plugin that did not register its + * own editor. They are the plugin's host settings (so the plugin reads them + * with ctx.settings.getHost), which means a host must be saved first. + */ +export function SshAuthProviderFields({ + pluginId, + hostId, + fields, +}: { + pluginId: string; + hostId: number | null | undefined; + fields: PluginSettingsField[]; +}) { + const { t } = useTranslation(); + const [values, setValues] = useState>({}); + const [errors, setErrors] = useState>({}); + const saveTimer = useRef | null>(null); + + useEffect(() => { + if (!hostId) return; + let cancelled = false; + getPluginHostSettings(pluginId, hostId) + .then((loaded) => { + if (!cancelled) setValues(loaded); + }) + .catch(() => {}); + return () => { + cancelled = true; + if (saveTimer.current) clearTimeout(saveTimer.current); + }; + }, [pluginId, hostId]); + + if (!hostId) { + return ( +

+ {t("hosts.authFieldsAfterSave")} +

+ ); + } + + const setValue = (key: string, value: unknown) => { + const next = { ...values, [key]: value }; + setValues(next); + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + updatePluginHostSettings(pluginId, hostId, { [key]: value }) + .then(() => setErrors((prev) => ({ ...prev, [key]: "" }))) + .catch((error) => { + if (error instanceof PluginSettingsValidationError) { + setErrors((prev) => ({ ...prev, ...error.errors })); + } + }); + }, 400); + }; + + return ( +
+ {fields.map((field) => ( + + ))} +
+ ); +} diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx index de78c6039..f72f4d5ae 100644 --- a/src/ui/sidebar/UserProfilePanel.tsx +++ b/src/ui/sidebar/UserProfilePanel.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect } from "react"; +import { PluginSecondFactorEnrollment } from "./PluginSecondFactorEnrollment"; import { useTranslation } from "react-i18next"; import { copyToClipboard } from "@/lib/clipboard"; import { @@ -2568,6 +2569,8 @@ export function UserProfilePanel({
+ + {canChangePasword && ( 0 && !filters.type.includes(cred.type)) return false; diff --git a/src/ui/tests/auth/AuthLoginMethods.test.tsx b/src/ui/tests/auth/AuthLoginMethods.test.tsx new file mode 100644 index 000000000..8f3c62f7e --- /dev/null +++ b/src/ui/tests/auth/AuthLoginMethods.test.tsx @@ -0,0 +1,268 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import type { + LoginMethodUIProps, + SecondFactorUIProps, +} from "@termix/plugin-sdk/frontend"; + +const mainAxios = vi.hoisted(() => ({ + loginUser: vi.fn(), + registerUser: vi.fn(), + getUserInfo: vi.fn(), + getRegistrationAllowed: vi.fn(), + getPasswordLoginAllowed: vi.fn(), + getPasswordResetAllowed: vi.fn(), + getSetupRequired: vi.fn(), + initiatePasswordReset: vi.fn(), + verifyPasswordResetCode: vi.fn(), + completePasswordReset: vi.fn(), + isElectron: vi.fn(), + getCurrentToken: vi.fn(), + getOidcSilentLoginDefault: vi.fn(), + requestDesktopAutoSession: vi.fn(), + requestTrustedProxyLogin: vi.fn(), +})); + +const authMethodsApi = vi.hoisted(() => ({ + getLoginMethods: vi.fn(), + submitLoginMethod: vi.fn(), + startLoginRedirect: vi.fn(), + verifySecondFactor: vi.fn(), + challengeSecondFactor: vi.fn(), +})); + +vi.mock("@/main-axios", () => mainAxios); +vi.mock("@/api/auth-methods-api", () => authMethodsApi); +vi.mock("@/plugin-host/loader", () => ({ + startPreLoginPlugins: vi.fn(async () => {}), +})); +vi.mock("@/i18n/i18n", () => ({ + changeAppLanguage: vi.fn(async (code: string) => code), + normalizeLanguageCode: vi.fn((code: string | null) => code || "en"), + rememberLoginLanguage: vi.fn((code: string) => code), +})); +vi.mock("../../auth/silent-signin", () => ({ + removeSilentSigninFromSearch: vi.fn(), + shouldTriggerSilentSignin: vi.fn(() => false), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts && typeof opts.name === "string" ? `${key}:${opts.name}` : key, + }), +})); +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() }, +})); + +import { Auth } from "../../auth/Auth"; +import { + registerLoginMethod, + registerSecondFactor, +} from "@/plugin-host/auth-registry"; + +const OriginalResizeObserver = globalThis.ResizeObserver; +const originalLocation = window.location; + +beforeAll(() => { + globalThis.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; +}); + +afterAll(() => { + globalThis.ResizeObserver = OriginalResizeObserver; +}); + +beforeEach(() => { + localStorage.clear(); + window.history.replaceState({}, "", "/"); + mainAxios.isElectron.mockReturnValue(false); + mainAxios.getRegistrationAllowed.mockResolvedValue({ allowed: true }); + mainAxios.getPasswordLoginAllowed.mockResolvedValue({ allowed: true }); + mainAxios.getPasswordResetAllowed.mockResolvedValue(true); + mainAxios.getSetupRequired.mockResolvedValue({ setup_required: false }); + mainAxios.getOidcSilentLoginDefault.mockResolvedValue({ enabled: false }); + mainAxios.requestTrustedProxyLogin.mockResolvedValue({ enabled: false }); + mainAxios.getUserInfo.mockResolvedValue({ + username: "alice", + userId: "u1", + is_admin: false, + }); + authMethodsApi.getLoginMethods.mockResolvedValue([ + { + id: "password", + pluginId: "core", + kind: "form", + labelKey: "auth.password", + instances: [{ id: "password", label: "Password" }], + }, + { + id: "oidc", + pluginId: "core", + kind: "redirect", + labelKey: "auth.loginWithSso", + instances: [{ id: "3", label: "Keycloak" }], + }, + { + id: "corp-sso", + pluginId: "corp", + kind: "redirect", + labelKey: "corp.sso", + instances: [{ id: "a", label: "Corp" }], + }, + ]); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); +}); + +async function openExternalTab() { + render(); + await waitFor(() => + expect(screen.getAllByText("auth.external").length).toBeGreaterThan(0), + ); + fireEvent.click(screen.getAllByText("auth.external")[0]); +} + +describe("login methods on the login screen", () => { + it("lists every enabled method, core and plugin", async () => { + await openExternalTab(); + expect(screen.getByText("auth.loginWithProvider:Keycloak")).toBeTruthy(); + // A plugin redirect method with no UI of its own gets a plain button. + expect(screen.getByText("auth.loginWithProvider:Corp")).toBeTruthy(); + }); + + it("starts a plugin redirect login through the generic endpoint", async () => { + const replace = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, replace, search: "" }, + }); + authMethodsApi.startLoginRedirect.mockResolvedValue("https://corp/auth"); + await openExternalTab(); + fireEvent.click(screen.getByText("auth.loginWithProvider:Corp")); + await waitFor(() => + expect(authMethodsApi.startLoginRedirect).toHaveBeenCalledWith( + "corp-sso", + expect.objectContaining({ instanceId: "a" }), + ), + ); + await waitFor(() => + expect(replace).toHaveBeenCalledWith("https://corp/auth"), + ); + }); + + it("renders a plugin's form UI and runs its second factor", async () => { + authMethodsApi.getLoginMethods.mockResolvedValue([ + { + id: "corp-form", + pluginId: "corp", + kind: "form", + labelKey: "corp.form", + instances: [], + }, + ]); + const disposeMethod = registerLoginMethod({ + id: "corp-form", + pluginId: "corp", + titleKey: "corp.form", + component: ({ submit }: LoginMethodUIProps) => ( + + ), + }); + const disposeFactor = registerSecondFactor({ + id: "pin", + pluginId: "corp", + titleKey: "corp.pin", + component: ({ verify }: SecondFactorUIProps) => ( + + ), + }); + authMethodsApi.submitLoginMethod.mockResolvedValue({ + success: true, + requires_totp: true, + temp_token: "pending-1", + second_factors: [{ id: "pin", pluginId: "corp", labelKey: "corp.pin" }], + }); + authMethodsApi.verifySecondFactor.mockResolvedValue({ + success: true, + username: "alice", + }); + const onLogin = vi.fn(); + try { + render(); + await waitFor(() => + expect(screen.getAllByText("auth.external").length).toBeGreaterThan(0), + ); + fireEvent.click(screen.getAllByText("auth.external")[0]); + fireEvent.click(await screen.findByText("corp sign in")); + await waitFor(() => + expect(authMethodsApi.submitLoginMethod).toHaveBeenCalledWith( + "corp-form", + expect.objectContaining({ token: "abc" }), + undefined, + ), + ); + fireEvent.click(await screen.findByText("send pin")); + await waitFor(() => + expect(authMethodsApi.verifySecondFactor).toHaveBeenCalledWith( + "pin", + expect.objectContaining({ pin: "1234", temp_token: "pending-1" }), + ), + ); + await waitFor(() => + expect(onLogin).toHaveBeenCalledWith("alice", "u1", false), + ); + } finally { + disposeMethod(); + disposeFactor(); + } + }); + + it("picks up the second factor step after a redirect login", async () => { + window.history.replaceState( + {}, + "", + "/?second_factor=1&second_factors=totp", + ); + render(); + const input = await screen.findByPlaceholderText("000000"); + // Backup codes are letters and digits, and are accepted now. + fireEvent.change(input, { target: { value: "abcd-2345" } }); + expect((input as HTMLInputElement).value).toBe("ABCD2345"); + authMethodsApi.verifySecondFactor.mockResolvedValue({ success: true }); + fireEvent.submit(input.closest("form")!); + await waitFor(() => + expect(authMethodsApi.verifySecondFactor).toHaveBeenCalledWith("totp", { + totp_code: "ABCD2345", + rememberMe: false, + }), + ); + }); +}); diff --git a/src/ui/tests/auth/ElectronLoginForm.test.tsx b/src/ui/tests/auth/ElectronLoginForm.test.tsx index d1cc97af9..3433cb152 100644 --- a/src/ui/tests/auth/ElectronLoginForm.test.tsx +++ b/src/ui/tests/auth/ElectronLoginForm.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ElectronLoginForm } from "../../auth/ElectronLoginForm"; @@ -23,4 +23,35 @@ describe("ElectronLoginForm", () => { expect(permissions).toContain("publickey-credentials-get"); expect(permissions).toContain("publickey-credentials-create"); }); + + it.each([ + "auth_component", + "totp_auth_component", + "passkey_auth_component", + "method_auth_component", + ])("accepts a login hand-off from %s", async (source) => { + const onAuthSuccess = vi.fn(); + render( + , + ); + const frame = screen.getByTitle( + "Server Authentication", + ) as HTMLIFrameElement; + window.dispatchEvent( + new MessageEvent("message", { + source: frame.contentWindow, + data: { + type: "AUTH_SUCCESS", + platform: "desktop", + source, + token: "jwt-1", + }, + }), + ); + await waitFor(() => expect(onAuthSuccess).toHaveBeenCalledWith("jwt-1")); + }); }); diff --git a/src/ui/tests/hooks/useSshAuthProviders.test.tsx b/src/ui/tests/hooks/useSshAuthProviders.test.tsx new file mode 100644 index 000000000..bee8d2f11 --- /dev/null +++ b/src/ui/tests/hooks/useSshAuthProviders.test.tsx @@ -0,0 +1,78 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const api = vi.hoisted(() => ({ getSshAuthProviders: vi.fn() })); +vi.mock("@/api/auth-methods-api", () => api); + +import { + invalidateSshAuthProviders, + useSshAuthProviders, +} from "@/hooks/useSshAuthProviders"; +import { registerSshAuthEditor } from "@/plugin-host/auth-registry"; + +function summary(type: string, extra: Record = {}) { + return { + type, + labelKey: `hosts.filterAuth${type}`, + pluginId: "core", + fields: [], + credentialType: false, + needsUserInteraction: false, + supportsBackground: true, + available: true, + ...extra, + }; +} + +afterEach(() => { + invalidateSshAuthProviders(); + vi.clearAllMocks(); +}); + +describe("useSshAuthProviders", () => { + it("lists the server's types, flags ones whose plugin is off, and merges editors", async () => { + api.getSshAuthProviders.mockResolvedValue([ + summary("password", { credentialType: true }), + summary("corp", { pluginId: "corp" }), + summary("gone", { + available: false, + pluginId: "gone-plugin", + missingPlugin: { id: "gone-plugin", name: "Gone" }, + }), + ]); + const dispose = registerSshAuthEditor({ + id: "corp", + pluginId: "corp", + titleKey: "corp:title", + }); + try { + const { result } = renderHook(() => useSshAuthProviders()); + await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.providers.map((p) => p.type)).toEqual([ + "password", + "corp", + "gone", + ]); + expect(result.current.find("corp")?.editorTitleKey).toBe("corp:title"); + expect(result.current.find("gone")).toMatchObject({ + available: false, + missingPlugin: { name: "Gone" }, + }); + } finally { + dispose(); + } + }); + + it("offers the built-in types while the server has not answered", () => { + api.getSshAuthProviders.mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useSshAuthProviders()); + expect(result.current.loaded).toBe(false); + expect(result.current.providers.map((p) => p.type)).toEqual([ + "password", + "key", + "credential", + "agent", + "none", + ]); + }); +}); diff --git a/src/ui/tests/sidebar/AdminSecondFactorsSection.test.tsx b/src/ui/tests/sidebar/AdminSecondFactorsSection.test.tsx new file mode 100644 index 000000000..967d46e28 --- /dev/null +++ b/src/ui/tests/sidebar/AdminSecondFactorsSection.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const api = vi.hoisted(() => ({ + getUserSecondFactors: vi.fn(), + resetUserSecondFactors: vi.fn(), +})); +vi.mock("@/api/auth-methods-api", () => api); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts?.plugin ? `${key}:${opts.plugin}` : key, + }), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { AdminSecondFactorsSection } from "@/sidebar/AdminSecondFactorsSection"; + +afterEach(() => vi.clearAllMocks()); + +describe("AdminSecondFactorsSection", () => { + it("shows a factor whose plugin is off and resets after confirming", async () => { + api.getUserSecondFactors.mockResolvedValue([ + { + pluginId: "core", + factorId: "totp", + labelKey: "auth.totpFactor", + available: true, + }, + { pluginId: "yubi", factorId: "otp", labelKey: null, available: false }, + ]); + api.resetUserSecondFactors.mockResolvedValue(undefined); + const onReset = vi.fn(); + const requestConfirm = vi.fn((_message: string, onConfirm: () => void) => + onConfirm(), + ); + render( + factors} + requestConfirm={requestConfirm} + onReset={onReset} + />, + ); + expect( + await screen.findByText("admin.secondFactorUnavailable:yubi"), + ).toBeTruthy(); + fireEvent.click(screen.getByText("admin.resetSecondFactors")); + expect(requestConfirm).toHaveBeenCalled(); + await waitFor(() => + expect(api.resetUserSecondFactors).toHaveBeenCalledWith("u1"), + ); + expect(onReset).toHaveBeenCalled(); + }); +}); diff --git a/src/ui/tests/types/credential-sidebar-preferences.test.ts b/src/ui/tests/types/credential-sidebar-preferences.test.ts index 1650f2d0e..57f80d702 100644 --- a/src/ui/tests/types/credential-sidebar-preferences.test.ts +++ b/src/ui/tests/types/credential-sidebar-preferences.test.ts @@ -69,11 +69,12 @@ describe("sanitizeCredentialSidebarPreferences", () => { it("filters out invalid entries from filter arrays instead of rejecting them", () => { const result = sanitizeCredentialSidebarPreferences({ filters: { - type: ["key", "bogus"], + type: ["key", "corp-token", "Bad Type!", 3], tags: ["prod", 42, null], }, }); - expect(result.filters.type).toEqual(["key"]); + // Plugins add credential types, so any well-formed id is kept. + expect(result.filters.type).toEqual(["key", "corp-token"]); expect(result.filters.tags).toEqual(["prod"]); }); diff --git a/src/ui/tests/types/host-sidebar-preferences.test.ts b/src/ui/tests/types/host-sidebar-preferences.test.ts index 263403e62..1483f5988 100644 --- a/src/ui/tests/types/host-sidebar-preferences.test.ts +++ b/src/ui/tests/types/host-sidebar-preferences.test.ts @@ -118,12 +118,13 @@ describe("sanitizeHostSidebarPreferences", () => { const result = sanitizeHostSidebarPreferences({ filters: { status: ["online", "bogus", "pinned"], - authType: ["key", "nope"], + authType: ["key", "corp-sso", "Not A Type!", 7], tags: ["prod", 42, null], }, }); expect(result.filters.status).toEqual(["online", "pinned"]); - expect(result.filters.authType).toEqual(["key"]); + // Plugins add auth types, so any well-formed id is kept. + expect(result.filters.authType).toEqual(["key", "corp-sso"]); expect(result.filters.tags).toEqual(["prod"]); });