fix(proxy): forward scoped stack evidence for alerts, auto-heal, and node-wide image refresh (#1749)

* fix(proxy): forward scoped stack evidence for alerts, auto-heal, and node-wide image refresh

Extend the remote proxy scoped-evidence mechanism beyond /stacks/*
routes. Three new gates in runGatedProxy:

- Alerts POST: reuse the already-buffered body from the existing
  isAlertCreateRoute block, extract stack_name, check hub-side
  scoped permission, and forward SCOPED_STACK_AUTH_EVIDENCE headers.
- Auto-heal POST: same pattern with new body buffering and encoding
  rejection (no pre-existing buffering exists for this route).
- Node-wide image refresh: elevate PROXY_ROLE_HEADER to node-admin
  when the user has a scoped node:manage grant on the target node,
  matching the Settings pre-auth gate pattern.

Also extend classifyStackApiPath to recognize
/image-updates/refresh/:stackName as a named-stack route (stack:deploy),
ready for when PR #1743 adds the per-stack refresh endpoint.

Explicitly excluded: ID-based routes (DELETE /alerts/:id,
PATCH/DELETE /auto-heal/policies/:id) where the hub cannot resolve
remote-owned IDs to stack names; GET routes where stack:read is
globally granted to every role; and POST /auto-update/execute where
multi-stack/wildcard targets need a different evidence format.

* chore(proxy): add RBAC diagnostic logging to scoped permission path

Add developer_mode-gated diagnostic logs to checkPermission to expose
which check is failing when a scoped user is denied: effective tier,
DB query parameters, and node-scoped assignment lookups.

* chore(rbac): log effective tier and license status when scoped checks are blocked

Add an always-visible console.warn in checkPermission when the
effective tier prevents scoped role-assignment lookups, logging
both the resolved tier and the raw license_status DB value. This
surfaces the failure reason in container logs without requiring
developer_mode, so QA can diagnose why scoped users are denied.

* chore(rbac): sanitize scoped-tier log values to satisfy CodeQL log-injection check
This commit is contained in:
Anso
2026-08-01 23:37:35 -04:00
committed by GitHub
parent 15801318d6
commit 2e2b095b00
7 changed files with 575 additions and 10 deletions
+19 -4
View File
@@ -134,15 +134,16 @@ function decodeStackSegment(raw: string): string | null {
/**
* Classify a post-/api path for hub stack RBAC gating and evidence.
* Paths outside `/stacks` (and static `/stacks` collection routes) are
* `static`. Known named-stack families return the primary pre-check action.
* An unrecognized `/stacks/<name>/...` path fails closed as `unknown-named`.
* Paths outside `/stacks` and `/image-updates/refresh/` (and static
* `/stacks` collection routes) are `static`. Known named-stack families
* return the primary pre-check action. An unrecognized
* `/stacks/<name>/...` path fails closed as `unknown-named`.
*/
export function classifyStackApiPath(method: string, pathAfterApiStrip: string): StackRouteClassify {
const methodUpper = method.toUpperCase();
const path = normalizePath(pathAfterApiStrip);
if (!path.startsWith('/stacks')) {
if (!path.startsWith('/stacks') && !path.startsWith('/image-updates/refresh/')) {
return { kind: 'static' };
}
@@ -161,6 +162,20 @@ export function classifyStackApiPath(method: string, pathAfterApiStrip: string):
return { kind: 'static' };
}
// /image-updates/refresh/:stackName → per-stack image check (stack:deploy).
// This branch runs before the /stacks/-only regex, which would never match.
// Unknown sub-paths under this prefix fail closed (unknown-named), matching
// the fail-closed behavior for unknown /stacks/<name>/... paths.
if (path.startsWith('/image-updates/refresh/')) {
const imageRefreshMatch = /^\/image-updates\/refresh\/([^/]+)$/.exec(path);
if (imageRefreshMatch) {
const stackName = decodeStackSegment(imageRefreshMatch[1]);
if (!stackName) return { kind: 'unknown-named' };
return { kind: 'named-stack', stackName, action: 'stack:deploy' };
}
return { kind: 'unknown-named' };
}
const match = /^\/stacks\/([^/]+)(.*)$/.exec(path);
if (!match) {
return { kind: 'static' };