392 lines
16 KiB
Python
392 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.skipif(os.name != 'posix', reason='Linux controller ownership/flock contract')
|
|
|
|
|
|
@pytest.fixture
|
|
def binding(tmp_path, monkeypatch):
|
|
path = Path(__file__).resolve().parents[2] / 'ops/gitea-deploy-broker/runner_statistics_binding.py'
|
|
spec = importlib.util.spec_from_file_location('binding_test', path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
anchor = tmp_path / 'controller-anchor'
|
|
state = anchor / 'state'
|
|
(state / 'locks').mkdir(parents=True)
|
|
source = tmp_path / 'brain'
|
|
source.mkdir()
|
|
data = tmp_path / 'appops'
|
|
data.mkdir()
|
|
monkeypatch.setattr(module, 'ROOT_UID', os.getuid())
|
|
if os.geteuid() != 0:
|
|
# The production controller is root. Keep portable unit coverage for
|
|
# non-root Linux runs while root-only tests assert 100:101.
|
|
monkeypatch.setattr(module, 'APPOPS_UID', os.geteuid())
|
|
monkeypatch.setattr(module, 'APPOPS_GID', os.getegid())
|
|
for key, value in {'STATE_ROOT': state, 'BINDING_ROOT': state / 'runner-statistics-binding',
|
|
'PB_SOURCE': source, 'PB_ENV': source / '.env', 'APPOPS_DATA': data,
|
|
'TOKEN_FILE': data / 'runner-statistics-token',
|
|
'ADMIN_TOKEN_FILE': data / 'mcp-token', 'GLOBAL_LOCK': anchor / 'deploy.lock'}.items():
|
|
monkeypatch.setattr(module, key, value)
|
|
module.PB_ENV.write_bytes(b'EXISTING_PRIVATE_VALUE=keep-me\nPROJECTBRAIN_APPOPS_MCP_URL=\nPROJECTBRAIN_APPOPS_MCP_TOKEN=\n')
|
|
module.PB_ENV.chmod(0o600)
|
|
module.ADMIN_TOKEN_FILE.write_text('admin-capability-separate-secret\n')
|
|
module.ADMIN_TOKEN_FILE.chmod(0o600)
|
|
module.project = {'jens/projectbrain': {'branch': 'main', 'slug': 'projectbrain',
|
|
'source_dir': str(source), 'containers': ['projectbrain'],
|
|
'deploy_script': 'scripts/deploy-unraid-single.sh',
|
|
'validation': {'required': True, 'contexts': ['Managed validation / full (push)']}}}
|
|
return module
|
|
|
|
|
|
def invoke(binding):
|
|
return binding.configure(revision='a' * 40, projects=binding.project)
|
|
|
|
|
|
def test_configuration_is_separate_private_and_idempotent(binding):
|
|
before = binding.PB_ENV.read_bytes()
|
|
result = invoke(binding)
|
|
assert result['status'] == 'configured'
|
|
assert result['projectbrainDeliveryRequired'] is True
|
|
token = binding.TOKEN_FILE.read_text().strip()
|
|
assert len(token) == 64
|
|
assert token != binding.ADMIN_TOKEN_FILE.read_text().strip()
|
|
assert token not in json.dumps(result)
|
|
assert binding._configured_values(binding.PB_ENV.read_bytes()) == {
|
|
binding.URL_KEY: binding.ENDPOINT, binding.TOKEN_KEY: token}
|
|
assert b'EXISTING_PRIVATE_VALUE=keep-me\n' in binding.PB_ENV.read_bytes()
|
|
assert (binding.BINDING_ROOT / 'environment-before').read_bytes() == before
|
|
for path in (binding.PB_ENV, binding.TOKEN_FILE, binding.BINDING_ROOT / 'receipt.json'):
|
|
assert path.stat().st_mode & 0o777 == 0o600
|
|
token_info = binding.TOKEN_FILE.stat()
|
|
assert (token_info.st_uid, token_info.st_gid) == (binding.APPOPS_UID, binding.APPOPS_GID)
|
|
assert token_info.st_nlink == 1
|
|
assert invoke(binding)['status'] == 'already_configured'
|
|
|
|
|
|
@pytest.mark.skipif(os.name != 'posix' or os.geteuid() != 0,
|
|
reason='fixed AppOps ownership requires a privileged Linux controller')
|
|
def test_statistics_token_is_appops_readable_while_binding_state_stays_root_private(binding):
|
|
assert os.geteuid() != 100
|
|
|
|
result = invoke(binding)
|
|
|
|
token_info = binding.TOKEN_FILE.stat()
|
|
assert (token_info.st_uid, token_info.st_gid) == (100, 101)
|
|
assert token_info.st_mode & 0o777 == 0o600
|
|
assert token_info.st_nlink == 1
|
|
|
|
assert len(binding.TOKEN_FILE.read_text().strip()) == 64
|
|
assert result['status'] == 'configured'
|
|
for path in (binding.PB_ENV, binding.BINDING_ROOT / 'receipt.json',
|
|
binding.BINDING_ROOT / 'environment-before'):
|
|
info = path.stat()
|
|
assert info.st_uid == binding.ROOT_UID
|
|
assert info.st_mode & 0o777 == 0o600
|
|
assert info.st_nlink == 1
|
|
assert invoke(binding)['status'] == 'already_configured'
|
|
token_info = binding.TOKEN_FILE.stat()
|
|
assert (token_info.st_uid, token_info.st_gid) == (100, 101)
|
|
assert token_info.st_mode & 0o777 == 0o600
|
|
assert token_info.st_nlink == 1
|
|
|
|
|
|
@pytest.mark.skipif(os.name != 'posix' or os.geteuid() != 0,
|
|
reason='dropping to the fixed AppOps identity requires a privileged Linux controller')
|
|
def test_private_secret_reader_accepts_statistics_token_as_dropped_appops_identity(binding):
|
|
invoke(binding)
|
|
repository = Path(__file__).resolve().parents[2]
|
|
code = (
|
|
'import sys; from pathlib import Path; '
|
|
'sys.path.insert(0, sys.argv[2]); '
|
|
'from backend.app.core.config import _private_secret_file; '
|
|
'value = _private_secret_file(sys.argv[1]); '
|
|
'raise SystemExit(0 if value is not None and len(value) == 64 else 1)'
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory(prefix='appops-statistics-reader-', dir='/tmp') as directory:
|
|
os.chmod(directory, 0o711)
|
|
token_copy = Path(directory) / 'runner-statistics-token'
|
|
token_copy.write_bytes(binding.TOKEN_FILE.read_bytes())
|
|
source_info = binding.TOKEN_FILE.stat()
|
|
os.chown(token_copy, source_info.st_uid, source_info.st_gid)
|
|
token_copy.chmod(source_info.st_mode & 0o777)
|
|
|
|
def drop_to_appops():
|
|
os.setgroups([])
|
|
os.setgid(101)
|
|
os.setuid(100)
|
|
|
|
result = subprocess.run(
|
|
['/usr/bin/python3', '-c', code, str(token_copy), str(repository)],
|
|
cwd=directory, preexec_fn=drop_to_appops,
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
token = binding.TOKEN_FILE.read_text().strip()
|
|
assert token not in result.stdout and token not in result.stderr
|
|
assert result.stdout == ''
|
|
assert result.returncode == 0, (
|
|
'AppOps config private-secret reader rejected the fixed token: '
|
|
+ result.stderr[-500:]
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(os.name != 'posix' or os.geteuid() != 0,
|
|
reason='wrong-owner validation requires a privileged Linux controller')
|
|
def test_configured_root_owned_statistics_token_fails_closed(binding):
|
|
assert invoke(binding)['status'] == 'configured'
|
|
token = binding.TOKEN_FILE.read_bytes()
|
|
os.chown(binding.TOKEN_FILE, binding.ROOT_UID, os.getgid())
|
|
|
|
with pytest.raises(binding.StatisticsBindingError, match='file_untrusted'):
|
|
invoke(binding)
|
|
assert binding.TOKEN_FILE.read_bytes() == token
|
|
info = binding.TOKEN_FILE.stat()
|
|
assert (info.st_uid, info.st_gid) == (binding.ROOT_UID, os.getgid())
|
|
|
|
|
|
@pytest.mark.parametrize('key', ['source_dir', 'deploy_script', 'containers', 'validation'])
|
|
def test_unknown_project_configuration_is_rejected_before_write(binding, key):
|
|
binding.project['jens/projectbrain'][key] = 'untrusted'
|
|
with pytest.raises(binding.StatisticsBindingError):
|
|
invoke(binding)
|
|
assert not binding.TOKEN_FILE.exists()
|
|
assert not binding.BINDING_ROOT.exists()
|
|
|
|
|
|
def test_existing_operator_configuration_is_preserved(binding):
|
|
before = b'PROJECTBRAIN_APPOPS_MCP_URL=https://operator.example/mcp\nOTHER=preserve\n'
|
|
binding.PB_ENV.write_bytes(before)
|
|
with pytest.raises(binding.StatisticsBindingError, match='existing_operator_configuration'):
|
|
invoke(binding)
|
|
assert binding.PB_ENV.read_bytes() == before
|
|
assert not binding.TOKEN_FILE.exists()
|
|
|
|
|
|
def test_existing_operator_token_is_never_adopted(binding):
|
|
binding.TOKEN_FILE.write_text('existing-operator-secret')
|
|
binding.TOKEN_FILE.chmod(0o600)
|
|
os.chown(binding.TOKEN_FILE, binding.APPOPS_UID, binding.APPOPS_GID)
|
|
with pytest.raises(binding.StatisticsBindingError, match='existing_operator_credential'):
|
|
invoke(binding)
|
|
assert binding.TOKEN_FILE.read_text() == 'existing-operator-secret'
|
|
|
|
|
|
@pytest.mark.parametrize('fault_path', ['token', 'environment', 'receipt'])
|
|
def test_crash_after_each_durable_boundary_recovers_same_credential(binding, monkeypatch, fault_path):
|
|
atomic = binding._atomic
|
|
interrupted = False
|
|
|
|
def crash(path, value, expected):
|
|
nonlocal interrupted
|
|
atomic(path, value, expected)
|
|
selected = {'token': binding.TOKEN_FILE, 'environment': binding.PB_ENV,
|
|
'receipt': binding.BINDING_ROOT / 'receipt.json'}[fault_path]
|
|
if path == selected and not interrupted:
|
|
interrupted = True
|
|
raise OSError('simulated interruption after fsync')
|
|
|
|
monkeypatch.setattr(binding, '_atomic', crash)
|
|
with pytest.raises(OSError):
|
|
invoke(binding)
|
|
journal = json.loads((binding.BINDING_ROOT / 'receipt.json').read_bytes())
|
|
expected = journal['token']
|
|
monkeypatch.setattr(binding, '_atomic', atomic)
|
|
assert invoke(binding)['status'] == 'configured'
|
|
assert binding.TOKEN_FILE.read_text().strip() == expected
|
|
|
|
|
|
@pytest.mark.skipif(os.name != 'posix' or os.geteuid() != 0,
|
|
reason='simulated ownership failure requires a privileged Linux controller')
|
|
def test_statistics_token_owner_failure_closes_fd_and_never_publishes(binding, monkeypatch, capsys):
|
|
opened_token_fds = []
|
|
|
|
def fail_chown(fd, uid, gid):
|
|
opened_token_fds.append(fd)
|
|
raise PermissionError('simulated AppOps ownership failure')
|
|
|
|
monkeypatch.setattr(binding.os, 'fchown', fail_chown)
|
|
with pytest.raises(PermissionError):
|
|
invoke(binding)
|
|
|
|
assert len(opened_token_fds) == 1
|
|
with pytest.raises(OSError):
|
|
binding.os.fstat(opened_token_fds[0])
|
|
assert not binding.TOKEN_FILE.exists()
|
|
assert [path.name for path in binding.APPOPS_DATA.iterdir()] == ['mcp-token']
|
|
assert capsys.readouterr().out == ''
|
|
|
|
|
|
def test_crash_after_backup_before_receipt_has_safe_retry(binding, monkeypatch):
|
|
atomic = binding._atomic
|
|
|
|
def crash(path, value, expected):
|
|
atomic(path, value, expected)
|
|
if path.name == 'environment-before':
|
|
raise OSError('interruption after backup')
|
|
|
|
monkeypatch.setattr(binding, '_atomic', crash)
|
|
with pytest.raises(OSError):
|
|
invoke(binding)
|
|
monkeypatch.setattr(binding, '_atomic', atomic)
|
|
assert invoke(binding)['status'] == 'configured'
|
|
|
|
|
|
def test_pending_environment_cas_conflict_preserves_operator_change(binding, monkeypatch):
|
|
atomic = binding._atomic
|
|
|
|
def crash(path, value, expected):
|
|
atomic(path, value, expected)
|
|
if path == binding.TOKEN_FILE:
|
|
raise OSError('interruption')
|
|
|
|
monkeypatch.setattr(binding, '_atomic', crash)
|
|
with pytest.raises(OSError):
|
|
invoke(binding)
|
|
binding.PB_ENV.write_bytes(b'OPERATOR_CHANGED=value\n')
|
|
monkeypatch.setattr(binding, '_atomic', atomic)
|
|
with pytest.raises(binding.StatisticsBindingError, match='environment_cas_conflict'):
|
|
invoke(binding)
|
|
assert binding.PB_ENV.read_bytes() == b'OPERATOR_CHANGED=value\n'
|
|
|
|
|
|
def test_completed_binding_keeps_unrelated_later_operator_edits(binding):
|
|
invoke(binding)
|
|
with binding.PB_ENV.open('ab') as stream:
|
|
stream.write(b'NEW_OPERATOR_VALUE=preserved\n')
|
|
before = binding.PB_ENV.read_bytes()
|
|
assert invoke(binding)['status'] == 'already_configured'
|
|
assert binding.PB_ENV.read_bytes() == before
|
|
|
|
|
|
@pytest.mark.parametrize('unsafe', ['symlink', 'public_file', 'writable_anchor', 'duplicate_key'])
|
|
def test_unsafe_configuration_blocks_before_credential_write(binding, unsafe):
|
|
if unsafe == 'symlink':
|
|
target = binding.PB_ENV.with_name('other.env')
|
|
binding.PB_ENV.rename(target)
|
|
binding.PB_ENV.symlink_to(target)
|
|
elif unsafe == 'public_file':
|
|
binding.PB_ENV.chmod(0o644)
|
|
elif unsafe == 'writable_anchor':
|
|
binding.PB_SOURCE.chmod(0o777)
|
|
else:
|
|
binding.PB_ENV.write_text(f'{binding.URL_KEY}=\n{binding.URL_KEY}=\n')
|
|
with pytest.raises((binding.StatisticsBindingError, OSError)):
|
|
invoke(binding)
|
|
assert not binding.TOKEN_FILE.exists()
|
|
|
|
|
|
def test_active_deployment_lock_blocks_without_configuration_changes(binding):
|
|
import fcntl
|
|
|
|
with binding.GLOBAL_LOCK.open('a') as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
with pytest.raises(binding.StatisticsBindingError, match='deployment_busy'):
|
|
invoke(binding)
|
|
assert not binding.TOKEN_FILE.exists()
|
|
|
|
|
|
@pytest.mark.parametrize('owner,accepted', [(100, True), (200, False)])
|
|
def test_existing_fixed_admin_file_owner_contract(binding, monkeypatch, owner, accepted):
|
|
actual = binding.os.fstat
|
|
inode = binding.ADMIN_TOKEN_FILE.stat().st_ino
|
|
|
|
def metadata(fd):
|
|
info = actual(fd)
|
|
if info.st_ino == inode:
|
|
fields = list(info)
|
|
fields[4] = owner
|
|
return os.stat_result(fields)
|
|
return info
|
|
|
|
monkeypatch.setattr(binding.os, 'fstat', metadata)
|
|
if accepted:
|
|
assert invoke(binding)['status'] == 'configured'
|
|
else:
|
|
with pytest.raises(binding.StatisticsBindingError, match='file_untrusted'):
|
|
invoke(binding)
|
|
assert not binding.TOKEN_FILE.exists()
|
|
|
|
|
|
def test_parent_swap_cannot_redirect_a_root_read(binding, monkeypatch):
|
|
actual_open = binding.os.open
|
|
original = binding.PB_ENV.read_bytes()
|
|
moved = binding.PB_SOURCE.with_name('moved-brain')
|
|
attacker = binding.PB_SOURCE.with_name('attacker')
|
|
attacker.mkdir()
|
|
(attacker / '.env').write_bytes(b'ATTACKER=must-not-be-read\n')
|
|
(attacker / '.env').chmod(0o600)
|
|
swapped = False
|
|
|
|
def swap(path, flags, *args, **kwargs):
|
|
nonlocal swapped
|
|
if path == '.env' and 'dir_fd' in kwargs and not swapped:
|
|
swapped = True
|
|
binding.PB_SOURCE.rename(moved)
|
|
binding.PB_SOURCE.symlink_to(attacker, target_is_directory=True)
|
|
return actual_open(path, flags, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(binding.os, 'open', swap)
|
|
assert binding._read(binding.PB_ENV, binding.MAX_ENV_BYTES) == original
|
|
assert (attacker / '.env').read_bytes() == b'ATTACKER=must-not-be-read\n'
|
|
with pytest.raises(OSError):
|
|
binding._read(binding.PB_ENV, binding.MAX_ENV_BYTES)
|
|
|
|
|
|
def test_parent_swap_cannot_redirect_atomic_replace(binding, monkeypatch):
|
|
actual_replace = binding.os.replace
|
|
before = binding.PB_ENV.read_bytes()
|
|
updated = before + b'APPENDED=private\n'
|
|
moved = binding.PB_SOURCE.with_name('moved-brain')
|
|
attacker = binding.PB_SOURCE.with_name('attacker')
|
|
attacker.mkdir()
|
|
(attacker / '.env').write_bytes(b'ATTACKER=must-not-be-overwritten\n')
|
|
(attacker / '.env').chmod(0o600)
|
|
|
|
def swap(source, destination, **kwargs):
|
|
assert source.startswith('.statistics-')
|
|
assert destination == '.env'
|
|
assert kwargs['src_dir_fd'] == kwargs['dst_dir_fd']
|
|
binding.PB_SOURCE.rename(moved)
|
|
binding.PB_SOURCE.symlink_to(attacker, target_is_directory=True)
|
|
return actual_replace(source, destination, **kwargs)
|
|
|
|
monkeypatch.setattr(binding.os, 'replace', swap)
|
|
binding._atomic(binding.PB_ENV, updated, before)
|
|
assert (moved / '.env').read_bytes() == updated
|
|
assert (attacker / '.env').read_bytes() == b'ATTACKER=must-not-be-overwritten\n'
|
|
with pytest.raises(OSError):
|
|
binding._read(binding.PB_ENV, binding.MAX_ENV_BYTES)
|
|
|
|
|
|
def test_parent_swap_cannot_replace_the_authentication_secret(binding, monkeypatch):
|
|
secret = b'original-administration-capability-1234567890'
|
|
binding.ADMIN_TOKEN_FILE.write_bytes(secret)
|
|
actual_open = binding.os.open
|
|
moved = binding.APPOPS_DATA.with_name('moved-appops')
|
|
attacker = binding.APPOPS_DATA.with_name('attacker-appops')
|
|
attacker.mkdir()
|
|
(attacker / 'mcp-token').write_bytes(b'attacker-chosen-authentication-capability-12345')
|
|
(attacker / 'mcp-token').chmod(0o600)
|
|
swapped = False
|
|
|
|
def swap(path, flags, *args, **kwargs):
|
|
nonlocal swapped
|
|
if path == 'mcp-token' and 'dir_fd' in kwargs and not swapped:
|
|
swapped = True
|
|
binding.APPOPS_DATA.rename(moved)
|
|
binding.APPOPS_DATA.symlink_to(attacker, target_is_directory=True)
|
|
return actual_open(path, flags, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(binding.os, 'open', swap)
|
|
assert binding.read_administration_secret() == secret
|
|
assert binding.read_administration_secret() is None
|