from __future__ import annotations import json from dataclasses import dataclass from app.core.security import EncryptedValue, SecretCipher from app.core.time import to_iso from app.database.database import Database SECRET_NAMES = ( "aws_access_key_id", "aws_secret_access_key", "aws_session_token", "cloudflare_api_token", ) @dataclass(slots=True) class IntegrationSettingsRecord: config_version: int use_default_aws_credentials: bool class IntegrationRepository: def __init__(self, database: Database, cipher: SecretCipher) -> None: self.database = database self.cipher = cipher def get(self) -> IntegrationSettingsRecord: with self.database.connect() as connection: row = connection.execute( """ SELECT config_version, use_default_aws_credentials FROM app_settings WHERE id = 1 """ ).fetchone() return IntegrationSettingsRecord( config_version=int(row["config_version"]), use_default_aws_credentials=bool(row["use_default_aws_credentials"]), ) def secret_flags(self) -> dict[str, bool]: with self.database.connect() as connection: rows = connection.execute("SELECT name FROM secrets").fetchall() configured = {str(row["name"]) for row in rows} return {name: name in configured for name in SECRET_NAMES} def get_secret(self, name: str) -> str | None: with self.database.connect() as connection: row = connection.execute( "SELECT key_version, nonce, ciphertext FROM secrets WHERE name = ?", (name,), ).fetchone() if row is None: return None return self.cipher.decrypt(name, EncryptedValue(**dict(row))) def get_secrets(self) -> dict[str, str | None]: return {name: self.get_secret(name) for name in SECRET_NAMES} def save( self, use_default_aws_credentials: bool, secret_values: dict[str, str | None], ) -> int: now = to_iso() supplied = { name: plaintext.strip() for name, plaintext in secret_values.items() if name in SECRET_NAMES and plaintext is not None and plaintext.strip() } with self.database.connect() as connection: try: connection.execute("BEGIN IMMEDIATE") current = connection.execute( """ SELECT config_version, use_default_aws_credentials FROM app_settings WHERE id = 1 """ ).fetchone() active = connection.execute( """ SELECT id, status, current_item_id FROM fleet_runs WHERE active_slot = 1 """ ).fetchone() recovery_update = active is not None if active is not None: if active["status"] not in {"needs_attention", "cleanup_pending"}: raise RuntimeError("ACTIVE_ROTATION") if bool(current["use_default_aws_credentials"]) != bool( use_default_aws_credentials ): raise RuntimeError("ACTIVE_ROTATION_CONFIG_CHANGE") if not supplied: raise RuntimeError("RECOVERY_SECRET_REQUIRED") if not recovery_update: connection.execute( """ UPDATE app_settings SET config_version = config_version + 1, use_default_aws_credentials = ?, updated_at = ? WHERE id = 1 """, (int(use_default_aws_credentials), now), ) if "aws_access_key_id" in supplied and "aws_session_token" not in supplied: connection.execute( "DELETE FROM secrets WHERE name = 'aws_session_token'" ) for name, plaintext in supplied.items(): encrypted = self.cipher.encrypt(name, plaintext) connection.execute( """ INSERT INTO secrets( name, key_version, nonce, ciphertext, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(name) DO UPDATE SET key_version = excluded.key_version, nonce = excluded.nonce, ciphertext = excluded.ciphertext, updated_at = excluded.updated_at """, ( name, encrypted.key_version, encrypted.nonce, encrypted.ciphertext, now, now, ), ) if recovery_update: connection.execute( """ INSERT INTO fleet_events( run_id, item_id, occurred_at, stage, level, message, details_json ) VALUES (?, ?, ?, 'credentials', 'info', ?, ?) """, ( active["id"], active["current_item_id"], now, "管理员已更新恢复凭据", json.dumps( {"updated_credentials": sorted(supplied)}, ensure_ascii=False, ), ), ) version = connection.execute( "SELECT config_version FROM app_settings WHERE id = 1" ).fetchone()["config_version"] connection.commit() return int(version) except Exception: connection.rollback() raise