159 lines
6.2 KiB
Python
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
|