From 422dabebbdb266d05b6e186cf914e2da593aee01 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Tue, 20 Oct 2026 20:18:45 +0800 Subject: [PATCH] [authentication/1.1] Private authority initialization Add offline authority init/status with persistent private SQLite owner/server UUID records, serialized initialization, no overwrite and safe retry of unpublished setup. Reject unsafe state and parent replacement rights on POSIX and Windows. Windows uses an operator-prepared private ACL, trusted host OS ancestry and a write-capable database flush. Caller authentication remains unchanged. Plan slice: arbiter-authentication/1.1. Parent: approved authentication planning prerequisites; next: authentication/1.2 credential profiles. Validation: full repository unit, Black/type, Go, documentation and plan checks passed. Negative mutation probes detect missing ancestor protection and read-only database flushing. DBB and independent review passed for the corrected content; native Windows/platform validation runs in CI. --- .../news/+authority-initialization.feature.md | 4 + server/src/arbiter_server/authority.py | 370 +++++++++++ .../arbiter_server/file_protection/windows.py | 7 +- server/src/arbiter_server/main.py | 27 +- server/tests/unit/test_authority.py | 596 ++++++++++++++++++ website/docs/operate/security.md | 36 ++ 6 files changed, 1038 insertions(+), 2 deletions(-) create mode 100644 server/news/+authority-initialization.feature.md create mode 100644 server/src/arbiter_server/authority.py create mode 100644 server/tests/unit/test_authority.py diff --git a/server/news/+authority-initialization.feature.md b/server/news/+authority-initialization.feature.md new file mode 100644 index 0000000..617d5af --- /dev/null +++ b/server/news/+authority-initialization.feature.md @@ -0,0 +1,4 @@ +Add offline `arbiter-server authority init/status` commands for private, persistent +owner and server identity metadata. Initialization rejects existing or unsafe +state and serializes concurrent attempts. This is authentication groundwork; +caller authentication is not enabled by these commands. diff --git a/server/src/arbiter_server/authority.py b/server/src/arbiter_server/authority.py new file mode 100644 index 0000000..5a47fc2 --- /dev/null +++ b/server/src/arbiter_server/authority.py @@ -0,0 +1,370 @@ +"""Offline authority records; this does not enable caller authentication.""" + +from __future__ import annotations + +from contextlib import closing, contextmanager +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +import os +from pathlib import Path +import sqlite3 +import stat +import sys +from typing import Iterator +from uuid import UUID, uuid4 + +DATABASE = "authority.sqlite3" +_PENDING = ".authority.pending.sqlite3" +_LOCK = ".authority.lock" +SCHEMA_VERSION = 1 +# Windows Modules Installer is part of the trusted host OS. This exception is +# for ancestors only; authority-root and file ACL allowlists remain unchanged. +_TRUSTED_INSTALLER = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464" + + +class AuthorityError(ValueError): + """An offline authority operation could not complete safely.""" + + +@dataclass(frozen=True) +class AuthorityRecord: + owner_id: str + server_id: str + created_at: str + + def to_dict(self) -> dict[str, object]: + return {"initialized": True, "schema_version": SCHEMA_VERSION, **asdict(self)} + + +def _check_path(path: Path, *, directory: bool = False) -> None: + info = path.lstat() + expected = stat.S_ISDIR if directory else stat.S_ISREG + if not expected(info.st_mode) or ( + getattr(info, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ): + raise AuthorityError( + f"authority path is not a regular {'directory' if directory else 'file'}: {path}" + ) + if os.name != "nt": + if info.st_uid != os.geteuid() or info.st_mode & 0o077: + raise AuthorityError( + f"authority path must be owned by this user and private: {path}" + ) + else: + _check_windows_permissions(path, directory=directory) + if not directory and info.st_nlink != 1: + raise AuthorityError(f"authority file must have exactly one link: {path}") + + +def _windows_caller_sid() -> str: + import csv + import subprocess + + try: + whoami = Path(os.environ["SystemRoot"]) / "System32" / "whoami.exe" + output = subprocess.check_output( + [str(whoami), "/user", "/fo", "csv", "/nh"], text=True, timeout=5 + ) + rows = list(csv.reader(output.splitlines())) + if len(rows) != 1 or len(rows[0]) != 2: + raise ValueError("invalid caller SID response") + caller_sid = rows[0][1] + if not caller_sid.startswith("S-1-"): + raise ValueError("invalid caller SID") + except (KeyError, OSError, subprocess.SubprocessError, ValueError) as exc: + raise AuthorityError("could not verify authority caller identity") from exc + return caller_sid + + +def _check_windows_permissions(path: Path, *, directory: bool) -> None: + from .file_protection.windows import ( + _WINDOWS_DIRECTORY_MUTATING_ACCESS_MASK, + _WINDOWS_FILE_READ_WRITE_ACCESS_MASK, + _windows_file_security, + _windows_unallowed_access_reason, + ) + + security = _windows_file_security(path) + owner = security.owner_sid + if owner not in {_windows_caller_sid(), "S-1-5-18", "S-1-5-32-544"}: + raise AuthorityError( + f"authority path must be owned by the caller, SYSTEM or Administrators: {path}" + ) + mask = ( + _WINDOWS_DIRECTORY_MUTATING_ACCESS_MASK + if directory + else _WINDOWS_FILE_READ_WRITE_ACCESS_MASK + ) + reason = ( + "null DACL grants full access" + if security.has_null_dacl + else _windows_unallowed_access_reason( + security.access_aces, owner_sid=owner, access_mask=mask + ) + ) + if reason is not None: + kind = "config directory" if directory else "app env file" + raise AuthorityError(f"unsafe {kind} permissions: {path}: {reason}") + + +def _check_windows_parents(parent: Path) -> None: + from .file_protection.windows import ( + _WindowsAccessAce, + _windows_file_security, + _windows_unallowed_access_reason, + ) + + caller_sid = _windows_caller_sid() + trusted = {caller_sid, "S-1-5-18", "S-1-5-32-544", _TRUSTED_INSTALLER} + # Creating unrelated siblings is safe. Replacing or changing permissions on + # an ancestor is not. Inherit-only ACEs do not grant access to that ancestor. + replacement_mask = 0x00000040 | 0x00010000 | 0x00040000 | 0x00080000 + replacement_mask |= 0x10000000 | 0x40000000 + for path in (parent, *parent.parents): + if not path.exists(): + continue + security = _windows_file_security(path) + owner_sid = security.owner_sid + if security.has_null_dacl or owner_sid is None or owner_sid not in trusted: + raise AuthorityError( + f"authority parent has untrusted ownership or ACL: {path}" + ) + aces = [ + _WindowsAccessAce( + sid=owner_sid if ace.sid == "S-1-3-4" else ace.sid, + mask=ace.mask, + ) + for ace in security.access_aces + if not ace.flags & 0x08 + ] + reason = _windows_unallowed_access_reason( + [ace for ace in aces if ace.sid not in trusted], + owner_sid=caller_sid, + access_mask=replacement_mask, + ) + if reason is not None: + raise AuthorityError( + f"authority parent can be replaced by another user: {path}: {reason}" + ) + + +def _check_parents(parent: Path) -> None: + if os.name == "nt": + _check_windows_parents(parent) + return + for path in (parent, *parent.parents): + try: + info = path.stat() + except FileNotFoundError: + continue + if not stat.S_ISDIR(info.st_mode): + raise AuthorityError(f"authority parent is not a directory: {path}") + if info.st_uid not in {0, os.geteuid()} or ( + info.st_mode & 0o022 and not info.st_mode & stat.S_ISVTX + ): + raise AuthorityError( + f"authority parent can be replaced by another user: {path}" + ) + + +def _root(path: Path, *, create: bool) -> Path: + path = path.expanduser().absolute() + if path.is_symlink(): + raise AuthorityError("authority directory must not be a symlink") + # Resolve parent aliases once, including platform aliases such as /tmp on macOS. + root = path.parent.resolve() / path.name + _check_parents(root.parent) + if create: + # On Windows, inherit the operator-prepared parent ACL and validate it + # below. Python's special 0o700 ACL uses Owner Rights, which the existing + # strict runtime permission validator deliberately rejects. + root.mkdir( + mode=0o777 if os.name == "nt" else 0o700, parents=True, exist_ok=True + ) + _check_parents(root.parent) + if root.exists() or root.is_symlink(): + _check_path(root, directory=True) + return root + + +def _open_private(path: Path) -> int: + flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(path, flags, 0o600) + try: + _check_path(path) + info = os.fstat(fd) + current = path.lstat() + if (info.st_dev, info.st_ino) != (current.st_dev, current.st_ino): + raise AuthorityError("authority file changed while opening") + return fd + except BaseException: + os.close(fd) + raise + + +@contextmanager +def _locked(root: Path) -> Iterator[None]: + # Never unlink this file: every process must lock the same persistent object. + with os.fdopen(_open_private(root / _LOCK), "r+b") as handle: + if sys.platform == "win32": + import msvcrt + + def lock() -> None: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + + def unlock() -> None: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + + else: + import fcntl + + def lock() -> None: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + def unlock() -> None: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + try: + lock() + except OSError as exc: + raise AuthorityError("authority state is busy; try again later") from exc + try: + yield + finally: + unlock() + + +def _remove_pending(root: Path) -> None: + for name in (_PENDING, _PENDING + "-journal"): + path = root / name + if path.exists() or path.is_symlink(): + _check_path(path) + path.unlink() + + +def _write_database(path: Path, record: AuthorityRecord) -> None: + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o600) + os.close(fd) + _check_path(path) + with closing(sqlite3.connect(path, timeout=0, isolation_level=None)) as db: + db.execute("PRAGMA synchronous=FULL") + db.execute("BEGIN IMMEDIATE") + db.execute( + "CREATE TABLE authority (singleton INTEGER PRIMARY KEY CHECK(singleton=1), " + "owner_id TEXT NOT NULL, server_id TEXT NOT NULL, created_at TEXT NOT NULL, " + "CHECK(owner_id != server_id))" + ) + db.execute( + "INSERT INTO authority VALUES (1, ?, ?, ?)", + (record.owner_id, record.server_id, record.created_at), + ) + db.execute(f"PRAGMA user_version={SCHEMA_VERSION}") + db.commit() + with path.open("r+b") as handle: + os.fsync(handle.fileno()) + + +def _publish(pending: Path, target: Path) -> None: + os.replace(pending, target) + if os.name != "nt": + for directory in (target.parent, *target.parent.parents): + fd = os.open(directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def initialize_authority(state_dir: Path) -> AuthorityRecord: + """Create one private set of IDs, refusing to replace any existing database.""" + root = _root(state_dir, create=True) + with _locked(root): + target = root / DATABASE + if target.exists() or target.is_symlink(): + raise AuthorityError( + "authority database already exists; inspect authority status" + ) + _remove_pending(root) + record = AuthorityRecord( + str(uuid4()), str(uuid4()), datetime.now(timezone.utc).isoformat() + ) + try: + _write_database(root / _PENDING, record) + _publish(root / _PENDING, target) + finally: + # A killed process may leave only these bounded, unpublished files. + # Never remove the target after a possibly successful publication. + _remove_pending(root) + return record + + +def authority_status(state_dir: Path) -> AuthorityRecord | None: + """Inspect committed records without creating or repairing a database.""" + root = _root(state_dir, create=False) + if not root.exists(): + return None + with _locked(root): + path = root / DATABASE + if not path.exists() and not path.is_symlink(): + return None + _check_path(path) + try: + with closing( + sqlite3.connect(path.as_uri() + "?mode=ro", uri=True, timeout=0) + ) as db: + version = db.execute("PRAGMA user_version").fetchone()[0] + if version != SCHEMA_VERSION: + raise AuthorityError( + "unsupported authority database schema version" + ) + rows = db.execute( + "SELECT singleton, owner_id, server_id, created_at FROM authority" + ).fetchall() + if len(rows) != 1: + raise ValueError("expected one authority record") + singleton, owner, server, created = rows[0] + if singleton != 1: + raise ValueError("invalid authority singleton") + for value in (owner, server): + if not isinstance(value, str) or str(UUID(value, version=4)) != value: + raise ValueError("invalid authority ID") + if owner == server or not isinstance(created, str): + raise ValueError("invalid authority record") + when = datetime.fromisoformat(created) + if when.tzinfo is None or when.utcoffset() != timezone.utc.utcoffset(None): + raise ValueError("invalid creation timestamp") + return AuthorityRecord(owner, server, created) + except (sqlite3.Error, ValueError) as exc: + if isinstance(exc, AuthorityError): + raise + raise AuthorityError( + "invalid or incomplete authority database; refusing to replace it" + ) from exc + + +def authority_cli(action: str, state_dir: Path, *, as_json: bool) -> int: + from .cli_errors import print_cli_error + import json + + try: + record = ( + initialize_authority(state_dir) + if action == "init" + else authority_status(state_dir) + ) + except (OSError, ValueError, sqlite3.Error) as exc: + print_cli_error(str(exc), area="authority") + return 1 + payload = record.to_dict() if record is not None else {"initialized": False} + if as_json: + print(json.dumps(payload, sort_keys=True)) + elif record is None: + print("authority: not initialized") + else: + print( + f"authority: initialized\nowner: {record.owner_id}\nserver: {record.server_id}" + ) + return 0 diff --git a/server/src/arbiter_server/file_protection/windows.py b/server/src/arbiter_server/file_protection/windows.py index 7218459..9d2c0e3 100644 --- a/server/src/arbiter_server/file_protection/windows.py +++ b/server/src/arbiter_server/file_protection/windows.py @@ -54,6 +54,7 @@ class _WindowsAccessAce: sid: str mask: int + flags: int = 0 @dataclass(frozen=True) @@ -248,7 +249,11 @@ def last_windows_error(message: str) -> OSError: if sid_string.value is None: raise OSError("ConvertSidToStringSidW returned a null SID") access_aces.append( - _WindowsAccessAce(sid=sid_string.value, mask=int(ace.Mask)) + _WindowsAccessAce( + sid=sid_string.value, + mask=int(ace.Mask), + flags=int(ace.Header.AceFlags), + ) ) finally: local_free(ctypes.cast(sid_string, ctypes.c_void_p)) diff --git a/server/src/arbiter_server/main.py b/server/src/arbiter_server/main.py index 73ee7de..fb23238 100644 --- a/server/src/arbiter_server/main.py +++ b/server/src/arbiter_server/main.py @@ -87,7 +87,15 @@ TransportMode = Literal["https"] HydraConfig = AppConfig | DictConfig BootstrapObjectKind = Literal["account", "policy"] -CLI_COMMANDS = {"serve", "config", "plugins", "bootstrap", "env", "version"} +CLI_COMMANDS = { + "serve", + "config", + "plugins", + "bootstrap", + "env", + "version", + "authority", +} BOOTSTRAP_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") ENV_FILE_CONFIG_KEY = "arbiter.env_file" @@ -4409,6 +4417,17 @@ def _build_parser() -> argparse.ArgumentParser: ) subcommands = parser.add_subparsers(dest="command", required=True) + authority = subcommands.add_parser( + "authority", help="initialize or inspect offline authority identity records" + ) + authority_commands = authority.add_subparsers( + dest="authority_command", required=True + ) + for action in ("init", "status"): + command = authority_commands.add_parser(action) + command.add_argument("--state-dir", type=Path, required=True) + command.add_argument("--json", action="store_true") + serve = subcommands.add_parser("serve", help="run the Arbiter HTTP server") _add_override_arguments( serve, @@ -4611,6 +4630,12 @@ def main(argv: Sequence[str] | None = None) -> int: return 0 namespace = parser.parse_args(args) + if namespace.command == "authority": + from .authority import authority_cli + + return authority_cli( + namespace.authority_command, namespace.state_dir, as_json=namespace.json + ) if namespace.command == "serve": return _run_serve( config_dir=namespace.config_dir, diff --git a/server/tests/unit/test_authority.py b/server/tests/unit/test_authority.py new file mode 100644 index 0000000..474c336 --- /dev/null +++ b/server/tests/unit/test_authority.py @@ -0,0 +1,596 @@ +from __future__ import annotations + +from contextlib import closing +import json +import os +from pathlib import Path +import sqlite3 +import stat +import subprocess +import sys +import time +from uuid import UUID + +import pytest + +from arbiter_server import authority +from arbiter_server.main import main + + +@pytest.fixture(autouse=True) +def private_windows_test_parent(tmp_path: Path) -> None: + if os.name != "nt": + return + from arbiter_server.file_protection import ensure_runtime_config_permissions + from arbiter_server.file_protection.windows import _windows_file_security + + security = _windows_file_security(tmp_path) + owner_sid = security.owner_sid + assert owner_sid is not None + allowed = {owner_sid, "S-1-5-18", "S-1-5-32-544"} + # Use real, inheritable ACLs. Python/pytest's special 0o700 Owner Rights ACE + # is not an operator-prepared private parent under Arbiter's allowlist. + subprocess.run( + [ + "icacls", + str(tmp_path), + "/inheritance:r", + "/grant:r", + f"*{owner_sid}:(OI)(CI)F", + "*S-1-5-18:(OI)(CI)F", + "*S-1-5-32-544:(OI)(CI)F", + ], + check=True, + capture_output=True, + text=True, + ) + for sid in sorted({ace.sid for ace in security.access_aces} - allowed): + subprocess.run( + ["icacls", str(tmp_path), "/remove:g", f"*{sid}"], + check=True, + capture_output=True, + text=True, + ) + ensure_runtime_config_permissions(config_dir=tmp_path, env_file=None) + + +def test_initialize_reopen_and_independent_roots(tmp_path: Path) -> None: + root = tmp_path / "one" + record = authority.initialize_authority(root) + assert authority.authority_status(root) == record + assert UUID(record.owner_id).version == UUID(record.server_id).version == 4 + assert record.owner_id != record.server_id + other = authority.initialize_authority(tmp_path / "two") + assert {record.owner_id, record.server_id}.isdisjoint( + {other.owner_id, other.server_id} + ) + with closing(sqlite3.connect(root / authority.DATABASE)) as db: + assert db.execute("PRAGMA integrity_check").fetchone() == ("ok",) + assert db.execute("PRAGMA user_version").fetchone() == (1,) + if os.name != "nt": + assert root.stat().st_mode & 0o777 == 0o700 + assert (root / authority.DATABASE).stat().st_mode & 0o777 == 0o600 + + +def test_database_flush_has_write_access(tmp_path: Path, monkeypatch) -> None: + real_fsync = os.fsync + + def writable_flush(fd): + if not stat.S_ISDIR(os.fstat(fd).st_mode): + # FlushFileBuffers on Windows requires write access. A zero-byte + # write tests that capability on POSIX too, without changing data. + os.write(fd, b"") + real_fsync(fd) + + monkeypatch.setattr(authority.os, "fsync", writable_flush) + record = authority.initialize_authority(tmp_path / "state") + assert authority.authority_status(tmp_path / "state") == record + + +def test_status_missing_is_not_initialization(tmp_path: Path) -> None: + root = tmp_path / "absent" + assert authority.authority_status(root) is None + assert not root.exists() + root.mkdir(mode=0o777 if os.name == "nt" else 0o700) + assert authority.authority_status(root) is None + assert not (root / authority.DATABASE).exists() + + +def test_second_initialization_preserves_database(tmp_path: Path) -> None: + record = authority.initialize_authority(tmp_path / "state") + path = tmp_path / "state" / authority.DATABASE + original = path.read_bytes() + with pytest.raises(authority.AuthorityError, match="already exists"): + authority.initialize_authority(path.parent) + assert path.read_bytes() == original + assert authority.authority_status(path.parent) == record + assert path.read_bytes() == original + + +def test_cli_is_offline_and_reports_errors(tmp_path: Path, capsys, monkeypatch) -> None: + # An absent config must never prevent stopped-instance init or status. + def no_composition(*args, **kwargs): + pytest.fail("offline authority command composed runtime configuration") + + monkeypatch.setattr("arbiter_server.main.compose_config", no_composition) + args = ["--config-dir", str(tmp_path / "no-config"), "authority"] + state = ["--state-dir", str(tmp_path / "state"), "--json"] + assert main([*args, "status", *state]) == 0 + assert json.loads(capsys.readouterr().out) == {"initialized": False} + assert main([*args, "init", *state]) == 0 + output = json.loads(capsys.readouterr().out) + assert output["initialized"] is True + assert output["schema_version"] == 1 + assert main([*args, "status", *state]) == 0 + assert json.loads(capsys.readouterr().out) == output + assert main([*args, "init", *state]) == 1 + captured = capsys.readouterr() + assert not captured.out + assert "already exists" in captured.err + + +@pytest.mark.parametrize( + "damage", ["garbage", "version", "missing", "duplicate", "uuid", "time"] +) +def test_corrupt_state_never_reinitialized(tmp_path: Path, damage: str) -> None: + root = tmp_path / "state" + authority.initialize_authority(root) + path = root / authority.DATABASE + if damage == "garbage": + path.write_bytes(b"not a database") + else: + with closing(sqlite3.connect(path)) as db: + if damage == "version": + db.execute("PRAGMA user_version=999") + elif damage == "missing": + db.execute("DROP TABLE authority") + elif damage == "duplicate": + db.execute("ALTER TABLE authority RENAME TO original") + db.execute("CREATE TABLE authority AS SELECT * FROM original") + db.execute("INSERT INTO authority SELECT * FROM original") + elif damage == "uuid": + db.execute("UPDATE authority SET owner_id='display label'") + else: + db.execute("UPDATE authority SET created_at='2026-01-01T00:00:00'") + db.commit() + original = path.read_bytes() + with pytest.raises(authority.AuthorityError): + authority.authority_status(root) + with pytest.raises(authority.AuthorityError, match="already exists"): + authority.initialize_authority(root) + assert path.read_bytes() == original + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX permission and link semantics") +@pytest.mark.parametrize("target", ["directory", "database", "lock"]) +def test_broad_permissions_rejected(tmp_path: Path, target: str) -> None: + root = tmp_path / "state" + authority.initialize_authority(root) + path = { + "directory": root, + "database": root / authority.DATABASE, + "lock": root / ".authority.lock", + }[target] + path.chmod(0o755 if target == "directory" else 0o644) + with pytest.raises(authority.AuthorityError, match="private"): + authority.authority_status(root) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink creation needs no privilege") +@pytest.mark.parametrize( + "name", [authority.DATABASE, ".authority.lock", ".authority.pending.sqlite3"] +) +def test_symlink_files_rejected_without_touching_destination( + tmp_path: Path, name: str +) -> None: + root = tmp_path / "state" + root.mkdir(mode=0o700) + outside = tmp_path / "outside" + outside.write_bytes(b"keep") + outside.chmod(0o600) + (root / name).symlink_to(outside) + operation = ( + authority.initialize_authority + if name.endswith("pending.sqlite3") + else authority.authority_status + ) + expected: tuple[type[Exception], ...] = (ValueError, OSError) + with pytest.raises(expected): + operation(root) + assert outside.read_bytes() == b"keep" + assert (root / name).is_symlink() + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink and hardlink semantics") +def test_directory_alias_and_hardlinked_database_rejected(tmp_path: Path) -> None: + root = tmp_path / "state" + authority.initialize_authority(root) + alias = tmp_path / "alias" + alias.symlink_to(root, target_is_directory=True) + with pytest.raises(authority.AuthorityError, match="symlink"): + authority.authority_status(alias) + os.link(root / authority.DATABASE, tmp_path / "linked-db") + with pytest.raises(authority.AuthorityError, match="exactly one link"): + authority.authority_status(root) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX unsafe parent permissions") +def test_writable_parent_rejected(tmp_path: Path) -> None: + parent = tmp_path / "shared" + parent.mkdir(mode=0o777) + parent.chmod(0o777) + with pytest.raises(authority.AuthorityError, match="another user"): + authority.initialize_authority(parent / "state") + assert not (parent / "state").exists() + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX unsafe parent permissions") +@pytest.mark.parametrize("umask", [0o000, 0o002]) +def test_nested_missing_parent_rejected_after_creation( + tmp_path: Path, umask: int +) -> None: + root = tmp_path / "missing-parent" / "state" + previous_umask = os.umask(umask) + try: + with pytest.raises(authority.AuthorityError, match="another user"): + authority.initialize_authority(root) + finally: + os.umask(previous_umask) + assert root.exists() + assert root.stat().st_mode & 0o777 == 0o700 + assert root.parent.stat().st_mode & 0o022 + assert not (root / authority.DATABASE).exists() + assert not (root / authority._LOCK).exists() + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX parent permissions") +def test_nested_missing_parent_with_safe_umask_reopens(tmp_path, monkeypatch) -> None: + root = tmp_path / "nested" / "missing-parent" / "state" + flushed = [] + real_fsync = os.fsync + + def track(fd): + info = os.fstat(fd) + if stat.S_ISDIR(info.st_mode): + flushed.append((info.st_dev, info.st_ino)) + real_fsync(fd) + + monkeypatch.setattr(authority.os, "fsync", track) + previous_umask = os.umask(0o022) + try: + record = authority.initialize_authority(root) + finally: + os.umask(previous_umask) + assert authority.authority_status(root) == record + assert root.parent.stat().st_mode & 0o777 == 0o755 + assert flushed == [ + (p.stat().st_dev, p.stat().st_ino) for p in (root, *root.parents) + ] + + +@pytest.mark.parametrize("failure", ["write", "before_publish", "after_publish"]) +def test_failure_leaves_only_complete_published_database( + tmp_path: Path, monkeypatch, failure: str +) -> None: + root = tmp_path / "state" + real_write = authority._write_database + real_publish = authority._publish + + def fail_write(path, record): + path.write_bytes(b"partial") + path.chmod(0o600) + raise OSError("injected write failure") + + def fail_publish(pending, target): + if failure == "after_publish": + real_publish(pending, target) + raise OSError("injected publication failure") + + monkeypatch.setattr( + authority, "_write_database", fail_write if failure == "write" else real_write + ) + monkeypatch.setattr(authority, "_publish", fail_publish) + with pytest.raises(OSError, match="injected"): + authority.initialize_authority(root) + assert not (root / ".authority.pending.sqlite3").exists() + assert not (root / ".authority.pending.sqlite3-journal").exists() + monkeypatch.setattr(authority, "_write_database", real_write) + monkeypatch.setattr(authority, "_publish", real_publish) + if failure == "after_publish": + assert authority.authority_status(root) is not None + with pytest.raises(authority.AuthorityError, match="already exists"): + authority.initialize_authority(root) + else: + assert authority.authority_status(root) is None + assert authority.initialize_authority(root) == authority.authority_status(root) + + +def _wait_for_file(path: Path, child: subprocess.Popen) -> None: + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if path.exists(): + return + if child.poll() is not None: + pytest.fail(f"authority child exited early: {child.communicate()}") + time.sleep(0.02) + pytest.fail("authority child did not reach checkpoint") + + +@pytest.mark.parametrize("phase", ["transaction", "publication"]) +def test_real_process_lock_and_killed_initialization_recover( + tmp_path: Path, phase: str +) -> None: + root = tmp_path / "state" + ready = tmp_path / "ready" + script = """ +from pathlib import Path +import sys, time, sqlite3 +from arbiter_server import authority + +def wait(pending, target): + Path(sys.argv[2]).write_text("ready") + time.sleep(60) + +if sys.argv[3] == "transaction": + def partial(path, record): + fd = authority.os.open(path, authority.os.O_CREAT | authority.os.O_EXCL | authority.os.O_RDWR, 0o600) + authority.os.close(fd) + db = sqlite3.connect(path) + db.execute("BEGIN IMMEDIATE") + db.execute("CREATE TABLE interrupted(value TEXT)") + wait(path, None) + authority._write_database = partial +else: + authority._publish = wait +authority.initialize_authority(Path(sys.argv[1])) +""" + child = subprocess.Popen( + [sys.executable, "-c", script, str(root), str(ready), phase], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + _wait_for_file(ready, child) + assert not (root / authority.DATABASE).exists() + for operation in (authority.initialize_authority, authority.authority_status): + with pytest.raises(authority.AuthorityError, match="busy"): + operation(root) + finally: + child.kill() + child.communicate(timeout=15) + assert (root / ".authority.pending.sqlite3").exists() + record = authority.initialize_authority(root) + assert authority.authority_status(root) == record + assert not (root / ".authority.pending.sqlite3").exists() + assert {p.name for p in root.iterdir()} == {authority.DATABASE, ".authority.lock"} + + +def test_concurrent_process_initializers_publish_one_identity(tmp_path: Path) -> None: + root = tmp_path / "state" + script = """ +import json, sys +from pathlib import Path +from arbiter_server.authority import initialize_authority, AuthorityError +try: + print(json.dumps(initialize_authority(Path(sys.argv[1])).to_dict())) +except AuthorityError as exc: + print(str(exc), file=sys.stderr) + sys.exit(1) +""" + children = [ + subprocess.Popen( + [sys.executable, "-c", script, str(root)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + for _ in range(3) + ] + try: + results = [child.communicate(timeout=20) for child in children] + finally: + for child in children: + if child.poll() is None: + child.kill() + child.communicate(timeout=15) + winners = [ + json.loads(out) + for child, (out, _) in zip(children, results) + if child.returncode == 0 + ] + assert len(winners) == 1 + record = authority.authority_status(root) + assert record is not None + assert record.to_dict() == winners[0] + assert all(child.returncode in {0, 1} for child in children) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX ownership") +def test_different_uid_is_not_authorized(tmp_path: Path, monkeypatch) -> None: + root = tmp_path / "state" + authority.initialize_authority(root) + current = os.geteuid() + monkeypatch.setattr(authority.os, "geteuid", lambda: current + 1) + with pytest.raises(authority.AuthorityError): + authority.authority_status(root) + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows ACL boundary") +def test_windows_non_inheritable_parent_acl_rejected(tmp_path: Path) -> None: + parent = tmp_path / "shared" + parent.mkdir() + subprocess.run( + ["icacls", str(parent), "/grant", "*S-1-5-32-545:M"], + check=True, + capture_output=True, + text=True, + ) + with pytest.raises(authority.AuthorityError, match="another user"): + authority.initialize_authority(parent / "state") + assert not (parent / "state").exists() + + +@pytest.mark.parametrize( + "owner, sid, mask, flags, null_dacl, rejected", + [ + ("caller", "other", 0x00000040, 0, False, True), + ("caller", "other", 0x00040000, 0, False, True), + ("other", "other", 0x10000000, 0, False, True), + ("caller", "caller", 0, 0, True, True), + ("caller", "other", 0x00000004, 0, False, False), + ("caller", "other", 0x10000000, 0x08, False, False), + ("caller", "S-1-3-4", 0x10000000, 0, False, False), + ("S-1-5-18", "S-1-5-18", 0x10000000, 0, False, False), + ( + authority._TRUSTED_INSTALLER, + authority._TRUSTED_INSTALLER, + 0x10000000, + 0, + False, + False, + ), + (authority._TRUSTED_INSTALLER, "S-1-3-4", 0x10000000, 0, False, False), + ("S-1-5-80-12345", "S-1-5-80-12345", 0x10000000, 0, False, True), + ], +) +def test_windows_parent_replacement_policy( + tmp_path: Path, monkeypatch, owner, sid, mask, flags, null_dacl, rejected +) -> None: + from arbiter_server.file_protection import windows + + caller = "S-1-5-21-1-2-3-1001" + other = "S-1-5-21-1-2-3-1002" + sids = {"caller": caller, "other": other} + monkeypatch.setenv("SystemRoot", str(tmp_path)) + monkeypatch.setattr( + subprocess, "check_output", lambda *a, **k: f'"operator","{caller}"\n' + ) + security = windows._WindowsFileSecurity( + owner_sid=sids.get(owner, owner), + access_aces=(windows._WindowsAccessAce(sids.get(sid, sid), mask, flags),), + has_null_dacl=null_dacl, + ) + monkeypatch.setattr(windows, "_windows_file_security", lambda path: security) + if rejected: + with pytest.raises(authority.AuthorityError): + authority._check_windows_parents(tmp_path) + else: + authority._check_windows_parents(tmp_path) + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows ACL boundary") +@pytest.mark.parametrize( + "name", ["root", authority.DATABASE, authority._LOCK, authority._PENDING] +) +def test_windows_foreign_state_owner_rejected(tmp_path: Path, name: str) -> None: + from arbiter_server.file_protection.windows import _windows_file_security + + root = tmp_path / "state" + root.mkdir() + path = root if name == "root" else root / name + if path != root: + path.write_bytes(b"keep") + foreign = "S-1-5-32-545" + subprocess.run( + ["icacls", str(path), "/setowner", f"*{foreign}"], + check=True, + capture_output=True, + text=True, + ) + operation = ( + authority.authority_status + if name == authority.DATABASE + else authority.initialize_authority + ) + with pytest.raises(authority.AuthorityError, match="owned by the caller"): + operation(root) + assert _windows_file_security(path).owner_sid == foreign + if path == root: + assert not list(root.iterdir()) + else: + assert path.read_bytes() == b"keep" + + +@pytest.mark.parametrize( + "owner, permitted", + [ + ("S-1-5-21-1-2-3-1001", True), + ("S-1-5-18", True), + ("S-1-5-32-544", True), + ("S-1-5-21-1-2-3-1002", False), + (authority._TRUSTED_INSTALLER, False), + (None, False), + ], +) +def test_windows_state_owner_policy( + tmp_path: Path, monkeypatch, owner, permitted +) -> None: + from arbiter_server.file_protection import windows + + monkeypatch.setattr(authority, "_windows_caller_sid", lambda: "S-1-5-21-1-2-3-1001") + monkeypatch.setattr( + windows, + "_windows_file_security", + lambda path: windows._WindowsFileSecurity(owner, (), False), + ) + if permitted: + authority._check_windows_permissions(tmp_path, directory=True) + else: + with pytest.raises(authority.AuthorityError, match="owned by the caller"): + authority._check_windows_permissions(tmp_path, directory=True) + + +@pytest.mark.parametrize("directory", [False, True]) +def test_windows_owner_and_acl_use_same_snapshot(tmp_path, monkeypatch, directory): + from arbiter_server.file_protection import windows + + caller, foreign = "S-1-5-21-1-2-3-1001", "S-1-5-21-1-2-3-1002" + monkeypatch.setattr(authority, "_windows_caller_sid", lambda: caller) + ace = windows._WindowsAccessAce(foreign, 0x10000000) + snapshots = iter( + windows._WindowsFileSecurity(owner, (ace,), False) + for owner in (caller, foreign) + ) + reads = [] + + def read_security(path): + reads.append(path) + return next(snapshots) + + monkeypatch.setattr(windows, "_windows_file_security", read_security) + with pytest.raises(authority.AuthorityError, match="outside the allowlist"): + authority._check_windows_permissions(tmp_path, directory=directory) + assert reads == [tmp_path] + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows ACL boundary") +def test_windows_broad_directory_acl_rejected_without_repair(tmp_path: Path) -> None: + root = tmp_path / "state" + root.mkdir() + subprocess.run( + ["icacls", str(root), "/grant", "*S-1-5-32-545:(OI)(CI)M"], + check=True, + capture_output=True, + text=True, + ) + before = subprocess.check_output(["icacls", str(root)], text=True) + with pytest.raises(ValueError, match="unsafe config directory permissions"): + authority.initialize_authority(root) + assert not list(root.iterdir()) + assert subprocess.check_output(["icacls", str(root)], text=True) == before + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows ACL boundary") +def test_windows_broad_database_acl_rejected(tmp_path: Path) -> None: + root = tmp_path / "state" + authority.initialize_authority(root) + path = root / authority.DATABASE + subprocess.run( + ["icacls", str(path), "/grant", "*S-1-5-32-545:R"], + check=True, + capture_output=True, + text=True, + ) + with pytest.raises(ValueError, match="unsafe app env file permissions"): + authority.authority_status(root) diff --git a/website/docs/operate/security.md b/website/docs/operate/security.md index a5ddba6..96ec8f3 100644 --- a/website/docs/operate/security.md +++ b/website/docs/operate/security.md @@ -161,3 +161,39 @@ OAuth delegation, local credential brokers, per-user execution workers, and confidential-computing approaches may all be useful building blocks, but none should be documented as solving operator trust until the runtime threat model is explicit. + +## Offline authority initialization (development) + +The first authentication milestone provides stopped-instance identity setup: + +```bash +arbiter-server authority init --state-dir /private/path/authority +arbiter-server authority status --state-dir /private/path/authority --json +``` + +Use an operator-owned private directory. The command creates persistent owner +and server UUIDs in a versioned SQLite database. It does not read Hydra +configuration, load service plugins, or contact upstream accounts. A second +initialization fails instead of replacing existing identity records. Status +reports absent or damaged state without creating a replacement database. + +Stop the instance before initializing. Concurrent initialization attempts use +one persistent process lock. A failed unpublished initialization can be retried; +a database already published must be inspected rather than overwritten. Keep +the directory private; the command rejects unsafe permissions and linked files. + +On Windows, prepare a private parent directory with inheritable access for the +operator, SYSTEM and Administrators only. New authority directories inherit that +ACL. Initialization validates it and refuses unsafe access; it does not repair +existing directory or file permissions. +Ancestors must also be owned by the operator, SYSTEM, Administrators or the +fixed Windows Modules Installer (TrustedInstaller) service and must not let +another user replace their children or change their permissions. The fixed +TrustedInstaller SID exception applies to ancestors only. The authority root and +every state file must be owned by the caller, SYSTEM or Administrators, in +addition to passing the existing private ACL checks. Use a private path under the +operator's profile; other unsupported ownership fails closed. + +These commands create identity metadata only. They do not issue credentials or +enable caller authentication. The current trusted-local-client restriction +above still applies.