508 lines
18 KiB
Python
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"]
|