Skip to content

security(chat): ownership enforcement fails OPEN — an unavailable feature-flag service disables every check platform-wide #14010

Description

@mrveiss

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:

if not self.feature_flags:
    return "disabled"
try:
    mode_enum = await self.feature_flags.get_enforcement_mode()
    return mode_enum.value
except Exception as e:
    logger.error(f"Failed to get enforcement mode: {e}, defaulting to disabled")
    return "disabled"

and validate_ownership fast-paths on it:

if enforcement_mode == "disabled":
    logger.debug("[DISABLED MODE] Skipping ownership validation for session ...")

validate_session_ownership constructs the validator with feature_flags=None whenever get_feature_flags() raises, and only logs a warning.

Impact

Three independent routes to "no authorization":

  1. the default — with the flag key unset, the mode is disabled
  2. an infra blip — the feature-flags service being briefly unavailable silently turns off every ownership check platform-wide, for the duration
  3. any exception inside the flags lookup, same effect

This is a fail-open authorization control. The failure is silent (a debug line and a warning), 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_ownership calls in api/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 enforced will 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:

  1. Fail closed on infrastructure failure. A flags service that cannot be reached should not be read as "policy is off". Either deny, or fall back to the last known mode, or at minimum fall back to log_only and emit a warning/alert rather than a debug. An outage must not be indistinguishable from a deliberate disabled.
  2. Decide the default. log_only first to size the fallout from real traffic, then enforced. 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

  • An unavailable/failing feature-flags service does not silently yield disabled; the behaviour on that path is explicit and logged at warning or above
  • Test: flags service raising does not produce an authorized result for a non-owner
  • Test: the deliberate disabled mode is still distinguishable in logs from the failure path
  • The intended default enforcement mode is decided and recorded, with the log_only measurement before any flip to enforced
  • No endpoint's behaviour changes silently — the rollout step lists what starts being refused

Related

Activity

  1. mrveiss commented on Aug 23, 2026

    @mrveiss
    OwnerAuthor

    #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_mode caught 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
    own except almost 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 EnforcementModeUnavailable instead of inventing
      DISABLED. An unset flag still returns DISABLED — that is policy and
      belongs to step 2.
    • All three undetermined routes in the validator degrade to log_only and log
      at WARNING, stating explicitly that this is not a deliberate disabled.

    log_only over 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_only first 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.

  2. mrveiss commented on Aug 23, 2026

    @mrveiss
    OwnerAuthor

    Noted for step 2: the mode is resolved twice per request

    Surfaced in the security review of #14865. validate_ownership calls
    _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.

  3. mrveiss commented on Aug 23, 2026

    @mrveiss
    OwnerAuthor

    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 = 8dfd9049
    

    And the behaviour, checked against origin/Dev_new_gui rather than the branch:

    • raise EnforcementModeUnavailable — present
    • DEGRADED_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 silently log_only + WARNING — check runs, violation recorded
    flags service failed to construct disabled log_only + WARNING
    unexpected error resolving the mode disabled log_only + WARNING
    flag simply unset disabled disabled — unchanged, this is step 2

    The 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_only first to size
    the fallout from real traffic, then enforced. 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:

    1. log_only is 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.
    2. Resolve the mode once per request. validate_ownership calls
      _get_enforcement_mode() twice (session_ownership.py:523 and :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.
  4. mrveiss commented on Aug 23, 2026

    @mrveiss
    OwnerAuthor

    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) and validate_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 is DISABLED — which
    short-circuits validate_ownership before the lookup, across 41 gated call
    sites
    in api/chat.py, api/chat_sessions.py, api/conversation_files.py and
    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_only would already be
    strictly more information than exists now, and enforced would 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
    disabled because a key was never set.

  5. mrveiss commented on Aug 26, 2026

    @mrveiss
    OwnerAuthor

    Closure audit against merged Dev_new_gui — stays OPEN

    c5109f91f (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 at warning or above MET autobot-backend/services/feature_flags.py:107-113 now raises EnforcementModeUnavailable instead of returning DISABLED. All three routes in autobot-backend/security/session_ownership.py converge on DEGRADED_ENFORCEMENT_MODE = "log_only" (:64): no flags service :255-263, EnforcementModeUnavailable :268-275, unexpected exception :276-283 — each with a logger.warning. Audibility is pinned by session_ownership_enforcement_mode_test.py:104 test_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, and session_ownership.py:483-493 returns {"authorized": True, "reason": "log_only_mode"} for a non-owner. The merged test asserts exactly that: session_ownership_enforcement_mode_test.py:157 test_a_mismatch_during_an_outage_is_allowed_but_audited, :171 assert 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 the disabled short-circuit skipped all of it. But that is a different, weaker statement than the criterion.
    3 Test: the deliberate disabled mode is still distinguishable in logs from the failure path MET The failure path logs WARNING naming log_only (:258, :269, :279); the deliberate path logs DEBUG [DISABLED MODE] Skipping ownership validation (:526) and emits no warning. Contrast pinned by session_ownership_enforcement_mode_test.py:70 and :185 test_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_only measurement before any flip to enforced NOT MET Step 2 was deliberately scoped out and the merged test says so: session_ownership_enforcement_mode_test.py:132 test_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_mode does not exist in any of the 16 Redis databases (read-only EXISTS sweep, -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_mode takes the unset branch at services/feature_flags.py:99-105 and returns DISABLED cleanly — no exception, so EnforcementModeUnavailable never fires and the log_only degrade never engages. session_ownership.py:523-531 then short-circuits with authorized: 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:230 is a separate, non-flag-gated ownership validator that does enforce. The exposure is the call sites behind security/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_only posture 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_only before any flip to enforced.

  6. mrveiss commented on Aug 26, 2026

    @mrveiss
    OwnerAuthor

    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 -> DISABLED and log_only -> allow are 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_control role runs a seeder
    that writes feature_flag:access_control:enforcement_mode with SET NX, so the
    key is written when absent and an operator's deliberate value — including a
    deliberate disabled — is never overwritten by a re-provision. The value
    written is log_only, which is what this issue's criterion 4 already
    prescribes ("the log_only measurement before any flip to enforced") and what
    #14866's audit called "the safe first value". The flip to enforced remains a
    separate, measured step and was not made.

    Any PR that changes what an unset key or log_only mean 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 the log_only branch 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 True sits 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 old disabled short-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 under log_only is what comes next.

    Also filed from that work: #15089 — services/feature_flags.py calls
    redis._redis on a client that has no such attribute, so set_enforcement_mode
    writes the key and then reports False, 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.

  7. mrveiss commented on Aug 28, 2026

    @mrveiss
    OwnerAuthor

    AC4 / AC5 need a decision — options and a recommendation

    PR #15158 (Refs, not Closes) 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 DISABLED to PROVISIONED_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 skipped log_only — every check runs, every violation audited, nothing refused
    seeded 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_only measurement before any flip to enforced". That measurement does not exist and cannot be produced from this repository. Concretely it needs:

    1. log_only genuinely 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.
    2. The violations to be counted, not just logged. _audit_log_violation and _record_violation_metrics both fire in log_only. Someone has to read them over a representative window and produce a count of distinct (user, session, endpoint) triples that enforced would have refused.
    3. A window long enough to include the periodic traffic — shared/exported sessions, admin access, anything weekly. A 24-hour sample will under-count.
    4. The false-positive classes separated out. _owner_when_cache_is_empty and 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.
    5. 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 enforced is unmeasured by definition.

    Options for AC4

    Option What it buys What it costs
    A Land #15155, run one measurement window under log_only, then decide The 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 enforced now Ends the exposure immediately across the gated call sites in api/chat.py, api/chat_sessions.py, api/chat_sessions_thinking.py Directly 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 enforced on the highest-value routes first, global stays log_only Already 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_only as the terminal posture Zero 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_only is 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 to enforced first, 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

  8. added
    needs-decisionBlocked on an owner decision; options and a recommendation are on the issue
    on Aug 28, 2026
  9. mrveiss commented on Aug 30, 2026

    @mrveiss
    OwnerAuthor

    The log_only measurement AC4 asks for, taken on a live install — and why it does not yet support a flip

    AC4 requires "the log_only measurement before any flip to enforced". I took it on a running install (deployed revision 7952df8986, current with Dev_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 by EXISTS against 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, or LOG_ONLY 0
    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' modules

    Why "zero violations" is not evidence that a flip is safe

    The obvious reading — nobody violated ownership, so enforced would 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. Neither services.feature_flags nor security.session_ownership appears 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 → enforced refuses nothing; the flip is safe.
    • Never executed → log_only has 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_only will 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 is middleware/service_auth_enforcement.py:148 and it is service-to-service auth, deliberately exempting frontend-accessible endpoints (is_path_exempt's docstring says exactly that). User authentication still applies: an unauthenticated GET /api/chat/sessions against 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.

  10. mrveiss commented on Aug 30, 2026

    @mrveiss
    OwnerAuthor

    Correction to my log_only measurement above — the cause was wrong

    I 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: 3885
    

    The running process has not restarted since before those files were replaced, so it is executing pre-deployment code. If the loaded feature_flags.py predates #15155, an absent key resolves to DISABLED and validate_ownership short-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 DISABLED short-circuit ownership is not being checked on this host; the flip's blast radius is unknown
    Path genuinely unreached by this traffic log_only has measured nothing; the flip's blast radius is unknown

    Either 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 the Enforcement mode not set; using the provisioned default INFO 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.

  11. mrveiss commented on Aug 30, 2026

    @mrveiss
    OwnerAuthor

    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 .py files were replaced under them. That is why the verification here read the file rather than the process.

  12. mrveiss commented on Sep 9, 2026

    @mrveiss
    OwnerAuthor

    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   0
    

    So 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_only and not PROVISIONED_ENFORCEMENT_MODE_DEFAULT. And no degradation occurred: zero degrading to log_only / UNDETERMINED / could not be resolved lines since the restart. The #15159 provenance work does what it was built for — the two log_only states 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 endpoints
    

    validate_chat_ownership guards /api/chat/*. Nothing has called it. So 0 violations is produced by a code path that never ran, which is indistinguishable from 0 violations produced 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_only measurement; a measurement over zero samples is not one.

    What would actually satisfy AC4/AC5

    • A log_only window 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-backend and 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.

  13. added this to the Backlog milestone on Sep 12, 2026
  14. mrveiss commented on Sep 20, 2026

    @mrveiss
    OwnerAuthor

    The assigned branch issue-14010-distinguishable-v2 (655aa4a622) is a stale pre-merge duplicate of 2389b251cf (PR #15158, merged), whose work was then extended by #15159 (PR #15242, also merged — security/enforcement_mode.py is the resulting module). Confirmed both are genuine ancestors of origin/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 to log_only (never disabled) on a missing service, an unreadable mode, or any unexpected error, with a logger.warning naming 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_audited is the real test, and its name says exactly what it does: during a degraded read, log_only allows 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 disabled stays distinguishable from the failure path in logs/records. ResolvedEnforcementMode.degraded plus the _degraded reason 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_one all 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) via enforcement_mode.yml, wired into the two playbooks the product actually runs. log_only is stage one of the issue's own described rollout (measure fallout before flipping to enforced); nothing here has flipped to enforced yet, which is the correct, expected state of an in-progress staged rollout, not a gap.
    • No endpoint's behavior changes silently. log_only by construction never denies (allow-and-audit only), so this rollout stage cannot silently start refusing anything; a future flip to enforced is 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.py alone). Step 2 (the rollout) has its first stage — decide and provision log_only — delivered via #14866. The enforced flip 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 given log_only has 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.

  15. mrveiss commented on Sep 20, 2026

    @mrveiss
    OwnerAuthor

    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 via security_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 under log_only is 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_log writes to self.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 landed log_only as 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:

    1. Check whether security_layer.audit_log_file has 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 carry action values matching an ownership violation from _audit_log_violation's call sites.
    2. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: chat-tenancyWave 3 · cluster F — Chat tenancy & session ownershipbackendneeds-decisionBlocked on an owner decision; options and a recommendation are on the issuesecurity

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions