feat: configure Gitea deployment and environment-managed WorldQuant credentials
Deploy production / deploy (push) Has been cancelled

This commit is contained in:
yuxuanhui
2026-09-09 09:58:09 +08:00
parent 20645d6d17
commit b8429efa3d
18 changed files with 430 additions and 45 deletions
+10 -1
View File
@@ -1,12 +1,16 @@
"""Deployment configuration; secrets are required and never included in API responses."""
from pathlib import Path
from cryptography.fernet import Fernet
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file="../.env", extra="ignore")
model_config = SettingsConfigDict(
env_file=Path(__file__).resolve().parents[2] / ".env", extra="ignore", hide_input_in_errors=True
)
database_url: str = "postgresql+asyncpg://wq:wq@localhost:5432/wq"
admin_username: str = "admin"
@@ -15,6 +19,8 @@ class Settings(BaseSettings):
public_origin: str = "http://localhost:8080"
cookie_secure: bool = False
session_hours: int = Field(default=24, ge=1, le=168)
wq_email: str = ""
wq_password: SecretStr = SecretStr("")
wq_base_url: str = "https://api.worldquantbrain.com"
request_timeout: float = 30
retry_attempts: int = Field(default=4, ge=1, le=8)
@@ -26,6 +32,9 @@ class Settings(BaseSettings):
@model_validator(mode="after")
def validate_secrets(self):
self.wq_email = self.wq_email.strip()
if bool(self.wq_email) != bool(self.wq_password.get_secret_value()):
raise ValueError("WQ_EMAIL and WQ_PASSWORD must be configured together")
Fernet(self.encryption_key.get_secret_value().encode())
if self.cookie_secure and not self.public_origin.startswith("https://"):
raise ValueError("COOKIE_SECURE requires an HTTPS PUBLIC_ORIGIN")
+7 -4
View File
@@ -53,7 +53,7 @@ from .schemas import (
from .security import bootstrap, cipher, issue_session, require_auth, token_hash, valid_password
def account_output(account, client):
def account_output(account, client, settings):
keys = (
"email",
"wq_user_id",
@@ -70,6 +70,7 @@ def account_output(account, client):
return {
**{k: getattr(account, k) for k in keys},
"configured": bool(account.password_encrypted),
"credentials_source": "environment" if settings.wq_email else "database",
"session": client.session_info(),
}
@@ -210,10 +211,12 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
@api.get("/account", response_model=AccountOutput, tags=["account"])
async def get_account():
async with sessions() as db:
return account_output(await db.get(Account, 1), runner.client)
return account_output(await db.get(Account, 1), runner.client, settings)
@api.put("/account/credentials", response_model=AccountOutput, tags=["account"])
async def credentials(body: CredentialsInput):
if settings.wq_email:
raise HTTPException(409, "WorldQuant 凭据由环境变量管理,请修改部署配置并重启服务")
async with sessions() as db:
account = await db.get(Account, 1)
if account.wq_user_id and account.email.casefold() != body.email.casefold():
@@ -224,7 +227,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
account.email = body.email
account.password_encrypted = cipher(settings).encrypt(body.password.encode()).decode()
await db.commit()
return account_output(account, runner.client)
return account_output(account, runner.client, settings)
@api.patch("/account/preferences", response_model=AccountOutput, tags=["account"])
async def preferences(body: PreferencesInput):
@@ -233,7 +236,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
for key, value in body.model_dump().items():
setattr(account, key, value)
await db.commit()
return account_output(account, runner.client)
return account_output(account, runner.client, settings)
async def account_job(kind):
async with sessions() as db:
+1
View File
@@ -287,6 +287,7 @@ class PlatformSessionOutput(BaseModel):
class AccountOutput(BaseModel):
email: str | None
configured: bool
credentials_source: Literal["environment", "database"]
wq_user_id: str | None
profile: dict
connection_status: str
+13 -3
View File
@@ -26,7 +26,7 @@ def cipher(settings) -> Fernet:
async def bootstrap(db, settings):
"""Only initialize missing singleton records; deployments never reset existing passwords."""
"""Initialize singletons and apply environment credentials without resetting the admin password."""
if not await db.get(Admin, 1):
db.add(
Admin(
@@ -35,8 +35,18 @@ async def bootstrap(db, settings):
password_hash=password_hasher.hash(settings.admin_password.get_secret_value()),
)
)
if not await db.get(Account, 1):
db.add(Account(id=1))
account = await db.get(Account, 1)
if account is None:
account = Account(id=1)
db.add(account)
if settings.wq_email:
# The environment must not bypass the single-account data ownership boundary.
if account.wq_user_id and (account.email or "").casefold() != settings.wq_email.casefold():
raise ValueError("WQ_EMAIL conflicts with the bound WorldQuant account")
account.email = settings.wq_email
account.password_encrypted = cipher(settings).encrypt(
settings.wq_password.get_secret_value().encode()
).decode()
await db.execute(delete(LoginSession).where(LoginSession.expires_at < now()))
await db.commit()
+66
View File
@@ -0,0 +1,66 @@
"""Environment credential precedence, rotation and account ownership boundaries."""
import pytest
from pydantic import SecretStr, ValidationError
from app.config import Settings
from app.models import Account, Admin
from app.security import bootstrap, cipher
def test_dotenv_and_process_precedence(settings, tmp_path, monkeypatch):
env = tmp_path / '.env'
env.write_text("WQ_EMAIL=local@example.com\nWQ_PASSWORD='literal-$value#password'\n")
values = settings.model_dump(exclude={'wq_email', 'wq_password'})
local = Settings(_env_file=env, **values)
assert local.wq_email == 'local@example.com'
assert local.wq_password.get_secret_value() == 'literal-$value#password'
monkeypatch.setenv('WQ_EMAIL', 'production@example.com')
monkeypatch.setenv('WQ_PASSWORD', 'production-secret')
production = Settings(_env_file=env, **values)
assert production.wq_email == 'production@example.com'
assert production.wq_password.get_secret_value() == 'production-secret'
assert 'production-secret' not in repr(production)
@pytest.mark.parametrize('email,password', [('person@example.com', ''), ('', 'private-value')])
def test_partial_configuration_fails_without_leaking(settings, email, password):
values = settings.model_dump(exclude={'wq_email', 'wq_password'})
with pytest.raises(ValidationError) as error:
Settings(_env_file=None, **values, wq_email=email, wq_password=password)
assert 'must be configured together' in str(error.value)
assert 'private-value' not in str(error.value)
assert 'person@example.com' not in str(error.value)
async def test_env_rotation_api_lock_and_bound_account(app, logged_in):
settings = app.state.settings
settings.wq_email = 'person@example.com'
settings.wq_password = SecretStr('initial-env-secret')
async with app.state.sessions() as db:
original_admin = (await db.get(Admin, 1)).password_hash
await bootstrap(db, settings)
account = await db.get(Account, 1)
assert cipher(settings).decrypt(account.password_encrypted.encode()) == b'initial-env-secret'
account.wq_user_id = 'bound-user'
await db.commit()
settings.wq_password = SecretStr('rotated-env-secret')
async with app.state.sessions() as db:
await bootstrap(db, settings)
account = await db.get(Account, 1)
assert cipher(settings).decrypt(account.password_encrypted.encode()) == b'rotated-env-secret'
assert (await db.get(Admin, 1)).password_hash == original_admin
response = await logged_in.get('/api/v1/account')
assert response.json()['credentials_source'] == 'environment'
assert response.json()['configured'] is True
assert 'secret' not in response.text and 'password' not in response.text
response = await logged_in.put('/api/v1/account/credentials', json={
'email': 'person@example.com', 'password': 'manual-secret',
})
assert response.status_code == 409
settings.wq_email = 'other@example.com'
async with app.state.sessions() as db:
with pytest.raises(ValueError, match='bound WorldQuant account'):
await bootstrap(db, settings)
await db.rollback()
assert (await db.get(Account, 1)).email == 'person@example.com'