security: load Creator secrets from files
CI / test (pull_request) Successful in 10s
CI / test (push) Successful in 10s

This commit was merged in pull request #1.
This commit is contained in:
m
2026-08-16 17:57:32 +03:00
parent e9594306e5
commit 03dde8c196
7 changed files with 190 additions and 7 deletions
+40 -2
View File
@@ -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
}
+58
View File
@@ -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")
}
})
}
+7 -3
View File
@@ -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
+2 -2
View File
@@ -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
}
@@ -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"
]
}
+2
View File
@@ -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.
+2
View File
@@ -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.