Repository navigation
fix(api): bound re-roling in both directions, and give the service principal one permission model - #1750
Merged
Conversation
…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.
Owner
Author
|
The failure is an import error in
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. |
11 tasks done
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]>
1 task done
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
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-2g8ris first-filed and already has the reporter credit accepted, so it is kept;GHSA-qcgm-c945-q96his closed as the duplicate naming it.Confirmed.
authorize_role_changechecked 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_adminprincipals 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:never_grantablerefuses those to every API caller. On a tenant created through the product it therefore protected nothing.tenant_admindemoted its only peer (200) and then itself (200), leaving a tenant with no principal holdingusers:writeand 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 fromROLE_PERMISSIONSso a role that gainsusers:writelater 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
:tand:keepas bare parameters againstuuidcolumns, so asyncpg answeredoperator does not exist: uuid = character varying. Measured on unmodifiedmainby deactivating the last wildcard administrator: HTTP 500, not the 409 the handler promises. Every bind is now cast, theCAST(:name AS type)spellingcheck_text_bind_casts.pyenforces 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_principalre-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 everytenant_adminterminate 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 recordsfromcorrectly: theUPDATEsynchronises the loaded object, so readinguser.roleafterwards 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.
SERVICE_PRINCIPAL_PERMISSIONSonlyactions:read,connectors:read,hunts:read,lake:queryROLE_PERMISSIONS["api_service"]onlyalerts:write,cases:writehas_permission_db("cases:write")on the principal the shipped resolver buildsTrue, whilerequire_permissionraised 403 on the same objectThe 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_permissionsdistinguishes "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_dbsaw the same zero rows and could not tell them apart. A deprovisionedtenant_adminanswered 403 at everyrequire_permissionroute and 200 atGET /api/v1/mcp-servers.Fixes: one object (
SERVICE_PRINCIPAL_PERMISSIONSis derived from the role row, rationale living with the row), andrequire_permission_dbdelegates 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.pygains the arm that would have caught this. It asserted everything about the dependency factory 275 routes bind and nothing about the method 27 routesawait, 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, andlake:querygates the hunt write routes — so a real service token reachesPOST /api/v1/huntsandPOST /api/v1/saved-hunts, measured at 422 (past authorization, into body validation) on unmodifiedmain. A test asserted the opposite under the note thatapi_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
xfailso 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.
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.13 passed.administrative_roles()back to the wildcard set → 4 failed; restoring the second permission model → 2 failed. Tree restored, baseline re-run at 13 passed.5378 passed, 230 skipped, 1 xfailed.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.pyasserted 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.pyleftdb.scalarunstubbed, so anAsyncMockreturned a truthy value and the double claimed its tenant administers access throughuser_roles. A real bootstrap tenant answers0.