Repository navigation
security(chat): ownership enforcement fails OPEN — an unavailable feature-flag service disables every check platform-wide #14010
Description
Activity
- added a commit that references this issue
on Aug 22, 2026 #14865 opens against step 1 of this issue: fail closed on infrastructure
failure. It does not close this issue — step 2 (deciding the default
posture) is untouched.One thing the analysis above missed, found while implementing
There is a fourth route to "no authorization", one layer deeper than the
three listed, and it is the reason the other two rarely fired.
FeatureFlags.get_enforcement_modecaught every exception itself:except Exception as e: logger.error("Failed to get enforcement mode: %s", e) # Fail-safe: default to DISABLED on error return EnforcementMode.DISABLED
Because that never propagated,
SessionOwnershipValidator._get_enforcement_mode's
ownexceptalmost never ran — the failure had already been converted into
"policy says off" before it got there. The comment calls it a fail-safe; for an
authorization control it is fail-open.What #14865 does
- A read failure now raises
EnforcementModeUnavailableinstead of inventing
DISABLED. An unset flag still returnsDISABLED— that is policy and
belongs to step 2. - All three undetermined routes in the validator degrade to
log_onlyand log
at WARNING, stating explicitly that this is not a deliberatedisabled.
log_onlyover denying, deliberately: every check runs and every violation is
recorded, while no request that works today starts failing. The outage becomes
loud instead of silently permissive, without smuggling in the posture change
step 2 exists to decide.Mutation evidence, with the policy path shown unmoved:
case PRE-FIX FIXED enforcement runs? flag store unreachable disabled log_only YES no flags service at all disabled log_only YES flag simply unset (policy) disabled disabled no (by policy)Still open here
Step 2 —
log_onlyfirst to size the fallout from real traffic, then
enforced. That changes what requests succeed, so it needs its own change and
rollout. I have not made that call.- A read failure now raises
- added a commit that references this issue
on Aug 23, 2026 Noted for step 2: the mode is resolved twice per request
Surfaced in the security review of #14865.
validate_ownershipcalls
_get_enforcement_mode()twice — once inside_resolve_fast_paths
(session_ownership.py:523) and again directly (:667).Harmless while the mode resolves cleanly. But after #14865 an undetermined
mode logs a WARNING each time, so a degraded window emits two warnings per
request — precisely the incident during which someone is reading the logs.Not fixed in #14865: that is a refactor of the authorization entry point, and
folding it into a security fix would widen the blast radius of a change whose
value is being small and reviewable. Recording it here because step 2 has to
touch this call path anyway — resolve once and pass the value through.- added a commit that references this issue
on Aug 23, 2026 Step 1 delivered — #14865 merged as
c5109f91f. This issue stays open.Verified in base by blob hash, not by the merge:
security/session_ownership.py 8b988ddb = 8b988ddb security/session_ownership_enforcement_mode_test.py b89bb3c0 = b89bb3c0 services/feature_flags.py 8dfd9049 = 8dfd9049And the behaviour, checked against
origin/Dev_new_guirather than the branch:raise EnforcementModeUnavailable— presentDEGRADED_ENFORCEMENT_MODE— present at all four sites"Fail-safe: default to DISABLED on error"— gone (0 occurrences)
What is now true
route to "no ownership check" before after flag store unreachable disabled— check skipped silentlylog_only+ WARNING — check runs, violation recordedflags service failed to construct disabledlog_only+ WARNINGunexpected error resolving the mode disabledlog_only+ WARNINGflag simply unset disableddisabled— unchanged, this is step 2The fourth row is the point: this changes what happens when the control
breaks, not what it does when it works.What remains — step 2, and it is a decision
Choosing the default posture when the flag is unset.
log_onlyfirst to size
the fallout from real traffic, thenenforced. That flips requests which
succeed today, so it needs its own change and its own rollout. I have not made
that call.Two things to carry into it:
log_onlyis still fail-open in substance for the outage window — an
unauthorized request succeeds, it is merely recorded. The security review and
I both judged that the right interim trade (denying would turn any flag-store
blip into a platform-wide read outage on users' own sessions, behind a
dependency not required for authentication), but it is a policy call on an
authorization control and should be made deliberately, not inherited from
fix(security): an unreadable enforcement mode must not disable ownership checks (#14010) #14865.- Resolve the mode once per request.
validate_ownershipcalls
_get_enforcement_mode()twice (session_ownership.py:523and:667), so a
degraded window now emits two WARNINGs per request — during exactly the
incident someone would be reading logs for. Step 2 touches this call path
anyway.
Step 2's framing needs revising — see #14866
Step 2 above says "decide the default", which assumes there is a current posture
someone chose. Checking that assumption: there is not.The mode is read from a Redis key. Only two things write it — the admin API
(a manual call) andvalidate_access_control.sh. That script sets it via:from backend.services.feature_flags import get_feature_flags, EnforcementMode
There is no
backend/package in this repo (the real path is
autobot-backend/services/feature_flags.py), and the script runs that block with
> /dev/null 2>&1. It contains 9 such dead imports. So the only automated
writer has never worked, and with the key unset the mode isDISABLED— which
short-circuitsvalidate_ownershipbefore the lookup, across 41 gated call
sites inapi/chat.py,api/chat_sessions.py,api/conversation_files.pyand
api/chat_sessions_thinking.py.I have not verified live state — an operator could have set it by hand through
the admin endpoint, and that can only be checked on the host. Filed as #14866
with that boundary stated.What this changes about step 2
There is no rollout to preserve and no working configuration to regress. The
choice is not "tighten from today's setting" —log_onlywould already be
strictly more information than exists now, andenforcedwould be the first time
these 41 sites have actually been guarded.It also means step 2 cannot be only a value change: whatever posture is chosen
has to be written by something that works, or an install keeps sitting at
disabledbecause a key was never set.Closure audit against merged
Dev_new_gui— stays OPENc5109f91f(PR #14865) is in base and delivers step 1's headline behaviour, but one criterion is contradicted by the merged change rather than satisfied by it, and step 2 is untouched.# Criterion Verdict Evidence 1 An unavailable/failing feature-flags service does not silently yield disabled; the behaviour on that path is explicit and logged atwarningor aboveMET autobot-backend/services/feature_flags.py:107-113now raisesEnforcementModeUnavailableinstead of returningDISABLED. All three routes inautobot-backend/security/session_ownership.pyconverge onDEGRADED_ENFORCEMENT_MODE = "log_only"(:64): no flags service:255-263,EnforcementModeUnavailable:268-275, unexpected exception:276-283— each with alogger.warning. Audibility is pinned bysession_ownership_enforcement_mode_test.py:104test_the_degrade_is_audible.2 Test: flags service raising does not produce an authorized result for a non-owner NOT MET — contradicted The degraded mode is log_only, andsession_ownership.py:483-493returns{"authorized": True, "reason": "log_only_mode"}for a non-owner. The merged test asserts exactly that:session_ownership_enforcement_mode_test.py:157test_a_mismatch_during_an_outage_is_allowed_but_audited,:171assert result["authorized"] is True. So under an outage a non-owner is authorized today. What did change is real and worth having — the ownership lookup now runs and the violation is audited and metered (:178-181), where before thedisabledshort-circuit skipped all of it. But that is a different, weaker statement than the criterion.3 Test: the deliberate disabledmode is still distinguishable in logs from the failure pathMET The failure path logs WARNINGnaminglog_only(:258,:269,:279); the deliberate path logsDEBUG[DISABLED MODE] Skipping ownership validation(:526) and emits no warning. Contrast pinned bysession_ownership_enforcement_mode_test.py:70and:185test_a_deliberate_disabled_still_short_circuits, which asserts the deliberate mode still skips the lookup and files no violation.4 The intended default enforcement mode is decided and recorded, with the log_onlymeasurement before any flip toenforcedNOT MET Step 2 was deliberately scoped out and the merged test says so: session_ownership_enforcement_mode_test.py:132test_an_unset_flag_still_means_disabled— "The default posture is a separate decision (#14010 step 2) — untouched." No provisioning path writes the flag.5 No endpoint's behaviour changes silently — the rollout step lists what starts being refused NOT MET Vacuously true only because no rollout has happened. Nothing is being refused because the mode is still off. End-to-end truth on the live install
This issue and #14866 compound, so the useful question is what the platform actually does today, not whether either patch landed. Checked on the deployed install, which is running the merged code:
feature_flag:access_control:enforcement_modedoes not exist in any of the 16 Redis databases (read-onlyEXISTSsweep,-n 0..15, zero hits).- Service log:
services.feature_flags - INFO - Enforcement mode not set, defaulting to DISABLED(2026-08-25 20:32:46).
The path that runs is therefore neither of the ones this issue fixed.
get_enforcement_modetakes the unset branch atservices/feature_flags.py:99-105and returnsDISABLEDcleanly — no exception, soEnforcementModeUnavailablenever fires and thelog_onlydegrade never engages.session_ownership.py:523-531then short-circuits withauthorized: True, reason: "enforcement_disabled"before the ownership lookup.Chat ownership enforcement is OFF today. #14865 closed the fail-open route that requires an outage; the fail-open route that requires nothing at all is still open, and it is the one in effect. That is #14866, and it is why these two should not be assessed separately.
Scope note:
api/conversation_files.py:230is a separate, non-flag-gated ownership validator that does enforce. The exposure is the call sites behindsecurity/session_ownership.py:801—api/chat.py,api/chat_sessions.py,api/chat_sessions_thinking.py.What is left
Criterion 2 needs a decision, not a patch: either the degrade posture becomes deny (and criterion 2 is met), or the criterion is amended to match the shipped
log_onlyposture and the reasoning recorded here. Both change or re-baseline an authorization control, so neither should be written on an auditor's judgement.Criteria 4 and 5 are step 2 and are blocked on the same posture decision as #14866 criteria 3 and 5. Suggest they be settled together, once: pick the posture, have provisioning write it, remove the unset-key fallback, then measure under
log_onlybefore any flip toenforced.Posture decision recorded: write the flag, keep the semantics
The owner has made the call this issue's step 2 was waiting on. Recording it
here so it is not re-litigated, and so nobody "fixes" the semantics by surprise.Decision.
unset -> DISABLEDandlog_only -> alloware kept
deliberately. Neither is a bug to be corrected. What changes is that unset
stops being the production state: a provisioning path now writes the enforcement
mode explicitly, so an install has a posture it was handed rather than one it
fell back to.The mitigation is PR #15088 (#14866): the
access_controlrole runs a seeder
that writesfeature_flag:access_control:enforcement_modewithSET NX, so the
key is written when absent and an operator's deliberate value — including a
deliberatedisabled— is never overwritten by a re-provision. The value
written islog_only, which is what this issue's criterion 4 already
prescribes ("thelog_onlymeasurement before any flip toenforced") and what
#14866's audit called "the safe first value". The flip toenforcedremains a
separate, measured step and was not made.Any PR that changes what an unset key or
log_onlymean is, as of this
decision, the wrong PR.Criterion 2 is a known, accepted state — not an oversight
Criterion 2 asks that a flags-service failure not produce an authorized result
for a non-owner. It remains contradicted by merged code, and deliberately so:- the degraded posture is
log_only(security/session_ownership.py,
DEGRADED_ENFORCEMENT_MODE), and thelog_onlybranch returns
{"authorized": True, "reason": "log_only_mode"}for a non-owner; - the merged test asserts exactly that —
session_ownership_enforcement_mode_test.py,
test_a_mismatch_during_an_outage_is_allowed_but_audited, whose
assert result["authorized"] is Truesits at roughly:171.
The trade was made knowingly when #14865 landed: denying would turn any
flag-store blip into a platform-wide read outage on users' own sessions, behind
a dependency that authentication does not require. What the degrade buys is that
the ownership lookup runs and the violation is audited and metered, where
the olddisabledshort-circuit skipped all of it.So criterion 2 should be read as re-baselined rather than pending: the shipped
posture is allow-and-audit during an outage, on purpose. Anyone revisiting it is
changing a decision, not fixing a defect, and the same goes for criterion 5,
which asks for the unset fallback to be removed — removing it changes what an
unset key means, which this decision keeps.Status of the two issues after PR #15088: #14866's criterion 3 is met in code;
its criterion 5 stays open with the exposure now mitigated rather than
unmitigated. This issue's criteria 4 and 5 are the same story — the posture is
decided and recorded here, the measurement underlog_onlyis what comes next.Also filed from that work: #15089 —
services/feature_flags.pycalls
redis._redison a client that has no such attribute, soset_enforcement_mode
writes the key and then reportsFalse, and the mode-change history is never
recorded. Relevant here because it means the admin API has never been a reliable
way to move this posture either.- the degraded posture is
AC4 / AC5 need a decision — options and a recommendation
PR #15158 (
Refs, notCloses) delivers what remained of step 1: AC3's distinguishability, asserted so that convergence fails the suite rather than only a regression in loudness. AC1 was already met by #14865 and AC2 was re-baselined by the posture decision recorded above. AC4 and AC5 remain, and they are not patches.The current state has moved — describe it correctly
PR #15155 (#14866) changes what an unset key means. The reader's fallback moves from
DISABLEDtoPROVISIONED_ENFORCEMENT_MODE_DEFAULT(log_only), matching what the provisioning seeder writes. Verified against that diff, not assumed.So once #15155 lands, the baseline AC4 is choosing from is:
flag state before after #15155 unset (every never-provisioned install) disabled— lookup skippedlog_only— every check runs, every violation audited, nothing refusedseeded by provisioning never written log_only(SET NX, an operator's deliberate value never overwritten)store unreachable / flags service failed log_only+ WARNING (#14865)unchanged deliberately set honoured honoured This matters for the framing: AC4 is no longer "turn on measurement". After #15155 the measurement posture is the default. AC4 becomes solely whether and when to flip to
enforced, and AC5 becomes what starts being refused when you do.What AC4's measurement actually requires
AC4 asks for "the
log_onlymeasurement before any flip toenforced". That measurement does not exist and cannot be produced from this repository. Concretely it needs:log_onlygenuinely in effect on a real install. After fix(access-control): run the enforcement-mode seeder from the paths that provision (#14866) #15155 that follows from provisioning — but only on an install that has actually been re-provisioned. Existing installs with the key already absent get it on the next provisioning run; nothing back-fills a running node.- The violations to be counted, not just logged.
_audit_log_violationand_record_violation_metricsboth fire inlog_only. Someone has to read them over a representative window and produce a count of distinct(user, session, endpoint)triples thatenforcedwould have refused. - A window long enough to include the periodic traffic — shared/exported sessions, admin access, anything weekly. A 24-hour sample will under-count.
- The false-positive classes separated out.
_owner_when_cache_is_emptyand the org-admin path exist precisely because a raw owner mismatch is not the same as an intrusion. A count that does not split legitimate org-admin access and legacy unowned sessions from genuine cross-user access will overstate the blast radius and stall the flip. - Host access. All of the above is host evidence. Nothing in this repository can supply it, and I have not gathered it.
Until that exists, any flip to
enforcedis unmeasured by definition.Options for AC4
Option What it buys What it costs A Land #15155, run one measurement window under log_only, then decideThe flip is made against a real number instead of an estimate; the false-positive classes surface before they become 403s Needs a host, an owner for the log read, and a defined window. AC4/AC5 stay open until it completes B Flip to enforcednowEnds the exposure immediately across the gated call sites in api/chat.py,api/chat_sessions.py,api/chat_sessions_thinking.pyDirectly contradicts AC4's own wording. These sites have never been enforced, so the fallout is entirely unknown — including on paths that are legitimate today C Per-endpoint enforcedon the highest-value routes first, global stayslog_onlyAlready wired and merged (#15086); an override can only ever tighten, never loosen. Bounded blast radius; measure the rest in parallel Partial. AC5's list is then per-endpoint and has to be maintained as routes are added D Leave it; accept log_onlyas the terminal postureZero risk of refusing a working request Ownership is then permanently advisory. Violations are recorded and nothing is ever prevented — the control reports a posture it does not enforce, which is the family of defect this issue exists to close Recommendation
A, with C available as the ramp. Land #15155 so
log_onlyis the actual default rather than a value only provisioning writes; take one measurement window; use #15086's per-endpoint overrides to move the clearest routes toenforcedfirst, so the global flip lands against a shrinking, already-understood set rather than a single fleet-wide step.B is not recommended — it is the one option AC4 explicitly rules out.
D is not recommended, but it is a legitimate call and should be recorded as one if chosen, rather than reached by default.AC5 is downstream of AC4 and cannot be written yet
AC5 asks the rollout step to list what starts being refused. That list is the measurement's output — the distinct violating triples from step 2 above, minus the false-positive classes from step 4. It cannot be written before the window runs. Writing it from static call-site analysis would produce a list of what is gated, not of what starts failing, which is the different and much less useful statement.
What I did not decide, and what I could not prove
- I did not change the default, flip anything to
enforced, or touch whatlog_onlyor an unset key mean. test(security): assert an outage and a deliberate 'disabled' cannot converge (#14010) #15158 adds no production code. - I could not verify live enforcement state: nothing here is deployed, and the audit above establishes that the only usable evidence for AC4 is host evidence.
- addedneeds-decisionBlocked on an owner decision; options and a recommendation are on the issueBlocked on an owner decision; options and a recommendation are on the issue
on Aug 28, 2026 - added a commit that references this issue
on Aug 28, 2026 The
log_onlymeasurement AC4 asks for, taken on a live install — and why it does not yet support a flipAC4 requires "the
log_onlymeasurement before any flip toenforced". I took it on a running install (deployed revision7952df8986, current withDev_new_gui). The result is not the clean "N violations observed" number the criterion anticipates, and the reason matters more than the number.What the install is actually set to
Established first, since the measurement is meaningless without it (full evidence on #14866):
feature_flag:access_control:enforcement_mode— absent from every database, verified byEXISTSagainst a connection proven live (db0 holds 2304 keys, so an empty read is a real absence rather than a failed connection).- The deployed reader resolves an absent key to
log_only(services/feature_flags.py:121, returned at:191).
So the install is genuinely in
log_only, which is the state AC4 wants measured.The measurement
Signal Count [LOG_ONLY MODE] Would block access but allowing…0 Any log line matching enforcement,ownership, orLOG_ONLY0 Enforcement mode not set; using the provisioned default…0 INFO-level lines overall in the same log 5086 Chat sessions actually created (real traffic, not health checks) several, e.g. chat_history.session - INFO - Created new chat session: …Modules observed logging includes api.chat_sessions— one of the 41 call sites' modulesWhy "zero violations" is not evidence that a flip is safe
The obvious reading — nobody violated ownership, so
enforcedwould refuse nothing — is not supported, and asserting it would be the exact mistake this criterion exists to prevent.get_enforcement_mode()logs at INFO the first time it resolves an absent key, guarded by_enforcement_default_logged. That line should appear once per instance regardless of whether any violation occurs. It appears zero times, in a log that captured 5086 other INFO lines and is current to within minutes. Neitherservices.feature_flagsnorsecurity.session_ownershipappears among the modules that logged anything at all.The most consistent explanation is that the ownership validation path did not execute on this install under this traffic — not that it executed and found nothing. Those two produce identical zeros and have opposite implications:
- Executed, no violations →
enforcedrefuses nothing; the flip is safe. - Never executed →
log_onlyhas measured nothing, and the flip's blast radius is entirely unknown.
I cannot currently distinguish them from logs alone, so AC4 is not satisfied by this measurement and I am not recording it as such.
What would make the measurement conclusive
The cheap discriminator is a single INFO line proving the path ran: the existing "Enforcement mode not set" line is already that instrument, and its silence is the finding. Either
- the call sites are not reached by the traffic this install serves (in which case
log_onlywill never produce data, and the flip needs a different justification — an exercised staging path, or a deliberate test request), or - something short-circuits before
get_enforcement_mode()is called, which would be a defect worth its own issue.
Confirming which requires one authenticated request to a gated endpoint and a check for that log line. I have not made one: it writes to a live install's data, and the choice of doing that belongs to whoever owns the flip decision.
One thing I checked and it is not a finding
The live log shows
Path exempt from service auth exempt_pattern=/api/chat path=/api/chat/sessions. That ismiddleware/service_auth_enforcement.py:148and it is service-to-service auth, deliberately exempting frontend-accessible endpoints (is_path_exempt's docstring says exactly that). User authentication still applies: an unauthenticatedGET /api/chat/sessionsagainst this install answers 401. Recording it because the line reads alarming out of context and someone will hit it later.Bearing on AC5
AC5 asks that no endpoint's behaviour change silently, with the rollout listing what starts being refused. That list cannot be written from this install's data either, for the same reason — nothing has been observed being allowed-but-would-be-refused.
Correction to my
log_onlymeasurement above — the cause was wrongI reported zero
[LOG_ONLY MODE]lines and zero enforcement/ownership log lines across 5086 INFO lines, and concluded the ownership path most likely was not reached by this install's traffic. That inference was wrong, and the real explanation is simpler.autobot-backend started : 2026-08-29 08:06:35 (NRestarts=0) feature_flags.py deployed : 2026-08-30 09:39:51 .py files newer than the backend's start: 3885The running process has not restarted since before those files were replaced, so it is executing pre-deployment code. If the loaded
feature_flags.pypredates #15155, an absent key resolves toDISABLEDandvalidate_ownershipshort-circuits before the ownership lookup — which logs nothing, by design. That produces exactly the silence I observed, with no need for the call sites to be unreached.The two readings are still indistinguishable from logs alone, but the candidate explanations have changed, and one of them means the exposure is live right now:
Explanation Implication Running code takes the old DISABLEDshort-circuitownership is not being checked on this host; the flip's blast radius is unknown Path genuinely unreached by this traffic log_onlyhas measured nothing; the flip's blast radius is unknownEither way AC4 remains unmet, and my earlier conclusion should not be relied on for the posture decision. What settles it is a restart of
autobot-backend, after which theEnforcement mode not set; using the provisioned defaultINFO line either appears or does not — and that single line discriminates between the two.I have also reopened #14866, which I had closed on the same faulty reasoning.
Cross-link: #15323 records the systemic cause — long-lived services keep running pre-deployment code and nothing reports the divergence. On this install the backend, celery and ai-stack have not restarted since a day before 3,885
.pyfiles were replaced under them. That is why the verification here read the file rather than the process.- addedarea: chat-tenancyWave 3 · cluster F — Chat tenancy & session ownershipWave 3 · cluster F — Chat tenancy & session ownership
on Sep 1, 2026 The stale-process blocker has cleared. AC4/AC5 are still unmet, for a different reason — and the new reason is measurable.
The recorded blocker was that "the log_only measurement reads zero, but the running process predates the fix, so zero is unreadable rather than reassuring." That is no longer true.
The backend restarted 2026-09-08 20:25 and now matches the deployed tree:
process started 2026-09-08 20:25:42 .py files under /opt/autobot newer than the process 0So the running code is the fixed code, and #13539's "fix landed, not reaching" no longer applies to this issue.
The mode is set, deliberately, and is not the fail-open:
feature_flag:access_control:enforcement_mode -> log_only (present, db 5)Present rather than absent, so this is a chosen
log_onlyand notPROVISIONED_ENFORCEMENT_MODE_DEFAULT. And no degradation occurred: zerodegrading to log_only/UNDETERMINED/could not be resolvedlines since the restart. The #15159 provenance work does what it was built for — the twolog_onlystates are distinguishable, and this is the deliberate one.Why zero is still not evidence
The ownership path has not executed once. Every request served since the restart:
1865 GET /api/analytics/agents/tasks/recent 1865 GET /api/agents/status 932 GET /api/monitoring/metrics/history 466 GET /api/redis-service/status 232 GET /api/system/health 2 POST /api/rum/event 1 GET /api/health ---- 5363 total requests, 11 hours, zero chat endpointsvalidate_chat_ownershipguards/api/chat/*. Nothing has called it. So0 violationsis produced by a code path that never ran, which is indistinguishable from0 violationsproduced by enforcement working.That is this repository's most-logged defect class (#15953): an empty result from a path that was never exercised, read as a true negative. AC4 asks for a
log_onlymeasurement; a measurement over zero samples is not one.What would actually satisfy AC4/AC5
- A
log_onlywindow containing non-zero traffic through/api/chat/*, with the request count stated alongside the violation count — so the denominator is visible and a future reader cannot mistake "no traffic" for "no violations" - At least one deliberate violation attempt confirming the check fires and records — the positive control, without which zero remains ambiguous no matter how much traffic arrives
Method note, because I nearly filed the wrong finding
My first measurement used
journalctl -u autobot-backendand found zero ownership lines. That was my instrument being blind, not a result: the backend logs to/var/log/autobot/backend.log, and the journal holds one line since restart. Had I reported it, "zero violations" would have entered the record twice over — once from a path that never ran, and once from a log that was never written to.- A
The assigned branch
issue-14010-distinguishable-v2(655aa4a622) is a stale pre-merge duplicate of2389b251cf(PR #15158, merged), whose work was then extended by #15159 (PR #15242, also merged —security/enforcement_mode.pyis the resulting module). Confirmed both are genuine ancestors oforigin/main. The trap hit on this branch (predates #15159's changes to the same files) is real for the branch's own copy but moot — not reviving it.This has had substantial, deliberate work land, including an explicit owner ruling recorded directly in
security/enforcement_mode.py's module docstring. AC by AC against current main:- Unavailable/failing flags service no longer silently yields
disabled.resolve_enforcement_mode()(security/enforcement_mode.py) degrades tolog_only(neverdisabled) on a missing service, an unreadable mode, or any unexpected error, with alogger.warningnaming it explicitly as "UNDETERMINED... not a deliberate 'disabled'." - [~] Test: a flags-service failure does not produce an authorized result for a non-owner — delivered as the owner's actual chosen behavior, not the AC's literal wording.
test_a_mismatch_during_an_outage_is_allowed_but_auditedis the real test, and its name says exactly what it does: during a degraded read,log_onlyallows the request and audits the violation — it does not deny. That is a deliberate, documented trade-off (enforcement_mode.py's docstring: "denying on a degraded read would turn a flag blip into a platform-wide read outage (security(chat): ownership enforcement fails OPEN — an unavailable feature-flag service disables every check platform-wide #14010, owner ruling)"), not an oversight. Naming this precisely rather than ticking the box as originally worded, since "does not produce an authorized result" and "allowed but audited" are different claims. - Deliberate
disabledstays distinguishable from the failure path in logs/records.ResolvedEnforcementMode.degradedplus the_degradedreason suffix;test_the_decision_reason_differs_between_a_deliberate_disabled_and_an_outage,test_no_message_is_shared_between_the_deliberate_and_failure_paths,test_the_specific_unavailable_handler_is_not_shadowed_by_the_generic_oneall pin this directly. - The intended default is decided and recorded —
log_only, and it's the current, actively-provisioned default, not left unset. Confirmed while closing ownership enforcement has likely never been on — the only automated writer of the flag imports a package that does not exist #14866:roles/access_control/defaults/main.yml:access_control_enforcement_mode: log_only, seeded at provisioning time (SET NX) viaenforcement_mode.yml, wired into the two playbooks the product actually runs.log_onlyis stage one of the issue's own described rollout (measure fallout before flipping toenforced); nothing here has flipped toenforcedyet, which is the correct, expected state of an in-progress staged rollout, not a gap. - No endpoint's behavior changes silently.
log_onlyby construction never denies (allow-and-audit only), so this rollout stage cannot silently start refusing anything; a future flip toenforcedis the point at which a "what starts being refused" list would matter, and that's still ahead.
Step 1 (the security fix) is fully delivered and heavily tested (17 dedicated tests in
session_ownership_enforcement_mode_test.pyalone). Step 2 (the rollout) has its first stage — decide and provisionlog_only— delivered via #14866. Theenforcedflip is future work, correctly sequenced behind a measurement window, not something to force here.Flagging to autobot-ai-df for the owner batch: whether "flip to
enforced" should now be scheduled givenlog_onlyhas been live since #14866 landed, and whether the "allowed but audited" behavior during a degraded read (rather than strict denial) should be revisited now that the platform has more experience — both are genuine forward decisions, not re-litigating what already shipped.- Unavailable/failing flags service no longer silently yields
Measurement-window check, per the owner's ruling: gate the flip on observed data, not on elapsed time.
What I could verify from code: the telemetry path itself is sound, not silently dropping anything.
_handle_violation(security/session_ownership.py:449-458) unconditionally calls both_audit_log_violation(writes a JSON record viasecurity_layer.audit_log) and_record_violation_metrics(a separate metrics-service path) for every ownership mismatch, regardless of enforcement mode — the comment says so directly ("Always audit log and record metrics"). So a mismatch underlog_onlyis not silently uncounted by design; if the platform saw zero violations, that would be a real zero, not a code-level blind spot swallowing them before they're recorded.What I could not verify, and it's the actual question:
security_layer.audit_logwrites toself.audit_log_file— a local, append-only file on whatever host runs the backend process. I have no host or live-filesystem access, so I cannot read what it actually recorded since #14866 landedlog_onlyas the provisioned default. Same for_record_violation_metrics's destination — whatever metrics service is configured, reading its accumulated counters needs a live query I can't make from here.So: the code-level capability to distinguish "no violations occurred" from "violations occurred but nothing recorded them" is real and verified — but the actual answer, which of those two states the platform is in, is host evidence only. I'm not guessing at it either way. Per the owner's own framing, this is exactly the distinction to watch for: an enforcement mode that has genuinely recorded nothing looks identical, from here, to one with no violations, and I cannot tell them apart without reading the audit log file or the metrics service directly on a live install.
What would answer this, for whoever has host access:
- Check whether
security_layer.audit_log_filehas any entries at all since ownership enforcement has likely never been on — the only automated writer of the flag imports a package that does not exist #14866's rollout, and whether any carryactionvalues matching an ownership violation from_audit_log_violation's call sites. - Separately confirm the metrics service itself received any writes in that window (proves the pipe isn't empty end-to-end) before treating a zero violation count as meaningful.
Not recommending a flip either way — that call needs the data above, which I cannot produce.
- Check whether
Found while reviewing PR #14009 (#13982). Pre-existing and systemic — it governs every ownership check in the platform, not one endpoint.
Problem
SessionOwnershipValidator._get_enforcement_mode(autobot-backend/security/session_ownership.py) returns"disabled"in three situations:and
validate_ownershipfast-paths on it:validate_session_ownershipconstructs the validator withfeature_flags=Nonewheneverget_feature_flags()raises, and only logs a warning.Impact
Three independent routes to "no authorization":
disabledThis is a fail-open authorization control. The failure is silent (a
debugline and awarning), so a deployment can run indefinitely with every ownership check inert and nothing in the logs saying "authorization is off".Concretely, it means the ~15
validate_chat_ownershipcalls inapi/chat.py— including the one just added by PR #14009 — are decorative in a default deployment. Those calls are correct and worth having; the point is that the switch above them defaults to off.Why this is filed separately
PR #14009 fixes a genuine missing check and explicitly scoped this out. Changing the default enforcement posture is a deployment-behaviour decision with real blast radius (flipping to
enforcedwill start refusing requests that work today), so it needs its own change and its own rollout, not a side effect of a one-line endpoint fix.Suggested approach
Two separable steps, in order:
log_onlyand emit awarning/alert rather than adebug. An outage must not be indistinguishable from a deliberatedisabled.log_onlyfirst to size the fallout from real traffic, thenenforced. The only place the mode is ever set today is a deployment validation script that cycles all three modes, so no environment appears to pin it deliberately.Step 1 is the security fix; step 2 is the rollout.
Acceptance criteria
disabled; the behaviour on that path is explicit and logged atwarningor abovedisabledmode is still distinguishable in logs from the failure pathlog_onlymeasurement before any flip toenforcedRelated