Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,10 @@ JAEGER_ENDPOINT=http://localhost:4318

# PowerDNS Configuration
POWERDNS_API_URL=http://localhost:8081
POWERDNS_API_KEY=thecloud-dns-secret
# POWERDNS_API_KEY is REQUIRED - must be set to a secure value
POWERDNS_API_KEY=your-secure-dns-api-key
POWERDNS_SERVER_ID=localhost

# Storage Configuration
# STORAGE_SECRET is REQUIRED for presigned URL signing - must be set to a secure value
STORAGE_SECRET=your-secure-storage-secret
5 changes: 5 additions & 0 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ on:
pull_request:
branches: [ main ]

env:
SECRETS_ENCRYPTION_KEY: abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789
STORAGE_SECRET: ci-test-storage-secret-key
POWERDNS_API_KEY: ci-test-powerdns-api-key

jobs:
benchmark:
name: Run Benchmarks
Expand Down
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ name: CI

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
SECRETS_ENCRYPTION_KEY: abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789
STORAGE_SECRET: ci-test-storage-secret-key
POWERDNS_API_KEY: ci-test-powerdns-api-key

on:
push:
Expand Down Expand Up @@ -245,18 +248,21 @@ jobs:
- name: Test Backend Switching
env:
DATABASE_URL: postgres://cloud:cloud@127.0.0.1:5433/cloud?sslmode=disable
SECRETS_ENCRYPTION_KEY: abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789
STORAGE_SECRET: ci-test-storage-secret-key
POWERDNS_API_KEY: ci-test-powerdns-api-key
run: |
# Ensure clean DB for switching tests
PGPASSWORD=cloud psql -h 127.0.0.1 -p 5433 -U cloud -d postgres -c "DROP DATABASE IF EXISTS cloud;"
PGPASSWORD=cloud psql -h 127.0.0.1 -p 5433 -U cloud -d postgres -c "CREATE DATABASE cloud;"
go run ./cmd/api -migrate-only

# Test with Docker backend (default)
export TEST_DOCKER_NETWORK=cloud-network
docker network create ${TEST_DOCKER_NETWORK} || true
export COMPUTE_BACKEND=docker
go test -p 1 -v -run TestInstanceService ./internal/core/services/...

# Test with Libvirt backend
export COMPUTE_BACKEND=libvirt
go test -p 1 -v -run TestInstanceService ./internal/core/services/...
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
name: Coverage Gate

env:
SECRETS_ENCRYPTION_KEY: abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789
STORAGE_SECRET: ci-test-storage-secret-key
POWERDNS_API_KEY: ci-test-powerdns-api-key

on:
pull_request:
branches: [ main ]
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/database-replication.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,15 @@ jobs:
DATABASE_URL: postgres://cloud:cloud@127.0.0.1:5433/cloud?sslmode=disable
COMPUTE_BACKEND: docker
TEST_DOCKER_NETWORK: cloud-network
SECRETS_ENCRYPTION_KEY: abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789
STORAGE_SECRET: ci-test-storage-secret-key
POWERDNS_API_KEY: ci-test-powerdns-api-key
run: |
# Start the API server in the background
# The server will run migrations automatically on startup
./api > api.log 2>&1 &
echo $! > api.pid

# Wait for the server to be ready
echo "Waiting for API server to start..."
timeout 90s bash -c 'until curl -s http://localhost:8080/health/live; do sleep 2; done' || (echo "API Server Logs:" && cat api.log && exit 1)
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ jobs:
SECRETS_ENCRYPTION_KEY: abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789
JWT_SECRET: e2e-test-secret-key-that-is-long-enough-for-32-chars
DATABASE_READ_URL: postgres://cloud:cloud@postgres:5432/cloud?sslmode=disable
STORAGE_SECRET: e2e-test-storage-secret-key
POWERDNS_API_KEY: e2e-test-powerdns-api-key
run: |
# Start internal dependencies
docker compose up -d postgres redis jaeger
Expand Down Expand Up @@ -67,6 +69,8 @@ jobs:
JWT_SECRET: e2e-test-secret-key-that-is-long-enough-for-32-chars
DATABASE_READ_URL: postgres://cloud:cloud@postgres:5432/cloud?sslmode=disable
TRACING_ENABLED: false
STORAGE_SECRET: e2e-test-storage-secret-key
POWERDNS_API_KEY: e2e-test-powerdns-api-key
run: |
# Export variables so docker-compose can access them
export SECRETS_ENCRYPTION_KEY="${SECRETS_ENCRYPTION_KEY}"
Expand All @@ -76,6 +80,8 @@ jobs:
export DB_PORT_MAPPING="${DB_PORT_MAPPING}"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME}"
export DOCKER_DEFAULT_NETWORK="${DOCKER_DEFAULT_NETWORK}"
export STORAGE_SECRET="${STORAGE_SECRET}"
export POWERDNS_API_KEY="${POWERDNS_API_KEY}"

# Ensure Docker socket has proper permissions for API to create containers
sudo chmod 666 /var/run/docker.sock
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ services:
- TRACING_ENABLED=${TRACING_ENABLED:-false}
- JAEGER_ENDPOINT=http://jaeger:4318
- POWERDNS_API_URL=http://powerdns:8081
- POWERDNS_API_KEY=${POWERDNS_API_KEY:-thecloud-dns-secret}
- POWERDNS_API_KEY=${POWERDNS_API_KEY}
- STORAGE_SECRET=${STORAGE_SECRET}
- DOCKER_DEFAULT_NETWORK=${DOCKER_DEFAULT_NETWORK:-cloud-network}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
Expand Down
1 change: 1 addition & 0 deletions internal/core/domain/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,5 @@ type Database struct {
KmsKeyID string `json:"kms_key_id,omitempty"`
EncryptedVolume bool `json:"encrypted_volume"`
VolumeKeyRef string `json:"volume_key_ref,omitempty"`
CredentialVersion int `json:"-"`
}
65 changes: 39 additions & 26 deletions internal/core/services/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,8 @@ func (s *DatabaseService) PromoteToPrimary(ctx context.Context, id uuid.UUID) er
}
}
}
cmd = []string{"mysql", "-u", "root", "-p" + password, "-e", "STOP REPLICA; RESET REPLICA ALL;"}
// Use MYSQL_PWD env var to avoid password appearing in process list
cmd = []string{"sh", "-c", fmt.Sprintf("MYSQL_PWD='%s' mysql -u root --execute='STOP REPLICA; RESET REPLICA ALL;'", sqlStringLiteral(password))}
Comment on lines +566 to +567
Comment on lines +566 to +567
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the ComputeBackend Exec signature so we know whether env/stdin are already plumbable.
ast-grep --pattern 'type ComputeBackend interface {
  $$$
}'
rg -nP '\bExec\s*\(' --type=go -C2 -g 'internal/core/ports/**'
rg -nP '\bfunc\s+\([^)]+\)\s+Exec\s*\(' --type=go -C2

Repository: poyrazK/thecloud

Length of output: 17019


🏁 Script executed:

# First, let's read the database.go file to see lines around 566-567
sed -n '560,575p' internal/core/services/database.go

Repository: poyrazK/thecloud

Length of output: 637


🏁 Script executed:

# Also get the buildPasswordChangeCmd implementations around lines 973 and 977
sed -n '970,985p' internal/core/services/database.go

Repository: poyrazK/thecloud

Length of output: 1229


🏁 Script executed:

# Let's see the full function context for both issues
sed -n '550,580p' internal/core/services/database.go | head -40
sed -n '960,990p' internal/core/services/database.go

Repository: poyrazK/thecloud

Length of output: 2822


🏁 Script executed:

# Find sqlStringLiteral implementation
rg -n "func sqlStringLiteral" internal/core/services/database.go -A 10

Repository: poyrazK/thecloud

Length of output: 432


🏁 Script executed:

# Check Docker adapter Exec implementation to understand the execution context
sed -n '1007,1030p' internal/repositories/docker/adapter.go

Repository: poyrazK/thecloud

Length of output: 790


🏁 Script executed:

# Understand the container execution context - are passwords visible to other processes?
rg -n "ExecOptions\|docker client" internal/repositories/docker/adapter.go | head -20

Repository: poyrazK/thecloud

Length of output: 42


sh -c command string still exposes password in process argv — fix requires ComputeBackend.Exec API changes.

The review comment is correct. Using sh -c with embedded MYSQL_PWD='<password>' still exposes the full command string (including password) in /proc/<pid>/cmdline for as long as the shell process runs, even though the intent was to avoid process list exposure. Any sibling process in the same container PID namespace can read this.

This affects:

  • Lines 566–567: promotion path for MySQL replication
  • Line 973: PostgreSQL password change via buildPasswordChangeCmd
  • Line 977: MySQL password change via buildPasswordChangeCmd

The comments claiming the password "won't appear in /proc/cmdline" (lines 567, 973) are incorrect.

To fix: Extend ComputeBackend.Exec interface to accept environment variables (or stdin), then pass MYSQL_PWD and POSTGRES_PASSWORD as env map entries rather than shell command strings. Current signature Exec(ctx context.Context, id string, cmd []string) (string, error) provides no mechanism for this.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/core/services/database.go` around lines 566 - 567, The Exec API
currently exposes secrets by requiring callers to embed env vars into a shell
command string; change the ComputeBackend.Exec signature to accept explicit
environment variables (e.g., Exec(ctx context.Context, id string, cmd []string,
env map[string]string) or an options struct that carries Env) and update every
backend implementation to use the new signature; then replace uses that build
shell commands with embedded MYSQL_PWD/POSTGRES_PASSWORD (notably the MySQL
promotion path that constructs cmd = []string{"sh","-c",
fmt.Sprintf("MYSQL_PWD='%s' mysql ...", ...)} and the callers
buildPasswordChangeCmd) so they pass the password in the Env map and invoke
mysql/psql directly (no sh -c or inline password), and update all callers and
tests accordingly.

Comment on lines +566 to +567
default:
return errors.New(errors.Internal, "unsupported engine for promotion")
}
Expand Down Expand Up @@ -852,7 +853,7 @@ func (s *DatabaseService) doRotateCredentials(ctx context.Context, id uuid.UUID,
return errors.Wrap(errors.Internal, "failed to generate new password", err)
}

// Get current password for MySQL auth
// Get current password for DB auth
currentPassword := db.Password
if db.CredentialPath != "" {
secret, err := s.secrets.GetSecret(ctx, db.CredentialPath)
Expand All @@ -863,7 +864,19 @@ func (s *DatabaseService) doRotateCredentials(ctx context.Context, id uuid.UUID,
}
}

// 1. Execute ALTER USER in container FIRST
// 1. Store new password in Vault at versioned path FIRST (before DB change)
vaultPath := db.CredentialPath
if vaultPath == "" {
vaultPath = s.getVaultPath(db.ID)
}
// Increment version and compute new versioned path
newVersion := db.CredentialVersion + 1
versionedPath := vaultPath + "/v" + strconv.Itoa(newVersion)
if err := s.secrets.StoreSecret(ctx, versionedPath, map[string]interface{}{"password": newPassword}); err != nil {
return errors.Wrap(errors.Internal, "failed to store new credential in vault", err)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +872 to +877

// 2. Execute ALTER USER in container using currentPassword for auth
cmd := s.buildPasswordChangeCmd(db.Engine, db.Username, currentPassword, newPassword)
if cmd == nil {
return errors.New(errors.Internal, "unsupported engine for credential rotation")
Expand All @@ -880,27 +893,20 @@ func (s *DatabaseService) doRotateCredentials(ctx context.Context, id uuid.UUID,
return errors.Wrap(errors.Internal, "failed to execute password rotation in container", execErr)
}

// 2. Update in Vault ONLY after DB success
vaultPath := db.CredentialPath
if vaultPath == "" {
vaultPath = s.getVaultPath(db.ID)
}
if err := s.secrets.StoreSecret(ctx, vaultPath, map[string]interface{}{"password": newPassword}); err != nil {
// Vault store failed but DB already has new password - rollback to original
rollbackCmd := s.buildPasswordChangeCmd(db.Engine, db.Username, currentPassword, newPassword)
if _, rollbackErr := s.compute.Exec(ctx, db.ContainerID, rollbackCmd); rollbackErr != nil {
// Rollback also failed - system is in critical state requiring manual intervention
return errors.Wrap(errors.Internal,
fmt.Sprintf("credential rotation failed and rollback also failed - manual intervention required (vault store error: %v)", err),
rollbackErr)
}
return errors.Wrap(errors.Internal, "vault store failed, DB password rolled back", err)
// 3. Update DB record to point to new versioned path (ONLY after DB confirmed updated)
// The old path still has the old password for rollback if needed
db.CredentialPath = versionedPath
db.CredentialVersion = newVersion
if err := s.repo.Update(ctx, db); err != nil {
Comment on lines 867 to +900
return errors.Wrap(errors.Internal, "failed to update DB credential path", err)
}

// 3. Update DB record if needed (metadata or path)
db.CredentialPath = vaultPath
if err := s.repo.Update(ctx, db); err != nil {
return err
// 4. Cleanup old versioned path from Vault
oldVersionedPath := vaultPath + "/v" + strconv.Itoa(db.CredentialVersion-1)
if err := s.secrets.DeleteSecret(ctx, oldVersionedPath); err != nil {
// Log but don't fail - old version may not exist if this is first rotation
s.logger.Warn("failed to cleanup old credential version from vault", "path", oldVersionedPath, "error", err)
platform.CredentialCleanupFailures.Inc()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 4. If pooler is enabled, restart it to pick up new credentials
Expand Down Expand Up @@ -965,11 +971,18 @@ func postgresIdentifier(id string) string {
func (s *DatabaseService) buildPasswordChangeCmd(engine domain.DatabaseEngine, username, authPassword, targetPassword string) []string {
switch engine {
case domain.EnginePostgres:
return []string{"psql", "-h", "127.0.0.1", "-U", username, "-d", "postgres", "-c",
fmt.Sprintf("ALTER USER %s WITH PASSWORD '%s';", postgresIdentifier(username), sqlStringLiteral(targetPassword))}
// Use PGPASSWORD env var for authentication (libpq standard).
// Note: While env vars avoid cmdline exposure, the password is still visible to
// root users via /proc/$pid/environ. This is a known tradeoff - the alternative
// (stdin password) requires more complex interaction patterns with psql.
// authPassword (current password) authenticates the connection; targetPassword is the new password being set.
stmt := fmt.Sprintf("ALTER USER %s WITH PASSWORD '%s';", postgresIdentifier(username), sqlStringLiteral(targetPassword))
return []string{"sh", "-c", "PGPASSWORD='" + sqlStringLiteral(authPassword) + "' psql -h 127.0.0.1 -U " + username + " -d postgres -c '" + stmt + "'"}
case domain.EngineMySQL:
Comment on lines 973 to 981
return []string{"mysql", "-u", "root", "-p" + authPassword, "-e",
fmt.Sprintf("ALTER USER '%s'@'%%' IDENTIFIED BY '%s';", sqlStringLiteral(username), sqlStringLiteral(targetPassword))}
// Use mysql with password via MYSQL_PWD env var to avoid cmdline exposure.
// Same tradeoff as above - password visible in /proc environment to root users.
stmt := fmt.Sprintf("ALTER USER '%s'@'%%' IDENTIFIED BY '%s';", sqlStringLiteral(username), sqlStringLiteral(targetPassword))
return []string{"sh", "-c", "MYSQL_PWD='" + sqlStringLiteral(authPassword) + "' mysql -u " + sqlStringLiteral(username) + " --execute='" + stmt + "'"}
}
return nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
82 changes: 53 additions & 29 deletions internal/core/services/database_vault_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,34 +62,50 @@ func TestDatabaseService_RotateCredentials(t *testing.T) {
ctx := context.Background()
dbID := uuid.New()
db := &domain.Database{
ID: dbID,
UserID: uuid.New(),
Name: "test-db",
Engine: domain.EnginePostgres,
Username: "cloud_user",
ContainerID: "cid-1",
CredentialPath: "secret/rds/" + dbID.String() + "/credentials",
ID: dbID,
UserID: uuid.New(),
Name: "test-db",
Engine: domain.EnginePostgres,
Username: "cloud_user",
ContainerID: "cid-1",
CredentialPath: "secret/rds/" + dbID.String() + "/credentials",
CredentialVersion: 1,
}

t.Run("RotateCredentials_Success", func(t *testing.T) {
mockRepo.On("GetByID", mock.Anything, dbID).Return(db, nil).Once()
mockSecrets.On("GetSecret", mock.Anything, db.CredentialPath).Return(map[string]interface{}{"password": "old-pass"}, nil).Once()

// 1. Execute ALTER USER in container
mockCompute.On("Exec", mock.Anything, db.ContainerID, mock.Anything).Return("ALTER ROLE", nil).Once()

// 2. Update in Vault
mockSecrets.On("StoreSecret", mock.Anything, db.CredentialPath, mock.MatchedBy(func(data map[string]interface{}) bool {
// Create a fresh db for this test to avoid mutation from previous test affecting this one
testDB := &domain.Database{
ID: db.ID,
UserID: db.UserID,
Name: db.Name,
Engine: db.Engine,
Username: db.Username,
ContainerID: db.ContainerID,
CredentialPath: "secret/rds/" + dbID.String() + "/credentials",
CredentialVersion: 1,
}
mockRepo.On("GetByID", mock.Anything, dbID).Return(testDB, nil).Once()
mockSecrets.On("GetSecret", mock.Anything, testDB.CredentialPath).Return(map[string]interface{}{"password": "old-pass"}, nil).Once()

// 1. Store new credential at versioned path in Vault FIRST (version 2 since db starts at version 1)
versionedPath := testDB.CredentialPath + "/v2"
mockSecrets.On("StoreSecret", mock.Anything, versionedPath, mock.MatchedBy(func(data map[string]interface{}) bool {
return data["password"] != ""
})).Return(nil).Once()

// 3. Update DB record
// 2. Execute ALTER USER in container
mockCompute.On("Exec", mock.Anything, testDB.ContainerID, mock.Anything).Return("ALTER ROLE", nil).Once()

// 3. Update DB record to point to new versioned path and increment version
mockRepo.On("Update", mock.Anything, mock.MatchedBy(func(d *domain.Database) bool {
return d.ID == dbID
return d.ID == dbID && d.CredentialPath == versionedPath && d.CredentialVersion == 2
})).Return(nil).Once()

// 4. Cleanup old versioned path from Vault (v1)
mockSecrets.On("DeleteSecret", mock.Anything, testDB.CredentialPath+"/v1").Return(nil).Once()

mockEventSvc.On("RecordEvent", mock.Anything, "DATABASE_CREDENTIALS_ROTATE", dbID.String(), "DATABASE", mock.Anything).Return(nil).Once()
mockAuditSvc.On("Log", mock.Anything, db.UserID, "database.rotate_credentials", "database", db.ID.String(), mock.Anything).Return(nil).Once()
mockAuditSvc.On("Log", mock.Anything, testDB.UserID, "database.rotate_credentials", "database", testDB.ID.String(), mock.Anything).Return(nil).Once()

err := svc.RotateCredentials(ctx, dbID, "")
require.NoError(t, err)
Expand All @@ -99,23 +115,31 @@ func TestDatabaseService_RotateCredentials(t *testing.T) {
mockRepo.AssertExpectations(t)
})

t.Run("RotateCredentials_VaultFailure_WithRollback", func(t *testing.T) {
mockRepo.On("GetByID", mock.Anything, dbID).Return(db, nil).Once()
mockSecrets.On("GetSecret", mock.Anything, db.CredentialPath).Return(map[string]interface{}{"password": "old-pass"}, nil).Once()
// First Exec: ALTER USER with new password (succeeds)
mockCompute.On("Exec", mock.Anything, db.ContainerID, mock.Anything).Return("ALTER ROLE", nil).Once()
// Vault store fails
mockSecrets.On("StoreSecret", mock.Anything, db.CredentialPath, mock.Anything).Return(fmt.Errorf("vault error")).Once()
// Second Exec: rollback to original password (succeeds)
mockCompute.On("Exec", mock.Anything, db.ContainerID, mock.Anything).Return("ALTER ROLE", nil).Once()
t.Run("RotateCredentials_VaultFailure_WithoutDBChange", func(t *testing.T) {
// Create a fresh db for this test
testDB := &domain.Database{
ID: db.ID,
UserID: db.UserID,
Name: db.Name,
Engine: db.Engine,
Username: db.Username,
ContainerID: db.ContainerID,
CredentialPath: "secret/rds/" + dbID.String() + "/credentials",
CredentialVersion: 1,
}
mockRepo.On("GetByID", mock.Anything, dbID).Return(testDB, nil).Once()
mockSecrets.On("GetSecret", mock.Anything, testDB.CredentialPath).Return(map[string]interface{}{"password": "old-pass"}, nil).Once()
// Vault store fails at step 1 (versioned path) - DB is NOT changed
versionedPath := testDB.CredentialPath + "/v2"
mockSecrets.On("StoreSecret", mock.Anything, versionedPath, mock.Anything).Return(fmt.Errorf("vault error")).Once()

err := svc.RotateCredentials(ctx, dbID, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "vault store failed, DB password rolled back")
assert.Contains(t, err.Error(), "failed to store new credential in vault")

mockSecrets.AssertExpectations(t)
// Compute.Exec should NOT be called since Vault failed before DB change
mockCompute.AssertExpectations(t)
mockRepo.AssertExpectations(t)
})
Comment on lines +118 to 143
}

Expand Down
16 changes: 8 additions & 8 deletions internal/core/services/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"crypto/sha256"
"encoding/hex"
"log/slog"
"os"
"time"

"github.com/google/uuid"
Expand All @@ -19,21 +20,20 @@ import (
)

// serverSecret is used as HMAC key to prevent rainbow table attacks on API key hashes.
// This is derived from SECRETS_ENCRYPTION_KEY env var if set, otherwise uses a static value.
// In production, set SECRETS_ENCRYPTION_KEY for proper security.
var serverSecret = getServerSecret()

func getServerSecret() string {
// Use the secrets encryption key if available, otherwise fall back to a warning string
// that will be rejected in production
secret := platform.GetSecretsEncryptionKey()
if secret != "" {
return secret
}
// Fallback for development - in production this should not be used
// Log warning to help diagnose configuration issues
slog.Default().Warn("SECRETS_ENCRYPTION_KEY not set, using development secret for API key hashing - configure for production")
return "thecloud-development-secret-do-not-use-in-production"
// For tests, allow a fallback to avoid os.Exit in package init
if os.Getenv("TEST_SECRETS") != "" {
return os.Getenv("TEST_SECRETS")
}
Comment on lines +30 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

TEST_SECRETS is a production-active bypass.

This branch isn't gated by build tag, env name, or test detection — it's just another env var that, when set in a real deployment, silently satisfies the missing-SECRETS_ENCRYPTION_KEY check. That undermines the very guarantee this PR is trying to add.

If you keep the package-init pattern in the short term, please at minimum gate the fallback on something only present in tests (e.g., testing.Testing() from Go 1.21+, or strings.HasSuffix(os.Args[0], ".test")) so a misconfigured production env can't trip it. Removing the global (see other comment) eliminates the need for it entirely.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/core/services/identity.go` around lines 30 - 33, The TEST_SECRETS
fallback in the package init (the branch checking os.Getenv("TEST_SECRETS")) is
too permissive for production; change the guard so it only triggers under real
test runs (e.g., use testing.Testing() from the testing package on Go 1.21+ or
detect os.Args[0] ending with ".test") before returning
os.Getenv("TEST_SECRETS"), so only true test processes can use TEST_SECRETS;
update the init/secret-loading code that references TEST_SECRETS accordingly and
remove or tighten this fallback if you later remove the global initialization
pattern.

slog.Default().Error("SECRETS_ENCRYPTION_KEY environment variable is required")
os.Exit(1)
return "" // unreachable but satisfies compiler
Comment on lines +30 to +36
Comment on lines 22 to +36
}
Comment on lines 23 to 37
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid os.Exit from a package-level initializer; inject the secret instead.

var serverSecret = getServerSecret() runs at package import, so os.Exit(1) here turns a missing env var into an opaque process termination during init for anything that imports internal/core/services (CLIs, migration tools, sibling-package tests, etc.). Per the repo guidelines this is also a "no panic / no global / use constructor injection" violation.

Recommended shape:

  • Enforce SECRETS_ENCRYPTION_KEY once in internal/platform/config.go's validateConfig (where STORAGE_SECRET and POWERDNS_API_KEY are already enforced).
  • Make the secret a field on IdentityService and pass it via IdentityServiceParams. Then computeKeyHash becomes a method on IdentityService using s.serverSecret.
  • Drop the TEST_SECRETS branch — tests can construct IdentityService with whatever secret they want via the params struct.

That removes the global, removes the os.Exit, removes a production-active backdoor (TEST_SECRETS), and centralises the missing-key error in one place.

As per coding guidelines: "Do not panic in production code - return errors instead", "Do not use global variables", and "Use constructor injection for dependencies instead of global initialization".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/core/services/identity.go` around lines 23 - 37, Remove the
package-level var serverSecret and the getServerSecret function; instead
validate presence of the secrets encryption key in validateConfig and pass it
into IdentityService via a new serverSecret field on IdentityService and through
IdentityServiceParams; convert computeKeyHash into a method on IdentityService
that uses s.serverSecret; ensure the IdentityService constructor returns an
error if the secret is missing (no os.Exit) and delete the TEST_SECRETS fallback
so tests create IdentityService with test secrets via IdentityServiceParams.


// computeKeyHash creates a HMAC-SHA256 hash of the API key using the server secret.
Expand Down
Loading
Loading