Files
cairnobs/api
jcoffey-dev 653e4efa76 Enforce the full user-management RBAC matrix, add self-service password change
api/localauth now enforces every rule of the requested matrix, each
checked inside the handler beyond RegisterRoutes' floor:
  - At least one owner must always exist -- handleDeleteUser and
    handleSetRole both refuse an operation that would leave zero
    owners (wouldRemoveLastOwner, backed by new store method
    CountUsersWithRole), whether the caller is admin or owner.
  - Owner can create/delete any role, including another owner (subject
    to the above). Admin can only create/delete viewer or editor --
    GET/POST /auth/users and DELETE .../{id} moved from RoleOwner to
    RoleAdmin floor, with an inner check narrowing what an admin
    caller specifically may target.
  - Only a user can change their own password -- new POST
    /auth/password (RoleViewer floor, i.e. every role) requires the
    caller's current password (verified via new store method
    GetPasswordHashByID) and is now the only path to changing your
    own, including for an owner. The existing admin-reset endpoint
    (POST /auth/users/{id}/reset-password, also moved to RoleAdmin
    floor) now refuses id == the caller's own ID, and refuses an
    owner target unless the caller is themselves an owner -- "admin
    can change any password except an owner's; owner can change any
    password, even another owner's."
  - Role reassignment (PUT .../{id}/role) stays owner-only, unchanged
    beyond the last-owner guard above.

New web/src/routes/account page (linked from NavSidebar next to "Log
out", visible to every local-auth role) is the self-service password
change UI. /users now mirrors the server's per-row restrictions
client-side (disabled role selects/delete/reset buttons with an
explanatory title, a restricted role list on the create form) so an
admin never sees an action that would just 403 -- the server remains
the actual authority.

Verified live against real Postgres and in the browser: the full
matrix via curl (owner creating a second owner, admin blocked from
creating/deleting/resetting admin or owner accounts, last-owner delete
and demote both blocked, admin resetting non-owner passwords,
self-target reset rejected, self-service change with wrong/right
current password), plus the actual /users page rendering correctly
restricted for an admin session and a full change-password round trip
through the real UI ending in a forced re-login with the new password.
2026-08-21 16:18:29 -07:00
..

api

Sentry's query API: a single POST /query endpoint accepting either the pipe syntax or raw SQL, compiled and routed across ClickHouse and Tantivy by internal/querylang. Replaces Phase 0/1's two separate placeholder endpoints (raw-SQL-only /query, free-text-only /search) — see /docs/query-language-design.md for the grammar, IR, and routing design, and /docs/query-language-reference.md for the user-facing syntax.

Why plain REST, not gRPC + REST gateway

CLAUDE.md pins the control plane to "Go, gRPC + REST gateway." This service is plain net/http instead — a deliberate simplification, not a change to the pinned stack. Wiring up a .proto service, google.api.http annotations, and protoc-gen-grpc-gateway codegen for one endpoint doesn't buy much at this size. api does speak gRPC internally — to /search — this simplification is about the public-facing surface only.

Endpoint

POST /query — body {"query": "...", "language": ""}, response {"columns": [...], "rows": [[...], ...]} or {"error": "..."}.

  • query is either pipe syntax (service=api | where status>=500 | stats count by host) or raw SQL (SELECT ...). Auto-detected by whether the query starts with SELECT (case-insensitive).
  • language optionally overrides detection: "sql" or "spl". Exists for the rare case a pipe query legitimately starts with the literal word "select" as a bare search term.
  • Both syntaxes compile to the same querylang/ir.Plan and execute through the same code path — see internal/querylang/executor for the four routing cases (pure ClickHouse; Tantivy prefilter + ClickHouse rows; Tantivy prefilter + ClickHouse aggregation; raw SQL passthrough).

GET /healthz — for docker-compose/k8s liveness checks.

No auth. Not scoped yet — don't expose this beyond a trusted dev/homelab network.

Configuration

Environment variables (see internal/config/config.go):

Var Default Purpose
HTTP_LISTEN_ADDR :8080
CLICKHOUSE_ADDR localhost:9000 Native protocol port
CLICKHOUSE_DATABASE / _USERNAME / _PASSWORD sentry / default / ``
SEARCH_GRPC_ADDR localhost:50052 Must match /search's GRPC_LISTEN_ADDR
QUERY_TIMEOUT_SECONDS 30 Per-request timeout
CORS_ALLOWED_ORIGIN * Wide open by default since there's no auth yet; tighten together

searchclient.Dial connects to /search over plain TCP, no TLS — same trust boundary as api's existing plain-TCP connection to ClickHouse. mTLS in this project is specifically the agent↔ingest edge boundary, not every internal hop.

Building & testing

go build ./...
go vet ./...
go test ./...
# from the repo root, not api/
docker build -f api/Dockerfile -t sentry-api .

Testing notes

internal/queryapi's HTTP handler depends on ClickHouse and /search only through the narrow interfaces querylang/executor defines (SQLRunner, SearchClient), so routing, compilation, JSON encoding, and error-status mapping are all unit-tested against fakes — no live ClickHouse or /search instance needed, and the real lexer/parser/ planner run unmocked in these tests, only the backends are faked. See internal/querylang's own package docs for how compilation and execution are tested independently of each other. executor.ChRunner (the reflection-based row scanning against ClickHouse's driver.Rows) and internal/searchclient's actual gRPC dial are not unit-tested — the former because faking driver.Rows fully would be significant test-only scaffolding the driver's own docs say isn't meant to be implemented by adopters; the latter because it's a thin wrapper with nothing but wiring to test. Both are exercised end-to-end via the docker-compose flow in /docs/phase-2-runbook.md.