security: load Creator secrets from files
This commit was merged in pull request #1.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user