Skip to content

serve: expose auth register-projects over REST (POST /auth/register-projects) - #677

Merged
padak merged 6 commits into
mainfrom
feat/537-serve-auth-register-projects
Aug 23, 2026
Merged

serve: expose auth register-projects over REST (POST /auth/register-projects)#677
padak merged 6 commits into
mainfrom
feat/537-serve-auth-register-projects

Conversation

@padak

@padak padak commented Aug 23, 2026

Copy link
Copy Markdown
Member

Closes #537.

Exposes the non-interactive part of the auth group over kbagent serve, exactly per the issue's proposed scope, and introduces the first permission-policy enforcement seam on the serve path (AC #5).

What's in

New REST endpoints (server/routers/auth.py):

Endpoint Mirrors Permission op
GET /auth/projects?stack= candidate listing (collision-free suggested aliases) auth.projects (read, new registry entry)
POST /auth/register-projects — body {stack?, all?, project_ids?, aliases?} auth register-projects --all/--project-id/--alias auth.register-projects (write)
GET /auth/status?stack= auth status auth.status (read)
  • The interactive picker stays CLI-only; the endpoints are selector-driven and never prompt. The service's selections parameter (the picker's input) is not reachable over REST.
  • login, login-password and logout deliberately get no endpoints (browser required / password over REST / machine-wide revocation), per the issue's out-of-scope list; the router docstring records the rationale.
  • No response carries a token value, including the kbc-session:// sentinel — pinned by a test that serializes real result dataclasses and greps the wire bytes.
  • SESSION_NOT_FOUND / SESSION_EXPIRED answer 401 via the existing central mapping; the exists / skipped alias-protection statuses pass through unchanged; session_unsupported_features survives the REST boundary (test-pinned).

Permission seam (first serve router to enforce the policy):

  • create_app now composes a PermissionEngine from the persisted policy of the config dir it actually serves, plus deny_writes / deny_destructive flags passed from the CLI (apply_firewall_flags is now shared between cli.py and the server instead of being CLI-only). An explicitly passed permission_engine= remains an embedder override.
  • The engine lives on app.state and the require_permission dependency fails closed — a test overriding get_registry cannot accidentally disable enforcement.
  • Only /auth/* routes are guarded so far; the other routers are unchanged (documented in gotchas.md so nobody assumes a deny policy firewalls the whole REST surface).
  • New SERVE_ONLY_OPERATIONS set in permissions.py: registry keys with no CLI leaf command (currently auth.projects), subtracted only in check_command_sync.py's dead-key check. Documented in CONTRIBUTING.md.

Docs: docs/web-server.md (endpoint section + accurate status-code semantics), CLAUDE.md, gotchas.md — all version-gated text uses the (since vNEXT) placeholder per the release process; no version bump, no changelog entry.

Notes for review

Two things the issue's AC #5 didn't anticipate, surfaced during implementation (both verified live):

  1. kbagent --deny-writes serve never starts — serve is an admin-class operation and --deny-writes denies cli:write, which spans admin, so the CLI callback blocks the command itself. The docs therefore lead with the reachable forms (a persisted narrow policy such as permissions set --mode allow --deny auth.register-projects, or mode=deny with serve in the allow list) and state the --deny-writes interaction explicitly. A test pins --deny-writes serveEXIT_PERMISSION_DENIED.
  2. serve has its own --config-dir, distinct from the root flag. The engine is now composed against the served dir, so its persisted policy always applies (regression test for the divergent-dirs case). Pre-existing and untouched: the root-level kbagent --config-dir X serve does not propagate X into serve's own resolution — filed separately as serve: root-level --config-dir is silently ignored (kbagent --config-dir X serve serves a different dir) #679 with a verified reproduction.

One deliberate semantic to be aware of: GET /auth/status reports a missing/expired session as HTTP 200 with the health in the body's status field (live / refreshed / degraded / expired / missing) — it is the probe you call to find out, so it must not itself fail; clients that want to branch on the HTTP status must call /auth/projects or /auth/register-projects (those 401).

Relation to #655

#655 ("serve: PermissionEngine firewall is not enforced on REST routes") asks for exactly the mechanism this PR builds — option 1 in that issue: build the engine in create_app() from the same config dir and enforce it via a FastAPI dependency. This PR implements it, but wires it to /auth/* only, so #655 stays open for the remaining ~30 routers; gotchas.md says so explicitly, so nobody reads a deny policy as a whole-surface firewall.

The last commit also corrects the http.* comment in permissions.py that #655 flagged as load-bearing and false — it claimed serve routes enforce their own permissions on top, which was true for no route before this PR and is true for /auth/* after it.

Testing

  • New: tests/test_server_auth.py (kwarg parity, all alias, 401/4xx/403 mapping, token-free wire check, no-login-routes check), tests/test_server_permissions.py (seam: persisted policy, flag composition, fail-closed, served-dir-wins, --deny-writes serve blocked end-to-end).
  • make check green end-to-end: 6069 passed, 12 skipped; ty clean for all touched files; check_command_sync (264 commands), check_sentinel_guards, check_version_gates, loc-check all OK.

@padak
padak requested a review from zajca August 23, 2026 22:54

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@padak
padak force-pushed the feat/537-serve-auth-register-projects branch from f3edda8 to 2a5761b Compare August 23, 2026 23:05
padak added 6 commits August 24, 2026 01:17
Adds the first permission-policy enforcement seam on the `kbagent serve`
REST surface, plus the AuthService wiring the /auth/* router will need.

- ServiceRegistry gains `auth: AuthService` and a `permission_engine`
  field. create_app builds the engine from the persisted
  `config.permissions` policy when none is passed (a corrupted config
  degrades to "no policy" instead of refusing to start), and
  `serve_command` now forwards the CLI's own engine so the process-global
  --deny-writes / --deny-destructive flags reach the routes too.
- New `require_permission(operation)` FastAPI dependency factory;
  PermissionDeniedError maps centrally to HTTP 403 with the same
  PERMISSION_DENIED error code the CLI prints.
- OPERATION_REGISTRY gains the serve-only `auth.projects` (read). It has
  no CLI leaf command, so SERVE_ONLY_OPERATIONS exempts it from the
  dead-key check in scripts/check_command_sync.py.
- tests/test_server_permissions.py covers the default/explicit engine
  precedence, the 403 envelope, and the serve -> create_app plumbing.
Expose the read/audit half of the auth session picker over the REST
surface: GET /auth/projects, POST /auth/register-projects, and GET
/auth/status, each gated by require_permission and delegating to
AuthService. login/login-password/logout stay CLI-only (documented
in the router's module docstring) since they mint or destroy live
browser sessions rather than read an existing one.
…nforcement

Task 3 of issue #537: document the read/audit auth endpoints added in Tasks
1-2 (GET /auth/projects, POST /auth/register-projects, GET /auth/status),
their permission ops, and the deliberate login/login-password/logout
REST gaps, replacing the now-stale "auth has no REST router" claim in
docs/web-server.md. Also records that /auth/* is the first (and so far
only) router enforcing the permissions policy, and documents the
SERVE_ONLY_OPERATIONS registry-key exemption in CONTRIBUTING.md's
command-sync gate section. All version-gated text uses the (since vNEXT)
placeholder per the no-version-bump-in-feature-PRs convention.
GET /auth/status never raises SESSION_EXPIRED/SESSION_NOT_FOUND -- AuthService.status()
explicitly catches both cases and reports session health (status: missing/expired/
degraded/refreshed/live) in the response body with HTTP 200, because it is the probe
callers use to detect a dead session and must not itself fail that way. The 401
behavior only applies to GET /auth/projects and POST /auth/register-projects, which
go through _introspect_accessible_projects / provider.introspect() and let those
error codes propagate. Corrects the previous docs/web-server.md passage that
lumped all three endpoints together.
…onfig dir

Fix wave over the whole #537 branch, addressing the review findings:

- create_app now builds the permission engine from the persisted policy of the
  config dir it actually SERVES plus the session flags, instead of taking the
  CLI callback's pre-built engine. `kbagent --config-dir A serve --config-dir B`
  serves B, so B's policy is the one routes are checked against; only the
  --deny-writes / --deny-destructive flags travel from the invocation.
  `apply_firewall_flags` moves to permissions.py so the CLI and create_app share
  one composition (re-exported from cli.py for existing callers).
  `permission_engine=` stays as an explicit override for embedders and tests.
- The engine now lives on app.state, not on ServiceRegistry, and
  require_permission reads it from request.app.state and fails CLOSED when it is
  absent. Overriding get_registry in a test can no longer silently disable
  enforcement; the hand-set registry.permission_engine workaround is gone.
- Docs (docs/web-server.md, CLAUDE.md, gotchas.md) now lead with the reachable
  enforcement forms. `kbagent --deny-writes serve` cannot start the server at
  all -- `serve` is admin-class and cli:write spans admin -- so a persisted
  narrow policy in the served config dir is the recipe, verified live.
- Scope the /auth/status doc claim: 200 for a missing or expired session, but an
  unresolvable stack (4xx) and an unexpected auth-service failure (502) are
  still errors.
- check_command_sync.find_drift takes serve-only keys as its own parameter and
  subtracts them only in the dead-key check, so a future `auth projects` CLI
  leaf cannot be misreported as missing from OPERATION_REGISTRY.
- Tests: divergent-config-dir regression, deny-writes-blocks-serve, end-to-end
  persisted-policy enforcement on /auth/*, session_unsupported_features surviving
  the REST boundary, and a dict annotation that unblocks `ty`.
@padak
padak force-pushed the feat/537-serve-auth-register-projects branch from 2a5761b to 66216db Compare August 23, 2026 23:20
@padak
padak merged commit b504d49 into main Aug 23, 2026
3 checks passed
@padak
padak deleted the feat/537-serve-auth-register-projects branch August 23, 2026 23:28
padak added a commit that referenced this pull request Aug 23, 2026
Batches the nine PRs merged since v0.90.0 (#670, #671, #672, #673, #674, #675, #676, #677, #678) into one version bump and one changelog entry.
padak added a commit that referenced this pull request Aug 24, 2026
…ns comments (#681)

* docs(permissions): fix a leftover vNEXT and the false serve-enforcement claim

Two corrections in OPERATION_REGISTRY's comments, both fallout of #677:

- `auth.projects` still carried `(since vNEXT)` after the 0.90.1 release.
  The release-time gate (scripts/check_version_gates.py) only scans markdown
  surfaces -- CLAUDE.md, docs/*.md, plugins/**/*.md -- so a placeholder in
  src/ resolves nowhere and ships silently. Pinned to 0.90.1, the release
  that actually carries the endpoint.

- The `http.*` block claimed "the serve's own routes enforce their own
  permissions on top". Issue #655 flagged that as load-bearing and false:
  it was true for no route at all. Since 0.90.1 it is true for /auth/* and
  nothing else, so the comment now states that scope instead of a blanket
  claim that misleads the risk classification directly beneath it.

* serve: honor the root-level --config-dir (closes #679)

`serve` is the only subcommand carrying a `--config-dir` of its own, and it
forwarded only that one into `create_app`. The root-level `kbagent
--config-dir A serve` was ignored entirely, with no warning: the server
exposed whatever the fallback chain resolved (usually the global config)
instead of the directory the caller named. Since 0.90.1, when the `/auth/*`
routes began enforcing the persisted `permissions` policy, that also meant a
deny policy stored beside the caller's projects was silently not the one in
force -- verified live: `POST /auth/register-projects` answered 401 (the other
directory's session state) where the named directory's policy says 403.

Precedence is now most-specific-wins, the same rule `kbagent repl` applies
when forwarding root flags into a subcommand: `serve --config-dir X` beats a
root `kbagent --config-dir Y`, which beats KBAGENT_CONFIG_DIR / the .kbagent
walk-up / global. Passing both is not an error. Only an explicit `cli-flag`
source propagates -- for env-var/local/global, `create_app` resolves the
identical directory itself, and forwarding those would pin a resolution made
at a different moment.

Tests cover all four flag combinations plus the env-var non-propagation, and
one end-to-end case proving a deny policy in the root-flag directory reaches
the REST surface. Docs updated on docs/web-server.md, CLAUDE.md, gotchas.md,
commands-reference.md and the `kbagent context` text; the new behavior is
gated `(since vNEXT)` for the release PR to resolve.

* tests: correct the mechanism named in the /auth/status control comment

The comment justifying the `!= 403` assertion said "there is no session in
this fixture dir", implying a SESSION_NOT_FOUND / 401 path. Verified: the
response is HTTP 400 CONFIG_ERROR -- the fixture dir has no default project
and the request carries no `?stack=`, so `AuthService.status()` ->
`_resolve_stack_url()` raises ConfigError before any session lookup happens.

Comment rewritten to name that mechanism and to lead with the point it is
there to make: `/auth/status` is gated by a different registry key
(`auth.status`), which the deny policy does not name, so a non-403 proves the
denial is narrow rather than a blanket failure of the whole REST surface. The
assertion is unchanged -- it was correct.

* ci(version-gates): scan src/**/*.py, so a placeholder cannot ship again

The previous commit rewrote a `(since vNEXT)` that survived the 0.90.1
release, but not the reason it survived: check_version_gates.py resolved
its file list from SCANNED_GLOBS, which listed markdown plus exactly one
hand-picked Python file (commands/context.py). permissions.py was never
read, so the placeholder was invisible to the very check built to catch
it -- and the next feature PR writing a gate in a Python comment would
have shipped the same defect. Fixing only the symptom leaves the hole.

A version gate in a Python comment is agent-facing documentation exactly
like a markdown one, so src/**/*.py is now scanned as a whole (it
subsumes the context.py entry). Verified against the 0.90.1 tree: the
widened scan reports permissions.py:29, the marker that shipped.

Its first run on the current tree also surfaced a real, pre-existing
defect -- `(since v0.26.1)` on the project members & invitations block.
0.26.1 never existed: it is not a CHANGELOG key, `git tag -l "v0.26*"`
lists only v0.26.0, and `gh release view v0.26.1` reports no release.
The feature shipped in 0.29.0 (CHANGELOG 0.29.0 describes the seven
`project` member/invitation commands; the commit that added this very
block is c7d7078 "feat(0.29.0): ... + member lifecycle", drafted as
0.26.1 and renumbered -- the docs were rewritten at release, the Python
comment was not). Corrected to v0.29.0.

scripts/*.py is deliberately left out and the constant now says why:
this module has to NAME the placeholder it hunts (its --release usage
line, the VNEXT_TOKEN constant), so scanning it self-flags forever.

INLINE_CODE_RE also learns double-backtick spans. Python docstrings here
use RST convention, so the first ``(since vNEXT)`` written in one would
otherwise be a false positive. Measured: markdown residue is unchanged
at 7, i.e. no live gate is lost to over-stripping. The double-backtick
alternative comes first because alternation is left-biased -- the
single-backtick pattern would match the empty span between the two
opening backticks and leave the token exposed.

Gate counts: 67 files / 476 markers -> 295 files / 529 markers.

* docs(permissions): stop the http.* comment contradicting its own registry

The block opened with "GET = read, mutating verbs = write" and then
classified `http.delete` as destructive three lines below it. DELETE is
a mutating verb, so a reader auditing the four keys against the stated
rule hits a contradiction on the strictest one -- and this comment is
the security rationale those keys are audited against.

State the mapping that the registry actually implements, in the taxonomy
declared at the top of the file (read = no side effects, write =
creates/modifies, destructive = deletes): GET = read, POST/PATCH =
write, DELETE = destructive. The extra rung is load-bearing, not
cosmetic: apply_firewall_flags adds `cli:destructive`, which matches
only the destructive class, so classifying http.delete as `write` would
let a --deny-destructive session delete through the REST boundary.

* fix(version-gates): pin UTF-8 when reading and writing scanned files

The Windows job failed on the new test with UnicodeEncodeError: cp1252 is
the platform default there, and the fixture mirrors a real repo line whose
section rule is box-drawing characters. Reproduced locally --
write_text(body, encoding='cp1252') raises exactly the CI error, and the
same bytes read back as cp1252 turn '--' rules into mojibake.

The read side matters beyond the test. Widening SCANNED_GLOBS to
src/**/*.py put 73 non-ASCII-carrying source files in front of a
read_text() that never named an encoding, so on Windows the scan would
regex over mangled text -- and since INLINE_CODE_RE keys on backticks,
mojibake can move or destroy the very characters that separate a live
placeholder from prose quoting one. A gate that classifies differently per
OS is worse than no gate.

Both read_text() calls in the script and every read/write in its tests now
name UTF-8 explicitly.
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.

serve: expose auth register-projects over REST (POST /auth/register-projects)

1 participant