From 1f3e19d3a508e9fabe8e0322d9ec8a80895a7be8 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Tue, 27 Jan 2026 17:22:37 +0000 Subject: [PATCH] Add configurable admin password via environment variable - Add ORCHARD_ADMIN_PASSWORD env var to set initial admin password - When set, admin user created without forced password change - Add AWS Secrets Manager support for stage/prod deployments - Add .env file support for local docker development - Add Helm chart auth config (adminPassword, existingSecret, secretsManager) Environments configured: - Local: .env file or defaults to changeme123 - Feature/dev: orchardtest123 (hardcoded in values-dev.yaml) - Stage: AWS Secrets Manager (orchard-stage-creds) - Prod: AWS Secrets Manager (orch-prod-creds) --- .env.example | 7 + .gitlab-ci.yml | 277 ++++++++++++++---- CHANGELOG.md | 9 + backend/app/auth.py | 19 +- backend/app/config.py | 3 + backend/tests/unit/test_auth.py | 95 ++++++ docker-compose.local.yml | 2 + helm/orchard/templates/_helpers.tpl | 13 + helm/orchard/templates/deployment.yaml | 32 +- .../templates/secret-provider-class.yaml | 24 ++ helm/orchard/templates/secret.yaml | 12 + helm/orchard/values-dev.yaml | 5 + helm/orchard/values-prod.yaml | 7 + helm/orchard/values-stage.yaml | 7 + helm/orchard/values.yaml | 11 + 15 files changed, 453 insertions(+), 70 deletions(-) create mode 100644 .env.example create mode 100644 backend/tests/unit/test_auth.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dbee420 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Orchard Local Development Environment +# Copy this file to .env and customize as needed +# Note: .env is gitignored and will not be committed + +# Admin account password (required for local development) +# This sets the initial admin password when the database is first created +ORCHARD_ADMIN_PASSWORD=changeme123 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5705dfb..b2c51ec 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,6 +15,7 @@ variables: STAGE_RDS_HOST: orchard-stage.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com STAGE_RDS_DBNAME: postgres STAGE_SECRET_ARN: "arn:aws-us-gov:secretsmanager:us-gov-west-1:052673043337:secret:rds!cluster-a573672b-1a38-4665-a654-1b7df37b5297-IaeFQL" + STAGE_AUTH_SECRET_ARN: "arn:aws-us-gov:secretsmanager:us-gov-west-1:052673043337:secret:orchard-stage-creds-SMqvQx" STAGE_S3_BUCKET: orchard-artifacts-stage AWS_REGION: us-gov-west-1 # Shared pip cache directory @@ -196,81 +197,140 @@ release: sys.exit(0) PYTEST_SCRIPT -# Integration tests for stage deployment (full suite) -# Reset stage template - shared by pre and post test reset jobs +# Reset stage template - runs in-cluster with IRSA for Secrets Manager access # Calls the /api/v1/admin/factory-reset endpoint which handles DB and S3 cleanup .reset_stage_template: &reset_stage_template stage: deploy - image: deps.global.bsf.tools/docker/python:3.12-slim - timeout: 5m - retry: 1 # Retry once on transient failures + image: deps.global.bsf.tools/registry-1.docker.io/alpine/k8s:1.29.12 + timeout: 10m + retry: 1 + variables: + NAMESPACE: orch-stage-namespace before_script: - - pip install --index-url "$PIP_INDEX_URL" httpx + - kubectl config use-context esv/bsf/bsf-integration/orchard/orchard-mvp:orchard-stage script: - | - python - <<'RESET_SCRIPT' - import httpx - import sys - import os - import time + # Create a Job to run the reset in the cluster + cat < /tmp/test_smoke.py << 'TESTEOF' + import os + import httpx + + def test_health(): + url = os.environ["ORCHARD_TEST_URL"] + r = httpx.get(f"{url}/health", timeout=30) + assert r.status_code == 200 + + def test_login(): + url = os.environ["ORCHARD_TEST_URL"] + password = os.environ["ORCHARD_TEST_PASSWORD"] + with httpx.Client(base_url=url, timeout=30) as client: + r = client.post("/api/v1/auth/login", json={"username": "admin", "password": password}) + assert r.status_code == 200, f"Login failed: {r.status_code} {r.text}" + + def test_api(): + url = os.environ["ORCHARD_TEST_URL"] + r = httpx.get(f"{url}/api/v1/projects", timeout=30) + assert r.status_code == 200 + TESTEOF + + python -m pytest /tmp/test_smoke.py -v + EOF + - | + echo "Waiting for test job to complete..." + kubectl wait --for=condition=complete --timeout=15m job/integration-test-${CI_PIPELINE_ID} -n ${NAMESPACE} || { + echo "Job failed or timed out. Fetching logs..." + kubectl logs job/integration-test-${CI_PIPELINE_ID} -n ${NAMESPACE} || true + kubectl delete job integration-test-${CI_PIPELINE_ID} -n ${NAMESPACE} || true + exit 1 + } + - kubectl logs job/integration-test-${CI_PIPELINE_ID} -n ${NAMESPACE} + - kubectl delete job integration-test-${CI_PIPELINE_ID} -n ${NAMESPACE} || true rules: - if: '$CI_COMMIT_BRANCH == "main"' when: on_success @@ -302,6 +448,7 @@ integration_test_feature: needs: [deploy_feature] variables: ORCHARD_TEST_URL: https://orchard-$CI_COMMIT_REF_SLUG.common.global.bsf.tools + ORCHARD_TEST_PASSWORD: orchardtest123 # Matches values-dev.yaml orchard.auth.adminPassword rules: - if: '$CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != "main"' when: on_success diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c3f4d9..6f8eaa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Added `ORCHARD_ADMIN_PASSWORD` environment variable to configure initial admin password (#87) + - When set, admin user is created with the specified password (no password change required) + - When not set, defaults to `changeme123` and requires password change on first login +- Added Helm chart support for admin password via multiple sources (#87): + - `orchard.auth.adminPassword` - plain value (creates K8s secret) + - `orchard.auth.existingSecret` - reference existing K8s secret + - `orchard.auth.secretsManager` - AWS Secrets Manager integration +- Added `.env.example` template for local development (#87) +- Added `.env` file support in docker-compose.local.yml (#87) - Added Project Settings page accessible to project admins (#65) - General settings section for editing description and visibility - Access Management section (moved from project page) diff --git a/backend/app/auth.py b/backend/app/auth.py index c35ca38..1cf41cf 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -360,21 +360,36 @@ def create_default_admin(db: Session) -> Optional[User]: """Create the default admin user if no users exist. Returns the created user, or None if users already exist. + + The admin password can be set via ORCHARD_ADMIN_PASSWORD environment variable. + If not set, defaults to 'changeme123' and requires password change on first login. """ # Check if any users exist user_count = db.query(User).count() if user_count > 0: return None + settings = get_settings() + + # Use configured password or default + password = settings.admin_password if settings.admin_password else "changeme123" + # Only require password change if using the default password + must_change = not settings.admin_password + # Create default admin auth_service = AuthService(db) admin = auth_service.create_user( username="admin", - password="changeme123", + password=password, is_admin=True, - must_change_password=True, + must_change_password=must_change, ) + if settings.admin_password: + logger.info("Created default admin user with configured password") + else: + logger.info("Created default admin user with default password (changeme123)") + return admin diff --git a/backend/app/config.py b/backend/app/config.py index 8a19d89..a691767 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -53,6 +53,9 @@ class Settings(BaseSettings): log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL log_format: str = "auto" # "json", "standard", or "auto" (json in production) + # Initial admin user settings + admin_password: str = "" # Initial admin password (if empty, uses 'changeme123') + # JWT Authentication settings (optional, for external identity providers) jwt_enabled: bool = False # Enable JWT token validation jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS diff --git a/backend/tests/unit/test_auth.py b/backend/tests/unit/test_auth.py new file mode 100644 index 0000000..cfe4157 --- /dev/null +++ b/backend/tests/unit/test_auth.py @@ -0,0 +1,95 @@ +"""Unit tests for authentication module.""" + +import pytest +from unittest.mock import patch, MagicMock + + +class TestCreateDefaultAdmin: + """Tests for the create_default_admin function.""" + + def test_create_default_admin_with_env_password(self): + """Test that ORCHARD_ADMIN_PASSWORD env var sets admin password.""" + from app.auth import create_default_admin, verify_password + + # Create mock settings with custom password + mock_settings = MagicMock() + mock_settings.admin_password = "my-custom-password-123" + + # Mock database session + mock_db = MagicMock() + mock_db.query.return_value.count.return_value = 0 # No existing users + + # Track the user that gets created + created_user = None + + def capture_user(user): + nonlocal created_user + created_user = user + + mock_db.add.side_effect = capture_user + + with patch("app.auth.get_settings", return_value=mock_settings): + admin = create_default_admin(mock_db) + + # Verify the user was created + assert mock_db.add.called + assert created_user is not None + assert created_user.username == "admin" + assert created_user.is_admin is True + # Password should NOT require change when set via env var + assert created_user.must_change_password is False + # Verify password was hashed correctly + assert verify_password("my-custom-password-123", created_user.password_hash) + + def test_create_default_admin_with_default_password(self): + """Test that default password 'changeme123' is used when env var not set.""" + from app.auth import create_default_admin, verify_password + + # Create mock settings with empty password (default) + mock_settings = MagicMock() + mock_settings.admin_password = "" + + # Mock database session + mock_db = MagicMock() + mock_db.query.return_value.count.return_value = 0 # No existing users + + # Track the user that gets created + created_user = None + + def capture_user(user): + nonlocal created_user + created_user = user + + mock_db.add.side_effect = capture_user + + with patch("app.auth.get_settings", return_value=mock_settings): + admin = create_default_admin(mock_db) + + # Verify the user was created + assert mock_db.add.called + assert created_user is not None + assert created_user.username == "admin" + assert created_user.is_admin is True + # Password SHOULD require change when using default + assert created_user.must_change_password is True + # Verify default password was used + assert verify_password("changeme123", created_user.password_hash) + + def test_create_default_admin_skips_when_users_exist(self): + """Test that no admin is created when users already exist.""" + from app.auth import create_default_admin + + # Create mock settings + mock_settings = MagicMock() + mock_settings.admin_password = "some-password" + + # Mock database session with existing users + mock_db = MagicMock() + mock_db.query.return_value.count.return_value = 1 # Users exist + + with patch("app.auth.get_settings", return_value=mock_settings): + result = create_default_admin(mock_db) + + # Should return None and not create any user + assert result is None + assert not mock_db.add.called diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 6426494..9c92f7f 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -26,6 +26,8 @@ services: - ORCHARD_REDIS_PORT=6379 # Higher rate limit for local development/testing - ORCHARD_LOGIN_RATE_LIMIT=1000/minute + # Admin password - set in .env file or environment (see .env.example) + - ORCHARD_ADMIN_PASSWORD=${ORCHARD_ADMIN_PASSWORD:-} depends_on: postgres: condition: service_healthy diff --git a/helm/orchard/templates/_helpers.tpl b/helm/orchard/templates/_helpers.tpl index d8d42a7..ea57cb6 100644 --- a/helm/orchard/templates/_helpers.tpl +++ b/helm/orchard/templates/_helpers.tpl @@ -141,3 +141,16 @@ MinIO secret name {{- printf "%s-s3-secret" (include "orchard.fullname" .) }} {{- end }} {{- end }} + +{{/* +Auth secret name (for admin password) +*/}} +{{- define "orchard.auth.secretName" -}} +{{- if and .Values.orchard.auth .Values.orchard.auth.existingSecret }} +{{- .Values.orchard.auth.existingSecret }} +{{- else if and .Values.orchard.auth .Values.orchard.auth.secretsManager .Values.orchard.auth.secretsManager.enabled }} +{{- printf "%s-auth-credentials" (include "orchard.fullname" .) }} +{{- else }} +{{- printf "%s-auth-secret" (include "orchard.fullname" .) }} +{{- end }} +{{- end }} diff --git a/helm/orchard/templates/deployment.yaml b/helm/orchard/templates/deployment.yaml index 5bc6fa2..ef5376c 100644 --- a/helm/orchard/templates/deployment.yaml +++ b/helm/orchard/templates/deployment.yaml @@ -128,11 +128,27 @@ spec: value: {{ .Values.orchard.rateLimit.login | quote }} {{- end }} {{- end }} - {{- if and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled }} + {{- if .Values.orchard.auth }} + {{- if or .Values.orchard.auth.secretsManager .Values.orchard.auth.existingSecret .Values.orchard.auth.adminPassword }} + - name: ORCHARD_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "orchard.auth.secretName" . }} + key: admin-password + {{- end }} + {{- end }} + {{- if or (and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled) (and .Values.orchard.auth .Values.orchard.auth.secretsManager .Values.orchard.auth.secretsManager.enabled) }} volumeMounts: + {{- if and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled }} - name: db-secrets - mountPath: /mnt/secrets-store + mountPath: /mnt/secrets-store/db readOnly: true + {{- end }} + {{- if and .Values.orchard.auth .Values.orchard.auth.secretsManager .Values.orchard.auth.secretsManager.enabled }} + - name: auth-secrets + mountPath: /mnt/secrets-store/auth + readOnly: true + {{- end }} {{- end }} livenessProbe: {{- toYaml .Values.livenessProbe | nindent 12 }} @@ -140,14 +156,24 @@ spec: {{- toYaml .Values.readinessProbe | nindent 12 }} resources: {{- toYaml .Values.resources | nindent 12 }} - {{- if and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled }} + {{- if or (and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled) (and .Values.orchard.auth .Values.orchard.auth.secretsManager .Values.orchard.auth.secretsManager.enabled) }} volumes: + {{- if and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled }} - name: db-secrets csi: driver: secrets-store.csi.k8s.io readOnly: true volumeAttributes: secretProviderClass: {{ include "orchard.fullname" . }}-db-secret + {{- end }} + {{- if and .Values.orchard.auth .Values.orchard.auth.secretsManager .Values.orchard.auth.secretsManager.enabled }} + - name: auth-secrets + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: {{ include "orchard.fullname" . }}-auth-secret + {{- end }} {{- end }} {{- with .Values.nodeSelector }} nodeSelector: diff --git a/helm/orchard/templates/secret-provider-class.yaml b/helm/orchard/templates/secret-provider-class.yaml index 1259b85..1a0bfed 100644 --- a/helm/orchard/templates/secret-provider-class.yaml +++ b/helm/orchard/templates/secret-provider-class.yaml @@ -25,3 +25,27 @@ spec: - objectName: db-password key: password {{- end }} +--- +{{- if and .Values.orchard.auth .Values.orchard.auth.secretsManager .Values.orchard.auth.secretsManager.enabled }} +apiVersion: secrets-store.csi.x-k8s.io/v1 +kind: SecretProviderClass +metadata: + name: {{ include "orchard.fullname" . }}-auth-secret + labels: + {{- include "orchard.labels" . | nindent 4 }} +spec: + provider: aws + parameters: + objects: | + - objectName: "{{ .Values.orchard.auth.secretsManager.secretArn }}" + objectType: "secretsmanager" + jmesPath: + - path: admin_password + objectAlias: admin-password + secretObjects: + - secretName: {{ include "orchard.fullname" . }}-auth-credentials + type: Opaque + data: + - objectName: admin-password + key: admin-password +{{- end }} diff --git a/helm/orchard/templates/secret.yaml b/helm/orchard/templates/secret.yaml index 994342a..32bbc14 100644 --- a/helm/orchard/templates/secret.yaml +++ b/helm/orchard/templates/secret.yaml @@ -22,3 +22,15 @@ data: access-key-id: {{ .Values.orchard.s3.accessKeyId | b64enc | quote }} secret-access-key: {{ .Values.orchard.s3.secretAccessKey | b64enc | quote }} {{- end }} +--- +{{- if and .Values.orchard.auth .Values.orchard.auth.adminPassword (not .Values.orchard.auth.existingSecret) (not (and .Values.orchard.auth.secretsManager .Values.orchard.auth.secretsManager.enabled)) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "orchard.fullname" . }}-auth-secret + labels: + {{- include "orchard.labels" . | nindent 4 }} +type: Opaque +data: + admin-password: {{ .Values.orchard.auth.adminPassword | b64enc | quote }} +{{- end }} diff --git a/helm/orchard/values-dev.yaml b/helm/orchard/values-dev.yaml index c15557d..723e414 100644 --- a/helm/orchard/values-dev.yaml +++ b/helm/orchard/values-dev.yaml @@ -90,6 +90,11 @@ orchard: host: "0.0.0.0" port: 8080 + # Authentication settings + auth: + # Plain admin password for ephemeral feature environments + adminPassword: "orchardtest123" + database: host: "" port: 5432 diff --git a/helm/orchard/values-prod.yaml b/helm/orchard/values-prod.yaml index ee77c2b..fb08ccb 100644 --- a/helm/orchard/values-prod.yaml +++ b/helm/orchard/values-prod.yaml @@ -93,6 +93,13 @@ orchard: host: "0.0.0.0" port: 8080 + # Authentication settings + auth: + # Admin password from AWS Secrets Manager + secretsManager: + enabled: true + secretArn: "arn:aws-us-gov:secretsmanager:us-gov-west-1:052673043337:secret:orch-prod-creds-0nhqkY" + # Database configuration - uses AWS Secrets Manager via CSI driver database: host: "orchard-prd.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com" diff --git a/helm/orchard/values-stage.yaml b/helm/orchard/values-stage.yaml index 19e889e..7e8cbcf 100644 --- a/helm/orchard/values-stage.yaml +++ b/helm/orchard/values-stage.yaml @@ -95,6 +95,13 @@ orchard: host: "0.0.0.0" port: 8080 + # Authentication settings + auth: + # Admin password from AWS Secrets Manager + secretsManager: + enabled: true + secretArn: "arn:aws-us-gov:secretsmanager:us-gov-west-1:052673043337:secret:orchard-stage-creds-SMqvQx" + # Database configuration - uses AWS Secrets Manager via CSI driver database: host: "orchard-stage.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com" diff --git a/helm/orchard/values.yaml b/helm/orchard/values.yaml index cafea3a..393a422 100644 --- a/helm/orchard/values.yaml +++ b/helm/orchard/values.yaml @@ -120,6 +120,17 @@ orchard: mode: "presigned" # presigned, redirect, or proxy presignedUrlExpiry: 3600 # Presigned URL expiry in seconds + # Authentication settings + auth: + # Option 1: Plain admin password (creates K8s secret) + adminPassword: "" + # Option 2: Use existing K8s secret (must have 'admin-password' key) + existingSecret: "" + # Option 3: AWS Secrets Manager + # secretsManager: + # enabled: false + # secretArn: "" # Secret must have 'admin_password' field + # PostgreSQL subchart configuration postgresql: enabled: true