Add configurable admin password via environment variable

This commit is contained in:
Mondo Diaz
2026-01-27 14:23:40 -06:00
parent 718e6e7193
commit 7120cf64f1
17 changed files with 308 additions and 42 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
@@ -117,6 +118,9 @@ release:
- pip install --index-url "$PIP_INDEX_URL" pytest pytest-asyncio httpx - pip install --index-url "$PIP_INDEX_URL" pytest pytest-asyncio httpx
script: script:
- cd backend - cd backend
# Debug: Print environment variables for test configuration
- echo "ORCHARD_TEST_URL=$ORCHARD_TEST_URL"
- echo "ORCHARD_TEST_PASSWORD is set to '${ORCHARD_TEST_PASSWORD:-NOT SET}'"
# Run full integration test suite, excluding: # Run full integration test suite, excluding:
# - large/slow tests # - large/slow tests
# - requires_direct_s3 tests (can't access MinIO from outside K8s cluster) # - requires_direct_s3 tests (can't access MinIO from outside K8s cluster)
@@ -196,14 +200,13 @@ release:
sys.exit(0) sys.exit(0)
PYTEST_SCRIPT PYTEST_SCRIPT
# Integration tests for stage deployment (full suite) # Reset stage template - runs from CI runner, uses CI variable for auth
# Reset stage template - shared by pre and post test reset jobs
# Calls the /api/v1/admin/factory-reset endpoint which handles DB and S3 cleanup # Calls the /api/v1/admin/factory-reset endpoint which handles DB and S3 cleanup
.reset_stage_template: &reset_stage_template .reset_stage_template: &reset_stage_template
stage: deploy stage: deploy
image: deps.global.bsf.tools/docker/python:3.12-slim image: deps.global.bsf.tools/docker/python:3.12-slim
timeout: 5m timeout: 5m
retry: 1 # Retry once on transient failures retry: 1
before_script: before_script:
- pip install --index-url "$PIP_INDEX_URL" httpx - pip install --index-url "$PIP_INDEX_URL" httpx
script: script:
@@ -216,19 +219,22 @@ release:
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 ADMIN_PASS = os.environ.get("STAGE_ADMIN_PASSWORD", "")
MAX_RETRIES = 3 MAX_RETRIES = 3
RETRY_DELAY = 5 # seconds RETRY_DELAY = 5
if not BASE_URL: if not BASE_URL:
print("ERROR: STAGE_URL environment variable not set") print("ERROR: STAGE_URL not set")
sys.exit(1)
if not ADMIN_PASS:
print("ERROR: STAGE_ADMIN_PASSWORD not set")
sys.exit(1) sys.exit(1)
print(f"=== Resetting stage environment at {BASE_URL} ===") print(f"=== Resetting stage environment at {BASE_URL} ===")
def do_reset(): def do_reset():
with httpx.Client(base_url=BASE_URL, timeout=120.0) as client: with httpx.Client(base_url=BASE_URL, timeout=120.0) as client:
# Login as admin
print("Logging in as admin...") print("Logging in as admin...")
login_response = client.post( login_response = client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
@@ -238,7 +244,6 @@ release:
raise Exception(f"Login failed: {login_response.status_code} - {login_response.text}") raise Exception(f"Login failed: {login_response.status_code} - {login_response.text}")
print("Login successful") print("Login successful")
# Call factory reset endpoint
print("Calling factory reset endpoint...") print("Calling factory reset endpoint...")
reset_response = client.post( reset_response = client.post(
"/api/v1/admin/factory-reset", "/api/v1/admin/factory-reset",
@@ -256,7 +261,6 @@ release:
else: else:
raise Exception(f"Factory reset failed: {reset_response.status_code} - {reset_response.text}") raise Exception(f"Factory reset failed: {reset_response.status_code} - {reset_response.text}")
# Retry loop
for attempt in range(1, MAX_RETRIES + 1): for attempt in range(1, MAX_RETRIES + 1):
try: try:
print(f"Attempt {attempt}/{MAX_RETRIES}") print(f"Attempt {attempt}/{MAX_RETRIES}")
@@ -280,12 +284,14 @@ reset_stage_pre:
<<: *reset_stage_template <<: *reset_stage_template
needs: [deploy_stage] needs: [deploy_stage]
# Integration tests for stage deployment (full suite) # Integration tests for stage deployment
# Uses CI variable STAGE_ADMIN_PASSWORD (set in GitLab CI/CD settings)
integration_test_stage: integration_test_stage:
<<: *integration_test_template <<: *integration_test_template
needs: [reset_stage_pre] needs: [reset_stage_pre]
variables: variables:
ORCHARD_TEST_URL: $STAGE_URL ORCHARD_TEST_URL: $STAGE_URL
ORCHARD_TEST_PASSWORD: $STAGE_ADMIN_PASSWORD
rules: rules:
- if: '$CI_COMMIT_BRANCH == "main"' - if: '$CI_COMMIT_BRANCH == "main"'
when: on_success when: on_success
@@ -297,11 +303,13 @@ reset_stage:
allow_failure: true # Don't fail pipeline if reset has issues allow_failure: true # Don't fail pipeline if reset has issues
# Integration tests for feature deployment (full suite) # Integration tests for feature deployment (full suite)
# Uses DEV_ADMIN_PASSWORD CI variable (same as deploy_feature)
integration_test_feature: integration_test_feature:
<<: *integration_test_template <<: *integration_test_template
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: $DEV_ADMIN_PASSWORD
rules: rules:
- if: '$CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != "main"' - if: '$CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != "main"'
when: on_success when: on_success
@@ -453,6 +461,7 @@ deploy_feature:
--namespace $NAMESPACE \ --namespace $NAMESPACE \
-f $VALUES_FILE \ -f $VALUES_FILE \
--set image.tag=git.linux-amd64-$CI_COMMIT_SHA \ --set image.tag=git.linux-amd64-$CI_COMMIT_SHA \
--set orchard.auth.adminPassword=$DEV_ADMIN_PASSWORD \
--set ingress.hosts[0].host=orchard-$CI_COMMIT_REF_SLUG.common.global.bsf.tools \ --set ingress.hosts[0].host=orchard-$CI_COMMIT_REF_SLUG.common.global.bsf.tools \
--set ingress.tls[0].hosts[0]=orchard-$CI_COMMIT_REF_SLUG.common.global.bsf.tools \ --set ingress.tls[0].hosts[0]=orchard-$CI_COMMIT_REF_SLUG.common.global.bsf.tools \
--set ingress.tls[0].secretName=orchard-$CI_COMMIT_REF_SLUG-tls \ --set ingress.tls[0].secretName=orchard-$CI_COMMIT_REF_SLUG-tls \

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

@@ -56,6 +56,26 @@ os.environ.setdefault("ORCHARD_S3_BUCKET", "test-bucket")
os.environ.setdefault("ORCHARD_S3_ACCESS_KEY_ID", "test") os.environ.setdefault("ORCHARD_S3_ACCESS_KEY_ID", "test")
os.environ.setdefault("ORCHARD_S3_SECRET_ACCESS_KEY", "test") os.environ.setdefault("ORCHARD_S3_SECRET_ACCESS_KEY", "test")
# =============================================================================
# Admin Credentials Helper
# =============================================================================
def get_admin_password() -> str:
"""Get the admin password for test authentication.
Returns the password from ORCHARD_TEST_PASSWORD environment variable,
or 'changeme123' as the default for local development.
"""
return os.environ.get("ORCHARD_TEST_PASSWORD", "changeme123")
def get_admin_username() -> str:
"""Get the admin username for test authentication."""
return os.environ.get("ORCHARD_TEST_USERNAME", "admin")
# Re-export factory functions for backward compatibility # Re-export factory functions for backward compatibility
from tests.factories import ( from tests.factories import (
create_test_file, create_test_file,

View File

@@ -8,6 +8,8 @@ allow these tests to run. Production uses strict rate limits (5/minute).
import pytest import pytest
from uuid import uuid4 from uuid import uuid4
from tests.conftest import get_admin_password, get_admin_username
# Mark all tests in this module as auth_intensive (informational, not excluded from CI) # Mark all tests in this module as auth_intensive (informational, not excluded from CI)
pytestmark = pytest.mark.auth_intensive pytestmark = pytest.mark.auth_intensive
@@ -21,11 +23,11 @@ class TestAuthLogin:
"""Test successful login with default admin credentials.""" """Test successful login with default admin credentials."""
response = auth_client.post( response = auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["username"] == "admin" assert data["username"] == get_admin_username()
assert data["is_admin"] is True assert data["is_admin"] is True
assert "orchard_session" in response.cookies assert "orchard_session" in response.cookies
@@ -34,7 +36,7 @@ class TestAuthLogin:
"""Test login with wrong password.""" """Test login with wrong password."""
response = auth_client.post( response = auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "wrongpassword"}, json={"username": get_admin_username(), "password": "wrongpassword"},
) )
assert response.status_code == 401 assert response.status_code == 401
assert "Invalid username or password" in response.json()["detail"] assert "Invalid username or password" in response.json()["detail"]
@@ -58,7 +60,7 @@ class TestAuthLogout:
# First login # First login
login_response = auth_client.post( login_response = auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
assert login_response.status_code == 200 assert login_response.status_code == 200
@@ -84,13 +86,13 @@ class TestAuthMe:
# Login first # Login first
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
response = auth_client.get("/api/v1/auth/me") response = auth_client.get("/api/v1/auth/me")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["username"] == "admin" assert data["username"] == get_admin_username()
assert data["is_admin"] is True assert data["is_admin"] is True
assert "id" in data assert "id" in data
assert "created_at" in data assert "created_at" in data
@@ -119,7 +121,7 @@ class TestAuthChangePassword:
# Login as admin to create a test user # Login as admin to create a test user
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
test_username = f"pwchange_{uuid4().hex[:8]}" test_username = f"pwchange_{uuid4().hex[:8]}"
auth_client.post( auth_client.post(
@@ -162,7 +164,7 @@ class TestAuthChangePassword:
# Login as admin to create a test user # Login as admin to create a test user
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
test_username = f"pwwrong_{uuid4().hex[:8]}" test_username = f"pwwrong_{uuid4().hex[:8]}"
auth_client.post( auth_client.post(
@@ -194,7 +196,7 @@ class TestAPIKeys:
# Login first # Login first
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
# Create API key # Create API key
@@ -226,7 +228,7 @@ class TestAPIKeys:
# Login and create API key # Login and create API key
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
create_response = auth_client.post( create_response = auth_client.post(
"/api/v1/auth/keys", "/api/v1/auth/keys",
@@ -242,12 +244,12 @@ class TestAPIKeys:
headers={"Authorization": f"Bearer {api_key}"}, headers={"Authorization": f"Bearer {api_key}"},
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["username"] == "admin" assert response.json()["username"] == get_admin_username()
# Clean up # Clean up
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
auth_client.delete(f"/api/v1/auth/keys/{key_id}") auth_client.delete(f"/api/v1/auth/keys/{key_id}")
@@ -257,7 +259,7 @@ class TestAPIKeys:
# Login and create API key # Login and create API key
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
create_response = auth_client.post( create_response = auth_client.post(
"/api/v1/auth/keys", "/api/v1/auth/keys",
@@ -288,14 +290,14 @@ class TestAdminUserManagement:
# Login as admin # Login as admin
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
response = auth_client.get("/api/v1/admin/users") response = auth_client.get("/api/v1/admin/users")
assert response.status_code == 200 assert response.status_code == 200
users = response.json() users = response.json()
assert len(users) >= 1 assert len(users) >= 1
assert any(u["username"] == "admin" for u in users) assert any(u["username"] == get_admin_username() for u in users)
@pytest.mark.integration @pytest.mark.integration
def test_create_user(self, auth_client): def test_create_user(self, auth_client):
@@ -303,7 +305,7 @@ class TestAdminUserManagement:
# Login as admin # Login as admin
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
# Create new user # Create new user
@@ -336,7 +338,7 @@ class TestAdminUserManagement:
# Login as admin # Login as admin
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
# Create a test user # Create a test user
@@ -362,7 +364,7 @@ class TestAdminUserManagement:
# Login as admin # Login as admin
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
# Create a test user # Create a test user
@@ -393,7 +395,7 @@ class TestAdminUserManagement:
# Login as admin and create non-admin user # Login as admin and create non-admin user
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
test_username = f"nonadmin_{uuid4().hex[:8]}" test_username = f"nonadmin_{uuid4().hex[:8]}"
auth_client.post( auth_client.post(
@@ -423,7 +425,7 @@ class TestSecurityEdgeCases:
# Login as admin and create a user # Login as admin and create a user
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
test_username = f"inactive_{uuid4().hex[:8]}" test_username = f"inactive_{uuid4().hex[:8]}"
auth_client.post( auth_client.post(
@@ -451,7 +453,7 @@ class TestSecurityEdgeCases:
"""Test that short passwords are rejected when creating users.""" """Test that short passwords are rejected when creating users."""
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
response = auth_client.post( response = auth_client.post(
@@ -467,7 +469,7 @@ class TestSecurityEdgeCases:
# Create test user # Create test user
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
test_username = f"shortchange_{uuid4().hex[:8]}" test_username = f"shortchange_{uuid4().hex[:8]}"
auth_client.post( auth_client.post(
@@ -494,7 +496,7 @@ class TestSecurityEdgeCases:
"""Test that short passwords are rejected when resetting password.""" """Test that short passwords are rejected when resetting password."""
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
# Create a test user first # Create a test user first
@@ -516,7 +518,7 @@ class TestSecurityEdgeCases:
"""Test that duplicate usernames are rejected.""" """Test that duplicate usernames are rejected."""
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
test_username = f"duplicate_{uuid4().hex[:8]}" test_username = f"duplicate_{uuid4().hex[:8]}"
@@ -541,7 +543,7 @@ class TestSecurityEdgeCases:
# Login as admin and create an API key # Login as admin and create an API key
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
create_response = auth_client.post( create_response = auth_client.post(
"/api/v1/auth/keys", "/api/v1/auth/keys",
@@ -572,7 +574,7 @@ class TestSecurityEdgeCases:
auth_client.cookies.clear() auth_client.cookies.clear()
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
auth_client.delete(f"/api/v1/auth/keys/{admin_key_id}") auth_client.delete(f"/api/v1/auth/keys/{admin_key_id}")
@@ -582,7 +584,7 @@ class TestSecurityEdgeCases:
# Create a test user # Create a test user
auth_client.post( auth_client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "changeme123"}, json={"username": get_admin_username(), "password": get_admin_password()},
) )
test_username = f"sessiontest_{uuid4().hex[:8]}" test_username = f"sessiontest_{uuid4().hex[:8]}"
auth_client.post( auth_client.post(

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,10 @@ orchard:
host: "0.0.0.0" host: "0.0.0.0"
port: 8080 port: 8080
# Authentication settings
# Admin password is set via CI variable (DEV_ADMIN_PASSWORD) passed as --set flag
# This keeps the password out of version control
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