From d24167b520c57f628149ae03148a0dbed05f8eec Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:39:32 +0200 Subject: [PATCH] refactor(auth): mark desktop sessions with auth source (#990) * refactor(auth): mark desktop sessions with auth source Makes it easier to filter out on session type in backend paths that behave differently depending on the context * chore: fix un-used import * fix(auth): align desktop session guards * refactor(auth): gate desktop sessions by runtime features --- .../auth/routes/download-recovery-key.tsx | 4 +- .../modules/settings/routes/settings.tsx | 17 +- .../20260615163731_empty_vulcan/migration.sql | 1 + .../20260615163731_empty_vulcan/snapshot.json | 3509 +++++++++++++++++ app/lib/__tests__/permission-policy.test.ts | 38 + app/lib/permission-policy.ts | 12 +- app/middleware/auth.ts | 7 + app/routes/(dashboard)/settings/index.tsx | 8 +- app/server/app.ts | 10 +- app/server/db/schema.ts | 3 + app/server/lib/auth.ts | 8 + .../lib/functions/current-permissions.ts | 3 +- .../modules/api-keys/api-keys.controller.ts | 57 +- .../auth/__tests__/auth.api-keys.test.ts | 61 +- .../auth/__tests__/auth.helpers.test.ts | 21 + app/server/modules/auth/auth.middleware.ts | 34 +- app/server/modules/auth/helpers.ts | 25 +- .../__tests__/desktop.controller.test.ts | 136 +- app/server/modules/desktop/bootstrap.ts | 19 +- app/server/modules/desktop/constants.ts | 2 + .../modules/desktop/desktop.controller.ts | 17 +- app/server/modules/desktop/desktop.service.ts | 28 +- .../__tests__/repositories.controller.test.ts | 32 +- .../repositories/repositories.controller.ts | 4 +- .../sso/__tests__/sso.registration.test.ts | 17 +- .../sso/middlewares/authorize-registration.ts | 2 +- app/server/modules/sso/sso.controller.ts | 20 +- .../__tests__/system.controller.test.ts | 74 +- .../modules/system/system.controller.ts | 13 +- 29 files changed, 4086 insertions(+), 96 deletions(-) create mode 100644 app/drizzle/20260615163731_empty_vulcan/migration.sql create mode 100644 app/drizzle/20260615163731_empty_vulcan/snapshot.json create mode 100644 app/server/modules/auth/__tests__/auth.helpers.test.ts create mode 100644 app/server/modules/desktop/constants.ts diff --git a/app/client/modules/auth/routes/download-recovery-key.tsx b/app/client/modules/auth/routes/download-recovery-key.tsx index 5ba88ec0..d188597c 100644 --- a/app/client/modules/auth/routes/download-recovery-key.tsx +++ b/app/client/modules/auth/routes/download-recovery-key.tsx @@ -62,7 +62,9 @@ export function DownloadRecoveryKeyPage({ passwordAuthSupported, hasPassword, us } setBlockedMessage(null); - downloadResticPassword.mutate({ body: { password: passwordAuthSupported ? password : "" } }); + downloadResticPassword.mutate({ + body: { password: passwordAuthSupported ? password : "" }, + }); }; const handleSkip = () => { diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index 66b738af..45dd439c 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -271,10 +271,12 @@ export function SettingsPage({ - + {permissions.hasRuntimeFeature("ssoManagement") && ( + + )}
@@ -488,7 +490,12 @@ export function SettingsPage({ - + {permissions.hasRuntimeFeature("apiKeys") && ( + + )} diff --git a/app/drizzle/20260615163731_empty_vulcan/migration.sql b/app/drizzle/20260615163731_empty_vulcan/migration.sql new file mode 100644 index 00000000..7255931e --- /dev/null +++ b/app/drizzle/20260615163731_empty_vulcan/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `sessions_table` ADD `auth_source` text DEFAULT 'browser-session' NOT NULL; \ No newline at end of file diff --git a/app/drizzle/20260615163731_empty_vulcan/snapshot.json b/app/drizzle/20260615163731_empty_vulcan/snapshot.json new file mode 100644 index 00000000..f98f1248 --- /dev/null +++ b/app/drizzle/20260615163731_empty_vulcan/snapshot.json @@ -0,0 +1,3509 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "0ad8fc87-d06c-416b-a98b-f79b79795955", + "prevIds": ["dc6150bc-d98b-4635-ae1c-ae23e5995eba"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "agents_table", + "entityType": "tables" + }, + { + "name": "apikey", + "entityType": "tables" + }, + { + "name": "app_metadata", + "entityType": "tables" + }, + { + "name": "backup_schedule_mirrors_table", + "entityType": "tables" + }, + { + "name": "backup_schedule_notifications_table", + "entityType": "tables" + }, + { + "name": "backup_schedules_table", + "entityType": "tables" + }, + { + "name": "invitation", + "entityType": "tables" + }, + { + "name": "member", + "entityType": "tables" + }, + { + "name": "notification_destinations_table", + "entityType": "tables" + }, + { + "name": "organization", + "entityType": "tables" + }, + { + "name": "passkey", + "entityType": "tables" + }, + { + "name": "repositories_table", + "entityType": "tables" + }, + { + "name": "repository_lock_waiters", + "entityType": "tables" + }, + { + "name": "repository_locks", + "entityType": "tables" + }, + { + "name": "sessions_table", + "entityType": "tables" + }, + { + "name": "sso_provider", + "entityType": "tables" + }, + { + "name": "tasks", + "entityType": "tables" + }, + { + "name": "two_factor", + "entityType": "tables" + }, + { + "name": "users_table", + "entityType": "tables" + }, + { + "name": "verification", + "entityType": "tables" + }, + { + "name": "volumes_table", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token_expires_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token_expires_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'offline'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'{}'", + "generated": null, + "name": "capabilities", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_seen_at", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_ready_at", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "agents_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'default'", + "generated": null, + "name": "config_id", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "start", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reference_id", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prefix", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refill_interval", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refill_amount", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_refill_at", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "rate_limit_enabled", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rate_limit_time_window", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rate_limit_max", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "request_count", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "remaining", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_request", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permissions", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "apikey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "schedule_id", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repository_id", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_copy_at", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_copy_status", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_copy_error", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "schedule_id", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "destination_id", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "notify_on_start", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "notify_on_success", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "notify_on_warning", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "notify_on_failure", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "short_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "volume_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repository_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "cron_expression", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "retention_policy", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "exclude_patterns", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "exclude_if_present", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "include_paths", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "include_patterns", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_backup_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_backup_status", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_backup_error", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "next_backup_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "one_file_system", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "custom_restic_params", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backup_webhooks", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "sort_order", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "failure_retry_count", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "2", + "generated": null, + "name": "max_retries", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "900000", + "generated": null, + "name": "retry_delay", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'pending'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "inviter_id", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "member" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "member" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "member" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'member'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "member" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "member" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'unknown'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_checked", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "config", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logo", + "entityType": "columns", + "table": "organization" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "credential_id", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "counter", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "device_type", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backed_up", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "transports", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aaguid", + "entityType": "columns", + "table": "passkey" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "short_id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provisioning_id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "config", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'auto'", + "generated": null, + "name": "compression_mode", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'unknown'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_checked", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "doctor_result", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stats", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stats_updated_at", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "upload_limit_enabled", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "1", + "generated": null, + "name": "upload_limit_value", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Mbps'", + "generated": null, + "name": "upload_limit_unit", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "download_limit_enabled", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "1", + "generated": null, + "name": "download_limit_value", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Mbps'", + "generated": null, + "name": "download_limit_unit", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repository_id", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "operation", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "requested_at", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "heartbeat_at", + "entityType": "columns", + "table": "repository_lock_waiters" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repository_id", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "operation", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "acquired_at", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "heartbeat_at", + "entityType": "columns", + "table": "repository_locks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ip_address", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_agent", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "impersonated_by", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_organization_id", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'browser-session'", + "generated": null, + "name": "auth_source", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "issuer", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "domain", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "auto_link_matching_emails", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "oidc_config", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "saml_config", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource_type", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource_id", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_agent_id", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "input", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "progress", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "error", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "cancellation_requested", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "started_at", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "finished_at", + "entityType": "columns", + "table": "tasks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backup_codes", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "verified", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "username", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password_hash", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "has_downloaded_restic_password", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'MM/DD/YYYY'", + "generated": null, + "name": "date_format", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'12h'", + "generated": null, + "name": "time_format", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "email_verified", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "image", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "display_username", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "two_factor_enabled", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'user'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "banned", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ban_reason", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ban_expires", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "identifier", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "short_id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provisioning_id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'unmounted'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "last_health_check", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "config", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "auto_remount", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'local'", + "generated": null, + "name": "agent_id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "account_user_id_users_table_id_fk", + "entityType": "fks", + "table": "account" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_agents_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "agents_table" + }, + { + "columns": ["reference_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_apikey_reference_id_users_table_id_fk", + "entityType": "fks", + "table": "apikey" + }, + { + "columns": ["schedule_id"], + "tableTo": "backup_schedules_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_mirrors_table" + }, + { + "columns": ["repository_id"], + "tableTo": "repositories_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_mirrors_table" + }, + { + "columns": ["schedule_id"], + "tableTo": "backup_schedules_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_notifications_table" + }, + { + "columns": ["destination_id"], + "tableTo": "notification_destinations_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_notifications_table" + }, + { + "columns": ["volume_id"], + "tableTo": "volumes_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedules_table_volume_id_volumes_table_id_fk", + "entityType": "fks", + "table": "backup_schedules_table" + }, + { + "columns": ["repository_id"], + "tableTo": "repositories_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedules_table_repository_id_repositories_table_id_fk", + "entityType": "fks", + "table": "backup_schedules_table" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedules_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "backup_schedules_table" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "invitation_organization_id_organization_id_fk", + "entityType": "fks", + "table": "invitation" + }, + { + "columns": ["inviter_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "invitation_inviter_id_users_table_id_fk", + "entityType": "fks", + "table": "invitation" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "member_organization_id_organization_id_fk", + "entityType": "fks", + "table": "member" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "member_user_id_users_table_id_fk", + "entityType": "fks", + "table": "member" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "notification_destinations_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "notification_destinations_table" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_passkey_user_id_users_table_id_fk", + "entityType": "fks", + "table": "passkey" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "repositories_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "repositories_table" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "sessions_table_user_id_users_table_id_fk", + "entityType": "fks", + "table": "sessions_table" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_sso_provider_organization_id_organization_id_fk", + "entityType": "fks", + "table": "sso_provider" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_sso_provider_user_id_users_table_id_fk", + "entityType": "fks", + "table": "sso_provider" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_tasks_organization_id_organization_id_fk", + "entityType": "fks", + "table": "tasks" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "two_factor_user_id_users_table_id_fk", + "entityType": "fks", + "table": "two_factor" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "volumes_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "volumes_table" + }, + { + "columns": ["schedule_id", "destination_id"], + "nameExplicit": false, + "name": "backup_schedule_notifications_table_schedule_id_destination_id_pk", + "entityType": "pks", + "table": "backup_schedule_notifications_table" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "agents_table_pk", + "table": "agents_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "apikey_pk", + "table": "apikey", + "entityType": "pks" + }, + { + "columns": ["key"], + "nameExplicit": false, + "name": "app_metadata_pk", + "table": "app_metadata", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_pk", + "table": "backup_schedule_mirrors_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "backup_schedules_table_pk", + "table": "backup_schedules_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "invitation_pk", + "table": "invitation", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "member_pk", + "table": "member", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "notification_destinations_table_pk", + "table": "notification_destinations_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "organization_pk", + "table": "organization", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "passkey_pk", + "table": "passkey", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "repositories_table_pk", + "table": "repositories_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "repository_lock_waiters_pk", + "table": "repository_lock_waiters", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "repository_locks_pk", + "table": "repository_locks", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_table_pk", + "table": "sessions_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sso_provider_pk", + "table": "sso_provider", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "tasks_pk", + "table": "tasks", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "two_factor_pk", + "table": "two_factor", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "users_table_pk", + "table": "users_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "verification_pk", + "table": "verification", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "volumes_table_pk", + "table": "volumes_table", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "account_userId_idx", + "entityType": "indexes", + "table": "account" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "agents_table_organization_id_idx", + "entityType": "indexes", + "table": "agents_table" + }, + { + "columns": [ + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "agents_table_status_idx", + "entityType": "indexes", + "table": "agents_table" + }, + { + "columns": [ + { + "value": "config_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "apikey_configId_idx", + "entityType": "indexes", + "table": "apikey" + }, + { + "columns": [ + { + "value": "reference_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "apikey_referenceId_idx", + "entityType": "indexes", + "table": "apikey" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "apikey_key_unique", + "entityType": "indexes", + "table": "apikey" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "invitation_organizationId_idx", + "entityType": "indexes", + "table": "invitation" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "invitation_email_idx", + "entityType": "indexes", + "table": "invitation" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "member_organizationId_idx", + "entityType": "indexes", + "table": "member" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "member_userId_idx", + "entityType": "indexes", + "table": "member" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "member_org_user_uidx", + "entityType": "indexes", + "table": "member" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "organization_slug_uidx", + "entityType": "indexes", + "table": "organization" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "passkey_userId_idx", + "entityType": "indexes", + "table": "passkey" + }, + { + "columns": [ + { + "value": "credential_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "passkey_credentialID_idx", + "entityType": "indexes", + "table": "passkey" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + }, + { + "value": "provisioning_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "repositories_table_org_provisioning_id_uidx", + "entityType": "indexes", + "table": "repositories_table" + }, + { + "columns": [ + { + "value": "repository_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repository_lock_waiters_repository_id_idx", + "entityType": "indexes", + "table": "repository_lock_waiters" + }, + { + "columns": [ + { + "value": "expires_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repository_lock_waiters_expires_at_idx", + "entityType": "indexes", + "table": "repository_lock_waiters" + }, + { + "columns": [ + { + "value": "owner_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repository_lock_waiters_owner_id_idx", + "entityType": "indexes", + "table": "repository_lock_waiters" + }, + { + "columns": [ + { + "value": "repository_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repository_locks_repository_id_idx", + "entityType": "indexes", + "table": "repository_locks" + }, + { + "columns": [ + { + "value": "expires_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repository_locks_expires_at_idx", + "entityType": "indexes", + "table": "repository_locks" + }, + { + "columns": [ + { + "value": "owner_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repository_locks_owner_id_idx", + "entityType": "indexes", + "table": "repository_locks" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "sessionsTable_userId_idx", + "entityType": "indexes", + "table": "sessions_table" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + }, + { + "value": "kind", + "isExpression": false + }, + { + "value": "resource_type", + "isExpression": false + }, + { + "value": "resource_id", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "tasks_org_kind_resource_status_idx", + "entityType": "indexes", + "table": "tasks" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + }, + { + "value": "updated_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "tasks_org_status_updated_at_idx", + "entityType": "indexes", + "table": "tasks" + }, + { + "columns": [ + { + "value": "secret", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "twoFactor_secret_idx", + "entityType": "indexes", + "table": "two_factor" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "twoFactor_userId_idx", + "entityType": "indexes", + "table": "two_factor" + }, + { + "columns": [ + { + "value": "identifier", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "verification_identifier_idx", + "entityType": "indexes", + "table": "verification" + }, + { + "columns": [ + { + "value": "agent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "volumes_table_agent_id_idx", + "entityType": "indexes", + "table": "volumes_table" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + }, + { + "value": "provisioning_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "volumes_table_org_provisioning_id_uidx", + "entityType": "indexes", + "table": "volumes_table" + }, + { + "columns": ["schedule_id", "repository_id"], + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique", + "entityType": "uniques", + "table": "backup_schedule_mirrors_table" + }, + { + "columns": ["name", "organization_id"], + "nameExplicit": false, + "name": "volumes_table_name_organization_id_unique", + "entityType": "uniques", + "table": "volumes_table" + }, + { + "columns": ["short_id"], + "nameExplicit": false, + "name": "backup_schedules_table_short_id_unique", + "entityType": "uniques", + "table": "backup_schedules_table" + }, + { + "columns": ["short_id"], + "nameExplicit": false, + "name": "repositories_table_short_id_unique", + "entityType": "uniques", + "table": "repositories_table" + }, + { + "columns": ["token"], + "nameExplicit": false, + "name": "sessions_table_token_unique", + "entityType": "uniques", + "table": "sessions_table" + }, + { + "columns": ["provider_id"], + "nameExplicit": false, + "name": "sso_provider_provider_id_unique", + "entityType": "uniques", + "table": "sso_provider" + }, + { + "columns": ["username"], + "nameExplicit": false, + "name": "users_table_username_unique", + "entityType": "uniques", + "table": "users_table" + }, + { + "columns": ["email"], + "nameExplicit": false, + "name": "users_table_email_unique", + "entityType": "uniques", + "table": "users_table" + }, + { + "columns": ["short_id"], + "nameExplicit": false, + "name": "volumes_table_short_id_unique", + "entityType": "uniques", + "table": "volumes_table" + } + ], + "renames": [] +} diff --git a/app/lib/__tests__/permission-policy.test.ts b/app/lib/__tests__/permission-policy.test.ts index a590a8c0..a0dd41ca 100644 --- a/app/lib/__tests__/permission-policy.test.ts +++ b/app/lib/__tests__/permission-policy.test.ts @@ -46,6 +46,40 @@ describe("permissions", () => { evaluatePermission("ssoProvider.create", { runtime: "server", orgRole: "owner", + authSource: "desktop-session", + }), + ).toEqual({ allowed: false, reason: "authSource" }); + + expect( + evaluatePermission("ssoProvider.create", { + runtime: "server", + orgRole: "owner", + authSource: "api-key", + }), + ).toEqual({ allowed: false, reason: "authSource" }); + }); + + test("allows recovery-key download for browser and desktop sessions but not API keys", () => { + expect( + evaluatePermission("recoveryKey.download", { + runtime: "desktop", + orgRole: "owner", + authSource: "desktop-session", + }).allowed, + ).toBe(true); + + expect( + evaluatePermission("recoveryKey.download", { + runtime: "server", + orgRole: "owner", + authSource: "browser-session", + }).allowed, + ).toBe(true); + + expect( + evaluatePermission("recoveryKey.download", { + runtime: "desktop", + orgRole: "owner", authSource: "api-key", }), ).toEqual({ allowed: false, reason: "authSource" }); @@ -80,5 +114,9 @@ describe("permissions", () => { test("models runtime features independently from user roles", () => { expect(hasRuntimeFeature("server", "remoteVolumeBackends")).toBe(true); expect(hasRuntimeFeature("desktop", "remoteVolumeBackends")).toBe(false); + expect(hasRuntimeFeature("server", "apiKeys")).toBe(true); + expect(hasRuntimeFeature("desktop", "apiKeys")).toBe(false); + expect(hasRuntimeFeature("server", "passwordAuthentication")).toBe(true); + expect(hasRuntimeFeature("desktop", "passwordAuthentication")).toBe(false); }); }); diff --git a/app/lib/permission-policy.ts b/app/lib/permission-policy.ts index ba4aa800..55bd93b2 100644 --- a/app/lib/permission-policy.ts +++ b/app/lib/permission-policy.ts @@ -1,11 +1,13 @@ export type Runtime = "server" | "desktop"; -export type AuthSource = "browser-session" | "api-key"; +export type AuthSource = "browser-session" | "desktop-session" | "api-key"; export type RuntimeFeature = | "instanceAdministration" | "organizationAdministration" | "ssoManagement" - | "remoteVolumeBackends"; + | "remoteVolumeBackends" + | "apiKeys" + | "passwordAuthentication"; export const RUNTIME_FEATURES = { server: { @@ -13,12 +15,16 @@ export const RUNTIME_FEATURES = { organizationAdministration: true, ssoManagement: true, remoteVolumeBackends: true, + apiKeys: true, + passwordAuthentication: true, }, desktop: { instanceAdministration: false, organizationAdministration: false, ssoManagement: false, remoteVolumeBackends: false, + apiKeys: false, + passwordAuthentication: false, }, } as const satisfies Record>; @@ -65,7 +71,7 @@ const PERMISSIONS = { }, "recoveryKey.download": { orgRoles: ["owner", "admin"], - authSources: ["browser-session"], + authSources: ["browser-session", "desktop-session"], }, } as const satisfies Record; diff --git a/app/middleware/auth.ts b/app/middleware/auth.ts index 3de945c8..439a2a1e 100644 --- a/app/middleware/auth.ts +++ b/app/middleware/auth.ts @@ -5,6 +5,7 @@ import { getCookie, getRequestHeaders } from "@tanstack/react-start/server"; import { authService } from "~/server/modules/auth/auth.service"; import { isAuthRoute } from "~/lib/auth-routes"; import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip"; +import { invalidateAuthSession, isSessionAuthSourceAllowed } from "~/server/modules/auth/helpers"; export const authMiddleware = createMiddleware().server(async ({ next, request }) => { const headers = getRequestHeaders(); @@ -21,6 +22,12 @@ export const authMiddleware = createMiddleware().server(async ({ next, request } } if (session?.user?.id) { + if (!isSessionAuthSourceAllowed(session.session.authSource)) { + await invalidateAuthSession(session.session.token); + + throw redirect({ to: "/login" }); + } + const hasSkippedRecoveryKeyDownload = getCookie(RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME) === session.user.id; if ( diff --git a/app/routes/(dashboard)/settings/index.tsx b/app/routes/(dashboard)/settings/index.tsx index e18db934..14cdc1e3 100644 --- a/app/routes/(dashboard)/settings/index.tsx +++ b/app/routes/(dashboard)/settings/index.tsx @@ -21,10 +21,12 @@ export const Route = createFileRoute("/(dashboard)/settings/")({ queryKey: ["organization-context"], queryFn: () => getOrganizationContext(), }); - const userInvitationsPromise = context.queryClient.ensureQueryData({ ...getUserSsoInvitationsOptions() }); - const [authContext, userInvitations] = await Promise.all([authContextPromise, userInvitationsPromise]); - await orgContextPromise; + const authContext = await authContextPromise; + const userInvitationsPromise = context.features.ssoManagement + ? context.queryClient.ensureQueryData({ ...getUserSsoInvitationsOptions() }) + : Promise.resolve([]); + const [userInvitations] = await Promise.all([userInvitationsPromise, orgContextPromise]); const shouldPrefetchOrgQueries = context.permissions["organizationSettings.view"]; diff --git a/app/server/app.ts b/app/server/app.ts index 37035c28..60982d32 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -22,6 +22,7 @@ import { logger } from "@zerobyte/core/node"; import { config } from "./core/config"; import { auth } from "~/server/lib/auth"; import { db } from "./db/db"; +import { invalidateAuthSession, isSessionAuthSourceAllowed } from "./modules/auth/helpers"; const requestLogger = async (c: Context, next: Next) => { const method = c.req.method; @@ -98,7 +99,7 @@ export const createApp = () => { .route("/api/v1/desktop", desktopController) .route("/api/v1/events", eventsController); - app.on(["POST", "GET"], "/api/auth/*", (c) => { + app.on(["POST", "GET"], "/api/auth/*", async (c) => { const pathname = new URL(c.req.url).pathname; if (pathname.startsWith("/api/auth/api-key/")) { return c.json({ message: "API key management is only supported through API v1 routes" }, 404); @@ -108,6 +109,13 @@ export const createApp = () => { return c.json({ message: "API key authentication is only supported for API v1 routes" }, 401); } + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + if (session && !isSessionAuthSourceAllowed(session.session.authSource)) { + await invalidateAuthSession(session.session.token, c); + + return c.json({ message: "Invalid or expired session" }, 401); + } + return auth.handler(c.req.raw); }); app.get("/api/v1/openapi.json", generalDescriptor(app)); diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 1081064d..b9caf9f3 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -15,6 +15,8 @@ import type { NotificationConfig, NotificationType } from "~/schemas/notificatio import type { ShortId } from "~/server/utils/branded"; import { LOCAL_AGENT_ID } from "../modules/agents/constants"; +type SessionAuthSource = "browser-session" | "desktop-session"; + /** * Users Table */ @@ -65,6 +67,7 @@ export const sessionsTable = sqliteTable( userAgent: text("user_agent"), impersonatedBy: text("impersonated_by"), activeOrganizationId: text("active_organization_id"), + authSource: text("auth_source").$type().notNull().default("browser-session"), }, (table) => [index("sessionsTable_userId_idx").on(table.userId)], ); diff --git a/app/server/lib/auth.ts b/app/server/lib/auth.ts index 9273e6db..2cf72cec 100644 --- a/app/server/lib/auth.ts +++ b/app/server/lib/auth.ts @@ -155,6 +155,14 @@ export const auth = betterAuth({ }, session: { modelName: "sessionsTable", + additionalFields: { + authSource: { + type: "string", + returned: true, + input: false, + defaultValue: "browser-session", + }, + }, }, plugins: [ username({ diff --git a/app/server/lib/functions/current-permissions.ts b/app/server/lib/functions/current-permissions.ts index c333a8f4..645c086d 100644 --- a/app/server/lib/functions/current-permissions.ts +++ b/app/server/lib/functions/current-permissions.ts @@ -3,6 +3,7 @@ import { getRequestHeaders } from "@tanstack/react-start/server"; import type { Permission, RuntimeFeature } from "~/lib/permission-policy"; import { resolvePermissions } from "~/server/core/request-context"; import { auth } from "~/server/lib/auth"; +import { getSessionAuthSource } from "~/server/modules/auth/helpers"; export const currentPermissionsQueryKey = ["current-permissions"] as const; @@ -27,7 +28,7 @@ export const getCurrentPermissions = createServerFn({ method: "GET" }).handler( const { permissions, features } = resolvePermissions({ instanceRole: session?.user?.role, orgRole: activeMember?.role, - authSource: session?.user ? ("browser-session" as const) : null, + authSource: session?.user ? getSessionAuthSource(session.session.authSource) : null, }); return { permissions, features }; diff --git a/app/server/modules/api-keys/api-keys.controller.ts b/app/server/modules/api-keys/api-keys.controller.ts index ad23e2d9..38ff70cc 100644 --- a/app/server/modules/api-keys/api-keys.controller.ts +++ b/app/server/modules/api-keys/api-keys.controller.ts @@ -9,21 +9,29 @@ import { type ListApiKeysDto, } from "./api-keys.dto"; import { MAX_API_KEYS_PER_USER, countActiveApiKeys, hasApiKey, listApiKeys } from "./api-keys.service"; -import { requireAuth, requireBrowserSession } from "../auth/auth.middleware"; +import { requireAuth, requireBrowserSession, requireRuntimeFeature } from "../auth/auth.middleware"; import { auth } from "~/server/lib/auth"; import { isPasswordAuthSupported, userHasPassword, verifyUserPassword } from "../auth/helpers"; export const apiKeysController = new Hono() - .get("/api-keys", requireAuth, requireBrowserSession, getApiKeysDto, async (c) => { - const user = c.get("user"); - const organizationId = c.get("organizationId"); - const apiKeys = await listApiKeys(user.id, organizationId); + .get( + "/api-keys", + requireAuth, + requireRuntimeFeature("apiKeys"), + requireBrowserSession, + getApiKeysDto, + async (c) => { + const user = c.get("user"); + const organizationId = c.get("organizationId"); + const apiKeys = await listApiKeys(user.id, organizationId); - return c.json({ apiKeys, limit: MAX_API_KEYS_PER_USER }); - }) + return c.json({ apiKeys, limit: MAX_API_KEYS_PER_USER }); + }, + ) .post( "/api-keys", requireAuth, + requireRuntimeFeature("apiKeys"), requireBrowserSession, createApiKeyDto, validator("json", createApiKeyBody), @@ -69,20 +77,27 @@ export const apiKeysController = new Hono() }); }, ) - .delete("/api-keys/:keyId", requireAuth, requireBrowserSession, deleteApiKeyDto, async (c) => { - const user = c.get("user"); - const organizationId = c.get("organizationId"); - const keyId = c.req.param("keyId"); + .delete( + "/api-keys/:keyId", + requireAuth, + requireRuntimeFeature("apiKeys"), + requireBrowserSession, + deleteApiKeyDto, + async (c) => { + const user = c.get("user"); + const organizationId = c.get("organizationId"); + const keyId = c.req.param("keyId"); - const belongsToUserOrganization = await hasApiKey(user.id, organizationId, keyId); - if (!belongsToUserOrganization) { - return c.json({ message: "API key not found" }, 404); - } + const belongsToUserOrganization = await hasApiKey(user.id, organizationId, keyId); + if (!belongsToUserOrganization) { + return c.json({ message: "API key not found" }, 404); + } - await auth.api.deleteApiKey({ - headers: c.req.raw.headers, - body: { keyId }, - }); + await auth.api.deleteApiKey({ + headers: c.req.raw.headers, + body: { keyId }, + }); - return c.json({ success: true }); - }); + return c.json({ success: true }); + }, + ); diff --git a/app/server/modules/auth/__tests__/auth.api-keys.test.ts b/app/server/modules/auth/__tests__/auth.api-keys.test.ts index e4dd4eb2..58f903a0 100644 --- a/app/server/modules/auth/__tests__/auth.api-keys.test.ts +++ b/app/server/modules/auth/__tests__/auth.api-keys.test.ts @@ -74,6 +74,16 @@ async function createStoredApiKey(session: TestSession, organizationId = session }); } +async function createDesktopRuntimeSession() { + config.runtime = "desktop"; + const session = await createTestSession(); + await db + .update(sessionsTable) + .set({ authSource: "desktop-session" }) + .where(eq(sessionsTable.token, session.session.token)); + return session; +} + describe("API keys", () => { test("creates and lists API keys for the current organization after password confirmation", async () => { const session = await createTestSession(); @@ -165,7 +175,7 @@ describe("API keys", () => { }); }); - test("creates API keys without password confirmation when password auth is unsupported", async () => { + test("rejects browser sessions in desktop runtime", async () => { config.runtime = "desktop"; const session = await createTestSession(); @@ -178,13 +188,21 @@ describe("API keys", () => { body: JSON.stringify({ name: "Desktop key", password: "" }), }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual( - expect.objectContaining({ - name: "Desktop key", - key: expect.stringMatching(/^zb_/), - }), - ); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ + message: "Invalid or expired session", + }); + }); + + test("does not expose API key endpoints when the runtime feature is unavailable", async () => { + const session = await createDesktopRuntimeSession(); + + const res = await app.request("/api/v1/auth/api-keys", { + headers: session.headers, + }); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ message: "Not available in desktop mode" }); }); test("enforces the per-user API key limit", async () => { @@ -323,6 +341,33 @@ describe("API keys", () => { } }); + test("does not expose SSO invitation browser flow routes when the runtime feature is unavailable", async () => { + const session = await createDesktopRuntimeSession(); + + const routes = [ + { method: "GET", path: "/api/v1/auth/sso-invitations" }, + { + method: "POST", + path: "/api/v1/auth/sso-invitations/test-invitation/verify", + body: { providerId: "test-provider" }, + }, + ]; + + for (const route of routes) { + const res = await app.request(route.path, { + method: route.method, + headers: { + ...session.headers, + "Content-Type": "application/json", + }, + body: route.body ? JSON.stringify(route.body) : undefined, + }); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ message: "Not available in desktop mode" }); + } + }); + test("does not allow API keys to mutate SSO admin resources", async () => { const session = await createTestSessionWithOrgAdmin(); await addPassword(session); diff --git a/app/server/modules/auth/__tests__/auth.helpers.test.ts b/app/server/modules/auth/__tests__/auth.helpers.test.ts new file mode 100644 index 00000000..a128de7b --- /dev/null +++ b/app/server/modules/auth/__tests__/auth.helpers.test.ts @@ -0,0 +1,21 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { config } from "~/server/core/config"; +import { isPasswordAuthSupported, isSessionAuthSourceAllowed } from "../helpers"; + +describe("auth helpers", () => { + beforeEach(() => { + config.runtime = "server"; + }); + + test("allows only the runtime session source and models password auth as a runtime feature", () => { + expect(isPasswordAuthSupported()).toBe(true); + expect(isSessionAuthSourceAllowed("browser-session")).toBe(true); + expect(isSessionAuthSourceAllowed("desktop-session")).toBe(false); + + config.runtime = "desktop"; + + expect(isPasswordAuthSupported()).toBe(false); + expect(isSessionAuthSourceAllowed("browser-session")).toBe(false); + expect(isSessionAuthSourceAllowed("desktop-session")).toBe(true); + }); +}); diff --git a/app/server/modules/auth/auth.middleware.ts b/app/server/modules/auth/auth.middleware.ts index 0039589d..02196e19 100644 --- a/app/server/modules/auth/auth.middleware.ts +++ b/app/server/modules/auth/auth.middleware.ts @@ -1,12 +1,12 @@ import { createMiddleware } from "hono/factory"; import { auth } from "~/server/lib/auth"; import { db } from "~/server/db/db"; -import { getPermission, withContext } from "~/server/core/request-context"; +import { getPermission, hasFeature, withContext } from "~/server/core/request-context"; import { getApiKeyOrganizationId } from "../api-keys/api-keys.service"; -import type { Permission } from "~/lib/permission-policy"; +import type { AuthSource, Permission, RuntimeFeature } from "~/lib/permission-policy"; +import { getSessionAuthSource, invalidateAuthSession, isSessionAuthSourceAllowed } from "./helpers"; const API_KEY_HEADER = "x-api-key"; -type AuthSource = "browser-session" | "api-key"; type AuthenticatedUser = { id: string; email: string; @@ -31,7 +31,7 @@ declare module "hono" { */ export const requireAuth = createMiddleware(async (c, next) => { const apiKeyValue = c.req.header(API_KEY_HEADER); - const authSource: AuthSource = apiKeyValue ? "api-key" : "browser-session"; + let authSource: AuthSource = apiKeyValue ? "api-key" : "browser-session"; let user: AuthenticatedUser | undefined; let activeOrganizationId: string | null | undefined; @@ -53,8 +53,15 @@ export const requireAuth = createMiddleware(async (c, next) => { const sess = await auth.api.getSession({ headers: c.req.raw.headers }); if (sess) { + if (!isSessionAuthSourceAllowed(sess.session.authSource)) { + await invalidateAuthSession(sess.session.token, c); + + return c.json({ message: "Invalid or expired session" }, 401); + } + user = sess.user; activeOrganizationId = sess.session.activeOrganizationId; + authSource = getSessionAuthSource(sess.session.authSource); } } @@ -100,6 +107,14 @@ export const requireAuth = createMiddleware(async (c, next) => { }); export const requireBrowserSession = createMiddleware(async (c, next) => { + if (c.get("authSource") !== "browser-session") { + return c.json({ message: "Browser session required" }, 401); + } + + await next(); +}); + +export const requireUserSession = createMiddleware(async (c, next) => { if (c.get("authSource") === "api-key") { return c.json({ message: "Browser session required" }, 401); } @@ -107,6 +122,15 @@ export const requireBrowserSession = createMiddleware(async (c, next) => { await next(); }); +export const requireRuntimeFeature = (feature: RuntimeFeature) => + createMiddleware(async (c, next) => { + if (!hasFeature(feature)) { + return c.json({ message: "Not available in desktop mode" }, 403); + } + + await next(); + }); + export const requirePermission = (permission: Permission) => createMiddleware(async (c, next) => { const result = getPermission(permission); @@ -142,7 +166,7 @@ export const requireOrgAdmin = createMiddleware(async (c, next) => { }); export const requireAdmin = createMiddleware(async (c, next) => { - if (c.get("authSource") === "api-key") { + if (c.get("authSource") !== "browser-session") { return c.json({ message: "Browser session required" }, 401); } diff --git a/app/server/modules/auth/helpers.ts b/app/server/modules/auth/helpers.ts index 1d7092cb..4e5ec48a 100644 --- a/app/server/modules/auth/helpers.ts +++ b/app/server/modules/auth/helpers.ts @@ -1,15 +1,38 @@ import { eq } from "drizzle-orm"; import { verifyPassword } from "better-auth/crypto"; +import type { Context } from "hono"; +import { deleteCookie } from "hono/cookie"; +import { hasRuntimeFeature } from "~/lib/permission-policy"; import { config } from "~/server/core/config"; import { db } from "~/server/db/db"; import { passkey, usersTable } from "~/server/db/schema"; +import { auth } from "~/server/lib/auth"; type PasswordVerificationBody = { userId: string; password: string; }; -export const isPasswordAuthSupported = () => config.runtime !== "desktop"; +type SessionAuthSource = "browser-session" | "desktop-session"; + +export const getSessionAuthSource = (authSource: string | null | undefined): SessionAuthSource => + authSource === "desktop-session" ? "desktop-session" : "browser-session"; + +export const isSessionAuthSourceAllowed = (authSource: string | null | undefined) => + getSessionAuthSource(authSource) === (config.runtime === "desktop" ? "desktop-session" : "browser-session"); + +export const invalidateAuthSession = async (token: string, c?: Context) => { + const authContext = await auth.$context; + await authContext.internalAdapter.deleteSession(token); + + if (c) { + for (const cookie of Object.values(authContext.authCookies)) { + deleteCookie(c, cookie.name, cookie.attributes); + } + } +}; + +export const isPasswordAuthSupported = () => hasRuntimeFeature(config.runtime, "passwordAuthentication"); export const verifyUserPassword = async ({ password, userId }: PasswordVerificationBody) => { const userAccount = await db.query.account.findFirst({ diff --git a/app/server/modules/desktop/__tests__/desktop.controller.test.ts b/app/server/modules/desktop/__tests__/desktop.controller.test.ts index 8b04a9f6..84f5419d 100644 --- a/app/server/modules/desktop/__tests__/desktop.controller.test.ts +++ b/app/server/modules/desktop/__tests__/desktop.controller.test.ts @@ -1,11 +1,13 @@ import { afterEach, describe, expect, test } from "vitest"; import { eq } from "drizzle-orm"; +import { hashPassword } from "better-auth/crypto"; import { createApp } from "~/server/app"; import { config } from "~/server/core/config"; import { db } from "~/server/db/db"; -import { usersTable } from "~/server/db/schema"; +import { account, usersTable } from "~/server/db/schema"; +import { createTestSession } from "~/test/helpers/auth"; import { DESKTOP_LAUNCH_SECRET_HEADER } from "../desktop.service"; -import { DESKTOP_USER_EMAIL } from "../bootstrap"; +import { DESKTOP_USER_EMAIL } from "../constants"; const app = createApp(); const launchSecret = "s".repeat(32); @@ -20,6 +22,32 @@ const useDesktopRuntime = () => { config.desktop.launchSecret = launchSecret; }; +const createDesktopSessionCookie = async () => { + useDesktopRuntime(); + + const res = await app.request("/api/v1/desktop/session", { + method: "POST", + headers: { + [DESKTOP_LAUNCH_SECRET_HEADER]: launchSecret, + "Content-Type": "application/json", + }, + body: JSON.stringify({ dateFormat: "DD/MM/YYYY", timeFormat: "24h" }), + }); + const cookie = res.headers.get("set-cookie")?.split(";")[0]; + const body = (await res.clone().json()) as { token: string }; + + expect(res.status).toBe(200); + expect(cookie).toBeTruthy(); + + return { cookie: cookie ?? "", token: body.token }; +}; + +const expectSessionCookieCleared = (res: Response) => { + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toContain("zerobyte.session_token="); + expect(setCookie).toContain("Max-Age=0"); +}; + describe("desktopController", () => { test("rejects desktop session requests without the launch secret", async () => { useDesktopRuntime(); @@ -46,7 +74,7 @@ describe("desktopController", () => { expect(res.status).toBe(400); }); - test("creates a normal session cookie when the launch secret is valid", async () => { + test("creates a desktop-scoped session cookie when the launch secret is valid", async () => { useDesktopRuntime(); await db .update(usersTable) @@ -64,10 +92,112 @@ describe("desktopController", () => { expect(res.status).toBe(200); expect(res.headers.get("set-cookie")).toContain("zerobyte.session_token"); + const body = (await res.clone().json()) as { token: string }; const desktopUser = await db.query.usersTable.findFirst({ where: { email: DESKTOP_USER_EMAIL }, }); + const desktopAuthSession = await db.query.sessionsTable.findFirst({ + where: { token: body.token }, + }); expect(desktopUser?.hasDownloadedResticPassword).toBe(false); + expect(desktopAuthSession?.authSource).toBe("desktop-session"); + }); + + test("rejects reserved desktop users that do not have the derived desktop credential", async () => { + useDesktopRuntime(); + await db.delete(usersTable).where(eq(usersTable.email, DESKTOP_USER_EMAIL)); + + const userId = crypto.randomUUID(); + try { + await db.insert(usersTable).values({ + id: userId, + username: `desktop-collision-${crypto.randomUUID()}`, + name: "Desktop Collision", + email: DESKTOP_USER_EMAIL, + }); + await db.insert(account).values({ + id: crypto.randomUUID(), + accountId: DESKTOP_USER_EMAIL, + providerId: "credential", + userId, + password: await hashPassword("wrong-password"), + }); + + const res = await app.request("/api/v1/desktop/session", { + method: "POST", + headers: { + [DESKTOP_LAUNCH_SECRET_HEADER]: launchSecret, + "Content-Type": "application/json", + }, + body: JSON.stringify({ dateFormat: "DD/MM/YYYY", timeFormat: "24h" }), + }); + + expect(res.status).toBe(401); + expect(await db.query.sessionsTable.findFirst({ where: { userId } })).toBeUndefined(); + } finally { + await db.delete(usersTable).where(eq(usersTable.email, DESKTOP_USER_EMAIL)); + } + }); + + test("does not treat desktop sessions as browser sessions for admin routes", async () => { + const v1Session = await createDesktopSessionCookie(); + config.runtime = "server"; + + const adminRes = await app.request("/api/v1/auth/admin-users", { + headers: { + Cookie: v1Session.cookie, + }, + }); + + expect(adminRes.status).toBe(401); + expectSessionCookieCleared(adminRes); + expect(await db.query.sessionsTable.findFirst({ where: { token: v1Session.token } })).toBeUndefined(); + + const directSession = await createDesktopSessionCookie(); + config.runtime = "server"; + const directSessionRes = await app.request("/api/auth/get-session", { + headers: { + Cookie: directSession.cookie, + }, + }); + + expect(directSessionRes.status).toBe(401); + expectSessionCookieCleared(directSessionRes); + expect(await db.query.sessionsTable.findFirst({ where: { token: directSession.token } })).toBeUndefined(); + + const betterAuthAdminSession = await createDesktopSessionCookie(); + config.runtime = "server"; + const betterAuthAdminRes = await app.request("/api/auth/admin/list-users", { + headers: { + Cookie: betterAuthAdminSession.cookie, + }, + }); + + expect(betterAuthAdminRes.status).toBe(401); + expectSessionCookieCleared(betterAuthAdminRes); + expect( + await db.query.sessionsTable.findFirst({ where: { token: betterAuthAdminSession.token } }), + ).toBeUndefined(); + }); + + test("does not allow browser sessions to self-mark as desktop sessions", async () => { + const session = await createTestSession(); + + const res = await app.request("/api/auth/update-session", { + method: "POST", + headers: { + ...session.headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ authSource: "desktop-session" }), + }); + + expect(res.status).toBe(400); + + const storedSession = await db.query.sessionsTable.findFirst({ + where: { token: session.session.token }, + }); + expect(storedSession?.authSource).toBe("browser-session"); }); }); diff --git a/app/server/modules/desktop/bootstrap.ts b/app/server/modules/desktop/bootstrap.ts index 6aec98c5..0bec9e1b 100644 --- a/app/server/modules/desktop/bootstrap.ts +++ b/app/server/modules/desktop/bootstrap.ts @@ -1,16 +1,14 @@ import { eq } from "drizzle-orm"; +import { UnauthorizedError } from "http-errors-enhanced"; import type { DateFormatPreference, TimeFormatPreference } from "~/lib/datetime"; import { config } from "~/server/core/config"; import { db } from "~/server/db/db"; import { usersTable } from "~/server/db/schema"; +import { verifyUserPassword } from "~/server/modules/auth/helpers"; import { ensureDefaultOrg } from "~/server/lib/auth/helpers/create-default-org"; import { auth } from "~/server/lib/auth"; import { cryptoUtils } from "~/server/utils/crypto"; - -export const DESKTOP_USER_EMAIL = "desktop@zerobyte.local"; -export const DESKTOP_USERNAME = "desktop-admin"; - -export const getDesktopUserPassword = () => cryptoUtils.deriveSecret("zerobyte:desktop-user-password"); +import { DESKTOP_USER_EMAIL, DESKTOP_USERNAME } from "./constants"; type DesktopDateTimePreferences = { dateFormat: DateFormatPreference; @@ -22,8 +20,8 @@ export const ensureDesktopIdentity = async ({ dateFormat, timeFormat }: DesktopD return; } - const password = await getDesktopUserPassword(); let user = await db.query.usersTable.findFirst({ where: { email: DESKTOP_USER_EMAIL } }); + const password = await cryptoUtils.deriveSecret("zerobyte:desktop-user-password"); if (!user) { await auth.api.signUpEmail({ @@ -40,6 +38,8 @@ export const ensureDesktopIdentity = async ({ dateFormat, timeFormat }: DesktopD }); user = await db.query.usersTable.findFirst({ where: { email: DESKTOP_USER_EMAIL } }); + } else if (!(await verifyUserPassword({ userId: user.id, password }))) { + throw new UnauthorizedError("Reserved desktop user is not trusted"); } if (!user) { @@ -52,4 +52,11 @@ export const ensureDesktopIdentity = async ({ dateFormat, timeFormat }: DesktopD .update(usersTable) .set({ role: "admin", emailVerified: true, updatedAt: new Date() }) .where(eq(usersTable.id, user.id)); + + const desktopUser = await db.query.usersTable.findFirst({ where: { id: user.id } }); + if (!desktopUser) { + throw new Error("Failed to load desktop user"); + } + + return desktopUser; }; diff --git a/app/server/modules/desktop/constants.ts b/app/server/modules/desktop/constants.ts new file mode 100644 index 00000000..bce1cebf --- /dev/null +++ b/app/server/modules/desktop/constants.ts @@ -0,0 +1,2 @@ +export const DESKTOP_USER_EMAIL = "desktop@zerobyte.local"; +export const DESKTOP_USERNAME = "desktop-admin"; diff --git a/app/server/modules/desktop/desktop.controller.ts b/app/server/modules/desktop/desktop.controller.ts index 2fab87a5..412f89b5 100644 --- a/app/server/modules/desktop/desktop.controller.ts +++ b/app/server/modules/desktop/desktop.controller.ts @@ -1,6 +1,8 @@ import { Hono } from "hono"; import { createMiddleware } from "hono/factory"; +import { setSignedCookie } from "hono/cookie"; import { validator } from "hono-openapi"; +import { auth } from "~/server/lib/auth"; import { DESKTOP_LAUNCH_SECRET_HEADER, desktopService, verifyDesktopLaunchSecret } from "./desktop.service"; import { createDesktopSessionBody, createDesktopSessionDto } from "./desktop.dto"; @@ -15,6 +17,19 @@ export const desktopController = new Hono().post( createDesktopSessionDto, validator("json", createDesktopSessionBody), async (c) => { - return desktopService.createDesktopSessionResponse(c.req.valid("json")); + const { session, user } = await desktopService.createDesktopSession(c.req.valid("json")); + const authContext = await auth.$context; + + await setSignedCookie(c, authContext.authCookies.sessionToken.name, session.token, authContext.secret, { + ...authContext.authCookies.sessionToken.attributes, + maxAge: authContext.sessionConfig.expiresIn, + }); + + return c.json({ + redirect: false, + token: session.token, + url: undefined, + user, + }); }, ); diff --git a/app/server/modules/desktop/desktop.service.ts b/app/server/modules/desktop/desktop.service.ts index fcb3003f..f2db174d 100644 --- a/app/server/modules/desktop/desktop.service.ts +++ b/app/server/modules/desktop/desktop.service.ts @@ -1,7 +1,7 @@ import { BadRequestError, UnauthorizedError } from "http-errors-enhanced"; import { config } from "~/server/core/config"; import { auth } from "~/server/lib/auth"; -import { DESKTOP_USER_EMAIL, ensureDesktopIdentity, getDesktopUserPassword } from "~/server/modules/desktop/bootstrap"; +import { ensureDesktopIdentity } from "~/server/modules/desktop/bootstrap"; import { cryptoUtils } from "~/server/utils/crypto"; import type { CreateDesktopSessionBody } from "./desktop.dto"; @@ -26,21 +26,23 @@ export const verifyDesktopLaunchSecret = (secret: string | undefined) => { } }; -const createDesktopSessionResponse = async (body: CreateDesktopSessionBody) => { +const createDesktopSession = async (body: CreateDesktopSessionBody) => { assertDesktopRuntime(); - await ensureDesktopIdentity(body); - const password = await getDesktopUserPassword(); - return auth.api.signInEmail({ - body: { - email: DESKTOP_USER_EMAIL, - password, - rememberMe: true, - }, - asResponse: true, - }); + const user = await ensureDesktopIdentity(body); + if (!user) { + throw new Error("Failed to bootstrap desktop user"); + } + + const ctx = await auth.$context; + const session = await ctx.internalAdapter.createSession(user.id, false, { authSource: "desktop-session" }, true); + if (!session) { + throw new Error("Failed to create desktop session"); + } + + return { session, user }; }; export const desktopService = { - createDesktopSessionResponse, + createDesktopSession, }; diff --git a/app/server/modules/repositories/__tests__/repositories.controller.test.ts b/app/server/modules/repositories/__tests__/repositories.controller.test.ts index 7de1322a..9a123cd4 100644 --- a/app/server/modules/repositories/__tests__/repositories.controller.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.controller.test.ts @@ -3,12 +3,16 @@ import crypto from "node:crypto"; import { PassThrough } from "node:stream"; import { createApp } from "~/server/app"; import { db } from "~/server/db/db"; -import { repositoriesTable } from "~/server/db/schema"; +import { member, repositoriesTable, sessionsTable } from "~/server/db/schema"; import { generateShortId } from "~/server/utils/id"; import { createTestSession, getAuthHeaders } from "~/test/helpers/auth"; import type { RepositoryConfig } from "@zerobyte/core/restic"; import { restic } from "~/server/core/restic"; import { Effect } from "effect"; +import { systemService } from "~/server/modules/system/system.service"; +import { repositoriesService } from "../repositories.service"; +import { eq } from "drizzle-orm"; +import { config } from "~/server/core/config"; const app = createApp(); @@ -23,6 +27,7 @@ beforeEach(() => { }); afterEach(() => { + config.runtime = "server"; vi.restoreAllMocks(); }); @@ -151,6 +156,31 @@ describe("repositories security", () => { const body = await res.json(); expect(body.message).toBe("Invalid or expired session"); }); + + test("POST /api/v1/repositories/:shortId/exec should allow desktop sessions", async () => { + config.runtime = "desktop"; + const desktopSession = await createTestSession(); + await db.update(member).set({ role: "admin" }).where(eq(member.userId, desktopSession.user.id)); + await db + .update(sessionsTable) + .set({ authSource: "desktop-session" }) + .where(eq(sessionsTable.token, desktopSession.session.token)); + vi.spyOn(systemService, "isDevPanelEnabled").mockReturnValue(true); + const execSpy = vi.spyOn(repositoriesService, "execResticCommand").mockResolvedValue({ exitCode: 0 }); + + const res = await app.request("/api/v1/repositories/test-repo/exec", { + method: "POST", + headers: { + ...desktopSession.headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ command: "version" }), + }); + + expect(res.status).toBe(200); + await res.text(); + expect(execSpy).toHaveBeenCalled(); + }); }); describe("information disclosure", () => { diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index c6fe2297..66f656d5 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -54,7 +54,7 @@ import { } from "./repositories.dto"; import { repositoriesService } from "./repositories.service"; import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone"; -import { requireAuth, requireBrowserSession, requireOrgAdmin } from "../auth/auth.middleware"; +import { requireAuth, requireOrgAdmin, requireUserSession } from "../auth/auth.middleware"; import { toMessage } from "~/server/utils/errors"; import { requireDevPanel } from "../auth/dev-panel.middleware"; import { getSnapshotDuration } from "../../utils/snapshots"; @@ -291,7 +291,7 @@ export const repositoriesController = new Hono() }) .post( "/:shortId/exec", - requireBrowserSession, + requireUserSession, requireDevPanel, requireOrgAdmin, devPanelExecDto, diff --git a/app/server/modules/sso/__tests__/sso.registration.test.ts b/app/server/modules/sso/__tests__/sso.registration.test.ts index f41d43b8..b4903ccc 100644 --- a/app/server/modules/sso/__tests__/sso.registration.test.ts +++ b/app/server/modules/sso/__tests__/sso.registration.test.ts @@ -2,7 +2,16 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { eq } from "drizzle-orm"; import { createApp } from "~/server/app"; import { db } from "~/server/db/db"; -import { account, invitation, member, organization, ssoProvider, usersTable, verification } from "~/server/db/schema"; +import { + account, + invitation, + member, + organization, + sessionsTable, + ssoProvider, + usersTable, + verification, +} from "~/server/db/schema"; import { createTestSession, createTestSessionWithOrgAdmin } from "~/test/helpers/auth"; import { SSO_INVITATION_INTENT_COOKIE, ssoService } from "../sso.service"; import { config } from "~/server/core/config"; @@ -84,7 +93,11 @@ describe("SSO provider registration authorization", () => { test("rejects provider registration when SSO management is unavailable", async () => { config.runtime = "desktop"; - const { headers, organizationId } = await createTestSession(); + const { headers, organizationId, session } = await createTestSession(); + await db + .update(sessionsTable) + .set({ authSource: "desktop-session" }) + .where(eq(sessionsTable.token, session.token)); const response = await app.request(ssoRegisterUrl, { method: "POST", diff --git a/app/server/modules/sso/middlewares/authorize-registration.ts b/app/server/modules/sso/middlewares/authorize-registration.ts index 29833c43..b4400ea7 100644 --- a/app/server/modules/sso/middlewares/authorize-registration.ts +++ b/app/server/modules/sso/middlewares/authorize-registration.ts @@ -45,7 +45,7 @@ export const authorizeSsoRegistration = async (ctx: AuthMiddlewareContext) => { organizationId, userId: session.user.id, orgRole: membership.role, - authSource: "browser-session", + authSource: session.session.authSource, }, () => getPermission("ssoProvider.create"), ); diff --git a/app/server/modules/sso/sso.controller.ts b/app/server/modules/sso/sso.controller.ts index 91d0badc..011bc46d 100644 --- a/app/server/modules/sso/sso.controller.ts +++ b/app/server/modules/sso/sso.controller.ts @@ -15,7 +15,7 @@ import { updateSsoProviderAutoLinkingDto, } from "./sso.dto"; import { SSO_INVITATION_INTENT_COOKIE, ssoService } from "./sso.service"; -import { requireAuth, requireBrowserSession, requirePermission } from "../auth/auth.middleware"; +import { requireAuth, requireBrowserSession, requirePermission, requireRuntimeFeature } from "../auth/auth.middleware"; import { auth } from "~/server/lib/auth"; import { mapAuthErrorToCode } from "./sso.errors"; import { config } from "~/server/core/config"; @@ -66,15 +66,23 @@ export const ssoController = new Hono() })), }); }) - .get("/sso-invitations", requireAuth, requireBrowserSession, getUserSsoInvitationsDto, async (c) => { - const user = c.get("user"); - const invitations = await ssoService.listPendingInvitationsForUser(user.email); + .get( + "/sso-invitations", + requireAuth, + requireRuntimeFeature("ssoManagement"), + requireBrowserSession, + getUserSsoInvitationsDto, + async (c) => { + const user = c.get("user"); + const invitations = await ssoService.listPendingInvitationsForUser(user.email); - return c.json(invitations); - }) + return c.json(invitations); + }, + ) .post( "/sso-invitations/:invitationId/verify", requireAuth, + requireRuntimeFeature("ssoManagement"), requireBrowserSession, startInvitationSsoVerificationDto, validator("json", startInvitationSsoVerificationBody), diff --git a/app/server/modules/system/__tests__/system.controller.test.ts b/app/server/modules/system/__tests__/system.controller.test.ts index e567f027..44c22096 100644 --- a/app/server/modules/system/__tests__/system.controller.test.ts +++ b/app/server/modules/system/__tests__/system.controller.test.ts @@ -4,7 +4,7 @@ import { createTestSession, createTestSessionWithGlobalAdmin, getAuthHeaders } f import { systemService } from "../system.service"; import * as authHelpers from "~/server/modules/auth/helpers"; import { db } from "~/server/db/db"; -import { organization, usersTable } from "~/server/db/schema"; +import { organization, sessionsTable, usersTable } from "~/server/db/schema"; import { eq } from "drizzle-orm"; import { cryptoUtils } from "~/server/utils/crypto"; import { config } from "~/server/core/config"; @@ -14,6 +14,15 @@ const app = createApp(); let session: Awaited>; let globalAdminSession: Awaited>; +const createDesktopTestSession = async () => { + const desktopAuthSession = await createTestSession(); + await db + .update(sessionsTable) + .set({ authSource: "desktop-session" }) + .where(eq(sessionsTable.token, desktopAuthSession.session.token)); + return desktopAuthSession; +}; + beforeAll(async () => { session = await createTestSession(); globalAdminSession = await createTestSessionWithGlobalAdmin(); @@ -51,10 +60,11 @@ describe("system security", () => { test("returns desktop runtime and effective backend lists in desktop mode", async () => { config.runtime = "desktop"; + const desktopAuthSession = await createDesktopTestSession(); try { const res = await app.request("/api/v1/system/info", { - headers: session.headers, + headers: desktopAuthSession.headers, }); expect(res.status).toBe(200); @@ -93,7 +103,9 @@ describe("system security", () => { describe("registration-status endpoint", () => { test("GET /api/v1/system/registration-status should be accessible with valid session", async () => { - const res = await app.request("/api/v1/system/registration-status", { headers: session.headers }); + const res = await app.request("/api/v1/system/registration-status", { + headers: session.headers, + }); expect(res.status).toBe(200); const body = await res.json(); expect(typeof body.enabled).toBe("boolean"); @@ -185,8 +197,9 @@ describe("system security", () => { expect(await res.text()).toBe(resticPassword); }); - test("should download restic password without password re-authentication in desktop mode", async () => { + test("should download restic password without password re-authentication for desktop sessions", async () => { config.runtime = "desktop"; + const desktopAuthSession = await createDesktopTestSession(); const { cryptoUtils: actualCryptoUtils } = await vi.importActual("~/server/utils/crypto"); const resticPassword = "desktop-restic-password"; @@ -196,17 +209,17 @@ describe("system security", () => { await db .update(organization) .set({ metadata: { resticPassword: encryptedResticPassword } }) - .where(eq(organization.id, session.organizationId)); + .where(eq(organization.id, desktopAuthSession.organizationId)); await db .update(usersTable) .set({ hasDownloadedResticPassword: false }) - .where(eq(usersTable.id, session.user.id)); + .where(eq(usersTable.id, desktopAuthSession.user.id)); vi.spyOn(cryptoUtils, "resolveSecret").mockImplementationOnce(actualCryptoUtils.resolveSecret); const res = await app.request("/api/v1/system/restic-password", { method: "POST", headers: { - ...session.headers, + ...desktopAuthSession.headers, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -218,10 +231,55 @@ describe("system security", () => { expect(await res.text()).toBe(resticPassword); expect(verifyPasswordSpy).not.toHaveBeenCalled(); - const updatedUser = await db.query.usersTable.findFirst({ where: { id: session.user.id } }); + const updatedUser = await db.query.usersTable.findFirst({ + where: { id: desktopAuthSession.user.id }, + }); expect(updatedUser?.hasDownloadedResticPassword).toBe(true); }); + test("rejects browser sessions in desktop mode", async () => { + config.runtime = "desktop"; + const browserSession = await createTestSession(); + const verifyPasswordSpy = vi.spyOn(authHelpers, "verifyUserPassword").mockResolvedValueOnce(false); + + const res = await app.request("/api/v1/system/restic-password", { + method: "POST", + headers: { + ...browserSession.headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + password: "wrong-password", + }), + }); + + expect(res.status).toBe(401); + expect(verifyPasswordSpy).not.toHaveBeenCalled(); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + }); + + test("rejects desktop sessions outside desktop mode", async () => { + const desktopAuthSession = await createDesktopTestSession(); + const verifyPasswordSpy = vi.spyOn(authHelpers, "verifyUserPassword").mockResolvedValueOnce(false); + + const res = await app.request("/api/v1/system/restic-password", { + method: "POST", + headers: { + ...desktopAuthSession.headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + password: "wrong-password", + }), + }); + + expect(res.status).toBe(401); + expect(verifyPasswordSpy).not.toHaveBeenCalled(); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + }); + test("should return 400 for invalid payload on restic-password", async () => { const res = await app.request("/api/v1/system/restic-password", { method: "POST", diff --git a/app/server/modules/system/system.controller.ts b/app/server/modules/system/system.controller.ts index c9fb41cc..819b4d91 100644 --- a/app/server/modules/system/system.controller.ts +++ b/app/server/modules/system/system.controller.ts @@ -19,7 +19,7 @@ import { requireAuth, requirePermission } from "../auth/auth.middleware"; import { db } from "../../db/db"; import { usersTable } from "../../db/schema"; import { eq } from "drizzle-orm"; -import { isPasswordAuthSupported, userHasPassword, verifyUserPassword } from "../auth/helpers"; +import { userHasPassword, verifyUserPassword } from "../auth/helpers"; import { cryptoUtils } from "../../utils/crypto"; import { getOrganizationId } from "~/server/core/request-context"; @@ -63,20 +63,25 @@ export const systemController = new Hono() const user = c.get("user"); const organizationId = getOrganizationId(); const body = c.req.valid("json"); - if (isPasswordAuthSupported()) { + if (c.get("authSource") !== "desktop-session") { const hasPassword = await userHasPassword(user.id); if (!hasPassword) { return c.json({ message: "A local password is required to download the recovery key" }, 403); } - const isPasswordValid = await verifyUserPassword({ password: body.password, userId: user.id }); + const isPasswordValid = await verifyUserPassword({ + password: body.password, + userId: user.id, + }); if (!isPasswordValid) { return c.json({ message: "Invalid password" }, 401); } } try { - const org = await db.query.organization.findFirst({ where: { id: organizationId } }); + const org = await db.query.organization.findFirst({ + where: { id: organizationId }, + }); if (!org?.metadata?.resticPassword) { return c.json({ message: "Organization Restic password not found" }, 404);