Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/hosted-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ Evidence cell after the operation succeeds. The running cell continues to read
the old mounted extract until that restart. A malformed, metadata-mismatched,
or non-newer publication fails closed.

A later full provision run composes with a published extract. It looks past the
superseded extract and the superseded runtime it left behind, so a rollback keeps
both, and still verifies everything it stages itself.

## Deployment order

Deploy the reset alongside the existing deployment in this order:
Expand Down
61 changes: 53 additions & 8 deletions scripts/provision-hosted-runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,11 @@
# ceiling is lifted rather than removed so a future-dated extract is still
# refused.
REUSE_MAX_EXTRACT_AGE_SECONDS = 100 * 365 * 24 * 60 * 60
ROLLBACK_RUNTIME = re.compile(
r"^runtime\.rollback-(?:cra-birth|nia-population|sro-poverty)-"
r"[0-9]{8}T[0-9]{6}(?:[0-9]{6})?Z\.yaml$"
)
# `publish-extract` names the extract it stages and the runtime it supersedes
# after the same publication, so both patterns are built from one fragment.
PUBLICATION_TIME = r"[0-9]{8}T[0-9]{6}(?:[0-9]{6})?Z"
PUBLICATION = rf"(?:cra-birth|nia-population|sro-poverty)-{PUBLICATION_TIME}"
ROLLBACK_RUNTIME = re.compile(rf"^runtime\.rollback-{PUBLICATION}\.yaml$")


class ProvisionError(RuntimeError):
Expand Down Expand Up @@ -560,6 +561,28 @@ def _preserve_extract_rollback(relative: str, value: tuple[str, int]) -> bool:
)


def _preserve_superseded_extracts(
prefix: str, active: str
) -> Callable[[str, tuple[str, int]], bool]:
"""Look past the extracts `publish-extract` superseded for this authority,
never past this run's own. A rollback needs the older file to stay readable,
so provisioning has to accept it beside the one it stages; the staged extract
is this run's output and stays verified against what it wrote, and an extract
output carries one authority, so any other name is still a mismatch."""
published = re.compile(rf"^{re.escape(prefix)}-{PUBLICATION_TIME}\.sqlite$")

def preserve(relative: str, value: tuple[str, int]) -> bool:
return (
"/" not in relative
and relative != active
and published.fullmatch(relative) is not None
and value[0] != "directory"
and value[1] == 0o444
Comment on lines +577 to +580

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate preserved extracts before excluding them

When a same-authority rollback extract is corrupted or replaced while retaining a timestamp-shaped filename and mode 0444, the newly scoped predicate still excludes it from both inventory comparisons based solely on its name and mode. A later full provision therefore reports success while preserving an unusable or untrusted database that the retained rollback runtime may reference; validate each candidate's metadata/schema/integrity, or correlate it with verified rollback state, before treating it as a superseded publication.

Useful? React with 👍 / 👎.

)

return preserve


def _copy_tree(source: Path, destination: Path) -> None:
shutil.copytree(source, destination)

Expand Down Expand Up @@ -913,7 +936,9 @@ def _stage_evidence(
observed_at: str,
mint_origin: str,
relay_origin: str | None,
) -> None:
) -> str | None:
"""Stage one evidence cell and report the extract it bound, if it holds one."""

source = assets / "evidence" / "cells" / cell
_copy_tree(source, runtime)
public = _public_jwk(_read_secret(secrets, "signing-public.jwk"), allow_rsa=False)
Expand Down Expand Up @@ -955,6 +980,7 @@ def _stage_evidence(
for directory in [secret_output, *secret_output.rglob("*")]:
if directory.is_dir():
directory.chmod(0o700)
return extract_name


def _stage_mint(
Expand Down Expand Up @@ -1103,7 +1129,7 @@ def _provision_target(args: argparse.Namespace) -> None:
if cell in DIRECT
else now
)
_stage_evidence(
extract_name = _stage_evidence(
assets,
cell,
args.secrets.absolute(),
Expand All @@ -1117,15 +1143,34 @@ def _provision_target(args: argparse.Namespace) -> None:
relay_origin,
)
runtime_preserve = _preserve_extract_rollback if cell in DIRECT else None
# `publish-extract` leaves the superseded extract beside the one it
# bound, exactly as it leaves the superseded runtime, so a later full
# provision has to look past both to recognise its own tree. Each cell
# provisions its own extract output, so the allowance is scoped to the
# one authority that publishes into this one.
extract_preserve = (
_preserve_superseded_extracts(DIRECT[cell][1], extract_name)
if cell in DIRECT and extract_name is not None
else None
)
_check_secret_install_tree(secret_output, args.secret_output.resolve())
if cell in DIRECT:
_check_install_tree(extracts, args.extract_output.resolve())
_check_install_tree(
extracts,
args.extract_output.resolve(),
preserve=extract_preserve,
)
_check_install_tree(
runtime, args.runtime_output.resolve(), preserve=runtime_preserve
)
_install_secret_tree(secret_output, args.secret_output.resolve())
if cell in DIRECT:
_install_tree(extracts, args.extract_output.resolve(), root_mode=0o555)
_install_tree(
extracts,
args.extract_output.resolve(),
root_mode=0o555,
preserve=extract_preserve,
)
else:
raise ProvisionError("invalid target")
_install_tree(
Expand Down
155 changes: 155 additions & 0 deletions scripts/test_provision_hosted_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,161 @@ def stage(_assets, _cell, destination, _published_at, _observed_at):
previous_name,
)

def _published_extract_provision(
self, root: Path, superseded: str, active: str
) -> object:
"""Lay out the state `publish-extract` leaves behind and return the
parsed provision arguments that a later full run would use."""
runtime_output = root / "runtime-output"
extract_output = root / "extract-output"
runtime_output.mkdir()
extract_output.mkdir()
provisioner._write(extract_output / superseded, b"superseded", 0o444)
provisioner._write(extract_output / active, b"active", 0o444)
provisioner._write(runtime_output / "runtime.yaml", b"runtime", 0o444)
provisioner._write(
runtime_output
/ f"runtime.rollback-{superseded.removesuffix('.sqlite')}.yaml",
b"rollback",
0o444,
)
return provisioner.parser().parse_args(
[
"provision",
"--target",
"sro-evidence",
"--assets",
str(root / "assets"),
"--secrets",
str(root / "secrets"),
"--runtime-output",
str(runtime_output),
"--secret-output",
str(root / "secret-output"),
"--extract-output",
str(extract_output),
"--bind-host",
provisioner.EXPECTED_BIND_HOST,
"--mint-origin",
provisioner.MINT_ORIGIN,
]
)

@contextlib.contextmanager
def _staged_provision(self, active: str):
def stage(
_assets,
_cell,
_secrets,
runtime,
_secret_output,
extracts,
_bind_host,
_published_at,
_observed_at,
_mint_origin,
_relay_origin,
):
provisioner._write(runtime / "runtime.yaml", b"runtime", 0o444)
provisioner._write(extracts / active, b"active", 0o444)
return active

with (
mock.patch.object(provisioner, "verify_assets"),
mock.patch.object(provisioner, "_validate_secret_inventory"),
mock.patch.object(provisioner, "_confine_secret_inventory"),
mock.patch.object(provisioner, "_consume_secret_inventory"),
mock.patch.object(
provisioner, "_publication_time", return_value="2026-08-12T10:00:00Z"
),
mock.patch.object(provisioner, "_stage_evidence", side_effect=stage),
mock.patch.object(provisioner, "_check_secret_install_tree"),
mock.patch.object(provisioner, "_install_secret_tree"),
):
yield

def test_provision_composes_with_a_published_extract(self) -> None:
"""`publish-extract` appends the extract it binds beside the one it
supersedes and leaves a rollback runtime next to the runtime it rewrote.
A later full provision has to look past both to recognise its own tree,
and must leave the superseded extract where a rollback can still find
it."""
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
superseded = "sro-poverty-20260812T090000Z.sqlite"
active = "sro-poverty-20260812T100000Z.sqlite"
arguments = self._published_extract_provision(root, superseded, active)
with self._staged_provision(active):
provisioner.provision(arguments)

extract_output = root / "extract-output"
self.assertEqual((extract_output / superseded).read_bytes(), b"superseded")
self.assertEqual((extract_output / active).read_bytes(), b"active")
self.assertEqual(
stat.S_IMODE((extract_output / superseded).stat().st_mode), 0o444
)
self.assertEqual(stat.S_IMODE(extract_output.stat().st_mode), 0o555)

def test_provision_still_refuses_an_unpublished_extract_file(self) -> None:
"""The allowance is only for extracts this provisioner published, so an
extract output carrying anything else is still a refusal rather than a
directory the check has stopped reading."""
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
superseded = "sro-poverty-20260812T090000Z.sqlite"
active = "sro-poverty-20260812T100000Z.sqlite"
arguments = self._published_extract_provision(root, superseded, active)
provisioner._write(
root / "extract-output" / "sro-poverty-restored.sqlite",
b"restored",
0o444,
)
with self._staged_provision(active), self.assertRaisesRegex(
provisioner.ProvisionError, "existing output mismatch"
):
provisioner.provision(arguments)

def test_only_a_superseded_published_extract_is_preserved(self) -> None:
active = "sro-poverty-20260812T100000Z.sqlite"
superseded = "sro-poverty-20260812T090000Z.sqlite"
preserve = provisioner._preserve_superseded_extracts("sro-poverty", active)
self.assertTrue(preserve(superseded, ("digest", 0o444)))
# The extract this run staged is its own output and stays verified.
self.assertFalse(preserve(active, ("digest", 0o444)))
# Only the three direct authorities publish extracts, only at the top of
# the output, only as immutable files.
self.assertFalse(
preserve("sipf-pension-20260812T090000Z.sqlite", ("digest", 0o444))
)
self.assertFalse(preserve(f"nested/{superseded}", ("digest", 0o444)))
self.assertFalse(preserve(superseded, ("directory", 0o444)))
self.assertFalse(preserve(superseded, ("digest", 0o644)))
self.assertFalse(preserve(f"{superseded}.bak", ("digest", 0o444)))
# Every cell provisions its own extract output, so a publication named
# after another authority is a mismatch rather than a rollback this cell
# could ever take.
self.assertFalse(
preserve("cra-birth-20260812T090000Z.sqlite", ("digest", 0o444))
)

def test_provision_refuses_a_foreign_authority_extract(self) -> None:
"""An extract output belongs to one cell, so a publication carrying
another authority's prefix is state this run cannot account for."""
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
superseded = "sro-poverty-20260812T090000Z.sqlite"
active = "sro-poverty-20260812T100000Z.sqlite"
arguments = self._published_extract_provision(root, superseded, active)
provisioner._write(
root / "extract-output" / "cra-birth-20260812T090000Z.sqlite",
b"foreign",
0o444,
)
with self._staged_provision(active), self.assertRaisesRegex(
provisioner.ProvisionError, "existing output mismatch"
):
provisioner.provision(arguments)

def test_extract_append_never_overwrites_a_mismatched_filename(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
Expand Down
Loading