FluxIP/tests/test_accounts_repository.py

508 lines
18 KiB
Python

from __future__ import annotations
import shutil
from datetime import timedelta
from pathlib import Path
import pytest
from app.accounts.repository import AccountRepository
from app.accounts.service import AccountService
from app.core.errors import AppError
from app.core.security import SecretCipher
from app.core.time import to_iso, utc_now
from app.database.database import Database
from app.fleet.repository import FleetRepository
from app.integrations.repository import IntegrationRepository
from app.rotation.repository import RotationRepository
MIGRATIONS_PATH = Path(__file__).resolve().parents[1] / "migrations"
@pytest.fixture
def database(tmp_path: Path) -> Database:
result = Database(tmp_path / "accounts.db", MIGRATIONS_PATH)
result.migrate()
return result
@pytest.fixture
def repository(database: Database) -> AccountRepository:
return AccountRepository(database, SecretCipher(b"a" * 32))
def create_aws_account(
repository: AccountRepository,
*,
name: str = "AWS Production",
access_key: str = "AKIA_REPOSITORY_ACCESS",
secret_key: str = "repository-secret-key",
session_token: str | None = "repository-session-token",
):
secrets = {
"aws_access_key_id": access_key,
"aws_secret_access_key": secret_key,
}
if session_token is not None:
secrets["aws_session_token"] = session_token
return repository.create_account(
provider="aws",
name=name,
use_default_aws_credentials=False,
secret_values=secrets,
)
def test_repository_crud_resolution_flags_and_encrypted_storage(
database: Database,
repository: AccountRepository,
) -> None:
aws = create_aws_account(repository)
default_aws = repository.create_account(
provider="aws",
name="AWS Instance Role",
use_default_aws_credentials=True,
secret_values={},
)
cloudflare = repository.create_account(
provider="cloudflare",
name="Cloudflare Production",
use_default_aws_credentials=None,
secret_values={"cloudflare_api_token": "cf-repository-token"},
)
assert repository.get_account(aws.id) == aws
assert {item.id for item in repository.list_accounts()} == {
aws.id,
default_aws.id,
cloudflare.id,
}
assert [item.id for item in repository.list_accounts("cloudflare")] == [cloudflare.id]
assert repository.secret_flags(aws.id) == {
"aws_access_key_id": True,
"aws_secret_access_key": True,
"aws_session_token": True,
"cloudflare_api_token": False,
}
assert repository.secret_flags(default_aws.id) == {
"aws_access_key_id": False,
"aws_secret_access_key": False,
"aws_session_token": False,
"cloudflare_api_token": False,
}
assert repository.secret_flags(cloudflare.id)["cloudflare_api_token"] is True
use_default, aws_secrets = repository.resolve_aws(aws.id)
assert use_default is False
assert aws_secrets == {
"aws_access_key_id": "AKIA_REPOSITORY_ACCESS",
"aws_secret_access_key": "repository-secret-key",
"aws_session_token": "repository-session-token",
}
assert repository.resolve_aws(default_aws.id) == (
True,
{
"aws_access_key_id": None,
"aws_secret_access_key": None,
"aws_session_token": None,
},
)
assert repository.resolve_cloudflare(cloudflare.id) == "cf-repository-token"
plaintexts = (
"AKIA_REPOSITORY_ACCESS",
"repository-secret-key",
"repository-session-token",
"cf-repository-token",
)
with database.connect() as connection:
rows = connection.execute(
"SELECT account_id, name, nonce, ciphertext FROM account_secrets"
).fetchall()
stored = " ".join(str(value) for row in rows for value in tuple(row))
assert all(secret not in stored for secret in plaintexts)
def test_account_secret_ciphertext_is_bound_to_account_aad(
database: Database,
repository: AccountRepository,
) -> None:
first = create_aws_account(repository, name="AWS First", session_token=None)
second = create_aws_account(repository, name="AWS Second", session_token=None)
with database.connect() as connection, connection:
encrypted = connection.execute(
"""
SELECT key_version, nonce, ciphertext FROM account_secrets
WHERE account_id = ? AND name = 'aws_access_key_id'
""",
(first.id,),
).fetchone()
assert encrypted is not None
connection.execute(
"""
UPDATE account_secrets
SET key_version = ?, nonce = ?, ciphertext = ?
WHERE account_id = ? AND name = 'aws_access_key_id'
""",
(
encrypted["key_version"],
encrypted["nonce"],
encrypted["ciphertext"],
second.id,
),
)
with pytest.raises(AppError) as exc_info:
repository.resolve_aws(second.id)
assert exc_info.value.code == "SECRET_DECRYPTION_FAILED"
def test_repository_update_cas_replaces_pair_clears_session_and_soft_deletes(
repository: AccountRepository,
) -> None:
account = create_aws_account(repository)
updated = repository.update_account(
account.id,
expected_version=account.config_version,
values={"name": "AWS Renamed"},
secret_values={
"aws_access_key_id": "AKIA_REPLACED_ACCESS",
"aws_secret_access_key": "replaced-secret-key",
},
delete_secret_names=("aws_session_token",),
)
assert updated.name == "AWS Renamed"
assert updated.config_version == account.config_version + 1
assert repository.resolve_aws(account.id) == (
False,
{
"aws_access_key_id": "AKIA_REPLACED_ACCESS",
"aws_secret_access_key": "replaced-secret-key",
"aws_session_token": None,
},
)
with pytest.raises(RuntimeError, match="ACCOUNT_CONFIG_VERSION_CONFLICT"):
repository.update_account(
account.id,
expected_version=account.config_version,
values={"name": "Stale rename"},
secret_values={},
)
archived = repository.archive_account(account.id)
assert archived.archived_at is not None
assert archived.config_version == updated.config_version + 1
assert repository.get_account(account.id) is None
assert repository.list_accounts() == []
assert repository.get_account(account.id, include_archived=True) == archived
assert not any(repository.secret_flags(account.id).values())
def test_provider_validation_and_active_instance_prevent_archive(
repository: AccountRepository,
database: Database,
) -> None:
aws = create_aws_account(repository)
cloudflare = repository.create_account(
provider="cloudflare",
name="Cloudflare Bound",
use_default_aws_credentials=None,
secret_values={"cloudflare_api_token": "cf-bound-token"},
)
with pytest.raises(RuntimeError, match="ACCOUNT_PROVIDER_MISMATCH"):
repository.resolve_cloudflare(aws.id)
with pytest.raises(RuntimeError, match="ACCOUNT_PROVIDER_MISMATCH"):
repository.resolve_aws(cloudflare.id)
with pytest.raises(RuntimeError, match="ACCOUNT_PROVIDER_MISMATCH"):
repository.create_account(
provider="aws",
name="Wrong secret type",
use_default_aws_credentials=False,
secret_values={"cloudflare_api_token": "wrong-provider-token"},
)
instance = FleetRepository(database).create_instance(
{
"id": "account-bound-instance",
"aws_account_id": aws.id,
"cloudflare_account_id": cloudflare.id,
"display_name": "Account bound proxy",
"aws_region": "ap-southeast-1",
"lightsail_instance_name": "account-bound-node",
"cloudflare_zone_name": "example.com",
"cloudflare_zone_id": "zone-account-bound",
"cloudflare_record_name": "account-bound.example.com",
"socks_port": 1080,
"proxy_health_check": True,
"health_timeout_seconds": 120,
"release_grace_seconds": 75,
"enabled": True,
}
)
with pytest.raises(RuntimeError, match="ACCOUNT_IN_USE"):
repository.archive_account(aws.id)
with pytest.raises(RuntimeError, match="ACCOUNT_IN_USE"):
repository.archive_account(cloudflare.id)
FleetRepository(database).archive_instance(instance.id)
assert repository.archive_account(aws.id).archived_at is not None
assert repository.archive_account(cloudflare.id).archived_at is not None
def test_active_fleet_run_and_dns_lock_block_account_writes(
database: Database,
repository: AccountRepository,
) -> None:
account = create_aws_account(repository)
now = to_iso()
with database.connect() as connection, connection:
connection.execute(
"""
INSERT INTO fleet_runs(
id, target_type, target_id, target_name, trigger, status,
active_slot, total_items, succeeded_items, started_at, updated_at
) VALUES (
'account-lock-run', 'instance', 'target', 'Target', 'manual',
'running', 1, 0, 0, ?, ?
)
""",
(now, now),
)
blocked_by_run = (
lambda: create_aws_account(repository, name="Blocked create"),
lambda: repository.update_account(
account.id,
expected_version=account.config_version,
values={"name": "Blocked update"},
secret_values={},
),
lambda: repository.archive_account(account.id),
)
for write in blocked_by_run:
with pytest.raises(RuntimeError, match="FLEET_RUN_ACTIVE"):
write()
with database.connect() as connection, connection:
connection.execute("DELETE FROM fleet_runs WHERE id = 'account-lock-run'")
connection.execute(
"""
INSERT INTO fleet_operation_locks(
slot, kind, owner_id, lease_until, created_at
) VALUES (1, 'dns_sync', 'account-dns-lock', ?, ?)
""",
(to_iso(utc_now() + timedelta(minutes=5)), now),
)
with pytest.raises(RuntimeError, match="FLEET_RUN_ACTIVE"):
repository.update_account(
account.id,
expected_version=account.config_version,
values={"name": "Still blocked"},
secret_values={},
)
with database.connect() as connection, connection:
connection.execute(
"UPDATE fleet_operation_locks SET lease_until = ? WHERE owner_id = ?",
(to_iso(utc_now() - timedelta(seconds=1)), "account-dns-lock"),
)
created = create_aws_account(repository, name="Created after lock expiry")
assert created.name == "Created after lock expiry"
with database.connect() as connection:
assert (
connection.execute(
"SELECT 1 FROM fleet_operation_locks WHERE owner_id = 'account-dns-lock'"
).fetchone()
is None
)
def test_legacy_global_credentials_are_imported_and_bound_idempotently(
tmp_path: Path,
) -> None:
staged_migrations = tmp_path / "migrations"
staged_migrations.mkdir()
for name in (
"001_initial.sql",
"002_operation_locks.sql",
"003_multi_instance_static_ip.sql",
"004_rotation_rollback.sql",
):
shutil.copy2(MIGRATIONS_PATH / name, staged_migrations)
database = Database(tmp_path / "legacy-accounts.db", staged_migrations)
database.migrate()
cipher = SecretCipher(b"l" * 32)
integrations = IntegrationRepository(database, cipher)
integrations.save(
False,
{
"aws_access_key_id": "AKIA_LEGACY_IMPORT",
"aws_secret_access_key": "legacy-import-secret",
"aws_session_token": "legacy-import-session",
"cloudflare_api_token": "legacy-import-cloudflare",
},
)
now = to_iso()
with database.connect() as connection, connection:
connection.execute(
"""
INSERT INTO managed_instances(
id, display_name, aws_region, lightsail_instance_name,
cloudflare_zone_name, cloudflare_zone_id, cloudflare_record_name,
socks_port, proxy_health_check, health_timeout_seconds,
release_grace_seconds, enabled, config_version, created_at, updated_at
) VALUES (
'legacy-account-instance', 'Legacy account instance', 'us-east-1',
'legacy-account-node', 'example.com', 'legacy-zone',
'legacy-account.example.com', 1080, 1, 120, 75, 1, 1, ?, ?
)
""",
(now, now),
)
shutil.copy2(MIGRATIONS_PATH / "005_credential_accounts.sql", staged_migrations)
database.migrate()
accounts = AccountRepository(database, cipher)
service = AccountService(accounts)
first = service.import_legacy_accounts(integrations)
second = service.import_legacy_accounts(integrations)
assert (
first
== second
== {
"aws": "legacy-aws",
"cloudflare": "legacy-cloudflare",
}
)
assert accounts.resolve_aws("legacy-aws") == (
False,
{
"aws_access_key_id": "AKIA_LEGACY_IMPORT",
"aws_secret_access_key": "legacy-import-secret",
"aws_session_token": "legacy-import-session",
},
)
assert accounts.resolve_cloudflare("legacy-cloudflare") == "legacy-import-cloudflare"
with database.connect() as connection:
instance = connection.execute(
"""
SELECT aws_account_id, cloudflare_account_id
FROM managed_instances WHERE id = 'legacy-account-instance'
"""
).fetchone()
counts = connection.execute(
"""
SELECT
(SELECT COUNT(*) FROM credential_accounts) AS accounts,
(SELECT COUNT(*) FROM account_secrets) AS secrets
"""
).fetchone()
assert dict(instance) == {
"aws_account_id": "legacy-aws",
"cloudflare_account_id": "legacy-cloudflare",
}
assert dict(counts) == {"accounts": 2, "secrets": 4}
def test_attention_run_allows_only_current_account_secret_recovery(
database: Database,
repository: AccountRepository,
) -> None:
aws = create_aws_account(repository, name="AWS Recovery")
other_aws = create_aws_account(repository, name="AWS Other")
cloudflare = repository.create_account(
provider="cloudflare",
name="Cloudflare Recovery",
use_default_aws_credentials=None,
secret_values={"cloudflare_api_token": "cf-recovery-token"},
)
instance = FleetRepository(database).create_instance(
{
"id": "recovery-account-instance",
"aws_account_id": aws.id,
"cloudflare_account_id": cloudflare.id,
"display_name": "Recovery account instance",
"aws_region": "us-east-1",
"lightsail_instance_name": "recovery-account-node",
"cloudflare_zone_name": "example.com",
"cloudflare_zone_id": "recovery-zone",
"cloudflare_record_name": "recovery-account.example.com",
"socks_port": 1080,
"proxy_health_check": True,
"health_timeout_seconds": 120,
"release_grace_seconds": 75,
"enabled": True,
}
)
run = RotationRepository(database).create_for_instance(instance.id)
with database.connect() as connection, connection:
connection.execute(
"UPDATE fleet_runs SET status = 'needs_attention' WHERE id = ?",
(run.id,),
)
connection.execute(
"""
UPDATE fleet_run_items SET status = 'needs_attention'
WHERE run_id = ?
""",
(run.id,),
)
recovered = repository.update_account(
aws.id,
expected_version=aws.config_version,
values={
"name": aws.name,
"use_default_aws_credentials": False,
},
secret_values={
"aws_access_key_id": "AKIA_RECOVERED_ACCESS",
"aws_secret_access_key": "recovered-secret-key",
},
delete_secret_names=("aws_session_token",),
)
assert recovered.config_version == aws.config_version + 1
assert repository.resolve_aws(aws.id)[1] == {
"aws_access_key_id": "AKIA_RECOVERED_ACCESS",
"aws_secret_access_key": "recovered-secret-key",
"aws_session_token": None,
}
with pytest.raises(RuntimeError, match="FLEET_RUN_ACTIVE"):
repository.update_account(
other_aws.id,
expected_version=other_aws.config_version,
values={},
secret_values={
"aws_access_key_id": "AKIA_OTHER_REPLACED",
"aws_secret_access_key": "other-replaced-secret",
},
)
with pytest.raises(RuntimeError, match="FLEET_RUN_ACTIVE"):
repository.update_account(
aws.id,
expected_version=recovered.config_version,
values={"name": "Renamed during recovery"},
secret_values={"aws_session_token": "new-session-token"},
)
with database.connect() as connection:
event = connection.execute(
"""
SELECT message, details_json FROM fleet_events
WHERE run_id = ? AND stage = 'credentials'
""",
(run.id,),
).fetchone()
assert event["message"] == "管理员已更新当前账号的恢复凭据"
assert "recovered-secret-key" not in event["details_json"]
assert "aws_secret_access_key" in event["details_json"]