Promote verified production hardening #1
@@ -57,6 +57,7 @@ Never “make it work somehow” by violating a boundary.
|
||||
|
||||
- Canonical remote: `https://git.cin.su/m/Assistant.git`.
|
||||
- Target deployment control plane: `https://docker.cin.su` (Portainer).
|
||||
- This project must not use `docker-test`, `test-docker`, or `docker-test.cin.su` for builds, CI runners, tests, deployment, backups, or project data unless the user gives a new explicit project-specific authorization.
|
||||
- Use configured credential helpers first; never echo secrets.
|
||||
- If push/deploy credentials are unavailable, ask the user for the minimum needed access.
|
||||
- Ordinary Codex work happens on a branch/worktree, never directly on protected `main`.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
umask 077
|
||||
|
||||
container="${ASSISTANT_POSTGRES_CONTAINER:-assistant-postgres-1}"
|
||||
backup_root="${ASSISTANT_BACKUP_DIR:-/home/m/assistant-backups/postgres}"
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
partial_dir="${backup_root}/.${timestamp}.partial"
|
||||
backup_dir="${backup_root}/${timestamp}"
|
||||
|
||||
cleanup() {
|
||||
if [ -d "$partial_dir" ]; then
|
||||
rm -f -- "$partial_dir/assistant.dump" "$partial_dir/globals.sql" \
|
||||
"$partial_dir/database.name" "$partial_dir/owner.name" "$partial_dir/SHA256SUMS"
|
||||
rmdir -- "$partial_dir" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
if ! docker inspect "$container" >/dev/null 2>&1; then
|
||||
echo "PostgreSQL container not found: $container" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$container")" != "true" ]; then
|
||||
echo "PostgreSQL container is not running: $container" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p -- "$backup_root"
|
||||
chmod 700 -- "$backup_root"
|
||||
if [ -e "$partial_dir" ] || [ -e "$backup_dir" ]; then
|
||||
echo "Backup destination already exists for $timestamp" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -- "$partial_dir"
|
||||
|
||||
database="$(docker exec "$container" sh -c 'printf %s "$POSTGRES_DB"')"
|
||||
owner="$(docker exec "$container" sh -c 'printf %s "$POSTGRES_USER"')"
|
||||
[ -n "$database" ] && [ -n "$owner" ] || {
|
||||
echo "Database metadata is empty" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$database:$owner" in
|
||||
*[!A-Za-z0-9_:.-]*) echo "Unsafe database metadata" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
printf '%s\n' "$database" >"$partial_dir/database.name"
|
||||
printf '%s\n' "$owner" >"$partial_dir/owner.name"
|
||||
docker exec "$container" sh -c 'exec pg_dump --format=custom --compress=9 --no-password -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >"$partial_dir/assistant.dump"
|
||||
docker exec "$container" sh -c 'exec pg_dumpall --globals-only --no-role-passwords --no-password -U "$POSTGRES_USER"' >"$partial_dir/globals.sql"
|
||||
|
||||
if [ ! -s "$partial_dir/assistant.dump" ] || [ ! -s "$partial_dir/globals.sql" ]; then
|
||||
echo "Backup output is incomplete" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$partial_dir"
|
||||
sha256sum assistant.dump globals.sql database.name owner.name >SHA256SUMS
|
||||
sha256sum -c SHA256SUMS >/dev/null
|
||||
)
|
||||
chmod 600 "$partial_dir"/*
|
||||
mv -- "$partial_dir" "$backup_dir"
|
||||
trap - EXIT HUP INT TERM
|
||||
printf '%s\n' "$backup_dir"
|
||||
@@ -0,0 +1,2 @@
|
||||
# Domovoy Assistant PostgreSQL backup: 00:15 UTC is 03:15 Europe/Moscow.
|
||||
15 0 * * * umask 077; { /home/m/assistant-ops/backup-postgres.sh && /home/m/assistant-ops/verify-latest-postgres-backup.sh; } >>/home/m/assistant-backups/postgres/backup.log 2>&1
|
||||
@@ -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
|
||||
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
schedule_file="${1:-/home/m/assistant-ops/assistant-postgres-backup.crontab}"
|
||||
marker="/home/m/assistant-ops/backup-postgres.sh"
|
||||
current="$(mktemp)"
|
||||
updated="$(mktemp)"
|
||||
|
||||
cleanup() {
|
||||
rm -f -- "$current" "$updated"
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
if [ ! -f "$schedule_file" ]; then
|
||||
echo "Cron schedule file not found: $schedule_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
crontab -l >"$current" 2>/dev/null || true
|
||||
grep -vF "$marker" "$current" >"$updated" || true
|
||||
cat "$schedule_file" >>"$updated"
|
||||
crontab "$updated"
|
||||
|
||||
matches="$(crontab -l | grep -Fc "$marker")"
|
||||
if [ "$matches" -ne 1 ]; then
|
||||
echo "Expected one backup cron entry, found $matches" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PostgreSQL backup cron entry installed"
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
backup_root="${ASSISTANT_BACKUP_DIR:-/home/m/assistant-backups/postgres}"
|
||||
verify_script="${ASSISTANT_RESTORE_VERIFY_SCRIPT:-/home/m/assistant-ops/verify-postgres-restore.sh}"
|
||||
|
||||
latest="$(find "$backup_root" -mindepth 1 -maxdepth 1 -type d ! -name '.*' -printf '%f\n' | sort | tail -n 1)"
|
||||
if [ -z "$latest" ]; then
|
||||
echo "No completed PostgreSQL backup found in $backup_root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$verify_script" "$backup_root/$latest"
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "usage: $0 <backup-directory>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
backup_dir="$1"
|
||||
image="${ASSISTANT_RESTORE_IMAGE:-postgres:18.6-alpine}"
|
||||
container="assistant-postgres-restore-check-$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||||
password="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')"
|
||||
created=false
|
||||
|
||||
cleanup() {
|
||||
if [ "$created" = true ]; then
|
||||
docker rm -f "$container" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
for file in assistant.dump globals.sql database.name owner.name SHA256SUMS; do
|
||||
if [ ! -f "$backup_dir/$file" ]; then
|
||||
echo "Backup file is missing: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
(
|
||||
cd "$backup_dir"
|
||||
sha256sum -c SHA256SUMS
|
||||
)
|
||||
|
||||
database="$(sed -n '1p' "$backup_dir/database.name")"
|
||||
owner="$(sed -n '1p' "$backup_dir/owner.name")"
|
||||
[ -n "$database" ] && [ -n "$owner" ] || {
|
||||
echo "Database metadata is empty" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$database:$owner" in
|
||||
*[!A-Za-z0-9_:.-]*) echo "Unsafe database metadata" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
docker run -d --name "$container" \
|
||||
--tmpfs /var/lib/postgresql:rw,noexec,nosuid,size=1g \
|
||||
-e POSTGRES_PASSWORD="$password" \
|
||||
"$image" >/dev/null
|
||||
created=true
|
||||
|
||||
attempt=0
|
||||
until docker exec "$container" pg_isready -U postgres -d postgres >/dev/null 2>&1; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 60 ]; then
|
||||
docker logs "$container" >&2
|
||||
echo "Restore-check PostgreSQL did not become ready" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
docker exec -i "$container" psql -v ON_ERROR_STOP=1 -U postgres -d postgres <"$backup_dir/globals.sql" >/dev/null
|
||||
docker exec "$container" psql -v ON_ERROR_STOP=1 -U postgres -d postgres \
|
||||
-c "CREATE DATABASE \"$database\" OWNER \"$owner\"" >/dev/null
|
||||
docker exec -i "$container" pg_restore --exit-on-error --no-password -U postgres -d "$database" <"$backup_dir/assistant.dump" >/dev/null
|
||||
|
||||
schema_count="$(docker exec "$container" psql -U postgres -d "$database" -Atqc "select count(*) from information_schema.schemata where schema_name not in ('pg_catalog','information_schema') and schema_name not like 'pg_toast%'")"
|
||||
table_count="$(docker exec "$container" psql -U postgres -d "$database" -Atqc "select count(*) from information_schema.tables where table_schema not in ('pg_catalog','information_schema')")"
|
||||
if [ "$schema_count" -lt 1 ] || [ "$table_count" -lt 1 ]; then
|
||||
echo "Restored database lacks expected user objects" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'restore verified: database=%s schemas=%s tables=%s\n' "$database" "$schema_count" "$table_count"
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/DEV-000033-creator-username-password.json",
|
||||
"task_id": "DEV-000033",
|
||||
"task_revision": 2
|
||||
"task_file": "dev/tasks/DEV-000036-creator-secret-files.json",
|
||||
"task_id": "DEV-000036",
|
||||
"task_revision": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"task_id": "DEV-000034",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0004",
|
||||
"base_repo_revision": "5fca81531a9d1ee26050a5d48e2d5b199135aa4e",
|
||||
"risk_tier": "E",
|
||||
"intent": "Make the existing project-specific production-host-only rule explicit and fail closed against accidental use of the shared docker-test host.",
|
||||
"requirement_ids": ["USER-DOCKER-PROD-ONLY", "CONSTITUTION-1.3.36"],
|
||||
"allowed_change_scope": {
|
||||
"modules": [],
|
||||
"paths": [
|
||||
"AGENTS.md",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/DEV-000034-production-host-only-policy.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"architecture/**",
|
||||
"ci/**",
|
||||
".gitea/**",
|
||||
"cmd/**",
|
||||
"contracts/**",
|
||||
"modules/**",
|
||||
"database/**",
|
||||
"deploy/**",
|
||||
"security/**"
|
||||
]
|
||||
},
|
||||
"allowed_architecture_delta": {
|
||||
"new_cross_module_dependencies": 0,
|
||||
"new_canonical_owners": 0,
|
||||
"new_privileges": 0
|
||||
},
|
||||
"contracts": {
|
||||
"consume": [],
|
||||
"may_change": []
|
||||
},
|
||||
"semantic_owner": "none; repository operations policy only",
|
||||
"canonical_state": ["none"],
|
||||
"state_transitions": ["none"],
|
||||
"side_effects_and_privileges": ["future project operations are limited to docker.cin.su unless the user explicitly re-authorizes another host"],
|
||||
"persistence_and_migrations": ["none"],
|
||||
"failure_retry_restart": ["operations targeting docker-test must stop before mutation and report the policy conflict"],
|
||||
"assumptions": [
|
||||
"The user explicitly reconfirmed that this project has a hard rule prohibiting docker-test",
|
||||
"The production Docker host remains docker.cin.su"
|
||||
],
|
||||
"acceptance_scenarios": [
|
||||
"AGENTS.md explicitly prohibits docker-test aliases and hostname for all project operations",
|
||||
"the rule allows an exception only after new explicit project-specific user authorization",
|
||||
"no runtime, contract, schema, dependency, privilege or deployment configuration changes",
|
||||
"exact diff validation observes only this policy task's three declared paths"
|
||||
],
|
||||
"required_tests": [
|
||||
"architecture preflight",
|
||||
"exact diff scope",
|
||||
"repository status",
|
||||
"production runner and application health"
|
||||
],
|
||||
"terminal_gap_states": [
|
||||
"BLOCKED_BY_ARCHITECTURE_GAP",
|
||||
"BLOCKED_BY_CONFLICT"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"task_id": "DEV-000035",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0004",
|
||||
"base_repo_revision": "6303dfe6eb2215ae79ab63a2b92b86d7fafd312d",
|
||||
"risk_tier": "E",
|
||||
"intent": "Add production-only PostgreSQL backups with integrity manifests, scheduled execution, and isolated restore verification without touching canonical production data.",
|
||||
"requirement_ids": ["CONSTITUTION-1.3.36", "INV-OWN-001", "USER-DOCKER-PROD-ONLY"],
|
||||
"allowed_change_scope": {
|
||||
"modules": [],
|
||||
"paths": [
|
||||
"deploy/backup-postgres.sh",
|
||||
"deploy/verify-postgres-restore.sh",
|
||||
"deploy/verify-latest-postgres-backup.sh",
|
||||
"deploy/install-postgres-backup-cron.sh",
|
||||
"deploy/cron/assistant-postgres-backup.crontab",
|
||||
"docs/DEPLOYMENT.md",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/DEV-000035-postgres-backup-recovery.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"architecture/**",
|
||||
"ci/**",
|
||||
".gitea/**",
|
||||
"cmd/**",
|
||||
"contracts/**",
|
||||
"modules/**",
|
||||
"database/**",
|
||||
"security/**"
|
||||
]
|
||||
},
|
||||
"allowed_architecture_delta": {
|
||||
"new_cross_module_dependencies": 0,
|
||||
"new_canonical_owners": 0,
|
||||
"new_privileges": 0
|
||||
},
|
||||
"contracts": {
|
||||
"consume": ["PostgreSQL logical backup format"],
|
||||
"may_change": []
|
||||
},
|
||||
"semantic_owner": "none; production recovery mechanism only",
|
||||
"canonical_state": ["all PostgreSQL owner schemas are captured without mutation"],
|
||||
"state_transitions": ["none"],
|
||||
"side_effects_and_privileges": ["reads the production PostgreSQL cluster through its existing local superuser context", "writes mode-0600 backup artifacts on docker.cin.su", "creates and removes an isolated temporary restore-check container"],
|
||||
"persistence_and_migrations": ["no migration", "daily logical dump plus role globals and SHA-256 manifest under /home/m/assistant-backups/postgres", "user crontab schedule survives host reboot without a persistent login session"],
|
||||
"failure_retry_restart": ["backup is written to a partial directory and atomically renamed only after all checksums succeed", "a failed backup leaves no object named as complete", "restore verification always removes its isolated temporary container", "no automatic deletion or retention policy is introduced"],
|
||||
"assumptions": [
|
||||
"The production database container remains assistant-postgres-1 unless explicitly overridden",
|
||||
"The production Docker host is docker.cin.su and docker-test is prohibited",
|
||||
"The host has Docker, sha256sum, and cron"
|
||||
],
|
||||
"acceptance_scenarios": [
|
||||
"a backup contains the application database dump, cluster roles, database and owner metadata, and verified hashes",
|
||||
"backup artifacts and directories are private to the production SSH user",
|
||||
"restore verification succeeds in a temporary PostgreSQL 18.6 container without mounting or writing the production volume",
|
||||
"the production user crontab schedules one daily backup at 03:15 Europe/Moscow and persists across reboot",
|
||||
"no backup retention deletion is performed",
|
||||
"exact diff validation observes only declared paths"
|
||||
],
|
||||
"required_tests": [
|
||||
"shell syntax",
|
||||
"architecture preflight",
|
||||
"exact diff scope",
|
||||
"live production backup",
|
||||
"SHA-256 verification",
|
||||
"isolated live restore",
|
||||
"cron schedule installation",
|
||||
"production application health",
|
||||
"CI"
|
||||
],
|
||||
"terminal_gap_states": [
|
||||
"BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"BLOCKED_BY_CONFLICT",
|
||||
"REQUIRES_MIGRATION_DECISION"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -17,3 +17,13 @@ The initial stack definition is `deploy/docker-compose.prod.yml`. Portainer may
|
||||
Credentials/API deployment automation is deliberately not embedded. Use `PORTAINER_API_KEY` from an external secret source or deploy through Portainer UI/API after authenticating.
|
||||
|
||||
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.
|
||||
|
||||
`deploy/verify-postgres-restore.sh <backup-directory>` verifies all hashes and restores into an isolated PostgreSQL 18.6 container backed by tmpfs. It never mounts or writes `assistant_assistant_pg`, and removes its temporary container on exit. A backup is operationally accepted only after this restore check passes.
|
||||
|
||||
The production user crontab installs the single entry from `deploy/cron/assistant-postgres-backup.crontab`. It runs the backup and restore check daily at 00:15 UTC (03:15 Europe/Moscow), survives host reboot without a persistent login session, and writes a private operational log beside the backups.
|
||||
|
||||
Reference in New Issue
Block a user