diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 2ec46aa..77439c7 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "fmt" "git.cin.su/m/Assistant/adapters/postgres" "git.cin.su/m/Assistant/contracts/capability" "git.cin.su/m/Assistant/contracts/identity" @@ -27,8 +28,20 @@ func main() { if addr == "" { addr = ":8080" } - bootstrapToken, sessionKey := os.Getenv("CREATOR_BOOTSTRAP_TOKEN"), []byte(os.Getenv("CREATOR_SESSION_SIGNING_KEY")) - creatorUsername, creatorPassword := os.Getenv("CREATOR_USERNAME"), os.Getenv("CREATOR_PASSWORD") + bootstrapToken, err := readRequiredSecret("CREATOR_BOOTSTRAP_TOKEN") + if err != nil { + log.Fatal(err) + } + sessionKeyValue, err := readRequiredSecret("CREATOR_SESSION_SIGNING_KEY") + if err != nil { + log.Fatal(err) + } + creatorPassword, err := readRequiredSecret("CREATOR_PASSWORD") + if err != nil { + log.Fatal(err) + } + sessionKey := []byte(sessionKeyValue) + creatorUsername := os.Getenv("CREATOR_USERNAME") repository, err := postgres.OpenSituation(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("POSTGRES_USER"), Password: os.Getenv("POSTGRES_PASSWORD")}) if err != nil { log.Fatal(err) @@ -299,3 +312,28 @@ func bearerToken(r *http.Request) string { } return strings.TrimPrefix(value, "Bearer ") } + +func readRequiredSecret(name string) (string, error) { + direct, directSet := os.LookupEnv(name) + fileName, fileSet := os.LookupEnv(name + "_FILE") + if directSet && fileSet { + return "", fmt.Errorf("%s and %s_FILE cannot both be configured", name, name) + } + if fileSet { + if fileName == "" { + return "", fmt.Errorf("%s_FILE is empty", name) + } + contents, err := os.ReadFile(fileName) + if err != nil { + return "", fmt.Errorf("read %s_FILE: %w", name, err) + } + direct = strings.TrimRight(string(contents), "\r\n") + if strings.ContainsAny(direct, "\r\n") { + return "", fmt.Errorf("%s_FILE must contain one line", name) + } + } + if direct == "" { + return "", fmt.Errorf("%s is required", name) + } + return direct, nil +} diff --git a/cmd/core-runtime/main_test.go b/cmd/core-runtime/main_test.go index 76def19..a244da4 100644 --- a/cmd/core-runtime/main_test.go +++ b/cmd/core-runtime/main_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "time" @@ -67,3 +69,59 @@ func TestCreatorSessionHandlerPreservesBootstrapRecovery(t *testing.T) { t.Fatalf("bootstrap recovery status = %d", response.Code) } } + +func TestReadRequiredSecretFromEnvironment(t *testing.T) { + t.Setenv("TEST_CREATOR_SECRET", "direct-value") + value, err := readRequiredSecret("TEST_CREATOR_SECRET") + if err != nil || value != "direct-value" { + t.Fatalf("value=%q err=%v", value, err) + } +} + +func TestReadRequiredSecretFromFile(t *testing.T) { + fileName := filepath.Join(t.TempDir(), "secret") + if err := os.WriteFile(fileName, []byte("file-value\r\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("TEST_CREATOR_SECRET_FILE", fileName) + value, err := readRequiredSecret("TEST_CREATOR_SECRET") + if err != nil || value != "file-value" { + t.Fatalf("value=%q err=%v", value, err) + } +} + +func TestReadRequiredSecretFailsClosed(t *testing.T) { + t.Run("both sources", func(t *testing.T) { + t.Setenv("TEST_CREATOR_SECRET", "direct") + t.Setenv("TEST_CREATOR_SECRET_FILE", filepath.Join(t.TempDir(), "secret")) + if _, err := readRequiredSecret("TEST_CREATOR_SECRET"); err == nil { + t.Fatal("expected ambiguous configuration to fail") + } + }) + t.Run("empty file", func(t *testing.T) { + fileName := filepath.Join(t.TempDir(), "secret") + if err := os.WriteFile(fileName, nil, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("TEST_CREATOR_SECRET_FILE", fileName) + if _, err := readRequiredSecret("TEST_CREATOR_SECRET"); err == nil { + t.Fatal("expected empty secret to fail") + } + }) + t.Run("multiline file", func(t *testing.T) { + fileName := filepath.Join(t.TempDir(), "secret") + if err := os.WriteFile(fileName, []byte("first\nsecond\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("TEST_CREATOR_SECRET_FILE", fileName) + if _, err := readRequiredSecret("TEST_CREATOR_SECRET"); err == nil { + t.Fatal("expected multiline secret to fail") + } + }) + t.Run("missing file", func(t *testing.T) { + t.Setenv("TEST_CREATOR_SECRET_FILE", filepath.Join(t.TempDir(), "missing")) + if _, err := readRequiredSecret("TEST_CREATOR_SECRET"); err == nil { + t.Fatal("expected missing secret file to fail") + } + }) +} diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index a880494..5bd9c58 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -42,15 +42,17 @@ services: POSTGRES_PASSWORD: ${SITUATION_DB_PASSWORD} IDENTITY_POSTGRES_USER: identity_role IDENTITY_POSTGRES_PASSWORD: ${IDENTITY_DB_PASSWORD} - CREATOR_BOOTSTRAP_TOKEN: ${CREATOR_BOOTSTRAP_TOKEN} - CREATOR_SESSION_SIGNING_KEY: ${CREATOR_SESSION_SIGNING_KEY} + CREATOR_BOOTSTRAP_TOKEN_FILE: /run/secrets/creator_bootstrap_token + CREATOR_SESSION_SIGNING_KEY_FILE: /run/secrets/creator_session_signing_key CREATOR_USERNAME: ${CREATOR_USERNAME} - CREATOR_PASSWORD: ${CREATOR_PASSWORD} + CREATOR_PASSWORD_FILE: /run/secrets/creator_password WORK_POSTGRES_USER: work_role WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD} CAPABILITY_POSTGRES_USER: capability_role CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD} depends_on: { postgres: { condition: service_healthy } } + volumes: + - assistant_core_secrets:/run/secrets:ro continuity-runtime: build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime } } @@ -107,3 +109,5 @@ services: volumes: assistant_pg: assistant_nats: + assistant_core_secrets: + external: true diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index b439baf..26c1fbc 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000035-postgres-backup-recovery.json", - "task_id": "DEV-000035", + "task_file": "dev/tasks/DEV-000036-creator-secret-files.json", + "task_id": "DEV-000036", "task_revision": 1 } diff --git a/dev/tasks/DEV-000036-creator-secret-files.json b/dev/tasks/DEV-000036-creator-secret-files.json new file mode 100644 index 0000000..cea99cf --- /dev/null +++ b/dev/tasks/DEV-000036-creator-secret-files.json @@ -0,0 +1,79 @@ +{ + "task_id": "DEV-000036", + "task_revision": 1, + "architecture_revision": "ADR-0004", + "base_repo_revision": "e9594306e589591b76a15bd6a955f5d74e5f6a10", + "risk_tier": "E", + "intent": "Remove Creator authentication secrets from the production container environment by adding fail-closed file-backed secret loading and a read-only external Docker volume.", + "requirement_ids": ["INV-AUTH-001", "CONSTITUTION-1.3.28", "USER-DOCKER-PROD-ONLY"], + "allowed_change_scope": { + "modules": ["identity"], + "paths": [ + "cmd/core-runtime/main.go", + "cmd/core-runtime/main_test.go", + "deploy/docker-compose.prod.yml", + "docs/CREDENTIALS_AND_SECRETS.md", + "docs/DEPLOYMENT.md", + "dev/ACTIVE_TASK.json", + "dev/tasks/DEV-000036-creator-secret-files.json" + ] + }, + "forbidden_change_scope": { + "paths": [ + "architecture/**", + "ci/**", + ".gitea/**", + "cmd/devctl/**", + "contracts/**", + "modules/authority/**", + "modules/commit/**", + "database/**", + "security/**" + ] + }, + "allowed_architecture_delta": { + "new_cross_module_dependencies": 0, + "new_canonical_owners": 0, + "new_privileges": 0 + }, + "contracts": { + "consume": ["Creator credentials and bounded session contract", "Docker read-only volume"], + "may_change": [] + }, + "semantic_owner": "identity", + "canonical_state": ["none; Creator identity and sessions are unchanged"], + "state_transitions": ["valid configured credentials still issue the existing eight-hour bounded Creator session"], + "side_effects_and_privileges": ["core-runtime reads three existing secrets from files instead of environment values in production", "external Docker volume is mounted read-only only into core-runtime"], + "persistence_and_migrations": ["no database migration", "existing Creator secrets move to the external assistant_core_secrets Docker volume"], + "failure_retry_restart": ["missing, empty, multiline, unreadable, or ambiguously configured secrets fail startup", "direct environment values remain supported for development but cannot be combined with a corresponding _FILE setting", "the existing production container remains available for rollback until the replacement is healthy"], + "assumptions": [ + "The current production secrets are valid and can be migrated without revealing their values", + "core-runtime continues to run as uid 100 and gid 101", + "Docker administrators retain necessary recovery access to the external secret volume" + ], + "acceptance_scenarios": [ + "direct environment secret loading remains supported for development", + "file-backed secrets trim line endings and reject empty or multiline content", + "simultaneous direct and file configuration fails closed", + "production core-runtime Env contains only _FILE paths for the three Creator secrets", + "the secret volume is read-only in core-runtime and absent from other application containers", + "Creator login and current-session checks succeed after deployment", + "exact diff validation observes only declared paths" + ], + "required_tests": [ + "unit", + "adversarial secret loading", + "architecture preflight", + "exact diff scope", + "go vet", + "production compose validation", + "remote Creator login smoke", + "production environment and mount audit", + "CI" + ], + "terminal_gap_states": [ + "BLOCKED_BY_MISSING_CAPABILITY", + "BLOCKED_BY_CONFLICT", + "BLOCKED_BY_ARCHITECTURE_GAP" + ] +} diff --git a/docs/CREDENTIALS_AND_SECRETS.md b/docs/CREDENTIALS_AND_SECRETS.md index b7c5dac..5295867 100644 --- a/docs/CREDENTIALS_AND_SECRETS.md +++ b/docs/CREDENTIALS_AND_SECRETS.md @@ -20,3 +20,5 @@ Do not guess Portainer credentials and do not search other project source trees ## Production services Each privileged runtime gets only its required secret/capability bindings. `commit-runtime` is intentionally isolated from the general Core Runtime. + +Creator authentication secrets are stored in the external Docker volume `assistant_core_secrets` and mounted read-only only at `/run/secrets` in `core-runtime`. The production container environment contains `_FILE` paths, not the secret values. Files are owned by runtime uid 100/gid 101 with mode 0400. `CREATOR_USERNAME` is not secret and remains an environment value. Development may continue to use direct environment values, but a direct value and its matching `_FILE` setting must never be configured together. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index ba4643a..c1dd9de 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -18,6 +18,8 @@ Credentials/API deployment automation is deliberately not embedded. Use `PORTAIN The single Creator login is configured with external `CREATOR_USERNAME` and `CREATOR_PASSWORD` values. They issue the same bounded Creator session and never select Actor, Principal, Scope or Authority. Keep `CREATOR_BOOTSTRAP_TOKEN` as a separate recovery/automation credential; none of these values belongs in Git, compose defaults for production, image layers or logs. +In production, `CREATOR_PASSWORD`, `CREATOR_BOOTSTRAP_TOKEN`, and `CREATOR_SESSION_SIGNING_KEY` are materialized as files in the external `assistant_core_secrets` Docker volume. `core-runtime` receives only the corresponding `_FILE` paths and mounts that volume read-only. Direct environment values remain a development compatibility path and fail closed if combined with `_FILE`. + ## PostgreSQL recovery Production backups run only on `docker.cin.su`; this project must not use the shared `docker-test` host. `deploy/backup-postgres.sh` creates an atomic, mode-0600 logical backup under `/home/m/assistant-backups/postgres`. Each completed directory contains the application database, cluster roles without role passwords, database/owner metadata, and `SHA256SUMS`. Partial output is never promoted to a completed backup directory, and no automatic retention deletion is performed.