Skip to content

fix(access-control): provision the enforcement mode instead of inheriting an unset key (#14866) - #15088

Merged
mrveiss merged 1 commit into
Dev_new_guifrom
issue-14866-enforcement-flag-seed
Aug 26, 2026
Merged

mrveiss merged 1 commit into
Dev_new_guifrom
issue-14866-enforcement-flag-seed

Conversation

@mrveiss

@mrveiss mrveiss commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Refs #14866

Thinking Path

feature_flag:access_control:enforcement_mode exists in no Redis database on a
deployed install, so get_enforcement_mode takes its unset branch, returns
DISABLED with no exception, and validate_ownership short-circuits with
authorized: 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_only mean — unset -> DISABLED and
log_only -> allow are kept deliberately. What changes is that unset stops
being 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 a
choice made here:

Under log_only every ownership check runs and every violation is audited and
metered, and no request that succeeds today starts being refused. The flip to
enforced is the separate, measured step both issues describe and is
deliberately not made here.

The writer goes on the provisioning path, not application startup: the
access_control role is what configures this subsystem on an install, and it
already runs an operator script from the shared scripts tree the same way.

What Changed

File Change
autobot-backend/services/feature_flags.py ENFORCEMENT_MODE_KEY constant replaces the repeated key literal at the read and write sites; PROVISIONED_ENFORCEMENT_MODE_DEFAULT / ..._ENV record the posture; resolve_provisioned_enforcement_mode() resolves explicit value -> env -> default and raises on an unrecognised value; seed_enforcement_mode() writes with SET NX and reports (written, effective_mode)
autobot-infrastructure/shared/scripts/security/seed_enforcement_mode.py New provisioning entry point, patterned on the session-ownership backfill in the same directory: --mode, --dry-run, canonical logger, exit codes 0 unchanged / 1 failed / 2 seeded
.../roles/access_control/tasks/enforcement_mode.yml New role task: asserts the provisioner exists, runs it, derives changed from the seeded exit code, fails the run on anything else
.../roles/access_control/tasks/main.yml, defaults/main.yml Include the new task; access_control_enforcement_mode: log_only and the seeder path as role variables
autobot-backend/services/feature_flags_enforcement_seed_test.py 15 tests over the seeding behaviour and the semantics deliberately left alone
repo_tests/access_control_enforcement_provisioning_test.py 9 tests that provisioning actually reaches the seeder, and that the Ansible default and the Python default still agree

Idempotency is Redis's, not ours: the write is SET NX, one atomic
operation, 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 with
nx=True rather than only asserting the outcome, since a read-then-write would
pass the outcome test and still race.

No hardcoding: the posture lives in one module constant, overridable by
ACCESS_CONTROL_ENFORCEMENT_MODE and by the role variable; the tests assert
against the constant, never a repeated literal. It is a module constant rather
than an SSOT field because ssot_config.py sits exactly at its file-size
ceiling and that ratchet is down-only.

Verification

Both new suites, and the existing suites over the same code:

feature_flags_enforcement_seed_test.py + access_control_enforcement_provisioning_test.py   24 passed
session_ownership_enforcement_mode_test.py + feature_flags_rollout_stats_test.py
  + validate_access_control_reporting_test.py                                              20 passed
test_infra_scripts_import_14129.py + deployment_script_imports_resolve_test.py   252 passed, 1 skipped

Contrast mutations — each applied, the named test run, then restored:

Mutation Named test Result
A: the writer reports success but writes nothing test_the_key_is_written_when_it_is_absent FAILED — KeyError on the enforcement-mode key
B: nx=True dropped, so the write always lands test_an_existing_value_is_left_untouched FAILED — assert True is False
C: posture constant flipped to disabled test_the_prescribed_posture_is_the_one_the_issues_record FAILED — assert DISABLED is LOG_ONLY
C: same mutation, parity check test_the_role_default_matches_the_python_default FAILED — - disabled / + log_only
D: the role stops including the seeder task test_the_role_includes_the_enforcement_mode_tasks FAILED — 'enforcement_mode.yml' not in [...]
E: the role includes nothing at all (vacuity) test_the_role_includes_the_enforcement_mode_tasks FAILED — "no task file is included at all — this check would pass vacuously"

E 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:

py_compile                     OK
black --check --line-length 120  4 files would be left unchanged
isort --check-only               clean
flake8 --select=F                rc 0
bandit -c .bandit -q             rc 0
check_python_file_size.py --audit-ceilings   rc 0 (4859 scanned, 509 grandfathered, all at size)

Branch is 0 commits behind the base.

Honest assessment against #14866's criteria — Refs, not Closes

# Criterion After this PR
1 Live enforcement mode checked and recorded Already met before this PR
2 The validator imports a module that exists and fails loudly Already met before this PR
3 The mode has a deliberate, deployed value set by whatever provisions the install Met by this PR — in code and pinned by tests. Host evidence needs a provisioning run, which this PR does not perform
4 A guard asserts the deployment scripts' imports resolve Already met before this PR
5 An install cannot silently sit at disabled because a key was never written Not met. The unset fallback still exists by decision. This PR makes it unreachable on a provisioned install, which is not the same as removing it

Criterion 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_statistics and the endpoint
history reads call redis._redis.<list op> on a client that has no _redis
attribute, so those calls raise AttributeError into their own except and
report 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)

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

@mrveiss
mrveiss merged commit 02cc083 into Dev_new_gui Aug 26, 2026
70 checks passed
@mrveiss
mrveiss deleted the issue-14866-enforcement-flag-seed branch August 26, 2026 06:46
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant