Repository navigation
Conversation
`PermissionEngine` was built only in the Typer callback, so a persisted `permissions set --mode deny` policy -- and both `--deny-writes` and `--deny-destructive` -- protected the CLI process and nothing else. `kbagent serve` exposed all 236 routes, `DELETE /storage/buckets` included, behind a single all-or-nothing bearer token. #677 built the enforcement machinery but wired it to three `/auth/*` routes; this adds the missing half, coverage. One app-level dependency looks the matched route up by `(method, path template)` in a central table and calls the same `check_or_raise` the CLI uses, so a denial answers HTTP 403 with `error_code: PERMISSION_DENIED` -- the same code the CLI exits on. - `server/route_permissions.py`: 226 route -> operation entries plus 9 exempt bootstrap paths. Central rather than 226 per-route decorators so a security reviewer reads one screen, and so an unclassified route is refused instead of silently exempted. - The per-route `require_permission(...)` form still wins: a route declaring it inline is skipped by the table lookup, which is what keeps #677's `/auth/*` routes and test probe routes working. - New `GET /permissions/show` reports the EFFECTIVE policy (persisted block merged with the daemon's `--deny-*` flags). Read-only by design: a bearer token must not be able to widen the policy constraining it. - Two new serve-only operations, both `write` because both spawn a local CLI process on the host: `ai.chat`, `workspace.sql-improve`. - `permissions.py`'s `http.*` comment claimed serve-side enforcement that did not exist; it is true now and says so precisely. Tests: 31 new in `tests/test_server_route_permissions.py`. The completeness pair asserts the table matches the live app in both directions, so a route added without an entry fails CI rather than meeting its 403 in production. Verb/risk agreement is checked too (every DELETE is destructive-or-admin; the read-classified POSTs are an explicit allowlist). Mutation-checked: disabling the dependency fails 5. Fixes #655
…ermissions # Conflicts: # CLAUDE.md # docs/web-server-endpoints.md # plugins/kbagent/skills/kbagent/references/gotchas.md # src/keboola_agent_cli/permissions.py # src/keboola_agent_cli/server/app.py
…ermissions # Conflicts: # docs/web-server-endpoints.md # src/keboola_agent_cli/permissions.py
…s as destructive over REST
keboola-pr-reviewer-bot
left a comment
There was a problem hiding this comment.
Verdict: needs_human (risk 3/5) · profile _default
Security-hardening change extends the serve permission firewall to every REST route; sound and well-tested, but auth-decision logic on a production service warrants human sign-off.
Suggested reviewers: keboola/keboola-cli-maintainers
There was a problem hiding this comment.
🟨 Updated firewall rules stay inactive
After the host tightens its persisted policy, enforce_route_permission keeps using the startup engine. REST calls remain allowed under the old policy until the server restarts.
(Refers to this code)
Was this helpful? React with 👍 or 👎 to provide feedback.
| ("GET", "/components"): "component.list", | ||
| ("GET", "/components/{component_id}"): "component.detail", | ||
| # Scaffolding writes a new configuration (`config new --push`). | ||
| ("POST", "/components/{component_id}/scaffold"): "config.new", |
There was a problem hiding this comment.
🟡 Read-only scaffolds blocked by write policy
Under a write-denying policy, config.new blocks component scaffolding. generate_scaffold only fetches metadata and returns generated files; read-only users lose this endpoint.
Learn more
The component scaffold endpoint builds an in-memory scaffold and returns its generated files. It never calls the Storage configuration creation API. The CLI's config new has a separate optional --push path, but this REST route does not expose it. Mapping this route to the write-class config.new key prevents it from running under --deny-writes even though it is a read-only request.
Example: A server started with a policy denying cli:write accepts component detail requests but answers 403 to POST /components/my-component/scaffold. Without the policy, that request only returns the proposed files.
Recommended fix: Add a distinct read-class registry key for the scaffold-only operation, mark it serve-only if it has no matching CLI leaf, and map the scaffold route to it. Check both exact-name policies and category policies in a regression test.
Was this helpful? React with 👍 or 👎 to provide feedback.
| ("POST", "/lineage/build"): "lineage.build", | ||
| ("GET", "/lineage/info"): "lineage.info", | ||
| ("POST", "/lineage/show"): "lineage.show", | ||
| ("GET", "/lineage/edges"): "lineage.show", |
There was a problem hiding this comment.
🟡 Sharing edges blocked by unrelated policy
With only sharing.edges allowed, lineage.show blocks the cross-project edges endpoint. The edges handler serves the same sharing graph, so matching policies produce different results.
| ("GET", "/lineage/edges"): "lineage.show", | |
| ("GET", "/lineage/edges"): "sharing.edges", |
Was this helpful? React with 👍 or 👎 to provide feedback.
| def _declares_inline_permission(route: object) -> bool: | ||
| """Whether ``route`` declares a ``require_permission(...)`` dependency itself.""" | ||
| from .dependencies import PERMISSION_DEPENDENCY_MARKER | ||
|
|
||
| for dependant in getattr(route, "dependencies", ()) or (): | ||
| call = getattr(dependant, "dependency", None) | ||
| if getattr(call, PERMISSION_DEPENDENCY_MARKER, None) is not None: | ||
| return True | ||
| return False |
There was a problem hiding this comment.
| sl = MagicMock() | ||
| sl.child_scope.return_value = inherited | ||
| for method in ( | ||
| "create_model", | ||
| "add_metric", | ||
| "add_dataset", | ||
| "add_relationship", | ||
| "add_constraint", | ||
| "add_glossary", | ||
| "import_snapshot_from_dict", | ||
| "promote_model", | ||
| "build_model", | ||
| "scope_set", | ||
| ): | ||
| # A bare MagicMock return value is not JSON-serializable. | ||
| getattr(sl, method).return_value = {} | ||
| registry = ServiceRegistry.__new__(ServiceRegistry) | ||
| registry.semantic_layer = sl | ||
| app = _app(tmp_path, **kwargs) | ||
| app.dependency_overrides[get_registry] = lambda: registry |
There was a problem hiding this comment.
| "/notifications/{project}/{subscription_id}/replace-recipient", | ||
| ): "notification.replace-recipient", | ||
| # ── lineage (all read-only; `build` is the only cache writer) ───── | ||
| ("POST", "/lineage/build"): "lineage.build", |
There was a problem hiding this comment.
🟥 Read-only permission allows lineage writes
With cli:write denied, lineage.build still allows POST /lineage/build. The build handler writes files and can run sync.pull_all when refresh is true.
Was this helpful? React with 👍 or 👎 to provide feedback.
| ("POST", "/workspaces/{project}"): "workspace.create", | ||
| ("GET", "/workspaces/{project}/{workspace_id}"): "workspace.detail", | ||
| ("DELETE", "/workspaces/{project}/{workspace_id}"): "workspace.delete", | ||
| ("POST", "/workspaces/{project}/{workspace_id}/password"): "workspace.password", |
There was a problem hiding this comment.
🟥 Read-only policy permits password resets
Under cli:write denial, workspace.password still permits a workspace password reset. The password handler rotates credentials rather than merely reading them.
Was this helpful? React with 👍 or 👎 to provide feedback.
soustruh
left a comment
There was a problem hiding this comment.
The firewall design holds up. I found no blocking issue. Two small findings are inline: one stale version number in the docs, one note on the exempt-path check.
Checked:
- Fail-closed: the app-level dependency runs first, an unmapped route answers 403, and inline
require_permissionroutes (/auth/*,/merge-requests/*) are skipped correctly. The completeness test walks the lazy_IncludedRouterproxies and is cross-checked against the OpenAPI paths, so it cannot pass by seeing no routes. - Classification: I compared every
ROUTE_OPERATIONSvalue with itsOPERATION_REGISTRYclass. Every non-GET route maps to write or higher, except the eight documented POST-as-read routes plusworkspace.password, which is alsoreadon the CLI. Every DELETE is destructive or admin, except the two scope DELETEs, which arewriteas on the CLI. Both exceptions are documented. - Scope escalation: typed and inherited organization scope match
resolve_scope_targetsandgate_inherited_organization_scope(same keys, same model lookup only when a policy is active). All five item kinds, models, scope PUT, import, promote and build are covered. Edit routes have no scope field. GET /permissions/showis read-only, returns the effective policy, and cannot widen it. The firewall runs before project-ID translation. The new tests pass locally.
…last version without the firewall
keboola-pr-reviewer-bot
left a comment
There was a problem hiding this comment.
Verdict: needs_human (risk 4/5) · profile keboola-mcp-server
Authorization control-flow change extending the permission firewall to every kbagent serve REST route — needs a human despite high quality.
zajca
left a comment
There was a problem hiding this comment.
Actionable findings from the automated review.
Fixes #655.
The gap
PermissionEnginewas built only in the Typer callback (cli.py), so a persistedpermissions set --mode denypolicy — and both--deny-writesand--deny-destructive— protected the CLI process and nothing else.kbagent serveexposed all 236 routes,DELETE /storage/bucketsandPOST /token/{p}/deleteincluded, behind a single all-or-nothing bearer token.#677 built the enforcement machinery — an engine on
app.state, aPermissionDeniedError→ HTTP 403 handler, and therequire_permissiondependency — and wired it to three/auth/*routes. This PR adds the missing half: coverage.How
One app-level dependency (
FastAPI(dependencies=[...]), so no router can be added outside it) runs on every request, looks the matched route up by(method, path template)in a central table, and calls the samePermissionEngine.check_or_raisethe CLI uses. FastAPI puts the matched route intorequest.scope["route"]before dependencies resolve, so the template —/storage/buckets/{project}, not the concrete URL — is what the policy is keyed on. Verified at runtime, not assumed.Why one table instead of 226 per-route decorators:
--deny-destructive?" is answered by reading one file, not thirty routers.The per-route
require_permission(...)form still wins where it is declared: such a route is skipped by the table lookup (recognised via aPERMISSION_DEPENDENCY_MARKERattribute). That is what keeps #677's three/auth/*routes — and the test-only probe routes intests/test_server_permissions.py, registered aftercreate_app— working unchanged.With no policy configured, nothing changes.
is_allowedreturns True for everything, so the default install behaves exactly as before.Also in scope
GET /permissions/show(the LOW ask in serve: missing mirrors — describe-batch (inline payload), unload-table; job run lacks idempotency_key #657, and serve: PermissionEngine firewall is not enforced on REST routes #655's option-2 discoverability point). Reports the effective policy — the persisted block already merged with the--deny-*flags the daemon was launched with, which a REST caller can neither see nor change. Reachable under any policy (the same anti-lockout rule the CLI has), still requires the bearer token. Deliberately no write counterpart: letting a bearer token widen the policy that constrains it would make the firewall self-defeating, sopermissions set/resetstay terminal actions on the host.ai.chatandworkspace.sql-improve, bothwrite: neither touches Keboola, but both spawn a localclaude/codex/geminiprocess on the host, exactly like the already-writeagent.prompt-improve. Added toSERVE_ONLY_OPERATIONSso the command-sync gate does not read them as dead keys.permissions.py'shttp.*block claimed "The serve's own routes enforce their own permissions on top." It is true now, and the comment says precisely since when.Classification notes
DELETEroute maps to adestructive- oradmin-class operation; a test asserts it.readoperations (/flows/validate,/lineage/show,/kai/ask, …) — POST because the request needs a body, not because it mutates. Explicit allowlist in the test, so a new mutating route classifiedreadfails.GET /branches/{project}/merge-urlmaps to thewrite-classbranch.merge. That is CLI parity, not a slip:kbagent branch mergeonly ever produces a URL too.POST /semantic-layer/items/{kind}coversmetric/dataset/… in one route, so it maps to the collapsed parent keysemantic-layer.add, notsemantic-layer.add.metric. A policy naming only a leaf key is enforced on the CLI but not over REST — name the parent or acli:*category to cover both./health/ping,/health/auth-info,/ui-config,/docs,/redoc,/openapi.json,/docs/oauth2-redirect, and the SPA shell/+/index.html). A locked-down server must still be able to say who it is, or a client cannot tell a policy refusal from a dead process.Testing
make checkgreen: 6148 passed, 12 skipped.tyclean (the one remaining diagnostic is the pre-existingscripts/hatch_build.pyunresolved import). Lint, format, command-sync, version-gates, sentinel-guards, error-codes and the endpoint-reference gate all pass;docs/web-server-endpoints.mdregenerated and committed.31 new tests in
tests/test_server_route_permissions.py(new file, so no conflict with #681's edits totest_server_permissions.py):OPERATION_REGISTRYkey. This is what keeps the runtime fail-closed branch unreachable in a released build.cli:destructivedeniesDELETE /storage/buckets/{project}whileGET /projectsstays 200;--deny-writesdeniesPOST /jobs/{p}/run; an exacttoken.deletepattern works;mode=denyblocks an unlisted read but still serves/health/*.ROUTE_OPERATIONS./permissions/show— clean, persisted, effective-with-flags, inert patterns, reachable under total deny, still 401 without the token.Mutation-checked: commenting out the app-level dependency fails exactly 5 of them, so they are load-bearing rather than decorative.
A trap worth flagging for review
FastAPI 0.137 stopped flattening
include_routereagerly —app.routesholds 35 lazy_IncludedRouterproxies instead of the 236 routes they stand for, and nothing materialises them (notapp.openapi(), notTestClientstartup). Request handling is unaffected, but a completeness test that walkedapp.routesnaively would audit four routes, find nothing wrong, and pass._iter_api_routesrecurses throughoriginal_router; the docstring says why, because this is exactly the false-pass shape a coverage test must not have.Merge-order notes
src/keboola_agent_cli/permissions.py. Both PRs rewrite the same three-linehttp.*comment (it fixes the false claim; this makes the claim true). Expect a small conflict there — resolve in favour of whichever lands second, keeping this PR's wording if that is this one.ROUTE_OPERATIONSentries. The completeness test names each missing route, so the failure is self-explaining rather than mysterious.No version bump, no changelog entry (per CONTRIBUTING: those belong to the release PR). New behaviour is gated with the literal
(since vNEXT)placeholder on every doc surface;context.pycarries no version tag, matching the precedent set in #681.Doc surfaces updated (convention #17)
docs/web-server.md(new "The session firewall applies to every route" section, plus two stale paragraphs that asserted the gap),docs/web-server-endpoints.md(regenerated),CLAUDE.md,plugins/kbagent/skills/kbagent/references/gotchas.md(the "A deny policy does NOT firewall the whole REST surface" entry was live and is now inverted, with the 0.90.1-and-older behaviour kept for readers on those versions),commands-reference.md, andcommands/context.py'sAGENT_CONTEXT. No CLI command added, renamed, or removed, sokeboola-expert.mdandSKILL.mdneed no change.Update 2026-10-10
main(0.98.0).make checkpasses.mainadded after this PR are in the table: 4 routes from 0.96.x and the 7semantic-layer scoperoutes from 0.97.0. The completeness test passes, so no live route is unclassified.--scope organizationwrites are destructive-class over REST too. On the CLI,FLAG_ESCALATIONSmakes an organization scope destructive-class, because the elevation is one-way. The route table checks only the base key, so before this change a REST caller under--deny-destructivecould still elevate an item. NowPUT /semantic-layer/scope/{context_id},POST /semantic-layer/models,POST /semantic-layer/items/{kind}, import, promote andbuild(with a model) check the escalated key when the request asks for organization scope. An inherited organization scope is checked the same way as on the CLI: only when a policy is active, through the scope of the target model. The item check uses the per-kind key,semantic-layer.add.<kind> --scope organization.DELETEroutes (remove target projects, withdraw an elevation request) arewrite, as on the CLI. The test that everyDELETEis destructive or admin lists them as exceptions.TestOrganizationScopeEscalation(typed and inherited organization scope, per-kind keys, no lookup without a policy). Mutation check: with the escalated check removed, 18 of the 20 new tests fail.docs/web-server.md,CLAUDE.md,gotchas.mdandcommands-reference.mdsay that organization-scope writes are destructive-class over REST.POST /semantic-layer/items/{kind},PUTandDELETE /semantic-layer/items/{kind}/{name}) now check the parent key and the leaf keysemantic-layer.<verb>.<kind>, like the CLI. A policy that denies an exact leaf key or a glob such assemantic-layer.add.*now holds over REST too.permissions setorreset.