Skip to content

Redact bnk_config from the instance-wide global cluster list - #174

Merged
jgruberf5 merged 3 commits into
stagingfrom
fix/116-redact-bnk-config-global-list
Aug 19, 2026
Merged

Redact bnk_config from the instance-wide global cluster list#174
jgruberf5 merged 3 commits into
stagingfrom
fix/116-redact-bnk-config-global-list

Conversation

@jgruberf5

@jgruberf5 jgruberf5 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

What & why

Relates to #116 (does not fully close it — see below) — GET /api/k8s/clusters is instance-wide (require_viewer, no project scope) and returns every cluster to any authenticated viewer. Metadata was already global by design, but ADR-424 widened it: it added bnk_config to the serialized shape, so the global list now also exposed each cluster's host_ids, dpu_ids, control_plane_host_id, and tmfifo_pool_cidr cross-project — infrastructure membership one project shouldn't see for another.

Why redaction is safe, not a feature loss

I traced every consumer of the global list. All nine frontend callers of useAllClusters ignore bnk_config; the only surface that renders it — K8sClusterList — uses the project-scoped useProjectClusters. No backend test asserts bnk_config on the global list either. So the field has no consumer on the global path, and removing it there closes the disclosure without losing anything.

The change

  • serialize_cluster gains include_bnk_config (default True); list_all_clusters passes False.
  • The project-scoped list and the per-cluster detail — whose callers actually consume it, and which can be authorized per project — keep it.
  • Redacting also lets the global path drop the membership bulk-fetch those fields required.

Testing

A BNK cluster with a BnkClusterConfig is None on the global list but present (with tmfifo_pool_cidr) on the project-scoped list. Non-vacuous: against the unpatched service the global row leaks the full config, and the assertion fails.

Scope note

Whether an instance-wide list of cluster metadata should be visible to any viewer at all is a pre-existing tenancy-model design choice (predates ADR-424) — that's the "decide the intended tenancy model" part of the issue, and it's a product decision, not something this PR settles. This fixes the specific ADR-424 widening: the newly-exposed bnk_config.

Relates to #116 (does not fully close it — see below)

⚠️ Does not fully close #116 — reframed after review

@mwiget traced the read path further and found the disclosure is still reachable in two requests, so this is Relates to, not Fixes:

  1. GET /api/k8s/clusters (global) still returns each cluster's project_id (metadata, pre-existing).
  2. GET /api/projects/{project_id}/k8s/clustersrequire_viewer (any authenticated user), no membership/ownership check — then serves full bnk_config for any project_id supplied.

Removing bnk_config from the instance-wide global surface is a strict improvement, but it does not close #116: the project-scoped list must enforce per-project membership first. That's a pre-existing tenancy-model decision (require_viewer is role-based across the app; scoping reads to the caller's projects is a real behaviour change) — the same boundary this PR already draws for instance-wide metadata. The residual is now documented in code at list_project_clusters, mirroring the get_cluster_details guard.

#116 stays open pending that membership-scoping decision.

https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

GET /api/k8s/clusters is instance-wide (require_viewer, no project scope) and
returns every cluster to any authenticated viewer. That was already true for
cluster metadata, but ADR-424 added bnk_config to the serialized shape, so the
global list now also exposed each cluster's host_ids, dpu_ids,
control_plane_host_id, and tmfifo_pool_cidr cross-project -- infrastructure
membership one project should not see for another.

No consumer needs it there: all nine frontend callers of the global list
(useAllClusters) ignore bnk_config; the only surface that renders it,
K8sClusterList, uses the project-scoped useProjectClusters. So redacting it on
the global path removes the disclosure without losing a feature.

serialize_cluster gains include_bnk_config (default True); list_all_clusters
passes False. The project-scoped list and the per-cluster detail -- whose
callers actually consume it, and which can be authorized per project -- keep it.
Redacting also lets the global path drop the membership bulk-fetch those fields
required.

Test: a BNK cluster with a BnkClusterConfig is absent (None) from the global
list but present on the project-scoped list. Verified non-vacuous -- against the
unpatched service the global row leaks the full config.

Note: the broader question of whether an instance-wide list of cluster METADATA
should be visible to any viewer at all is a pre-existing tenancy-model design
choice (predates ADR-424) and is out of scope here; this fixes the specific
ADR-424 widening the issue flags.

Fixes #116

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

Self-review

The load-bearing claim is "no consumer," so I verified it exhaustively, not by sampling

The whole safety argument is that redacting bnk_config on the global list loses nothing. If that's wrong, this breaks a real screen. So I checked all nine useAllClusters callers (DpuInfrastructurePanel, Sidebar, pickers, BenchmarkTargetsTab, Dashboard, KubernetesV2, CNF, Fleet, F5BNK) for any bnk_config reference — zero. The one component that renders bnk_config (K8sClusterList, showing "BNK Pool" and feeding the member-config dialog) fetches via useProjectClusters, the project-scoped endpoint this PR leaves untouched. And no backend test asserts bnk_config on the global list. If a reviewer knows of a non-frontend consumer of the global list that needs the field (an agent, an external script), that's the one thing that would change the calculus — flagging it explicitly.

I kept the fix to the actual regression, and said so

It's tempting to "fix the tenancy problem." But the global exposure of cluster metadata predates ADR-424 and is design, not a bug — the issue itself rates it SUGGESTION and says "decide the intended tenancy model." That decision isn't mine to make in a PR. So this narrowly reverses the ADR-424 widening (the bnk_config addition) and calls the broader question out as out of scope. If the team later decides the global list should be project-scoped or gated entirely, that's a separate, larger change.

One shared-serializer risk I checked

serialize_cluster is used by three callers (global list, project list, detail). A flag defaulting to True means only the caller I changed (list_all_clusters) redacts; the other two keep their current behaviour by default — I didn't have to touch them, which is what the test pins (same cluster: None on global, present on project). Defaulting to True also means any future caller gets the full config unless it opts out, which is the safer default for a serializer whose omission is a redaction.

Small efficiency bonus, called out so it's not a surprise

Removing bnk_config from the global path also let me drop the bulk_cluster_membership fetch it required — the global list is now a single query + serialize, no membership round-trip. That's why the diff touches the query code, not just the serializer flag.

@mwiget mwiget left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The redaction itself is right, and I traced it further than the description does — but CI is red on a test that disproves one of the PR's load-bearing claims.

Blocking — P2 · Component Tests · Backend fails, and the failing test is exactly the one the description says doesn't exist.

FAILED tests/component/test_bnk_cluster_member_assignment.py::TestBulkClusterMembership::
       test_serialize_cluster_with_bnk_config_and_members
       - AssertionError: bnk_config must be present for a BNK cluster
       assert None is not None
=========== 1 failed, 3127 passed ===========

That test does client.get("/api/k8s/clusters") — the global list — and asserts bnk_config is present with correct host_ids / dpu_ids. The PR says "No backend test asserts bnk_config on the global list either." One does, and it wasn't updated.

Please retarget it rather than delete it. Its real subject isn't the global list; it's _serialize_bnk_config's bucketing — the B6 cross-bucket guard that host_ids and dpu_ids don't contaminate each other, with two hosts and two DPUs specifically chosen so SQLite's per-table autoincrement can't make the IDs coincide and mask a leak. That coverage is worth keeping. Pointing it at the project-scoped list (/api/projects/{id}/clusters), where bnk_config is still rendered, preserves the guard and simultaneously pins the new behaviour: present when project-scoped, absent when global. A second short assertion on the global list — bnk_config is None — would then lock the redaction itself, which nothing currently does.


What I verified and found correct, since the description's tracing stops at the frontend:

  • All three serialize_cluster call sites are right: list_all_clusters redacts, list_project_clusters keeps it (project-scoped), and the third at cluster_management_service.py:513 is update_cluster, whose route requires require_cluster_owner. No fourth caller.
  • The MCP server is a second consumer of this endpoint and the description doesn't mention it — mcp-server/src/bnk_forge_mcp/tools/cluster_management.py:31 calls GET /api/k8s/clusters and passes the body through. It has no bnk_config reference anywhere, so nothing breaks, and since MCP agents are instance-wide too the redaction is correct there for the same reason. Worth stating explicitly in the description.
  • BnkClusterMemberDialog also reads bnk_config, which the description doesn't mention alongside K8sClusterList — but it receives currentConfig as a prop and its only renderer is K8sClusterList, which uses the project-scoped hook. So the conclusion holds; the enumeration was just incomplete.

Two non-blocking corrections:

The per-cluster detail never carried bnk_config. The description says it "keeps it", but get_cluster_details (cluster_management_service.py:395-415) builds its own dict by hand and has no bnk_config key at all — it doesn't route through serialize_cluster. Nothing to keep.

That's worth a comment in the code rather than just a correction here, because GET /k8s/clusters/{cluster_id} is Depends(require_viewer) with no project scoping — unlike the PUT and DELETE on the same path, which use require_cluster_owner. Any future change that makes that handler reuse serialize_cluster would silently reintroduce #116 one request further along, and the id needed to exploit it comes straight from the global list this PR is hardening. A line noting why that endpoint must not gain bnk_config would be cheap insurance.

Stale docstring: bnk_cluster_service.py:84 still reads "Eliminates the 2N query pattern in list_all_clusters / list_project_clusters" — after this change list_all_clusters no longer calls bulk_cluster_membership at all. It's still correct for list_project_clusters, so just drop the first name.

The scope note is the right call — the instance-wide visibility of cluster metadata is a tenancy-model decision that predates ADR-424, and fixing the specific widening without relitigating that is the correct boundary for this PR.

serialize_cluster(c, membership=membership_map.get(c.id))
for c in clusters
]
result = [serialize_cluster(c, include_bnk_config=False) for c in clusters]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what breaks test_serialize_cluster_with_bnk_config_and_members in tests/component/test_bnk_cluster_member_assignment.py, which asserts bnk_config is present on GET /api/k8s/clusters. The redaction is correct; the test needs retargeting to the project-scoped list so its B6 cross-bucket coverage survives, plus a bnk_config is None assertion here to pin the new behaviour.

membership: pre-fetched (host_ids, dpu_ids) for BNK clusters in list
contexts. When provided, _serialize_bnk_config uses it directly instead
of issuing per-cluster queries (eliminates the 2N pattern; ADR-424 finding C).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting here that get_cluster_details doesn't route through this function at all — it hand-builds its dict and has never carried bnk_config, so the PR description's "the per-cluster detail keeps it" isn't accurate.

More usefully: GET /k8s/clusters/{cluster_id} is Depends(require_viewer) with no project scoping, while PUT/DELETE on that path use require_cluster_owner. If that handler is ever refactored to reuse serialize_cluster, it inherits include_bnk_config=True and reopens #116 — with the cluster ids available from the very list this PR is redacting. A sentence in this docstring saying so would stop that.

…ring + guard

mwiget is right: CI was red and my "no backend test asserts bnk_config on the
global list" claim was wrong. test_serialize_cluster_with_bnk_config_and_members
did exactly that via client.get("/api/k8s/clusters"), and I missed it because it
goes through the HTTP client, not a serialize_cluster reference.

Retargeted rather than deleted, as suggested: its real subject is
_serialize_bnk_config's B6 cross-bucket guard (host_ids/dpu_ids don't
contaminate, with two hosts + two DPUs chosen so SQLite's per-table
autoincrement can't mask a leak) -- coverage worth keeping. It now fetches the
PROJECT-scoped list (/api/projects/{id}/k8s/clusters), where bnk_config is still
rendered, so the B6 guard survives, and adds a second assertion on the global
list that bnk_config is None -- locking the redaction, which nothing did before.

Also from the review:
- Guard comment on get_cluster_details: GET /k8s/clusters/{id} is require_viewer
  with NO project scope (unlike the PUT/DELETE, which use require_cluster_owner),
  and it hand-builds its dict. A future change reusing serialize_cluster there
  would silently reintroduce #116 one request further along, with the id coming
  from the very global list this PR hardens. Noted in code as cheap insurance.
- Dropped list_all_clusters from bulk_cluster_membership's 2N docstring -- it no
  longer serializes bnk_config, so it no longer calls this.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

Fixed, and thank you for tracing it further than I did — the red CI is the important catch, and my "no backend test asserts it" line was just wrong.

Blocking — the failing test. test_serialize_cluster_with_bnk_config_and_members did assert bnk_config on GET /api/k8s/clusters; I missed it because it goes through client.get(...), not a serialize_cluster/list_all_clusters reference my grep matched. Retargeted as you suggested rather than deleted — its real value is the B6 cross-bucket guard (host_ids/dpu_ids don't contaminate, two-of-each so SQLite's per-table autoincrement can't mask a leak). It now:

  • fetches the project-scoped list (/api/projects/{id}/k8s/clusters), where bnk_config is still rendered → the B6 guard survives intact, and
  • adds a second assertion on the global list that bnk_config is None → locks the redaction, which nothing did before.

So the one test now pins both halves of the new behaviour. Full member-assignment file green (19 passed).

Non-blocking, both done:

  • Guard comment on get_cluster_details. Exactly your point — GET /k8s/clusters/{id} is require_viewer with no project scope, it hand-builds its dict, and a future reuse of serialize_cluster there would reintroduce Global GET /k8s/clusters exposes cross-project BNK membership to any viewer (S2) #116 one request along, with the id coming from the global list this PR hardens. Added the warning in code.
  • Stale 2N docstring at bnk_cluster_service.py:84 — dropped list_all_clusters, since it no longer serializes bnk_config and no longer calls bulk_cluster_membership.

Corrections to the description, for the record (you're right on all three):

  • The per-cluster detail never carried bnk_configget_cluster_details hand-builds its dict and has no such key, so "keeps it" was wrong; there was nothing to keep. (The guard comment above now marks why it must stay that way.)
  • The MCP server is a second consumer of GET /api/k8s/clusters (cluster_management.py:31), passes the body through, references bnk_config nowhere, and is instance-wide too — so the redaction is correct there for the same reason. Should have named it.
  • BnkClusterMemberDialog also reads bnk_config, but via a currentConfig prop whose only renderer is K8sClusterList (project-scoped) — so the conclusion holds; my enumeration was just incomplete.

@mwiget mwiget left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three review items are done well, and P2 · Component Tests · Backend is green again.

The test retarget is exactly right: it moved to the project-scoped list so the B6 cross-bucket coverage survives, and you added the global-list bnk_config is None assertion in the same test — so present-when-scoped and absent-when-global are now pinned together, by one test, which is better than the two separate ones I suggested. The get_cluster_details guard note and the bulk_cluster_membership docstring fix both landed.

But while verifying the redaction I traced the read path further, and the disclosure is still reachable.

GET /projects/{project_id}/k8s/clusters (routes/k8s/clusters.py:92) carries dependencies=[Depends(require_viewer)] — and require_viewer is require_role(ROLE_ADMIN, ROLE_OPERATOR, ROLE_VIEWER), commented in routes/auth.py:138 as "any authenticated user". It is role-based only. There is no membership or ownership check on that route; _check_ownership exists but is wired into require_project_owner / require_cluster_owner, which guard the mutating endpoints.

So the project-scoped list is not actually scoped to the caller's projects — only to a project_id in the path, which anyone may supply.

And the global list hands over that parameter. serialize_cluster's include_project_id still defaults to True, so list_all_clusters returns every cluster's project_id alongside its id:

  1. GET /api/k8s/clusters → every cluster id and project_id, instance-wide.
  2. GET /api/projects/{that_project_id}/k8s/clusters → full bnk_confighost_ids, dpu_ids, control_plane_host_id, tmfifo_pool_cidr.

Same viewer, same data, two requests, no guessing. This is the shape I checked for on GET /k8s/clusters/{cluster_id} and correctly did not find — you hand-build that dict, and the guard note you added keeps it that way. The open door is the project list instead.

I don't think that makes the change wrong — removing bnk_config from an instance-wide surface is a strict improvement, and the missing per-project read authorization is genuinely pre-existing and larger than this PR. What I'm blocking on is narrower: the PR says Fixes #116, and merging it would auto-close an issue whose disclosure is still open.

The description's own wording already hedges this — the project-scoped list is described as one "which can be authorized per project". It can, and today it isn't.

Two ways to land this, either fine:

  • Keep the scope and fix the framingRelates to #116, and say plainly in the description that bnk_config remains readable by any authenticated user through the project-scoped list until that route enforces membership. That's the same call you made on #172, and it was the right one there.
  • Or close it properly — add the membership check to list_project_clusters in this PR. That genuinely fixes #116, and given require_cluster_owner and _check_ownership already exist it may be a small addition, though it's a real behaviour change for any caller relying on cross-project reads.

Your scope note distinguishes instance-wide metadata visibility (a pre-existing tenancy decision, correctly out of scope) from the ADR-424 bnk_config widening (in scope). I agree with that boundary — I'm only pointing out that the second one isn't closed yet either, for a different reason than the one you fixed.

CI: P1 and P2 all green including the retargeted component test. P3 · Integration Tests · Backend still running.

"""List all Kubernetes clusters (global).

This endpoint is instance-wide (require_viewer, not project-scoped), so
it must not expose the ADR-424 bnk_config -- host/DPU membership,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is accurate about this endpoint, but the conclusion it implies — that bnk_config is no longer exposed instance-wide — doesn't hold yet.

GET /projects/{project_id}/k8s/clusters is also Depends(require_viewer), which routes/auth.py:138 defines as any authenticated user with no membership check. And this method still returns project_id for every cluster (include_project_id defaults True), so a viewer reads the ids here and fetches the full bnk_config from the project list in a second request.

Worth either narrowing this docstring's claim or fixing the project route's authorization — see the review body.

#116)

mwiget traced the read path further: the disclosure is still reachable in two
requests. The global list returns each cluster's project_id (include_project_id
defaults True), and list_project_clusters serves full bnk_config for any
project_id supplied -- its route is require_viewer (any authenticated user) with
no membership/ownership check. So redacting bnk_config on the global list is a
strict improvement but does NOT close #116.

The real fix -- enforcing per-project membership on that route -- is the
pre-existing tenancy-model decision this PR deliberately scoped out
(require_viewer is role-based across the app; changing it is a real behaviour
change for any caller relying on cross-project reads). Documented the residual
at list_project_clusters, mirroring the get_cluster_details guard, so the open
door is discoverable in code. The PR is reframed Relates to #116 (not Fixes) so
merging does not auto-close an issue whose disclosure is still open.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

You're right, and I verified the chain independently before reframing: require_viewer is require_role(ADMIN, OPERATOR, VIEWER) — "any authenticated user," no membership check — and list_project_clusters filters by the project_id path param without ever checking the caller is a member of that project. The global list still hands out project_id (include_project_id defaults True), so it's exactly the two-request path you described: enumerate ids + project_ids instance-wide, then read any project's full bnk_config. My redaction closes the instance-wide door and leaves the project-list door open.

Taking Option ARelates to #116, not Fixes — for the same reason it was right on #172: the real fix is enforcing per-project membership on that route, and that's the tenancy-model decision this PR deliberately scoped out. require_viewer is role-based across the whole app; scoping reads to the caller's projects is a genuine behaviour change for anything relying on cross-project reads, and it's the product's call, not one to smuggle into a redaction PR. The issue's own framing ("decide the intended tenancy model") points the same way.

Done:

  • PR body reframed to Relates to #116, with a section stating plainly that bnk_config remains readable by any authenticated user through the project-scoped list until that route enforces membership. Merging no longer auto-closes Global GET /k8s/clusters exposes cross-project BNK membership to any viewer (S2) #116.
  • Documented the residual in code at list_project_clusters (mirroring the get_cluster_details guard you liked), so the open door is discoverable where someone would next touch it — with the #116 marker and why the membership check is the missing piece.

So this lands as a strict hardening of the instance-wide surface, and #116 stays open pending the membership-scoping decision. If the team wants Option B instead, it's a small addition mechanically (require_cluster_owner/_check_ownership exist) but a real tenancy change — happy to do it as its own PR against a decision, the way #103 went.

Thanks for tracing past the frontend both times — the project_id-as-the-key insight is the part I'd have missed.

@mwiget mwiget left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Fixes #116 is now Relates to #116 (does not fully close it), so merging won't auto-close an issue whose disclosure is still live — that was the whole of my block.

Putting the limitation in list_project_clusters' docstring rather than only in the PR body is the better half of this commit. A PR description is read once; that docstring sits on the method that actually renders bnk_config, and it states the mechanism precisely — require_viewer is role-based, project_id is a path param anyone may supply, and the global list still hands over that parameter. Whoever eventually adds membership scoping will find the reasoning where they need it, and whoever is tempted to render bnk_config somewhere new gets warned first. Together with the get_cluster_details guard note from the previous round, both of the routes that could reopen this now carry an explanation of why they're shaped the way they are.

The code was already correct; this round only changed comments and framing, and the retargeted component test from the round before still passes.

Three stale sentences in the PR description, worth a quick edit since the body outlives the review and will read as the record of what happened:

  • "No backend test asserts bnk_config on the global list either" — one did (test_serialize_cluster_with_bnk_config_and_members); it's the test you retargeted in 0f8d98f4.
  • "removing it there closes the disclosure without losing anything" — the "without losing anything" half holds, but "closes the disclosure" is now contradicted by the section you added below it.
  • "the project-scoped list and the per-cluster detail — whose callers actually consume it — keep it" — the detail endpoint never carried bnk_config; it hand-builds its dict, which is exactly why it needed the guard comment rather than a redaction.

Non-blocking, and I'd rather have the accurate docstrings than a polished description if it's a choice between them.

Worth opening a follow-up for the real fix — per-project membership enforcement on the read routes — so the remaining half of #116 doesn't rest solely on a docstring. I haven't filed one; tell me if you want it and I'll check for an existing issue first rather than duplicating something already open.

CI: P1 unit, P2 component and P2 legacy all green. P3 · Integration Tests · Backend still running; it doesn't touch this path and I'm watching it.

@jgruberf5
jgruberf5 merged commit 009840a into staging Aug 19, 2026
25 checks passed
@jgruberf5
jgruberf5 deleted the fix/116-redact-bnk-config-global-list branch August 19, 2026 21:34
@jgruberf5 jgruberf5 mentioned this pull request Aug 19, 2026
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.

3 participants