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)
This commit is contained in:
Mondo Diaz
2026-01-27 17:22:37 +00:00
parent 718e6e7193
commit 347183aeac
15 changed files with 275 additions and 7 deletions

7
.env.example Normal file
View File

@@ -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

View File

@@ -15,6 +15,7 @@ variables:
STAGE_RDS_HOST: orchard-stage.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com STAGE_RDS_HOST: orchard-stage.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com
STAGE_RDS_DBNAME: postgres 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_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 STAGE_S3_BUCKET: orchard-artifacts-stage
AWS_REGION: us-gov-west-1 AWS_REGION: us-gov-west-1
# Shared pip cache directory # Shared pip cache directory
@@ -205,7 +206,7 @@ release:
timeout: 5m timeout: 5m
retry: 1 # Retry once on transient failures retry: 1 # Retry once on transient failures
before_script: before_script:
- pip install --index-url "$PIP_INDEX_URL" httpx - pip install --index-url "$PIP_INDEX_URL" httpx boto3
script: script:
- | - |
python - <<'RESET_SCRIPT' python - <<'RESET_SCRIPT'
@@ -213,13 +214,30 @@ release:
import sys import sys
import os import os
import time import time
import json
import boto3
BASE_URL = os.environ.get("STAGE_URL", "") BASE_URL = os.environ.get("STAGE_URL", "")
ADMIN_USER = "admin" ADMIN_USER = "admin"
ADMIN_PASS = "changeme123" # Default admin password
MAX_RETRIES = 3 MAX_RETRIES = 3
RETRY_DELAY = 5 # seconds RETRY_DELAY = 5 # seconds
# Fetch admin password from AWS Secrets Manager
secret_arn = os.environ.get("STAGE_AUTH_SECRET_ARN", "")
if not secret_arn:
print("ERROR: STAGE_AUTH_SECRET_ARN environment variable not set")
sys.exit(1)
try:
client = boto3.client('secretsmanager', region_name=os.environ.get("AWS_REGION", "us-gov-west-1"))
secret = client.get_secret_value(SecretId=secret_arn)
data = json.loads(secret['SecretString'])
ADMIN_PASS = data['admin_password']
print("Successfully fetched admin password from Secrets Manager")
except Exception as e:
print(f"ERROR: Failed to fetch secret: {e}")
sys.exit(1)
if not BASE_URL: if not BASE_URL:
print("ERROR: STAGE_URL environment variable not set") print("ERROR: STAGE_URL environment variable not set")
sys.exit(1) sys.exit(1)
@@ -286,6 +304,19 @@ integration_test_stage:
needs: [reset_stage_pre] needs: [reset_stage_pre]
variables: variables:
ORCHARD_TEST_URL: $STAGE_URL ORCHARD_TEST_URL: $STAGE_URL
before_script:
- pip install --index-url "$PIP_INDEX_URL" -r backend/requirements.txt
- pip install --index-url "$PIP_INDEX_URL" pytest pytest-asyncio httpx boto3
# Fetch admin password from AWS Secrets Manager
- |
export ORCHARD_TEST_PASSWORD=$(python -c "
import boto3
import json
client = boto3.client('secretsmanager', region_name='$AWS_REGION')
secret = client.get_secret_value(SecretId='$STAGE_AUTH_SECRET_ARN')
data = json.loads(secret['SecretString'])
print(data['admin_password'])
")
rules: rules:
- if: '$CI_COMMIT_BRANCH == "main"' - if: '$CI_COMMIT_BRANCH == "main"'
when: on_success when: on_success
@@ -302,6 +333,7 @@ integration_test_feature:
needs: [deploy_feature] needs: [deploy_feature]
variables: variables:
ORCHARD_TEST_URL: https://orchard-$CI_COMMIT_REF_SLUG.common.global.bsf.tools 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: rules:
- if: '$CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != "main"' - if: '$CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != "main"'
when: on_success when: on_success

View File

@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### 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) - Added Project Settings page accessible to project admins (#65)
- General settings section for editing description and visibility - General settings section for editing description and visibility
- Access Management section (moved from project page) - Access Management section (moved from project page)

View File

@@ -360,21 +360,36 @@ def create_default_admin(db: Session) -> Optional[User]:
"""Create the default admin user if no users exist. """Create the default admin user if no users exist.
Returns the created user, or None if users already 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 # Check if any users exist
user_count = db.query(User).count() user_count = db.query(User).count()
if user_count > 0: if user_count > 0:
return None 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 # Create default admin
auth_service = AuthService(db) auth_service = AuthService(db)
admin = auth_service.create_user( admin = auth_service.create_user(
username="admin", username="admin",
password="changeme123", password=password,
is_admin=True, 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 return admin

View File

@@ -53,6 +53,9 @@ class Settings(BaseSettings):
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
log_format: str = "auto" # "json", "standard", or "auto" (json in production) 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 Authentication settings (optional, for external identity providers)
jwt_enabled: bool = False # Enable JWT token validation jwt_enabled: bool = False # Enable JWT token validation
jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS

View File

@@ -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

View File

@@ -26,6 +26,8 @@ services:
- ORCHARD_REDIS_PORT=6379 - ORCHARD_REDIS_PORT=6379
# Higher rate limit for local development/testing # Higher rate limit for local development/testing
- ORCHARD_LOGIN_RATE_LIMIT=1000/minute - 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: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View File

@@ -141,3 +141,16 @@ MinIO secret name
{{- printf "%s-s3-secret" (include "orchard.fullname" .) }} {{- printf "%s-s3-secret" (include "orchard.fullname" .) }}
{{- end }} {{- end }}
{{- 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 }}

View File

@@ -128,20 +128,37 @@ spec:
value: {{ .Values.orchard.rateLimit.login | quote }} value: {{ .Values.orchard.rateLimit.login | quote }}
{{- end }} {{- end }}
{{- 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: volumeMounts:
{{- if and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled }}
- name: db-secrets - name: db-secrets
mountPath: /mnt/secrets-store mountPath: /mnt/secrets-store/db
readOnly: true readOnly: true
{{- end }} {{- 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: livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }} {{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe: readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }} {{- toYaml .Values.readinessProbe | nindent 12 }}
resources: resources:
{{- toYaml .Values.resources | nindent 12 }} {{- 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: volumes:
{{- if and .Values.orchard.database.secretsManager .Values.orchard.database.secretsManager.enabled }}
- name: db-secrets - name: db-secrets
csi: csi:
driver: secrets-store.csi.k8s.io driver: secrets-store.csi.k8s.io
@@ -149,6 +166,15 @@ spec:
volumeAttributes: volumeAttributes:
secretProviderClass: {{ include "orchard.fullname" . }}-db-secret secretProviderClass: {{ include "orchard.fullname" . }}-db-secret
{{- end }} {{- 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 }} {{- with .Values.nodeSelector }}
nodeSelector: nodeSelector:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}

View File

@@ -25,3 +25,27 @@ spec:
- objectName: db-password - objectName: db-password
key: password key: password
{{- end }} {{- 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 }}

View File

@@ -22,3 +22,15 @@ data:
access-key-id: {{ .Values.orchard.s3.accessKeyId | b64enc | quote }} access-key-id: {{ .Values.orchard.s3.accessKeyId | b64enc | quote }}
secret-access-key: {{ .Values.orchard.s3.secretAccessKey | b64enc | quote }} secret-access-key: {{ .Values.orchard.s3.secretAccessKey | b64enc | quote }}
{{- end }} {{- 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 }}

View File

@@ -90,6 +90,11 @@ orchard:
host: "0.0.0.0" host: "0.0.0.0"
port: 8080 port: 8080
# Authentication settings
auth:
# Plain admin password for ephemeral feature environments
adminPassword: "orchardtest123"
database: database:
host: "" host: ""
port: 5432 port: 5432

View File

@@ -93,6 +93,13 @@ orchard:
host: "0.0.0.0" host: "0.0.0.0"
port: 8080 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 configuration - uses AWS Secrets Manager via CSI driver
database: database:
host: "orchard-prd.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com" host: "orchard-prd.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com"

View File

@@ -95,6 +95,13 @@ orchard:
host: "0.0.0.0" host: "0.0.0.0"
port: 8080 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 configuration - uses AWS Secrets Manager via CSI driver
database: database:
host: "orchard-stage.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com" host: "orchard-stage.cluster-cvw3jzjkozoc.us-gov-west-1.rds.amazonaws.com"

View File

@@ -120,6 +120,17 @@ orchard:
mode: "presigned" # presigned, redirect, or proxy mode: "presigned" # presigned, redirect, or proxy
presignedUrlExpiry: 3600 # Presigned URL expiry in seconds 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 subchart configuration
postgresql: postgresql:
enabled: true enabled: true