FluxIP/app/integrations/repository.py

159 lines
6.2 KiB
Python

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