Skip to content

fix(api): bound re-roling in both directions, and give the service principal one permission model - #1750

Merged
beenuar merged 4 commits into
mainfrom
security/symmetric-role-authority
Nov 7, 2026
Merged

beenuar merged 4 commits into
mainfrom
security/symmetric-role-authority

Conversation

@beenuar

@beenuar beenuar commented Nov 7, 2026 •

Copy link
Copy Markdown
Owner

Closes two advisories (three reports — two were duplicates) from @pavancharak, both submitted as code review with no proof of concept. Every claim was verified against the tree and then reproduced by execution against the real ASGI application, real Postgres with all 157 migrations applied, real JWTs and ENVIRONMENT=test, before anything was changed.

GHSA-hrc5-w9r3-2g8r — re-roling was bounded in one direction only

Submitted twice, 28 seconds apart, with byte-identical descriptions. GHSA-hrc5-w9r3-2g8r is first-filed and already has the reporter credit accepted, so it is kept; GHSA-qcgm-c945-q96h is closed as the duplicate naming it.

Confirmed. authorize_role_change checked that the caller may confer the new role and never that it holds what the target currently holds.

Symmetric authority is the rule but it is not what bites, and that distinction is the finding. Two tenant_admin principals hold identical permissions, so no subset test separates them — administrators managing administrators is what the role is for. What must be unreachable is the end state, and it was reachable:

  • The last-administrator guard counted only the wildcard roles, and never_grantable refuses those to every API caller. On a tenant created through the product it therefore protected nothing.
  • Reproduced: one tenant_admin demoted its only peer (200) and then itself (200), leaving a tenant with no principal holding users:write and no route to recovery short of database credentials.

Fixes: the subset test now applies to the role being taken away as well as the one conferred; the guard counts role_grants.administrative_roles(), derived from ROLE_PERMISSIONS so a role that gains users:write later is covered without anyone remembering. The second demotion now answers 409 with the row unwritten.

That guard had also never once worked. Its count bound :t and :keep as bare parameters against uuid columns, so asyncpg answered operator does not exist: uuid = character varying. Measured on unmodified main by deactivating the last wildcard administrator: HTTP 500, not the 409 the handler promises. Every bind is now cast, the CAST(:name AS type) spelling check_text_bind_casts.py enforces elsewhere.

Cache invalidation: in. The handler never bumped the tenant's RBAC version, so a principal resolved before the change kept its old set on every replica until the fallback TTL lapsed — for a revocation, the wrong direction.

Session revocation: out, with a reason that is measured rather than asserted. An access token carries no permissions; resolve_jwt_principal re-reads the user row and re-resolves the set on every request. A test demotes a principal and replays a token minted beforehand, and it is refused. The stale channel was the cache. Letting every tenant_admin terminate a peer's sessions would be a new capability, not a fix.

The inverse defect, also fixed. Where a tenant does have RBAC grants the stored role changes and the effective permissions do not, so an administrator is told a demotion worked when it did not. The audit row now carries effective_permissions_follow_role. It also now records from correctly: the UPDATE synchronises the loaded object, so reading user.role afterwards returned the new value and the trail read {"from": "viewer", "to": "viewer"}.

GHSA-ghpm-vc6j-hp5r — two declarations, and the permissive one won

Header-derived tenant: judged separately, and largely by design. A service token identifies a service, not a tenant; the agents container triages for every tenant on the deployment, so the tenant has to come from the work. The API already refuses a service caller that names no tenant and verifies the tenant exists. "Any real tenant is accepted" is the designed behaviour. The reporter's real point — the blast radius of a leaked token — is a legitimate design critique, and per-workload credentials (_resolve_workload_principal, already shipped, with per-credential scopes, attributable audit and rotation with a grace window) are the stronger answer. Migrating internal callers onto them is a larger change than an advisory fix and is not attempted here.

Permission conflict: confirmed, and worse than reported.

measured
SERVICE_PRINCIPAL_PERMISSIONS only actions:read, connectors:read, hunts:read, lake:query
ROLE_PERMISSIONS["api_service"] only alerts:write, cases:write
has_permission_db("cases:write") on the principal the shipped resolver builds True, while require_permission raised 403 on the same object

The reporter notes no current route exploits it. True for those two permissions — and the same blindness reopened a defect the resolver had already closed, which is reachable: resolve_permissions distinguishes "this tenant has no RBAC" from "this tenant has RBAC and gave this user none", returning the empty set for the second so removing every grant does not restore a static role. has_permission_db saw the same zero rows and could not tell them apart. A deprovisioned tenant_admin answered 403 at every require_permission route and 200 at GET /api/v1/mcp-servers.

Fixes: one object (SERVICE_PRINCIPAL_PERMISSIONS is derived from the role row, rationale living with the row), and require_permission_db delegates to the one decision function — so elevation and the tenant's attribute conditions now bind there too, having previously applied on one path and not the other.

check_one_permission_model.py gains the arm that would have caught this. It asserted everything about the dependency factory 275 routes bind and nothing about the method 27 routes await, so the second model it exists to prevent sat one method over with every check printing OK. Run against the pre-fix tree the new arm exits 1 naming all three defects.

Found while fixing, left open deliberately

The shared service token holds lake:query, and lake:query gates the hunt write routes — so a real service token reaches POST /api/v1/hunts and POST /api/v1/saved-hunts, measured at 422 (past authorization, into body validation) on unmodified main. A test asserted the opposite under the note that api_service "holds no lake permission either"; it passed only because it built a principal with no resolved set and so read the narrower of the two lists — the same two-declaration defect hiding its own consequence.

Closing it means splitting authoring from running into a write permission, which changes what four human roles hold and needs a migration for RBAC tenants. That is its own change, not an advisory fix. Recorded as a strict xfail so whoever does it is told to remove the marker, rather than as a passing test that blesses the exposure.

Evidence

Reproduction first, then the fix, then controls executed and watched.

  • Pre-fix: 9 failed, 3 passed — every property assertion failing for the reason its name gives, all three positive controls passing so the harness is known good.
  • Post-fix: 13 passed.
  • Negative controls, each run and observed: removing the symmetric check → 1 failed; narrowing administrative_roles() back to the wildcard set → 4 failed; restoring the second permission model → 2 failed. Tree restored, baseline re-run at 13 passed.
  • Full API suite: 5378 passed, 230 skipped, 1 xfailed.
  • Gates: check_one_permission_model (with a 13-case self-test), check_role_grant_scope, check_text_bind_casts, check_claim_gate_matrix (402 rows / 393 GATED / 0 NO GATE), check_claim_matrix_sources, check_gate_coverage, readme_gates, check_machine_paths, check_comment_paths, check_competitor_names, check_attribution, ruff format --check, ruff check — all pass.

Three existing tests changed, each because it measured a declaration rather than the path:

  • test_content_route_permissions.py asserted a service principal may submit phishing. Driven with the resolved set the shipped resolver hands out, that route answers 403 both before and after this change, and nothing in the tree calls it as a service — the capability did not exist.
  • test_hunting_route_permissions.py — see above.
  • test_autonomy_effective_posture.py left db.scalar unstubbed, so an AsyncMock returned a truthy value and the double claimed its tenant administers access through user_roles. A real bootstrap tenant answers 0.

…incipal one permission model

GHSA-qcgm-c945-q96h: authorize_role_change checked only the role being
conferred, and the last-administrator guard counted only the wildcard roles
that no API caller can hold -- so one tenant_admin could demote every peer
and then itself, leaving a tenant nobody can administer. Reproduced at 200
twice against real Postgres. The guard's count also bound uuid columns as
text, so it answered 500 rather than 409 the moment it fired.

GHSA-ghpm-vc6j-hp5r: SERVICE_PRINCIPAL_PERMISSIONS and
ROLE_PERMISSIONS[api_service] declared one principal twice and disagreed,
and has_permission_db read neither the resolved set nor elevation nor
conditions. A deprovisioned user reached GET /api/v1/mcp-servers on their
old static role at 200 while every require_permission route answered 403.
The finding was submitted twice, 28 seconds apart, with byte-identical
descriptions. GHSA-hrc5-w9r3-2g8r is the first-filed one and the one whose
reporter credit is already accepted, so it is kept and GHSA-qcgm-c945-q96h
is closed as the duplicate. Every code comment, test and document that
named the duplicate now names the live advisory.
mypy's attr-defined: the conditional pulled .targets and .value off a bare
ast.stmt. Same check, narrowed per node type.
Trivy pins its merge ref at run creation, so the golang.org/x/net fix that
landed in #1749 could not reach a re-run of the existing check.
@beenuar

beenuar commented Nov 7, 2026

Copy link
Copy Markdown
Owner Author

Cross-store isolation (Neo4j + Redis + ClickHouse + Kafka) is red for a reason that predates this branch, and is not caused by it. Recording the evidence rather than leaving a red tick unexplained.

The failure is an import error in services/api/app/core/config.py:

No module named 'app.core.version'; 'app.core' is not a package
  • config.py is byte-identical to main on this branch — diff against origin/main is empty. The import at line 18, from app.core.version import resolve_version, was added by b24d81228 ("fix(version): report the running release", fix(version): report the running release, and grade the schema against it #1732).
  • The live isolation workflow last ran on main at ba41f3134 (2026-11-06 10:50), which predates that commit. main is stale-green on this check; this pull request is simply the first to trigger the live job, because it touches tests/isolation/**.
  • Reproduced on an unmodified checkout of main at 010a3981b: BACKTEST_LAKE_LIVE_REQUIRED=1 pytest tests/isolation/test_backtest_lake_live.py gives 3 passed, 12 errors — identical to this branch and to CI.
  • Other open pull requests show that check green only because they ran the offline job of the same name; none triggered the live one.

Not a required check, nothing in this diff touches that import chain, and the fix belongs with the version work rather than here. Flagged for whoever owns that lane.

@beenuar
beenuar merged commit 2d034fc into main Nov 7, 2026
133 of 134 checks passed
seafay pushed a commit to seafay/AiSOC that referenced this pull request Nov 7, 2026
…window

Carries the work of beenuar#1752 onto a repo-owned branch so the paging gate's
ceiling can be resolved against the Link-header change that landed in
beenuar#1751. The code is unchanged from the original commit; the only edits are
the two lines the two branches both touch.

`SlackAuditConnector.fetch_alerts` sent `limit=200` and ignored
`response_metadata.next_cursor`. Slack documents that cursor as the way to
reach the rest of a collection, that an "empty, null, or non-existent"
value is the only end-of-collection signal, and that the audit log's
default ordering is "descending (most to least recent)". The poll window is
clock-derived and this connector keeps no checkpoint, so an entry past the
200th was requested once, not returned, and never asked for again — and
being the oldest of the window, it was the beginning of whatever burst
overflowed the page.

`read_token_pages` already had the right loop; `token_field` now accepts a
dotted path so a vendor that nests its token can use it.

Resolved against beenuar#1751:

  * MAX_UNCLASSIFIED 55 -> 54. The original commit wrote 58 -> 57 against a
    base where the Link-header work had not landed. Applied on top of 55
    that would raise a ceiling the gate's own docstring says may only fall,
    which is why git refused the merge rather than taking either side.
  * The CHANGELOG entry is kept verbatim apart from the same two numbers.

Both beenuar#1751 and beenuar#1752 were opened against a base that predates the merge of
beenuar#1750, and a fork branch cannot be rebased by a maintainer from here, so
the carry-over is the only route that keeps the author's commit in the
history.

Co-authored-by: Asish Kumar <[email protected]>
beenuar added a commit that referenced this pull request Nov 10, 2026
…r rows (carries #1755) (#1793)

Carries #1755 into a repo-owned branch, since fork branches cannot be
pushed to from this repository and #1755 is conflicting against main.

What happened: two parallel lanes each added a credits row for
@pavancharak, so main now carries **two overlapping rows** — both cite
GHSA-hrc5-w9r3-2g8r, and they disagree about the fix reference (one says
PR #1750, the other commit `2d034fc` — which is #1750's squash commit,
i.e. the same change). #1755 proposed a third variant and went
conflicting.

This PR consolidates to **one row** that:
- credits
[GHSA-ghpm-vc6j-hp5r](GHSA-ghpm-vc6j-hp5r)
(static role fallback in `has_permission_db()` + conflicting
service-principal declarations, verified: pavancharak is the accepted
reporter),
- credits
[GHSA-hrc5-w9r3-2g8r](GHSA-hrc5-w9r3-2g8r)
(one-directional re-roling bound; verification also surfaced the
never-working last-administrator guard and the inverted audit-trail
record),
- names the one real fix: [PR
#1750](#1750) (commit `2d034fc`).

The commit is authored as @pavancharak to preserve the contribution
credit.

Co-authored-by: Pavan Dev Singh Charak <[email protected]>
@beenuar beenuar mentioned this pull request Nov 10, 2026
beenuar added a commit that referenced this pull request Nov 10, 2026
Promotes `[Unreleased]` → `[23.0.0]` (2026-11-10) and moves every
release surface together.

**Why a major:** [#1732](#1732)
removed `DeploymentConfig.update_channel` / `auto_update` and
`DeploymentConfigUpdate.telemetry_enabled` from the published spec
(fields nothing implements; `telemetry_enabled` becomes read-only
`Literal[False]`) — a published-spec break under the repo's own
openapi-breaking policy, sitting in `[Unreleased]` under `### BREAKING`.

**CHANGELOG mechanics:** the accumulated section had lane-duplicated
headings (three `Fixed`, two `Added`/`Security`); they are merged into
one canonical set with entry text preserved byte-for-byte (bullet count
51 → 56, the +5 being this wave's new entries), and `### BREAKING` is
hoisted to the top so a release-body cut at the 125k cap can never drop
it.

**Surfaces:** `VERSION` 23.0.0 · `apps/web/package.json` · chart
`appVersion: v23.0.0` (chart `version` stays at the staged, unpublished
7.18.0 — 7.17.0 is what v22.2.0 published) · README badge · RELEASES.md
TL;DR (new paragraph; v22.2.0 demoted to *Previously*) + What's-new
rewrite · `llms.txt`/`llms-full.txt` regenerated · scoreboard row
refreshed (substrate 0.97, stamped 23.0.0) · CREDITS row for @knowlet.

**TL;DR defect count:** 30 in this release (21 Fixed + 9 Security, by
the established counting rule), 55 across the last three; six
documentation-claim corrections exist and are explicitly not counted.

**Local verification:** `readme_gates` OK ·
`check_single_version_source` 5/5 OK · `check_chart_version` OK ·
`check_scoreboard --check` OK (row re-measured, not relabelled) ·
`generate_llms_txt --check` OK · attribution + competitor scans OK.

**Sequencing:** #1797 (Settings connector links, @knowlet) is in CI now
and its CHANGELOG entry is included here — this PR merges after it so
the tagged tree contains everything the notes claim. Tag `v23.0.0` goes
on main after this merges; the two advisories fixed by #1750 get their
`patched` field set to 23.0.0 once the release exists. Neither `stable`
channel moves (application major; deliberate dispatch required).
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