Skip to content

feat: authentication and role-based repo access - #9

Merged
gangtao merged 11 commits into
mainfrom
worktree-auth-roles
Aug 12, 2026
Merged

feat: authentication and role-based repo access#9
gangtao merged 11 commits into
mainfrom
worktree-auth-roles

Conversation

@gangtao

@gangtao gangtao commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #8. Closes #6 (eager tpk serve bootstrap seeds the admin account on startup).

Summary

Adds authentication and role-based repo access to tpk serve:

  • Auth store (src/tpk/auth.py): kg_users/kg_roles/kg_sessions mutable streams, argon2id password hashing, seeded admin/changeme account with forced password change, session tokens with configurable TTL (TPK_SESSION_TTL, default 24h).
  • /auth router: login, logout, me, change-password. Every non-/auth, non-/healthz route requires a valid bearer session; a must_change_password user gets 403 password_change_required until they change it. Login is timing-safe against username enumeration (dummy argon2 verify on unknown usernames).
  • Admin-gated management API + user/role CRUD: every /api/* route (corpus management, /api/users*, /api/roles*) now requires an authenticated admin bearer token — replaces the old optional TPK_ADMIN_TOKEN/X-Admin-Token header scheme entirely. Last-admin protection (can't demote/disable/delete the only remaining admin). Role deletion blocked while still assigned to a user.
  • Role-scoped chat: roles are named access lists of corpus entry_keys (name@ref, e.g. docs@main). A ROLE_SCOPE contextvar threads the caller's role scope through KnowledgeGraph's query paths and the chat agent's tools, so a non-admin user's chat/search is provably restricted to their role's entries — admin is reserved and always unscoped.
  • Login UI + Users tab: React web UI gates on login, forces a password change screen for must_change_password users, and adds a Users tab (admin-only) for user/role CRUD, including checkbox-based entry_keys selection when building a role.
  • Dedicated tpk DB user: docker compose now provisions a tpk DB user and password-locks the built-in default user with the same TIMEPLUS_PASSWORD (now required), instead of leaving default open. Both tpk and agent services connect as tpk.

E2E evidence (live stack built from this branch)

Ran docker compose build && docker compose up -d from this branch with fresh, uniquely-named compose volumes (no reused state from prior stacks).

  • Seeded login: POST /auth/login {admin, changeme} → 200, must_change_password: true.
  • Forced change enforced: /api/repos with that token → 403 password_change_required; /auth/change-password clears it; /api/repos then → 200.
  • Unauthenticated /api/repos and /chat → 401. Non-admin /api/repos → 403.
  • Ingested docs (semantic) and proton-enterprise (code-only) via the management API using the admin token, polling /api/jobs to ok.
  • Created role docs-only (entry_keys: ["docs@main"]) and user cust; confirmed role-scoped chat answers docs questions but never cites proton-enterprise, while an admin asking the same question does cite it.
  • DB user separation: curl -u default: http://localhost:8123 --data "SELECT 1"AUTHENTICATION_FAILED (403); -u tpk:$TIMEPLUS_PASSWORD1 (200).
  • Browser (Playwright): login screen gates the app; forced-change screen shown and clearable for a fresh must_change_password user; Users tab CRUD (create/delete role with checkbox-built entry_keys, create/delete user) all verified end-to-end; non-admin session shows only the Chat nav tab (no Manage/Users).
  • TIMEPLUS_HOST=localhost TIMEPLUS_USER=tpk TIMEPLUS_PASSWORD=... uv run pytest -q → 134 passed (proves the credentialed path against the password-locked stack, not just the open dev default).
  • web: npm run build and npm run check:sanitize both pass.

Deviations from the spec

  1. Default-user password lock instead of loopback network restriction. The spec's original wording described restricting the default user to loopback networks; this branch instead password-locks default with the same TIMEPLUS_PASSWORD as the new tpk user. Reason: reliably scoping default to loopback-only via the timeplusd YAML config-merge semantics proved fragile across the base image's XML/YAML merge behavior (network restrictions didn't merge/override predictably), whereas an explicit password on default is simple and equally effective for this compose topology (no service other than tpk/agent needs default at all).
  2. Entrypoint chmod/@remove fixes. The base image ships /etc/timeplusd-server/users.d/ as dr-x------, read-only even to its own owner (UID 101) — the entrypoint needs chmod u+w on that directory at build time so it can write the rendered tpk-users.yaml override at container start. Separately, timeplusd's YAML config merge unions sibling keys rather than replacing them, so overriding default's plaintext password: '' required an explicit '@remove': '1' on the password key (the YAML equivalent of ClickHouse/timeplusd's XML @remove merge attribute) before setting password_sha256_hex, or the server refused to start with "more than one field of 'password', 'password_sha256_hex' are used."

Also fixed during this task's live E2E gate: unquoted backticks in a heredoc comment inside tpk-entrypoint.sh were being interpreted as shell command substitution (`password: ''` / `default`), producing spurious "command not found" errors in container logs on every startup (functionally harmless — the generated YAML's real fields were unaffected — but noisy); escaped them.

Post-final-review fixes

Follow-up fixes from the final whole-branch review (commit 46691b8):

  • App.tsx mount-time /auth/me unhandled rejection. The mount effect now wraps the /auth/me call in try/catch/finally; a network-level fetch rejection (e.g. server restarting on page load) falls through to the login gate instead of leaving the app rendering null forever.
  • App.tsx logout() error handling. logout() now clears the local session (setToken(null), setMe(null)) in a finally block, so a network failure on /auth/logout no longer strands the user in the authenticated view.
  • server.py non-blocking role lookup. The chat handler's non-admin auth_mod.get_role(...) call now runs via run_in_threadpool instead of synchronously on the event loop, so a slow/unreachable store no longer blocks every concurrent request for a TCP connect timeout. The existing fail-closed frozenset() behavior on error is unchanged.
  • Users.tsx least-privilege add-user default. The new-user form no longer defaults role to admin; role selection is now required (empty option disabled, submit button disabled until a role is picked).

Verified: cd web && npm run build && npm run check:sanitize pass; TIMEPLUS_HOST=localhost uv run pytest -q → 134 passed (dev store's default user has no password, so the plain unauthenticated path applies).

gangtao added 11 commits August 11, 2026 16:45
Equalize argon2 cost for unknown-username logins against a precomputed
dummy hash so the store-lookup miss no longer returns faster than a
known-user/wrong-password attempt (username enumeration via timing).
Wrap the remaining unguarded store calls (create_session in login;
delete_session in logout; upsert_user/delete_user_sessions in
change-password) so any store error surfaces as 503, never a 500,
matching the plan's fail-closed constraint.
_StubAuth._client() now raises immediately instead of falling through to
AuthLayer's real db.get_client(), which the /chat handler's non-admin
role-scope lookup was silently reaching over the network -- ~21s stalls
against an unreachable-but-not-refusing TIMEPLUS_HOST. The fail-closed
except-Exception -> frozenset() path is now exercised deterministically
with zero I/O, matching the rest of test_server.py.
Documents the auth/roles feature (login model, seeded admin + forced
change, name@ref role access lists, user/role API, break-glass DB reset,
TPK_SESSION_TTL) and the dedicated tpk DB user / required
TIMEPLUS_PASSWORD, and removes every stale TPK_ADMIN_TOKEN/X-Admin-Token
reference now that /api is always admin bearer-token gated.

Also fixes a real bug hit while running the live E2E gate against a
stack built from this branch: unquoted backticks in a heredoc comment
in tpk-entrypoint.sh were interpreted as shell command substitution,
producing spurious "command not found" errors on every container start
(harmless to the generated users.d override, but noisy).
Post-final-review fixes on the auth & roles branch:
- App.tsx mount effect now catches /auth/me network failures and still
  sets checked=true, falling through to the login gate instead of
  rendering a permanent blank page.
- App.tsx logout() always clears the local session in a finally block,
  even if the /auth/logout request throws.
- server.py's chat handler runs the non-admin role lookup via
  run_in_threadpool so a slow/unreachable store no longer blocks the
  event loop for every concurrent request; fail-closed frozenset()
  behavior on error is preserved.
- Users.tsx add-user form no longer defaults to the admin role; role
  selection is now required (least privilege).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant