diff --git a/Dockerfile b/Dockerfile index 70336eb..004eaeb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") " - busybox" \ " - tzdata" \ " - docker-cli" \ - " - docker-compose=5.0.2-r1" \ + " - docker-compose=5.1.3-r0" \ " - docker-cli-buildx" \ " - sqlite" \ " - postgresql-client" \ @@ -77,8 +77,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Copy package files and install dependencies (--ignore-scripts blocks malicious postinstall hooks) COPY package.json package-lock.json ./ -RUN npm ci --ignore-scripts \ - && npm rebuild better-sqlite3 argon2 +RUN MAKEFLAGS="-j$(nproc)" npm ci --ignore-scripts \ + && MAKEFLAGS="-j$(nproc)" npm rebuild better-sqlite3 argon2 # Copy source code and build COPY . . @@ -93,7 +93,7 @@ RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \ && rm -rf node_modules/@types /tmp/better-sqlite3-build # Build Go collector -FROM --platform=$BUILDPLATFORM golang:1.25.8 AS go-builder +FROM --platform=$BUILDPLATFORM golang:1.25.9 AS go-builder ARG TARGETARCH WORKDIR /app COPY collector/ ./collector/ diff --git a/VERSION b/VERSION index 1727484..46904da 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.24 +v1.0.25 diff --git a/collector/go.mod b/collector/go.mod index abfb84b..a4a26b2 100644 --- a/collector/go.mod +++ b/collector/go.mod @@ -1,3 +1,3 @@ module github.com/Finsys/dockhand/collector -go 1.25 +go 1.25.9 diff --git a/drizzle-pg/0005_add_api_tokens.sql b/drizzle-pg/0005_add_api_tokens.sql new file mode 100644 index 0000000..630a1f7 --- /dev/null +++ b/drizzle-pg/0005_add_api_tokens.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS "api_tokens" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "name" text NOT NULL, + "token_hash" text NOT NULL, + "token_prefix" text NOT NULL, + "last_used" timestamp, + "expires_at" timestamp, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now(), + CONSTRAINT "api_tokens_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "api_tokens_user_id_idx" ON "api_tokens" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "api_tokens_token_prefix_idx" ON "api_tokens" USING btree ("token_prefix"); diff --git a/drizzle-pg/meta/0004_snapshot.json b/drizzle-pg/meta/0004_snapshot.json index 02a5c0b..b459d40 100644 --- a/drizzle-pg/meta/0004_snapshot.json +++ b/drizzle-pg/meta/0004_snapshot.json @@ -1,6 +1,6 @@ { - "id": "b10cba96-4947-484f-84a2-efb65205381f", - "prevId": "eef8322a-0ccc-418c-b0f6-f51972a1850e", + "id": "cefce4cc-994a-4b79-b55a-e995211b8f6a", + "prevId": "b10cba96-4947-484f-84a2-efb65205381f", "version": "7", "dialect": "postgresql", "tables": { diff --git a/drizzle-pg/meta/0005_snapshot.json b/drizzle-pg/meta/0005_snapshot.json new file mode 100644 index 0000000..2a8f2c3 --- /dev/null +++ b/drizzle-pg/meta/0005_snapshot.json @@ -0,0 +1,3038 @@ +{ + "id": "b2d5db02-2e3b-44bc-b559-8857c52577ef", + "prevId": "cefce4cc-994a-4b79-b55a-e995211b8f6a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_name": { + "name": "entity_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_logs_environment_id_environments_id_fk": { + "name": "audit_logs_environment_id_environments_id_fk", + "tableFrom": "audit_logs", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_settings": { + "name": "auth_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "auth_enabled": { + "name": "auth_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "default_provider": { + "name": "default_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "session_timeout": { + "name": "session_timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_update_settings": { + "name": "auto_update_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'daily'" + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vulnerability_criteria": { + "name": "vulnerability_criteria", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'never'" + }, + "last_checked": { + "name": "last_checked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_updated": { + "name": "last_updated", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auto_update_settings_environment_id_environments_id_fk": { + "name": "auto_update_settings_environment_id_environments_id_fk", + "tableFrom": "auto_update_settings", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auto_update_settings_environment_id_container_name_unique": { + "name": "auto_update_settings_environment_id_container_name_unique", + "nullsNotDistinct": false, + "columns": [ + "environment_id", + "container_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.config_sets": { + "name": "config_sets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_vars": { + "name": "env_vars", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ports": { + "name": "ports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "volumes": { + "name": "volumes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "network_mode": { + "name": "network_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'bridge'" + }, + "restart_policy": { + "name": "restart_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'no'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "config_sets_name_unique": { + "name": "config_sets_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.container_events": { + "name": "container_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_attributes": { + "name": "actor_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "container_events_env_timestamp_idx": { + "name": "container_events_env_timestamp_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "container_events_environment_id_environments_id_fk": { + "name": "container_events_environment_id_environments_id_fk", + "tableFrom": "container_events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_notifications": { + "name": "environment_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "event_types": { + "name": "event_types", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_notifications_environment_id_environments_id_fk": { + "name": "environment_notifications_environment_id_environments_id_fk", + "tableFrom": "environment_notifications", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_notifications_notification_id_notification_settings_id_fk": { + "name": "environment_notifications_notification_id_notification_settings_id_fk", + "tableFrom": "environment_notifications", + "tableTo": "notification_settings", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_notifications_environment_id_notification_id_unique": { + "name": "environment_notifications_environment_id_notification_id_unique", + "nullsNotDistinct": false, + "columns": [ + "environment_id", + "notification_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environments": { + "name": "environments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2375 + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'http'" + }, + "tls_ca": { + "name": "tls_ca", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tls_cert": { + "name": "tls_cert", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tls_key": { + "name": "tls_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tls_skip_verify": { + "name": "tls_skip_verify", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'globe'" + }, + "collect_activity": { + "name": "collect_activity", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "collect_metrics": { + "name": "collect_metrics", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "highlight_changes": { + "name": "highlight_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'socket'" + }, + "socket_path": { + "name": "socket_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'/var/run/docker.sock'" + }, + "hawser_token": { + "name": "hawser_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hawser_last_seen": { + "name": "hawser_last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "hawser_agent_id": { + "name": "hawser_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hawser_agent_name": { + "name": "hawser_agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hawser_version": { + "name": "hawser_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hawser_capabilities": { + "name": "hawser_capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environments_name_unique": { + "name": "environments_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_credentials": { + "name": "git_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_private_key": { + "name": "ssh_private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_passphrase": { + "name": "ssh_passphrase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "git_credentials_name_unique": { + "name": "git_credentials_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_repositories": { + "name": "git_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'main'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "compose_path": { + "name": "compose_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'docker-compose.yml'" + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_update_schedule": { + "name": "auto_update_schedule", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'daily'" + }, + "auto_update_cron": { + "name": "auto_update_cron", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'0 3 * * *'" + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync": { + "name": "last_sync", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_commit": { + "name": "last_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "git_repositories_credential_id_git_credentials_id_fk": { + "name": "git_repositories_credential_id_git_credentials_id_fk", + "tableFrom": "git_repositories", + "tableTo": "git_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "git_repositories_name_unique": { + "name": "git_repositories_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_stacks": { + "name": "git_stacks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "compose_path": { + "name": "compose_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'docker-compose.yml'" + }, + "env_file_path": { + "name": "env_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_update_schedule": { + "name": "auto_update_schedule", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'daily'" + }, + "auto_update_cron": { + "name": "auto_update_cron", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'0 3 * * *'" + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_on_deploy": { + "name": "build_on_deploy", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "repull_images": { + "name": "repull_images", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "force_redeploy": { + "name": "force_redeploy", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "last_sync": { + "name": "last_sync", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_commit": { + "name": "last_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "git_stacks_environment_id_environments_id_fk": { + "name": "git_stacks_environment_id_environments_id_fk", + "tableFrom": "git_stacks", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "git_stacks_repository_id_git_repositories_id_fk": { + "name": "git_stacks_repository_id_git_repositories_id_fk", + "tableFrom": "git_stacks", + "tableTo": "git_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "git_stacks_stack_name_environment_id_unique": { + "name": "git_stacks_stack_name_environment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stack_name", + "environment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hawser_tokens": { + "name": "hawser_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "hawser_tokens_environment_id_environments_id_fk": { + "name": "hawser_tokens_environment_id_environments_id_fk", + "tableFrom": "hawser_tokens", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hawser_tokens_token_unique": { + "name": "hawser_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_metrics": { + "name": "host_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "memory_percent": { + "name": "memory_percent", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "memory_used": { + "name": "memory_used", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "memory_total": { + "name": "memory_total", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "host_metrics_env_timestamp_idx": { + "name": "host_metrics_env_timestamp_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_metrics_environment_id_environments_id_fk": { + "name": "host_metrics_environment_id_environments_id_fk", + "tableFrom": "host_metrics", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ldap_config": { + "name": "ldap_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bind_dn": { + "name": "bind_dn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bind_password": { + "name": "bind_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_dn": { + "name": "base_dn", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_filter": { + "name": "user_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'(uid={{username}})'" + }, + "username_attribute": { + "name": "username_attribute", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'uid'" + }, + "email_attribute": { + "name": "email_attribute", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'mail'" + }, + "display_name_attribute": { + "name": "display_name_attribute", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'cn'" + }, + "group_base_dn": { + "name": "group_base_dn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_filter": { + "name": "group_filter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_group": { + "name": "admin_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role_mappings": { + "name": "role_mappings", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tls_enabled": { + "name": "tls_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "tls_ca": { + "name": "tls_ca", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_types": { + "name": "event_types", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_config": { + "name": "oidc_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'openid profile email'" + }, + "username_claim": { + "name": "username_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'preferred_username'" + }, + "email_claim": { + "name": "email_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'email'" + }, + "display_name_claim": { + "name": "display_name_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'name'" + }, + "admin_claim": { + "name": "admin_claim", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_value": { + "name": "admin_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role_mappings_claim": { + "name": "role_mappings_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'groups'" + }, + "role_mappings": { + "name": "role_mappings", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_container_updates": { + "name": "pending_container_updates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_image": { + "name": "current_image", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pending_container_updates_environment_id_environments_id_fk": { + "name": "pending_container_updates_environment_id_environments_id_fk", + "tableFrom": "pending_container_updates", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pending_container_updates_environment_id_container_id_unique": { + "name": "pending_container_updates_environment_id_container_id_unique", + "nullsNotDistinct": false, + "columns": [ + "environment_id", + "container_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.registries": { + "name": "registries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "registries_name_unique": { + "name": "registries_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_ids": { + "name": "environment_ids", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedule_executions": { + "name": "schedule_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "entity_name": { + "name": "entity_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "triggered_by": { + "name": "triggered_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "triggered_at": { + "name": "triggered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs": { + "name": "logs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "schedule_executions_type_id_idx": { + "name": "schedule_executions_type_id_idx", + "columns": [ + { + "expression": "schedule_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedule_executions_environment_id_environments_id_fk": { + "name": "schedule_executions_environment_id_environments_id_fk", + "tableFrom": "schedule_executions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stack_environment_variables": { + "name": "stack_environment_variables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stack_environment_variables_environment_id_environments_id_fk": { + "name": "stack_environment_variables_environment_id_environments_id_fk", + "tableFrom": "stack_environment_variables", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stack_environment_variables_stack_name_environment_id_key_unique": { + "name": "stack_environment_variables_stack_name_environment_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "stack_name", + "environment_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stack_events": { + "name": "stack_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "stack_events_environment_id_environments_id_fk": { + "name": "stack_events_environment_id_environments_id_fk", + "tableFrom": "stack_events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stack_sources": { + "name": "stack_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "git_repository_id": { + "name": "git_repository_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "git_stack_id": { + "name": "git_stack_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_compose_path": { + "name": "external_compose_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_env_path": { + "name": "external_env_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stack_sources_environment_id_environments_id_fk": { + "name": "stack_sources_environment_id_environments_id_fk", + "tableFrom": "stack_sources", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "stack_sources_git_repository_id_git_repositories_id_fk": { + "name": "stack_sources_git_repository_id_git_repositories_id_fk", + "tableFrom": "stack_sources", + "tableTo": "git_repositories", + "columnsFrom": [ + "git_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "stack_sources_git_stack_id_git_stacks_id_fk": { + "name": "stack_sources_git_stack_id_git_stacks_id_fk", + "tableFrom": "stack_sources", + "tableTo": "git_stacks", + "columnsFrom": [ + "git_stack_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stack_sources_stack_name_environment_id_unique": { + "name": "stack_sources_stack_name_environment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stack_name", + "environment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_preferences_environment_id_environments_id_fk": { + "name": "user_preferences_environment_id_environments_id_fk", + "tableFrom": "user_preferences", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_preferences_user_id_environment_id_key_unique": { + "name": "user_preferences_user_id_environment_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "environment_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_environment_id_environments_id_fk": { + "name": "user_roles_environment_id_environments_id_fk", + "tableFrom": "user_roles", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_roles_user_id_role_id_environment_id_unique": { + "name": "user_roles_user_id_role_id_environment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "role_id", + "environment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "mfa_enabled": { + "name": "mfa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "mfa_secret": { + "name": "mfa_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "last_login": { + "name": "last_login", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vulnerability_scans": { + "name": "vulnerability_scans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_name": { + "name": "image_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scanner": { + "name": "scanner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scanned_at": { + "name": "scanned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "scan_duration": { + "name": "scan_duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "critical_count": { + "name": "critical_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "high_count": { + "name": "high_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "medium_count": { + "name": "medium_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "low_count": { + "name": "low_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "negligible_count": { + "name": "negligible_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "unknown_count": { + "name": "unknown_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "vulnerabilities": { + "name": "vulnerabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "vulnerability_scans_env_image_idx": { + "name": "vulnerability_scans_env_image_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vulnerability_scans_environment_id_environments_id_fk": { + "name": "vulnerability_scans_environment_id_environments_id_fk", + "tableFrom": "vulnerability_scans", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "api_tokens_user_id_idx": { + "name": "api_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_tokens_token_prefix_idx": { + "name": "api_tokens_token_prefix_idx", + "columns": [ + { + "expression": "token_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_tokens_user_id_users_id_fk": { + "name": "api_tokens_user_id_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_token_hash_unique": { + "name": "api_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json index 7e297bb..e919fd8 100644 --- a/drizzle-pg/meta/_journal.json +++ b/drizzle-pg/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1774155653752, "tag": "0004_add_git_stack_deploy_options", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1775312212996, + "tag": "0005_add_api_tokens", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/0005_add_api_tokens.sql b/drizzle/0005_add_api_tokens.sql new file mode 100644 index 0000000..c385c98 --- /dev/null +++ b/drizzle/0005_add_api_tokens.sql @@ -0,0 +1,16 @@ +CREATE TABLE `api_tokens` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` integer NOT NULL, + `name` text NOT NULL, + `token_hash` text NOT NULL, + `token_prefix` text NOT NULL, + `last_used` text, + `expires_at` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP, + `updated_at` text DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `api_tokens_token_hash_unique` ON `api_tokens` (`token_hash`);--> statement-breakpoint +CREATE INDEX `api_tokens_user_id_idx` ON `api_tokens` (`user_id`);--> statement-breakpoint +CREATE INDEX `api_tokens_token_prefix_idx` ON `api_tokens` (`token_prefix`); \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..874e56e --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,3157 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1d5b42c6-dad2-4a6a-a27f-86862c5ad820", + "prevId": "89db2259-da8c-4c3e-be28-084a352fe533", + "tables": { + "api_tokens": { + "name": "api_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "api_tokens_token_hash_unique": { + "name": "api_tokens_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "api_tokens_user_id_idx": { + "name": "api_tokens_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "api_tokens_token_prefix_idx": { + "name": "api_tokens_token_prefix_idx", + "columns": [ + "token_prefix" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_tokens_user_id_users_id_fk": { + "name": "api_tokens_user_id_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_name": { + "name": "entity_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_logs_environment_id_environments_id_fk": { + "name": "audit_logs_environment_id_environments_id_fk", + "tableFrom": "audit_logs", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_settings": { + "name": "auth_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "auth_enabled": { + "name": "auth_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "default_provider": { + "name": "default_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'local'" + }, + "session_timeout": { + "name": "session_timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auto_update_settings": { + "name": "auto_update_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'daily'" + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vulnerability_criteria": { + "name": "vulnerability_criteria", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'never'" + }, + "last_checked": { + "name": "last_checked", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_updated": { + "name": "last_updated", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "auto_update_settings_environment_id_container_name_unique": { + "name": "auto_update_settings_environment_id_container_name_unique", + "columns": [ + "environment_id", + "container_name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "auto_update_settings_environment_id_environments_id_fk": { + "name": "auto_update_settings_environment_id_environments_id_fk", + "tableFrom": "auto_update_settings", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "config_sets": { + "name": "config_sets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "env_vars": { + "name": "env_vars", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ports": { + "name": "ports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "volumes": { + "name": "volumes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "network_mode": { + "name": "network_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'bridge'" + }, + "restart_policy": { + "name": "restart_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'no'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "config_sets_name_unique": { + "name": "config_sets_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "container_events": { + "name": "container_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_attributes": { + "name": "actor_attributes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "container_events_env_timestamp_idx": { + "name": "container_events_env_timestamp_idx", + "columns": [ + "environment_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "container_events_environment_id_environments_id_fk": { + "name": "container_events_environment_id_environments_id_fk", + "tableFrom": "container_events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_notifications": { + "name": "environment_notifications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "event_types": { + "name": "event_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "environment_notifications_environment_id_notification_id_unique": { + "name": "environment_notifications_environment_id_notification_id_unique", + "columns": [ + "environment_id", + "notification_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "environment_notifications_environment_id_environments_id_fk": { + "name": "environment_notifications_environment_id_environments_id_fk", + "tableFrom": "environment_notifications", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_notifications_notification_id_notification_settings_id_fk": { + "name": "environment_notifications_notification_id_notification_settings_id_fk", + "tableFrom": "environment_notifications", + "tableTo": "notification_settings", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environments": { + "name": "environments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 2375 + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'http'" + }, + "tls_ca": { + "name": "tls_ca", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tls_cert": { + "name": "tls_cert", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tls_key": { + "name": "tls_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tls_skip_verify": { + "name": "tls_skip_verify", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'globe'" + }, + "collect_activity": { + "name": "collect_activity", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "collect_metrics": { + "name": "collect_metrics", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "highlight_changes": { + "name": "highlight_changes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'socket'" + }, + "socket_path": { + "name": "socket_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'/var/run/docker.sock'" + }, + "hawser_token": { + "name": "hawser_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hawser_last_seen": { + "name": "hawser_last_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hawser_agent_id": { + "name": "hawser_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hawser_agent_name": { + "name": "hawser_agent_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hawser_version": { + "name": "hawser_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hawser_capabilities": { + "name": "hawser_capabilities", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "environments_name_unique": { + "name": "environments_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "git_credentials": { + "name": "git_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_private_key": { + "name": "ssh_private_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_passphrase": { + "name": "ssh_passphrase", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "git_credentials_name_unique": { + "name": "git_credentials_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "git_repositories": { + "name": "git_repositories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'main'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compose_path": { + "name": "compose_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'compose.yaml'" + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_update": { + "name": "auto_update", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "auto_update_schedule": { + "name": "auto_update_schedule", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'daily'" + }, + "auto_update_cron": { + "name": "auto_update_cron", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0 3 * * *'" + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_sync": { + "name": "last_sync", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_commit": { + "name": "last_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "git_repositories_name_unique": { + "name": "git_repositories_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "git_repositories_credential_id_git_credentials_id_fk": { + "name": "git_repositories_credential_id_git_credentials_id_fk", + "tableFrom": "git_repositories", + "tableTo": "git_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "git_stacks": { + "name": "git_stacks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compose_path": { + "name": "compose_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'compose.yaml'" + }, + "env_file_path": { + "name": "env_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_update": { + "name": "auto_update", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "auto_update_schedule": { + "name": "auto_update_schedule", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'daily'" + }, + "auto_update_cron": { + "name": "auto_update_cron", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0 3 * * *'" + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_on_deploy": { + "name": "build_on_deploy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "repull_images": { + "name": "repull_images", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "force_redeploy": { + "name": "force_redeploy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "last_sync": { + "name": "last_sync", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_commit": { + "name": "last_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "git_stacks_stack_name_environment_id_unique": { + "name": "git_stacks_stack_name_environment_id_unique", + "columns": [ + "stack_name", + "environment_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "git_stacks_environment_id_environments_id_fk": { + "name": "git_stacks_environment_id_environments_id_fk", + "tableFrom": "git_stacks", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "git_stacks_repository_id_git_repositories_id_fk": { + "name": "git_stacks_repository_id_git_repositories_id_fk", + "tableFrom": "git_stacks", + "tableTo": "git_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hawser_tokens": { + "name": "hawser_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "hawser_tokens_token_unique": { + "name": "hawser_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "hawser_tokens_environment_id_environments_id_fk": { + "name": "hawser_tokens_environment_id_environments_id_fk", + "tableFrom": "hawser_tokens", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_metrics": { + "name": "host_metrics", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memory_percent": { + "name": "memory_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memory_used": { + "name": "memory_used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory_total": { + "name": "memory_total", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "host_metrics_env_timestamp_idx": { + "name": "host_metrics_env_timestamp_idx", + "columns": [ + "environment_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_metrics_environment_id_environments_id_fk": { + "name": "host_metrics_environment_id_environments_id_fk", + "tableFrom": "host_metrics", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ldap_config": { + "name": "ldap_config", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bind_dn": { + "name": "bind_dn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bind_password": { + "name": "bind_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_dn": { + "name": "base_dn", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_filter": { + "name": "user_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'(uid={{username}})'" + }, + "username_attribute": { + "name": "username_attribute", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'uid'" + }, + "email_attribute": { + "name": "email_attribute", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'mail'" + }, + "display_name_attribute": { + "name": "display_name_attribute", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'cn'" + }, + "group_base_dn": { + "name": "group_base_dn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_filter": { + "name": "group_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_group": { + "name": "admin_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_mappings": { + "name": "role_mappings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tls_enabled": { + "name": "tls_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "tls_ca": { + "name": "tls_ca", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_settings": { + "name": "notification_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_types": { + "name": "event_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oidc_config": { + "name": "oidc_config", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'openid profile email'" + }, + "username_claim": { + "name": "username_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'preferred_username'" + }, + "email_claim": { + "name": "email_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'email'" + }, + "display_name_claim": { + "name": "display_name_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'name'" + }, + "admin_claim": { + "name": "admin_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_value": { + "name": "admin_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_mappings_claim": { + "name": "role_mappings_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'groups'" + }, + "role_mappings": { + "name": "role_mappings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_container_updates": { + "name": "pending_container_updates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_image": { + "name": "current_image", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "pending_container_updates_environment_id_container_id_unique": { + "name": "pending_container_updates_environment_id_container_id_unique", + "columns": [ + "environment_id", + "container_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pending_container_updates_environment_id_environments_id_fk": { + "name": "pending_container_updates_environment_id_environments_id_fk", + "tableFrom": "pending_container_updates", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "registries": { + "name": "registries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "registries_name_unique": { + "name": "registries_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_ids": { + "name": "environment_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_executions": { + "name": "schedule_executions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_name": { + "name": "entity_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "triggered_by": { + "name": "triggered_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "triggered_at": { + "name": "triggered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logs": { + "name": "logs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "schedule_executions_type_id_idx": { + "name": "schedule_executions_type_id_idx", + "columns": [ + "schedule_type", + "schedule_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "schedule_executions_environment_id_environments_id_fk": { + "name": "schedule_executions_environment_id_environments_id_fk", + "tableFrom": "schedule_executions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stack_environment_variables": { + "name": "stack_environment_variables", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_secret": { + "name": "is_secret", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "stack_environment_variables_stack_name_environment_id_key_unique": { + "name": "stack_environment_variables_stack_name_environment_id_key_unique", + "columns": [ + "stack_name", + "environment_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "stack_environment_variables_environment_id_environments_id_fk": { + "name": "stack_environment_variables_environment_id_environments_id_fk", + "tableFrom": "stack_environment_variables", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stack_events": { + "name": "stack_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "stack_events_environment_id_environments_id_fk": { + "name": "stack_events_environment_id_environments_id_fk", + "tableFrom": "stack_events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stack_sources": { + "name": "stack_sources", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'internal'" + }, + "git_repository_id": { + "name": "git_repository_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_stack_id": { + "name": "git_stack_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compose_path": { + "name": "compose_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "env_path": { + "name": "env_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "stack_sources_stack_name_environment_id_unique": { + "name": "stack_sources_stack_name_environment_id_unique", + "columns": [ + "stack_name", + "environment_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "stack_sources_environment_id_environments_id_fk": { + "name": "stack_sources_environment_id_environments_id_fk", + "tableFrom": "stack_sources", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "stack_sources_git_repository_id_git_repositories_id_fk": { + "name": "stack_sources_git_repository_id_git_repositories_id_fk", + "tableFrom": "stack_sources", + "tableTo": "git_repositories", + "columnsFrom": [ + "git_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "stack_sources_git_stack_id_git_stacks_id_fk": { + "name": "stack_sources_git_stack_id_git_stacks_id_fk", + "tableFrom": "stack_sources", + "tableTo": "git_stacks", + "columnsFrom": [ + "git_stack_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_preferences_user_id_environment_id_key_unique": { + "name": "user_preferences_user_id_environment_id_key_unique", + "columns": [ + "user_id", + "environment_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_preferences_environment_id_environments_id_fk": { + "name": "user_preferences_environment_id_environments_id_fk", + "tableFrom": "user_preferences", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_roles_user_id_role_id_environment_id_unique": { + "name": "user_roles_user_id_role_id_environment_id_unique", + "columns": [ + "user_id", + "role_id", + "environment_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_environment_id_environments_id_fk": { + "name": "user_roles_environment_id_environments_id_fk", + "tableFrom": "user_roles", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'local'" + }, + "mfa_enabled": { + "name": "mfa_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "mfa_secret": { + "name": "mfa_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "last_login": { + "name": "last_login", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vulnerability_scans": { + "name": "vulnerability_scans", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_name": { + "name": "image_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scanner": { + "name": "scanner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scanned_at": { + "name": "scanned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scan_duration": { + "name": "scan_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "critical_count": { + "name": "critical_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "high_count": { + "name": "high_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "medium_count": { + "name": "medium_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "low_count": { + "name": "low_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "negligible_count": { + "name": "negligible_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "unknown_count": { + "name": "unknown_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "vulnerabilities": { + "name": "vulnerabilities", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "vulnerability_scans_env_image_idx": { + "name": "vulnerability_scans_env_image_idx", + "columns": [ + "environment_id", + "image_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "vulnerability_scans_environment_id_environments_id_fk": { + "name": "vulnerability_scans_environment_id_environments_id_fk", + "tableFrom": "vulnerability_scans", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b6dcf18..6e39aa6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1774155653752, "tag": "0004_add_git_stack_deploy_options", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1775311743346, + "tag": "0005_add_api_tokens", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index f7efbb3..1b54a81 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.24", + "version": "1.0.25", "type": "module", "scripts": { "dev": "npx vite dev", @@ -77,11 +77,11 @@ "croner": "9.1.0", "cronstrue": "3.9.0", "devalue": "5.6.4", - "drizzle-orm": "0.45.1", + "drizzle-orm": "0.45.2", "fast-xml-parser": "5.5.8", "js-yaml": "4.1.1", "ldapts": "8.1.3", - "nodemailer": "8.0.4", + "nodemailer": "8.0.5", "otpauth": "9.4.1", "postgres": "3.4.8", "qrcode": "1.5.4", @@ -136,6 +136,7 @@ "@codemirror/commands": "6.10.1", "@codemirror/search": "6.6.0", "@lezer/common": "1.5.0", - "@lezer/highlight": "1.2.3" + "@lezer/highlight": "1.2.3", + "devalue": "5.6.4" } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index bb697b4..ad7ba14 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -4,6 +4,8 @@ import { initDatabase, hasAdminUser } from '$lib/server/db'; import { startSubprocesses, stopSubprocesses } from '$lib/server/subprocess-manager'; import { startScheduler } from '$lib/server/scheduler'; import { isAuthEnabled, validateSession } from '$lib/server/auth'; +import { validateApiToken } from '$lib/server/api-tokens'; +import { requestContext } from '$lib/server/request-context'; import { setServerStartTime } from '$lib/server/uptime'; import { checkLicenseExpiry, getHostname } from '$lib/server/license'; import { initCryptoFallback } from '$lib/server/crypto-fallback'; @@ -198,6 +200,58 @@ if (!initialized) { } } +// Bearer token auth failure rate limiting (per IP, 5-minute cooldown after 10 failures) +const bearerFailCounts = new Map(); +const BEARER_FAIL_WINDOW_MS = 60_000; // 1-minute sliding window +const BEARER_FAIL_MAX = 15; // max failures per window +const BEARER_COOLDOWN_MS = 5 * 60 * 1000; // 5-minute cooldown after exceeding limit +const bearerCooldowns = new Map(); // IP → cooldown-until timestamp + +// Periodic cleanup +setInterval(() => { + const now = Date.now(); + for (const [ip, until] of bearerCooldowns) { + if (now > until) bearerCooldowns.delete(ip); + } + for (const [ip, entry] of bearerFailCounts) { + if (now - entry.firstFail > BEARER_FAIL_WINDOW_MS) bearerFailCounts.delete(ip); + } +}, BEARER_COOLDOWN_MS).unref?.(); + +function getClientIp(event: { request: Request; getClientAddress?: () => string }): string { + // Prefer socket-level IP (SvelteKit resolves proxy headers via adapter config) + // This prevents X-Forwarded-For spoofing to bypass rate limiting + try { + const addr = event.getClientAddress?.(); + if (addr) return addr; + } catch { /* getClientAddress may throw if unavailable */ } + return 'unknown'; +} + +function recordBearerFailure(ip: string): void { + const now = Date.now(); + const entry = bearerFailCounts.get(ip); + if (!entry || now - entry.firstFail > BEARER_FAIL_WINDOW_MS) { + bearerFailCounts.set(ip, { count: 1, firstFail: now }); + return; + } + entry.count++; + if (entry.count >= BEARER_FAIL_MAX) { + bearerCooldowns.set(ip, now + BEARER_COOLDOWN_MS); + bearerFailCounts.delete(ip); + } +} + +function isBearerRateLimited(ip: string): boolean { + const until = bearerCooldowns.get(ip); + if (!until) return false; + if (Date.now() > until) { + bearerCooldowns.delete(ip); + return false; + } + return true; +} + // Routes that don't require authentication const PUBLIC_PATHS = [ '/login', @@ -247,21 +301,51 @@ export const handle: Handle = async ({ event, resolve }) => { // Check if auth is enabled const authEnabled = await isAuthEnabled(); - // If auth is disabled, allow everything (app works as before) + // If auth is disabled, allow everything if (!authEnabled) { event.locals.user = null; event.locals.authEnabled = false; - return compressResponse(event.request, await resolve(event)); + const ctx = { user: null, authEnabled: false, authMethod: 'none' as const }; + return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event))); + } + + // Auth is enabled - check session first + let user = await validateSession(event.cookies); + let authMethod: 'cookie' | 'bearer' | 'none' = user ? 'cookie' : 'none'; + + // If no session, try Bearer token on API routes + if (!user && event.url.pathname.startsWith('/api/')) { + const authHeader = event.request.headers.get('authorization'); + if (authHeader && authHeader.startsWith('Bearer dh_') && authHeader.length <= 207) { + const clientIp = getClientIp(event); + + // Rate limit failed Bearer attempts + if (isBearerRateLimited(clientIp)) { + return new Response( + JSON.stringify({ error: 'Too many failed authentication attempts' }), + { status: 429, headers: { 'Content-Type': 'application/json', 'Retry-After': '300' } } + ); + } + + const token = authHeader.substring(7); // strip "Bearer " + user = await validateApiToken(token); + + if (user) { + authMethod = 'bearer'; + } else { + recordBearerFailure(clientIp); + } + } } - // Auth is enabled - check session - const user = await validateSession(event.cookies); event.locals.user = user; event.locals.authEnabled = true; + const ctx = { user, authEnabled: true, authMethod }; + // Public paths don't require authentication if (isPublicPath(event.url.pathname)) { - return compressResponse(event.request, await resolve(event)); + return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event))); } // If not authenticated @@ -270,7 +354,7 @@ export const handle: Handle = async ({ event, resolve }) => { // This enables the first admin user to be created during initial setup const noAdminSetupMode = !(await hasAdminUser()); if (noAdminSetupMode && event.url.pathname === '/api/users' && event.request.method === 'POST') { - return compressResponse(event.request, await resolve(event)); + return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event))); } // API routes return 401 @@ -289,7 +373,7 @@ export const handle: Handle = async ({ event, resolve }) => { redirect(307, `/login?redirect=${redirectUrl}`); } - return compressResponse(event.request, await resolve(event)); + return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event))); } finally { rssAfterOp('http', httpBefore); } diff --git a/src/lib/components/ExecutionLogViewer.svelte b/src/lib/components/ExecutionLogViewer.svelte index 7920616..7a389fb 100644 --- a/src/lib/components/ExecutionLogViewer.svelte +++ b/src/lib/components/ExecutionLogViewer.svelte @@ -1,13 +1,15 @@ diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 8e753f6..d3d942a 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,25 @@ [ + { + "version": "1.0.25", + "date": "coming soon", + "comingSoon": true, + "changes": [ + { "type": "feature", "text": "API token authentication — Bearer tokens for CI/CD pipelines and scripts" }, + { "type": "feature", "text": "Telegram topic support — send notifications to supergroup topics (#855)" }, +{ "type": "fix", "text": "allow removing healthcheck, ports, and honor startAfterUpdate=false during container edit (#892)" }, + { "type": "fix", "text": "validate stack names and prevent broken DB entries on invalid input (#876)" }, + { "type": "fix", "text": "use per-environment timezone for schedule execution log timestamps (#882)" }, + { "type": "fix", "text": "\"Pull image before update\" and \"Start after update\" settings ignored (#909)" }, + { "type": "fix", "text": "image prune timeout on hawser-standard when pruning many images (#905)" }, + { "type": "fix", "text": "bump Docker Compose to 5.1.3" }, + { "type": "fix", "text": "mask secret environment variables in container inspect modal (#924)" }, + { "type": "fix", "text": "viewer role can toggle, delete, and run schedules (#923)" }, + { "type": "fix", "text": "settings show defaults instead of saved values after login until page refresh (#921)" }, + { "type": "fix", "text": "settings toggle notifications show wrong state (#931)" }, + { "type": "fix", "text": "stack memory tooltip shows inflated total on multi-container stacks (#936)" } + ], + "imageTag": "fnsys/dockhand:v1.0.25" + }, { "version": "1.0.24", "date": "2026-04-03", diff --git a/src/lib/server/api-tokens.ts b/src/lib/server/api-tokens.ts new file mode 100644 index 0000000..4150811 --- /dev/null +++ b/src/lib/server/api-tokens.ts @@ -0,0 +1,272 @@ +/** + * API Token Management + * + * Provides Bearer token authentication for CI/CD pipelines and scripts. + * Tokens use `dh_` prefix, Argon2id hashing, and prefix-based lookup. + * + * Performance: An in-memory cache (SHA-256 key, 60s TTL) avoids running + * Argon2id on every request. First request: ~100ms. Subsequent: ~0ms. + */ + +import { createHash } from 'node:crypto'; +import { db, eq, and } from '$lib/server/db/drizzle'; +import { hashPassword, verifyPassword, type AuthenticatedUser } from './auth'; +import { secureRandomBytes } from './crypto-fallback'; +import { getUserRoles, userHasAdminRole, type Permissions } from './db'; +import { isEnterprise } from './license'; +import { tokenCache, ensureCleanupInterval, invalidateTokenCacheForUser, clearTokenCache } from './token-cache'; + +// Re-export cache functions so existing consumers don't need to change imports +export { invalidateTokenCacheForUser, clearTokenCache } from './token-cache'; + +// Dynamic schema import (same pattern as db.ts) +let apiTokensTable: any; + +async function getApiTokensTable() { + if (apiTokensTable) return apiTokensTable; + const isPostgres = !!(process.env.DATABASE_URL && ( + process.env.DATABASE_URL.startsWith('postgres://') || + process.env.DATABASE_URL.startsWith('postgresql://') + )); + const schema = isPostgres + ? await import('./db/schema/pg-schema.js') + : await import('./db/schema/index.js'); + apiTokensTable = schema.apiTokens; + return apiTokensTable; +} + +// Token format: dh_ + 32 bytes base64url = dh_ + 43 chars +const TOKEN_PREFIX = 'dh_'; +const TOKEN_BYTES = 32; +const PREFIX_LENGTH = 8; // chars after dh_ stored for identification +const MAX_TOKEN_LENGTH = 200; +const CACHE_TTL = 60_000; // 60 seconds + +function cacheKey(rawToken: string): string { + return createHash('sha256').update(rawToken).digest('hex'); +} + +// Pre-computed dummy hash for timing protection on invalid prefixes +let dummyHash: string | null = null; + +async function getDummyHash(): Promise { + if (!dummyHash) { + dummyHash = await hashPassword('dh_dummy_token_for_timing_protection'); + } + return dummyHash; +} + +// Initialize dummy hash on import (fire and forget) +void getDummyHash(); + +/** + * Generate a new API token. + * Returns the plaintext token (shown once) and the database record. + */ +export async function generateApiToken( + userId: number, + name: string, + expiresAt?: string | null +): Promise<{ token: string; id: number; tokenPrefix: string }> { + const table = await getApiTokensTable(); + + // Generate random token + const randomBytes = secureRandomBytes(TOKEN_BYTES); + const rawToken = TOKEN_PREFIX + randomBytes.toString('base64url'); + const tokenPrefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH); + + // Hash for storage + const tokenHash = await hashPassword(rawToken); + + const result = await db.insert(table).values({ + userId, + name, + tokenHash, + tokenPrefix, + expiresAt: expiresAt || null + }).returning(); + + return { + token: rawToken, + id: result[0].id, + tokenPrefix + }; +} + +/** + * Validate a Bearer token and return the associated user. + * Uses cache to avoid Argon2id on every request. + */ +export async function validateApiToken(rawToken: string): Promise { + // Input validation + if (!rawToken || rawToken.length > MAX_TOKEN_LENGTH || !rawToken.startsWith(TOKEN_PREFIX)) { + return null; + } + + // Check cache first + ensureCleanupInterval(); + const key = cacheKey(rawToken); + const cached = tokenCache.get(key); + if (cached && cached.expiresAt > Date.now()) { + return cached.user; + } + + const table = await getApiTokensTable(); + + // Extract prefix for lookup + const prefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH); + + // Find tokens with matching prefix (deleted tokens are gone, no isActive filter needed) + const candidates = await db + .select() + .from(table) + .where(eq(table.tokenPrefix, prefix)); + + if (candidates.length === 0) { + // Timing protection: run Argon2id anyway + await verifyPassword(rawToken, await getDummyHash()); + return null; + } + + // Verify against each candidate (usually just one) + for (const candidate of candidates) { + const valid = await verifyPassword(rawToken, candidate.tokenHash); + if (!valid) continue; + + // Check expiration AFTER hash verification to avoid timing oracle + if (candidate.expiresAt && new Date(candidate.expiresAt) < new Date()) { + continue; + } + + // Build AuthenticatedUser from the token's user + const user = await buildUserFromToken(candidate); + if (!user) continue; + + // Update lastUsed (fire and forget — non-critical audit field) + void db.update(table) + .set({ lastUsed: new Date().toISOString() }) + .where(eq(table.id, candidate.id)) + .catch((err) => { + if (typeof process !== 'undefined' && process.env.DB_VERBOSE_LOGGING === 'true') { + console.debug('[api-tokens] lastUsed update failed:', err?.message); + } + }); + + // Cache the result — cap TTL at token expiry time if sooner + let cacheTtl = CACHE_TTL; + if (candidate.expiresAt) { + const timeUntilExpiry = new Date(candidate.expiresAt).getTime() - Date.now(); + if (timeUntilExpiry < cacheTtl) { + cacheTtl = Math.max(0, timeUntilExpiry); + } + } + tokenCache.set(key, { user, expiresAt: Date.now() + cacheTtl }); + + return user; + } + + return null; +} + +/** + * Build an AuthenticatedUser from a token's database record. + */ +async function buildUserFromToken(tokenRecord: any): Promise { + // Import getUserWithoutPassword dynamically to avoid circular deps + // This avoids keeping passwordHash in memory unnecessarily + const { getUserWithoutPassword } = await import('./db'); + + const dbUser = await getUserWithoutPassword(tokenRecord.userId); + if (!dbUser || !dbUser.isActive) return null; + + const enterprise = await isEnterprise(); + let isAdmin = false; + let permissions: Permissions; + + if (!enterprise) { + // Free edition: everyone is effectively admin + isAdmin = true; + const { getRoleByName } = await import('./db'); + const adminRole = await getRoleByName('Admin'); + permissions = adminRole?.permissions ?? {} as Permissions; + } else { + isAdmin = await userHasAdminRole(dbUser.id); + const roles = await getUserRoles(dbUser.id); + // Merge permissions from all roles + permissions = {} as Permissions; + for (const role of roles) { + const rolePerms = typeof role.permissions === 'string' + ? JSON.parse(role.permissions) + : role.permissions; + for (const [key, actions] of Object.entries(rolePerms)) { + if (!permissions[key as keyof Permissions]) { + permissions[key as keyof Permissions] = []; + } + for (const action of actions as string[]) { + if (!permissions[key as keyof Permissions].includes(action)) { + permissions[key as keyof Permissions].push(action); + } + } + } + } + } + + // Determine provider from authProvider field + let provider: 'local' | 'ldap' | 'oidc' = 'local'; + if (dbUser.authProvider?.startsWith('ldap')) provider = 'ldap'; + else if (dbUser.authProvider?.startsWith('oidc')) provider = 'oidc'; + + return { + id: dbUser.id, + username: dbUser.username, + email: dbUser.email ?? undefined, + displayName: dbUser.displayName ?? undefined, + avatar: dbUser.avatar ?? undefined, + isAdmin, + provider, + permissions + }; +} + +/** + * List all tokens for a user (no hashes returned). + */ +export async function listUserTokens(userId: number) { + const table = await getApiTokensTable(); + return db + .select({ + id: table.id, + name: table.name, + tokenPrefix: table.tokenPrefix, + lastUsed: table.lastUsed, + expiresAt: table.expiresAt, + createdAt: table.createdAt + }) + .from(table) + .where(eq(table.userId, userId)); +} + +/** + * Revoke (delete) a token. Owner or admin can revoke. + */ +export async function revokeApiToken(tokenId: number, requestingUserId: number, isAdmin: boolean): Promise { + const table = await getApiTokensTable(); + + // Find the token + const [token] = await db.select().from(table).where(eq(table.id, tokenId)); + if (!token) return false; + + // Check ownership or admin + if (token.userId !== requestingUserId && !isAdmin) { + return false; + } + + // Hard-delete + await db.delete(table).where(eq(table.id, tokenId)); + + // Clear cache — we can't map prefix to SHA-256 cache keys, so clear all + clearTokenCache(); + + return true; +} + diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts index 5da5b1d..4e3fc4c 100644 --- a/src/lib/server/audit.ts +++ b/src/lib/server/audit.ts @@ -9,6 +9,7 @@ import type { RequestEvent } from '@sveltejs/kit'; import { isEnterprise } from './license'; import { logAuditEvent, type AuditAction, type AuditEntityType, type AuditLogCreateData } from './db'; import { authorize } from './authorize'; +import { getRequestContext } from './request-context'; export interface AuditContext { userId?: number | null; @@ -21,7 +22,8 @@ export interface AuditContext { * Extract audit context from a request event */ export async function getAuditContext(event: RequestEvent): Promise { - const auth = await authorize(event.cookies); + const ctx = getRequestContext(); + const user = ctx?.user ?? (await authorize(event.cookies)).user; // Get IP address from various headers (proxied requests) const forwardedFor = event.request.headers.get('x-forwarded-for'); @@ -40,8 +42,8 @@ export async function getAuditContext(event: RequestEvent): Promise { const authEnabled = await isAuthEnabled(); const enterprise = await isEnterprise(); - const user = authEnabled ? await validateSession(cookies) : null; + + // Try request context first (set by hook — handles both cookie and Bearer) + const reqCtx = getRequestContext(); + const user = reqCtx?.user ?? (authEnabled ? await validateSession(cookies) : null); // Determine admin status: // - Free edition: all authenticated users are effectively admins (full access) diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 54d1921..89b7088 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -1185,6 +1185,37 @@ export async function getUser(id: number): Promise { return results[0] as UserData || null; } +export interface SafeUserData { + id: number; + username: string; + email: string | null; + displayName: string | null; + avatar: string | null; + authProvider: string | null; + mfaEnabled: boolean; + isActive: boolean; + lastLogin: string | null; + createdAt: string; + updatedAt: string; +} + +export async function getUserWithoutPassword(id: number): Promise { + const results = await db.select({ + id: users.id, + username: users.username, + email: users.email, + displayName: users.displayName, + avatar: users.avatar, + authProvider: users.authProvider, + mfaEnabled: users.mfaEnabled, + isActive: users.isActive, + lastLogin: users.lastLogin, + createdAt: users.createdAt, + updatedAt: users.updatedAt + }).from(users).where(eq(users.id, id)); + return results[0] as SafeUserData || null; +} + export async function hasAdminUser(): Promise { // Check if any user has the Admin role assigned const adminRole = await db.select().from(roles).where(eq(roles.name, 'Admin')).limit(1); @@ -3046,7 +3077,7 @@ export type AuditAction = export type AuditEntityType = | 'container' | 'image' | 'stack' | 'volume' | 'network' | 'user' | 'role' | 'settings' | 'environment' | 'registry' | 'git_repository' | 'git_credential' - | 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack'; + | 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack' | 'api_token'; export interface AuditLogData { id: number; @@ -4586,6 +4617,18 @@ export async function setStackEnvVars( } } +/** + * Get the set of secret key names for a stack. + * Used to mask secret values in container inspect responses. + */ +export async function getSecretKeyNames( + stackName: string, + environmentId?: number | null +): Promise> { + const vars = await getStackEnvVars(stackName, environmentId, true); + return new Set(vars.filter(v => v.isSecret).map(v => v.key)); +} + /** * Get count of environment variables for a stack. * @param stackName - Name of the stack diff --git a/src/lib/server/db/drizzle.ts b/src/lib/server/db/drizzle.ts index 9f55171..7e2cbfb 100644 --- a/src/lib/server/db/drizzle.ts +++ b/src/lib/server/db/drizzle.ts @@ -335,7 +335,8 @@ const REQUIRED_TABLES = [ 'audit_logs', 'container_events', 'schedule_executions', - 'user_preferences' + 'user_preferences', + 'api_tokens' ]; /** @@ -768,7 +769,7 @@ async function seedDatabase(): Promise { license: ['manage'], audit_logs: ['view'], activity: ['view'], - schedules: ['view'] + schedules: ['view', 'edit', 'run'] }); const operatorPermissions = JSON.stringify({ @@ -787,7 +788,7 @@ async function seedDatabase(): Promise { license: [], audit_logs: [], activity: ['view'], - schedules: ['view'] + schedules: ['view', 'edit', 'run'] }); const viewerPermissions = JSON.stringify({ @@ -898,6 +899,7 @@ export const userPreferences = schemaProxy.userPreferences; export const scheduleExecutions = schemaProxy.scheduleExecutions; export const stackEnvironmentVariables = schemaProxy.stackEnvironmentVariables; export const pendingContainerUpdates = schemaProxy.pendingContainerUpdates; +export const apiTokens = schemaProxy.apiTokens; // Re-export types from SQLite schema (they're compatible with PostgreSQL) export type { @@ -956,7 +958,9 @@ export type { StackEnvironmentVariable, NewStackEnvironmentVariable, PendingContainerUpdate, - NewPendingContainerUpdate + NewPendingContainerUpdate, + ApiToken, + NewApiToken } from './schema/index.js'; export { eq, and, or, desc, asc, like, sql, inArray, isNull, isNotNull } from 'drizzle-orm'; diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index cb6ebaa..8c73eaa 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -467,6 +467,25 @@ export const pendingContainerUpdates = sqliteTable('pending_container_updates', envContainerUnique: unique().on(table.environmentId, table.containerId) })); +// ============================================================================= +// API TOKENS TABLE +// ============================================================================= + +export const apiTokens = sqliteTable('api_tokens', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + tokenHash: text('token_hash').notNull().unique(), + tokenPrefix: text('token_prefix').notNull(), + lastUsed: text('last_used'), + expiresAt: text('expires_at'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + userIdIdx: index('api_tokens_user_id_idx').on(table.userId), + tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix) +})); + // ============================================================================= // USER PREFERENCES TABLE (unified key-value store) // ============================================================================= @@ -570,3 +589,6 @@ export type NewStackEnvironmentVariable = typeof stackEnvironmentVariables.$infe export type PendingContainerUpdate = typeof pendingContainerUpdates.$inferSelect; export type NewPendingContainerUpdate = typeof pendingContainerUpdates.$inferInsert; + +export type ApiToken = typeof apiTokens.$inferSelect; +export type NewApiToken = typeof apiTokens.$inferInsert; diff --git a/src/lib/server/db/schema/pg-schema.ts b/src/lib/server/db/schema/pg-schema.ts index 68754f7..23cc370 100644 --- a/src/lib/server/db/schema/pg-schema.ts +++ b/src/lib/server/db/schema/pg-schema.ts @@ -470,6 +470,25 @@ export const pendingContainerUpdates = pgTable('pending_container_updates', { envContainerUnique: unique().on(table.environmentId, table.containerId) })); +// ============================================================================= +// API TOKENS TABLE +// ============================================================================= + +export const apiTokens = pgTable('api_tokens', { + id: serial('id').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + tokenHash: text('token_hash').notNull().unique(), + tokenPrefix: text('token_prefix').notNull(), + lastUsed: timestamp('last_used', { mode: 'string' }), + expiresAt: timestamp('expires_at', { mode: 'string' }), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + userIdIdx: index('api_tokens_user_id_idx').on(table.userId), + tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix) +})); + // ============================================================================= // USER PREFERENCES TABLE (unified key-value store) // ============================================================================= diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 06c8ea7..6dfe10a 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -415,7 +415,8 @@ export function httpsAgentRequest( if (!streaming) { const isComposeOperation = path === '/_hawser/compose'; const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000; - reqOptions.timeout = isComposeOperation ? composeTimeoutMs : 30000; + const isPrune = path.endsWith('/prune'); + reqOptions.timeout = isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000; } // Honor AbortSignal from caller (e.g., AbortSignal.timeout(5000) for ping) @@ -884,7 +885,7 @@ export async function dockerFetch( body, headers, streaming || false, - (streaming || path === '/_hawser/compose') ? 300000 : 30000, // 5 min for streaming/compose, 30s for normal + (streaming || path === '/_hawser/compose' || path.endsWith('/prune')) ? 300000 : 30000, // 5 min for streaming/compose/prune, 30s for normal isBinary, fetchOptions.signal ?? undefined ); @@ -960,7 +961,8 @@ export async function dockerFetch( if (!streaming && !finalOptions.signal) { const isComposeOperation = path === '/_hawser/compose'; const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000; - finalOptions.signal = AbortSignal.timeout(isComposeOperation ? composeTimeoutMs : 30000); + const isPrune = path.endsWith('/prune'); + finalOptions.signal = AbortSignal.timeout(isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000); } try { @@ -1225,9 +1227,9 @@ export interface DeviceRequest { export interface CreateContainerOptions { name: string; image: string; - ports?: { [key: string]: { HostIp?: string; HostPort: string } }; + ports?: { [key: string]: { HostIp?: string; HostPort: string } } | null; volumes?: { [key: string]: {} }; - volumeBinds?: string[]; + volumeBinds?: string[] | null; env?: string[]; labels?: { [key: string]: string }; cmd?: string[]; @@ -1247,7 +1249,7 @@ export interface CreateContainerOptions { networkGwPriority?: number; user?: string | null; privileged?: boolean; - healthcheck?: HealthcheckConfig; + healthcheck?: HealthcheckConfig | null; memory?: number; memoryReservation?: number; memorySwap?: number; @@ -1338,7 +1340,10 @@ export async function createContainer(options: CreateContainerOptions, envId?: n containerConfig.User = options.user ?? ''; } - if (options.healthcheck) { + if (options.healthcheck === null) { + // Explicitly disable healthcheck (user cleared it) + containerConfig.Healthcheck = { Test: ["NONE"] }; + } else if (options.healthcheck) { containerConfig.Healthcheck = {}; if (options.healthcheck.test && options.healthcheck.test.length > 0) { containerConfig.Healthcheck.Test = options.healthcheck.test; @@ -1357,7 +1362,11 @@ export async function createContainer(options: CreateContainerOptions, envId?: n } } - if (options.ports) { + if (options.ports === null) { + // Explicitly clear ports (user removed all mappings) + containerConfig.ExposedPorts = {}; + containerConfig.HostConfig.PortBindings = {}; + } else if (options.ports) { containerConfig.ExposedPorts = {}; containerConfig.HostConfig.PortBindings = {}; @@ -1367,7 +1376,10 @@ export async function createContainer(options: CreateContainerOptions, envId?: n } } - if (options.volumeBinds && options.volumeBinds.length > 0) { + if (options.volumeBinds === null) { + // Explicitly clear volume binds (user removed all) + containerConfig.HostConfig.Binds = []; + } else if (options.volumeBinds && options.volumeBinds.length > 0) { containerConfig.HostConfig.Binds = options.volumeBinds; } @@ -2393,7 +2405,7 @@ export async function updateContainer(id: string, options: Partial(); + +export function getRequestContext(): RequestContext | undefined { + return requestContext.getStore(); +} diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index e6f3cac..a4a5017 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -752,9 +752,10 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]', api } try { - // Extract registry host from URL - const url = new URL(reg.url); - const registryHost = url.host; + // Extract registry host from URL (parseRegistryUrl handles bare hostnames like 'ghcr.io') + const { parseRegistryUrl } = await import('./docker.js'); + const { host } = parseRegistryUrl(reg.url); + const registryHost = host; console.log(`${logPrefix} Logging into registry: ${registryHost}`); diff --git a/src/lib/server/token-cache.ts b/src/lib/server/token-cache.ts new file mode 100644 index 0000000..21b8d2b --- /dev/null +++ b/src/lib/server/token-cache.ts @@ -0,0 +1,109 @@ +/** + * API Token Cache — LRU with TTL + * + * In-memory cache for validated API tokens. Without this, every Bearer + * request would run Argon2id verification (~64MB RAM, ~100ms CPU per call). + * + * Cache key is SHA-256(fullToken) — safe to hold in memory (not reversible). + * TTL is 60s by default, capped at the token's expiry time if sooner. + * + * Uses a bounded LRU (max 1024 entries) to cap memory usage. On overflow + * the least-recently-used entry is evicted. Expired entries are also pruned + * every 5 minutes. + * + * Separated from api-tokens.ts to avoid circular dependencies + * (auth.ts ↔ api-tokens.ts). + */ + +export interface TokenCacheEntry { + user: { id: number; [key: string]: any }; + expiresAt: number; +} + +const MAX_CACHE_SIZE = 1024; + +/** + * Simple LRU cache backed by a Map. + * Map iteration order is insertion order, so we delete-and-re-set on every + * access to move the entry to the "newest" position. The oldest entry + * (Map.keys().next()) is evicted when the cache exceeds MAX_CACHE_SIZE. + */ +class LruTokenCache { + private map = new Map(); + + get(key: string): TokenCacheEntry | undefined { + const entry = this.map.get(key); + if (!entry) return undefined; + // Move to end (most-recently-used) + this.map.delete(key); + this.map.set(key, entry); + return entry; + } + + set(key: string, entry: TokenCacheEntry): void { + // If key already exists, delete first so re-insert moves it to end + if (this.map.has(key)) { + this.map.delete(key); + } + this.map.set(key, entry); + // Evict oldest if over capacity + if (this.map.size > MAX_CACHE_SIZE) { + const oldest = this.map.keys().next().value!; + this.map.delete(oldest); + } + } + + delete(key: string): void { + this.map.delete(key); + } + + clear(): void { + this.map.clear(); + } + + entries(): IterableIterator<[string, TokenCacheEntry]> { + return this.map.entries(); + } + + get size(): number { + return this.map.size; + } +} + +export const tokenCache = new LruTokenCache(); + +/** + * Invalidate all cached tokens for a specific user. + * Call when user permissions change, roles are updated, or user is deleted/deactivated. + */ +export function invalidateTokenCacheForUser(userId: number): void { + for (const [key, entry] of tokenCache.entries()) { + if (entry.user.id === userId) { + tokenCache.delete(key); + } + } +} + +/** + * Clear the entire token cache. + * Called on revocation, role permission edits, and license state changes. + */ +export function clearTokenCache(): void { + tokenCache.clear(); +} + +// Periodic cleanup every 5 minutes +let cleanupInterval: ReturnType | undefined; + +export function ensureCleanupInterval(): void { + if (cleanupInterval) return; + cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of tokenCache.entries()) { + if (entry.expiresAt <= now) { + tokenCache.delete(key); + } + } + }, 5 * 60 * 1000); + if (cleanupInterval.unref) cleanupInterval.unref(); +} diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index 31ae185..8597774 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -349,7 +349,10 @@ function createSettingsStore() { }); }, // Manual refresh from database - refresh: loadSettings + refresh: () => { + initialized = false; + return loadSettings(); + } }; } diff --git a/src/routes/api/auth/tokens/+server.ts b/src/routes/api/auth/tokens/+server.ts new file mode 100644 index 0000000..5f01163 --- /dev/null +++ b/src/routes/api/auth/tokens/+server.ts @@ -0,0 +1,164 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { generateApiToken, listUserTokens } from '$lib/server/api-tokens'; +import { isAuthEnabled, verifyPassword } from '$lib/server/auth'; +import { getUser } from '$lib/server/db'; +import { audit } from '$lib/server/audit'; +import { getRequestContext } from '$lib/server/request-context'; + +// Password confirmation rate limiting (per userId) +const pwFailCounts = new Map(); +const pwCooldowns = new Map(); +const PW_FAIL_WINDOW = 60_000; // 1-minute sliding window +const PW_FAIL_MAX = 5; // max failures per window +const PW_COOLDOWN = 5 * 60 * 1000; // 5-minute cooldown + +const MAX_TOKENS_PER_USER = 25; + +// Periodic cleanup +setInterval(() => { + const now = Date.now(); + for (const [id, until] of pwCooldowns) { + if (now > until) pwCooldowns.delete(id); + } + for (const [id, entry] of pwFailCounts) { + if (now - entry.firstFail > PW_FAIL_WINDOW) pwFailCounts.delete(id); + } +}, PW_COOLDOWN).unref?.(); + +function isPwRateLimited(userId: number): boolean { + const until = pwCooldowns.get(userId); + if (!until) return false; + if (Date.now() > until) { + pwCooldowns.delete(userId); + return false; + } + return true; +} + +function recordPwFailure(userId: number): void { + const now = Date.now(); + const entry = pwFailCounts.get(userId); + if (!entry || now - entry.firstFail > PW_FAIL_WINDOW) { + pwFailCounts.set(userId, { count: 1, firstFail: now }); + return; + } + entry.count++; + if (entry.count >= PW_FAIL_MAX) { + pwCooldowns.set(userId, now + PW_COOLDOWN); + pwFailCounts.delete(userId); + } +} + +/** + * GET /api/auth/tokens - List the current user's API tokens + */ +export const GET: RequestHandler = async ({ cookies }) => { + const authEnabled = await isAuthEnabled(); + if (!authEnabled) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + const auth = await authorize(cookies); + if (!auth.isAuthenticated || !auth.user) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + + const tokens = await listUserTokens(auth.user.id); + return json(tokens); +}; + +/** + * POST /api/auth/tokens - Create a new API token + */ +export const POST: RequestHandler = async (event) => { + const { cookies, request } = event; + + const authEnabled = await isAuthEnabled(); + if (!authEnabled) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + const auth = await authorize(cookies); + if (!auth.isAuthenticated || !auth.user) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + + // Token creation requires a cookie session — a Bearer token cannot create new tokens + const reqCtx = getRequestContext(); + if (reqCtx?.authMethod === 'bearer') { + return json({ error: 'Token creation requires a session login, not a Bearer token' }, { status: 403 }); + } + + let body: any; + try { + body = await request.json(); + } catch { + return json({ error: 'Invalid request body' }, { status: 400 }); + } + const { name, expiresAt, password } = body; + + // Local users must confirm their password to create tokens + // SSO/OIDC and LDAP users skip this (they authenticated via their IdP) + if (auth.user.provider === 'local') { + if (isPwRateLimited(auth.user.id)) { + return json({ error: 'Too many failed password attempts. Try again later.' }, { status: 429 }); + } + if (!password || typeof password !== 'string') { + return json({ error: 'Password is required to create an API token' }, { status: 400 }); + } + const dbUser = await getUser(auth.user.id); + if (!dbUser) { + return json({ error: 'User not found' }, { status: 404 }); + } + const valid = await verifyPassword(password, dbUser.passwordHash); + if (!valid) { + recordPwFailure(auth.user.id); + return json({ error: 'Invalid password' }, { status: 403 }); + } + } + + // L2: Per-user token count limit + const existingTokens = await listUserTokens(auth.user.id); + if (existingTokens.length >= MAX_TOKENS_PER_USER) { + return json({ error: `Maximum of ${MAX_TOKENS_PER_USER} API tokens per user` }, { status: 400 }); + } + + // Validate name + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return json({ error: 'Token name is required' }, { status: 400 }); + } + if (name.length > 255) { + return json({ error: 'Token name must be 255 characters or less' }, { status: 400 }); + } + + // Validate expiration + if (expiresAt) { + const expiryDate = new Date(expiresAt); + if (isNaN(expiryDate.getTime())) { + return json({ error: 'Invalid expiration date' }, { status: 400 }); + } + if (expiryDate <= new Date()) { + return json({ error: 'Expiration date must be in the future' }, { status: 400 }); + } + } + + const result = await generateApiToken( + auth.user.id, + name.trim(), + expiresAt || null + ); + + await audit(event, 'create', 'api_token', { + entityId: String(result.id), + entityName: name.trim(), + description: `API token "${name.trim()}" created` + }); + + return json({ + id: result.id, + token: result.token, + tokenPrefix: result.tokenPrefix + }, { status: 201, headers: { 'Cache-Control': 'no-store' } }); +}; diff --git a/src/routes/api/auth/tokens/[id]/+server.ts b/src/routes/api/auth/tokens/[id]/+server.ts new file mode 100644 index 0000000..8620762 --- /dev/null +++ b/src/routes/api/auth/tokens/[id]/+server.ts @@ -0,0 +1,40 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { revokeApiToken } from '$lib/server/api-tokens'; +import { isAuthEnabled } from '$lib/server/auth'; +import { audit } from '$lib/server/audit'; + +/** + * DELETE /api/auth/tokens/[id] - Revoke an API token + */ +export const DELETE: RequestHandler = async (event) => { + const { cookies, params } = event; + + const authEnabled = await isAuthEnabled(); + if (!authEnabled) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + const auth = await authorize(cookies); + if (!auth.isAuthenticated || !auth.user) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + + const tokenId = parseInt(params.id); + if (isNaN(tokenId)) { + return json({ error: 'Invalid token ID' }, { status: 400 }); + } + + const success = await revokeApiToken(tokenId, auth.user.id, auth.isAdmin); + if (!success) { + return json({ error: 'Token not found or access denied' }, { status: 404 }); + } + + await audit(event, 'delete', 'api_token', { + entityId: params.id, + description: `API token revoked` + }); + + return json({ success: true }); +}; diff --git a/src/routes/api/auto-update/[containerName]/+server.ts b/src/routes/api/auto-update/[containerName]/+server.ts index df3c142..97c925e 100644 --- a/src/routes/api/auto-update/[containerName]/+server.ts +++ b/src/routes/api/auto-update/[containerName]/+server.ts @@ -7,6 +7,7 @@ import { deleteAutoUpdateSchedule } from '$lib/server/db'; import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; +import { authorize } from '$lib/server/authorize'; export const GET: RequestHandler = async ({ params, url }) => { try { @@ -38,7 +39,12 @@ export const GET: RequestHandler = async ({ params, url }) => { } }; -export const POST: RequestHandler = async ({ params, url, request }) => { +export const POST: RequestHandler = async ({ params, url, request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('schedules', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + try { const containerName = decodeURIComponent(params.containerName); const envIdParam = url.searchParams.get('env'); @@ -101,7 +107,12 @@ export const POST: RequestHandler = async ({ params, url, request }) => { } }; -export const DELETE: RequestHandler = async ({ params, url }) => { +export const DELETE: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('schedules', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + try { const containerName = decodeURIComponent(params.containerName); const envIdParam = url.searchParams.get('env'); diff --git a/src/routes/api/containers/[id]/+server.ts b/src/routes/api/containers/[id]/+server.ts index 6a27236..48dc348 100644 --- a/src/routes/api/containers/[id]/+server.ts +++ b/src/routes/api/containers/[id]/+server.ts @@ -4,7 +4,7 @@ import { removeContainer, getContainerLogs } from '$lib/server/docker'; -import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db'; +import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeyNames, removePendingContainerUpdate } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { auditContainer } from '$lib/server/audit'; import { unregisterSchedule } from '$lib/server/scheduler'; @@ -33,6 +33,24 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { try { const details = await inspectContainer(params.id, envIdNum); + + // Mask secret env vars for containers belonging to a Compose stack + const stackName = details.Config?.Labels?.['com.docker.compose.project']; + if (stackName && Array.isArray(details.Config?.Env)) { + const secretKeys = await getSecretKeyNames(stackName, envIdNum); + if (secretKeys.size > 0) { + details.Config.Env = details.Config.Env.map((entry: string) => { + const eqIdx = entry.indexOf('='); + if (eqIdx === -1) return entry; + const key = entry.substring(0, eqIdx); + if (secretKeys.has(key)) { + return `${key}=***`; + } + return entry; + }); + } + } + return json(details); } catch (error: any) { if (error?.statusCode === 404) { diff --git a/src/routes/api/containers/[id]/inspect/+server.ts b/src/routes/api/containers/[id]/inspect/+server.ts index e2a6556..2e6a9b7 100644 --- a/src/routes/api/containers/[id]/inspect/+server.ts +++ b/src/routes/api/containers/[id]/inspect/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { inspectContainer } from '$lib/server/docker'; +import { getSecretKeyNames } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; @@ -20,6 +21,24 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { try { const containerData = await inspectContainer(params.id, envIdNum); + + // Mask secret env vars for containers belonging to a Compose stack + const stackName = containerData.Config?.Labels?.['com.docker.compose.project']; + if (stackName && Array.isArray(containerData.Config?.Env)) { + const secretKeys = await getSecretKeyNames(stackName, envIdNum); + if (secretKeys.size > 0) { + containerData.Config.Env = containerData.Config.Env.map((entry: string) => { + const eqIdx = entry.indexOf('='); + if (eqIdx === -1) return entry; + const key = entry.substring(0, eqIdx); + if (secretKeys.has(key)) { + return `${key}=***`; + } + return entry; + }); + } + } + return json(containerData); } catch (error) { console.error('Failed to inspect container:', error); diff --git a/src/routes/api/containers/[id]/update/+server.ts b/src/routes/api/containers/[id]/update/+server.ts index 50a9719..a3e23b2 100644 --- a/src/routes/api/containers/[id]/update/+server.ts +++ b/src/routes/api/containers/[id]/update/+server.ts @@ -1,5 +1,5 @@ import { json } from '@sveltejs/kit'; -import { pullImage, updateContainer, type CreateContainerOptions } from '$lib/server/docker'; +import { inspectContainer, pullImage, updateContainer, type CreateContainerOptions } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; import { auditContainer } from '$lib/server/audit'; import { removePendingContainerUpdate } from '$lib/server/db'; @@ -25,6 +25,29 @@ export const POST: RequestHandler = async (event) => { const body = await request.json(); const { startAfterUpdate, repullImage, ...options } = body; + // Resolve masked secret values (***) back to real values from the current container. + // The GET endpoint masks secrets in Config.Env, so the edit modal sends *** for unchanged secrets. + if (Array.isArray(options.env) && options.env.some((e: string) => e.endsWith('=***'))) { + const currentData = await inspectContainer(params.id, envIdNum); + const currentEnvMap = new Map(); + for (const entry of currentData.Config?.Env || []) { + const eqIdx = entry.indexOf('='); + if (eqIdx !== -1) { + currentEnvMap.set(entry.substring(0, eqIdx), entry.substring(eqIdx + 1)); + } + } + options.env = options.env.map((entry: string) => { + const eqIdx = entry.indexOf('='); + if (eqIdx === -1) return entry; + const key = entry.substring(0, eqIdx); + const value = entry.substring(eqIdx + 1); + if (value === '***' && currentEnvMap.has(key)) { + return `${key}=${currentEnvMap.get(key)}`; + } + return entry; + }); + } + if (repullImage) { console.log(`Pulling image...`); try { diff --git a/src/routes/api/environments/+server.ts b/src/routes/api/environments/+server.ts index bc6ec1a..8ec8aa8 100644 --- a/src/routes/api/environments/+server.ts +++ b/src/routes/api/environments/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types'; import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, getImagePruneSettings, type Environment } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { auditEnvironment } from '$lib/server/audit'; +import { invalidateTokenCacheForUser } from '$lib/server/api-tokens'; import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager'; import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; import { cleanPem } from '$lib/utils/pem'; @@ -130,6 +131,7 @@ export const POST: RequestHandler = async (event) => { const adminRole = await getRoleByName('Admin'); if (adminRole) { await assignUserRole(user.id, adminRole.id, env.id); + invalidateTokenCacheForUser(user.id); } } catch (roleError) { // Log but don't fail - environment was created successfully diff --git a/src/routes/api/license/+server.ts b/src/routes/api/license/+server.ts index 0c1392f..c3d3d9a 100644 --- a/src/routes/api/license/+server.ts +++ b/src/routes/api/license/+server.ts @@ -7,6 +7,7 @@ import { getHostname } from '$lib/server/license'; import { authorize } from '$lib/server/authorize'; +import { clearTokenCache } from '$lib/server/api-tokens'; // GET /api/license - Get current license status // Any authenticated user can view license status (needed to determine if RBAC applies) @@ -59,6 +60,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { ); } + // Permission model changes between free/enterprise — clear cached tokens + clearTokenCache(); + return json({ success: true, license: result.license @@ -81,6 +85,8 @@ export const DELETE: RequestHandler = async ({ cookies }) => { try { await deactivateLicense(); + // Permission model changes between free/enterprise — clear cached tokens + clearTokenCache(); return json({ success: true }); } catch (error) { console.error('Error deactivating license:', error); diff --git a/src/routes/api/profile/+server.ts b/src/routes/api/profile/+server.ts index cd30baf..919ea44 100644 --- a/src/routes/api/profile/+server.ts +++ b/src/routes/api/profile/+server.ts @@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit'; import { getUser, updateUser as dbUpdateUser, deleteUserSessions, userHasAdminRole } from '$lib/server/db'; import { validateSession, hashPassword, isAuthEnabled } from '$lib/server/auth'; +import { invalidateTokenCacheForUser } from '$lib/server/api-tokens'; // GET /api/profile - Get current user's profile export const GET: RequestHandler = async ({ cookies }) => { @@ -85,8 +86,9 @@ export const PUT: RequestHandler = async ({ request, cookies }) => { } updateData.passwordHash = await hashPassword(data.newPassword); - // Invalidate other sessions on password change - deleteUserSessions(currentUser.id); + // Invalidate other sessions and token cache on password change + await deleteUserSessions(currentUser.id); + invalidateTokenCacheForUser(currentUser.id); } const user = await dbUpdateUser(currentUser.id, updateData); diff --git a/src/routes/api/roles/[id]/+server.ts b/src/routes/api/roles/[id]/+server.ts index f2d081a..19e4f57 100644 --- a/src/routes/api/roles/[id]/+server.ts +++ b/src/routes/api/roles/[id]/+server.ts @@ -8,6 +8,7 @@ import { import { authorize } from '$lib/server/authorize'; import { auditRole } from '$lib/server/audit'; import { computeAuditDiff } from '$lib/utils/diff'; +import { clearTokenCache } from '$lib/server/api-tokens'; // GET /api/roles/[id] - Get a specific role export const GET: RequestHandler = async ({ params, cookies }) => { @@ -75,6 +76,9 @@ export const PUT: RequestHandler = async (event) => { return json({ error: 'Failed to update role' }, { status: 500 }); } + // Clear token cache — any cached user with this role has stale permissions + clearTokenCache(); + // Compute diff for audit const diff = computeAuditDiff(existingRole, role); @@ -128,6 +132,9 @@ export const DELETE: RequestHandler = async (event) => { return json({ error: 'Failed to delete role' }, { status: 500 }); } + // Clear token cache — users with this role may have stale cached permissions + clearTokenCache(); + // Audit log await auditRole(event, 'delete', id, role.name); diff --git a/src/routes/api/schedules/[type]/[id]/+server.ts b/src/routes/api/schedules/[type]/[id]/+server.ts index 5f229d8..055c443 100644 --- a/src/routes/api/schedules/[type]/[id]/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/+server.ts @@ -13,8 +13,14 @@ import { deleteImagePruneSettings } from '$lib/server/db'; import { unregisterSchedule } from '$lib/server/scheduler'; +import { authorize } from '$lib/server/authorize'; + +export const DELETE: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('schedules', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } -export const DELETE: RequestHandler = async ({ params }) => { try { const { type, id } = params; const scheduleId = parseInt(id, 10); diff --git a/src/routes/api/schedules/[type]/[id]/run/+server.ts b/src/routes/api/schedules/[type]/[id]/run/+server.ts index 8f9feba..85aafb9 100644 --- a/src/routes/api/schedules/[type]/[id]/run/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/run/+server.ts @@ -11,8 +11,14 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler'; +import { authorize } from '$lib/server/authorize'; + +export const POST: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('schedules', 'run')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } -export const POST: RequestHandler = async ({ params }) => { try { const { type, id } = params; const scheduleId = parseInt(id, 10); diff --git a/src/routes/api/schedules/[type]/[id]/toggle/+server.ts b/src/routes/api/schedules/[type]/[id]/toggle/+server.ts index 9fb9e66..b226c34 100644 --- a/src/routes/api/schedules/[type]/[id]/toggle/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/toggle/+server.ts @@ -7,8 +7,14 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings } from '$lib/server/db'; import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; +import { authorize } from '$lib/server/authorize'; + +export const POST: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('schedules', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } -export const POST: RequestHandler = async ({ params }) => { try { const { type, id } = params; const scheduleId = parseInt(id, 10); diff --git a/src/routes/api/schedules/executions/[id]/+server.ts b/src/routes/api/schedules/executions/[id]/+server.ts index 6f12152..ce41dde 100644 --- a/src/routes/api/schedules/executions/[id]/+server.ts +++ b/src/routes/api/schedules/executions/[id]/+server.ts @@ -8,6 +8,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getScheduleExecution, deleteScheduleExecution } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; export const GET: RequestHandler = async ({ params }) => { try { @@ -28,7 +29,12 @@ export const GET: RequestHandler = async ({ params }) => { } }; -export const DELETE: RequestHandler = async ({ params }) => { +export const DELETE: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('schedules', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + try { const id = parseInt(params.id, 10); if (isNaN(id)) { diff --git a/src/routes/api/stacks/+server.ts b/src/routes/api/stacks/+server.ts index 8573bc8..4218033 100644 --- a/src/routes/api/stacks/+server.ts +++ b/src/routes/api/stacks/+server.ts @@ -144,10 +144,13 @@ export const POST: RequestHandler = async (event) => { } // ALWAYS save compose file first - deployStack expects it to exist - await saveStackComposeFile(name, compose, true, envIdNum, { + const saveResult = await saveStackComposeFile(name, compose, true, envIdNum, { composePath: composePath || undefined, envPath: envPath || undefined }); + if (!saveResult.success) { + return json({ error: saveResult.error }, { status: 400 }); + } // Save environment variables BEFORE deploying so they're available during start if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) { diff --git a/src/routes/api/users/+server.ts b/src/routes/api/users/+server.ts index 259e50c..ff56670 100644 --- a/src/routes/api/users/+server.ts +++ b/src/routes/api/users/+server.ts @@ -11,6 +11,7 @@ import { } from '$lib/server/db'; import { hashPassword, createUserSession } from '$lib/server/auth'; import { authorize } from '$lib/server/authorize'; +import { invalidateTokenCacheForUser } from '$lib/server/api-tokens'; import { auditUser } from '$lib/server/audit'; // GET /api/users - List all users @@ -108,6 +109,7 @@ export const POST: RequestHandler = async (event) => { const adminRole = await getRoleByName('Admin'); if (adminRole) { await assignUserRole(user.id, adminRole.id, null); + invalidateTokenCacheForUser(user.id); } } diff --git a/src/routes/api/users/[id]/+server.ts b/src/routes/api/users/[id]/+server.ts index 19d7c3a..8934017 100644 --- a/src/routes/api/users/[id]/+server.ts +++ b/src/routes/api/users/[id]/+server.ts @@ -16,6 +16,7 @@ import { hashPassword } from '$lib/server/auth'; import { authorize } from '$lib/server/authorize'; import { auditUser } from '$lib/server/audit'; import { computeAuditDiff } from '$lib/utils/diff'; +import { invalidateTokenCacheForUser } from '$lib/server/api-tokens'; // GET /api/users/[id] - Get a specific user // Free for all - local users are needed for basic auth @@ -125,6 +126,7 @@ export const PUT: RequestHandler = async (event) => { if (isDeactivating) { updateData.isActive = false; await deleteUserSessions(userId); + invalidateTokenCacheForUser(userId); } // Disable authentication @@ -142,6 +144,7 @@ export const PUT: RequestHandler = async (event) => { if (adminRole) { await removeUserRole(userId, adminRole.id, null); } + invalidateTokenCacheForUser(userId); } return json({ @@ -170,9 +173,10 @@ export const PUT: RequestHandler = async (event) => { } if (data.isActive !== undefined) { updateData.isActive = data.isActive; - // If deactivating, invalidate all sessions + // If deactivating, invalidate all sessions and token cache if (!data.isActive) { await deleteUserSessions(userId); + invalidateTokenCacheForUser(userId); } } } @@ -183,8 +187,9 @@ export const PUT: RequestHandler = async (event) => { return json({ error: 'Password must be at least 8 characters' }, { status: 400 }); } updateData.passwordHash = await hashPassword(data.password); - // Invalidate all sessions on password change (except current) + // Invalidate all sessions and token cache on password change await deleteUserSessions(userId); + invalidateTokenCacheForUser(userId); } const user = await dbUpdateUser(userId, updateData); @@ -198,8 +203,10 @@ export const PUT: RequestHandler = async (event) => { if (adminRole) { if (shouldPromote) { await assignUserRole(userId, adminRole.id, null); + invalidateTokenCacheForUser(userId); } else if (shouldDemote) { await removeUserRole(userId, adminRole.id, null); + invalidateTokenCacheForUser(userId); } } @@ -284,6 +291,7 @@ export const DELETE: RequestHandler = async (event) => { // User confirmed - proceed with deletion and disable auth await deleteUserSessions(id); + invalidateTokenCacheForUser(id); const deleted = await dbDeleteUser(id); if (!deleted) { return json({ error: 'Failed to delete user' }, { status: 500 }); @@ -299,8 +307,9 @@ export const DELETE: RequestHandler = async (event) => { } } - // Delete all sessions first + // Delete all sessions and invalidate token cache await deleteUserSessions(id); + invalidateTokenCacheForUser(id); const deleted = await dbDeleteUser(id); if (!deleted) { diff --git a/src/routes/api/users/[id]/roles/+server.ts b/src/routes/api/users/[id]/roles/+server.ts index 35bdc6a..4fd8149 100644 --- a/src/routes/api/users/[id]/roles/+server.ts +++ b/src/routes/api/users/[id]/roles/+server.ts @@ -10,6 +10,7 @@ import { getRole } from '$lib/server/db'; import { auditUser } from '$lib/server/audit'; +import { invalidateTokenCacheForUser } from '$lib/server/api-tokens'; // GET /api/users/[id]/roles - Get roles assigned to a user export const GET: RequestHandler = async ({ params, cookies }) => { @@ -69,6 +70,7 @@ export const POST: RequestHandler = async (event) => { } const userRole = await assignUserRole(userId, roleId, environmentId); + invalidateTokenCacheForUser(userId); // Audit log - role assigned const role = await getRole(roleId); @@ -117,6 +119,7 @@ export const DELETE: RequestHandler = async (event) => { if (!deleted) { return json({ error: 'Role assignment not found' }, { status: 404 }); } + invalidateTokenCacheForUser(userId); // Audit log - role removed if (user) { diff --git a/src/routes/containers/EditContainerModal.svelte b/src/routes/containers/EditContainerModal.svelte index 5a57d3d..9fbbdd4 100644 --- a/src/routes/containers/EditContainerModal.svelte +++ b/src/routes/containers/EditContainerModal.svelte @@ -864,6 +864,8 @@ retries: healthcheckRetries, startPeriod: healthcheckStartPeriod * 1e9 }; + } else if (!healthcheckEnabled) { + healthcheck = null; } const devices = deviceMappings @@ -902,8 +904,8 @@ const payload = { name: name.trim(), image: image.trim(), - ports: Object.keys(ports).length > 0 ? ports : undefined, - volumeBinds: volumeBinds.length > 0 ? volumeBinds : undefined, + ports: Object.keys(ports).length > 0 ? ports : null, + volumeBinds: volumeBinds.length > 0 ? volumeBinds : null, env: env.length > 0 ? env : undefined, labels: Object.keys(labelsObj).length > 0 ? labelsObj : undefined, cmd, @@ -1081,8 +1083,8 @@ bind:restartPolicy bind:restartMaxRetries bind:networkMode - startAfterCreate={startAfterUpdate} - {repullImage} + bind:startAfterCreate={startAfterUpdate} + bind:repullImage bind:portMappings bind:volumeMappings bind:envVars diff --git a/src/routes/images/ScanResultsView.svelte b/src/routes/images/ScanResultsView.svelte index e7c99b0..0aac3f9 100644 --- a/src/routes/images/ScanResultsView.svelte +++ b/src/routes/images/ScanResultsView.svelte @@ -241,8 +241,8 @@ {vuln.severity} - - {vuln.package} + + {vuln.package} {vuln.version} diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index d325524..aace4cf 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -9,6 +9,7 @@ import { Loader2, LogIn, Shield, AlertCircle, Network, User, KeyRound, TriangleAlert } from 'lucide-svelte'; import { authStore } from '$lib/stores/auth'; import { environments } from '$lib/stores/environment'; + import { appSettings } from '$lib/stores/settings'; import * as Alert from '$lib/components/ui/alert'; import { themeStore, applyTheme } from '$lib/stores/theme'; @@ -114,7 +115,8 @@ return; } - // Success - refresh environments (they were cleared during pre-login fetch) then redirect + // Success - refresh settings and environments (they were fetched before auth) then redirect + await appSettings.refresh(); await environments.refresh(); goto(redirectUrl); } catch (e) { diff --git a/src/routes/profile/+page.svelte b/src/routes/profile/+page.svelte index cbb45ef..f8c3e79 100644 --- a/src/routes/profile/+page.svelte +++ b/src/routes/profile/+page.svelte @@ -11,6 +11,7 @@ Shield, ShieldCheck, Key, + KeyRound, RefreshCw, Check, Smartphone, @@ -22,7 +23,8 @@ Camera, Trash2, TriangleAlert, - Palette + Palette, + Plus } from 'lucide-svelte'; import { authStore } from '$lib/stores/auth'; import { formatDateTime } from '$lib/stores/settings'; @@ -32,6 +34,9 @@ import ChangePasswordModal from './ChangePasswordModal.svelte'; import MfaSetupModal from './MfaSetupModal.svelte'; import DisableMfaModal from './DisableMfaModal.svelte'; + import ApiTokenModal from './ApiTokenModal.svelte'; + import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; + import * as Table from '$lib/components/ui/table'; import ThemeSelector from '$lib/components/ThemeSelector.svelte'; import { themeStore } from '$lib/stores/theme'; import PageHeader from '$lib/components/PageHeader.svelte'; @@ -73,6 +78,49 @@ let mfaError = $state(''); let showDisableMfaModal = $state(false); + // API tokens state + interface ApiToken { + id: number; + name: string; + tokenPrefix: string; + lastUsed: string | null; + expiresAt: string | null; + createdAt: string; + } + let apiTokens = $state([]); + let showApiTokenModal = $state(false); + let tokensLoading = $state(false); + + async function fetchApiTokens() { + tokensLoading = true; + try { + const response = await fetch('/api/auth/tokens'); + if (response.ok) { + apiTokens = await response.json(); + } + } catch { + // Silently fail - tokens section will show empty + } finally { + tokensLoading = false; + } + } + + async function revokeToken(tokenId: number) { + try { + const response = await fetch(`/api/auth/tokens/${tokenId}`, { method: 'DELETE' }); + if (response.ok) { + apiTokens = apiTokens.filter(t => t.id !== tokenId); + } + } catch { + // Ignore + } + } + + function isTokenExpired(expiresAt: string | null): boolean { + if (!expiresAt) return false; + return new Date(expiresAt) < new Date(); + } + // Avatar state let showAvatarCropper = $state(false); let avatarSaving = $state(false); @@ -291,6 +339,7 @@ if (!profileFetched) { profileFetched = true; fetchProfile(); + fetchApiTokens(); } } }); @@ -300,7 +349,7 @@ Profile - Dockhand -
+

Manage your account settings

@@ -330,6 +379,9 @@
{/if} + +
+ @@ -431,14 +483,14 @@ - + Profile details - + {#if formError} @@ -446,7 +498,7 @@ {/if} -
+
+
+ + +
+ - + @@ -582,6 +639,81 @@ + + + + + + + API tokens + + + + Create tokens for CI/CD pipelines and scripts + + + {#if tokensLoading} +

Loading tokens...

+ {:else if apiTokens.length === 0} +

No API tokens created yet.

+ {:else} + + + + Name + Prefix + Last used + Expires + + + + + {#each apiTokens as token (token.id)} + + {token.name} + + dh_{token.tokenPrefix}... + + + {token.lastUsed ? formatDateTime(token.lastUsed) : 'Never'} + + + {#if isTokenExpired(token.expiresAt)} + Expired + {:else if token.expiresAt} + {formatDateTime(token.expiresAt)} + {:else} + Never + {/if} + + + revokeToken(token.id)} + > + + + + + {/each} + + + {/if} +
+
+ +
+ + +
+ @@ -595,6 +727,8 @@ + +
{/if}
@@ -625,6 +759,13 @@ /> {/if} + + + + import * as Dialog from '$lib/components/ui/dialog'; + import { Button } from '$lib/components/ui/button'; + import { Input } from '$lib/components/ui/input'; + import { Label } from '$lib/components/ui/label'; + import * as Select from '$lib/components/ui/select'; + import * as Alert from '$lib/components/ui/alert'; + import { KeyRound, Copy, Check, TriangleAlert } from 'lucide-svelte'; + + let { + open = $bindable(false), + onCreated, + provider = 'local' + }: { + open: boolean; + onCreated: () => void; + provider?: string; + } = $props(); + + let name = $state(''); + let password = $state(''); + let expirationOption = $state('none'); + let customDate = $state(''); + let creating = $state(false); + let error = $state(''); + + const isLocalUser = $derived(provider === 'local'); + + // After creation + let createdToken = $state(''); + let copied = $state(false); + + function reset() { + name = ''; + password = ''; + expirationOption = 'none'; + customDate = ''; + creating = false; + error = ''; + createdToken = ''; + copied = false; + } + + function getExpiresAt(): string | null { + const now = new Date(); + switch (expirationOption) { + case '30d': + return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(); + case '90d': + return new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000).toISOString(); + case '1y': + return new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000).toISOString(); + case 'custom': + return customDate ? new Date(customDate + 'T23:59:59').toISOString() : null; + default: + return null; + } + } + + async function createToken() { + if (!name.trim()) { + error = 'Token name is required'; + return; + } + if (isLocalUser && !password) { + error = 'Password is required'; + return; + } + + creating = true; + error = ''; + + try { + const payload: Record = { + name: name.trim(), + expiresAt: getExpiresAt() + }; + if (isLocalUser) { + payload.password = password; + } + + const response = await fetch('/api/auth/tokens', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (response.ok) { + const data = await response.json(); + createdToken = data.token; + } else { + const data = await response.json(); + error = data.error || 'Failed to create token'; + } + } catch { + error = 'Failed to create token'; + } finally { + creating = false; + } + } + + async function copyToken() { + try { + await navigator.clipboard.writeText(createdToken); + copied = true; + setTimeout(() => copied = false, 2000); + } catch { + // Fallback: select the input text + } + } + + function handleClose() { + if (createdToken) { + onCreated(); + } + open = false; + // Reset after animation + setTimeout(reset, 200); + } + + // Get minimum date for custom picker (tomorrow) + const minDate = $derived(() => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + return tomorrow.toISOString().split('T')[0]; + }); + + + { if (!v) handleClose(); }}> + + + + + {createdToken ? 'Token created' : 'Generate API token'} + + + + {#if createdToken} + +
+ + + + Copy this token now. It will not be shown again. + + + +
+ + +
+ +
+ +
+
+ {:else} + +
+
+ + +
+ + {#if isLocalUser} +
+ + +
+ {/if} + +
+ + + + {#if expirationOption === 'none'}No expiration + {:else if expirationOption === '30d'}30 days + {:else if expirationOption === '90d'}90 days + {:else if expirationOption === '1y'}1 year + {:else if expirationOption === 'custom'}Custom date + {/if} + + + No expiration + 30 days + 90 days + 1 year + Custom date + + + + {#if expirationOption === 'custom'} + + {/if} +
+ + {#if error} + + + {error} + + {/if} + +
+ + +
+
+ {/if} +
+
diff --git a/src/routes/schedules/+page.svelte b/src/routes/schedules/+page.svelte index ba4a756..183281e 100644 --- a/src/routes/schedules/+page.svelte +++ b/src/routes/schedules/+page.svelte @@ -41,13 +41,17 @@ import { DataGrid } from '$lib/components/data-grid'; import type { DataGridRowState } from '$lib/components/data-grid'; import { toast } from 'svelte-sonner'; - import { formatDateTime, appSettings } from '$lib/stores/settings'; + import { formatDateTime, getTimeFormat, appSettings } from '$lib/stores/settings'; import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import ScannerSeverityPills from '$lib/components/ScannerSeverityPills.svelte'; import VulnerabilityCriteriaBadge from '$lib/components/VulnerabilityCriteriaBadge.svelte'; import UpdateSummaryStats from '$lib/components/UpdateSummaryStats.svelte'; import ExecutionLogViewer from '$lib/components/ExecutionLogViewer.svelte'; + import { canAccess } from '$lib/stores/auth'; + + const canEditSchedules = $derived($canAccess('schedules', 'edit')); + const canRunSchedules = $derived($canAccess('schedules', 'run')); import { vulnerabilityCriteriaIcons, vulnerabilityCriteriaLabels } from '$lib/utils/update-steps'; import type { VulnerabilityCriteria } from '$lib/server/db'; import cronstrue from 'cronstrue'; @@ -183,7 +187,7 @@ // State let schedules = $state([]); - let environments = $state<{ id: number; name: string; icon: string }[]>([]); + let environments = $state<{ id: number; name: string; icon: string; timezone?: string }[]>([]); let loading = $state(true); let refreshing = $state(false); let searchQuery = $state(''); @@ -204,6 +208,11 @@ let selectedExecution = $state(null); let loadingExecutionDetail = $state(false); let logDarkMode = $state(true); + let selectedExecutionTimezone = $derived( + selectedExecution?.environmentId + ? environments.find(e => e.id === selectedExecution!.environmentId)?.timezone + : undefined + ); function toggleLogTheme() { logDarkMode = !logDarkMode; @@ -736,9 +745,21 @@ } } - function formatTimestamp(iso: string | null): string { + function formatTimestamp(iso: string | null, tz?: string): string { if (!iso) return '-'; - return formatDateTime(iso, true); + if (!tz) return formatDateTime(iso, true); + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return new Intl.DateTimeFormat('en-GB', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: getTimeFormat() === '12h' + }).format(d); } function formatDuration(ms: number | null): string { @@ -1287,27 +1308,31 @@ {/if} - - - {#if !schedule.isSystem} + {#if canEditSchedules} + + {/if} + {#if canRunSchedules} + + {/if} + {#if canEditSchedules && !schedule.isSystem} {@const scheduleKey = getScheduleKey(schedule)}

Execution history

- {#if executions.length > 0} + {#if executions.length > 0 && canEditSchedules} - + {#if canEditSchedules} + + {/if}
@@ -1491,7 +1518,7 @@ {#if selectedExecution} - {formatTimestamp(selectedExecution.triggeredAt)} · {formatDuration(selectedExecution.duration)} + {formatTimestamp(selectedExecution.triggeredAt, selectedExecutionTimezone)} · {formatDuration(selectedExecution.duration)} {/if} @@ -1628,6 +1655,7 @@
diff --git a/src/routes/settings/auth/roles/RoleModal.svelte b/src/routes/settings/auth/roles/RoleModal.svelte index 80c71eb..f92d4a5 100644 --- a/src/routes/settings/auth/roles/RoleModal.svelte +++ b/src/routes/settings/auth/roles/RoleModal.svelte @@ -135,7 +135,9 @@ { key: 'view', label: 'View audit logs' } ], schedules: [ - { key: 'view', label: 'View schedules' } + { key: 'view', label: 'View schedules' }, + { key: 'edit', label: 'Edit schedules' }, + { key: 'run', label: 'Run schedules' } ] }; @@ -242,6 +244,7 @@ disconnect: Unplug, edit: Pencil, test: Play, + run: Play, manage: Settings }; diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte index 8a4e3ca..d81ab40 100644 --- a/src/routes/settings/general/GeneralTab.svelte +++ b/src/routes/settings/general/GeneralTab.svelte @@ -94,13 +94,15 @@ } function handleScheduleCleanupEnabledChange() { - appSettings.setScheduleCleanupEnabled(!scheduleCleanupEnabled); - toast.success(scheduleCleanupEnabled ? 'Schedule cleanup disabled' : 'Schedule cleanup enabled'); + const newState = !scheduleCleanupEnabled; + appSettings.setScheduleCleanupEnabled(newState); + toast.success(newState ? 'Schedule cleanup enabled' : 'Schedule cleanup disabled'); } function handleEventCleanupEnabledChange() { - appSettings.setEventCleanupEnabled(!eventCleanupEnabled); - toast.success(eventCleanupEnabled ? 'Event cleanup disabled' : 'Event cleanup enabled'); + const newState = !eventCleanupEnabled; + appSettings.setEventCleanupEnabled(newState); + toast.success(newState ? 'Event cleanup enabled' : 'Event cleanup disabled'); } function handleGrypeImageBlur(e: Event) { @@ -199,9 +201,9 @@ { - appSettings.setShowStoppedContainers(!showStoppedContainers); - toast.success(showStoppedContainers ? 'Stopped containers hidden' : 'Stopped containers shown'); + onchange={(checked) => { + appSettings.setShowStoppedContainers(checked); + toast.success(checked ? 'Stopped containers shown' : 'Stopped containers hidden'); }} disabled={!$canAccess('settings', 'edit')} /> @@ -213,9 +215,9 @@ { - appSettings.setHighlightUpdates(!highlightUpdates); - toast.success(highlightUpdates ? 'Update highlighting disabled' : 'Update highlighting enabled'); + onchange={(checked) => { + appSettings.setHighlightUpdates(checked); + toast.success(checked ? 'Update highlighting enabled' : 'Update highlighting disabled'); }} disabled={!$canAccess('settings', 'edit')} /> @@ -227,9 +229,9 @@ { - appSettings.setCompactPorts(!compactPorts); - toast.success(compactPorts ? 'Showing all ports' : 'Compact port display enabled'); + onchange={(checked) => { + appSettings.setCompactPorts(checked); + toast.success(checked ? 'Compact port display enabled' : 'Showing all ports'); }} disabled={!$canAccess('settings', 'edit')} /> @@ -337,9 +339,9 @@ { - appSettings.setConfirmDestructive(!confirmDestructive); - toast.success(confirmDestructive ? 'Confirmations disabled' : 'Confirmations enabled'); + onchange={(checked) => { + appSettings.setConfirmDestructive(checked); + toast.success(checked ? 'Confirmations enabled' : 'Confirmations disabled'); }} disabled={!$canAccess('settings', 'edit')} /> @@ -405,9 +407,9 @@ { - appSettings.setFormatLogTimestamps(!formatLogTimestamps); - toast.success(formatLogTimestamps ? 'Log timestamp formatting disabled' : 'Log timestamp formatting enabled'); + onchange={(checked) => { + appSettings.setFormatLogTimestamps(checked); + toast.success(checked ? 'Log timestamp formatting enabled' : 'Log timestamp formatting disabled'); }} disabled={!$canAccess('settings', 'edit')} /> diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index 83e50ec..31c896c 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -260,7 +260,7 @@ if (stats) { cpuPercent += stats.cpuPercent; memoryUsage += stats.memoryUsage; - memoryLimit += stats.memoryLimit; + memoryLimit = Math.max(memoryLimit, stats.memoryLimit); networkRx += stats.networkRx; networkTx += stats.networkTx; blockRead += stats.blockRead; diff --git a/src/routes/stacks/StackModal.svelte b/src/routes/stacks/StackModal.svelte index 19d15a1..b73ff77 100644 --- a/src/routes/stacks/StackModal.svelte +++ b/src/routes/stacks/StackModal.svelte @@ -867,6 +867,9 @@ services: if (!newStackName.trim()) { errors.stackName = 'Stack name is required'; hasErrors = true; + } else if (!/^[a-z0-9][a-z0-9_-]*$/.test(newStackName.trim())) { + errors.stackName = 'Must be lowercase, start with a letter or number, and only contain letters, numbers, hyphens, and underscores'; + hasErrors = true; } const content = composeContent || defaultCompose; @@ -903,6 +906,7 @@ services: // Prepare env vars for creating - syncs variables and rawContent const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] }; + let response: Response | undefined; try { // Build request body const requestBody: Record = { @@ -930,7 +934,7 @@ services: } // Create the stack - const response = await fetch(appendEnvParam('/api/stacks', envId), { + response = await fetch(appendEnvParam('/api/stacks', envId), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) @@ -955,9 +959,10 @@ services: message: e.message || 'An error occurred while creating the stack', details: e.details }; - // If start=true, files were saved and stack is in DB — transition to edit mode - // so the user can fix and redeploy without leaving the modal - if (start) { + // Only transition to edit mode if the stack was actually persisted (response was ok + // but deploy failed). A 400 from validation means nothing was saved — stay in create + // mode so the name field remains visible and the user can fix the error. + if (start && response?.ok) { mode = 'edit'; stackName = newStackName.trim(); onSuccess(); // refresh stack list so the new stack appears