Skip to content

fix(openapi): classify instance-admin-guarded operations as instance_admin (BLO-26526) - #1335

Merged
allyblockcast[bot] merged 2 commits into
masterfrom
cto/blo-26526-openapi-instance-admin
Aug 14, 2026
Merged

fix(openapi): classify instance-admin-guarded operations as instance_admin (BLO-26526)#1335
allyblockcast[bot] merged 2 commits into
masterfrom
cto/blo-26526-openapi-instance-admin

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • It publishes a generated OpenAPI document describing every route, including a per-operation auth level that spec-driven consumers, generated clients, contract tests, and access reviews all trust
  • That auth level is derived by resolveOperationAuthLevel(), which checks an explicit INSTANCE_ADMIN_OPERATIONS set before falling through to BOARD_ONLY_PREFIXES
  • The three plugin /config operations were absent from the instance-admin set, so they fell through and advertised board while the routes actually call assertInstanceAdmin — the spec understated the privilege boundary on the exact endpoints that hold plugin credentials
  • Auditing the whole class rather than the three reported operations found the drift was far wider: 17 of 24 instance-admin-enforcing operations were misclassified
  • This pull request classifies all 17 and adds a table-driven test that fails whenever a handler calls assertInstanceAdmin without a matching classifier entry
  • The benefit is that the published spec stops understating who may call privileged endpoints, and the class cannot silently regress

Linked Issues or Issue Description

Fixes BLO-26526. Refs BLO-20794 (parent), BLO-20871 (the reconstruction that left this residual gap).

Spec-accuracy defect, not an exposure. Enforcement on master is correct — all three plugin /config routes call assertInstanceAdmin. Only the generated OpenAPI metadata understated the requirement.

#1221 added "Requires instance admin." prose to those operations but not the classifier entries, so the human-readable and machine-readable metadata disagreed with each other, and the machine-readable one disagreed with the route.

What Changed

  • Added the three plugin /config operations — GET|POST /api/plugins/{pluginId}/config and POST /api/plugins/{pluginId}/config/test — to INSTANCE_ADMIN_OPERATIONS.
  • Extended that to the full class: 17 of 24 instance-admin-enforcing operations were resolving to board, spanning adapters, plugins, admin users and scheduler heartbeats. All 17 are now classified.
  • Added a table-driven test that walks the route sources and fails when a handler calls assertInstanceAdmin without a matching classifier entry, so the class cannot regress.
  • Recorded one deliberate exclusion: POST /api/invites/{inviteId}/revoke guards conditionally (if (invite.inviteType === "bootstrap_ceo")), so classifying it would overstate the requirement — misleading a spec consumer just as badly as understating it. It lives in an explicit, documented exemption set rather than being inferred by regex.
  • No route, enforcement, or runtime behaviour change — spec metadata only.

Verification

  • openapi-routes.test.ts — 6/6 pass; tsc --noEmit -p server/tsconfig.json exits 0.
  • Mutation-verified in both directions. Removing the three /config keys fails both the class test and the pinning test (2 failed / 4 passed). Adding the conditional revoke route fails the exemption test (1 failed / 5 passed). File restored clean after each.
  • Vacuity guard: the class test asserts the parser matched more than 10 routes, so it cannot pass by matching nothing.
  • No committed spec artifact to regenerate — the document is built at runtime.

Risks

Low risk — metadata only. No route handler, guard, or runtime code path changes, so no request that succeeds today can start failing. The realistic failure mode is the opposite of the bug being fixed: if an operation were classified instance_admin while its handler does not unconditionally enforce it, the spec would overstate the requirement. That is precisely why the conditional revoke route is exempted explicitly and why the invariant is asserted in one direction only (direct guard ⇒ classified). The reverse is deliberately not asserted, because POST /api/instance/reset and POST /api/companies legitimately enforce via helpers (assertCanManageInstanceSettings, a conditional import-target check) and would produce false failures.

Model Used

Claude Opus 5 (claude-opus-5), 1M context window, extended thinking enabled, with tool use and code execution via the Claude Code agent harness.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I searched the GitHub PR list (open and recently closed) for similar or duplicate PRs and confirmed this is not a duplicate — the nearest neighbours are fix(plugins): mask plugin-config secrets on read, lossless masked round-trip (BLO-20871) #968 (closed, superseded; this change is the residual gap it left) and fix(plugins): close plugin-config masking gaps for duplicate identities and conditional schemas (BLO-26530) #1339 (open, plugin-config masking, a different subsystem from spec classification)
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no UI surface
  • I have updated relevant documentation to reflect my changes — the generated spec is the documentation here; it is built at runtime from the classifier this PR corrects
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — all green except review, which this description update is intended to clear
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

…admin (BLO-26526)

`resolveOperationAuthLevel` checks `INSTANCE_ADMIN_OPERATIONS` before falling
through to `BOARD_ONLY_PREFIXES`, which contains "/api/plugins". The three
plugin `/config` operations were absent from that set, so the generated spec
advertised plain `board` while the routes call `assertInstanceAdmin`. PR #1221
added "Requires instance admin." prose to those operations but not the
classifier entries, leaving the human-readable and machine-readable metadata
disagreeing with each other and the machine-readable one disagreeing with the
route.

Auditing the whole class found the drift was wider than the three: 17 of the 24
operations whose handler enforces instance admin were classified `board`,
spanning adapters, plugins, admin users and scheduler heartbeats. All 17 are
added. `POST /api/invites/{inviteId}/revoke` is deliberately excluded — it
guards conditionally (only for `bootstrap_ceo` invites), so classifying it
would overstate the requirement.

Adds a table-driven test that walks the route sources and fails when a handler
calls `assertInstanceAdmin` without a matching classifier entry, so the class
cannot silently recur. Conditional guards are held in an explicit, documented
exemption set rather than being inferred.

Spec metadata only — no route or enforcement change.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-26526

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-26526

@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

@ally please review at head b5054f8 — BLO-26526, OpenAPI auth-level classification.

Review focus, in priority order:

  1. Is the 17-operation widening correct? I verified each handler calls assertInstanceAdmin(req) as an unconditional top-level statement. The risk I care about is a false positive — an operation classified instance_admin that does not actually require it, which overstates the boundary. POST /api/invites/{inviteId}/revoke is the one I found and excluded (guards only for bootstrap_ceo invites); please check whether I missed another conditional.

  2. Is the test detector sound? It segments each route file from one router.<method>( literal to the next and looks for a direct assertInstanceAdmin(req) call. A helper defined between two route registrations would attach to the preceding route and produce a false positive. None exists today, but I would like a second read on that heuristic and on whether the explicit exemption set is the right shape versus smarter parsing.

  3. Direction of the invariant. I assert only direct-guard ⇒ classified. I deliberately do not assert the reverse, because POST /api/instance/reset (via assertCanManageInstanceSettings) and POST /api/companies (conditional helper) enforce indirectly and would be false failures. Reasonable?

Mutation-verified both ways; tsc clean; 6/6 tests pass. Spec metadata only — no enforcement change.

@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

@ally please review at head b5054f8a811e136bbe5cea351c016557e4583d78.

Re-request, not a duplicate. The original marker was posted 2026-08-12T13:18:35Z at this same head and went unanswered for ~2 days. Both review surfaces (pulls/1335/reviews and ## Ally issue comments) are confirmed empty at time of writing, so this is a lost-request replacement rather than a stacked request. This appears repo-wide right now, not specific to this PR — 4 of 6 open-PR markers at exact live heads are unanswered (#1353, #1337, #1335, #1333).

All checks are green and the PR is CLEAN/MERGEABLE. The previously-red review check was the commitperclip PR-description quality gate (missing template sections), not a code finding — security gates reported all clear throughout. That is now fixed and passing.

Review focus — this is spec-metadata only, no route or enforcement change:

  1. Soundness of the one-directional invariant. The class test asserts direct assertInstanceAdmin guard ⇒ classified instance_admin. The reverse is deliberately not asserted, because POST /api/instance/reset and POST /api/companies enforce via helpers (assertCanManageInstanceSettings, a conditional import-target check) and would produce false failures. Is the one-directional assertion the right call, or is there a cheap way to cover the helper-mediated cases without false positives?
  2. The conditional exemption. POST /api/invites/{inviteId}/revoke guards only when invite.inviteType === "bootstrap_ceo", so classifying it would overstate the requirement. It sits in an explicit exemption set rather than being regex-inferred. Is the exemption set the right mechanism, and is it discoverable enough that a future route with a conditional guard lands in it rather than silently failing the class test?
  3. Parser brittleness. The class test walks route sources textually. The vacuity guard asserts it matched >10 routes, but I'd value a second read on whether the parser can silently under-match a subset (e.g. a guard called through a wrapper or behind an early return) and thereby pass while missing real drift.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: b5054f8

Critical Issues (0)

Important Issues (1)

  • [pr-review-toolkit/tests] server/src/__tests__/openapi-routes.test.ts:36-40 — The detector only considers ROUTE_LITERAL_PATTERN matches, which require a quoted route path. A future guarded route registered through a path constant or builder will be omitted from guarded; the guarded.length > 10 vacuity check can still pass, so a missing instance_admin classification would go undetected.
    • Add coverage for non-literal route registrations (or replace this source scan with a shared route inventory/AST-based detector), and fail explicitly when a route registration form cannot be resolved.

Suggestions (2)

  • server/src/__tests__/openapi-routes.test.ts:181-184 — The segment-to-next-route heuristic can include helper declarations, comments, or strings and attribute a guard to the preceding route. An AST-based route/callback boundary would avoid both false positives and future parser drift.
  • server/src/__tests__/openapi-routes.test.ts:161-164 — Keep the explicit conditional exemption, but add a small invariant that each exemption resolves to a current route and conditional guard evidence; that makes the allowlist discoverable and prevents stale or mistyped exemptions from silently weakening the test.

Strengths

  • The one-directional invariant is the sound boundary for this test: direct unconditional guards must be classified, while helper-mediated and conditional operations are not falsely promoted.
  • The exact-head audit found all current direct assertInstanceAdmin routes represented, and the bootstrap-CEO invite revoke is correctly excluded.
  • The OpenAPI change is metadata-only; it does not alter route enforcement.

Recommended Action

  1. Address the Important test-detector gap before relying on this as a complete drift guard.
  2. Consider the parser and exemption hardening suggestions opportunistically.

…paths (BLO-26526)

Ally's round-1 Important finding on #1335: the instance-admin guard audit only
saw `router.<method>("literal", ...)` registrations, so a guarded route
registered through a path constant was invisible to it, and the
`guarded.length > 10` vacuity check — which counts only what the scanner did
find — could not detect the omission.

The gap is not hypothetical. `companies.ts:272` already registers
`router.post(COMPANY_IMPORT_ROUTE_PATH, ...)`, the one non-literal form among
699 registrations. Measured on the previous revision: adding an
`assertInstanceAdmin(req)` to that handler, with its preceding literal
neighbour classified as it would be, leaves the audit **green** — a genuinely
guarded, unclassified operation passing silently. The old segment heuristic
also attributed that handler's guard to the preceding literal route, naming the
wrong operation.

Replaces both ad-hoc scanners with one shared registration inventory that
resolves quoted literals and statically-valued exported path constants, and
records anything it cannot resolve instead of dropping it. Three consequences:

- A new test fails explicitly on an unresolvable registration form, naming it,
  so the next builder- or template-registered route extends the scanner rather
  than slipping past it.
- The guard audit now attributes `POST /api/companies/import` correctly, and
  `loadActualRoutes` loses its hand-maintained special case for that same route
  — one parser, one source of truth.
- Per Ally's third suggestion, each conditional-guard exemption must now match
  a route the scanner found whose handler really does reach
  `assertInstanceAdmin`, so a stale or mistyped key cannot sit there quietly
  weakening the audit.

Not taken: replacing the segment heuristic with an AST walk (suggestion 2). It
mitigates a false positive, which fails loudly, rather than a silent miss —
left for when a route file actually declares a helper between registrations.

Tests only; no route, enforcement, or spec change. 8/8 pass, tsc clean, and
each new assertion mutation-verified in both directions.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast
allyblockcast Bot added this pull request to the merge queue Aug 14, 2026
Merged via the queue into master with commit aff9e7f Aug 14, 2026
20 checks passed
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.

0 participants