feat: configure Gitea deployment and environment-managed WorldQuant credentials
Deploy production / deploy (push) Has been cancelled
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
+10
-1
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user