diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f7c405c..a897fa7ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## 26.11.1 +### Added + +- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals +- API keys can be limited to reading or running actions + ### Changed - Host status, import and export, and credential tools now start on for new installs diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index fbe592f9c..127692337 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -276,6 +276,15 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/operations(/.*)?$ { + 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 ~ ^/plugins(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index e529ce06c..d52d06f76 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -245,6 +245,15 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/operations(/.*)?$ { + 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 ~ ^/plugins(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/drizzle/mysql/0078_sweet_king_cobra.sql b/drizzle/mysql/0078_sweet_king_cobra.sql new file mode 100644 index 000000000..5987a9673 --- /dev/null +++ b/drizzle/mysql/0078_sweet_king_cobra.sql @@ -0,0 +1,70 @@ +CREATE TABLE `operation_approvals` ( + `id` varchar(255) NOT NULL, + `run_id` varchar(255) NOT NULL, + `requested_by` text, + `reason` text, + `allow_self` boolean NOT NULL DEFAULT false, + `status` varchar(255) NOT NULL, + `decided_by` text, + `comment` text, + `created_at` text NOT NULL, + `expires_at` varchar(255) NOT NULL, + `decided_at` text, + CONSTRAINT `operation_approvals_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `operation_events` ( + `id` varchar(255) NOT NULL, + `event_id` text NOT NULL, + `plugin_id` text NOT NULL, + `host_id` int, + `severity` text, + `payload` text, + `run_id` text, + `created_at` varchar(255) NOT NULL, + CONSTRAINT `operation_events_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `operation_runs` ( + `id` varchar(255) NOT NULL, + `action_id` text NOT NULL, + `action_version` int NOT NULL DEFAULT 1, + `plugin_id` text NOT NULL, + `plugin_version` text NOT NULL, + `user_id` varchar(255), + `username` text NOT NULL, + `origin` text NOT NULL, + `host_id` int, + `host_name` text, + `batch_id` varchar(255), + `dry_run` boolean NOT NULL DEFAULT false, + `risk` text NOT NULL, + `status` varchar(255) NOT NULL, + `summary` text, + `changed` boolean, + `input` text, + `sealed_input` text, + `output` text, + `undo_input` text, + `error` text, + `error_code` text, + `undo_of` text, + `idempotency_key` text, + `created_at` varchar(255) NOT NULL, + `started_at` text, + `finished_at` text, + CONSTRAINT `operation_runs_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `api_keys` ADD `scopes` text;--> statement-breakpoint +ALTER TABLE `operation_approvals` ADD CONSTRAINT `operation_approvals_run_id_operation_runs_id_fk` FOREIGN KEY (`run_id`) REFERENCES `operation_runs`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `operation_runs` ADD CONSTRAINT `operation_runs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_operation_approvals_status` ON `operation_approvals` (`status`,`expires_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_approvals_run` ON `operation_approvals` (`run_id`);--> statement-breakpoint +CREATE INDEX `idx_operation_events_created` ON `operation_events` (`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_events_host_created` ON `operation_events` (`host_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_created` ON `operation_runs` (`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_user_created` ON `operation_runs` (`user_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_host_created` ON `operation_runs` (`host_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_status` ON `operation_runs` (`status`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_batch` ON `operation_runs` (`batch_id`); \ No newline at end of file diff --git a/drizzle/mysql/meta/0078_snapshot.json b/drizzle/mysql/meta/0078_snapshot.json new file mode 100644 index 000000000..489cce7c7 --- /dev/null +++ b/drizzle/mysql/meta/0078_snapshot.json @@ -0,0 +1,5278 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "d516ec4d-26b1-460f-8020-63ef7d960512", + "prevId": "7ed8bfd3-eeba-4c4a-a7d2-a0ebcfb89d66", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "api_keys_id": { + "name": "api_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + "action", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + "resource_type", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audit_logs_id": { + "name": "audit_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "credential_access": { + "name": "credential_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('use')" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "credential_access_id": { + "name": "credential_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "credential_sidebar_preferences_user_id": { + "name": "credential_sidebar_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folder_access": { + "name": "folder_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('connect')" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + "owner_user_id", + "folder" + ], + "isUnique": false + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "folder_access_id": { + "name": "folder_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('connect')" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_access_id": { + "name": "host_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_defaults": { + "name": "host_defaults", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "level": { + "name": "level", + "type": "enum('admin','user','folder')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder_id": { + "name": "folder_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "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": false, + "autoincrement": false + }, + "updated_by": { + "name": "updated_by", + "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_host_defaults_scope_key": { + "name": "idx_host_defaults_scope_key", + "columns": [ + "scope_key", + "namespace", + "key" + ], + "isUnique": true + }, + "idx_host_defaults_user": { + "name": "idx_host_defaults_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_defaults_folder": { + "name": "idx_host_defaults_folder", + "columns": [ + "folder_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_defaults_user_id_users_id_fk": { + "name": "host_defaults_user_id_users_id_fk", + "tableFrom": "host_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_defaults_folder_id_ssh_folders_id_fk": { + "name": "host_defaults_folder_id_ssh_folders_id_fk", + "tableFrom": "host_defaults", + "tableTo": "ssh_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_defaults_id": { + "name": "host_defaults_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_protocol_auth": { + "name": "host_protocol_auth", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('direct')" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_fields": { + "name": "secret_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + "host_id", + "protocol" + ], + "isUnique": true + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_protocol_auth_id": { + "name": "host_protocol_auth_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_sidebar_preferences_user_id": { + "name": "host_sidebar_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('ssh')" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_options": { + "name": "ssh_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "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 + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('sha256')" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_overrides": { + "name": "default_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + "parent_host_id" + ], + "isUnique": false + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_data_id": { + "name": "ssh_data_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "operation_approvals": { + "name": "operation_approvals", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_self": { + "name": "allow_self", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_operation_approvals_status": { + "name": "idx_operation_approvals_status", + "columns": [ + "status", + "expires_at" + ], + "isUnique": false + }, + "idx_operation_approvals_run": { + "name": "idx_operation_approvals_run", + "columns": [ + "run_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "operation_approvals_run_id_operation_runs_id_fk": { + "name": "operation_approvals_run_id_operation_runs_id_fk", + "tableFrom": "operation_approvals", + "tableTo": "operation_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "operation_approvals_id": { + "name": "operation_approvals_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "operation_events": { + "name": "operation_events", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_operation_events_created": { + "name": "idx_operation_events_created", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_operation_events_host_created": { + "name": "idx_operation_events_host_created", + "columns": [ + "host_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "operation_events_id": { + "name": "operation_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "operation_runs": { + "name": "operation_runs", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_id": { + "name": "action_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_version": { + "name": "action_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_version": { + "name": "plugin_version", + "type": "text", + "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": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "risk": { + "name": "risk", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed": { + "name": "changed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sealed_input": { + "name": "sealed_input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undo_input": { + "name": "undo_input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undo_of": { + "name": "undo_of", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_operation_runs_created": { + "name": "idx_operation_runs_created", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_operation_runs_user_created": { + "name": "idx_operation_runs_user_created", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + }, + "idx_operation_runs_host_created": { + "name": "idx_operation_runs_host_created", + "columns": [ + "host_id", + "created_at" + ], + "isUnique": false + }, + "idx_operation_runs_status": { + "name": "idx_operation_runs_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_operation_runs_batch": { + "name": "idx_operation_runs_batch", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "operation_runs_user_id_users_id_fk": { + "name": "operation_runs_user_id_users_id_fk", + "tableFrom": "operation_runs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "operation_runs_id": { + "name": "operation_runs_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 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + "plugin_id", + "registry_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_install_counts_id": { + "name": "plugin_install_counts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_migrations": { + "name": "plugin_migrations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "migration_id": { + "name": "migration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + "plugin_id", + "migration_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_migrations_id": { + "name": "plugin_migrations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_permission_grants": { + "name": "plugin_permission_grants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('admin')" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + "plugin_id", + "capability" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_permission_grants_id": { + "name": "plugin_permission_grants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_registries": { + "name": "plugin_registries", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('community')" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_registries_id": { + "name": "plugin_registries_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "enum('admin','user','host','secret')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + "plugin_id", + "scope", + "scope_id", + "key" + ], + "isUnique": true + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + "plugin_id", + "scope" + ], + "isUnique": false + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_settings_id": { + "name": "plugin_settings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + "plugin_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_storage_id": { + "name": "plugin_storage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('available')" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('community')" + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('disabled')" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pinned_version": { + "name": "pinned_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('stable')" + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + "registry_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugins_id": { + "name": "plugins_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "role_name": { + "name": "role_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + "role_name", + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rbac_applied_defaults_id": { + "name": "rbac_applied_defaults_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rbac_known_permissions": { + "name": "rbac_known_permissions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rbac_known_permissions_id": { + "name": "rbac_known_permissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "roles_id": { + "name": "roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_session_ref": { + "name": "external_session_ref", + "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": "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)" + }, + "restriction": { + "name": "restriction", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idle_timeout": { + "name": "idle_timeout", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "settings_key": { + "name": "settings_key", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_credential_secrets": { + "name": "shared_credential_secrets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('password')" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + "credential_access_id", + "target_user_id" + ], + "isUnique": true + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + "target_user_id", + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_credential_secrets_id": { + "name": "shared_credential_secrets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_folder_auth_overrides": { + "name": "shared_folder_auth_overrides", + "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 + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "shared_folder_auth_overrides_user_folder_protocol_unique": { + "name": "shared_folder_auth_overrides_user_folder_protocol_unique", + "columns": [ + "user_id", + "folder", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_folder_auth_overrides_user_id_users_id_fk": { + "name": "shared_folder_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_folder_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_folder_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_folder_auth_overrides_id": { + "name": "shared_folder_auth_overrides_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + "host_id", + "user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_host_auth_overrides_id": { + "name": "shared_host_auth_overrides_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('credential')" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_fields": { + "name": "encrypted_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_host_secrets_id": { + "name": "shared_host_secrets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credential_usage_id": { + "name": "ssh_credential_usage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credentials_id": { + "name": "ssh_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_folders_id": { + "name": "ssh_folders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "sync_conflicts": { + "name": "sync_conflicts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_revision": { + "name": "server_revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_conflicts_id": { + "name": "sync_conflicts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_link": { + "name": "sync_link", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('idle')" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_link_id": { + "name": "sync_link_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_records": { + "name": "sync_records", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + "user_id", + "entity_type", + "sync_id" + ], + "isUnique": true + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + "user_id", + "seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_records_id": { + "name": "sync_records_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "trusted_devices": { + "name": "trusted_devices", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "trusted_devices_id": { + "name": "trusted_devices_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ui_preferences_user_id": { + "name": "ui_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_external_identities": { + "name": "user_external_identities", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + "provider_id", + "subject" + ], + "isUnique": true + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_external_identities_id": { + "name": "user_external_identities_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_open_tabs": { + "name": "user_open_tabs", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_open_tabs_id": { + "name": "user_open_tabs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_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 + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_preferences_user_id": { + "name": "user_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + "user_id", + "role_id" + ], + "isUnique": true + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_id": { + "name": "user_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_second_factors": { + "name": "user_second_factors", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "factor_id": { + "name": "factor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + "user_id", + "plugin_id", + "factor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_second_factors_id": { + "name": "user_second_factors_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "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 + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/mysql/meta/_journal.json b/drizzle/mysql/meta/_journal.json index b4f852fc9..d6b0bec6b 100644 --- a/drizzle/mysql/meta/_journal.json +++ b/drizzle/mysql/meta/_journal.json @@ -547,6 +547,13 @@ "when": 1793304929772, "tag": "0077_shared_folder_auth_overrides", "breakpoints": true + }, + { + "idx": 78, + "version": "5", + "when": 1793765383557, + "tag": "0078_sweet_king_cobra", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/postgres/0078_clean_living_mummy.sql b/drizzle/postgres/0078_clean_living_mummy.sql new file mode 100644 index 000000000..59000a2fd --- /dev/null +++ b/drizzle/postgres/0078_clean_living_mummy.sql @@ -0,0 +1,67 @@ +CREATE TABLE "operation_approvals" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "run_id" varchar(255) NOT NULL, + "requested_by" text, + "reason" text, + "allow_self" boolean DEFAULT false NOT NULL, + "status" varchar(255) NOT NULL, + "decided_by" text, + "comment" text, + "created_at" text NOT NULL, + "expires_at" varchar(255) NOT NULL, + "decided_at" text +); +--> statement-breakpoint +CREATE TABLE "operation_events" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "event_id" text NOT NULL, + "plugin_id" text NOT NULL, + "host_id" integer, + "severity" text, + "payload" text, + "run_id" text, + "created_at" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "operation_runs" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "action_id" text NOT NULL, + "action_version" integer DEFAULT 1 NOT NULL, + "plugin_id" text NOT NULL, + "plugin_version" text NOT NULL, + "user_id" varchar(255), + "username" text NOT NULL, + "origin" text NOT NULL, + "host_id" integer, + "host_name" text, + "batch_id" varchar(255), + "dry_run" boolean DEFAULT false NOT NULL, + "risk" text NOT NULL, + "status" varchar(255) NOT NULL, + "summary" text, + "changed" boolean, + "input" text, + "sealed_input" text, + "output" text, + "undo_input" text, + "error" text, + "error_code" text, + "undo_of" text, + "idempotency_key" text, + "created_at" varchar(255) NOT NULL, + "started_at" text, + "finished_at" text +); +--> statement-breakpoint +ALTER TABLE "api_keys" ADD COLUMN "scopes" text;--> statement-breakpoint +ALTER TABLE "operation_approvals" ADD CONSTRAINT "operation_approvals_run_id_operation_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."operation_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "operation_runs" ADD CONSTRAINT "operation_runs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_operation_approvals_status" ON "operation_approvals" USING btree ("status","expires_at");--> statement-breakpoint +CREATE INDEX "idx_operation_approvals_run" ON "operation_approvals" USING btree ("run_id");--> statement-breakpoint +CREATE INDEX "idx_operation_events_created" ON "operation_events" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "idx_operation_events_host_created" ON "operation_events" USING btree ("host_id","created_at");--> statement-breakpoint +CREATE INDEX "idx_operation_runs_created" ON "operation_runs" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "idx_operation_runs_user_created" ON "operation_runs" USING btree ("user_id","created_at");--> statement-breakpoint +CREATE INDEX "idx_operation_runs_host_created" ON "operation_runs" USING btree ("host_id","created_at");--> statement-breakpoint +CREATE INDEX "idx_operation_runs_status" ON "operation_runs" USING btree ("status");--> statement-breakpoint +CREATE INDEX "idx_operation_runs_batch" ON "operation_runs" USING btree ("batch_id"); \ No newline at end of file diff --git a/drizzle/postgres/meta/0078_snapshot.json b/drizzle/postgres/meta/0078_snapshot.json new file mode 100644 index 000000000..3caccb3c7 --- /dev/null +++ b/drizzle/postgres/meta/0078_snapshot.json @@ -0,0 +1,5343 @@ +{ + "id": "9ef48da0-f63a-4f05-9337-3aeb762e18d3", + "prevId": "4e428aad-48b3-42b7-a27c-aa136b6b4452", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_access": { + "name": "credential_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'use'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder_access": { + "name": "folder_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_access": { + "name": "host_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_defaults": { + "name": "host_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "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": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_defaults_scope_key": { + "name": "idx_host_defaults_scope_key", + "columns": [ + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_defaults_user": { + "name": "idx_host_defaults_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_defaults_folder": { + "name": "idx_host_defaults_folder", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_defaults_user_id_users_id_fk": { + "name": "host_defaults_user_id_users_id_fk", + "tableFrom": "host_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_defaults_folder_id_ssh_folders_id_fk": { + "name": "host_defaults_folder_id_ssh_folders_id_fk", + "tableFrom": "host_defaults", + "tableTo": "ssh_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_protocol_auth": { + "name": "host_protocol_auth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'direct'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_fields": { + "name": "secret_fields", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_data": { + "name": "ssh_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ssh_options": { + "name": "ssh_options", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 22 + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_overrides": { + "name": "default_overrides", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + { + "expression": "parent_host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operation_approvals": { + "name": "operation_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_self": { + "name": "allow_self", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_operation_approvals_status": { + "name": "idx_operation_approvals_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_operation_approvals_run": { + "name": "idx_operation_approvals_run", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operation_approvals_run_id_operation_runs_id_fk": { + "name": "operation_approvals_run_id_operation_runs_id_fk", + "tableFrom": "operation_approvals", + "tableTo": "operation_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operation_events": { + "name": "operation_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_operation_events_created": { + "name": "idx_operation_events_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_operation_events_host_created": { + "name": "idx_operation_events_host_created", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operation_runs": { + "name": "operation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "action_id": { + "name": "action_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_version": { + "name": "action_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_version": { + "name": "plugin_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "risk": { + "name": "risk", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changed": { + "name": "changed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sealed_input": { + "name": "sealed_input", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "undo_input": { + "name": "undo_input", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "undo_of": { + "name": "undo_of", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_operation_runs_created": { + "name": "idx_operation_runs_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_operation_runs_user_created": { + "name": "idx_operation_runs_user_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_operation_runs_host_created": { + "name": "idx_operation_runs_host_created", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_operation_runs_status": { + "name": "idx_operation_runs_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_operation_runs_batch": { + "name": "idx_operation_runs_batch", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operation_runs_user_id_users_id_fk": { + "name": "operation_runs_user_id_users_id_fk", + "tableFrom": "operation_runs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_migrations": { + "name": "plugin_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "migration_id": { + "name": "migration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_permission_grants": { + "name": "plugin_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "capability": { + "name": "capability", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "capability", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_registries": { + "name": "plugin_registries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'community'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_settings": { + "name": "plugin_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'community'" + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'disabled'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pinned_version": { + "name": "pinned_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stable'" + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + { + "expression": "registry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "role_name": { + "name": "role_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + { + "expression": "role_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_known_permissions": { + "name": "rbac_known_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_session_ref": { + "name": "external_session_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "restriction": { + "name": "restriction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idle_timeout": { + "name": "idle_timeout", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_credential_secrets": { + "name": "shared_credential_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'password'" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + { + "expression": "credential_access_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_folder_auth_overrides": { + "name": "shared_folder_auth_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "shared_folder_auth_overrides_user_folder_protocol_unique": { + "name": "shared_folder_auth_overrides_user_folder_protocol_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_folder_auth_overrides_user_id_users_id_fk": { + "name": "shared_folder_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_folder_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_folder_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_host_secrets": { + "name": "shared_host_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_fields": { + "name": "encrypted_fields", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + { + "expression": "host_access_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credential_usage": { + "name": "ssh_credential_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credentials": { + "name": "ssh_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_folders": { + "name": "ssh_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_conflicts": { + "name": "sync_conflicts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_revision": { + "name": "server_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_link": { + "name": "sync_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_records": { + "name": "sync_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sync_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trusted_devices": { + "name": "trusted_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ui_preferences": { + "name": "ui_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_external_identities": { + "name": "user_external_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_open_tabs": { + "name": "user_open_tabs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command_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 + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_second_factors": { + "name": "user_second_factors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "factor_id": { + "name": "factor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "factor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "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 + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index cd320dad6..95a703242 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -547,6 +547,13 @@ "when": 1793304928399, "tag": "0077_shared_folder_auth_overrides", "breakpoints": true + }, + { + "idx": 78, + "version": "7", + "when": 1793765382955, + "tag": "0078_clean_living_mummy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0073_dazzling_amazoness.sql b/drizzle/sqlite/0073_dazzling_amazoness.sql new file mode 100644 index 000000000..535d2c31c --- /dev/null +++ b/drizzle/sqlite/0073_dazzling_amazoness.sql @@ -0,0 +1,67 @@ +CREATE TABLE `operation_approvals` ( + `id` text PRIMARY KEY NOT NULL, + `run_id` text NOT NULL, + `requested_by` text, + `reason` text, + `allow_self` integer DEFAULT false NOT NULL, + `status` text NOT NULL, + `decided_by` text, + `comment` text, + `created_at` text NOT NULL, + `expires_at` text NOT NULL, + `decided_at` text, + FOREIGN KEY (`run_id`) REFERENCES `operation_runs`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `idx_operation_approvals_status` ON `operation_approvals` (`status`,`expires_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_approvals_run` ON `operation_approvals` (`run_id`);--> statement-breakpoint +CREATE TABLE `operation_events` ( + `id` text PRIMARY KEY NOT NULL, + `event_id` text NOT NULL, + `plugin_id` text NOT NULL, + `host_id` integer, + `severity` text, + `payload` text, + `run_id` text, + `created_at` text NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_operation_events_created` ON `operation_events` (`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_events_host_created` ON `operation_events` (`host_id`,`created_at`);--> statement-breakpoint +CREATE TABLE `operation_runs` ( + `id` text PRIMARY KEY NOT NULL, + `action_id` text NOT NULL, + `action_version` integer DEFAULT 1 NOT NULL, + `plugin_id` text NOT NULL, + `plugin_version` text NOT NULL, + `user_id` text, + `username` text NOT NULL, + `origin` text NOT NULL, + `host_id` integer, + `host_name` text, + `batch_id` text, + `dry_run` integer DEFAULT false NOT NULL, + `risk` text NOT NULL, + `status` text NOT NULL, + `summary` text, + `changed` integer, + `input` text, + `sealed_input` text, + `output` text, + `undo_input` text, + `error` text, + `error_code` text, + `undo_of` text, + `idempotency_key` text, + `created_at` text NOT NULL, + `started_at` text, + `finished_at` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `idx_operation_runs_created` ON `operation_runs` (`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_user_created` ON `operation_runs` (`user_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_host_created` ON `operation_runs` (`host_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_status` ON `operation_runs` (`status`);--> statement-breakpoint +CREATE INDEX `idx_operation_runs_batch` ON `operation_runs` (`batch_id`);--> statement-breakpoint +ALTER TABLE `api_keys` ADD `scopes` text; \ No newline at end of file diff --git a/drizzle/sqlite/meta/0073_snapshot.json b/drizzle/sqlite/meta/0073_snapshot.json new file mode 100644 index 000000000..2cd349010 --- /dev/null +++ b/drizzle/sqlite/meta/0073_snapshot.json @@ -0,0 +1,4978 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5ad2dc73-99c2-4514-ac7b-0fb1dc421890", + "prevId": "53940e39-d429-4ebe-acfd-496bc4144fa1", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + "action", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + "resource_type", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credential_access": { + "name": "credential_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'use'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "folder_access": { + "name": "folder_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + "owner_user_id", + "folder" + ], + "isUnique": false + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_defaults": { + "name": "host_defaults", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "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": false, + "autoincrement": false + }, + "updated_by": { + "name": "updated_by", + "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_host_defaults_scope_key": { + "name": "idx_host_defaults_scope_key", + "columns": [ + "scope_key", + "namespace", + "key" + ], + "isUnique": true + }, + "idx_host_defaults_user": { + "name": "idx_host_defaults_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_defaults_folder": { + "name": "idx_host_defaults_folder", + "columns": [ + "folder_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_defaults_user_id_users_id_fk": { + "name": "host_defaults_user_id_users_id_fk", + "tableFrom": "host_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_defaults_folder_id_ssh_folders_id_fk": { + "name": "host_defaults_folder_id_ssh_folders_id_fk", + "tableFrom": "host_defaults", + "tableTo": "ssh_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_protocol_auth": { + "name": "host_protocol_auth", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_fields": { + "name": "secret_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + "host_id", + "protocol" + ], + "isUnique": true + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_options": { + "name": "ssh_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "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 + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "use_socks5": { + "name": "use_socks5", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_overrides": { + "name": "default_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + "parent_host_id" + ], + "isUnique": false + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "operation_approvals": { + "name": "operation_approvals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_self": { + "name": "allow_self", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decided_at": { + "name": "decided_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_operation_approvals_status": { + "name": "idx_operation_approvals_status", + "columns": [ + "status", + "expires_at" + ], + "isUnique": false + }, + "idx_operation_approvals_run": { + "name": "idx_operation_approvals_run", + "columns": [ + "run_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "operation_approvals_run_id_operation_runs_id_fk": { + "name": "operation_approvals_run_id_operation_runs_id_fk", + "tableFrom": "operation_approvals", + "tableTo": "operation_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "operation_events": { + "name": "operation_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_operation_events_created": { + "name": "idx_operation_events_created", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_operation_events_host_created": { + "name": "idx_operation_events_host_created", + "columns": [ + "host_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "operation_runs": { + "name": "operation_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "action_id": { + "name": "action_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_version": { + "name": "action_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_version": { + "name": "plugin_version", + "type": "text", + "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": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "risk": { + "name": "risk", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed": { + "name": "changed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sealed_input": { + "name": "sealed_input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undo_input": { + "name": "undo_input", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undo_of": { + "name": "undo_of", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_operation_runs_created": { + "name": "idx_operation_runs_created", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_operation_runs_user_created": { + "name": "idx_operation_runs_user_created", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + }, + "idx_operation_runs_host_created": { + "name": "idx_operation_runs_host_created", + "columns": [ + "host_id", + "created_at" + ], + "isUnique": false + }, + "idx_operation_runs_status": { + "name": "idx_operation_runs_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_operation_runs_batch": { + "name": "idx_operation_runs_batch", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "operation_runs_user_id_users_id_fk": { + "name": "operation_runs_user_id_users_id_fk", + "tableFrom": "operation_runs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_install_counts": { + "name": "plugin_install_counts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registry_id": { + "name": "registry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "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 + }, + "pinned_version": { + "name": "pinned_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stable'" + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + "registry_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + "role_name", + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rbac_known_permissions": { + "name": "rbac_known_permissions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_session_ref": { + "name": "external_session_ref", + "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_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "restriction": { + "name": "restriction", + "type": "text(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idle_timeout": { + "name": "idle_timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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_folder_auth_overrides": { + "name": "shared_folder_auth_overrides", + "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 + }, + "folder": { + "name": "folder", + "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_folder_auth_overrides_user_folder_protocol_unique": { + "name": "shared_folder_auth_overrides_user_folder_protocol_unique", + "columns": [ + "user_id", + "folder", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_folder_auth_overrides_user_id_users_id_fk": { + "name": "shared_folder_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_folder_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_folder_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + "host_id", + "user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_fields": { + "name": "encrypted_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_conflicts": { + "name": "sync_conflicts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_revision": { + "name": "server_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_link": { + "name": "sync_link", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_records": { + "name": "sync_records", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + "user_id", + "entity_type", + "sync_id" + ], + "isUnique": true + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + "user_id", + "seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_devices": { + "name": "trusted_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_external_identities": { + "name": "user_external_identities", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + "provider_id", + "subject" + ], + "isUnique": true + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_open_tabs": { + "name": "user_open_tabs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_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 + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + "user_id", + "role_id" + ], + "isUnique": true + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_second_factors": { + "name": "user_second_factors", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "factor_id": { + "name": "factor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + "user_id", + "plugin_id", + "factor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index d001624cd..256052e97 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -512,6 +512,13 @@ "when": 1793304926942, "tag": "0072_shared_folder_auth_overrides", "breakpoints": true + }, + { + "idx": 73, + "version": "6", + "when": 1793765382283, + "tag": "0073_dazzling_amazoness", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 68659a958..e37af6b2c 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.0.23 + +Plugin API 1.14.0. + +### Added + +- `contributes.operations` declares a plugin's actions, events, resources and signals, so Automations, Fleets, the assistant, the CLI, the phone app and the command palette can find them with no wiring +- `ctx.operations` registers handlers, runs other plugins' declared actions, emits events and provides approval rules, with the new `operations:provide`, `operations:invoke` and `operations:approve` capabilities +- `@termix-ssh/plugin-sdk/operations` has the types, input checks, redaction and JSON Schema for declared actions +- `contributes.hostSections` declares cards for the host page + ## 1.0.22 Plugin API 1.13.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 7ac4df056..d1e1789d9 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -1,6 +1,6 @@ # Termix plugin guide -SDK 1.0.22 | Plugin API 1.13.0 +SDK 1.0.23 | Plugin API 1.14.0 This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. @@ -414,6 +414,70 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 }); - Use a service: declare it in `requires`, then `ctx.services.get("snippets.access")`. It returns nothing while no provider runs, so always handle that. +### Operations + +Declare what your plugin can do in `contributes.operations`, and it shows up in Automations, Fleets, the +assistant, the CLI, the phone app and the command palette with no other wiring. It also gets per host results, +dry runs, run history, approvals and drift checks. + +```json +"capabilities": ["operations:provide"], +"engine": { "termix": ">=26.11.1-0", "api": "^1.13" }, +"contributes": { + "operations": { + "actions": [{ + "id": "demo.restart", + "titleKey": "ops.restart.title", + "scope": "host", + "inputs": [{ "key": "name", "type": "string", "labelKey": "ops.restart.name", "required": true }], + "risk": "change", + "permission": "use", + "hostAccess": "edit", + "dryRun": "describe" + }] + } +} +``` + +```ts +ctx.operations.register("demo.restart", { + describe: (input) => `Would restart ${input.name}`, + run: async (input, { hostId, signal, log }) => { + return { summary: `Restarted ${input.name}`, changed: true }; + }, +}); +``` + +- Actions: `scope` is `host`, `resource` or `server`. `risk` is `read`, `change` or `destructive`. Host actions declare + `hostAccess`. Use `edit` for changes unless people with `connect` already do this today. A destructive action + must use `edit` or `manage` and needs a confirm. +- Inputs use the settings field types, plus `required`, `pattern`, `optionsFrom` (a read action fills the choices) + and type `resource`. Keys you did not declare are refused before your handler runs. `secret` values reach the handler + but are never stored or shown. Mark private outputs `sensitive`. +- `dryRun`: `native` calls `run` with `dryRun: true` and nothing may change. `describe` calls `describe` instead. +- `check` names a read action that proves the result, used for drift. `undo` names the reverse action, and `run` returns + `undoInput`. `idempotent` lets a rollout retry it. `exclusive` allows one at a time per host. `when` limits it to + hosts by setting or OS. +- `surfaces`: the assistant and MCP are off until you set them true. Only do that for actions safe for a model to + ask for. The rest are on unless you set them false. +- Resources describe a kind of thing, such as a container: a read action on one host returns `{ rows }` as `data`, and + core draws the All hosts table and host page section. Attach actions with `scope: "resource"`. +- Signals are status tiles: a read action returns `{ value, status, label }` as `data`, with status `ok`, `warn`, `fail` or + `unknown`. `fix` names the action offered when it is not ok. Keep these reads cheap, they run for every host. +- Events: declare them, then call `ctx.operations.emit(id, payload, { hostId })`. +- Handlers must watch `signal` and stop when it fires. Throw on failure; a failed run never counts against your + plugin's error budget. Throw an error with `code = "NEEDS_INTERACTION"` when a host needs a person to sign in first. +- Handlers have no request and no session. Take what you need from the input and `hostId`. +- Keep your REST routes. Inside one, call `ctx.operations.run(id, { hostId, input })` so a button press goes through + the same checks and lands in the same history. +- To run another plugin's action, add `operations:invoke`, list it in `operations.invokes` and call + `ctx.operations.invoke(id, { hostId, input, via: "automation" })`. It acts as the current user and rules still apply. +- `risk` is your plugin's claim. Plugins run in-process, so this is not a sandbox. Actions from plugins not signed as + official count as `change` when rules decide what needs a person, and stay off the assistant and MCP until an admin + allows them. +- Set `engine.termix` to at least `>=26.11.1-0`. Keep `engine.api` as it was unless you import something new from + `/ui`, or the phone app will ask people to update to see your plugin. + ### Shared packages These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`, @@ -962,6 +1026,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie | `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders | | `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks | | `@termix-ssh/plugin-sdk/capabilities` | The capability catalog | +| `@termix-ssh/plugin-sdk/operations` | Types, input checks and JSON Schema for declared actions | | `@termix-ssh/plugin-sdk/settings` | Settings field validation | | `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` | | `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` | @@ -993,6 +1058,8 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie | `desktop:local` | high | Use the user's own computer, desktop app only | | `hosts:export` | high | Export hosts with their saved logins | | `notify:hub` | high | Receive every alert (the alerts hub only) | +| `operations:invoke` | high | Run other plugins' declared actions, listed in `invokes` | +| `operations:approve` | high | Decide which actions need a person | | `ui:layout` | high | Replace the whole app layout | | `hosts:write` | medium | Add and edit hosts | | `hosts:status-provide` | medium | Be the source of host status dots | @@ -1008,6 +1075,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie | `users:read` | medium | Usernames, roles and UI language | | `events:core` | medium | Listen to core events | | `notify:send` | medium | Send alerts | +| `operations:provide` | medium | Offer declared actions and events | | `audit:read` | medium | Read the audit log | | `desktop:window` | medium | Open windows in the desktop app | | `hosts:read` | low | Read hosts the user can see | diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index b85d7da8a..8f43f72ed 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.22", + "version": "1.0.23", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { @@ -61,6 +61,10 @@ "types": "./dist/openapi.d.ts", "default": "./dist/openapi.js" }, + "./operations": { + "types": "./dist/operations.d.ts", + "default": "./dist/operations.js" + }, "./settings": { "types": "./dist/settings.d.ts", "default": "./dist/settings.js" diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index f1b8d76aa..d4ca15aa0 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -536,6 +536,31 @@ } } }, + "hostSections": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "titleKey"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "titleKey": { "type": "string", "minLength": 1 }, + "icon": { "type": "string", "minLength": 1 } + } + } + }, + "operations": { + "type": "object", + "description": "What this plugin can do. Every surface finds these on its own. See the operations section of PLUGIN-GUIDE.md.", + "additionalProperties": false, + "properties": { + "actions": { "type": "array", "items": { "type": "object" } }, + "events": { "type": "array", "items": { "type": "object" } }, + "resources": { "type": "array", "items": { "type": "object" } }, + "signals": { "type": "array", "items": { "type": "object" } }, + "invokes": { "type": "array", "items": { "type": "string" } } + } + }, "dashboardCards": { "type": "array", "items": { diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index bef15d30c..d6e685a14 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -14,6 +14,11 @@ import type { HostSshOptions } from "./ssh-options.js"; export type { HostSshOptions } from "./ssh-options.js"; import type { PluginManifest, PluginSettingsField } from "./manifest.js"; import type { PluginTableDefinition } from "./db.js"; +import type { + OperationResult, + OperationRisk, + OperationSurfaces, +} from "./operations.js"; /** * Thrown when a plugin calls a guarded member without the capability. @@ -3104,6 +3109,136 @@ export interface PluginPlugins { list: () => Promise; } +/** What a handler gets besides its input. */ +export interface PluginOperationContext { + hostId?: number; + /** The row a resource action targets. */ + resourceKey?: string; + /** Null for a background read. */ + runId: string | null; + /** Nothing may change when this is set. */ + dryRun: boolean; + /** Fires when the run is cancelled, times out or the plugin stops. */ + signal: AbortSignal; + log: (line: string) => void; +} + +export interface PluginOperationHandler { + run: ( + input: Record, + context: PluginOperationContext, + ) => Promise | OperationResult; + /** The dry run text for an action declared with dryRun "describe". */ + describe?: ( + input: Record, + context: PluginOperationContext, + ) => string | Promise; +} + +export interface PluginOperationInvokeOptions { + hostId?: number; + resourceKey?: string; + input?: Record; + dryRun?: boolean; + /** A person agreed to a destructive action. */ + confirmed?: boolean; + idempotencyKey?: string; + batchId?: string; + /** What this plugin is doing: automation, assistant, mcp, rollout and so on. */ + via?: string; +} + +export interface PluginOperationOutcome { + runId: string | null; + status: string; + summary?: string; + changed?: boolean; + data?: unknown; + error?: string; + errorCode?: string; + approvalId?: string; +} + +export interface PluginOperationApprovalInput { + actionId: string; + pluginId: string; + risk: OperationRisk; + /** What rules should use. Plugins not signed as official count as change. */ + effectiveRisk: OperationRisk; + origin: { kind: string; pluginId?: string; via?: string }; + userId: string; + hostId?: number; + hostTags?: string[]; + batchId?: string; +} + +export interface PluginOperationApprovalDecision { + decision: "allow" | "approve" | "deny"; + reason?: string; + allowSelf?: boolean; + expiresInMinutes?: number; +} + +export interface PluginOperationEvent { + id: string; + eventId: string; + pluginId: string; + hostId?: number; + severity?: string; + payload?: unknown; + runId?: string; + depth: number; + at: string; +} + +/** + * Declared actions and events. See contributes.operations in the manifest. + * register and emit need operations:provide. invoke, list and onEvent need + * operations:invoke, and invoke only reaches ids in the manifest's invokes list. + */ +export interface PluginOperations { + register: (id: string, handler: PluginOperationHandler) => void; + /** + * Runs one of this plugin's own actions from its route, so a button press + * goes through the same checks and lands in the same history. + */ + run: ( + actionId: string, + options?: PluginOperationInvokeOptions, + ) => Promise; + invoke: ( + actionId: string, + options?: PluginOperationInvokeOptions, + ) => Promise; + list: (options?: { + lang?: string; + surface?: keyof OperationSurfaces; + }) => Promise; + emit: ( + eventId: string, + payload?: unknown, + options?: { hostId?: number }, + ) => Promise; + onEvent: (subscription: { + pattern: string; + handler: (event: PluginOperationEvent) => void | Promise; + userId?: string; + cooldownMs?: number; + }) => () => void; + /** Rules for what needs a person. Needs operations:approve. */ + approvals: { + provide: ( + provider: ( + input: PluginOperationApprovalInput, + ) => + | PluginOperationApprovalDecision + | null + | undefined + | Promise, + ) => () => void; + }; +} + export interface PluginContext { readonly pluginId: string; readonly manifest: PluginManifest; @@ -3144,6 +3279,8 @@ export interface PluginContext { readonly schedule: PluginSchedule; /** Core's notification channels. Needs notify:send. */ readonly notify: PluginNotify; + /** Declared actions and events, reachable from every surface. */ + readonly operations: PluginOperations; /** Outbound HTTP through core's SSRF guard. Needs network:outbound. */ readonly fetch: PluginFetch; /** Plain reachability checks. Needs network:probe. */ diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index e03def44c..3855fe589 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -52,6 +52,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("ssh:transport", "high"), entry("device:serial", "high"), entry("notify:hub", "high"), + entry("operations:invoke", "high"), + entry("operations:approve", "high"), entry("ui:layout", "high"), entry("hosts:write", "medium"), @@ -68,6 +70,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("users:read", "medium"), entry("events:core", "medium"), entry("notify:send", "medium"), + entry("operations:provide", "medium"), entry("audit:read", "medium"), entry("desktop:window", "medium"), entry("desktop:local", "high"), diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 9ae79dcc2..023a61adb 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -12,6 +12,11 @@ import semver from "semver"; import { isKnownCapability } from "./capabilities.js"; +import { + checkOperationReferences, + validateOperations, + type PluginOperationsContribution, +} from "./operations.js"; import { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, @@ -37,7 +42,7 @@ export { * "^1.2" needs 1.2 or later). engine.termix is checked against the core * version by the server. */ -export const PLUGIN_API_VERSION = "1.13.0"; +export const PLUGIN_API_VERSION = "1.14.0"; /** The API major, the default engine.api for a new plugin. */ export const SUPPORTED_PLUGIN_API_VERSION = String( @@ -465,6 +470,10 @@ export interface PluginContributions { tabs?: PluginTabContribution[]; panels?: PluginViewContribution[]; dashboardCards?: PluginViewContribution[]; + /** Cards on the host page, filled with app.registerHostSection. */ + hostSections?: PluginViewContribution[]; + /** What this plugin can do, for every surface to find on its own. */ + operations?: PluginOperationsContribution; /** * The frontend also runs on anonymous guest pages (shared-session and * collab links), where it is activated with app.guest set. @@ -779,6 +788,8 @@ const ALLOWED_CONTRIBUTES = new Set([ "tabs", "panels", "dashboardCards", + "hostSections", + "operations", "guest", "guestViews", "actions", @@ -1500,6 +1511,8 @@ function validateContributes( validateTabs(contributes.tabs, errors); validateViews(contributes.panels, "panels", errors); validateViews(contributes.dashboardCards, "dashboardCards", errors); + validateViews(contributes.hostSections, "hostSections", errors); + validateOperations(contributes.operations, pluginId, errors); if ( contributes.guest !== undefined && typeof contributes.guest !== "boolean" @@ -2622,6 +2635,12 @@ export function parseManifest(raw: unknown): ParsedManifest { ); } } + checkOperationReferences( + manifest.contributes?.operations, + manifest.capabilities, + (name) => declared.has(qualifyPermission(manifest.id, name)), + errors, + ); // A settings field may gate on one of this plugin's own permissions, or on // a core/other-plugin id used as given. Only the first form is checkable // here, and an undeclared one would hide the field from every admin. diff --git a/packages/plugin-sdk/src/operations.ts b/packages/plugin-sdk/src/operations.ts new file mode 100644 index 000000000..149ebd85d --- /dev/null +++ b/packages/plugin-sdk/src/operations.ts @@ -0,0 +1,951 @@ +/** + * The operations contract: what a plugin declares it can do, in one shape every + * surface reads (Automations, Fleets, AI, CLI, Mobile, palette, API, MCP). + * + * Declarations are data in the manifest, so a surface can list and describe an + * action without loading the plugin's code. Handlers are registered at + * activation with ctx.operations.register and must match a declaration. + * + * No Node imports: the frontend, the CLI and Mobile read this file too. + */ + +import { coerceSettingValue, validateSettingValue } from "./settings.js"; +import type { PluginSettingsField, PluginSettingsOption } from "./manifest.js"; + +export const OPERATION_RISKS = ["read", "change", "destructive"] as const; +export type OperationRisk = (typeof OPERATION_RISKS)[number]; + +export const OPERATION_SCOPES = ["host", "resource", "server"] as const; +export type OperationScope = (typeof OPERATION_SCOPES)[number]; + +export const OPERATION_HOST_ACCESS = [ + "connect", + "view", + "edit", + "manage", +] as const; +export type OperationHostAccess = (typeof OPERATION_HOST_ACCESS)[number]; + +export const OPERATION_DRY_RUN = ["native", "describe"] as const; +/** native: the handler checks ctx.dryRun. describe: core reports its text. */ +export type OperationDryRun = (typeof OPERATION_DRY_RUN)[number]; + +export const OPERATION_FIELD_TYPES = [ + "boolean", + "string", + "number", + "select", + "multiselect", + "secret", + "textarea", + "json", + "resource", +] as const; +export type OperationFieldType = (typeof OPERATION_FIELD_TYPES)[number]; + +export const OPERATION_SEVERITIES = ["info", "warning", "critical"] as const; +export type OperationSeverity = (typeof OPERATION_SEVERITIES)[number]; + +export const SIGNAL_KINDS = ["count", "gauge", "status", "list"] as const; +export type SignalKind = (typeof SIGNAL_KINDS)[number]; + +export const SIGNAL_STATUSES = ["ok", "warn", "fail", "unknown"] as const; +export type SignalStatus = (typeof SIGNAL_STATUSES)[number]; + +export const RESOURCE_COLUMN_TYPES = [ + "text", + "status", + "bytes", + "percent", + "time", + "tags", +] as const; +export type ResourceColumnType = (typeof RESOURCE_COLUMN_TYPES)[number]; + +/** One input or output of an action. A subset of a settings field, plus rules. */ +export interface OperationField { + key: string; + type: OperationFieldType; + labelKey?: string; + descriptionKey?: string; + placeholderKey?: string; + default?: unknown; + options?: PluginSettingsOption[]; + min?: number; + max?: number; + /** Key of a boolean field in the same list that must be on. */ + requires?: string; + group?: string; + required?: boolean; + /** string and textarea only. A regular expression the whole value matches. */ + pattern?: string; + /** Read action whose rows fill the choices, for a list known only at run time. */ + optionsFrom?: string; + /** type "resource" only: the resource id whose list fills the picker. */ + resource?: string; + /** Outputs only: dropped before a run is stored or shown. */ + sensitive?: boolean; +} + +export interface OperationWhen { + /** Host setting values that must match, such as { enableDocker: true }. */ + settings?: Record; + /** Lowercase OS names. A host with no known OS passes. */ + os?: string[]; +} + +export interface OperationSurfaces { + /** Offered to the AI assistant. Off unless set. */ + assistant?: boolean; + /** Offered over MCP. Off unless set. */ + mcp?: boolean; + /** Set false to keep out of the command palette. */ + palette?: boolean; + /** Set false to keep out of Automations. */ + automations?: boolean; + /** Set false to keep out of the phone app. */ + mobile?: boolean; + /** Set false to keep out of the CLI. */ + cli?: boolean; +} + +export interface OperationAction { + /** `.`. */ + id: string; + /** Bump when inputs change in a way saved references cannot follow. */ + version?: number; + titleKey: string; + descriptionKey?: string; + icon?: string; + scope: OperationScope; + /** Required for scope "resource": the resource this acts on. */ + resource?: string; + inputs?: OperationField[]; + outputs?: OperationField[]; + risk: OperationRisk; + /** One of this plugin's own permissions, short or full. */ + permission: string; + /** Lowest share level. Required for host and resource scopes. */ + hostAccess?: OperationHostAccess; + dryRun?: OperationDryRun; + /** A read action of this plugin that proves the result. */ + check?: string; + /** An action of this plugin that reverses this one. */ + undo?: string; + /** Safe to run twice, so a rollout may retry it. */ + idempotent?: boolean; + /** Only one such action per host at a time. */ + exclusive?: boolean; + timeoutMs?: number; + when?: OperationWhen; + surfaces?: OperationSurfaces; +} + +export interface OperationEvent { + id: string; + titleKey: string; + descriptionKey?: string; + severity?: OperationSeverity; + scope: "host" | "server"; + payload?: OperationField[]; +} + +export interface ResourceColumn { + key: string; + titleKey: string; + type?: ResourceColumnType; +} + +export interface OperationResource { + id: string; + titleKey: string; + icon?: string; + /** Read action that returns { rows: [...] } for one host. */ + list: string; + /** Row field that is unique on a host. */ + key: string; + /** Row field shown as the name. */ + name: string; + /** Row field holding a status word. */ + status?: string; + /** Row field holding a link, for auto links on the homepage. */ + url?: string; + /** Tab type that opens this on a host. */ + tab?: string; + columns?: ResourceColumn[]; + filters?: string[]; +} + +export interface OperationSignal { + id: string; + titleKey: string; + /** Read action that returns a SignalResult. */ + read: string; + scope: "host" | "server"; + kind: SignalKind; + refreshSec?: number; + /** Action offered as the fix when this signal is not ok. */ + fix?: string; +} + +export interface PluginOperationsContribution { + actions?: OperationAction[]; + events?: OperationEvent[]; + resources?: OperationResource[]; + signals?: OperationSignal[]; + /** + * Actions this plugin may run through ctx.operations.invoke: exact ids, + * `.*`, or `*`. + */ + invokes?: string[]; +} + +/** What a signal's read action returns. */ +export interface SignalResult { + value?: number | string | null; + status: SignalStatus; + label?: string; + items?: { label: string; status?: SignalStatus }[]; + link?: { tab?: string; data?: Record }; +} + +/** What a resource's list action returns. */ +export interface ResourceListResult { + rows: Record[]; +} + +/** What a handler returns. */ +export interface OperationResult { + /** One plain line for history and per host results. */ + summary: string; + /** Whether anything actually changed. */ + changed?: boolean; + data?: unknown; + /** Input for the `undo` action that reverses this exact run. */ + undoInput?: Record; +} + +export const OPERATION_ID_PATTERN = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/; + +const FIELD_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/; +const INVOKE_PATTERN = /^(\*|[a-z0-9][a-z0-9-]*\.(\*|[a-z0-9][a-z0-9-]*))$/; + +export const MAX_OPERATION_TIMEOUT_MS = 3_600_000; +export const REDACTED_VALUE = "[redacted]"; + +const ALLOWED_OPERATIONS = [ + "actions", + "events", + "resources", + "signals", + "invokes", +]; +const ALLOWED_ACTION = [ + "id", + "version", + "titleKey", + "descriptionKey", + "icon", + "scope", + "resource", + "inputs", + "outputs", + "risk", + "permission", + "hostAccess", + "dryRun", + "check", + "undo", + "idempotent", + "exclusive", + "timeoutMs", + "when", + "surfaces", +]; +const ALLOWED_FIELD = [ + "key", + "type", + "labelKey", + "descriptionKey", + "placeholderKey", + "default", + "options", + "min", + "max", + "requires", + "group", + "required", + "pattern", + "optionsFrom", + "resource", + "sensitive", +]; +const ALLOWED_EVENT = [ + "id", + "titleKey", + "descriptionKey", + "severity", + "scope", + "payload", +]; +const ALLOWED_RESOURCE = [ + "id", + "titleKey", + "icon", + "list", + "key", + "name", + "status", + "url", + "tab", + "columns", + "filters", +]; +const ALLOWED_SIGNAL = [ + "id", + "titleKey", + "read", + "scope", + "kind", + "refreshSec", + "fix", +]; +const ALLOWED_SURFACES = [ + "assistant", + "mcp", + "palette", + "automations", + "mobile", + "cli", +]; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function rejectUnknown( + raw: Record, + allowed: string[], + where: string, + errors: string[], +): void { + for (const key of Object.keys(raw)) { + if (!allowed.includes(key)) + errors.push(`Unknown field "${key}" in ${where}`); + } +} + +function requireEnum( + value: unknown, + allowed: readonly string[], + where: string, + errors: string[], +): void { + if (typeof value !== "string" || !allowed.includes(value)) { + errors.push(`${where} must be one of: ${allowed.join(", ")}`); + } +} + +function requireText(value: unknown, where: string, errors: string[]): void { + if (typeof value !== "string" || value.trim().length === 0) { + errors.push(`${where} must be a non-empty string`); + } +} + +function optionalText(value: unknown, where: string, errors: string[]): void { + if (value !== undefined) requireText(value, where, errors); +} + +function optionalBoolean( + value: unknown, + where: string, + errors: string[], +): void { + if (value !== undefined && typeof value !== "boolean") { + errors.push(`${where} must be a boolean`); + } +} + +function requireId( + value: unknown, + pluginId: string | undefined, + where: string, + seen: Set, + errors: string[], +): void { + if (typeof value !== "string" || !OPERATION_ID_PATTERN.test(value)) { + errors.push(`${where}.id must look like "." in lowercase`); + return; + } + if (pluginId && !value.startsWith(`${pluginId}.`)) { + errors.push(`${where}.id must start with "${pluginId}."`); + } + if (seen.has(value)) errors.push(`${where}.id "${value}" is declared twice`); + seen.add(value); +} + +function eachObject( + value: unknown, + where: string, + errors: string[], + each: (raw: Record, at: string) => void, +): void { + if (value === undefined) return; + if (!Array.isArray(value)) { + errors.push(`${where} must be an array`); + return; + } + value.forEach((raw, index) => { + const at = `${where}[${index}]`; + if (!isObject(raw)) { + errors.push(`${at} must be an object`); + return; + } + each(raw, at); + }); +} + +function validateFields(value: unknown, where: string, errors: string[]): void { + const keys = new Set(); + eachObject(value, where, errors, (raw, at) => { + rejectUnknown(raw, ALLOWED_FIELD, at, errors); + if (typeof raw.key !== "string" || !FIELD_KEY_PATTERN.test(raw.key)) { + errors.push(`${at}.key must be a short name starting with a letter`); + } else if (keys.has(raw.key)) { + errors.push(`${at}.key "${raw.key}" is declared twice`); + } else { + keys.add(raw.key); + } + requireEnum(raw.type, OPERATION_FIELD_TYPES, `${at}.type`, errors); + optionalText(raw.labelKey, `${at}.labelKey`, errors); + optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors); + optionalText(raw.placeholderKey, `${at}.placeholderKey`, errors); + optionalText(raw.group, `${at}.group`, errors); + optionalText(raw.requires, `${at}.requires`, errors); + optionalText(raw.optionsFrom, `${at}.optionsFrom`, errors); + optionalBoolean(raw.required, `${at}.required`, errors); + optionalBoolean(raw.sensitive, `${at}.sensitive`, errors); + if (raw.type === "resource" && typeof raw.resource !== "string") { + errors.push(`${at}.resource is required for type "resource"`); + } + if (raw.resource !== undefined && raw.type !== "resource") { + errors.push(`${at}.resource needs type "resource"`); + } + if (raw.type === "secret" && raw.default !== undefined) { + errors.push(`${at}.default is not allowed on a secret`); + } + if ( + (raw.type === "select" || raw.type === "multiselect") && + !raw.optionsFrom && + (!Array.isArray(raw.options) || raw.options.length === 0) + ) { + errors.push(`${at} needs options or optionsFrom`); + } + for (const bound of ["min", "max"] as const) { + if (raw[bound] !== undefined && typeof raw[bound] !== "number") { + errors.push(`${at}.${bound} must be a number`); + } + } + if (raw.pattern !== undefined) { + if (typeof raw.pattern !== "string") { + errors.push(`${at}.pattern must be a string`); + } else { + try { + new RegExp(raw.pattern); + } catch { + errors.push(`${at}.pattern is not a valid regular expression`); + } + } + } + }); +} + +function validateWhen(value: unknown, at: string, errors: string[]): void { + if (value === undefined) return; + if (!isObject(value)) { + errors.push(`${at} must be an object`); + return; + } + rejectUnknown(value, ["settings", "os"], at, errors); + if (value.settings !== undefined && !isObject(value.settings)) { + errors.push(`${at}.settings must be an object`); + } + if ( + value.os !== undefined && + (!Array.isArray(value.os) || + value.os.some((entry) => typeof entry !== "string")) + ) { + errors.push(`${at}.os must be an array of strings`); + } +} + +function validateAction( + raw: Record, + at: string, + errors: string[], +): void { + rejectUnknown(raw, ALLOWED_ACTION, at, errors); + requireText(raw.titleKey, `${at}.titleKey`, errors); + optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors); + optionalText(raw.icon, `${at}.icon`, errors); + requireEnum(raw.scope, OPERATION_SCOPES, `${at}.scope`, errors); + requireEnum(raw.risk, OPERATION_RISKS, `${at}.risk`, errors); + requireText(raw.permission, `${at}.permission`, errors); + validateFields(raw.inputs, `${at}.inputs`, errors); + validateFields(raw.outputs, `${at}.outputs`, errors); + validateWhen(raw.when, `${at}.when`, errors); + optionalBoolean(raw.idempotent, `${at}.idempotent`, errors); + optionalBoolean(raw.exclusive, `${at}.exclusive`, errors); + + if ( + raw.version !== undefined && + (!Number.isInteger(raw.version) || (raw.version as number) < 1) + ) { + errors.push(`${at}.version must be a whole number from 1`); + } + if (raw.dryRun !== undefined) { + requireEnum(raw.dryRun, OPERATION_DRY_RUN, `${at}.dryRun`, errors); + } + if ( + raw.timeoutMs !== undefined && + (typeof raw.timeoutMs !== "number" || + raw.timeoutMs <= 0 || + raw.timeoutMs > MAX_OPERATION_TIMEOUT_MS) + ) { + errors.push(`${at}.timeoutMs must be above 0 and at most 1 hour`); + } + if (raw.scope === "resource" && typeof raw.resource !== "string") { + errors.push(`${at}.resource is required for scope "resource"`); + } + if (raw.scope !== "resource" && raw.resource !== undefined) { + errors.push(`${at}.resource needs scope "resource"`); + } + if (raw.scope === "server" && raw.hostAccess !== undefined) { + errors.push(`${at}.hostAccess does not apply to scope "server"`); + } + if (raw.scope === "host" || raw.scope === "resource") { + requireEnum( + raw.hostAccess, + OPERATION_HOST_ACCESS, + `${at}.hostAccess`, + errors, + ); + if ( + raw.risk === "destructive" && + (raw.hostAccess === "connect" || raw.hostAccess === "view") + ) { + errors.push(`${at}.hostAccess must be edit or manage when destructive`); + } + } + if (raw.surfaces !== undefined) { + if (!isObject(raw.surfaces)) { + errors.push(`${at}.surfaces must be an object`); + } else { + rejectUnknown(raw.surfaces, ALLOWED_SURFACES, `${at}.surfaces`, errors); + for (const [key, value] of Object.entries(raw.surfaces)) { + optionalBoolean(value, `${at}.surfaces.${key}`, errors); + } + } + } +} + +function validateResource( + raw: Record, + at: string, + errors: string[], +): void { + rejectUnknown(raw, ALLOWED_RESOURCE, at, errors); + requireText(raw.titleKey, `${at}.titleKey`, errors); + requireText(raw.list, `${at}.list`, errors); + requireText(raw.key, `${at}.key`, errors); + requireText(raw.name, `${at}.name`, errors); + optionalText(raw.icon, `${at}.icon`, errors); + optionalText(raw.status, `${at}.status`, errors); + optionalText(raw.url, `${at}.url`, errors); + optionalText(raw.tab, `${at}.tab`, errors); + eachObject(raw.columns, `${at}.columns`, errors, (column, where) => { + rejectUnknown(column, ["key", "titleKey", "type"], where, errors); + requireText(column.key, `${where}.key`, errors); + requireText(column.titleKey, `${where}.titleKey`, errors); + if (column.type !== undefined) { + requireEnum(column.type, RESOURCE_COLUMN_TYPES, `${where}.type`, errors); + } + }); + if ( + raw.filters !== undefined && + (!Array.isArray(raw.filters) || + raw.filters.some((entry) => typeof entry !== "string")) + ) { + errors.push(`${at}.filters must be an array of strings`); + } +} + +/** Shape checks for contributes.operations, run by validateManifest. */ +export function validateOperations( + value: unknown, + pluginId: string | undefined, + errors: string[], +): void { + if (value === undefined) return; + const root = "contributes.operations"; + if (!isObject(value)) { + errors.push(`Field "${root}" must be an object`); + return; + } + rejectUnknown(value, ALLOWED_OPERATIONS, `"${root}"`, errors); + + const ids = new Set(); + eachObject(value.actions, `${root}.actions`, errors, (raw, at) => { + requireId(raw.id, pluginId, at, ids, errors); + validateAction(raw, at, errors); + }); + + const eventIds = new Set(); + eachObject(value.events, `${root}.events`, errors, (raw, at) => { + requireId(raw.id, pluginId, at, eventIds, errors); + rejectUnknown(raw, ALLOWED_EVENT, at, errors); + requireText(raw.titleKey, `${at}.titleKey`, errors); + optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors); + requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors); + if (raw.severity !== undefined) { + requireEnum(raw.severity, OPERATION_SEVERITIES, `${at}.severity`, errors); + } + validateFields(raw.payload, `${at}.payload`, errors); + }); + + const resourceIds = new Set(); + eachObject(value.resources, `${root}.resources`, errors, (raw, at) => { + requireId(raw.id, pluginId, at, resourceIds, errors); + validateResource(raw, at, errors); + }); + + const signalIds = new Set(); + eachObject(value.signals, `${root}.signals`, errors, (raw, at) => { + requireId(raw.id, pluginId, at, signalIds, errors); + rejectUnknown(raw, ALLOWED_SIGNAL, at, errors); + requireText(raw.titleKey, `${at}.titleKey`, errors); + requireText(raw.read, `${at}.read`, errors); + requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors); + requireEnum(raw.kind, SIGNAL_KINDS, `${at}.kind`, errors); + optionalText(raw.fix, `${at}.fix`, errors); + if ( + raw.refreshSec !== undefined && + (typeof raw.refreshSec !== "number" || raw.refreshSec < 10) + ) { + errors.push(`${at}.refreshSec must be at least 10`); + } + }); + + if (value.invokes !== undefined) { + if ( + !Array.isArray(value.invokes) || + value.invokes.some( + (entry) => typeof entry !== "string" || !INVOKE_PATTERN.test(entry), + ) + ) { + errors.push( + `${root}.invokes must list action ids, ".*" or "*"`, + ); + } + } +} + +/** + * The cross-field rules: every id one declaration names must exist in this + * plugin, or a surface would offer a button that goes nowhere. + */ +export function checkOperationReferences( + operations: PluginOperationsContribution | undefined, + capabilities: readonly string[], + hasPermission: (name: string) => boolean, + errors: string[], +): void { + if (!operations) return; + const root = "contributes.operations"; + const actions = new Map( + (operations.actions ?? []).map((action) => [action.id, action]), + ); + const resources = new Set((operations.resources ?? []).map((r) => r.id)); + + const declaresAnything = + actions.size > 0 || + (operations.events?.length ?? 0) > 0 || + resources.size > 0 || + (operations.signals?.length ?? 0) > 0; + if (declaresAnything && !capabilities.includes("operations:provide")) { + errors.push(`${root} needs the "operations:provide" capability`); + } + if ( + operations.invokes?.length && + !capabilities.includes("operations:invoke") + ) { + errors.push(`${root}.invokes needs the "operations:invoke" capability`); + } + + const readAction = (id: string, where: string) => { + const target = actions.get(id); + if (!target) errors.push(`${where} "${id}" is not a declared action`); + else if (target.risk !== "read") { + errors.push(`${where} "${id}" must be a read action`); + } + }; + + for (const action of actions.values()) { + const at = `${root}.actions["${action.id}"]`; + if (!hasPermission(action.permission)) { + errors.push( + `${at}.permission "${action.permission}" is not declared in contributes.permissions`, + ); + } + if (action.resource && !resources.has(action.resource)) { + errors.push(`${at}.resource "${action.resource}" is not declared`); + } + if (action.check) readAction(action.check, `${at}.check`); + if (action.undo && !actions.has(action.undo)) { + errors.push(`${at}.undo "${action.undo}" is not a declared action`); + } + for (const field of action.inputs ?? []) { + if (field.optionsFrom) { + readAction(field.optionsFrom, `${at} input "${field.key}" optionsFrom`); + } + if (field.resource && !resources.has(field.resource)) { + errors.push(`${at} input "${field.key}" names an unknown resource`); + } + } + } + for (const resource of operations.resources ?? []) { + readAction(resource.list, `${root}.resources["${resource.id}"].list`); + } + for (const signal of operations.signals ?? []) { + const at = `${root}.signals["${signal.id}"]`; + readAction(signal.read, `${at}.read`); + if (signal.fix && !actions.has(signal.fix)) { + errors.push(`${at}.fix "${signal.fix}" is not a declared action`); + } + } +} + +/** Whether a plugin's invokes list lets it run an action. */ +export function invokeAllowed( + invokes: readonly string[] | undefined, + actionId: string, +): boolean { + for (const entry of invokes ?? []) { + if (entry === "*" || entry === actionId) return true; + if (entry.endsWith(".*") && actionId.startsWith(entry.slice(0, -1))) { + return true; + } + } + return false; +} + +const RISK_RANK: Record = { + read: 0, + change: 1, + destructive: 2, +}; + +export function riskRank(risk: OperationRisk): number { + return RISK_RANK[risk]; +} + +/** A surface's own switch. The assistant and MCP are opt in, the rest opt out. */ +export function onSurface( + action: Pick, + surface: keyof OperationSurfaces, +): boolean { + const value = action.surfaces?.[surface]; + if (surface === "assistant" || surface === "mcp") return value === true; + return value !== false; +} + +/** Whether an action applies to a host, from fields any client can read. */ +export function matchesWhen( + when: OperationWhen | undefined, + host: { settings?: Record; os?: string | null }, +): boolean { + if (!when) return true; + for (const [key, expected] of Object.entries(when.settings ?? {})) { + if (host.settings?.[key] !== expected) return false; + } + if (when.os?.length && host.os) { + if (!when.os.includes(host.os.toLowerCase())) return false; + } + return true; +} + +function asSettingsField(field: OperationField): PluginSettingsField { + const type = field.type === "resource" ? "string" : field.type; + return { ...field, type } as PluginSettingsField; +} + +function isEmpty(value: unknown): boolean { + return value === undefined || value === null || value === ""; +} + +export interface OperationInputResult { + values: Record; + errors: string[]; +} + +/** + * Turns raw input into validated values. Keys no field declares are refused, + * so nothing a handler did not ask for ever reaches it. + */ +export function parseOperationInput( + fields: readonly OperationField[] | undefined, + raw: unknown, +): OperationInputResult { + const errors: string[] = []; + const values: Record = {}; + const list = fields ?? []; + + if (raw !== undefined && raw !== null && !isObject(raw)) { + return { values, errors: ["Input must be an object"] }; + } + const input = (raw ?? {}) as Record; + for (const key of Object.keys(input)) { + if (!list.some((field) => field.key === key)) { + errors.push(`"${key}" is not an input of this action`); + } + } + + for (const field of list) { + if (field.requires && input[field.requires] !== true) continue; + + let value = input[field.key]; + if (isEmpty(value)) { + if (field.default !== undefined) { + values[field.key] = field.default; + } else if (field.required) { + errors.push(`"${field.key}" is required`); + } + continue; + } + + const settingsField = asSettingsField(field); + value = coerceSettingValue(settingsField, value); + + if (field.type === "secret" && typeof value !== "string") { + errors.push(`"${field.key}" must be a string`); + continue; + } + if (field.type === "json" && typeof value === "string") { + try { + value = JSON.parse(value); + } catch { + errors.push(`"${field.key}" must be valid JSON`); + continue; + } + } + + // A list known only at run time is checked by the handler. + const skipChoices = + field.optionsFrom && + (field.type === "select" || field.type === "multiselect"); + const problem = skipChoices + ? field.type === "select" + ? typeof value === "string" + ? null + : `"${field.key}" must be a string` + : Array.isArray(value) + ? null + : `"${field.key}" must be an array` + : validateSettingValue(settingsField, value); + if (problem) { + errors.push(problem); + continue; + } + + if ( + field.pattern && + typeof value === "string" && + !new RegExp(`^(?:${field.pattern})$`).test(value) + ) { + errors.push(`"${field.key}" has the wrong format`); + continue; + } + values[field.key] = value; + } + + return { values, errors }; +} + +/** A copy safe to store or show: secret and sensitive values are replaced. */ +export function redactFields( + fields: readonly OperationField[] | undefined, + values: unknown, +): unknown { + if (!isObject(values)) return values; + const copy: Record = { ...values }; + for (const field of fields ?? []) { + if (field.type !== "secret" && !field.sensitive) continue; + if (field.key in copy) copy[field.key] = REDACTED_VALUE; + } + return copy; +} + +/** Trims text to a byte-friendly length with a plain marker. */ +export function capText(text: string, maxLength: number): string { + return text.length <= maxLength + ? text + : `${text.slice(0, maxLength)}\n[cut at ${maxLength} characters]`; +} + +function fieldSchema(field: OperationField): Record { + const schema: Record = {}; + if (field.default !== undefined) schema.default = field.default; + const values = field.optionsFrom + ? undefined + : field.options?.map((option) => option.value); + + switch (field.type) { + case "boolean": + schema.type = "boolean"; + break; + case "number": + schema.type = "number"; + if (field.min !== undefined) schema.minimum = field.min; + if (field.max !== undefined) schema.maximum = field.max; + break; + case "select": + schema.type = "string"; + if (values) schema.enum = values; + break; + case "multiselect": + schema.type = "array"; + schema.items = values + ? { type: "string", enum: values } + : { type: "string" }; + break; + case "json": + break; + default: + schema.type = "string"; + if (field.pattern) schema.pattern = `^(?:${field.pattern})$`; + } + if (field.type === "resource" && field.resource) { + schema["x-termix-resource"] = field.resource; + } + if (field.type === "secret") schema.writeOnly = true; + return schema; +} + +/** JSON Schema for a field list, for AI tools, MCP and OpenAPI. */ +export function toJsonSchema( + fields: readonly OperationField[] | undefined, + describe?: (field: OperationField) => string | undefined, +): Record { + const properties: Record = {}; + const required: string[] = []; + for (const field of fields ?? []) { + const schema = fieldSchema(field); + const description = describe?.(field); + if (description) schema.description = description; + properties[field.key] = schema; + if (field.required && field.default === undefined && !field.requires) { + required.push(field.key); + } + } + return { type: "object", properties, required, additionalProperties: false }; +} diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 7b0d3f053..d56ca10e5 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -68,7 +68,11 @@ import type { PluginSshKeyCredential, PluginSshKeyCredentialInput, } from "./backend.js"; -import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js"; +import type { + PluginOperationHandler, + PluginSyncHost, + SyncEntityRegistration, +} from "./backend.js"; import type { PluginDatabase } from "./backend.js"; import type { PluginExternalClientRequest, @@ -355,6 +359,10 @@ export interface FakePluginContext { }>; /** The hub the plugin registered with ctx.notify.serve, if any. */ notifyHub: { current: PluginNotifyHub | null }; + /** Handlers from ctx.operations.register, by action id. */ + operationHandlers: Map; + /** Every ctx.operations.emit call. */ + operationEvents: { eventId: string; payload: unknown; hostId?: number }[]; /** Backing store behind ctx.secrets.get/set, keyed ":". */ secretStore: Map; /** Every ctx.fetch call, in order. */ @@ -566,6 +574,8 @@ export function createFakeContext( const createdSshKeys: FakePluginContext["createdSshKeys"] = []; const notifications: FakePluginContext["notifications"] = []; const notifyHub: FakePluginContext["notifyHub"] = { current: null }; + const operationHandlers: FakePluginContext["operationHandlers"] = new Map(); + const operationEvents: FakePluginContext["operationEvents"] = []; const fetches: FakePluginContext["fetches"] = []; const processRuns: FakePluginContext["processRuns"] = []; const binaries: FakePluginContext["binaries"] = []; @@ -1391,6 +1401,46 @@ export function createFakeContext( }, }, + operations: { + register: (id, handler) => { + operationHandlers.set(id, handler); + }, + run: async (actionId, options = {}) => { + const handler = operationHandlers.get(actionId); + if (!handler) throw new Error(`No action "${actionId}"`); + const result = await handler.run(options.input ?? {}, { + hostId: options.hostId, + resourceKey: options.resourceKey, + runId: null, + dryRun: options.dryRun === true, + signal: new AbortController().signal, + log: () => {}, + }); + return { runId: null, status: "success", ...result }; + }, + invoke: async (actionId, options = {}) => { + const handler = operationHandlers.get(actionId); + if (!handler) throw new Error(`No action "${actionId}"`); + const result = await handler.run(options.input ?? {}, { + hostId: options.hostId, + resourceKey: options.resourceKey, + runId: null, + dryRun: options.dryRun === true, + signal: new AbortController().signal, + log: () => {}, + }); + return { runId: null, status: "success", ...result }; + }, + list: async () => ({ + actions: manifest.contributes?.operations?.actions ?? [], + }), + emit: async (eventId, payload, options) => { + operationEvents.push({ eventId, payload, hostId: options?.hostId }); + }, + onEvent: () => () => {}, + approvals: { provide: () => () => {} }, + }, + notify: { channels: async () => { if (!actor) throw new Error("ctx.notify needs an acting user"); @@ -1554,6 +1604,8 @@ export function createFakeContext( createdSshKeys, notifications, notifyHub, + operationHandlers, + operationEvents, fetches, processRuns, binaries, diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 94b3b76e0..0ec0e5142 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -30,6 +30,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js"; import { syncForwarder } from "../sync/provider.js"; import { syncChangeWatcher } from "../sync/server/change-watcher.js"; import dashboardRoutes from "./routes/dashboard-routes.js"; +import operationsRoutes from "./routes/operations-routes.js"; import { mountPluginApi, mountPluginLegacyPaths, @@ -1627,6 +1628,7 @@ app.use("/ui-preferences", uiPreferencesRoutes); registerAuditLogRoutes(app, authenticateJWT); app.use("/sync", syncForwarder); app.use("/dashboard", dashboardRoutes); +app.use("/operations", operationsRoutes); app.use("/plugins", pluginRoutes); app.use( "/plugin-assets", diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index c70cef2ac..c4bc33892 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -599,6 +599,70 @@ async function initializeCompleteDatabase(): Promise { UNIQUE (role_name, permission) ); + CREATE TABLE IF NOT EXISTS operation_runs ( + id TEXT PRIMARY KEY, + action_id TEXT NOT NULL, + action_version INTEGER NOT NULL DEFAULT 1, + plugin_id TEXT NOT NULL, + plugin_version TEXT NOT NULL, + user_id TEXT REFERENCES users (id) ON DELETE SET NULL, + username TEXT NOT NULL, + origin TEXT NOT NULL, + host_id INTEGER, + host_name TEXT, + batch_id TEXT, + dry_run INTEGER NOT NULL DEFAULT 0, + risk TEXT NOT NULL, + status TEXT NOT NULL, + summary TEXT, + changed INTEGER, + input TEXT, + sealed_input TEXT, + output TEXT, + undo_input TEXT, + error TEXT, + error_code TEXT, + undo_of TEXT, + idempotency_key TEXT, + created_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_operation_runs_created ON operation_runs (created_at); + CREATE INDEX IF NOT EXISTS idx_operation_runs_user_created ON operation_runs (user_id, created_at); + CREATE INDEX IF NOT EXISTS idx_operation_runs_host_created ON operation_runs (host_id, created_at); + CREATE INDEX IF NOT EXISTS idx_operation_runs_status ON operation_runs (status); + CREATE INDEX IF NOT EXISTS idx_operation_runs_batch ON operation_runs (batch_id); + + CREATE TABLE IF NOT EXISTS operation_approvals ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES operation_runs (id) ON DELETE CASCADE, + requested_by TEXT, + reason TEXT, + allow_self INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + decided_by TEXT, + comment TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + decided_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_operation_approvals_status ON operation_approvals (status, expires_at); + CREATE INDEX IF NOT EXISTS idx_operation_approvals_run ON operation_approvals (run_id); + + CREATE TABLE IF NOT EXISTS operation_events ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + plugin_id TEXT NOT NULL, + host_id INTEGER, + severity TEXT, + payload TEXT, + run_id TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_operation_events_created ON operation_events (created_at); + CREATE INDEX IF NOT EXISTS idx_operation_events_host_created ON operation_events (host_id, created_at); + CREATE TABLE IF NOT EXISTS api_keys ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, @@ -961,6 +1025,7 @@ const enforceUniqueUsernames = () => { const migrateSchema = () => { repairSnippetsColumns(sqlite); + addColumnIfNotExists("api_keys", "scopes", "TEXT"); addColumnIfNotExists("user_preferences", "theme", "TEXT"); addColumnIfNotExists("user_preferences", "font_size", "TEXT"); addColumnIfNotExists("user_preferences", "accent_color", "TEXT"); diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts index dbc45ef18..95e5caca9 100644 --- a/src/backend/database/db/schema.mysql.ts +++ b/src/backend/database/db/schema.mysql.ts @@ -673,10 +673,102 @@ export const apiKeys = mysqlTable( expiresAt: text("expires_at"), lastUsedAt: text("last_used_at"), isActive: boolean("is_active").notNull().default(true), + // JSON list. Null means full access, which every older key has. + scopes: text("scopes"), }, (table) => [index("idx_api_keys_user_id").on(table.userId)], ); +export const operationRuns = mysqlTable( + "operation_runs", + { + id: varchar("id", { length: 255 }).primaryKey(), + actionId: text("action_id").notNull(), + actionVersion: int("action_version").notNull().default(1), + pluginId: text("plugin_id").notNull(), + pluginVersion: text("plugin_version").notNull(), + + // Nullable on purpose: history outlives the account, like audit_logs. + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }), + username: text("username").notNull(), + + origin: text("origin").notNull(), + hostId: int("host_id"), + hostName: text("host_name"), + batchId: varchar("batch_id", { length: 255 }), + dryRun: boolean("dry_run").notNull().default(false), + risk: text("risk").notNull(), + status: varchar("status", { length: 255 }).notNull(), + summary: text("summary"), + changed: boolean("changed"), + input: text("input"), + // Only until the run ends, then wiped. + sealedInput: text("sealed_input"), + output: text("output"), + undoInput: text("undo_input"), + error: text("error"), + errorCode: text("error_code"), + undoOf: text("undo_of"), + idempotencyKey: text("idempotency_key"), + createdAt: varchar("created_at", { length: 255 }).notNull(), + startedAt: text("started_at"), + finishedAt: text("finished_at"), + }, + (table) => [ + index("idx_operation_runs_created").on(table.createdAt), + index("idx_operation_runs_user_created").on(table.userId, table.createdAt), + index("idx_operation_runs_host_created").on(table.hostId, table.createdAt), + index("idx_operation_runs_status").on(table.status), + index("idx_operation_runs_batch").on(table.batchId), + ], +); + +export const operationApprovals = mysqlTable( + "operation_approvals", + { + id: varchar("id", { length: 255 }).primaryKey(), + runId: varchar("run_id", { length: 255 }) + .notNull() + .references(() => operationRuns.id, { onDelete: "cascade" }), + requestedBy: text("requested_by"), + reason: text("reason"), + allowSelf: boolean("allow_self") + .notNull() + .default(false), + status: varchar("status", { length: 255 }).notNull(), + decidedBy: text("decided_by"), + comment: text("comment"), + createdAt: text("created_at").notNull(), + expiresAt: varchar("expires_at", { length: 255 }).notNull(), + decidedAt: text("decided_at"), + }, + (table) => [ + index("idx_operation_approvals_status").on(table.status, table.expiresAt), + index("idx_operation_approvals_run").on(table.runId), + ], +); + +export const operationEvents = mysqlTable( + "operation_events", + { + id: varchar("id", { length: 255 }).primaryKey(), + eventId: text("event_id").notNull(), + pluginId: text("plugin_id").notNull(), + hostId: int("host_id"), + severity: text("severity"), + payload: text("payload"), + runId: text("run_id"), + createdAt: varchar("created_at", { length: 255 }).notNull(), + }, + (table) => [ + index("idx_operation_events_created").on(table.createdAt), + index("idx_operation_events_host_created").on( + table.hostId, + table.createdAt, + ), + ], +); + export const userOpenTabs = mysqlTable( "user_open_tabs", { diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts index 521bac863..8a866bdd0 100644 --- a/src/backend/database/db/schema.pg.ts +++ b/src/backend/database/db/schema.pg.ts @@ -673,10 +673,102 @@ export const apiKeys = pgTable( expiresAt: text("expires_at"), lastUsedAt: text("last_used_at"), isActive: boolean("is_active").notNull().default(true), + // JSON list. Null means full access, which every older key has. + scopes: text("scopes"), }, (table) => [index("idx_api_keys_user_id").on(table.userId)], ); +export const operationRuns = pgTable( + "operation_runs", + { + id: varchar("id", { length: 255 }).primaryKey(), + actionId: text("action_id").notNull(), + actionVersion: integer("action_version").notNull().default(1), + pluginId: text("plugin_id").notNull(), + pluginVersion: text("plugin_version").notNull(), + + // Nullable on purpose: history outlives the account, like audit_logs. + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }), + username: text("username").notNull(), + + origin: text("origin").notNull(), + hostId: integer("host_id"), + hostName: text("host_name"), + batchId: varchar("batch_id", { length: 255 }), + dryRun: boolean("dry_run").notNull().default(false), + risk: text("risk").notNull(), + status: varchar("status", { length: 255 }).notNull(), + summary: text("summary"), + changed: boolean("changed"), + input: text("input"), + // Only until the run ends, then wiped. + sealedInput: text("sealed_input"), + output: text("output"), + undoInput: text("undo_input"), + error: text("error"), + errorCode: text("error_code"), + undoOf: text("undo_of"), + idempotencyKey: text("idempotency_key"), + createdAt: varchar("created_at", { length: 255 }).notNull(), + startedAt: text("started_at"), + finishedAt: text("finished_at"), + }, + (table) => [ + index("idx_operation_runs_created").on(table.createdAt), + index("idx_operation_runs_user_created").on(table.userId, table.createdAt), + index("idx_operation_runs_host_created").on(table.hostId, table.createdAt), + index("idx_operation_runs_status").on(table.status), + index("idx_operation_runs_batch").on(table.batchId), + ], +); + +export const operationApprovals = pgTable( + "operation_approvals", + { + id: varchar("id", { length: 255 }).primaryKey(), + runId: varchar("run_id", { length: 255 }) + .notNull() + .references(() => operationRuns.id, { onDelete: "cascade" }), + requestedBy: text("requested_by"), + reason: text("reason"), + allowSelf: boolean("allow_self") + .notNull() + .default(false), + status: varchar("status", { length: 255 }).notNull(), + decidedBy: text("decided_by"), + comment: text("comment"), + createdAt: text("created_at").notNull(), + expiresAt: varchar("expires_at", { length: 255 }).notNull(), + decidedAt: text("decided_at"), + }, + (table) => [ + index("idx_operation_approvals_status").on(table.status, table.expiresAt), + index("idx_operation_approvals_run").on(table.runId), + ], +); + +export const operationEvents = pgTable( + "operation_events", + { + id: varchar("id", { length: 255 }).primaryKey(), + eventId: text("event_id").notNull(), + pluginId: text("plugin_id").notNull(), + hostId: integer("host_id"), + severity: text("severity"), + payload: text("payload"), + runId: text("run_id"), + createdAt: varchar("created_at", { length: 255 }).notNull(), + }, + (table) => [ + index("idx_operation_events_created").on(table.createdAt), + index("idx_operation_events_host_created").on( + table.hostId, + table.createdAt, + ), + ], +); + export const userOpenTabs = pgTable( "user_open_tabs", { diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index be3018a42..47bf843fd 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -663,10 +663,102 @@ export const apiKeys = sqliteTable( expiresAt: text("expires_at"), lastUsedAt: text("last_used_at"), isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), + // JSON list. Null means full access, which every older key has. + scopes: text("scopes"), }, (table) => [index("idx_api_keys_user_id").on(table.userId)], ); +export const operationRuns = sqliteTable( + "operation_runs", + { + id: text("id").primaryKey(), + actionId: text("action_id").notNull(), + actionVersion: integer("action_version").notNull().default(1), + pluginId: text("plugin_id").notNull(), + pluginVersion: text("plugin_version").notNull(), + + // Nullable on purpose: history outlives the account, like audit_logs. + userId: text("user_id").references(() => users.id, { onDelete: "set null" }), + username: text("username").notNull(), + + origin: text("origin").notNull(), + hostId: integer("host_id"), + hostName: text("host_name"), + batchId: text("batch_id"), + dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false), + risk: text("risk").notNull(), + status: text("status").notNull(), + summary: text("summary"), + changed: integer("changed", { mode: "boolean" }), + input: text("input"), + // Only until the run ends, then wiped. + sealedInput: text("sealed_input"), + output: text("output"), + undoInput: text("undo_input"), + error: text("error"), + errorCode: text("error_code"), + undoOf: text("undo_of"), + idempotencyKey: text("idempotency_key"), + createdAt: text("created_at").notNull(), + startedAt: text("started_at"), + finishedAt: text("finished_at"), + }, + (table) => [ + index("idx_operation_runs_created").on(table.createdAt), + index("idx_operation_runs_user_created").on(table.userId, table.createdAt), + index("idx_operation_runs_host_created").on(table.hostId, table.createdAt), + index("idx_operation_runs_status").on(table.status), + index("idx_operation_runs_batch").on(table.batchId), + ], +); + +export const operationApprovals = sqliteTable( + "operation_approvals", + { + id: text("id").primaryKey(), + runId: text("run_id") + .notNull() + .references(() => operationRuns.id, { onDelete: "cascade" }), + requestedBy: text("requested_by"), + reason: text("reason"), + allowSelf: integer("allow_self", { mode: "boolean" }) + .notNull() + .default(false), + status: text("status").notNull(), + decidedBy: text("decided_by"), + comment: text("comment"), + createdAt: text("created_at").notNull(), + expiresAt: text("expires_at").notNull(), + decidedAt: text("decided_at"), + }, + (table) => [ + index("idx_operation_approvals_status").on(table.status, table.expiresAt), + index("idx_operation_approvals_run").on(table.runId), + ], +); + +export const operationEvents = sqliteTable( + "operation_events", + { + id: text("id").primaryKey(), + eventId: text("event_id").notNull(), + pluginId: text("plugin_id").notNull(), + hostId: integer("host_id"), + severity: text("severity"), + payload: text("payload"), + runId: text("run_id"), + createdAt: text("created_at").notNull(), + }, + (table) => [ + index("idx_operation_events_created").on(table.createdAt), + index("idx_operation_events_host_created").on( + table.hostId, + table.createdAt, + ), + ], +); + export const userOpenTabs = sqliteTable( "user_open_tabs", { diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index b34811df3..795657b13 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -13,6 +13,7 @@ import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repos import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js"; import { UiPreferenceRepository } from "./ui-preference-repository.js"; import { OpenTabRepository } from "./open-tab-repository.js"; +import { OperationRepository } from "./operation-repository.js"; import { PluginRepository } from "./plugin-repository.js"; import { PluginStorageRepository } from "./plugin-storage-repository.js"; import { PluginSettingsRepository } from "./plugin-settings-repository.js"; @@ -134,6 +135,14 @@ export function createCurrentAuditLogRepository(): AuditLogRepository { ); } +export function createCurrentOperationRepository(): OperationRepository { + return new OperationRepository( + createCurrentRepositoryContext(), + // Rows land on every run. A rollout saves once on its own when it starts. + createCurrentRepositoryLazyWriteHook("operation_repository_write"), + ); +} + export function createCurrentCredentialRepository(): CredentialRepository { return new CredentialRepository( createCurrentRepositoryContext(), diff --git a/src/backend/database/repositories/operation-repository.ts b/src/backend/database/repositories/operation-repository.ts new file mode 100644 index 000000000..7d449ca1b --- /dev/null +++ b/src/backend/database/repositories/operation-repository.ts @@ -0,0 +1,255 @@ +import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm"; +import { + operationApprovals, + operationEvents, + operationRuns, +} from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; +import { countValue, rowsAffected } from "./mutation-result.js"; + +export type OperationRunRecord = typeof operationRuns.$inferSelect; +export type NewOperationRunRecord = typeof operationRuns.$inferInsert; +export type OperationApprovalRecord = typeof operationApprovals.$inferSelect; +export type NewOperationApprovalRecord = typeof operationApprovals.$inferInsert; +export type OperationEventRecord = typeof operationEvents.$inferSelect; +export type NewOperationEventRecord = typeof operationEvents.$inferInsert; + +export interface OperationRunFilters { + userId?: string; + /** Rows also visible because the viewer owns the host. */ + orHostIds?: number[]; + hostId?: number; + actionId?: string; + status?: string; + batchId?: string; + before?: string; +} + +const MAX_PAGE = 200; + +export class OperationRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async insertRun(run: NewOperationRunRecord): Promise { + await this.context.drizzle.insert(operationRuns).values(run); + await this.onWrite?.(); + } + + async findRun(id: string): Promise { + const rows = await this.context.drizzle + .select() + .from(operationRuns) + .where(eq(operationRuns.id, id)) + .limit(1); + return rows[0] ?? null; + } + + async findByIdempotencyKey( + userId: string, + key: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(operationRuns) + .where( + and( + eq(operationRuns.userId, userId), + eq(operationRuns.idempotencyKey, key), + ), + ) + .limit(1); + return rows[0] ?? null; + } + + async listRuns( + filters: OperationRunFilters, + limit = 50, + ): Promise { + const owner = filters.userId + ? filters.orHostIds?.length + ? or( + eq(operationRuns.userId, filters.userId), + inArray(operationRuns.hostId, filters.orHostIds), + ) + : eq(operationRuns.userId, filters.userId) + : undefined; + const conditions = [ + owner, + filters.hostId !== undefined + ? eq(operationRuns.hostId, filters.hostId) + : undefined, + filters.actionId + ? eq(operationRuns.actionId, filters.actionId) + : undefined, + filters.status ? eq(operationRuns.status, filters.status) : undefined, + filters.batchId ? eq(operationRuns.batchId, filters.batchId) : undefined, + filters.before ? lt(operationRuns.createdAt, filters.before) : undefined, + ].filter((condition) => condition !== undefined); + return this.context.drizzle + .select() + .from(operationRuns) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(operationRuns.createdAt)) + .limit(Math.min(Math.max(limit, 1), MAX_PAGE)); + } + + /** Moves a run on only from the status given, so two writers cannot race. */ + async updateRunFrom( + id: string, + fromStatus: string, + patch: Partial, + ): Promise { + const result = await this.context.drizzle + .update(operationRuns) + .set(patch) + .where( + and(eq(operationRuns.id, id), eq(operationRuns.status, fromStatus)), + ); + await this.onWrite?.(); + return rowsAffected(result) > 0; + } + + /** Anything still running when the server stopped has an unknown outcome. */ + async markRunningInterrupted(finishedAt: string): Promise { + const result = await this.context.drizzle + .update(operationRuns) + .set({ + status: "interrupted", + finishedAt, + sealedInput: null, + error: "The server stopped during this run. Check the host.", + }) + .where(eq(operationRuns.status, "running")); + await this.onWrite?.(); + return rowsAffected(result); + } + + async insertApproval(approval: NewOperationApprovalRecord): Promise { + await this.context.drizzle.insert(operationApprovals).values(approval); + await this.onWrite?.(); + } + + async findApproval(id: string): Promise { + const rows = await this.context.drizzle + .select() + .from(operationApprovals) + .where(eq(operationApprovals.id, id)) + .limit(1); + return rows[0] ?? null; + } + + async findApprovalForRun( + runId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(operationApprovals) + .where(eq(operationApprovals.runId, runId)) + .limit(1); + return rows[0] ?? null; + } + + async listPendingApprovals(limit = 100): Promise { + return this.context.drizzle + .select() + .from(operationApprovals) + .where(eq(operationApprovals.status, "pending")) + .orderBy(desc(operationApprovals.createdAt)) + .limit(Math.min(Math.max(limit, 1), MAX_PAGE)); + } + + /** One shot: only the first decision on a pending row wins. */ + async decideApproval( + id: string, + status: "approved" | "rejected" | "expired", + decidedBy: string | null, + comment: string | null, + decidedAt: string, + ): Promise { + const result = await this.context.drizzle + .update(operationApprovals) + .set({ status, decidedBy, comment, decidedAt }) + .where( + and( + eq(operationApprovals.id, id), + eq(operationApprovals.status, "pending"), + ), + ); + await this.onWrite?.(); + return rowsAffected(result) > 0; + } + + async listExpiredApprovals(now: string): Promise { + return this.context.drizzle + .select() + .from(operationApprovals) + .where( + and( + eq(operationApprovals.status, "pending"), + lt(operationApprovals.expiresAt, now), + ), + ) + .limit(MAX_PAGE); + } + + async insertEvent(event: NewOperationEventRecord): Promise { + await this.context.drizzle.insert(operationEvents).values(event); + await this.onWrite?.(); + } + + async listEvents( + filters: { hostId?: number; before?: string }, + limit = 50, + ): Promise { + const conditions = [ + filters.hostId !== undefined + ? eq(operationEvents.hostId, filters.hostId) + : undefined, + filters.before + ? lt(operationEvents.createdAt, filters.before) + : undefined, + ].filter((condition) => condition !== undefined); + return this.context.drizzle + .select() + .from(operationEvents) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(operationEvents.createdAt)) + .limit(Math.min(Math.max(limit, 1), MAX_PAGE)); + } + + /** Drops rows older than the cutoff, finished runs only. */ + async prune(before: string): Promise { + const runs = await this.context.drizzle + .delete(operationRuns) + .where( + and( + lt(operationRuns.createdAt, before), + sql`${operationRuns.status} NOT IN ('running', 'pending_approval')`, + ), + ); + const events = await this.context.drizzle + .delete(operationEvents) + .where(lt(operationEvents.createdAt, before)); + await this.onWrite?.(); + return rowsAffected(runs) + rowsAffected(events); + } + + async countRuns(): Promise { + const rows = await this.context.drizzle + .select({ value: sql`count(*)` }) + .from(operationRuns); + return countValue(rows[0]?.value); + } + + /** History outlives the account, but the name goes with it. */ + async anonymizeUser(userId: string): Promise { + await this.context.drizzle + .update(operationRuns) + .set({ username: "deleted user" }) + .where(eq(operationRuns.userId, userId)); + await this.onWrite?.(); + } +} diff --git a/src/backend/database/routes/delete-user-data.ts b/src/backend/database/routes/delete-user-data.ts index 1457579a6..e86d1a9f8 100644 --- a/src/backend/database/routes/delete-user-data.ts +++ b/src/backend/database/routes/delete-user-data.ts @@ -3,6 +3,7 @@ import { recentActivity } from "../../plugins/activity-provider.js"; import { createCurrentApiKeyRepository, createCurrentAuditLogRepository, + createCurrentOperationRepository, createCurrentCredentialRepository, createCurrentHostFolderRepository, createCurrentHostRepository, @@ -61,6 +62,7 @@ export async function deleteUserAndRelatedData( await createCurrentRoleRepository().removeAllRolesFromUser(userId); await createCurrentAuditLogRepository().anonymizeByUserId(userId); + await createCurrentOperationRepository().anonymizeUser(userId); await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); diff --git a/src/backend/database/routes/operations-routes.ts b/src/backend/database/routes/operations-routes.ts new file mode 100644 index 000000000..09ca3f553 --- /dev/null +++ b/src/backend/database/routes/operations-routes.ts @@ -0,0 +1,760 @@ +import express, { type Request, type Response } from "express"; +import type { + ResourceListResult, + SignalResult, +} from "@termix-ssh/plugin-sdk/operations"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { PermissionManager } from "../../utils/permission-manager.js"; +import { databaseLogger } from "../../utils/logger.js"; +import { getErrorMessage } from "../../utils/error-message.js"; +import { createCurrentOperationRepository } from "../repositories/factory.js"; +import type { OperationRunRecord } from "../repositories/operation-repository.js"; +import { buildCatalog } from "../../operations/catalog.js"; +import { + cancelOperationRun, + decideOperationApproval, + OperationError, + runOperation, +} from "../../operations/gate.js"; +import { getAction } from "../../operations/registry.js"; +import { + flattenResourceRows, + readAcrossHosts, +} from "../../operations/resources.js"; +import type { ActorOrigin } from "../../plugins/actor.js"; + +const router = express.Router(); +const authenticateJWT = AuthManager.getInstance().createAuthMiddleware(); +const permissions = PermissionManager.getInstance(); + +/** Sees every run on the server, not only their own. */ +export const VIEW_ALL_RUNS = "admin.operations.view"; + +type OperationsRequest = Request & { + userId: string; + apiKeyId?: string; + apiKeyScopes?: string[] | null; + actingAdminUserId?: string; +}; + +function originOf(req: OperationsRequest): ActorOrigin { + return { kind: req.apiKeyId ? "api" : "ui" }; +} + +function sendError(res: Response, error: unknown, operation: string): void { + if (error instanceof OperationError) { + res.status(error.status).json({ + error: error.message, + code: error.code, + details: error.details, + }); + return; + } + databaseLogger.error("Operations route failed", error, { operation }); + res.status(500).json({ error: getErrorMessage(error, "Request failed") }); +} + +function parseJson(text: string | null): unknown { + if (!text) return undefined; + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +/** Never includes the sealed input. */ +function publicRun(run: OperationRunRecord) { + return { + id: run.id, + actionId: run.actionId, + pluginId: run.pluginId, + pluginVersion: run.pluginVersion, + userId: run.userId, + username: run.username, + origin: parseJson(run.origin), + hostId: run.hostId, + hostName: run.hostName, + batchId: run.batchId, + dryRun: run.dryRun, + risk: run.risk, + status: run.status, + summary: run.summary, + changed: run.changed, + input: parseJson(run.input), + output: parseJson(run.output), + undoAvailable: run.undoInput !== null, + error: run.error, + errorCode: run.errorCode, + undoOf: run.undoOf, + createdAt: run.createdAt, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + }; +} + +async function canSeeRun( + run: OperationRunRecord, + userId: string, +): Promise { + if (run.userId === userId) return true; + if (await permissions.hasPermission(userId, VIEW_ALL_RUNS)) return true; + if (run.hostId === null) return false; + const access = await permissions.canAccessHost(userId, run.hostId, "manage"); + return access.hasAccess; +} + +function hostIdsFrom(value: unknown): number[] | undefined { + if (typeof value !== "string" || value === "") return undefined; + const ids = value + .split(",") + .map(Number) + .filter((id) => Number.isInteger(id)); + return ids.length > 0 ? ids : undefined; +} + +function limitFrom(value: unknown): number | undefined { + const limit = Number(value); + return Number.isInteger(limit) && limit > 0 ? limit : undefined; +} + +/** + * @openapi + * /operations/catalog: + * get: + * summary: List what this user can do + * description: Every action, event, resource and signal from running plugins that the caller has permission for, with titles in the requested language. + * tags: + * - Operations + * parameters: + * - in: query + * name: lang + * schema: + * type: string + * - in: query + * name: surface + * schema: + * type: string + * enum: [assistant, mcp, palette, automations, mobile, cli] + * responses: + * 200: + * description: The catalog. + */ +router.get("/catalog", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + res.json( + await buildCatalog({ + userId, + lang: typeof req.query.lang === "string" ? req.query.lang : undefined, + surface: + typeof req.query.surface === "string" + ? (req.query.surface as never) + : undefined, + }), + ); + } catch (error) { + sendError(res, error, "operations_catalog"); + } +}); + +/** + * @openapi + * /operations/catalog/{id}: + * get: + * summary: Describe one action + * tags: + * - Operations + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The action. + * 404: + * description: No such action, or the caller may not use it. + */ +router.get("/catalog/:id", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + const catalog = await buildCatalog({ + userId, + lang: typeof req.query.lang === "string" ? req.query.lang : undefined, + }); + const action = catalog.actions.find((entry) => entry.id === req.params.id); + if (!action) { + res.status(404).json({ error: "Action not found" }); + return; + } + res.json(action); + } catch (error) { + sendError(res, error, "operations_catalog_action"); + } +}); + +/** + * @openapi + * /operations/runs: + * get: + * summary: List runs + * description: The caller's own runs, runs on hosts they manage, or every run with the view permission. + * tags: + * - Operations + * parameters: + * - in: query + * name: hostId + * schema: + * type: integer + * - in: query + * name: actionId + * schema: + * type: string + * - in: query + * name: status + * schema: + * type: string + * - in: query + * name: batchId + * schema: + * type: string + * - in: query + * name: before + * schema: + * type: string + * - in: query + * name: limit + * schema: + * type: integer + * responses: + * 200: + * description: Runs, newest first. + */ +router.get("/runs", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS); + const hostId = limitFrom(req.query.hostId); + let managed = false; + if (!viewAll && hostId !== undefined) { + managed = (await permissions.canAccessHost(userId, hostId, "manage")) + .hasAccess; + } + const runs = await createCurrentOperationRepository().listRuns( + { + userId: viewAll || managed ? undefined : userId, + hostId, + actionId: + typeof req.query.actionId === "string" + ? req.query.actionId + : undefined, + status: + typeof req.query.status === "string" ? req.query.status : undefined, + batchId: + typeof req.query.batchId === "string" ? req.query.batchId : undefined, + before: + typeof req.query.before === "string" ? req.query.before : undefined, + }, + limitFrom(req.query.limit), + ); + res.json({ runs: runs.map(publicRun) }); + } catch (error) { + sendError(res, error, "operations_runs"); + } +}); + +/** + * @openapi + * /operations/runs/{id}: + * get: + * summary: Get a run + * tags: + * - Operations + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The run. + * 404: + * description: Not found, or not visible to the caller. + */ +router.get("/runs/:id", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + const run = await createCurrentOperationRepository().findRun( + String(req.params.id), + ); + if (!run || !(await canSeeRun(run, userId))) { + res.status(404).json({ error: "Run not found" }); + return; + } + res.json(publicRun(run)); + } catch (error) { + sendError(res, error, "operations_run"); + } +}); + +/** + * @openapi + * /operations/runs/{id}/cancel: + * post: + * summary: Cancel a run + * description: Stops a running action, or withdraws one waiting for approval. + * tags: + * - Operations + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Whether anything was cancelled. + * 404: + * description: Not found, or not visible to the caller. + */ +router.post("/runs/:id/cancel", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + const run = await createCurrentOperationRepository().findRun( + String(req.params.id), + ); + if (!run || !(await canSeeRun(run, userId))) { + res.status(404).json({ error: "Run not found" }); + return; + } + res.json({ cancelled: await cancelOperationRun(run) }); + } catch (error) { + sendError(res, error, "operations_cancel"); + } +}); + +/** + * @openapi + * /operations/runs/{id}/undo: + * post: + * summary: Undo a run + * description: Runs the action's undo with the input the run recorded. It goes through the same checks as any run. + * tags: + * - Operations + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The new run. + * 404: + * description: Not found, or nothing to undo. + */ +router.post("/runs/:id/undo", authenticateJWT, async (req, res) => { + const authReq = req as OperationsRequest; + try { + const run = await createCurrentOperationRepository().findRun( + String(req.params.id), + ); + const undo = run ? getAction(run.actionId)?.action.undo : undefined; + if ( + !run || + !undo || + !run.undoInput || + !(await canSeeRun(run, authReq.userId)) + ) { + res.status(404).json({ error: "Nothing to undo" }); + return; + } + res.json( + await runOperation({ + actionId: undo, + hostId: run.hostId ?? undefined, + input: parseJson(run.undoInput), + confirmed: req.body?.confirmed === true, + undoOf: run.id, + userId: authReq.userId, + origin: originOf(authReq), + apiKeyScopes: authReq.apiKeyScopes, + }), + ); + } catch (error) { + sendError(res, error, "operations_undo"); + } +}); + +/** + * @openapi + * /operations/approvals: + * get: + * summary: List waiting approvals + * description: Requests the caller could decide, with the exact input that would run. + * tags: + * - Operations + * responses: + * 200: + * description: Waiting approvals. + */ +router.get("/approvals", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + const repository = createCurrentOperationRepository(); + const approvals = []; + for (const approval of await repository.listPendingApprovals()) { + const run = await repository.findRun(approval.runId); + if (run && (await canSeeRun(run, userId))) { + approvals.push({ ...approval, run: publicRun(run) }); + } + } + res.json({ approvals }); + } catch (error) { + sendError(res, error, "operations_approvals"); + } +}); + +/** + * @openapi + * /operations/approvals/{id}: + * post: + * summary: Approve or reject a request + * description: One shot. What runs is the sealed request, never anything sent here. + * tags: + * - Operations + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [approve] + * properties: + * approve: + * type: boolean + * comment: + * type: string + * responses: + * 200: + * description: The run's outcome. + * 403: + * description: The caller may not decide this. + * 409: + * description: Already decided, expired, or the host changed. + */ +router.post("/approvals/:id", authenticateJWT, async (req, res) => { + const authReq = req as OperationsRequest; + if (typeof req.body?.approve !== "boolean") { + res.status(400).json({ error: "approve must be true or false" }); + return; + } + // A key could be handed to a script, so a person decides from a session. + if (authReq.apiKeyScopes) { + res.status(403).json({ error: "This API key may not approve" }); + return; + } + try { + res.json( + await decideOperationApproval({ + approvalId: String(req.params.id), + approverId: authReq.userId, + approve: req.body.approve, + comment: + typeof req.body.comment === "string" ? req.body.comment : undefined, + impersonated: !!authReq.actingAdminUserId, + }), + ); + } catch (error) { + sendError(res, error, "operations_approve"); + } +}); + +/** + * @openapi + * /operations/events: + * get: + * summary: List declared events + * tags: + * - Operations + * responses: + * 200: + * description: Declared events from running plugins, plus core's run events. + */ +router.get("/events", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + const catalog = await buildCatalog({ + userId, + lang: typeof req.query.lang === "string" ? req.query.lang : undefined, + }); + res.json({ events: catalog.events }); + } catch (error) { + sendError(res, error, "operations_events"); + } +}); + +/** + * @openapi + * /operations/timeline: + * get: + * summary: Recent events + * description: Events the caller may see, newest first. Events tied to a host need access to it. + * tags: + * - Operations + * parameters: + * - in: query + * name: hostId + * schema: + * type: integer + * - in: query + * name: before + * schema: + * type: string + * - in: query + * name: limit + * schema: + * type: integer + * responses: + * 200: + * description: Events. + */ +router.get("/timeline", authenticateJWT, async (req, res) => { + const { userId } = req as OperationsRequest; + try { + const rows = await createCurrentOperationRepository().listEvents( + { + hostId: limitFrom(req.query.hostId), + before: + typeof req.query.before === "string" ? req.query.before : undefined, + }, + limitFrom(req.query.limit), + ); + const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS); + const access = new Map(); + const events = []; + for (const row of rows) { + if (row.hostId === null) { + if (viewAll) events.push(row); + continue; + } + let allowed = access.get(row.hostId); + if (allowed === undefined) { + allowed = ( + await permissions.canAccessHost(userId, row.hostId, "connect") + ).hasAccess; + access.set(row.hostId, allowed); + } + if (allowed) events.push(row); + } + res.json({ + events: events.map((event) => ({ + ...event, + payload: parseJson(event.payload), + })), + }); + } catch (error) { + sendError(res, error, "operations_timeline"); + } +}); + +/** + * @openapi + * /operations/signals: + * get: + * summary: Read status tiles + * description: Answers from memory right away and refreshes stale hosts behind it, so a page should poll. Reads hosts the caller can reach. + * tags: + * - Operations + * parameters: + * - in: query + * name: hostIds + * description: Comma separated. Leave out for every host. + * schema: + * type: string + * - in: query + * name: ids + * description: Comma separated signal ids. Leave out for all. + * schema: + * type: string + * responses: + * 200: + * description: One entry per signal with per host results. + */ +router.get("/signals", authenticateJWT, async (req, res) => { + const authReq = req as OperationsRequest; + try { + const catalog = await buildCatalog({ userId: authReq.userId }); + const wanted = + typeof req.query.ids === "string" && req.query.ids + ? new Set(req.query.ids.split(",")) + : null; + const hostIds = hostIdsFrom(req.query.hostIds); + const signals = []; + for (const signal of catalog.signals) { + if (wanted && !wanted.has(signal.id)) continue; + if (signal.scope === "server") { + const outcome = await runOperation({ + actionId: signal.read, + internal: true, + userId: authReq.userId, + origin: originOf(authReq), + }).catch(() => null); + signals.push({ + id: signal.id, + hosts: [], + result: outcome?.data as SignalResult | undefined, + }); + continue; + } + signals.push({ + id: signal.id, + hosts: await readAcrossHosts({ + userId: authReq.userId, + actionId: signal.read, + hostIds, + origin: originOf(authReq), + }), + }); + } + res.json({ signals }); + } catch (error) { + sendError(res, error, "operations_signals"); + } +}); + +/** + * @openapi + * /operations/resources/{id}: + * get: + * summary: Read one kind of thing across hosts + * description: Rows from cache plus each host's state (ok, loading, offline, error, no-access). Answers right away and refreshes stale hosts behind it, so a page should poll until no host is loading. + * tags: + * - Operations + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * - in: query + * name: hostIds + * description: Comma separated. Leave out for every host. + * schema: + * type: string + * - in: query + * name: refresh + * schema: + * type: boolean + * responses: + * 200: + * description: Rows and per host state. + * 404: + * description: No such resource, or the caller may not read it. + */ +router.get("/resources/:id", authenticateJWT, async (req, res) => { + const authReq = req as OperationsRequest; + try { + const catalog = await buildCatalog({ userId: authReq.userId }); + const resource = catalog.resources.find( + (entry) => entry.id === req.params.id, + ); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + const reads = await readAcrossHosts({ + userId: authReq.userId, + actionId: resource.list, + hostIds: hostIdsFrom(req.query.hostIds), + refresh: req.query.refresh === "true", + origin: originOf(authReq), + }); + res.json(flattenResourceRows(reads, resource.key)); + } catch (error) { + sendError(res, error, "operations_resources"); + } +}); + +/** + * @openapi + * /operations/{id}/run: + * post: + * summary: Run an action + * description: Goes through permission, host access, input and approval checks. A change returns as soon as it starts unless wait is true, then poll the run. A read always answers inline. + * tags: + * - Operations + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * resourceKey: + * type: string + * input: + * type: object + * dryRun: + * type: boolean + * confirmed: + * type: boolean + * idempotencyKey: + * type: string + * wait: + * type: boolean + * responses: + * 200: + * description: The outcome, or a run waiting for approval. + * 400: + * description: Invalid input. + * 403: + * description: Not allowed. + * 409: + * description: Needs a confirm, or safe mode is on. + */ +router.post("/:id/run", authenticateJWT, async (req, res) => { + const authReq = req as OperationsRequest; + const body = (req.body ?? {}) as Record; + try { + res.json( + await runOperation({ + actionId: String(req.params.id), + hostId: typeof body.hostId === "number" ? body.hostId : undefined, + resourceKey: + typeof body.resourceKey === "string" ? body.resourceKey : undefined, + input: body.input, + dryRun: body.dryRun === true, + confirmed: body.confirmed === true, + idempotencyKey: + typeof body.idempotencyKey === "string" + ? body.idempotencyKey.slice(0, 128) + : undefined, + userId: authReq.userId, + origin: originOf(authReq), + apiKeyScopes: authReq.apiKeyScopes, + }), + ); + } catch (error) { + sendError(res, error, "operations_run_action"); + } +}); + +export default router; diff --git a/src/backend/operations/approvals.ts b/src/backend/operations/approvals.ts new file mode 100644 index 000000000..8e9c10fcd --- /dev/null +++ b/src/backend/operations/approvals.ts @@ -0,0 +1,129 @@ +/** + * Who may run what without a person saying yes. + * + * A plugin fills this through ctx.operations.approvals.provide. Core asks every + * provider and the strictest answer wins. A provider that throws denies the + * run, since skipping a safety rule is worse than a refused action. + */ + +import type { OperationRisk } from "@termix-ssh/plugin-sdk/operations"; +import { riskRank } from "@termix-ssh/plugin-sdk/operations"; +import { hasCapability } from "../plugins/permissions.js"; +import { pluginLogger } from "../utils/logger.js"; +import type { ActorOrigin } from "../plugins/actor.js"; +import type { PluginManifest } from "../plugins/manifest.js"; + +export interface ApprovalPolicyInput { + actionId: string; + pluginId: string; + /** What the plugin declared. */ + risk: OperationRisk; + /** What rules should use. Plugins not signed as official count as change. */ + effectiveRisk: OperationRisk; + origin: ActorOrigin; + userId: string; + hostId?: number; + hostTags?: string[]; + batchId?: string; +} + +export interface ApprovalDecision { + decision: "allow" | "approve" | "deny"; + reason?: string; + /** The requester may approve their own run. */ + allowSelf?: boolean; + expiresInMinutes?: number; +} + +export type ApprovalPolicyProvider = ( + input: ApprovalPolicyInput, +) => + | ApprovalDecision + | null + | undefined + | Promise; + +interface Entry { + pluginId: string; + manifest: PluginManifest; + provider: ApprovalPolicyProvider; +} + +const providers = new Map(); + +export function provideApprovalPolicy( + pluginId: string, + manifest: PluginManifest, + provider: ApprovalPolicyProvider, +): () => void { + const key = Symbol(pluginId); + providers.set(key, { pluginId, manifest, provider }); + return () => { + providers.delete(key); + }; +} + +/** Test seam. */ +export function clearApprovalPolicies(): void { + providers.clear(); +} + +const RANK = { allow: 0, approve: 1, deny: 2 } as const; +const TRUSTED_VIA = new Set(["automation", "rollout", "fleet"]); +const SELF_APPROVE_VIA = new Set(["assistant", "mcp", "automation"]); + +/** A person is the check when the request came from something else. */ +export function canSelfApprove(origin: ActorOrigin): boolean { + return origin.kind === "plugin" && SELF_APPROVE_VIA.has(origin.via ?? ""); +} + +/** + * Used when no provider has an opinion. People and their own keys go by RBAC, + * and so do automations they set up. Anything else needs a rule that allows it. + */ +function builtIn(input: ApprovalPolicyInput): ApprovalDecision { + if (riskRank(input.effectiveRisk) === 0) return { decision: "allow" }; + if (input.origin.kind !== "plugin") return { decision: "allow" }; + if (TRUSTED_VIA.has(input.origin.via ?? "")) return { decision: "allow" }; + return { + decision: "deny", + reason: "This needs an approval rule. Turn on approvals to allow it.", + }; +} + +export async function evaluateApproval( + input: ApprovalPolicyInput, +): Promise { + let strictest: ApprovalDecision | null = null; + for (const entry of providers.values()) { + let decision: ApprovalDecision | null | undefined; + try { + // Rechecked at use, so a revoked grant stops the provider now. + if ( + !(await hasCapability( + entry.pluginId, + "operations:approve", + entry.manifest.capabilities, + )) + ) { + continue; + } + decision = await entry.provider(input); + } catch (error) { + pluginLogger.warn("Approval rule failed, refusing the run", { + operation: "operation_approval", + pluginId: entry.pluginId, + error: error instanceof Error ? error.message : String(error), + }); + return { + decision: "deny", + reason: "Approval rules are not available right now.", + }; + } + if (!decision) continue; + if (!strictest || RANK[decision.decision] > RANK[strictest.decision]) { + strictest = decision; + } + } + return strictest ?? builtIn(input); +} diff --git a/src/backend/operations/catalog.ts b/src/backend/operations/catalog.ts new file mode 100644 index 000000000..86c11dd21 --- /dev/null +++ b/src/backend/operations/catalog.ts @@ -0,0 +1,251 @@ +/** + * What a user can do right now, in their language. + * + * Built from the manifests of running plugins, never from GET /plugins, which + * hands every signed-in user the raw manifest. A plugin's locale file loads + * here on the server, because its frontend may not be loaded on the client: + * the CLI has no frontend, and a linked desktop may not have the plugin at all. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { + onSurface, + toJsonSchema, + type OperationAction, + type OperationEvent, + type OperationField, + type OperationResource, + type OperationSignal, + type OperationSurfaces, +} from "@termix-ssh/plugin-sdk/operations"; +import { getAction } from "./registry.js"; +import { qualifyPermission, type PluginManifest } from "../plugins/manifest.js"; +import { PermissionManager } from "../utils/permission-manager.js"; + +interface CatalogPlugin { + id: string; + dir: string; + manifest: PluginManifest; + state: string; + official: boolean; +} + +export interface CatalogField extends OperationField { + label: string; + description?: string; + placeholder?: string; + optionLabels?: Record; +} + +export interface CatalogAction extends Omit< + OperationAction, + "inputs" | "outputs" +> { + pluginId: string; + pluginName: string; + official: boolean; + title: string; + description?: string; + inputs: CatalogField[]; + outputs: CatalogField[]; + inputSchema: Record; + available: boolean; +} + +export interface CatalogEvent extends OperationEvent { + pluginId: string; + title: string; +} + +export interface CatalogResource extends OperationResource { + pluginId: string; + title: string; + columns: (NonNullable[number] & { + title: string; + })[]; +} + +export interface CatalogSignal extends OperationSignal { + pluginId: string; + title: string; +} + +export interface OperationCatalog { + actions: CatalogAction[]; + events: CatalogEvent[]; + resources: CatalogResource[]; + signals: CatalogSignal[]; +} + +type Strings = Record; + +const localeCache = new Map(); + +function flatten(value: unknown, prefix: string, out: Strings): void { + if (typeof value === "string") { + out[prefix] = value; + return; + } + if (typeof value !== "object" || value === null) return; + for (const [key, child] of Object.entries(value)) { + flatten(child, prefix ? `${prefix}.${key}` : key, out); + } +} + +function readLocale(file: string): Strings { + const out: Strings = {}; + try { + flatten(JSON.parse(fs.readFileSync(file, "utf8")), "", out); + } catch { + // A missing or broken file falls back to the key. + } + return out; +} + +/** Locale names reach a path, so only a plain language tag is used. */ +function safeLanguage(lang: string | undefined): string { + return lang && /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$/.test(lang) ? lang : "en"; +} + +function stringsFor(plugin: CatalogPlugin, lang: string): Strings { + const cacheKey = `${plugin.id}@${plugin.manifest.version}:${lang}`; + const cached = localeCache.get(cacheKey); + if (cached) return cached; + + const dir = path.resolve(plugin.dir, plugin.manifest.locales ?? "locales"); + const strings = readLocale(path.join(dir, "en.json")); + if (lang !== "en") { + Object.assign( + strings, + readLocale(path.join(dir, "translated", `${lang}.json`)), + readLocale(path.join(dir, `${lang}.json`)), + ); + } + localeCache.set(cacheKey, strings); + return strings; +} + +function localizedField(field: OperationField, strings: Strings): CatalogField { + const text = (key?: string) => (key ? (strings[key] ?? key) : undefined); + const optionLabels: Record = {}; + for (const option of field.options ?? []) { + optionLabels[option.value] = text(option.labelKey) ?? option.value; + } + return { + ...field, + label: text(field.labelKey) ?? field.key, + description: text(field.descriptionKey), + placeholder: text(field.placeholderKey), + optionLabels: field.options ? optionLabels : undefined, + }; +} + +async function runningPlugins(): Promise { + const { getPluginRuntime } = await import("../plugins/index.js"); + return getPluginRuntime() + .loader.list() + .filter((plugin) => plugin.state === "active") + .map((plugin) => ({ + id: plugin.id, + dir: plugin.dir, + manifest: plugin.manifest, + state: plugin.state, + official: plugin.source === "bundled" || plugin.signedTier === "official", + })); +} + +export async function buildCatalog(options: { + userId: string; + lang?: string; + surface?: keyof OperationSurfaces; +}): Promise { + const lang = safeLanguage(options.lang); + const permissions = PermissionManager.getInstance(); + const catalog: OperationCatalog = { + actions: [], + events: [], + resources: [], + signals: [], + }; + + for (const plugin of await runningPlugins()) { + const operations = plugin.manifest.contributes?.operations; + if (!operations) continue; + const strings = stringsFor(plugin, lang); + const text = (key: string) => strings[key] ?? key; + + for (const action of operations.actions ?? []) { + if (options.surface && !onSurface(action, options.surface)) continue; + // Community plugins stay away from the assistant and MCP until an admin allows them. + if ( + !plugin.official && + (options.surface === "assistant" || options.surface === "mcp") + ) { + continue; + } + const permission = qualifyPermission(plugin.id, action.permission); + if (!(await permissions.hasPermission(options.userId, permission))) { + continue; + } + const inputs = (action.inputs ?? []).map((field) => + localizedField(field, strings), + ); + catalog.actions.push({ + ...action, + pluginId: plugin.id, + pluginName: plugin.manifest.name, + official: plugin.official, + title: text(action.titleKey), + description: action.descriptionKey + ? text(action.descriptionKey) + : undefined, + inputs, + outputs: (action.outputs ?? []) + .filter((field) => !field.sensitive) + .map((field) => localizedField(field, strings)), + inputSchema: toJsonSchema( + action.inputs, + (field) => + inputs.find((input) => input.key === field.key)?.description, + ), + available: getAction(action.id) !== undefined, + }); + } + + const visible = new Set(catalog.actions.map((action) => action.id)); + for (const event of operations.events ?? []) { + catalog.events.push({ + ...event, + pluginId: plugin.id, + title: text(event.titleKey), + }); + } + for (const resource of operations.resources ?? []) { + if (!visible.has(resource.list)) continue; + catalog.resources.push({ + ...resource, + pluginId: plugin.id, + title: text(resource.titleKey), + columns: (resource.columns ?? []).map((column) => ({ + ...column, + title: text(column.titleKey), + })), + }); + } + for (const signal of operations.signals ?? []) { + if (!visible.has(signal.read)) continue; + catalog.signals.push({ + ...signal, + pluginId: plugin.id, + title: text(signal.titleKey), + }); + } + } + return catalog; +} + +/** Test seam. */ +export function clearCatalogCache(): void { + localeCache.clear(); +} diff --git a/src/backend/operations/constants.ts b/src/backend/operations/constants.ts new file mode 100644 index 000000000..d8a99e90a --- /dev/null +++ b/src/backend/operations/constants.ts @@ -0,0 +1,8 @@ +/** How many events or actions may lead to one another before the chain stops. */ +export const MAX_EVENT_DEPTH = 3; + +/** How long finished runs and events stay in history. */ +export const RETENTION_DAYS = 90; + +/** How long a cached table row or tile counts as fresh. */ +export const RESOURCE_FRESH_MS = 60_000; diff --git a/src/backend/operations/events.ts b/src/backend/operations/events.ts new file mode 100644 index 000000000..0c7f3e4d4 --- /dev/null +++ b/src/backend/operations/events.ts @@ -0,0 +1,155 @@ +/** + * Declared events, separate from the plugin.* bus any plugin can read. + * + * Each event is kept in a capped timeline. A subscriber bound to a user only + * hears about hosts that user can reach, checked at delivery. Events carry a + * depth, so a rule that fires an action that fires the same rule stops. + */ + +import { randomUUID } from "node:crypto"; +import { MAX_EVENT_DEPTH } from "./constants.js"; +import { getActorTrail, runAsActor } from "../plugins/actor.js"; +import { PermissionManager } from "../utils/permission-manager.js"; +import { pluginLogger } from "../utils/logger.js"; +import { createCurrentOperationRepository } from "../database/repositories/factory.js"; + +export interface OperationEventInput { + eventId: string; + pluginId: string; + hostId?: number; + severity?: string; + payload?: unknown; + runId?: string; + depth?: number; +} + +export interface DeliveredEvent { + id: string; + eventId: string; + pluginId: string; + hostId?: number; + severity?: string; + payload?: unknown; + runId?: string; + depth: number; + at: string; +} + +export interface OperationEventSubscription { + /** Exact id, `.*` or `*`. */ + pattern: string; + handler: (event: DeliveredEvent) => void | Promise; + /** Only events for hosts this user can reach, and runs the handler as them. */ + userId?: string; + /** Skips repeats of one event and host inside this window. */ + cooldownMs?: number; +} + +interface Subscriber extends OperationEventSubscription { + pluginId: string; + lastFired: Map; +} + +const subscribers = new Set(); + +function matches(pattern: string, eventId: string): boolean { + if (pattern === "*" || pattern === eventId) return true; + return pattern.endsWith(".*") && eventId.startsWith(pattern.slice(0, -1)); +} + +export function subscribeOperationEvents( + pluginId: string, + subscription: OperationEventSubscription, +): () => void { + const entry = { ...subscription, pluginId, lastFired: new Map() }; + subscribers.add(entry); + return () => { + subscribers.delete(entry); + }; +} + +async function deliver(subscriber: Subscriber, event: DeliveredEvent) { + const key = `${event.eventId}|${event.hostId ?? ""}`; + if (subscriber.cooldownMs) { + const last = subscriber.lastFired.get(key) ?? 0; + if (Date.now() - last < subscriber.cooldownMs) return; + subscriber.lastFired.set(key, Date.now()); + } + + const { userId } = subscriber; + if (userId && event.hostId !== undefined) { + const access = await PermissionManager.getInstance().canAccessHost( + userId, + event.hostId, + "connect", + ); + if (!access.hasAccess) return; + } + + const fire = () => Promise.resolve(subscriber.handler(event)); + await (userId + ? runAsActor(userId, "service", fire, undefined, { depth: event.depth }) + : fire()); +} + +/** Never throws. An event must not be able to fail what caused it. */ +export async function emitOperationEvent( + input: OperationEventInput, +): Promise { + try { + const trail = getActorTrail(); + const depth = input.depth ?? trail.depth ?? 0; + const event: DeliveredEvent = { + id: randomUUID(), + eventId: input.eventId, + pluginId: input.pluginId, + hostId: input.hostId, + severity: input.severity, + payload: input.payload, + runId: input.runId ?? trail.runId, + depth, + at: new Date().toISOString(), + }; + + await createCurrentOperationRepository().insertEvent({ + id: event.id, + eventId: event.eventId, + pluginId: event.pluginId, + hostId: event.hostId ?? null, + severity: event.severity ?? null, + payload: + event.payload === undefined ? null : JSON.stringify(event.payload), + runId: event.runId ?? null, + createdAt: event.at, + }); + + if (depth >= MAX_EVENT_DEPTH) { + pluginLogger.warn("Event chain stopped at the depth limit", { + operation: "operation_event_depth", + eventId: event.eventId, + }); + return; + } + + for (const subscriber of subscribers) { + if (!matches(subscriber.pattern, event.eventId)) continue; + void deliver(subscriber, event).catch((error) => { + pluginLogger.warn("Event subscriber failed", { + operation: "operation_event_delivery", + pluginId: subscriber.pluginId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + } catch (error) { + pluginLogger.warn("Could not record an event", { + operation: "operation_event", + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** Test seam. */ +export function clearOperationEvents(): void { + subscribers.clear(); +} diff --git a/src/backend/operations/gate.ts b/src/backend/operations/gate.ts new file mode 100644 index 000000000..46b2b5152 --- /dev/null +++ b/src/backend/operations/gate.ts @@ -0,0 +1,891 @@ +/** + * The one path every action takes, from any surface. + * + * In order: plugin up, grant still held, input valid, role permission, host + * access, key scope, destructive confirm, approval, limits, run row, audit. + * Handlers run in-process, so this is an access check for callers, not a + * sandbox around plugins. + * + * Handler failures stay here. They never reach the plugin error budget, or five + * failed hosts in a rollout would switch the plugin off. + */ + +import { randomUUID } from "node:crypto"; +import { + capText, + matchesWhen, + parseOperationInput, + redactFields, + riskRank, + type OperationAction, + type OperationRisk, +} from "@termix-ssh/plugin-sdk/operations"; +import { MAX_EVENT_DEPTH } from "./constants.js"; +import { getAction, type OperationRegistration } from "./registry.js"; +import { + canSelfApprove, + evaluateApproval, + type ApprovalDecision, +} from "./approvals.js"; +import { withLimits } from "./limits.js"; +import { emitOperationEvent } from "./events.js"; +import { dropResourceCache } from "./resources.js"; +import { loadOperationHost, type OperationHost } from "./hosts.js"; +import { runAsActor, type ActorOrigin } from "../plugins/actor.js"; +import { hasCapability } from "../plugins/permissions.js"; +import { qualifyPermission } from "../plugins/manifest.js"; +import { safeModeOn } from "../plugins/safe-mode.js"; +import { PermissionManager } from "../utils/permission-manager.js"; +import { logAudit } from "../utils/audit-logger.js"; +import { + decryptSystemSecret, + encryptSystemSecret, + isSystemEncrypted, +} from "../utils/system-secret-crypto.js"; +import { pluginLogger } from "../utils/logger.js"; +import { + createCurrentOperationRepository, + createCurrentUserRepository, +} from "../database/repositories/factory.js"; +import type { + OperationApprovalRecord, + OperationRunRecord, +} from "../database/repositories/operation-repository.js"; + +const MAX_OUTPUT = 32_000; +const DEFAULT_TIMEOUT_MS = 300_000; +const READ_TIMEOUT_MS = 60_000; +const DEFAULT_APPROVAL_MINUTES = 24 * 60; +const DRAIN_WAIT_MS = 10_000; + +export type OperationErrorCode = + | "NOT_FOUND" + | "UNAVAILABLE" + | "INVALID_INPUT" + | "FORBIDDEN" + | "SCOPE" + | "NEEDS_CONFIRM" + | "DENIED" + | "TOO_DEEP" + | "STALE" + | "CONFLICT" + | "SAFE_MODE"; + +const STATUS: Record = { + NOT_FOUND: 404, + UNAVAILABLE: 503, + INVALID_INPUT: 400, + FORBIDDEN: 403, + SCOPE: 403, + NEEDS_CONFIRM: 409, + DENIED: 403, + TOO_DEEP: 409, + STALE: 409, + CONFLICT: 409, + SAFE_MODE: 409, +}; + +/** Refused before anything ran. Carries a status so routes can pass it on. */ +export class OperationError extends Error { + readonly status: number; + + constructor( + readonly code: OperationErrorCode, + message: string, + readonly details?: unknown, + ) { + super(message); + this.name = "OperationError"; + this.status = STATUS[code]; + } +} + +/** A handler throws this when a host needs a person to sign in first. */ +export class OperationNeedsInteraction extends Error { + readonly code = "NEEDS_INTERACTION"; +} + +export interface OperationRequest { + actionId: string; + hostId?: number; + resourceKey?: string; + input?: unknown; + dryRun?: boolean; + /** A person agreed to a destructive action. */ + confirmed?: boolean; + idempotencyKey?: string; + batchId?: string; + undoOf?: string; + /** Background reads for tables and tiles. Writes no run row. */ + internal?: boolean; + userId: string; + origin: ActorOrigin; + apiKeyScopes?: string[] | null; + depth?: number; +} + +export interface OperationOutcome { + runId: string | null; + status: string; + summary?: string; + changed?: boolean; + data?: unknown; + error?: string; + errorCode?: string; + approvalId?: string; +} + +interface Prepared { + registration: OperationRegistration; + action: OperationAction; + pluginVersion: string; + input: Record; + host: OperationHost | null; + effectiveRisk: OperationRisk; +} + +/** The sealed copy an approval is bound to. */ +interface ApprovalSnapshot { + runId: string; + actionId: string; + actionVersion: number; + pluginVersion: string; + hostId: number | null; + hostUpdatedAt: string | null; + resourceKey: string | null; + input: Record; +} + +const active = new Map< + string, + { pluginId: string; controller: AbortController; done: Promise } +>(); +const draining = new Set(); + +function now(): string { + return new Date().toISOString(); +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function loadedPlugin(pluginId: string) { + const { getPluginRuntime } = await import("../plugins/index.js"); + return getPluginRuntime().loader.get(pluginId); +} + +async function usernameOf(userId: string): Promise { + try { + const user = await createCurrentUserRepository().findById(userId); + return user?.username ?? userId; + } catch { + return userId; + } +} + +/** Every check that does not depend on who approved it. */ +async function prepare(request: OperationRequest): Promise { + if (safeModeOn()) { + throw new OperationError("SAFE_MODE", "Actions are off in safe mode"); + } + if ((request.depth ?? 0) > MAX_EVENT_DEPTH) { + throw new OperationError("TOO_DEEP", "Too many actions led to this one"); + } + + const registration = getAction(request.actionId); + if (!registration) { + throw new OperationError( + "NOT_FOUND", + `No action "${request.actionId}" is available`, + ); + } + const { action, pluginId } = registration; + const plugin = await loadedPlugin(pluginId); + if (plugin?.state !== "active" || draining.has(pluginId)) { + throw new OperationError("UNAVAILABLE", `${pluginId} is not running`); + } + // Rechecked at use, so revoking the grant stops it on the next call. + if ( + !(await hasCapability( + pluginId, + "operations:provide", + plugin.manifest.capabilities, + )) + ) { + throw new OperationError("UNAVAILABLE", `${pluginId} may not run actions`); + } + + const parsed = parseOperationInput(action.inputs, request.input); + if (parsed.errors.length > 0) { + throw new OperationError( + "INVALID_INPUT", + parsed.errors.join("; "), + parsed.errors, + ); + } + + const permissions = PermissionManager.getInstance(); + const permission = qualifyPermission(pluginId, action.permission); + if (!(await permissions.hasPermission(request.userId, permission))) { + throw new OperationError( + "FORBIDDEN", + `This needs the ${permission} permission`, + ); + } + + let host: OperationHost | null = null; + if (action.scope !== "server") { + if (request.hostId === undefined) { + throw new OperationError("INVALID_INPUT", "This action needs a host"); + } + if (action.scope === "resource" && !request.resourceKey) { + throw new OperationError("INVALID_INPUT", "This action needs a target"); + } + const access = await permissions.canAccessHost( + request.userId, + request.hostId, + action.hostAccess ?? "edit", + ); + if (!access.hasAccess) { + throw new OperationError( + "FORBIDDEN", + `You need ${action.hostAccess ?? "edit"} access to this host`, + ); + } + host = await loadOperationHost(request.hostId, plugin.manifest); + if (!host) throw new OperationError("NOT_FOUND", "Host not found"); + if (!matchesWhen(action.when, host)) { + throw new OperationError( + "UNAVAILABLE", + "This action does not apply to this host", + ); + } + } + + // A claim by the plugin, so only signed official plugins are taken at + // their word when rules decide what needs a person. + const official = + plugin.source === "bundled" || plugin.signedTier === "official"; + const effectiveRisk: OperationRisk = + !official && action.risk === "read" ? "change" : action.risk; + + return { + registration, + action, + pluginVersion: plugin.manifest.version, + input: parsed.values, + host, + effectiveRisk, + }; +} + +function checkKeyScope(request: OperationRequest, action: OperationAction) { + const scopes = request.apiKeyScopes; + if (scopes === undefined || scopes === null) return; + if (scopes.includes("operations:run")) return; + if (scopes.includes("operations:read") && action.risk === "read") return; + throw new OperationError("SCOPE", "This API key may not run this action"); +} + +function originText(origin: ActorOrigin): string { + return JSON.stringify(origin); +} + +function auditRun( + request: OperationRequest, + username: string, + action: OperationAction, + host: OperationHost | null, + success: boolean, + errorMessage?: string, + verb = "operation_run", +): void { + void logAudit({ + userId: request.userId, + username, + action: verb, + resourceType: "operation", + resourceId: action.id, + resourceName: host?.name ?? undefined, + details: originText(request.origin), + success, + errorMessage, + }).catch(() => {}); +} + +async function execute( + prepared: Prepared, + request: OperationRequest, + runId: string | null, + username: string, +): Promise { + const { action, registration, input, host } = prepared; + const repository = createCurrentOperationRepository(); + const controller = new AbortController(); + const dryRun = request.dryRun === true; + const timeoutMs = + action.timeoutMs ?? + (action.risk === "read" ? READ_TIMEOUT_MS : DEFAULT_TIMEOUT_MS); + const lines: string[] = []; + const context = { + hostId: request.hostId, + resourceKey: request.resourceKey, + runId, + dryRun, + signal: controller.signal, + log: (line: string) => { + if (lines.length < 200) lines.push(line); + }, + }; + + const work = async () => { + if (dryRun && action.dryRun !== "native") { + const summary = registration.handler.describe + ? await registration.handler.describe(input, context) + : `Would run ${action.id}`; + return { summary, changed: false }; + } + return registration.handler.run(input, context); + }; + + const timer = setTimeout(() => controller.abort(), timeoutMs); + timer.unref?.(); + const aborted = new Promise((_, reject) => { + controller.signal.addEventListener("abort", () => + reject(new Error("The action was stopped or timed out")), + ); + }); + aborted.catch(() => {}); + + // Settles when the handler really stops, which can be after the run gives + // up on it. A plugin going down waits on this, not on the run. + let handlerStopped: () => void = () => {}; + const stopped = new Promise((resolve) => { + handlerStopped = resolve; + }); + + const startWork = () => { + // Cancelled while waiting for a slot, so it never starts. + if (controller.signal.aborted) { + handlerStopped(); + return aborted; + } + const handling = Promise.resolve().then(work); + void handling.finally(handlerStopped).catch(() => {}); + return Promise.race([handling, aborted]); + }; + + const running = withLimits( + { hostId: request.hostId, exclusive: action.exclusive }, + () => + runAsActor(request.userId, "service", startWork, undefined, { + origin: request.origin, + apiKeyScopes: request.apiKeyScopes, + runId: runId ?? undefined, + depth: (request.depth ?? 0) + 1, + }), + ); + if (runId) { + active.set(runId, { + pluginId: registration.pluginId, + controller, + done: stopped, + }); + } + + let outcome: OperationOutcome; + try { + const result = await running; + const data = redactFields(action.outputs, result.data); + outcome = { + runId, + status: "success", + summary: result.summary, + changed: result.changed, + data, + }; + if (runId) { + await repository.updateRunFrom(runId, "running", { + status: "success", + summary: capText(result.summary ?? "", 2000), + changed: result.changed ?? null, + output: + data === undefined ? null : capText(JSON.stringify(data), MAX_OUTPUT), + undoInput: result.undoInput ? JSON.stringify(result.undoInput) : null, + sealedInput: null, + finishedAt: now(), + }); + } + } catch (error) { + const needsPerson = + (error as { code?: unknown } | null)?.code === "NEEDS_INTERACTION"; + const stopping = draining.has(registration.pluginId); + const status = needsPerson + ? "needs_interaction" + : stopping + ? "interrupted" + : "failed"; + const message = stopping + ? "The plugin stopped during this run. Check the host." + : capText(errorText(error), 2000); + outcome = { runId, status, error: message }; + if (needsPerson) outcome.errorCode = "NEEDS_INTERACTION"; + if (runId) { + await repository.updateRunFrom(runId, "running", { + status, + error: message, + errorCode: outcome.errorCode ?? null, + sealedInput: null, + finishedAt: now(), + }); + } + } finally { + clearTimeout(timer); + if (runId && !draining.has(registration.pluginId)) active.delete(runId); + } + + if (action.risk !== "read" && !dryRun) { + if (request.hostId !== undefined && outcome.status === "success") { + dropResourceCache(request.hostId); + } + auditRun( + request, + username, + action, + host, + outcome.status === "success", + outcome.error, + ); + void emitOperationEvent({ + eventId: + outcome.status === "success" + ? "operation.succeeded" + : "operation.failed", + pluginId: registration.pluginId, + hostId: request.hostId, + severity: outcome.status === "success" ? "info" : "warning", + payload: { + actionId: action.id, + summary: outcome.summary, + error: outcome.error, + origin: request.origin, + }, + runId: runId ?? undefined, + depth: (request.depth ?? 0) + 1, + }); + } + return outcome; +} + +function outcomeOf( + run: OperationRunRecord, + approvalId?: string, +): OperationOutcome { + return { + runId: run.id, + status: run.status, + summary: run.summary ?? undefined, + changed: run.changed ?? undefined, + error: run.error ?? undefined, + errorCode: run.errorCode ?? undefined, + approvalId, + }; +} + +async function requestApproval( + prepared: Prepared, + request: OperationRequest, + decision: ApprovalDecision, + run: { id: string; username: string; createdAt: string }, +): Promise { + const { action, host, input } = prepared; + const repository = createCurrentOperationRepository(); + const snapshot: ApprovalSnapshot = { + runId: run.id, + actionId: action.id, + actionVersion: action.version ?? 1, + pluginVersion: prepared.pluginVersion, + hostId: request.hostId ?? null, + hostUpdatedAt: host?.updatedAt ?? null, + resourceKey: request.resourceKey ?? null, + input, + }; + await repository.updateRunFrom(run.id, "created", { + status: "pending_approval", + summary: decision.reason ?? null, + sealedInput: await encryptSystemSecret(JSON.stringify(snapshot)), + }); + const minutes = decision.expiresInMinutes ?? DEFAULT_APPROVAL_MINUTES; + const approvalId = randomUUID(); + await repository.insertApproval({ + id: approvalId, + runId: run.id, + requestedBy: request.userId, + reason: decision.reason ?? null, + allowSelf: decision.allowSelf === true || canSelfApprove(request.origin), + status: "pending", + createdAt: run.createdAt, + expiresAt: new Date(Date.now() + minutes * 60_000).toISOString(), + }); + void emitOperationEvent({ + eventId: "operation.approval-requested", + pluginId: prepared.registration.pluginId, + hostId: request.hostId, + severity: "warning", + payload: { actionId: action.id, approvalId }, + runId: run.id, + }); + return { runId: run.id, status: "pending_approval", approvalId }; +} + +/** Runs an action, or parks it for approval. Throws OperationError if refused. */ +export async function runOperation( + request: OperationRequest, +): Promise { + const registration = getAction(request.actionId); + if (registration) checkKeyScope(request, registration.action); + + const repository = createCurrentOperationRepository(); + if (request.idempotencyKey) { + const earlier = await repository.findByIdempotencyKey( + request.userId, + request.idempotencyKey, + ); + if (earlier) return outcomeOf(earlier); + } + + const prepared = await prepare(request); + const { action } = prepared; + const dryRun = request.dryRun === true; + const isRead = action.risk === "read"; + + if (action.risk === "destructive" && !dryRun && !request.confirmed) { + throw new OperationError("NEEDS_CONFIRM", "This action needs a confirm"); + } + + let decision: ApprovalDecision = { decision: "allow" }; + if (!dryRun && !request.internal) { + decision = await evaluateApproval({ + actionId: action.id, + pluginId: prepared.registration.pluginId, + risk: action.risk, + effectiveRisk: prepared.effectiveRisk, + origin: request.origin, + userId: request.userId, + hostId: request.hostId, + hostTags: prepared.host?.tags, + batchId: request.batchId, + }); + } + const username = await usernameOf(request.userId); + if (decision.decision === "deny") { + auditRun( + request, + username, + action, + prepared.host, + false, + decision.reason, + "operation_denied", + ); + throw new OperationError( + "DENIED", + decision.reason ?? "A rule refused this action", + ); + } + + // Internal reads poll constantly and would flood history and the save loop. + if (request.internal && isRead) { + return execute(prepared, request, null, username); + } + + const run = { + id: randomUUID(), + actionId: action.id, + actionVersion: action.version ?? 1, + pluginId: prepared.registration.pluginId, + pluginVersion: prepared.pluginVersion, + userId: request.userId, + username, + origin: originText(request.origin), + hostId: request.hostId ?? null, + hostName: prepared.host?.name ?? null, + batchId: request.batchId ?? null, + dryRun, + risk: action.risk, + status: decision.decision === "approve" ? "created" : "running", + input: JSON.stringify(redactFields(action.inputs, prepared.input)), + undoOf: request.undoOf ?? null, + idempotencyKey: request.idempotencyKey ?? null, + createdAt: now(), + startedAt: decision.decision === "approve" ? null : now(), + }; + await repository.insertRun(run); + + if (decision.decision === "approve") { + return requestApproval(prepared, request, decision, run); + } + return execute(prepared, request, run.id, username); +} + +function originOf(run: OperationRunRecord): ActorOrigin { + try { + return JSON.parse(run.origin) as ActorOrigin; + } catch { + return { kind: "api" }; + } +} + +async function failStale(run: OperationRunRecord, why: string): Promise { + await createCurrentOperationRepository().updateRunFrom( + run.id, + "pending_approval", + { + status: "rejected", + error: why, + sealedInput: null, + finishedAt: now(), + }, + ); +} + +/** + * Records a person's decision. One shot: the row moves from pending once, so a + * second click or a replay does nothing. What runs is the sealed copy, never + * what the caller sends now. + */ +export async function decideOperationApproval(options: { + approvalId: string; + approverId: string; + approve: boolean; + comment?: string; + impersonated?: boolean; +}): Promise { + const { approvalId, approverId, approve, comment } = options; + const repository = createCurrentOperationRepository(); + const approval = await repository.findApproval(approvalId); + if (!approval) throw new OperationError("NOT_FOUND", "Approval not found"); + if (approval.status !== "pending") { + throw new OperationError("CONFLICT", "This was already decided"); + } + const run = await repository.findRun(approval.runId); + if (!run) throw new OperationError("NOT_FOUND", "Run not found"); + + if (options.impersonated) { + throw new OperationError("FORBIDDEN", "You cannot approve as another user"); + } + if (approval.requestedBy === approverId && !approval.allowSelf) { + throw new OperationError( + "FORBIDDEN", + "Someone else has to approve your request", + ); + } + + if (new Date(approval.expiresAt).getTime() < Date.now()) { + await repository.decideApproval(approvalId, "expired", null, null, now()); + await failStale(run, "The approval expired"); + throw new OperationError("STALE", "This approval expired"); + } + + // Approving needs the same rights as running it. + const registration = getAction(run.actionId); + if (registration) { + const permissions = PermissionManager.getInstance(); + const needed = qualifyPermission( + registration.pluginId, + registration.action.permission, + ); + const allowed = + (await permissions.hasPermission(approverId, needed)) && + (run.hostId === null || + ( + await permissions.canAccessHost( + approverId, + run.hostId, + registration.action.hostAccess ?? "edit", + ) + ).hasAccess); + if (!allowed) { + throw new OperationError( + "FORBIDDEN", + "You need the same access as this action to approve it", + ); + } + } + + const won = await repository.decideApproval( + approvalId, + approve ? "approved" : "rejected", + approverId, + comment?.slice(0, 500) ?? null, + now(), + ); + if (!won) throw new OperationError("CONFLICT", "This was already decided"); + + const username = await usernameOf(approverId); + void logAudit({ + userId: approverId, + username, + action: approve ? "operation_approved" : "operation_rejected", + resourceType: "operation", + resourceId: run.actionId, + details: run.id, + success: true, + }).catch(() => {}); + + if (!approve) { + await repository.updateRunFrom(run.id, "pending_approval", { + status: "rejected", + sealedInput: null, + finishedAt: now(), + }); + return { runId: run.id, status: "rejected" }; + } + return runApproved(run, approval); +} + +async function readSnapshot( + run: OperationRunRecord, +): Promise { + if (!run.sealedInput || !isSystemEncrypted(run.sealedInput)) return null; + try { + const snapshot = JSON.parse( + await decryptSystemSecret(run.sealedInput), + ) as ApprovalSnapshot; + return snapshot.runId === run.id ? snapshot : null; + } catch { + return null; + } +} + +async function runApproved( + run: OperationRunRecord, + approval: OperationApprovalRecord, +): Promise { + const snapshot = await readSnapshot(run); + if (!snapshot || !run.userId) { + await failStale(run, "The approved request could not be read"); + throw new OperationError("STALE", "The approved request could not be read"); + } + + const request: OperationRequest = { + actionId: snapshot.actionId, + hostId: snapshot.hostId ?? undefined, + resourceKey: snapshot.resourceKey ?? undefined, + input: snapshot.input, + confirmed: true, + batchId: run.batchId ?? undefined, + userId: run.userId, + origin: originOf(run), + }; + + // The requester's own rights, as of now. They may have changed. + let prepared: Prepared; + try { + prepared = await prepare(request); + } catch (error) { + await failStale(run, errorText(error)); + throw error; + } + + const changedSince = + prepared.pluginVersion !== snapshot.pluginVersion || + (prepared.action.version ?? 1) !== snapshot.actionVersion || + (prepared.host?.updatedAt ?? null) !== snapshot.hostUpdatedAt; + if (changedSince) { + await failStale(run, "The host or plugin changed after this was requested"); + throw new OperationError( + "STALE", + "The host or plugin changed after this was requested", + ); + } + + const repository = createCurrentOperationRepository(); + const claimed = await repository.updateRunFrom(run.id, "pending_approval", { + status: "running", + startedAt: now(), + }); + if (!claimed) throw new OperationError("CONFLICT", "This already ran"); + + const outcome = await execute(prepared, request, run.id, run.username); + return { ...outcome, approvalId: approval.id }; +} + +export async function cancelOperationRun( + run: OperationRunRecord, +): Promise { + const repository = createCurrentOperationRepository(); + if (run.status === "pending_approval") { + const approval = await repository.findApprovalForRun(run.id); + if (approval) { + await repository.decideApproval( + approval.id, + "rejected", + null, + "cancelled", + now(), + ); + } + return repository.updateRunFrom(run.id, "pending_approval", { + status: "cancelled", + sealedInput: null, + finishedAt: now(), + }); + } + const running = active.get(run.id); + if (!running) return false; + running.controller.abort(); + return true; +} + +/** + * Called when a plugin stops, before its SSH connections are torn down. New + * runs are refused and running ones told to stop. Anything still going after + * the wait has an unknown outcome and is never retried. + */ +export async function drainPlugin(pluginId: string): Promise { + draining.add(pluginId); + try { + const runs = [...active.entries()].filter( + ([, entry]) => entry.pluginId === pluginId, + ); + if (runs.length === 0) return; + for (const [, entry] of runs) entry.controller.abort(); + await Promise.race([ + Promise.all(runs.map(([, entry]) => entry.done)), + new Promise((resolve) => setTimeout(resolve, DRAIN_WAIT_MS)), + ]); + const repository = createCurrentOperationRepository(); + for (const [runId] of runs) { + await repository.updateRunFrom(runId, "running", { + status: "interrupted", + error: "The plugin stopped during this run. Check the host.", + sealedInput: null, + finishedAt: now(), + }); + active.delete(runId); + } + } catch (error) { + pluginLogger.warn("Could not finish stopping runs", { + operation: "operation_drain", + pluginId, + error: errorText(error), + }); + } finally { + draining.delete(pluginId); + } +} + +export function riskAtLeast( + risk: OperationRisk, + floor: OperationRisk, +): boolean { + return riskRank(risk) >= riskRank(floor); +} + +/** Test seam. */ +export function clearOperationGate(): void { + active.clear(); + draining.clear(); +} diff --git a/src/backend/operations/hosts.ts b/src/backend/operations/hosts.ts new file mode 100644 index 000000000..6f4de0345 --- /dev/null +++ b/src/backend/operations/hosts.ts @@ -0,0 +1,68 @@ +/** + * The small view of a host the operations layer needs: enough to name it, test + * an action's `when`, and notice it changed. No secret field is ever read here. + */ + +import type { OperationWhen } from "@termix-ssh/plugin-sdk/operations"; +import type { PluginManifest } from "../plugins/manifest.js"; +import { getSetting } from "../plugins/settings.js"; +import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js"; + +export interface OperationHost { + id: number; + name: string; + updatedAt: string | null; + tags: string[]; + os: string | null; + /** Only the setting keys some `when` asks about. */ + settings: Record; +} + +export function splitTags(tags: string | null | undefined): string[] { + return (tags ?? "") + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); +} + +async function settingsFor( + manifest: PluginManifest, + hostId: number, +): Promise> { + const keys = new Set(); + for (const action of manifest.contributes?.operations?.actions ?? []) { + for (const key of Object.keys(action.when?.settings ?? {})) keys.add(key); + } + const settings: Record = {}; + for (const key of keys) { + settings[key] = await getSetting(manifest, "host", hostId, key); + } + return settings; +} + +/** A host with the settings its plugin's `when` rules read. */ +export async function loadOperationHost( + hostId: number, + manifest: PluginManifest, +): Promise { + const repository = createCurrentHostResolutionRepository(); + const ownerId = await repository.findHostOwnerId(hostId); + if (!ownerId) return null; + const host = (await repository.findHostById(hostId, ownerId)) as { + name: string | null; + ip: string; + tags: string | null; + updatedAt?: string | null; + } | null; + if (!host) return null; + return { + id: hostId, + name: host.name || host.ip, + updatedAt: host.updatedAt ?? null, + tags: splitTags(host.tags), + os: null, + settings: await settingsFor(manifest, hostId), + }; +} + +export type { OperationWhen }; diff --git a/src/backend/operations/key-scopes.ts b/src/backend/operations/key-scopes.ts new file mode 100644 index 000000000..4db923588 --- /dev/null +++ b/src/backend/operations/key-scopes.ts @@ -0,0 +1,44 @@ +/** + * What a scoped API key may reach. Enforced where keys are authenticated, so a + * limited key cannot step around the gate through /plugin-api or a ws ticket. A + * key with no scopes has full access, which is every key made before this. + */ + +export const API_KEY_SCOPES = ["operations:read", "operations:run"] as const; + +const MCP_PATH = /^\/plugin-api\/[a-z0-9-]+\/mcp(\/|$)/; +const OPERATIONS_PATH = /^\/operations(\/|$)/; + +export function parseKeyScopes( + raw: string | null | undefined, +): string[] | null { + if (!raw) return null; + try { + const value = JSON.parse(raw); + return Array.isArray(value) + ? value.filter((scope): scope is string => typeof scope === "string") + : []; + } catch { + // Unreadable means no access, not full access. + return []; + } +} + +export function keyScopeAllows( + scopes: string[] | null, + method: string, + path: string, +): boolean { + if (scopes === null) return true; + const clean = path.split("?")[0]; + if (MCP_PATH.test(clean)) { + return ( + scopes.includes("operations:read") || scopes.includes("operations:run") + ); + } + if (!OPERATIONS_PATH.test(clean)) return false; + if (scopes.includes("operations:run")) return true; + if (!scopes.includes("operations:read")) return false; + // The gate still checks each action's risk on a run. + return method === "GET" || /^\/operations\/[^/]+\/run$/.test(clean); +} diff --git a/src/backend/operations/limits.ts b/src/backend/operations/limits.ts new file mode 100644 index 000000000..11ecb1eb5 --- /dev/null +++ b/src/backend/operations/limits.ts @@ -0,0 +1,90 @@ +/** + * Caps on how much runs at once. The SSH pool caps per key, not per host, so a + * large rollout or an all hosts table needs its own limit here. + */ + +export const MAX_RUNNING = 32; +export const MAX_PER_HOST = 3; + +class Slots { + private used = 0; + private readonly waiting: (() => void)[] = []; + + constructor(private readonly size: number) {} + + async take(): Promise { + if (this.used < this.size) { + this.used++; + return; + } + await new Promise((resolve) => this.waiting.push(resolve)); + } + + give(): void { + const next = this.waiting.shift(); + if (next) next(); + else this.used--; + } + + get idle(): boolean { + return this.used === 0 && this.waiting.length === 0; + } +} + +const everything = new Slots(MAX_RUNNING); +const perHost = new Map(); +const exclusive = new Map(); + +function slotsFor( + map: Map, + hostId: number, + size: number, +): Slots { + let slots = map.get(hostId); + if (!slots) { + slots = new Slots(size); + map.set(hostId, slots); + } + return slots; +} + +/** Runs `work` once there is room. Exclusive work on a host goes one at a time. */ +export async function withLimits( + options: { hostId?: number; exclusive?: boolean }, + work: () => Promise, +): Promise { + const { hostId } = options; + const held: { map?: Map; slots: Slots }[] = []; + const hold = async (slots: Slots, map?: Map) => { + await slots.take(); + held.push({ slots, map }); + }; + + try { + // Same order everywhere, so two runs cannot wait on each other. + if (hostId !== undefined && options.exclusive) { + await hold(slotsFor(exclusive, hostId, 1), exclusive); + } + if (hostId !== undefined) { + await hold(slotsFor(perHost, hostId, MAX_PER_HOST), perHost); + } + await hold(everything); + return await work(); + } finally { + for (const { slots, map } of held.reverse()) { + slots.give(); + if (map && hostId !== undefined && slots.idle) map.delete(hostId); + } + } +} + +/** Shares one in-flight promise per key, so one read serves every waiter. */ +const inflight = new Map>(); + +export function coalesce(key: string, work: () => Promise): Promise { + const running = inflight.get(key); + if (running) return running as Promise; + const started = work().finally(() => inflight.delete(key)); + inflight.set(key, started); + return started; +} diff --git a/src/backend/operations/maintenance.ts b/src/backend/operations/maintenance.ts new file mode 100644 index 000000000..88400ff05 --- /dev/null +++ b/src/backend/operations/maintenance.ts @@ -0,0 +1,47 @@ +/** Startup sweep and periodic cleanup for runs, approvals and events. */ + +import { RETENTION_DAYS } from "./constants.js"; +import { pluginLogger } from "../utils/logger.js"; +import { createCurrentOperationRepository } from "../database/repositories/factory.js"; + +const SWEEP_MS = 10 * 60_000; + +let timer: ReturnType | null = null; + +/** Parks expired approvals and trims history. Returns rows removed. */ +export async function sweepOperations(at = new Date()): Promise { + const repository = createCurrentOperationRepository(); + const iso = at.toISOString(); + for (const approval of await repository.listExpiredApprovals(iso)) { + await repository.decideApproval(approval.id, "expired", null, null, iso); + await repository.updateRunFrom(approval.runId, "pending_approval", { + status: "expired", + sealedInput: null, + finishedAt: iso, + }); + } + const cutoff = new Date(at.getTime() - RETENTION_DAYS * 86_400_000); + return repository.prune(cutoff.toISOString()); +} + +/** Anything still running at boot lost its process, so its outcome is unknown. */ +export async function startOperationsMaintenance(): Promise { + await createCurrentOperationRepository().markRunningInterrupted( + new Date().toISOString(), + ); + await sweepOperations(); + timer ??= setInterval(() => { + sweepOperations().catch((error) => { + pluginLogger.warn("Operations cleanup failed", { + operation: "operations_sweep", + error: error instanceof Error ? error.message : String(error), + }); + }); + }, SWEEP_MS); + timer.unref?.(); +} + +export function stopOperationsMaintenance(): void { + if (timer) clearInterval(timer); + timer = null; +} diff --git a/src/backend/operations/registry.ts b/src/backend/operations/registry.ts new file mode 100644 index 000000000..ce4d59021 --- /dev/null +++ b/src/backend/operations/registry.ts @@ -0,0 +1,84 @@ +/** + * Handlers plugins registered for the actions their manifest declares. + * + * Same generation rule as service-registry.ts: a plugin that crashed and + * restarted must not have its old disposer remove the new registration, and a + * run that resolved a handler before a reload must not call the stale one. + */ + +import type { + OperationAction, + OperationResult, +} from "@termix-ssh/plugin-sdk/operations"; + +export interface OperationRunContext { + hostId?: number; + resourceKey?: string; + runId: string | null; + dryRun: boolean; + signal: AbortSignal; + log: (line: string) => void; +} + +export interface OperationHandler { + run: ( + input: Record, + context: OperationRunContext, + ) => Promise | OperationResult; + /** What a dry run reports when the action has no native dry run. */ + describe?: ( + input: Record, + context: OperationRunContext, + ) => string | Promise; +} + +export interface OperationRegistration { + pluginId: string; + action: OperationAction; + handler: OperationHandler; + generation: number; +} + +const registrations = new Map(); +let generationCounter = 0; + +export function registerAction( + pluginId: string, + action: OperationAction, + handler: OperationHandler, +): OperationRegistration { + const existing = registrations.get(action.id); + if (existing && existing.pluginId !== pluginId) { + throw new Error( + `Action "${action.id}" is already registered by ${existing.pluginId}`, + ); + } + const entry = { + pluginId, + action, + handler, + generation: ++generationCounter, + }; + registrations.set(action.id, entry); + return entry; +} + +/** Removes only the exact registration given. */ +export function revokeAction(registration: OperationRegistration): boolean { + const current = registrations.get(registration.action.id); + if (!current || current.generation !== registration.generation) return false; + return registrations.delete(registration.action.id); +} + +export function getAction(id: string): OperationRegistration | undefined { + return registrations.get(id); +} + +export function listActions(): OperationRegistration[] { + return [...registrations.values()]; +} + +/** Test seam. */ +export function clearOperationRegistry(): void { + registrations.clear(); +} diff --git a/src/backend/operations/resources.ts b/src/backend/operations/resources.ts new file mode 100644 index 000000000..0e5340f2c --- /dev/null +++ b/src/backend/operations/resources.ts @@ -0,0 +1,231 @@ +/** + * Reads for All hosts tables and status tiles. + * + * Asking every host over SSH on each page load cannot hold up, so a request + * answers right away from memory and refreshes stale hosts behind it. The page + * polls and the table fills in as hosts answer. Results stay in memory, never + * SQLite, since a save rewrites the whole database. + * + * Keys include the user: someone a host is shared with can have their own + * login there and see different rows. + */ + +import type { + ResourceListResult, + SignalResult, + SignalStatus, +} from "@termix-ssh/plugin-sdk/operations"; +import { RESOURCE_FRESH_MS } from "./constants.js"; +import { coalesce } from "./limits.js"; +import { getAction } from "./registry.js"; +import { matchesWhen } from "@termix-ssh/plugin-sdk/operations"; +import { loadOperationHost } from "./hosts.js"; +import type { ActorOrigin } from "../plugins/actor.js"; +import { hostStatus } from "../plugins/host-status-provider.js"; +import { PermissionManager } from "../utils/permission-manager.js"; +import { loadProbeTargets } from "../plugins/ctx-probe.js"; + +export type HostReadState = + "ok" | "loading" | "offline" | "error" | "no-access" | "not-applicable"; + +interface CacheEntry { + value: unknown; + error?: string; + fetchedAt: number; +} + +export interface HostRead { + hostId: number; + state: HostReadState; + value?: T; + error?: string; + fetchedAt?: string; +} + +const cache = new Map(); +const MAX_ENTRIES = 20_000; + +function cacheKey(userId: string, actionId: string, hostId: number): string { + return `${userId}|${actionId}|${hostId}`; +} + +/** After a change, the next read of this host goes back to the host. */ +export function dropResourceCache(hostId: number): void { + const suffix = `|${hostId}`; + for (const key of cache.keys()) { + if (key.endsWith(suffix)) cache.delete(key); + } +} + +function remember(key: string, entry: CacheEntry): void { + if (cache.size >= MAX_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest) cache.delete(oldest); + } + cache.delete(key); + cache.set(key, entry); +} + +function isOffline(hostId: number): boolean { + const status = hostStatus.get(hostId) as + string | { status?: string } | null | undefined; + const value = typeof status === "string" ? status : status?.status; + return value === "offline"; +} + +interface ReadOptions { + userId: string; + actionId: string; + hostIds?: number[]; + refresh?: boolean; + input?: Record; + origin: ActorOrigin; +} + +function refreshHost( + options: ReadOptions, + hostId: number, + key: string, +): Promise { + return coalesce(key, async () => { + const { runOperation, OperationError } = await import("./gate.js"); + try { + const outcome = await runOperation({ + actionId: options.actionId, + hostId, + input: options.input, + internal: true, + userId: options.userId, + origin: options.origin, + }); + if (outcome.status !== "success") { + remember(key, { + value: undefined, + error: outcome.error ?? outcome.status, + fetchedAt: Date.now(), + }); + return; + } + remember(key, { value: outcome.data, fetchedAt: Date.now() }); + } catch (error) { + remember(key, { + value: undefined, + error: + error instanceof OperationError || error instanceof Error + ? error.message + : String(error), + fetchedAt: Date.now(), + }); + } + }); +} + +/** One entry per host the user can reach. Never waits on a host. */ +export async function readAcrossHosts( + options: ReadOptions, +): Promise[]> { + const registration = getAction(options.actionId); + if (!registration) return []; + const { action } = registration; + const { getPluginRuntime } = await import("../plugins/index.js"); + const manifest = getPluginRuntime().loader.get( + registration.pluginId, + )?.manifest; + if (!manifest) return []; + + const permissions = PermissionManager.getInstance(); + const targets = await loadProbeTargets(options.userId, options.hostIds); + const reads: HostRead[] = []; + + for (const target of targets) { + const hostId = target.id; + const access = await permissions.canAccessHost( + options.userId, + hostId, + action.hostAccess ?? "connect", + ); + if (!access.hasAccess) { + reads.push({ hostId, state: "no-access" }); + continue; + } + if (action.when) { + const host = await loadOperationHost(hostId, manifest); + if (!host || !matchesWhen(action.when, host)) continue; + } + + const key = cacheKey(options.userId, options.actionId, hostId); + const cached = cache.get(key); + const fresh = cached && Date.now() - cached.fetchedAt < RESOURCE_FRESH_MS; + + if (isOffline(hostId)) { + reads.push({ + hostId, + state: "offline", + value: cached?.value as T | undefined, + fetchedAt: cached + ? new Date(cached.fetchedAt).toISOString() + : undefined, + }); + continue; + } + if (!fresh || options.refresh) void refreshHost(options, hostId, key); + if (!cached) { + reads.push({ hostId, state: "loading" }); + } else if (cached.error) { + reads.push({ + hostId, + state: "error", + error: cached.error, + fetchedAt: new Date(cached.fetchedAt).toISOString(), + }); + } else { + reads.push({ + hostId, + state: "ok", + value: cached.value as T, + fetchedAt: new Date(cached.fetchedAt).toISOString(), + }); + } + } + return reads; +} + +export interface ResourceRow { + hostId: number; + key: string; + row: Record; +} + +/** Flattens per host reads into table rows, keeping each host's state. */ +export function flattenResourceRows( + reads: HostRead[], + keyField: string, +): { rows: ResourceRow[]; hosts: Omit, "value">[] } { + const rows: ResourceRow[] = []; + const hosts: Omit, "value">[] = []; + for (const read of reads) { + const { value, ...state } = read; + hosts.push(state); + for (const row of value?.rows ?? []) { + rows.push({ hostId: read.hostId, key: String(row[keyField]), row }); + } + } + return { rows, hosts }; +} + +const WORST: SignalStatus[] = ["fail", "warn", "unknown", "ok"]; + +/** The worst status across hosts, for a server level tile. */ +export function worstStatus( + results: (SignalResult | undefined)[], +): SignalStatus { + for (const status of WORST) { + if (results.some((result) => result?.status === status)) return status; + } + return "unknown"; +} + +/** Test seam. */ +export function clearResourceCache(): void { + cache.clear(); +} diff --git a/src/backend/plugins/actor.ts b/src/backend/plugins/actor.ts index f0a49b57b..d29c3cf1c 100644 --- a/src/backend/plugins/actor.ts +++ b/src/backend/plugins/actor.ts @@ -20,7 +20,28 @@ import { AsyncLocalStorage } from "node:async_hooks"; -interface ActorStore { +/** How a call reached core, for run history and approval rules. */ +export interface ActorOrigin { + kind: "ui" | "api" | "plugin"; + /** Which plugin asked, set by core from the ctx that made the call. */ + pluginId?: string; + /** What the plugin was doing: automation, assistant, mcp, rollout and so on. */ + via?: string; +} + +/** Follows a call through plugin work, so nothing loses who started it. */ +export interface ActorTrail { + origin?: ActorOrigin; + apiKeyId?: string; + /** Null means a key with full access. */ + apiKeyScopes?: string[] | null; + /** The operation run this work belongs to. */ + runId?: string; + /** How many operations or events led here. */ + depth?: number; +} + +interface ActorStore extends ActorTrail { userId: string; /** Where the identity came from, for the audit line. */ source: "request" | "asUser" | "service"; @@ -35,8 +56,34 @@ export function runAsActor( source: ActorStore["source"], fn: () => T, sessionId?: string, + trail?: ActorTrail, ): T { - return storage.run({ userId, source, sessionId }, fn); + const parent = storage.getStore(); + return storage.run( + { + origin: parent?.origin, + apiKeyId: parent?.apiKeyId, + apiKeyScopes: parent?.apiKeyScopes, + runId: parent?.runId, + depth: parent?.depth, + ...trail, + userId, + source, + sessionId, + }, + fn, + ); +} + +export function getActorTrail(): ActorTrail { + const store = storage.getStore(); + return { + origin: store?.origin, + apiKeyId: store?.apiKeyId, + apiKeyScopes: store?.apiKeyScopes, + runId: store?.runId, + depth: store?.depth, + }; } export function getActorSessionId(): string | undefined { diff --git a/src/backend/plugins/ctx-operations.ts b/src/backend/plugins/ctx-operations.ts new file mode 100644 index 000000000..5c895a1ea --- /dev/null +++ b/src/backend/plugins/ctx-operations.ts @@ -0,0 +1,195 @@ +/** + * ctx.operations: declaring what a plugin can do, and asking for another + * plugin's action to run. + * + * invoke acts for the current user, so on its own it would let any plugin spend + * that user's full authority. It needs the capability, the action named in the + * manifest's invokes list, and the origin always names the calling plugin, set + * here from the closure rather than from anything the plugin passes. + */ + +import type { + PluginOperations, + PluginOperationInvokeOptions, +} from "@termix-ssh/plugin-sdk/backend"; +import { + invokeAllowed, + type OperationAction, +} from "@termix-ssh/plugin-sdk/operations"; +import type { PluginManifest } from "./manifest.js"; +import { assertCapability, capabilityRefused } from "./permissions.js"; +import { getActor, getActorTrail, type ActorOrigin } from "./actor.js"; +import type { DisposableBag } from "./disposables.js"; +import { + registerAction, + revokeAction, + type OperationHandler, +} from "../operations/registry.js"; +import { provideApprovalPolicy } from "../operations/approvals.js"; +import { + emitOperationEvent, + subscribeOperationEvents, +} from "../operations/events.js"; +import { + drainPlugin, + OperationError, + runOperation, +} from "../operations/gate.js"; +import { buildCatalog } from "../operations/catalog.js"; + +interface Deps { + manifest: PluginManifest; + bag: DisposableBag; +} + +export function createPluginOperations({ + manifest, + bag, +}: Deps): PluginOperations { + const pluginId = manifest.id; + const declared = manifest.capabilities; + const contribution = manifest.contributes?.operations; + let drainRegistered = false; + + const declaredAction = (id: string): OperationAction => { + const action = contribution?.actions?.find((entry) => entry.id === id); + if (!action) { + throw new Error( + `Plugin ${pluginId} cannot register action "${id}": it is not declared in contributes.operations`, + ); + } + return action; + }; + + const actingUser = (): string => { + const userId = getActor(); + if (!userId) { + throw new Error( + "ctx.operations needs an acting user: call it inside a request or ctx.asUser", + ); + } + return userId; + }; + + return { + register: (id, handler) => { + if (!declared.includes("operations:provide")) { + throw capabilityRefused(pluginId, "operations:provide"); + } + const registration = registerAction( + pluginId, + declaredAction(id), + handler as OperationHandler, + ); + bag.add(() => void revokeAction(registration), `action "${id}"`); + // Added once and after the actions, so disposal, which runs newest + // first, drains before anything the handlers use is torn down. + if (!drainRegistered) { + drainRegistered = true; + bag.add(() => drainPlugin(pluginId), "operations drain"); + } + }, + + // A plugin's own route running its own action, so a button press lands in + // the same history. The caller stays the origin, not this plugin. + run: async (actionId, options = {}) => { + if (!declared.includes("operations:provide")) { + throw capabilityRefused(pluginId, "operations:provide"); + } + declaredAction(actionId); + const trail = getActorTrail(); + return runOperation({ + actionId, + hostId: options.hostId, + resourceKey: options.resourceKey, + input: options.input, + dryRun: options.dryRun, + confirmed: options.confirmed, + idempotencyKey: options.idempotencyKey, + batchId: options.batchId, + userId: actingUser(), + origin: trail.origin ?? { kind: "plugin", pluginId }, + apiKeyScopes: trail.apiKeyScopes, + depth: trail.depth, + }); + }, + + invoke: async (actionId, options = {}) => { + await assertCapability(pluginId, "operations:invoke", declared); + if (!invokeAllowed(contribution?.invokes, actionId)) { + throw new OperationError( + "FORBIDDEN", + `${pluginId} did not declare that it runs ${actionId}`, + ); + } + const trail = getActorTrail(); + const origin: ActorOrigin = { + kind: "plugin", + pluginId, + via: (options as PluginOperationInvokeOptions).via, + }; + return runOperation({ + actionId, + hostId: options.hostId, + resourceKey: options.resourceKey, + input: options.input, + dryRun: options.dryRun, + confirmed: options.confirmed, + idempotencyKey: options.idempotencyKey, + batchId: options.batchId, + userId: actingUser(), + origin, + apiKeyScopes: trail.apiKeyScopes, + depth: trail.depth, + }); + }, + + list: async (options) => { + await assertCapability(pluginId, "operations:invoke", declared); + return buildCatalog({ + userId: actingUser(), + lang: options?.lang, + surface: options?.surface, + }); + }, + + emit: async (eventId, payload, options) => { + if (!declared.includes("operations:provide")) { + throw capabilityRefused(pluginId, "operations:provide"); + } + const event = contribution?.events?.find((entry) => entry.id === eventId); + if (!event) { + throw new Error( + `Plugin ${pluginId} cannot emit "${eventId}": it is not declared in contributes.operations`, + ); + } + await emitOperationEvent({ + eventId, + pluginId, + hostId: options?.hostId, + severity: event.severity, + payload, + }); + }, + + onEvent: (subscription) => { + if (!declared.includes("operations:invoke")) { + throw capabilityRefused(pluginId, "operations:invoke"); + } + const remove = subscribeOperationEvents(pluginId, subscription); + bag.add(remove, `events "${subscription.pattern}"`); + return remove; + }, + + approvals: { + provide: (provider) => { + if (!declared.includes("operations:approve")) { + throw capabilityRefused(pluginId, "operations:approve"); + } + const remove = provideApprovalPolicy(pluginId, manifest, provider); + bag.add(remove, "approval rules"); + return remove; + }, + }, + }; +} diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index 73c35428a..4935658de 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -68,6 +68,7 @@ import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js"; import { createPluginHosts } from "./ctx-hosts.js"; import { createPluginSchedule } from "./schedule.js"; import { createPluginFetch, createPluginNotify } from "./ctx-notify.js"; +import { createPluginOperations } from "./ctx-operations.js"; import { createPluginProcess } from "./ctx-process.js"; import { createPluginSystem } from "./ctx-system.js"; import { createPluginPlugins } from "./ctx-plugins.js"; @@ -976,6 +977,8 @@ export function createPluginContext( void reportRuntimeError(pluginId, error); }), ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }), + operations: createPluginOperations({ manifest, bag: handle.bag }), + notify: createPluginNotify({ manifest, audit: auditCall, diff --git a/src/backend/plugins/http.ts b/src/backend/plugins/http.ts index 7ebab8745..e8cd765f2 100644 --- a/src/backend/plugins/http.ts +++ b/src/backend/plugins/http.ts @@ -354,15 +354,21 @@ export function createPluginRouter({ // After auth, so the actor is the user core authenticated rather than // whatever the request claimed. outer.use((req: Request, _res: Response, next: NextFunction) => { - const { userId, sessionId } = req as Request & { + const { userId, sessionId, apiKeyId, apiKeyScopes } = req as Request & { userId?: string; sessionId?: string; + apiKeyId?: string; + apiKeyScopes?: string[] | null; }; if (!userId) { next(); return; } - runAsActor(userId, "request", () => next(), sessionId); + runAsActor(userId, "request", () => next(), sessionId, { + origin: { kind: apiKeyId ? "api" : "ui" }, + apiKeyId, + apiKeyScopes, + }); }); outer.use(inner); diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 10bd20a28..d8b09c8a6 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -243,6 +243,9 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { // Last, so a plugin's activate() sees a fully wired server. A plugin that // fails to load must not stop the backend, so this never rejects. try { + const { startOperationsMaintenance } = + await import("./operations/maintenance.js"); + await startOperationsMaintenance(); const { initializePlugins } = await import("./plugins/index.js"); const loaded = await initializePlugins({ freshInstall }); if (loaded.length > 0) { diff --git a/src/backend/tests/database/routes/operations-routes.test.ts b/src/backend/tests/database/routes/operations-routes.test.ts new file mode 100644 index 000000000..158302370 --- /dev/null +++ b/src/backend/tests/database/routes/operations-routes.test.ts @@ -0,0 +1,277 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + userId: "user-1", + apiKeyId: undefined as string | undefined, + apiKeyScopes: undefined as string[] | null | undefined, + actingAdminUserId: undefined as string | undefined, + viewAll: false, + hostAccess: false, + runs: new Map>(), + runOperation: vi.fn(), + decide: vi.fn(), + cancel: vi.fn(async () => true), +})); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, + pluginLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: + () => + ( + req: Record, + _res: unknown, + next: () => void, + ): void => { + req.userId = state.userId; + req.apiKeyId = state.apiKeyId; + req.apiKeyScopes = state.apiKeyScopes; + req.actingAdminUserId = state.actingAdminUserId; + next(); + }, + }), + }, +})); +vi.mock("../../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + hasPermission: async () => state.viewAll, + canAccessHost: async () => ({ hasAccess: state.hostAccess }), + }), + }, +})); +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentOperationRepository: () => ({ + findRun: async (id: string) => state.runs.get(id) ?? null, + listRuns: async (filters: { userId?: string }) => + [...state.runs.values()].filter( + (run) => !filters.userId || run.userId === filters.userId, + ), + listPendingApprovals: async () => [], + listEvents: async () => [], + }), +})); +vi.mock("../../../operations/catalog.js", () => ({ + buildCatalog: vi.fn(async () => ({ + actions: [{ id: "demo.restart" }], + events: [], + resources: [], + signals: [], + })), +})); +vi.mock("../../../operations/resources.js", () => ({ + flattenResourceRows: vi.fn(), + readAcrossHosts: vi.fn(async () => []), +})); +vi.mock("../../../operations/gate.js", () => { + class OperationError extends Error { + status: number; + constructor( + readonly code: string, + message: string, + status = 403, + ) { + super(message); + this.status = status; + } + } + return { + OperationError, + runOperation: state.runOperation, + decideOperationApproval: state.decide, + cancelOperationRun: state.cancel, + }; +}); + +const { default: router } = + await import("../../../database/routes/operations-routes.js"); +const { OperationError } = await import("../../../operations/gate.js"); + +const app = express(); +app.use(express.json()); +app.use("/operations", router); + +const run = (overrides: Record = {}) => ({ + id: "run-1", + actionId: "demo.restart", + pluginId: "demo", + pluginVersion: "1.0.0", + userId: "user-1", + username: "name", + origin: '{"kind":"ui"}', + hostId: 1, + hostName: "web", + batchId: null, + dryRun: false, + risk: "change", + status: "success", + summary: "done", + changed: true, + input: '{"token":"[redacted]"}', + sealedInput: "sealed:secret", + output: null, + undoInput: null, + error: null, + errorCode: null, + undoOf: null, + createdAt: "2026-01-01", + startedAt: null, + finishedAt: null, + ...overrides, +}); + +beforeEach(() => { + state.userId = "user-1"; + state.apiKeyId = undefined; + state.apiKeyScopes = undefined; + state.actingAdminUserId = undefined; + state.viewAll = false; + state.hostAccess = false; + state.runs.clear(); + state.runOperation.mockReset(); + state.runOperation.mockResolvedValue({ runId: "r", status: "success" }); + state.decide.mockReset(); + state.decide.mockResolvedValue({ runId: "r", status: "success" }); +}); + +describe("POST /operations/:id/run", () => { + it("passes a session as a ui origin", async () => { + await request(app) + .post("/operations/demo.restart/run") + .send({ hostId: 1, input: { name: "x" } }) + .expect(200); + expect(state.runOperation).toHaveBeenCalledWith( + expect.objectContaining({ + actionId: "demo.restart", + hostId: 1, + userId: "user-1", + origin: { kind: "ui" }, + }), + ); + }); + + it("passes a key as an api origin with its scopes", async () => { + state.apiKeyId = "key"; + state.apiKeyScopes = ["operations:read"]; + await request(app) + .post("/operations/demo.restart/run") + .send({}) + .expect(200); + expect(state.runOperation).toHaveBeenCalledWith( + expect.objectContaining({ + origin: { kind: "api" }, + apiKeyScopes: ["operations:read"], + }), + ); + }); + + it("does not take the user or origin from the body", async () => { + await request(app) + .post("/operations/demo.restart/run") + .send({ userId: "admin", origin: { kind: "plugin", via: "automation" } }) + .expect(200); + expect(state.runOperation).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", origin: { kind: "ui" } }), + ); + }); + + it("sends a refusal's status and code", async () => { + state.runOperation.mockRejectedValue( + new ( + OperationError as never as new (code: string, message: string) => Error + )("FORBIDDEN", "No"), + ); + const response = await request(app) + .post("/operations/demo.restart/run") + .send({}) + .expect(403); + expect(response.body).toMatchObject({ code: "FORBIDDEN", error: "No" }); + }); +}); + +describe("runs", () => { + it("never returns the sealed input", async () => { + state.runs.set("run-1", run()); + const response = await request(app) + .get("/operations/runs/run-1") + .expect(200); + expect(JSON.stringify(response.body)).not.toContain("secret"); + }); + + it("hides another user's run", async () => { + state.runs.set("run-1", run({ userId: "someone" })); + await request(app).get("/operations/runs/run-1").expect(404); + }); + + it("shows a run to someone who manages its host", async () => { + state.runs.set("run-1", run({ userId: "someone" })); + state.hostAccess = true; + await request(app).get("/operations/runs/run-1").expect(200); + }); + + it("lists only a user's own runs", async () => { + state.runs.set("a", run({ id: "a" })); + state.runs.set("b", run({ id: "b", userId: "someone" })); + const response = await request(app).get("/operations/runs").expect(200); + expect(response.body.runs.map((row: { id: string }) => row.id)).toEqual([ + "a", + ]); + }); + + it("lists everything with the view permission", async () => { + state.viewAll = true; + state.runs.set("a", run({ id: "a" })); + state.runs.set("b", run({ id: "b", userId: "someone" })); + const response = await request(app).get("/operations/runs").expect(200); + expect(response.body.runs).toHaveLength(2); + }); + + it("does not cancel another user's run", async () => { + state.runs.set("run-1", run({ userId: "someone" })); + await request(app).post("/operations/runs/run-1/cancel").expect(404); + expect(state.cancel).not.toHaveBeenCalled(); + }); +}); + +describe("POST /operations/approvals/:id", () => { + it("needs a clear answer", async () => { + await request(app) + .post("/operations/approvals/a") + .send({ approve: "yes" }) + .expect(400); + expect(state.decide).not.toHaveBeenCalled(); + }); + + it("refuses a scoped key", async () => { + state.apiKeyScopes = ["operations:run"]; + await request(app) + .post("/operations/approvals/a") + .send({ approve: true }) + .expect(403); + expect(state.decide).not.toHaveBeenCalled(); + }); + + it("marks an impersonated request", async () => { + state.actingAdminUserId = "admin"; + await request(app) + .post("/operations/approvals/a") + .send({ approve: true }) + .expect(200); + expect(state.decide).toHaveBeenCalledWith( + expect.objectContaining({ impersonated: true }), + ); + }); +}); + +describe("GET /operations/catalog", () => { + it("answers", async () => { + const response = await request(app).get("/operations/catalog").expect(200); + expect(response.body.actions).toEqual([{ id: "demo.restart" }]); + }); +}); diff --git a/src/backend/tests/operations/gate.test.ts b/src/backend/tests/operations/gate.test.ts new file mode 100644 index 000000000..cb862cfdc --- /dev/null +++ b/src/backend/tests/operations/gate.test.ts @@ -0,0 +1,694 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OperationAction } from "@termix-ssh/plugin-sdk/operations"; + +const state = vi.hoisted(() => ({ + permissions: new Set(), + hostLevels: new Map(), + plugin: { + state: "active", + source: "bundled", + signedTier: undefined as string | undefined, + manifest: { + version: "1.0.0", + capabilities: ["operations:provide"], + }, + }, + capability: true, + safeMode: false, + host: { name: "web", updatedAt: "2026-01-01", tags: ["prod"] } as { + name: string; + updatedAt: string; + tags: string[]; + }, + runs: new Map>(), + approvals: new Map>(), + audits: [] as Record[], + events: [] as Record[], +})); + +vi.mock("../../utils/logger.js", () => ({ + pluginLogger: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (entry: Record) => { + state.audits.push(entry); + }, +})); +vi.mock("../../utils/system-secret-crypto.js", () => ({ + encryptSystemSecret: async (text: string) => `sealed:${text}`, + decryptSystemSecret: async (text: string) => text.slice("sealed:".length), + isSystemEncrypted: (text: string) => text.startsWith("sealed:"), +})); +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + hasPermission: async (userId: string, permission: string) => + state.permissions.has(`${userId}:${permission}`), + canAccessHost: async (userId: string, hostId: number, level: string) => { + const held = state.hostLevels.get(hostId); + const rank = ["connect", "view", "edit", "manage"]; + return { + hasAccess: + held !== undefined && rank.indexOf(held) >= rank.indexOf(level), + }; + }, + }), + }, +})); +vi.mock("../../plugins/permissions.js", () => ({ + hasCapability: async () => state.capability, +})); +vi.mock("../../plugins/safe-mode.js", () => ({ + safeModeOn: () => state.safeMode, +})); +vi.mock("../../plugins/manifest.js", () => ({ + qualifyPermission: (pluginId: string, name: string) => + name.startsWith(`${pluginId}.`) ? name : `${pluginId}.${name}`, +})); +vi.mock("../../plugins/index.js", () => ({ + getPluginRuntime: () => ({ loader: { get: () => state.plugin } }), +})); +vi.mock("../../plugins/host-status-provider.js", () => ({ + hostStatus: { get: () => null }, +})); +vi.mock("../../plugins/ctx-probe.js", () => ({ + loadProbeTargets: async () => [], +})); +vi.mock("../../operations/hosts.js", () => ({ + loadOperationHost: async (id: number) => ({ + id, + ...state.host, + os: null, + settings: {}, + }), +})); +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentUserRepository: () => ({ + findById: async (id: string) => ({ username: `name-${id}` }), + }), + createCurrentOperationRepository: () => ({ + insertRun: async (run: Record) => { + state.runs.set(run.id as string, { ...run }); + }, + findRun: async (id: string) => state.runs.get(id) ?? null, + findByIdempotencyKey: async (userId: string, key: string) => + [...state.runs.values()].find( + (run) => run.userId === userId && run.idempotencyKey === key, + ) ?? null, + updateRunFrom: async ( + id: string, + from: string, + patch: Record, + ) => { + const run = state.runs.get(id); + if (!run || run.status !== from) return false; + Object.assign(run, patch); + return true; + }, + insertApproval: async (row: Record) => { + state.approvals.set(row.id as string, { ...row }); + }, + findApproval: async (id: string) => state.approvals.get(id) ?? null, + findApprovalForRun: async (runId: string) => + [...state.approvals.values()].find((row) => row.runId === runId) ?? null, + decideApproval: async (id: string, status: string) => { + const row = state.approvals.get(id); + if (!row || row.status !== "pending") return false; + row.status = status; + return true; + }, + insertEvent: async (event: Record) => { + state.events.push(event); + }, + }), +})); + +const { registerAction, clearOperationRegistry } = + await import("../../operations/registry.js"); +const { provideApprovalPolicy, clearApprovalPolicies } = + await import("../../operations/approvals.js"); +const { + cancelOperationRun, + clearOperationGate, + decideOperationApproval, + drainPlugin, + OperationError, + OperationNeedsInteraction, + runOperation, +} = await import("../../operations/gate.js"); + +const USER = "user-1"; +const APPROVER = "user-2"; + +const action = (overrides: Partial = {}): OperationAction => ({ + id: "demo.restart", + titleKey: "t", + scope: "host", + risk: "change", + permission: "use", + hostAccess: "edit", + inputs: [ + { key: "name", type: "string", required: true }, + { key: "token", type: "secret" }, + ], + dryRun: "native", + ...overrides, +}); + +function register( + overrides: Partial = {}, + run = vi.fn(async () => ({ summary: "done", changed: true })), +) { + registerAction("demo", action(overrides), { run }); + return run; +} + +const manifestForPolicy = { + capabilities: ["operations:approve"], +} as never; + +const base = { + actionId: "demo.restart", + hostId: 1, + input: { name: "nginx" }, + userId: USER, + origin: { kind: "ui" } as const, +}; + +async function refused(request: Parameters[0]) { + try { + await runOperation(request); + } catch (error) { + expect(error).toBeInstanceOf(OperationError); + return (error as InstanceType).code; + } + throw new Error("Expected a refusal"); +} + +beforeEach(() => { + state.permissions = new Set([`${USER}:demo.use`, `${APPROVER}:demo.use`]); + state.hostLevels = new Map([[1, "edit"]]); + state.plugin.state = "active"; + state.plugin.source = "bundled"; + state.plugin.manifest.version = "1.0.0"; + state.capability = true; + state.safeMode = false; + state.host = { name: "web", updatedAt: "2026-01-01", tags: ["prod"] }; + state.runs.clear(); + state.approvals.clear(); + state.audits.length = 0; + state.events.length = 0; +}); + +afterEach(() => { + clearOperationRegistry(); + clearApprovalPolicies(); + clearOperationGate(); +}); + +describe("refusals", () => { + it("does not run an unknown action", async () => { + expect(await refused(base)).toBe("NOT_FOUND"); + }); + + it("does not run in safe mode", async () => { + register(); + state.safeMode = true; + expect(await refused(base)).toBe("SAFE_MODE"); + }); + + it("does not run for a plugin that stopped", async () => { + register(); + state.plugin.state = "disabled"; + expect(await refused(base)).toBe("UNAVAILABLE"); + }); + + it("rechecks the grant at use", async () => { + const run = register(); + state.capability = false; + expect(await refused(base)).toBe("UNAVAILABLE"); + expect(run).not.toHaveBeenCalled(); + }); + + it("needs the role permission", async () => { + const run = register(); + state.permissions.clear(); + expect(await refused(base)).toBe("FORBIDDEN"); + expect(run).not.toHaveBeenCalled(); + }); + + it("needs the declared host level", async () => { + const run = register(); + state.hostLevels.set(1, "connect"); + expect(await refused(base)).toBe("FORBIDDEN"); + expect(run).not.toHaveBeenCalled(); + }); + + it("refuses bad input before anything runs", async () => { + const run = register(); + expect(await refused({ ...base, input: { nope: 1 } })).toBe( + "INVALID_INPUT", + ); + expect(run).not.toHaveBeenCalled(); + }); + + it("needs a host for a host action", async () => { + register(); + expect(await refused({ ...base, hostId: undefined })).toBe("INVALID_INPUT"); + }); + + it("needs a confirm for destructive actions", async () => { + register({ risk: "destructive" }); + expect(await refused(base)).toBe("NEEDS_CONFIRM"); + expect((await runOperation({ ...base, confirmed: true })).status).toBe( + "success", + ); + }); + + it("stops long chains", async () => { + register(); + expect(await refused({ ...base, depth: 4 })).toBe("TOO_DEEP"); + }); +}); + +describe("api key scopes", () => { + it("lets a read key run reads only", async () => { + register(); + register({ id: "demo.read", risk: "read", hostAccess: "connect" }); + expect(await refused({ ...base, apiKeyScopes: ["operations:read"] })).toBe( + "SCOPE", + ); + const read = await runOperation({ + ...base, + actionId: "demo.read", + apiKeyScopes: ["operations:read"], + }); + expect(read.status).toBe("success"); + }); + + it("treats no scopes as full access", async () => { + register(); + const outcome = await runOperation({ ...base, apiKeyScopes: null }); + expect(outcome.status).toBe("success"); + }); +}); + +describe("runs", () => { + it("records a run and never stores a secret", async () => { + const run = register(); + const outcome = await runOperation({ + ...base, + input: { name: "nginx", token: "hunter2" }, + }); + + expect(outcome.status).toBe("success"); + expect(run.mock.calls[0]?.[0]).toMatchObject({ token: "hunter2" }); + const row = state.runs.get(outcome.runId!)!; + expect(row.status).toBe("success"); + expect(JSON.stringify(row)).not.toContain("hunter2"); + expect(JSON.stringify(state.audits)).not.toContain("hunter2"); + expect(JSON.stringify(state.events)).not.toContain("hunter2"); + }); + + it("keeps failures in the run, not thrown", async () => { + register( + {}, + vi.fn(async () => { + throw new Error("no such unit"); + }), + ); + const outcome = await runOperation(base); + expect(outcome).toMatchObject({ status: "failed", error: "no such unit" }); + expect(state.runs.get(outcome.runId!)!.status).toBe("failed"); + }); + + it("reports hosts that need a person", async () => { + register( + {}, + vi.fn(async () => { + throw new OperationNeedsInteraction("Sign in first"); + }), + ); + const outcome = await runOperation(base); + expect(outcome.status).toBe("needs_interaction"); + }); + + it("passes dry run to a native handler", async () => { + const run = register(); + await runOperation({ ...base, dryRun: true }); + expect(run.mock.calls[0]).toBeDefined(); + expect( + (run.mock.calls[0] as unknown as [unknown, { dryRun: boolean }])[1] + .dryRun, + ).toBe(true); + }); + + it("never calls run for a describe dry run", async () => { + const run = vi.fn(async () => ({ summary: "x" })); + registerAction("demo", action({ dryRun: "describe" }), { + run, + describe: () => "Would restart nginx", + }); + const outcome = await runOperation({ ...base, dryRun: true }); + expect(outcome.summary).toBe("Would restart nginx"); + expect(run).not.toHaveBeenCalled(); + }); + + it("drops sensitive outputs", async () => { + register( + { outputs: [{ key: "conf", type: "textarea", sensitive: true }] }, + vi.fn(async () => ({ + summary: "ok", + data: { conf: "private key" }, + })), + ); + const outcome = await runOperation(base); + expect(JSON.stringify(outcome)).not.toContain("private key"); + expect(JSON.stringify(state.runs.get(outcome.runId!))).not.toContain( + "private key", + ); + }); + + it("writes no row for a background read", async () => { + register({ risk: "read", hostAccess: "connect" }); + const outcome = await runOperation({ ...base, internal: true }); + expect(outcome.runId).toBeNull(); + expect(state.runs.size).toBe(0); + }); + + it("does not run twice for one idempotency key", async () => { + const run = register(); + const first = await runOperation({ ...base, idempotencyKey: "abc" }); + const second = await runOperation({ ...base, idempotencyKey: "abc" }); + expect(second.runId).toBe(first.runId); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("emits a run event for a change", async () => { + register(); + await runOperation(base); + await vi.waitFor(() => + expect(state.events[0]).toMatchObject({ + eventId: "operation.succeeded", + }), + ); + }); + + it("stops a run on cancel", async () => { + let started = () => {}; + const ready = new Promise((resolve) => (started = resolve)); + register( + {}, + vi.fn( + (_input, context: { signal: AbortSignal }) => + new Promise((_, reject) => { + started(); + context.signal.addEventListener("abort", () => + reject(new Error("stopped")), + ); + }), + ) as never, + ); + const running = runOperation(base); + await ready; + const row = [...state.runs.values()][0]!; + expect(await cancelOperationRun(row as never)).toBe(true); + expect((await running).status).toBe("failed"); + }); +}); + +describe("policy", () => { + it("keeps AI and MCP away from changes with no rule", async () => { + register(); + for (const via of ["assistant", "mcp"]) { + expect( + await refused({ + ...base, + origin: { kind: "plugin", pluginId: "x", via }, + }), + ).toBe("DENIED"); + } + }); + + it("refuses a plugin with no stated purpose", async () => { + register(); + expect( + await refused({ ...base, origin: { kind: "plugin", pluginId: "x" } }), + ).toBe("DENIED"); + }); + + it("lets a person's automation go by RBAC", async () => { + register(); + const outcome = await runOperation({ + ...base, + origin: { kind: "plugin", pluginId: "x", via: "automation" }, + }); + expect(outcome.status).toBe("success"); + }); + + it("treats a community read as a change", async () => { + state.plugin.source = "user"; + state.plugin.signedTier = "community"; + register({ risk: "read", hostAccess: "connect" }); + expect( + await refused({ + ...base, + origin: { kind: "plugin", pluginId: "x", via: "assistant" }, + }), + ).toBe("DENIED"); + state.plugin.signedTier = undefined; + }); + + it("denies when a provider throws", async () => { + register(); + provideApprovalPolicy("rules", manifestForPolicy, () => { + throw new Error("boom"); + }); + expect(await refused(base)).toBe("DENIED"); + }); + + it("takes the strictest answer", async () => { + register(); + provideApprovalPolicy("a", manifestForPolicy, () => ({ + decision: "allow", + })); + provideApprovalPolicy("b", manifestForPolicy, () => ({ + decision: "deny", + reason: "No", + })); + expect(await refused(base)).toBe("DENIED"); + }); + + it("audits a denial", async () => { + register(); + provideApprovalPolicy("a", manifestForPolicy, () => ({ decision: "deny" })); + await refused(base); + expect(state.audits[0]).toMatchObject({ + action: "operation_denied", + success: false, + }); + }); +}); + +describe("approvals", () => { + const needApproval = () => + provideApprovalPolicy("rules", manifestForPolicy, () => ({ + decision: "approve", + })); + + async function pending(run = register()) { + needApproval(); + const outcome = await runOperation({ + ...base, + input: { name: "nginx", token: "hunter2" }, + }); + expect(outcome.status).toBe("pending_approval"); + expect(run).not.toHaveBeenCalled(); + return outcome; + } + + const decide = (approvalId: string, approverId = APPROVER) => + decideOperationApproval({ approvalId, approverId, approve: true }); + + it("runs the sealed input once approved, then wipes it", async () => { + const run = register(); + const { approvalId, runId } = await pending(run); + + const row = state.runs.get(runId!)!; + expect(row.input).not.toContain("hunter2"); + expect(row.sealedInput).toBeTruthy(); + + const outcome = await decide(approvalId!); + expect(outcome.status).toBe("success"); + expect(run.mock.calls[0]?.[0]).toMatchObject({ token: "hunter2" }); + expect(state.runs.get(runId!)!.sealedInput).toBeNull(); + }); + + it("is one shot", async () => { + const run = register(); + const { approvalId } = await pending(run); + await decide(approvalId!); + await expect(decide(approvalId!)).rejects.toMatchObject({ + code: "CONFLICT", + }); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("does not let a person approve their own request", async () => { + const { approvalId } = await pending(); + await expect(decide(approvalId!, USER)).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("lets a person approve what an AI asked for", async () => { + register(); + needApproval(); + const outcome = await runOperation({ + ...base, + origin: { kind: "plugin", pluginId: "x", via: "assistant" }, + }); + const done = await decide(outcome.approvalId!, USER); + expect(done.status).toBe("success"); + }); + + it("does not let someone impersonating approve", async () => { + const { approvalId } = await pending(); + await expect( + decideOperationApproval({ + approvalId: approvalId!, + approverId: APPROVER, + approve: true, + impersonated: true, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("needs the approver to hold the action's rights", async () => { + const { approvalId } = await pending(); + state.permissions.delete(`${APPROVER}:demo.use`); + await expect(decide(approvalId!)).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("rechecks the requester when it runs", async () => { + const run = register(); + const { approvalId, runId } = await pending(run); + state.permissions.delete(`${USER}:demo.use`); + await expect(decide(approvalId!)).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + expect(run).not.toHaveBeenCalled(); + expect(state.runs.get(runId!)!.status).toBe("rejected"); + }); + + it("goes stale when the host changed", async () => { + const run = register(); + const { approvalId } = await pending(run); + state.host.updatedAt = "2026-02-02"; + await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" }); + expect(run).not.toHaveBeenCalled(); + }); + + it("goes stale when the plugin changed", async () => { + const run = register(); + const { approvalId } = await pending(run); + state.plugin.manifest.version = "2.0.0"; + await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" }); + expect(run).not.toHaveBeenCalled(); + }); + + it("refuses a sealed copy from another run", async () => { + const run = register(); + const first = await pending(run); + const second = await runOperation(base); + state.runs.get(second.runId!)!.sealedInput = state.runs.get( + first.runId!, + )!.sealedInput; + await expect(decide(second.approvalId!)).rejects.toMatchObject({ + code: "STALE", + }); + expect(run).not.toHaveBeenCalled(); + }); + + it("refuses an expired approval", async () => { + const run = register(); + const { approvalId } = await pending(run); + state.approvals.get(approvalId!)!.expiresAt = "2020-01-01T00:00:00.000Z"; + await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" }); + expect(run).not.toHaveBeenCalled(); + }); + + it("wipes the sealed input on reject", async () => { + const { approvalId, runId } = await pending(); + const outcome = await decideOperationApproval({ + approvalId: approvalId!, + approverId: APPROVER, + approve: false, + }); + expect(outcome.status).toBe("rejected"); + expect(state.runs.get(runId!)!.sealedInput).toBeNull(); + }); +}); + +describe("drain", () => { + it("waits for a handler that is still stopping", async () => { + let finish = () => {}; + register( + {}, + vi.fn( + () => + new Promise((_, reject) => { + finish = () => reject(new Error("closed")); + }), + ) as never, + ); + vi.useFakeTimers(); + void runOperation(base).catch(() => {}); + await vi.advanceTimersByTimeAsync(0); + + let drained = false; + const draining = drainPlugin("demo").then(() => (drained = true)); + await vi.advanceTimersByTimeAsync(5_000); + expect(drained).toBe(false); + + finish(); + await vi.advanceTimersByTimeAsync(0); + await draining; + vi.useRealTimers(); + expect(drained).toBe(true); + }); + + it("stops running work and marks it interrupted", async () => { + register({}, vi.fn(() => new Promise(() => {})) as never); + vi.useFakeTimers(); + const running = runOperation(base); + await vi.advanceTimersByTimeAsync(0); + const draining = drainPlugin("demo"); + await vi.advanceTimersByTimeAsync(11_000); + await draining; + await running; + vi.useRealTimers(); + expect([...state.runs.values()][0]!.status).toBe("interrupted"); + }); + + it("refuses new runs while draining", async () => { + register({}, vi.fn(() => new Promise(() => {})) as never); + vi.useFakeTimers(); + void runOperation(base); + await vi.advanceTimersByTimeAsync(0); + const draining = drainPlugin("demo"); + await expect(runOperation(base)).rejects.toMatchObject({ + code: "UNAVAILABLE", + }); + await vi.advanceTimersByTimeAsync(11_000); + await draining; + vi.useRealTimers(); + }); +}); diff --git a/src/backend/tests/operations/key-scopes.test.ts b/src/backend/tests/operations/key-scopes.test.ts new file mode 100644 index 000000000..75595fdaf --- /dev/null +++ b/src/backend/tests/operations/key-scopes.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { keyScopeAllows, parseKeyScopes } from "../../operations/key-scopes.js"; + +const READ = ["operations:read"]; +const RUN = ["operations:run"]; + +describe("keyScopeAllows", () => { + it("leaves a key with no scopes alone", () => { + expect(keyScopeAllows(null, "POST", "/users/ws-ticket/api-key")).toBe(true); + expect(keyScopeAllows(null, "DELETE", "/host/1")).toBe(true); + }); + + it("keeps a scoped key off every other route", () => { + for (const path of [ + "/users/ws-ticket/api-key", + "/host/1", + "/plugin-api/docker/1/containers", + "/credentials", + ]) { + expect(keyScopeAllows(RUN, "GET", path)).toBe(false); + } + }); + + it("lets a read key read and start runs the gate then checks", () => { + expect(keyScopeAllows(READ, "GET", "/operations/catalog?lang=en")).toBe( + true, + ); + expect(keyScopeAllows(READ, "POST", "/operations/docker.list/run")).toBe( + true, + ); + expect(keyScopeAllows(READ, "POST", "/operations/approvals/abc")).toBe( + false, + ); + expect(keyScopeAllows(READ, "POST", "/operations/runs/abc/cancel")).toBe( + false, + ); + }); + + it("reaches a plugin's MCP route only", () => { + expect(keyScopeAllows(READ, "POST", "/plugin-api/some-plugin/mcp")).toBe( + true, + ); + expect( + keyScopeAllows(READ, "POST", "/plugin-api/some-plugin/mcp-admin"), + ).toBe(false); + }); + + it("does not match lookalike paths", () => { + expect(keyScopeAllows(RUN, "GET", "/operationsx/catalog")).toBe(false); + }); + + it("grants nothing with an empty list", () => { + expect(keyScopeAllows([], "GET", "/operations/catalog")).toBe(false); + }); +}); + +describe("parseKeyScopes", () => { + it("reads a list", () => { + expect(parseKeyScopes('["operations:read"]')).toEqual(READ); + }); + + it("keeps old keys at full access", () => { + expect(parseKeyScopes(null)).toBeNull(); + }); + + it("fails closed on junk", () => { + expect(parseKeyScopes("{nope")).toEqual([]); + expect(parseKeyScopes('"x"')).toEqual([]); + }); +}); diff --git a/src/backend/tests/plugins/ctx-operations.test.ts b/src/backend/tests/plugins/ctx-operations.test.ts new file mode 100644 index 000000000..3dd62afc0 --- /dev/null +++ b/src/backend/tests/plugins/ctx-operations.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginManifest } from "../../plugins/manifest.js"; + +const gate = vi.hoisted(() => ({ + runOperation: vi.fn(async () => ({ runId: "r", status: "success" })), + drainPlugin: vi.fn(async () => {}), +})); + +vi.mock("../../utils/logger.js", () => ({ + pluginLogger: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); +vi.mock("../../operations/gate.js", () => ({ + ...gate, + OperationError: class extends Error { + constructor(_code: string, message: string) { + super(message); + } + }, +})); +vi.mock("../../operations/catalog.js", () => ({ + buildCatalog: vi.fn(async () => ({ actions: [] })), +})); +vi.mock("../../operations/events.js", () => ({ + emitOperationEvent: vi.fn(async () => {}), + subscribeOperationEvents: vi.fn(() => () => {}), +})); +vi.mock("../../plugins/permissions.js", () => ({ + assertCapability: vi.fn( + async (_id: string, capability: string, declared: string[]) => { + if (!declared.includes(capability)) throw new Error("refused"); + }, + ), + capabilityRefused: (pluginId: string, capability: string) => + new Error(`${pluginId} lacks ${capability}`), +})); + +const { createPluginOperations } = + await import("../../plugins/ctx-operations.js"); +const { DisposableBag } = await import("../../plugins/disposables.js"); +const { clearOperationRegistry, getAction } = + await import("../../operations/registry.js"); +const { runAsActor } = await import("../../plugins/actor.js"); + +const action = { + id: "demo.restart", + titleKey: "t", + scope: "host", + risk: "change", + permission: "use", + hostAccess: "edit", +}; + +function build(capabilities: string[], invokes?: string[]) { + const bag = new DisposableBag("demo"); + const manifest = { + id: "demo", + capabilities, + contributes: { operations: { actions: [action], invokes } }, + } as unknown as PluginManifest; + return { bag, operations: createPluginOperations({ manifest, bag }) }; +} + +const handler = { run: async () => ({ summary: "ok" }) }; + +beforeEach(() => { + clearOperationRegistry(); + gate.runOperation.mockClear(); + gate.drainPlugin.mockClear(); +}); + +describe("register", () => { + it("needs the capability", () => { + const { operations } = build([]); + expect(() => operations.register("demo.restart", handler)).toThrow( + "operations:provide", + ); + }); + + it("refuses an undeclared action", () => { + const { operations } = build(["operations:provide"]); + expect(() => operations.register("demo.other", handler)).toThrow( + "not declared", + ); + }); + + it("drains, then revokes, when the plugin stops", async () => { + const { bag, operations } = build(["operations:provide"]); + operations.register("demo.restart", handler); + expect(getAction("demo.restart")).toBeDefined(); + + await bag.disposeAll(); + expect(gate.drainPlugin).toHaveBeenCalledWith("demo"); + expect(getAction("demo.restart")).toBeUndefined(); + }); + + it("keeps the new handler when an old disposer runs after a restart", async () => { + const first = build(["operations:provide"]); + first.operations.register("demo.restart", handler); + const second = build(["operations:provide"]); + const fresh = { run: async () => ({ summary: "new" }) }; + second.operations.register("demo.restart", fresh); + + await first.bag.disposeAll(); + expect(getAction("demo.restart")?.handler).toBe(fresh); + }); +}); + +describe("invoke", () => { + const asUser = (fn: () => Promise) => + runAsActor("user", "request", fn, undefined, { + origin: { kind: "ui" }, + apiKeyScopes: ["operations:read"], + }); + + it("needs the capability", async () => { + const { operations } = build([], ["*"]); + await expect( + asUser(() => operations.invoke("docker.restart")), + ).rejects.toThrow("refused"); + }); + + it("only reaches what the manifest lists", async () => { + const { operations } = build(["operations:invoke"], ["docker.*"]); + await expect( + asUser(() => operations.invoke("firewall.allow")), + ).rejects.toThrow("did not declare"); + expect(gate.runOperation).not.toHaveBeenCalled(); + }); + + it("names the plugin as origin and keeps the key's limits", async () => { + const { operations } = build(["operations:invoke"], ["docker.*"]); + await asUser(() => + operations.invoke("docker.restart", { via: "automation" }), + ); + expect(gate.runOperation).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user", + origin: { kind: "plugin", pluginId: "demo", via: "automation" }, + apiKeyScopes: ["operations:read"], + }), + ); + }); + + it("does not let a plugin pick its own origin", async () => { + const { operations } = build(["operations:invoke"], ["*"]); + await asUser(() => + operations.invoke("docker.restart", { + origin: { kind: "ui" }, + } as never), + ); + expect(gate.runOperation).toHaveBeenCalledWith( + expect.objectContaining({ + origin: expect.objectContaining({ kind: "plugin", pluginId: "demo" }), + }), + ); + }); + + it("needs an acting user", async () => { + const { operations } = build(["operations:invoke"], ["*"]); + await expect(operations.invoke("docker.restart")).rejects.toThrow( + "acting user", + ); + }); +}); + +describe("run", () => { + it("keeps the caller as origin for a plugin's own action", async () => { + const { operations } = build(["operations:provide"]); + await runAsActor( + "user", + "request", + () => operations.run("demo.restart", { hostId: 1 }), + undefined, + { origin: { kind: "ui" } }, + ); + expect(gate.runOperation).toHaveBeenCalledWith( + expect.objectContaining({ origin: { kind: "ui" }, hostId: 1 }), + ); + }); + + it("refuses another plugin's action", async () => { + const { operations } = build(["operations:provide"]); + await expect( + runAsActor("user", "request", () => operations.run("docker.restart")), + ).rejects.toThrow("not declared"); + }); +}); + +describe("emit", () => { + it("refuses an undeclared event", async () => { + const { operations } = build(["operations:provide"]); + await expect(operations.emit("demo.nothing")).rejects.toThrow( + "not declared", + ); + }); +}); + +describe("approvals", () => { + it("needs the capability", () => { + const { operations } = build([]); + expect(() => operations.approvals.provide(() => null)).toThrow( + "operations:approve", + ); + }); +}); diff --git a/src/backend/tests/plugins/operations-manifest.test.ts b/src/backend/tests/plugins/operations-manifest.test.ts new file mode 100644 index 000000000..0e9fa60ea --- /dev/null +++ b/src/backend/tests/plugins/operations-manifest.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it } from "vitest"; +import { parseManifest } from "@termix-ssh/plugin-sdk/manifest"; +import { + invokeAllowed, + matchesWhen, + onSurface, + parseOperationInput, + redactFields, + toJsonSchema, + type OperationAction, + type OperationField, +} from "@termix-ssh/plugin-sdk/operations"; + +const action = (overrides: Partial = {}): OperationAction => ({ + id: "demo.allow-port", + titleKey: "ops.allow", + scope: "host", + risk: "change", + permission: "use", + hostAccess: "edit", + ...overrides, +}); + +function manifest(operations: unknown, capabilities = ["operations:provide"]) { + return { + id: "demo", + name: "Demo", + version: "1.0.0", + description: "A demo.", + author: { name: "test" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=26.11.1-0", api: "^1.13" }, + capabilities, + contributes: { + permissions: [ + { name: "use", titleKey: "perm.use", descriptionKey: "perm.useDesc" }, + ], + operations, + }, + }; +} + +function errorsFor(operations: unknown, capabilities?: string[]): string[] { + return parseManifest(manifest(operations, capabilities)).errors ?? []; +} + +describe("contributes.operations", () => { + it("accepts a full declaration", () => { + const errors = errorsFor({ + actions: [ + action({ + check: "demo.port-allowed", + inputs: [ + { key: "port", type: "number", required: true, min: 1, max: 65535 }, + { key: "name", type: "resource", resource: "demo.rule" }, + ], + }), + action({ + id: "demo.port-allowed", + risk: "read", + hostAccess: "connect", + }), + action({ + id: "demo.list-rules", + risk: "read", + hostAccess: "connect", + }), + ], + events: [{ id: "demo.turned-off", titleKey: "e", scope: "host" }], + resources: [ + { + id: "demo.rule", + titleKey: "r", + list: "demo.list-rules", + key: "id", + name: "name", + }, + ], + signals: [ + { + id: "demo.rules", + titleKey: "s", + read: "demo.port-allowed", + scope: "host", + kind: "count", + fix: "demo.allow-port", + }, + ], + }); + expect(errors).toEqual([]); + }); + + it("needs the capability", () => { + expect(errorsFor({ actions: [action()] }, [])).toContainEqual( + expect.stringContaining("operations:provide"), + ); + }); + + it("refuses an id outside the plugin", () => { + expect( + errorsFor({ actions: [action({ id: "docker.stop" })] }), + ).toContainEqual(expect.stringContaining('must start with "demo."')); + }); + + it("refuses a destructive action a connect level user could run", () => { + expect( + errorsFor({ + actions: [action({ risk: "destructive", hostAccess: "connect" })], + }), + ).toContainEqual(expect.stringContaining("edit or manage")); + }); + + it("lets a change declare its own level", () => { + expect(errorsFor({ actions: [action({ hostAccess: "connect" })] })).toEqual( + [], + ); + }); + + it("refuses an undeclared permission", () => { + expect( + errorsFor({ actions: [action({ permission: "nope" })] }), + ).toContainEqual(expect.stringContaining("is not declared")); + }); + + it("refuses a check that is not a read", () => { + expect( + errorsFor({ + actions: [ + action({ check: "demo.other" }), + action({ id: "demo.other" }), + ], + }), + ).toContainEqual(expect.stringContaining("must be a read action")); + }); + + it("refuses references that go nowhere", () => { + const errors = errorsFor({ + actions: [action({ undo: "demo.gone" })], + resources: [ + { + id: "demo.rule", + titleKey: "r", + list: "demo.gone", + key: "id", + name: "n", + }, + ], + }); + expect(errors.length).toBeGreaterThanOrEqual(2); + }); + + it("refuses unknown fields", () => { + expect( + errorsFor({ actions: [{ ...action(), extra: true }] }), + ).toContainEqual(expect.stringContaining('Unknown field "extra"')); + }); + + it("refuses a default on a secret", () => { + expect( + errorsFor({ + actions: [ + action({ + inputs: [{ key: "token", type: "secret", default: "abc" }], + }), + ], + }), + ).toContainEqual(expect.stringContaining("not allowed on a secret")); + }); + + it("needs operations:invoke for invokes", () => { + expect(errorsFor({ invokes: ["docker.*"] }, [])).toContainEqual( + expect.stringContaining("operations:invoke"), + ); + }); +}); + +describe("parseOperationInput", () => { + const fields: OperationField[] = [ + { key: "port", type: "number", required: true, min: 1, max: 65535 }, + { + key: "proto", + type: "select", + default: "tcp", + options: [ + { value: "tcp", labelKey: "t" }, + { value: "udp", labelKey: "u" }, + ], + }, + { key: "name", type: "string", pattern: "[a-z]+" }, + { key: "data", type: "json" }, + ]; + + it("coerces strings and fills defaults", () => { + expect(parseOperationInput(fields, { port: "443" })).toEqual({ + values: { port: 443, proto: "tcp" }, + errors: [], + }); + }); + + it("refuses keys no field declares", () => { + expect( + parseOperationInput(fields, { port: 1, sudo: true }).errors, + ).toContainEqual(expect.stringContaining('"sudo"')); + }); + + it("needs required fields", () => { + expect(parseOperationInput(fields, {}).errors).toEqual([ + '"port" is required', + ]); + }); + + it("checks bounds, options and the whole pattern", () => { + expect(parseOperationInput(fields, { port: 0 }).errors).toHaveLength(1); + expect( + parseOperationInput(fields, { port: 1, proto: "icmp" }).errors, + ).toHaveLength(1); + expect( + parseOperationInput(fields, { port: 1, name: "abc; rm -rf" }).errors, + ).toEqual(['"name" has the wrong format']); + }); + + it("parses json strings", () => { + expect( + parseOperationInput(fields, { port: 1, data: '{"a":1}' }).values.data, + ).toEqual({ a: 1 }); + }); + + it("refuses a redacted echo for a secret", () => { + const secret: OperationField[] = [{ key: "token", type: "secret" }]; + expect( + parseOperationInput(secret, { token: { set: true } }).errors, + ).toHaveLength(1); + }); + + it("refuses input that is not an object", () => { + expect(parseOperationInput(fields, "nope").errors).toHaveLength(1); + }); +}); + +describe("helpers", () => { + it("redacts secrets and sensitive outputs", () => { + expect( + redactFields( + [ + { key: "token", type: "secret" }, + { key: "conf", type: "textarea", sensitive: true }, + { key: "port", type: "number" }, + ], + { token: "abc", conf: "key", port: 1 }, + ), + ).toEqual({ token: "[redacted]", conf: "[redacted]", port: 1 }); + }); + + it("builds JSON Schema", () => { + const schema = toJsonSchema([ + { key: "port", type: "number", required: true, min: 1 }, + { key: "token", type: "secret" }, + ]) as { properties: Record; required: string[] }; + expect(schema.required).toEqual(["port"]); + expect(schema.properties.port).toEqual({ type: "number", minimum: 1 }); + expect(schema.properties.token).toEqual({ + type: "string", + writeOnly: true, + }); + }); + + it("keeps AI and MCP opt in, the rest opt out", () => { + expect(onSurface({}, "assistant")).toBe(false); + expect(onSurface({}, "mcp")).toBe(false); + expect(onSurface({}, "cli")).toBe(true); + expect(onSurface({ surfaces: { assistant: true } }, "assistant")).toBe( + true, + ); + expect(onSurface({ surfaces: { cli: false } }, "cli")).toBe(false); + }); + + it("matches invokes patterns", () => { + expect(invokeAllowed(["docker.*"], "docker.stop")).toBe(true); + expect(invokeAllowed(["docker.*"], "dockerx.stop")).toBe(false); + expect(invokeAllowed(["docker.stop"], "docker.start")).toBe(false); + expect(invokeAllowed(["*"], "anything.at-all")).toBe(true); + expect(invokeAllowed(undefined, "docker.stop")).toBe(false); + }); + + it("tests when against a host", () => { + const when = { settings: { enableDocker: true }, os: ["linux"] }; + expect( + matchesWhen(when, { settings: { enableDocker: true }, os: "Linux" }), + ).toBe(true); + expect(matchesWhen(when, { settings: {} })).toBe(false); + expect( + matchesWhen(when, { settings: { enableDocker: true }, os: "windows" }), + ).toBe(false); + expect(matchesWhen(when, { settings: { enableDocker: true } })).toBe(true); + }); +}); diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 7516ea26f..0bd203370 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -17,6 +17,7 @@ import { inArray, isNotNull } from "drizzle-orm"; import type { ExternalSessionRef } from "../auth/types.js"; import { listSecondFactors } from "../auth/registry.js"; import { SECOND_FACTOR_SETUP } from "../auth/policy.js"; +import { keyScopeAllows, parseKeyScopes } from "../operations/key-scopes.js"; import type { DeviceType } from "./user-agent-parser.js"; import { getDb } from "../database/db/index.js"; import { sessions } from "../database/db/schema.js"; @@ -69,6 +70,7 @@ interface AuthenticatedRequest extends Request { userId?: string; sessionId?: string; apiKeyId?: string; + apiKeyScopes?: string[] | null; pendingTOTP?: boolean; dataKey?: Buffer; actingAdminUserId?: string; @@ -879,8 +881,18 @@ class AuthManager { }); }); + const scopes = parseKeyScopes(matchedKey.scopes); + if (!keyScopeAllows(scopes, req.method, req.originalUrl ?? "")) { + res.status(403).json({ + error: "This API key may not use this route", + code: "API_KEY_SCOPE", + }); + return; + } + req.userId = matchedKey.userId; req.apiKeyId = matchedKey.id; + req.apiKeyScopes = scopes; next(); } catch (error) { databaseLogger.error("API key authentication failed", error, { diff --git a/src/backend/utils/permission-catalog.ts b/src/backend/utils/permission-catalog.ts index 51b4926ba..c45fbb39b 100644 --- a/src/backend/utils/permission-catalog.ts +++ b/src/backend/utils/permission-catalog.ts @@ -54,6 +54,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [ "admin.roles.manage", "admin.settings.manage", "admin.sessions.manage", + "admin.operations.view", "admin.plugins.manage", ], }, diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 1a6c275a0..a0d109edc 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1214,6 +1214,18 @@ "title": "Send alerts", "consequence": "To you or other users, and through the channels they set up." }, + "operations:provide": { + "title": "Offer actions", + "consequence": "Its actions can be run from Automations, the CLI, the phone app and other places, by people allowed to use them." + }, + "operations:invoke": { + "title": "Run other plugins' actions", + "consequence": "Acts for the signed-in user on the actions its manifest lists. Approval rules still apply." + }, + "operations:approve": { + "title": "Decide what needs approval", + "consequence": "Can let actions run with no approval, or block them, for every user." + }, "notify:hub": { "title": "Receive every alert", "consequence": "Stores and delivers the alerts all plugins send, including their contents."