Skip to content

feat(api): scope installation health/repair to the owning tenant (#7661) - #8076

Closed
boskodev790 wants to merge 1 commit into
JSONbored:mainfrom
boskodev790:feat/tenant-installation-self-service-7661
Closed

feat(api): scope installation health/repair to the owning tenant (#7661)#8076
boskodev790 wants to merge 1 commit into
JSONbored:mainfrom
boskodev790:feat/tenant-installation-self-service-7661

Conversation

@boskodev790

Copy link
Copy Markdown
Contributor

Summary

The installation self-service routes — GET /v1/installations, GET /v1/installations/:id/health,
GET /v1/installations/:id/repair, and POST /v1/installations/:id/repair/refresh — carried no inline
role/ownership check. They were reachable only because the coarse canSessionAccessPath gate fell through
to the global operator check, so today only the fleet operator can see or repair installation health; a
hosted tenant has no self-service equivalent for their own installation (#7661).

This adds a tenant-scoped equivalent by reusing the maintainer-dashboard's exact scoping pattern:

  • canSessionAccessPath now lets these four paths through for a session, with per-installation authority
    enforced in the handlers (mirroring how the repo-scoped routes are allowed through the coarse gate and
    then check authority themselves).
  • Each handler runs resolveInstallationSelfServiceScope, which authenticates, role-gates
    (maintainer/owner/operator), then computes identity.kind === "session" && !operator ? loadControlPanelAccessScope(...) : null — the identical identity → role-gate → scope shape
    /v1/app/maintainer-dashboard uses.
  • A non-operator session is scoped to its own installations via scope.installationIds (the authoritative
    per-tenant set from the same loadControlPanelAccessScope): the list is filtered; a per-installation
    read for another tenant returns the same installation_health_not_found shape as a genuinely-missing
    id, so cross-tenant existence never leaks; and repair/refresh is scope-checked before the mutation,
    so a tenant can never trigger a refresh on another tenant's installation.
  • An operator or a server-to-server token gets scope === null — the unscoped fleet view, byte-for-byte
    unchanged from today (every existing operator/service-token test still passes).

Out of scope (per the issue): bulk/installation-level pause-all controls (#7676).

Scope

Validation

Verified locally against the current main base (deps refreshed with npm ci; @loopover/engine rebuilt
before typecheck, exactly as test:ci does):

  • git diff --check
  • npm run typecheck — 0 errors.
  • npm run test:coverage — the new test/unit/routes-installation-self-service.test.ts passes (9
    cases), and every changed line and branch in src/api/routes.ts is covered (100% on the diff,
    verified via lcov). Only src/** is Codecov-measured; this change is src/api/routes.ts only.
  • npm run ui:openapi:check — no spec drift (routes' request/response shapes are unchanged; only the
    auth/scoping behavior is added).
  • Existing installation-route tests (routes-errors.test.ts, api.test.ts, auth.test.ts) still pass
    — operator/service-token behavior is unchanged.
  • New behavior has unit tests for new branches and boundaries: unauthenticated → 401; no-role session →
    403 on every route; operator/service token → unscoped list + any installation; tenant → own-only list;
    tenant reads/repairs its own installation (200) but another tenant's returns 404; tenant's cross-tenant
    refresh is denied 404 without invoking the refresh mutation; tenant refresh of its own installation
    succeeds.

Safety

  • No secrets, wallet details, hotkeys, coldkeys, user PATs, private keys, raw trust scores, private
    rankings, or private maintainer evidence are exposed.
  • Public GitHub text stays sanitized and low-noise. (No public output changes.)
  • Auth/session change includes negative-path tests: unauthenticated (401), no-role session (403), and
    cross-tenant access (404 without leaking existence, and without mutating) are all covered. The scoping
    reuses the already-reviewed loadControlPanelAccessScope path rather than inventing a new one.
  • API behavior is tested. No OpenAPI/MCP surface changed (ui:openapi:check clean).
  • UI changes use live data / real states — N/A: no UI is added, this is backend route authorization.
  • Public docs/changelogs updated where needed — N/A; no changelog edit.

UI Evidence

N/A — this PR adds no visible UI, frontend page, or component. It is backend route authorization/scoping in
src/api/routes.ts plus its tests.

Notes

  • Cross-tenant reads deliberately return the same installation_health_not_found 404 as a missing id
    (rather than a 403), so a tenant cannot probe which installation ids exist for other tenants — the same
    no-leak posture the issue calls out.
  • repair/refresh is scope-checked before refreshInstallationHealthForInstallation runs, so a
    cross-tenant refresh performs zero work (asserted by a test that also verifies no fetch occurs).

…Nbored#7661)

The /v1/installations list, per-installation health, repair, and repair/refresh
routes had no inline ownership check — they were operator-only only because the
coarse canSessionAccessPath gate fell through to the global operator check. Add a
tenant-scoped equivalent so a hosted tenant can self-serve their own installation:
allow those paths through canSessionAccessPath, then in each handler reuse the
maintainer-dashboard's identity -> role-gate -> loadControlPanelAccessScope pattern.
A non-operator session is scoped to its own installations (list filtered; another
tenant's id returns the same not-found shape so existence never leaks; refresh is
scope-checked before any mutation); an operator or server-to-server token keeps the
unscoped fleet view unchanged.

Closes JSONbored#7661
@boskodev790
boskodev790 requested a review from JSONbored as a code owner July 22, 2026 19:31
@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.30%. Comparing base (a3dc73e) to head (7f15b41).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8076      +/-   ##
==========================================
+ Coverage   91.83%   95.30%   +3.47%     
==========================================
  Files         734        1     -733     
  Lines       75086     2555   -72531     
  Branches    23068      837   -22231     
==========================================
- Hits        68952     2435   -66517     
+ Misses       5034       27    -5007     
+ Partials     1100       93    -1007     
Flag Coverage Δ
rees ?
shard-1 30.17% <100.00%> (-25.17%) ⬇️
shard-2 43.67% <30.76%> (-12.12%) ⬇️
shard-3 71.19% <46.15%> (+20.62%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/api/routes.ts 95.30% <100.00%> (+0.08%) ⬆️

... and 733 files with indirect coverage changes

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.

Add tenant self-service for installation health/repair (currently operator-only)

1 participant