You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Enforce root ownership for internal credential files
Context
On an endpoint-enabled bootroot host, five fixed files below the state-recorded secrets directory authenticate the host to OpenBao or configure its dedicated agent: registrar-internal/key.pem, chain.pem, acme-account.json, root-fingerprint, and agent.toml. They must be published as UID 0, GID 0, and mode 0600.
origin/main already applies StagedMode::Policy(KEY_FILE_MODE) (0600) to every one of these files before its atomic rename, and the registrar-internal init E2E already checks that mode. The missing invariant is ownership: the general Destination::bootroot_owned wrappers preserve an existing destination's owner and leave a new destination owned by the invoking process.
Four protected files are published through src/registrar/internal/material.rs::write_atomic. agent.toml has two separate publish paths: publish_internal_files during init/credential publication and write_trust_pair during full-rotation Phase 3 and Phase 6. A full rotation reaches its trust-pair writer when the internal credential set is present; that set identifies a registrar host, so the writer must enforce root:root unconditionally when it is reached and must not add a separate endpoint-enabled conditional. The all-or-none internal set has a sixth member, ca-bundle.pem; it is public trust material and remains under its existing CertGroupPolicy::none() ownership policy. The reference documentation already states the five-file root-owned 0600 contract; the new operator-facing requirement is that an endpoint-enabled host is root-operated.
Scope
Add an opt-in fixed-owner staged-write API whose ownership-establishment step accepts the expected UID and GID, chowns the temporary inode before applying its mode, fsyncing it, and renaming it. Production protected-file call sites pass UID 0 and GID 0. Keep the existing atomic_write and atomic_replace owner-preserving wrappers unchanged; the protected paths must reach the fixed-owner API explicitly.
Use that API at all three protected publication sites: the four-file material writer, init's agent.toml publication, and rotation's agent.toml trust-pair publication. Apply the same root:root policy to .prior snapshot copies of the five protected files and to their restore writes. A root-run replacement of a pre-existing non-root-owned protected file deliberately converges its replacement and any protected snapshot or restore to root:root; do not preserve the old destination owner. Keep ca-bundle.pem, including its snapshot and restore path, on its current ownership policy.
Endpoint-enabled registrar-internal publication is root-only. A process that cannot establish root:root ownership must fail before rename; it must not publish the affected final name or continue with a best-effort, warning-only, or invoking-user-owned result. The resulting error must name both the root-ownership requirement and the file being published. If snapshot capture fails while establishing ownership, leave all final names untouched and remove any partial .prior snapshot.
Document in docs/en/installation.md and docs/ko/installation.md that operators must run endpoint-enabled bootroot init as root and continue to use root for commands that modify that installation. The existing reference table needs no ownership-policy rewrite.
Acceptance criteria
A root-run, endpoint-enabled init publishes each of the five protected files as UID 0, GID 0, and mode 0600; mode remains a regression guard rather than new functionality.
Every protected replacement path reasserts UID 0 and GID 0 before rename: the four-file material publisher, init's agent.toml writer, and rotation's agent.toml trust-pair writer.
A non-root endpoint-enabled publication fails before modifying a protected final name and reports that root ownership is required for the named file. A capture failure leaves no partial .prior snapshot; a failure after a complete snapshot retains the existing all-or-none restoration behavior.
The five protected .prior copies are root:root and 0600; ca-bundle.pem remains the sixth all-or-none member but retains its existing policy.
The English and Korean installation guides state the root-operated requirement for an endpoint-enabled host.
Existing ownership behavior remains unchanged for the general atomic_write/atomic_replace wrappers and the private CA bundle. Service credentials and operator-named outputs keep their prior effective ownership: files the sidecars and step-ca read (the secrets/ tree) remain owned by that tree's owner even under a root-run init, via the containing-directory ownership policy their writers select. (Amended at verification: the original call-site-invariance wording was unsatisfiable together with this issue's own root-run E2E, which asserts exactly that sidecar-tree ownership — see the closing comment.)
Constraints
Do not set ownership after the final name is published. Preserve same-directory staging, atomic rename, restrictive temporary creation, chown-before-chmod-before-fsync ordering, and the current symlink policy. Do not weaken the policy to apply only when privileged: endpoint-enabled publication must explicitly require the authority to create root:root files.
The fixed-owner ownership step must take its expected owner as input. Production passes UID 0 and GID 0; no agent.toml key, environment variable, or non-test public API may change that. A #[cfg(test)]-gated constructor alone may supply the test process's effective UID and GID. It must drive the real staging, chown, and rename path, not a mock or a seam that only records a requested owner.
Running endpoint-enabled init as root also makes its newly created install-tree files root-owned outside the sidecar-read secrets/ tree, so later mutating bootroot commands on that host must run as root. (Amended at verification: writers of sidecar-read files select the containing-directory ownership policy, so a root-run init cannot strand files their readers — the step-ca server, step helper containers, and both OpenBao Agent sidecars, all running as the tree owner's uid — can no longer open; the root-run E2E asserts that tree's ownership. The original "not a change to any writer beyond the three protected publication sites" wording was unsatisfiable together with that E2E and is superseded — see the closing comment.)
Out of scope
Changing the registrar-internal credential lifecycle, OpenBao authentication, certificate or trust rotation policy, service credential ownership, private CA-bundle ownership, endpoint transport, process supervision, or the changelog.
Test plan
Add Unix unit tests through the real fixed-owner staging path, using the #[cfg(test)] constructor with the test process's effective UID and GID. Assert MetadataExt::uid() on real published files after a fresh publication and after a replacement; cover that the staged inode is chowned before rename. Do not use a mock that asserts the requested owner.
In the negative unit test, explicitly assert the non-root effective-UID precondition, then request UID 0 and assert the kernel chown failure leaves the old destination unmodified. CI runs the unit suite as an unprivileged user; this test is neither root-gated nor #[ignore]d.
Cover protected snapshot capture and restore ownership, snapshot cleanup on an ownership failure, and the independent init and rotation agent.toml writer selection.
Update scripts/impl/run-registrar-internal-init-e2e.sh to probe sudo -n true before setup and fail with a clear passwordless-sudo prerequisite if it is unavailable. Run endpoint-enabled bootroot init through sudo -n env, preserving BOOTROOT_LANG=en and the invoking user's HOME for Docker client configuration. This deliberately makes the scenario, and scripts/preflight/run-all.sh when it reaches that scenario, unavailable on a machine without passwordless sudo.
Reclaim root-owned E2E run output with sudo -n rm -rf on the known per-run root after container teardown, so remove_run_root removes the run root and the existing run-root-survived check remains green. Assert UID 0, GID 0, and mode 0600 for the five protected files in this root-run registrar-internal init E2E.
Run the affected Rust tests, ./scripts/check-docs.sh, and scripts/preflight/ci/e2e-matrix.sh; before pushing, run scripts/preflight/run-all.sh.
Enforce root ownership for internal credential files
Context
On an endpoint-enabled bootroot host, five fixed files below the state-recorded secrets directory authenticate the host to OpenBao or configure its dedicated agent:
registrar-internal/key.pem,chain.pem,acme-account.json,root-fingerprint, andagent.toml. They must be published as UID 0, GID 0, and mode0600.origin/mainalready appliesStagedMode::Policy(KEY_FILE_MODE)(0600) to every one of these files before its atomic rename, and the registrar-internal init E2E already checks that mode. The missing invariant is ownership: the generalDestination::bootroot_ownedwrappers preserve an existing destination's owner and leave a new destination owned by the invoking process.Four protected files are published through
src/registrar/internal/material.rs::write_atomic.agent.tomlhas two separate publish paths:publish_internal_filesduring init/credential publication andwrite_trust_pairduring full-rotation Phase 3 and Phase 6. A full rotation reaches its trust-pair writer when the internal credential set is present; that set identifies a registrar host, so the writer must enforce root:root unconditionally when it is reached and must not add a separate endpoint-enabled conditional. The all-or-none internal set has a sixth member,ca-bundle.pem; it is public trust material and remains under its existingCertGroupPolicy::none()ownership policy. The reference documentation already states the five-file root-owned0600contract; the new operator-facing requirement is that an endpoint-enabled host is root-operated.Scope
Add an opt-in fixed-owner staged-write API whose ownership-establishment step accepts the expected UID and GID, chowns the temporary inode before applying its mode, fsyncing it, and renaming it. Production protected-file call sites pass UID 0 and GID 0. Keep the existing
atomic_writeandatomic_replaceowner-preserving wrappers unchanged; the protected paths must reach the fixed-owner API explicitly.Use that API at all three protected publication sites: the four-file material writer, init's
agent.tomlpublication, and rotation'sagent.tomltrust-pair publication. Apply the same root:root policy to.priorsnapshot copies of the five protected files and to their restore writes. A root-run replacement of a pre-existing non-root-owned protected file deliberately converges its replacement and any protected snapshot or restore to root:root; do not preserve the old destination owner. Keepca-bundle.pem, including its snapshot and restore path, on its current ownership policy.Endpoint-enabled registrar-internal publication is root-only. A process that cannot establish root:root ownership must fail before rename; it must not publish the affected final name or continue with a best-effort, warning-only, or invoking-user-owned result. The resulting error must name both the root-ownership requirement and the file being published. If snapshot capture fails while establishing ownership, leave all final names untouched and remove any partial
.priorsnapshot.Document in
docs/en/installation.mdanddocs/ko/installation.mdthat operators must run endpoint-enabledbootroot initas root and continue to use root for commands that modify that installation. The existing reference table needs no ownership-policy rewrite.Acceptance criteria
0600; mode remains a regression guard rather than new functionality.agent.tomlwriter, and rotation'sagent.tomltrust-pair writer..priorsnapshot; a failure after a complete snapshot retains the existing all-or-none restoration behavior..priorcopies are root:root and0600;ca-bundle.pemremains the sixth all-or-none member but retains its existing policy.atomic_write/atomic_replacewrappers and the private CA bundle. Service credentials and operator-named outputs keep their prior effective ownership: files the sidecars and step-ca read (thesecrets/tree) remain owned by that tree's owner even under a root-run init, via the containing-directory ownership policy their writers select. (Amended at verification: the original call-site-invariance wording was unsatisfiable together with this issue's own root-run E2E, which asserts exactly that sidecar-tree ownership — see the closing comment.)Constraints
Do not set ownership after the final name is published. Preserve same-directory staging, atomic rename, restrictive temporary creation, chown-before-chmod-before-fsync ordering, and the current symlink policy. Do not weaken the policy to apply only when privileged: endpoint-enabled publication must explicitly require the authority to create root:root files.
The fixed-owner ownership step must take its expected owner as input. Production passes UID 0 and GID 0; no
agent.tomlkey, environment variable, or non-test public API may change that. A#[cfg(test)]-gated constructor alone may supply the test process's effective UID and GID. It must drive the real staging, chown, and rename path, not a mock or a seam that only records a requested owner.Running endpoint-enabled
initas root also makes its newly created install-tree files root-owned outside the sidecar-readsecrets/tree, so later mutatingbootrootcommands on that host must run as root. (Amended at verification: writers of sidecar-read files select the containing-directory ownership policy, so a root-run init cannot strand files their readers — the step-ca server,stephelper containers, and both OpenBao Agent sidecars, all running as the tree owner's uid — can no longer open; the root-run E2E asserts that tree's ownership. The original "not a change to any writer beyond the three protected publication sites" wording was unsatisfiable together with that E2E and is superseded — see the closing comment.)Out of scope
Changing the registrar-internal credential lifecycle, OpenBao authentication, certificate or trust rotation policy, service credential ownership, private CA-bundle ownership, endpoint transport, process supervision, or the changelog.
Test plan
#[cfg(test)]constructor with the test process's effective UID and GID. AssertMetadataExt::uid()on real published files after a fresh publication and after a replacement; cover that the staged inode is chowned before rename. Do not use a mock that asserts the requested owner.#[ignore]d.agent.tomlwriter selection.scripts/impl/run-registrar-internal-init-e2e.shto probesudo -n truebefore setup and fail with a clear passwordless-sudo prerequisite if it is unavailable. Run endpoint-enabledbootroot initthroughsudo -n env, preservingBOOTROOT_LANG=enand the invoking user'sHOMEfor Docker client configuration. This deliberately makes the scenario, andscripts/preflight/run-all.shwhen it reaches that scenario, unavailable on a machine without passwordless sudo.sudo -n rm -rfon the known per-run root after container teardown, soremove_run_rootremoves the run root and the existing run-root-survived check remains green. Assert UID 0, GID 0, and mode0600for the five protected files in this root-run registrar-internal init E2E../scripts/check-docs.sh, andscripts/preflight/ci/e2e-matrix.sh; before pushing, runscripts/preflight/run-all.sh.Dependencies
Part of #790.
Pointers
src/fs_util.rs(StagedOwner,publish_staged_blocking,atomic_write, andatomic_replace)src/registrar/internal/material.rs(write_atomic,capture_set, andSetSnapshot::restore)src/commands/init/steps/registrar_internal.rs(publish_internal_files)src/commands/rotate/registrar_internal.rs(write_trust_pair)scripts/impl/run-registrar-internal-init-e2e.sh(ensure_prerequisites,run_bootroot,run_init,remove_run_root, andcleanup)docs/en/installation.mdanddocs/ko/installation.md(the bootroot-internal registrar-agent section)