Repository navigation
fix(access-control): provision the enforcement mode instead of inheriting an unset key (#14866) - #15088
Merged
Conversation
…ting an unset key (#14866)
This was referenced Aug 26, 2026
Contributor
✅ SSOT Configuration Compliance: Passing🎉 No new hardcoded values of either class — Known backlog in |
This was referenced Aug 26, 2026
Merged
This was referenced Sep 5, 2026
Closed
mrveiss
added a commit
that referenced
this pull request
Sep 5, 2026
…ins (#15726) (#15727) Every self-update on this fleet has been failing at one task, leaving the install 34 commits behind: Enforcement mode provisioner not found at /opt/autobot/autobot-infrastructure/shared/scripts/security/seed_enforcement_mode.py 00-SLM-Manager : ok=126 changed=25 unreachable=0 failed=1 roles/access_control resolved the seeder against project_root. Nothing deploys that tree: on the live SLM it dates from install time in June, while the seeder arrived in August via #15088. The file is present and correct in code_source; it is absent from the path the role read. The four other consumers of the tree all read the controller checkout. access_control was the only one pointing at a path nothing maintains, and the only one that hard-fails when a file is missing. Also fixes a second, latent instance: rotate-service-keys.yml resolved its key generator against a hardcoded autobot_root of /opt/autobot. That one works today only because the generator predates the stale copy, so a change to it would never reach the host. The new guard binds the half the existing one could not see. The repo-side assertions were green throughout -- the file exists in the repository, which is all they checked -- while the deployment was broken. It now fails any ansible value resolving into the tree from a root nothing populates. Anchored on a Jinja root so prose in a failure message and a git pathspec are not mistaken for filesystem references, and the reach floor binds to references discovered rather than to findings. 15 tests pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #14866
Thinking Path
feature_flag:access_control:enforcement_modeexists in no Redis database on adeployed install, so
get_enforcement_modetakes its unset branch, returnsDISABLEDwith no exception, andvalidate_ownershipshort-circuits withauthorized: True, reason: "enforcement_disabled"before any ownership lookup.The two prior patches do not cover this route: one closed the unreadable
path, which needs an outage; the unset path needs nothing at all, and it is the
one in effect.
The decision this implements is write the flag, keep the semantics. Nothing
here changes what an unset key or
log_onlymean —unset -> DISABLEDandlog_only -> alloware kept deliberately. What changes is that unset stopsbeing the production state, because provisioning now gives the install a value
it was handed rather than one it fell back to.
The value written is
log_only, which is what the issues prescribe, not achoice made here:
decided and recorded, with the
log_onlymeasurement before any flip toenforced".has never been on anywhere,
log_onlyis strictly more information thanexists today and refuses nothing. That is the safe first value."
Under
log_onlyevery ownership check runs and every violation is audited andmetered, and no request that succeeds today starts being refused. The flip to
enforcedis the separate, measured step both issues describe and isdeliberately not made here.
The writer goes on the provisioning path, not application startup: the
access_controlrole is what configures this subsystem on an install, and italready runs an operator script from the shared scripts tree the same way.
What Changed
autobot-backend/services/feature_flags.pyENFORCEMENT_MODE_KEYconstant replaces the repeated key literal at the read and write sites;PROVISIONED_ENFORCEMENT_MODE_DEFAULT/..._ENVrecord the posture;resolve_provisioned_enforcement_mode()resolves explicit value -> env -> default and raises on an unrecognised value;seed_enforcement_mode()writes withSET NXand reports(written, effective_mode)autobot-infrastructure/shared/scripts/security/seed_enforcement_mode.py--mode,--dry-run, canonical logger, exit codes0unchanged /1failed /2seeded.../roles/access_control/tasks/enforcement_mode.ymlchangedfrom the seeded exit code, fails the run on anything else.../roles/access_control/tasks/main.yml,defaults/main.ymlaccess_control_enforcement_mode: log_onlyand the seeder path as role variablesautobot-backend/services/feature_flags_enforcement_seed_test.pyrepo_tests/access_control_enforcement_provisioning_test.pyIdempotency is Redis's, not ours: the write is
SET NX, one atomicoperation, so the check and the write cannot be split by a concurrent run and
re-provisioning leaves an operator's deliberate value — including a deliberate
disabled— exactly as it found it. A test asserts the call is made withnx=Truerather than only asserting the outcome, since a read-then-write wouldpass the outcome test and still race.
No hardcoding: the posture lives in one module constant, overridable by
ACCESS_CONTROL_ENFORCEMENT_MODEand by the role variable; the tests assertagainst the constant, never a repeated literal. It is a module constant rather
than an SSOT field because
ssot_config.pysits exactly at its file-sizeceiling and that ratchet is down-only.
Verification
Both new suites, and the existing suites over the same code:
Contrast mutations — each applied, the named test run, then restored:
test_the_key_is_written_when_it_is_absentKeyErroron the enforcement-mode keynx=Truedropped, so the write always landstest_an_existing_value_is_left_untouchedassert True is Falsedisabledtest_the_prescribed_posture_is_the_one_the_issues_recordassert DISABLED is LOG_ONLYtest_the_role_default_matches_the_python_default- disabled / + log_onlytest_the_role_includes_the_enforcement_mode_tasks'enforcement_mode.yml' not in [...]test_the_role_includes_the_enforcement_mode_tasksE is the non-vacuity control: an empty enumeration fails rather than passing
quietly, so a role whose task list stopped being parsed cannot report clean.
The mode enumeration in the "every operator value is preserved" test and in the
role-default validity test carries the same floor.
Tooling, on the four touched/added Python files:
Branch is 0 commits behind the base.
Honest assessment against #14866's criteria —
Refs, notClosesdisabledbecause a key was never writtenCriterion 5 asks for the fallback itself to go, and removing it changes what an
unset key means — exactly the semantics this decision keeps. It stays open, now
with the mitigation in place rather than as an unmitigated exposure.
Filed from this work
FeatureFlags.set_enforcement_mode,get_rollout_statisticsand the endpointhistory reads call
redis._redis.<list op>on a client that has no_redisattribute, so those calls raise
AttributeErrorinto their ownexceptandreport failure — the admin API's writer sets the key and then reports
False.Untouched here (a different path, and widening this change would widen its blast
radius); filed separately. The new seeder does not use that path.
Model Used
Claude Opus 5 (1M context)