Commit Graph
122 Commits
Author SHA1 Message Date
jcoffey-dev 754996dbfd Parse a query that starts with a pipe-stage keyword
`stats count by host` with no leading filter is documented as valid in
/docs/query-language-reference.md, but every pipe-stage keyword is also
a valid bare identifier, so the parser read it as a base_search of four
ANDed free-text terms ("stats", "count", "by", "host") -- matching
nothing, and returning an empty result rather than an error, which is
the worst of both outcomes for anyone typing it.

Recognizing a leading stage keyword up front and skipping straight to
stage-parsing fixes it with no planner or executor change: q.Base stays
its zero value, and compileBoolExpr already treats zero terms as
match-everything. The comparator lookahead keeps a genuine field named
`where`/`stats` (`where=foo`) parsing as a filter, as before.
2026-08-22 16:12:17 -07:00
jcoffey-dev c920e0f2c4 Finish the Cairn OBS rename through services, docs, and assets
The rename commit before this one covered module paths and the obvious
user-facing strings; this is the rest of it -- the places where "sentry"
was a default value, a filename, or a picture rather than a word in a
sentence.

Defaults that changed: CLICKHOUSE_DATABASE (sentry -> cairnobs),
POSTGRES_DATABASE (sentry_metadata -> cairnobs_metadata), and
POSTGRES_USERNAME (sentry -> cairnobs), across api/alerting/ingest and
the enterprise binaries, plus the compose files and migrate scripts that
create those objects. These are *defaults*, so a deployment that sets
them explicitly is unaffected -- but any deployment relying on the old
defaults must have its environment updated before it picks this up, or
it will come up pointing at a database that doesn't exist.

Also: the light-mode logo variants (the dark ones existed alone, so the
landing page and sidebar rendered a dark mark on a light background),
regenerated favicons, and the docs/README/threat-model prose that still
said Sentry.
2026-08-22 16:12:08 -07:00
jcoffey-dev 13cf9a30cb Rebrand: Sentry -> Cairn OBS
Full rebrand across cosmetic branding, code identifiers, and
infrastructure/data-plane naming, using the supplied Cairn OBS logo
package. Cosmetic: favicon/logo swap (also closes a stale license-audit
finding -- the old favicon was SvelteKit's unreplaced scaffold logo),
new centered welcome landing page, larger/legible sidebar logo, page
titles, CLAUDE.md/README/docs prose.

Code identifiers: Go module path github.com/sentry/sentry ->
github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc
regenerated); Rust crates sentry-agent/sentry-parser/sentry-search ->
cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully
renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type,
env vars); every session/auth cookie name; agent config paths and
Windows service identity.

Deliberately preserved: the gRPC wire protocol's protobuf packages
(sentry.logs.v1, sentry.agent.v1) and their Go import directory
(proto/sentry/...) -- renaming the wire-level package would break every
currently-deployed agent binary (confirmed two real hosts, including
mail.inbuxa.com, are actively streaming through this exact contract)
until rebuilt and redeployed in lockstep with an ingest cutover. Only
the Go module path wrapping the generated code changes.

Infrastructure: every docker-compose container name (root and three
component-level compose files); the Helm chart (directory, Chart.yaml,
named-template helpers, all templates, values.yaml image repos);
Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML
files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd
package. Caught and fixed real path-coupling bugs along the way: the
Helm chart's search/ingest volume mounts and the dev-only-credential
detection constant vs. docker-compose.yml's literal values had to move
together or a security warning would have silently stopped firing.

Data plane: Postgres database sentry_metadata -> cairnobs_metadata and
role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka
topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups.
Source-level defaults, docker-compose.yml, and every migrate.sh/
provision script default updated together; already-applied migration
files left untouched per this repo's immutable-migration convention.

Verified at every layer: all 13 Go modules build/vet/test clean, both
Rust workspaces (agent, search) build/clippy/test clean, npm run check/
build clean, docker compose config validates on all four compose files.
Live-verified against a real docker stack multiple times through this
work, including a final fresh-volume run confirming the actual renamed
Postgres database/role, ClickHouse database, and Kafka topic all work
end to end with a real login and query, zero console errors.
2026-08-21 20:53:32 -07:00
jcoffey-dev 9e21ea17bb Fix log retention Settings section getting stuck on "Loading hosts…"
Root cause: handleHosts and partitionTargets both declared their result
slices with `var`, so an empty result (no logs old enough yet, or every
requested target blocked by a floor) marshaled to JSON `null` instead of
`[]` on fields without `omitempty`. The frontend's `.length` access on
that `null` threw mid-render, which is why this shipped with the spinner
stuck forever instead of the empty state ever painting -- production is
freshly deployed with nothing yet older than the default 30-day cutoff,
so every user hit this on first load.

Also replaces the static "Loading hosts…" text with the existing
shimmer Skeleton component for real visual feedback, and adds `?? []`
fallbacks in api.ts as a second line of defense.
2026-08-21 17:08:50 -07:00
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
jcoffey-dev 5ff2e5bb60 Scope log retention deletion and floors to (host, service), not host alone
logs rows carry a real per-record `service` (nginx, smtp, ufw, ...) --
already true of the schema (storage/migrations/0001) and wire protocol,
not something this feature invents. Both the deletion picker and the
retention floor now operate on (host, service) pairs instead of whole
hosts, so an operator can delete just one noisy log type from an agent
without touching everything else it ships, and can protect one service
(e.g. keep smtp a year) longer than the rest of that host's default.

api/agents.ConfigOverride gains ServiceLogRetentionDays (map[string]int),
owner-only to change like LogRetentionDays -- a service listed there
overrides the host's LogRetentionDays default for that service only.
Agent config page gets a matching "Per-service log retention overrides"
add/remove list next to the existing host-level field.

api/logretention: Store's count/delete now take []HostService and build
a ClickHouse tuple IN ((?,?),...) over (host, service); AgentRetentionStore.
FloorsByHost returns each host's default plus its per-service map, with
HostFloor.Effective(service) resolving which one applies. preview/delete
moved from GET/DELETE-with-query-params to POST-with-JSON-body (a list of
targets needs a real body, not a repeated compound query param), and
partitionTargets checks the floor per target so one protected service
never blocks deleting a different, unprotected one in the same request.

Settings' Log retention section is a two-level picker now: each host
row (with a "select all services" checkbox and its default floor badge)
expands to its services, each with its own count and effective
protected-days badge.

Verified live against real ClickHouse/Postgres and in-browser: a host
with a 7-day default plus a 365-day smtp override -- deleting nginx+
smtp+ufw together correctly removed nginx and ufw, left smtp's 10
records untouched, and confirmed via a follow-up owner delete that
bypassing the floor works. Also verified the full click-through (add a
service override on the agent page, see it reflected in Settings'
picker, select/preview/cancel) and confirmed no regression from the
prior host-only version's tests.
2026-08-21 15:54:58 -07:00
jcoffey-dev 087c52a64f Scope log retention deletion to selected hosts, not the whole table
api/logretention no longer deletes wholesale by age alone: a new
GET /logs/retention/hosts lists every host with matching records (plus
any configured retention floor), and preview/delete now require an
explicit, non-empty host list -- there is no "omitted host means every
host" shortcut server-side. Store's count/delete statements are
host-scoped (host IN (...)); Handler.partitionHosts checks the floor
per host instead of one global max, so a floor on one host never
blocks acting on other hosts requested in the same call. A request
that ends up fully or partially blocked still returns 200 with
blocked_hosts explaining why, rather than rejecting the whole call.

Settings' Log retention section is a host picker now: checkboxes with
per-host counts and a "protected Nd" badge where a floor applies,
"select all/none", and a confirm panel that names exactly which hosts
will be affected and which were skipped and why.

Verified live against real ClickHouse/Postgres and in-browser: three
hosts seeded, one protected by a 90-day floor -- a scoped delete
correctly removed the two open hosts' records, left the protected
host's untouched, and the response/UI both named it as skipped. Also
fixed a real spacing bug in the result message caught during that
browser pass (an adjacent {expr}{#if} with no source whitespace
between them rendered with no space either).
2026-08-21 15:32:05 -07:00
jcoffey-dev a20bb5d1c7 Add per-agent log retention floor, owner-only to set or override
api/agents.ConfigOverride gains LogRetentionDays: a per-agent setting
edited on the same remote-config page as extra_file_paths, but unlike
every other field there it's central-policy metadata api/logretention
reads, never something the agent process itself sees. Any change to
it -- setting, raising, lowering, or clearing -- requires RoleOwner,
not just RoleAdmin: the whole point of the field is a floor an admin
can't move, so an admin able to freely edit it would defeat that.

api/logretention now checks the largest LogRetentionDays configured
across any agent (AgentRetentionStore, new) before every preview/delete:
a non-owner's request is rejected with a clear 403 if it would reach
into that protected window. An owner always bypasses it, matching "make
the log retention override any attempts to delete logs by anyone other
than owner role."

Verified live end-to-end: owner sets a 90-day floor on an agent, admin
is blocked deleting anything newer than that (both preview and delete),
allowed beyond it, and owner bypasses it entirely -- confirmed against
real ClickHouse data, not just the fake-backed unit tests. Also caught
and fixed a real pre-existing latent bug while verifying in-browser: a
type="number" Input's bind:value becomes an actual JS number once a
user types into it (only the initial value is a string), which broke a
bare .trim() call on the new field.
2026-08-21 15:11:41 -07:00
jcoffey-dev 787def06fd Add owner/admin-only log retention deletion to Settings
New api/logretention package: GET /logs/retention/preview and
DELETE /logs/retention, both gated to RoleAdmin (Owner satisfies it
too), issue purpose-built parameterized statements against ClickHouse's
logs table (a count and a synchronous ALTER TABLE ... DELETE mutation)
rather than routing through querylang/executor's SELECT-only SQLRunner.

Settings gets a new "Log retention" section, visible only to an owner
or admin, that previews how many records a chosen age cutoff would
remove before showing an explicit confirm/cancel panel -- no delete
happens without that second step.

Scoped to core's single-tenant ClickHouse table; enterprise/'s
per-tenant routing and Tantivy's lack of a bulk-delete primitive are
disclosed gaps in api/logretention/store.go's doc comment, not silently
assumed to already work.
2026-08-21 14:49:18 -07:00
jcoffey-dev 864e68253a Give local users their own manager: custom passwords and role reassignment
Move user management out of Settings into its own /users page (nav-gated
to owners), let an owner type a specific password on reset instead of
always generating a random one, and add role reassignment via a new
PUT /auth/users/{id}/role endpoint. Role changes revoke the target's
existing sessions, same as a password reset, so a demoted user can't
keep acting under a stale, higher-privileged session.
2026-08-21 14:23:52 -07:00
jcoffey-dev 4b5dae5879 Add local login, agent extra log paths, IPv4/IPv6 metrics; remediate security audit findings
This is a large squashed commit covering two batches of prior uncommitted
work plus a full security-audit remediation pass, kept together because
go.mod/go.sum and several shared files (main.go, handler.go) were touched
by both and splitting risked non-building intermediate commits.

Features (built earlier, previously uncommitted):
- Local username/password login for single-tenant deployments with no
  SSO configured (api/localauth, alerting/internal/sessioncheck,
  sentryctl users, web/src/routes/login, metadata migrations 0040/0041).
- Remotely-editable additional log file paths for agents, on top of
  their existing primary source (api/agents, agent/sentry-agent
  extra-file-path diffing, web agent config UI).
- IPv4/IPv6 addresses reported alongside other host system metrics.

Security audit remediation (this pass, all live-verified in production):
- Critical: block ClickHouse SSRF table functions (url/remote/file/s3/...)
  in the raw-SQL query escape hatch.
- High: deny sensitive paths and require Admin to add agent
  extra_file_paths (Editor could previously point an agent at /etc/shadow
  or an SSH key); alerting webhook targets now validate against
  internal/metadata/loopback addresses, both at creation and send time;
  alerting's session middleware now enforces an Editor+ floor on
  mutating requests instead of "any authenticated session"; bumped
  goxmldsig to close a SAML signature-verification bypass (GO-2026-4753).
- Medium: per-IP login rate limiting; security response headers
  (HSTS/CSP/nosniff/X-Frame-Options/Referrer-Policy/Permissions-Policy)
  on web/nginx.conf; a DevCredentialWarnings check in every Go service's
  config loader, logging loudly at startup if a deployment is still on
  docker-compose.yml's literal dev-only credentials; dependency bumps
  (golang.org/x/text, grpc, x/net, quick-xml, h2) across every affected
  Go module and both Rust crates, including a previously-uncovered x/net
  vulnerability in deploy/operator; a new security-scan.yml CI workflow
  running cargo-deny/govulncheck/npm-audit, mirroring the existing
  license-compliance.yml matrix shape.
- Low: removed sentryctl's plaintext --password flag (shell
  history/`ps` exposure) in favor of stdin and a --password-stdin flag
  for reset-password's optional specific-password path; a dummy bcrypt
  comparison closes a login response-time username-enumeration
  side-channel.
2026-08-18 23:53:20 -07:00
jcoffey-dev d2bb9de245 Add sentryctl agents CLI surface
sentryctl agents list|get, config get|set|clear, restart -- same
list/get shape as dashboards/alerts, plus a config sub-subcommand
mirroring dashboards' permissions since an override has its own
lifecycle distinct from the agent resource itself.

config set is the one command with real logic: since PUT
/agents/{host}/config replaces the whole stored override rather than
patching individual fields, it fetches the agent's current effective
config first and merges only the flags actually passed on top of it,
mirroring the web UI's edit form logic in Go. restart requires
explicit confirmation (interactive y/N or --yes) and refuses on
non-interactive stdin without --yes, the same posture cmd_query.go's
--nl/--execute already established for anything that changes what's
running.

Live-verified the merge logic specifically, since it's the part most
likely to hide a real bug: setting one field on a clean agent
correctly carried forward its other reported values, and a second
config set call correctly carried forward the first call's override
rather than resetting it to the reported baseline. config clear and
restart --yes both round-tripped against a live agent, with the
restart picked up and acted on within one check-in cycle.

This closes out the agent-management punch list (restart, fleet-wide
alerting, this CLI surface) -- see /docs/agent-management-design.md.
2026-08-16 20:30:45 -07:00
jcoffey-dev 21fb68a0d4 Document fleet-wide alerting via the raw-SQL escape hatch
No code changes -- alert rules already accepted query_language: "sql"
with zero validation restricting it to the pipe syntax, and the web
UI's rule-creation form already auto-detects SQL vs. pipe syntax via
the shared QueryBar component. This was simply never exercised in
this specific combination before.

Live-verified with three agents: a threshold rule on
count(DISTINCT host) against an expected fleet size correctly
evaluated ok with all three healthy, then correctly fired when one
was killed and its heartbeat rows aged out of the window -- one rule
covering a whole named group of hosts instead of one rule per host.

Documented as an honest aggregate check, not true per-host alerting:
that would need the alerting engine's own per-group state tracking,
already named a Phase 3 non-goal for the whole engine, not something
specific to agents -- explicitly out of scope here rather than
quietly built as a side effect.
2026-08-16 20:30:20 -07:00
jcoffey-dev 93c160ec51 Add agent restart lifecycle command
Extends the existing CheckIn RPC with a one-shot AgentCommand
(restart only -- stop/uninstall need real per-platform OS
service-manager integration and stay deliberately out of scope),
delivered at-most-once: cleared the instant it's handed to the agent
in a response, since a restarting agent's process is gone before it
could ever confirm receipt. On restart, the agent flushes whatever's
buffered, aborts its source task, and exits cleanly, relying entirely
on the host's own service manager to bring it back up.

Issuing a command is gated at RoleAdmin (stricter than config
editing's RoleEditor) and logged into the same audit_log table Phase
7's AI interactions use, via a new agent_command event type.

A real bug was found and fixed during live verification: the first
implementation tried to atomically read-and-clear pending_command in
a single INSERT...ON CONFLICT statement using a sibling CTE
referenced only from RETURNING, on the assumption that Postgres
evaluates every part of a WITH query against one pre-statement
snapshot. That's wrong specifically for FOR UPDATE, which always
reads the latest row version including one written earlier in the
same statement -- confirmed empirically (a restart command was
always coming back empty even when genuinely pending, so the agent
never received it). Fixed by splitting into two real, ordered
statements inside one explicit transaction.

See /docs/agent-management-design.md's "Lifecycle commands" section.
2026-08-16 20:30:07 -07:00
jcoffey-dev 3827d10e6e benchmark-fixture: add -time-spread and -include-fatal flags
-time-spread spreads generated records' timestamps uniformly at random
across [now-spread, now] instead of all landing at ~now, for building a
demo/exploration dataset with a real time axis (0, the default,
preserves the original all-at-now volume-benchmark behavior).
-include-fatal adds a low-frequency FATAL severity to the mix, off by
default so the volume benchmark's existing severity distribution is
unchanged unless asked for.
2026-08-16 18:09:04 -07:00
jcoffey-dev 4f0da1ae5e Add agent inventory, management, and remote config
Extends the heartbeat mechanism with a second gRPC service on the same
mTLS channel (AgentControl.CheckIn, agent-initiated on the existing
heartbeat ticker -- still push-only, no inbound port on any agent) so
an agent reports its running config and can pick up an operator-set
override. A new web UI section (/agents) lists every agent that's
checked in, shows its reported config, and lets an operator edit a
narrow, deliberately-scoped subset remotely: batch/heartbeat tuning,
and (journald sources only) the unit filter.

TLS material and the ingest endpoint are never reportable or remotely
editable, by proto shape rather than a validation rule -- a bad or
malicious edit there could permanently strand an agent or redirect
where its logs go, unlike every other editable field, which only
degrades behavior.

An override lives only in the agent's memory (agent.toml is never
rewritten) and re-syncs on the agent's own schedule; changing the
journald filter aborts and respawns the source task since there's no
other way to change what's being tailed. Building the hot-reload path
surfaced a real, independent, pre-existing bug: shutdown was using
poll_timeout(), which only drains once flush_interval has elapsed,
silently dropping anything buffered more recently on every graceful
shutdown that landed between flushes -- fixed with a new unconditional
Batcher::flush_all(), now used at both shutdown and hot-reload.

Verified live end-to-end against a real stack: an edited heartbeat
interval changed a running agent's actual send cadence within one
check-in cycle (confirmed by the real timestamps landing in
ClickHouse), and an edited journald filter triggered a real source
restart, both reflected back in the next reported-config snapshot.

See /docs/agent-management-design.md.
2026-08-16 18:08:51 -07:00
jcoffey-dev 4df6931869 Add agent heartbeat monitoring and fix a query-language lexer bug
Agents now send an independent "still alive" record on a configurable
schedule (seconds/minutes/hours, [heartbeat] in agent.toml), separate
from real log traffic and tagged with a sentry.heartbeat attribute.
No new wire protocol -- it's an ordinary record through the same
PushBatch RPC/mTLS identity every log line already uses. Unavailability
alerting reuses the existing absence-condition alert rule type
unchanged; no new alerting code was needed. See
/docs/agent-heartbeat-monitoring.md for the design and how to build the
alert rule.

While verifying the alert rule live, found that the query language's
lexer never treated '-' as part of an identifier, so any unquoted
hyphenated filter value -- including the reference doc's own canonical
example, `host!=host-03` -- failed to parse at all. Fixed in
api/internal/querylang/lexer/lexer.go with regression tests; a leading
'-' still lexes as its own token so earliest=-1h/sort -count are
unaffected.
2026-08-16 18:08:05 -07:00
jcoffey-dev 7d316f92db Phase 7: AI-assisted query authoring (autocomplete, explain, fix, optimize, NL translation)
Adds a self-hosted (Ollama, qwen2.5-coder) model provider abstraction
with a pluggable opt-in cloud adapter, schema grounding, and a shared
cost/safety guard every AI-suggested query is assessed against --
compiling to and executing through the same unchanged Phase 2 IR/
compiler and Phase 4 tenant scoping as a hand-written query, no
parallel execution path.

Track A (built into the query bar): inline ghost-text autocomplete,
"Explain this query", "Fix this query" with a diff view, and a
rule-based "Optimize" suggestion. Track B: natural-language-to-query
translation, always a separate review step from execution, with
`sentryctl query --nl` requiring explicit confirmation to run.
Every accepted/dismissed translate-fix-optimize interaction is logged
into the same append-only audit_log table Phase 4 built.

Two real product bugs were found and fixed via live browser
verification (a Svelte effect re-running on every keystroke that
silently cancelled the ghost-text debounce; a ghost-text widget
positioned at document offset 0 instead of the cursor), and a real
costguard logic bug (unbounded-aggregation vs. raw-row) was caught by
its own test suite. New integration tests wire a real Ollama client
through the real HTTP handler against a mock server matching Ollama's
wire contract (hack/mock-ollama), keeping model-quality verification
out of CI as a disclosed, periodic human-run check instead.

See /docs/phase-7-ai-design.md and /docs/phase-7-runbook.md.
2026-08-16 18:06:27 -07:00
jcoffey-dev 661568085e Phase 6: license-compliance audit and enterprise/ relicensing to AGPLv3
Full dependency inventory across Rust/Go/npm plus Docker base images
and vendored assets (776 rows, 502 unique deps), classified against
AGPLv3 compatibility with real citations rather than assumptions.
enterprise/ relicensed from its commercial-license stub to AGPLv3,
matching core -- the one real flag (Redpanda's BSL 1.1) was evaluated
against primary sources and accepted as-is rather than triggering a
broker swap. CI enforcement wired up (.github/workflows/license-
compliance.yml, this repo's first CI workflow), a root LICENSE file
added, and every doc/comment referencing the old commercial-license
boundary updated to describe it as architectural only.

See /docs/compliance/ for the full report, inventory, and policy.
2026-08-16 18:03:32 -07:00
jcoffey-dev 595d1fe0fd Document Phase 5: finalize design-system.md, add runbook, exit criteria
design-system.md was still describing a mid-Phase-5 state (charting,
dashboard panels, query/search, and alerting UI all listed as "not
built yet"); added sections for all of them plus a real Accessibility
section, and fixed color-token values that had drifted from the actual
tokens.css since the contrast fixes.

phase-5-runbook.md documents what was actually verified against a live
docker-compose stack with real seeded data, including the five real
bugs that live-verification caught -- two of them backend bugs with no
connection to the frontend redesign, only surfaced because getting
real dashboard/alert data required exercising write paths nothing had
exercised since Phase 4's tenant_id migrations landed.

CLAUDE.md gets Phase 5's exit criteria, matching every prior phase's
"what done looks like" section.
2026-08-16 12:36:31 -07:00
jcoffey-dev 9862bcccae Populate tenant_id on new alert_state/delivery_log rows
Phase 4 added a NOT NULL tenant_id column to both tables (migrations
0022/0023, backfilled via a join through alert_rules.id), but Store's
Create and ApplyTransition were never updated to populate it on new
inserts -- every existing row already had a value from the backfill,
which is exactly why this went uncaught: nothing created a *new* rule
against a Phase-4-or-later database until now. Every alert rule
creation since those migrations landed was silently broken.

Create's alert_state insert now passes rule.TenantID explicitly.
ApplyTransition only receives a rule ID, not a full Rule, so its
delivery_log insert resolves tenant_id via a subquery against
alert_rules. Confirmed against a live stack: rule creation, evaluation,
firing, and a real delivery attempt all completed end to end.
2026-08-16 12:36:21 -07:00
jcoffey-dev 8ec370dcee Redesign alerting UI: severity-colored state and a delivery timeline
AlertStatePill reuses the log-severity color tiers instead of a second
color vocabulary: ok -> quiet, pending -> warn, firing -> critical.
DeliveryTimeline reframes the existing delivery_log data (no new
backend fields) as a vertical timeline -- "why didn't I get paged" is a
chronological question a flat table answered less directly. Rules list
sorts firing-first. The list table's trailing actions column had a
bare empty <th>, which axe-core flags (empty-table-header) -- fixed
with visually-hidden text via app.css's shared .sr-only utility.
2026-08-16 12:36:10 -07:00
jcoffey-dev 0e37ca6669 Redesign query/search: syntax highlighting, autocomplete, richer results
QueryEditor.svelte wraps CodeMirror 6, not a hand-rolled
textarea-plus-overlay highlighter -- autocomplete needs real
cursor-aware popup positioning a plain textarea can't give. language.ts
is a StreamLanguage tokenizer for the pipe grammar; its token() function
must return real @lezer/highlight tag names looked up by string
('controlKeyword', 'operatorKeyword', 'name.function' for tag+modifier
pairs) -- a custom Tag.define() looks plausible but silently highlights
nothing. completions.ts is context-aware: stage keywords after `|`,
stats functions after `stats`, field names elsewhere.

A two-way-binding race between the editor's updateListener and an
external-sync $effect could drop characters on rapid/bulk input --
fixed with a lastEmitted guard so the sync effect only reacts to
genuinely external value changes, not its own echoes.

ResultsTable gets sortable columns (a real <button> in the <th>, so
sorting is keyboard-operable for free), resizable columns
(pointer-drag, deliberately mouse-only -- the resize handle stays out
of the tab order, same as most apps treat column resize), and
expandable rows. The row-expand affordance was originally a bare `<tr
onclick>` with no keyboard equivalent at all; fixed with
tabindex/role="button"/aria-expanded and an Enter/Space handler.

AddToDashboardModal lets a query built on the Search page become a
saved panel without hand-copying the query string.
2026-08-16 12:36:01 -07:00
jcoffey-dev 45e0865a0c Rebuild dashboard panels on the new chart layer
Drag-and-drop grid stays on GridStack (already a Phase 3 dependency --
no new library needed). PanelEditor.svelte (a Modal) replaces the old
inline add-panel form: a debounced live preview reuses PanelViz
directly, so the preview is pixel-identical to what renders on save
instead of drifting from a separate preview renderer. Dashboards list
and detail pages get EmptyState/Skeleton for empty/loading states
instead of a blank panel or a raw error string, and panel titles are
now clickable buttons that open the editor.
2026-08-16 12:35:49 -07:00
jcoffey-dev 5e8b3d8edd Add a real charting layer and a heatmap panel type
Five chart types on ECharts (modular imports, not the full bundle):
TimeSeriesChart (multi-series, legend toggle), BarChart (incl.
stacked), SingleStat (big number + sparkline + trend), Heatmap, TopN.
Shared interactions: tooltips, dataZoom feeding the global time-range
picker, click-to-drill-into-query (drilldown.ts strips a panel's query
to its pre-stats filter and appends the clicked series/x-value as a
new filter term -- no backend change needed).

pivot.ts reshapes the query language's existing {columns, rows} tabular
output into per-series chart data client-side -- `stats count by
service, timestamp` already returns "long" rows, so multi-series
support needed zero query-language changes. theme.ts reads real
computed CSS custom properties so charts render in the active theme's
actual colors, with an SSR_FALLBACK for adapter-static's prerender pass
where `document` doesn't exist.

heatmap is the one narrow, justified backend change: a new VizType
needed to feed a new visualization, not a new query capability. Three
places had to change together, not two -- api/dashboards/types.go's
validator, web/src/lib/api.ts's union (previous commit), and the
dashboard_panels table's viz_type CHECK constraint
(migrations/0035_add_heatmap_viz_type.sql), which mirrors the Go
validator and doesn't update itself.

/dev/charts (unlisted, dev-only) is a synthetic fixture/perf-test route:
confirmed 50ms first-two-frames render time on a production build
against a 30,006-row/6-series stress case, and a 211,975-byte gzipped
chart chunk -- both real measurements behind the ECharts-over-
Observable-Plot-or-D3 choice, not estimates.
2026-08-16 12:35:40 -07:00
jcoffey-dev f1a68455e0 Build the ui/ component library and persistent app shell
Button, Input, Select, Badge, SeverityBadge, Table, Card, Modal,
Tooltip, Tabs, Skeleton, EmptyState -- a shared library so pages stop
hand-rolling markup per page (web/src/lib/components/ui, barrel export
in index.ts). Modal and CommandPalette are built on native <dialog>
for a real focus trap, Escape-to-close, and top-layer stacking instead
of hand-rolling those. Tabs uses roving tabindex with arrow-key nav.

NavSidebar replaces the old top-nav with a persistent sidebar
(Search/Dashboards/Alerts/Data Sources/Settings), a live tenant
indicator (api.ts's new getCurrentSession(), a client for the
already-existing POST /internal/authorize -- zero new backend surface),
theme/density quick-toggles, and a command-palette hint. Collapses to
an off-canvas drawer under 860px. CommandPalette (Cmd/Ctrl+K) indexes
the five static destinations plus live-fetched dashboards/alert rules.

Data Sources is a new, honestly-scoped placeholder page (one data
source per tenant today, no UI needed yet). Settings and Select-tenant
are re-tokened onto the new component library. +layout.svelte's content
wrapper is a plain <div>, not a second <main> -- every page already
renders its own top-level <main>.
2026-08-16 12:35:25 -07:00
jcoffey-dev a6153c5f90 Build the Signal design system: tokens, fonts, theme, density
Self-hosted variable fonts (Overpass/Overpass Mono, OFL-licensed) --
no CDN dependency for the app to render correctly. Dark is the literal
default in tokens.css (:root defines it directly; light is the
override via both prefers-color-scheme and an explicit data-theme),
not a retrofit. Severity tokens collapse OTel's seven severities to
five visual tiers (severity.ts); their translucent -bg variants and
light-mode warn's base color are already tuned for WCAG AA contrast
against an opaque surface, not just the plain page background --
verified with real axe-core runs during the accessibility pass, see
docs/design-system.md.

theme.svelte.ts/density.svelte.ts persist to localStorage and expose
getter/setter functions wrapping module-level $state (Svelte 5's
shared-state-module pattern -- a directly exported $state doesn't
preserve reactivity across modules). app.html's inline script applies
both before first paint to avoid a flash of the wrong theme/density.
2026-08-16 12:35:10 -07:00
jcoffey-dev fb502f3d31 Add ECharts, CodeMirror, and axe-core for Phase 5
ECharts backs the new charting layer, the @codemirror packages back
the query editor's syntax highlighting and autocomplete, and axe-core
(dev-only) drives the automated accessibility sweep. Drops uplot, which
the chart rebuild replaces entirely.
2026-08-16 12:34:59 -07:00
jcoffey-dev 05d166cfa9 Document real kind-cluster verification, closing every runbook gap
kind/kubectl/helm were installed without root and a real local cluster
ran the full two-tenant walkthrough end to end: both acme and globex
reached Tenant.status.phase: Active with real generated ClickHouse
credentials. Updates deploy/README.md's and deploy/helm/sentry/README.md's
verification-status framing from "not verified against a live cluster"
to what's actually true now, fixes both READMEs' helm install
--include-crds (a helm template-only flag, never valid for install),
documents the two chart bugs this run found (see the previous commit)
and the mandatory ingest TLS Secret step, and updates the threat model's
summary table and "No general multi-cluster orchestration" residual-risk
note accordingly. This closes the last remaining gap in
/docs/phase-4-runbook.md.
2026-08-15 18:22:01 -07:00
jcoffey-dev 4b1b0e3b22 Fix two Helm chart bugs found running against a real kind cluster
templates/enterprise-auth.yaml never set POSTGRES_ADDR/DATABASE/
USERNAME/PASSWORD at all -- enterprise-auth silently fell back to its
localhost:5432 default and could never actually reach Postgres,
crash-looping forever. Fixed to match api.yaml's existing pattern
(Service DNS name + Secret-sourced password), plus a wait-for-postgres
initContainer for the same startup-ordering reason api.yaml has one.

templates/clickhouse.yaml was missing CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT
-- same real bug docker-compose.yml had, now fixed there too: the
official image's default user lacks CREATE USER privilege without it,
so tenantprovision's -provision-tenant could never actually provision a
tenant through this chart.

Neither of these had ever been caught before because this chart had
never been installed against a real cluster -- both surfaced and were
fixed running the full "Trying the two-tenant example" walkthrough
against a real kind cluster, ending with both tenants reaching
status.phase: Active and real generated ClickHouse credentials in their
Secrets, closing /docs/phase-4-runbook.md's last remaining gap.
2026-08-15 18:19:44 -07:00
jcoffey-dev 783d66786b Document real Auth0 SAML verification, closing §3b -- the last SSO gap
Auth0's SAML2 Web App addon (the same dev tenant §3a used) stood in as
a real SAML IdP, over a genuine self-signed TLS proxy in front of
enterprise-auth (required, not optional, for SAML's SameSite=None
cookie). Full round trip confirmed: real signed assertion, audience/
destination/signature validation, correct multi-membership handling,
and POST /internal/authorize returning the selected tenant/role.
Updates the runbook's verification status, §3b, and the threat model's
"Read this first" finding and summary table to reflect this and the
isSecureRequest fix it found. §7/§11's live-cluster steps (no
kind/kubectl in this environment) are now the only remaining gap in the
entire runbook.
2026-08-15 18:08:25 -07:00
jcoffey-dev f5ca09f686 Honor X-Forwarded-Proto for cookie Secure, not just r.TLS
Every auth cookie loginhandler.go sets (OIDC state, SAML request,
pending-login, session) decided Secure from r.TLS != nil alone --
correct only if enterprise-auth terminates TLS itself, which it never
does (it's a plain http.Server, same as every other service here). In
any real deployment, TLS is terminated at a reverse proxy/ingress in
front of it, so r.TLS is nil at this process even over a genuinely
HTTPS client connection.

Found live: SAML's request-tracking cookie is SameSite=None (required,
since the ACS POST is cross-site from the IdP's origin), which the
cookie spec requires to be paired with Secure. Behind a real
TLS-terminating nginx proxy, the cookie came back without Secure and
Chrome silently dropped it -- breaking the SAML login flow entirely, not
just weakening it. Fixed with isSecureRequest(r), which also checks
X-Forwarded-Proto: https -- not a new trust boundary, since this handler
already assumes it sits behind exactly this kind of proxy, never
directly internet-facing.
2026-08-15 18:06:51 -07:00
jcoffey-dev 17a2fda939 Document real Auth0 OIDC + tenant-picker verification, closing §3a/§12
A free Auth0 developer tenant was wired into enterprise-auth via a
local-only docker-compose.override.yml and driven through a real
browser: login correctly failed closed with no tenant membership while
still creating the users row, then succeeded after -grant-membership-*
and issued a real session. With a second real membership granted, the
multi-membership path landed on the real /select-tenant page, rendered
both real tenants with correct roles via a real credentialed
cross-origin request to the real enterprise-auth container, and
selecting either one issued a session that POST /internal/authorize
confirmed matched. Updates the runbook's top-level verification status,
§3a, and §12, plus the threat model's "Read this first" finding and
summary table to reflect what's now genuinely confirmed versus what
still needs SAML's real IdP (§3b) or a real cluster (§7/§11).
2026-08-15 17:46:35 -07:00
jcoffey-dev 0452d1f921 Fix web/Dockerfile dropping two of its three VITE_* build args
Only VITE_API_BASE_URL had a matching ARG/ENV pair; docker-compose.yml's
build args for VITE_ALERTING_API_BASE_URL and
VITE_ENTERPRISE_AUTH_BASE_URL were silently dropped by Docker (an
undeclared --build-arg is dropped, not an error). enterpriseAuthBase
came out undefined in the built bundle, so the tenant-picker page threw
"enterprise-auth is not configured" against a real running container
even though docker-compose.yml looked correct. The other two vars masked
this because web/src/lib/api.ts's apiBase/alertingBase both have
hardcoded fallbacks that happen to match the intended values.

Found while wiring a real Auth0 developer tenant into enterprise-auth to
close §3a/§12's remaining "real external IdP" gap. Also gitignores
docker-compose.override.yml, since that's where such real credentials
belong for local testing -- never committed.
2026-08-15 17:46:27 -07:00
jcoffey-dev 010f66ec70 Update Phase 4 docs: the ClickHouse-side live verification actually ran
Docker access became available and §§1-10, 10a, 13, 14, and most of §8
of the runbook have now genuinely been run against a real docker-
compose stack, not just documented as a procedure to run. Rewrites the
runbook's "Verification status" section and the threat model's "Read
this first" finding to describe what was actually confirmed (including
the six real bugs this pass found and fixed) versus what's still gated
on a real external IdP (§3a/§3b/§12) or a real Kubernetes cluster
(§7/§11's live-cluster halves). Also fixes two stale runbook commands:
§5a/§6's docker run mounts needed the repo root, not just enterprise/,
for the same replace-directive reason the Dockerfile fix does; §14 cited
a chwriter test name that never existed.
2026-08-15 17:17:35 -07:00
jcoffey-dev c5c68f22e9 Stop enterprise-api panicking on startup from a duplicate /healthz route
main.go registered GET /healthz explicitly, on top of the one
queryapi.Handler.RegisterRoutes already registers -- net/http's
ServeMux panics on a duplicate pattern, so enterprise-api could never
actually start. Every previous "built" claim for this binary had only
ever been a successful go build, never a successful process start;
caught the first time this ran against a real docker-compose stack.
2026-08-15 17:17:28 -07:00
jcoffey-dev 86afe7a005 Treat a malformed data source id as ErrNotFound, not a raw pg error
SetDataSourceClickHouseCredentials let a non-UUID id leak Postgres's
raw 22P02 (invalid_text_representation) error past the store's
ErrNotFound boundary. A malformed id can never match a row either way,
so it should be treated the same as "no such row" rather than exposing
a database-internal error past this package's boundary. Found via a
live Postgres integration test.
2026-08-15 17:17:22 -07:00
jcoffey-dev 5365c92ffa Validate panels in Store.AddPanel/UpdatePanel, not just at the handler
CreateDashboard's inline panel-creation path already called
validatePanel before insert; AddPanel and UpdatePanel relied on the
HTTP handler to validate first instead of enforcing it themselves.
Found via a live Postgres integration test: calling store.AddPanel
directly (bypassing the handler) hit a viz_config NOT NULL constraint
violation instead of getting the same default-empty-JSON treatment
every other panel-creation path gets.
2026-08-15 17:17:16 -07:00
jcoffey-dev e15a63408a Revoke tenant users' system.* access explicitly, don't assume default-deny
Verified live against clickhouse/clickhouse-server:24.8: a freshly
created tenant user was NOT default-denied from system.* the way the
original design assumed -- system.tables listed every tenant's
database/table names to any authenticated user regardless of grants.
Fixed with an explicit REVOKE SELECT ON system.* FROM <user> after the
existing GRANT SELECT, INSERT.

Verifying this live also surfaced a real ClickHouse behavioral split
the design didn't anticipate: system.query_log is genuinely access-
checked (the REVOKE makes it hard-deny, ACCESS_DENIED), but
system.tables is a filtered catalog view that ClickHouse 24.8 never
denies outright -- it just returns zero rows for a properly-revoked
user. Both outcomes close the actual leak. Corrected
TestProvisionedUserCannotReadSystemTables to assert what each table
actually does (hard error for query_log, verified-empty-and-no-foreign-
database-names for tables) instead of demanding a hard error from both.
2026-08-15 17:17:11 -07:00
jcoffey-dev d9e4f22200 Fix enterprise-auth's Docker build context and ClickHouse admin access
enterprise/Dockerfile built from enterprise/ alone, too narrow for
enterprise/go.mod's replace ../api directive once enterprise-auth
started importing api/httpserver and (transitively) api/dashboards --
confirmed broken the first time this was built with real Docker access.
Fixed to build from the repo root, matching enterprise-api/
enterprise-ingest's Dockerfiles.

Also: ClickHouse's default admin user genuinely lacked CREATE USER
privilege in docker-compose.yml -- the official image needs
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 (not the more obvious-looking
CLICKHOUSE_ACCESS_MANAGEMENT, confirmed by reading the image's own
/entrypoint.sh), which this file never set. Every tenant-provisioning
code path was correct Go that had simply never been able to
authenticate its own admin connection strongly enough to run.
2026-08-15 17:17:03 -07:00
jcoffey-dev 5a898cb43e Phase 4 verification pass: re-run every Docker-free check, fix drift
Docker access is still unavailable in this environment (permission
denied on the socket, no docker group membership, no passwordless
sudo -- confirmed again), so this can't be the live-infrastructure
verification pass Phase 4 actually needs. What it can be: re-running
every command this runbook claims is Docker-free and fixing what's
drifted since it was written across several commits.

Found and fixed by actually executing each command, not just reading
the prose:

- go test ./internal/chwriter/... -run TestRegistry -v (§14) doesn't
  match what the surrounding paragraph claims it verifies --
  TestRegistry as a regex matches TestRegistryWritesEachTenantToItsOwnDatabase/
  TestRegistryRefusesUnprovisionedTenant (the live-ClickHouse tests,
  which just skip), not TestWriteBatchRefusesEmptyTenantID/
  TestWriteBatchRefusesUnknownTenantWithEmptyRegistry (the actual
  Docker-free fail-closed tests the paragraph describes). Fixed the
  filter and left a note explaining the mismatch, since it's the kind
  of thing worth knowing was caught by running the command, not just
  proofreading it.
- §9's cargo test still said "expect 14 tests passing" -- stale since
  the Tantivy write-routing and active-tenant-gate passes added 10 more
  (now 24, verified by actually running it). Added a pointer to the new
  registry.rs/tenants.rs tests those two passes added.
- §3a's "Not yet built: an equivalent for revoking/listing memberships"
  was stale -- -revoke-membership-*/-list-memberships-tenant/
  -transfer-owner-* all exist now. Narrowed the still-accurate part
  (dashboard_permissions grants have no operator flag) and pointed at
  sentryctl dashboards permissions instead, which does cover it.
- The "Two genuine exceptions" intro undercounted its own list, which
  had grown to five items across later edits without the header being
  updated to match.

Every other Docker-free command in this runbook (§3a/§3b's login
tests, §9's searchclient/chrunner mid-provisioning probes, §11's
tenantcrd tests, §12's session/loginhandler tests, §13's ingest
identity tests, §14's active-tenant-gate tests, the cli dashboard-
permissions tests, docker compose config's profile/port/alias
assertions) was re-run and matches what the doc already claimed -- no
further drift found. Full cross-module build/vet/test sweep (all Go
modules including terraform/, search's cargo build/clippy,
hack/check-tenant-boundary.sh) also re-confirmed clean.
2026-08-15 16:29:05 -07:00
jcoffey-dev 278b24cf67 Add sentry_dashboard_panel, resolving panels as their own resource
The open design question named in the last three commits' README --
"a sentry_dashboard_panel resource (or a panels list block on this one)"
-- is resolved: separate resource, matching api/dashboards.Handler's own
shape (a panel is created/updated/deleted independently of its parent
dashboard via its own endpoints, never by rewriting the dashboard's
whole panel list). A nested list block would have forced every panel to
be rewritten on any single panel's change, hiding fine-grained diffs a
separate resource shows naturally -- the more idiomatic Terraform
pattern for independently-lifecycled child resources, and the one that
matches what the API actually does.

Unlike sentry_alert_rule/sentry_notification_target, this resource
supports a real in-place Update -- api/dashboards.Handler actually has a
PUT /dashboards/{id}/panels/{panelId}. Only dashboard_id forces
RequiresReplace: UpdatePanel's SQL matches WHERE id = $panelID AND
dashboard_id = $dashboardID, so changing dashboard_id through the
existing panel's URL wouldn't move it, it would just fail to match --
there's no API operation for "move a panel to a different dashboard."

Panels have no standalone GET endpoint -- only GET /dashboards/{id},
which includes the full panels array. client.go's new getPanel fetches
the parent dashboard and finds the panel by ID within it, returning the
same *apiError{StatusCode: 404} shape a direct GET would whether the
dashboard itself or just the panel within it is gone, so isNotFound
works identically either way. This also means a bare panel ID isn't
enough to import from -- ImportState takes "dashboard_id/panel_id" and
splits on the last "/", the one resource here with a composite import
identifier.

query_language never accepts "sql" for panels specifically -- confirmed
in api/dashboards's own validatePanel ("dashboards only support
pipe-syntax queries, since the dashboard time-range picker is injected
as leading query terms"), a real constraint from the API this client
doesn't re-validate client-side (same "let the API be the one source of
truth for validation" posture the other resources already take), but
documented in the schema so it's not a surprise 400 from Create.

sentry_dashboard_panel gets a matching data source too
(dashboard_id + id both Required, unlike the other three data sources'
single Required id, since getPanel itself needs both).

Verified: client tests are real httptest.Server round trips, including
getPanel finding the right panel within a real dashboard response and
returning a recognizable not-found both when the panel is missing and
when the parent dashboard itself is gone. Schema validation needs no
Terraform binary. TestAccDashboardPanelResource_basic and
TestAccDashboardPanelDataSource_basic are real acceptance tests,
skip-gated by TF_ACC same as the other six -- the resource test proves a
genuine in-place update (a title change, no plancheck needed since
in-place update is the default expectation here, unlike the
create/destroy-only resources). Not run against a live stack in this
environment, same disclosed gap as everything else Docker-gated in this
repo.
2026-08-15 10:40:27 -07:00
jcoffey-dev eb38611aa8 Add read-only data sources for all three Terraform resources
Mechanical, low-risk follow-up -- no new architectural question, no new
external service, no new write path. Each of sentry_dashboard,
sentry_alert_rule, and sentry_notification_target gets a matching data
source: a single Required id attribute in, every other attribute
Computed out, backed by the exact same getDashboard/getRule/
getNotificationTarget client methods and dashboardModelFromAPI/
alertRuleModelFromAPI/notificationTargetModelFromAPI conversion
functions the resources already use and already have tests for -- these
data sources add no new client code at all, just a thin
datasource.DataSource wrapper reusing what Create/Read/Update/Delete
already exercise.

sentry_notification_target's data source carries the same secret
caveat its resource does (Sensitive, but alerting's GET /targets/{id}
returns it unredacted, so it's a real plaintext value in Terraform
state) -- named again here rather than assumed obvious from the
resource's own docs.

Verified: provider_test.go's new schema-validation tests confirm each
data source's id is Required and everything else Computed, no
Terraform binary needed. Three new real acceptance tests
(TestAccDashboardDataSource_basic and its two siblings) each create a
resource then look it up via the matching data source, using
resource.TestCheckResourceAttrPair to prove the data source's Read
actually agrees with what the resource wrote -- not just that both
compile. Skip-gated by TF_ACC same as the existing six acceptance
tests, and needs the same live api/alerting services this environment
has no Docker access to bring up, so not run here -- same disclosed gap
as everything else Docker-gated in this repo.
2026-08-15 10:28:08 -07:00
jcoffey-dev 30ae84cd04 Add sentry_notification_target, closing the alert-rule-as-code loop
sentry_alert_rule.notification_target_id could previously only point at
a target created outside Terraform (sentryctl/curl/the web UI) --
without this resource, "manage alert rules as code" was only half true.
Same create/destroy-only shape as sentry_alert_rule and for the same
reason: alerting has no PUT /targets/{id} either, confirmed down to
notifystore.Store (Create/List/Get/Delete, no Update).

client.go's notificationTarget type mirrors notifystore.Target's JSON
shape. headers stays raw JSON bytes end to end -- the client has no
opinion about its shape (neither does alerting's own Target type,
json.RawMessage), and the resource layer round-trips it as a plain
JSON-text string a caller provides via Terraform's jsonencode().

secret is marked Sensitive in the schema, but alerting's own
GET /targets/{id} returns it unredacted (confirmed in
notifystore/store.go -- no redaction at the store or handler layer, an
existing property of alerting's API, not something this provider
introduces). A new client test
(TestGetNotificationTargetReturnsSecretUnredacted) documents that real
behavior so a future change to it would be caught here, not discovered
by surprise. Sensitive keeps the value out of plan/apply console output;
it does not keep it out of Terraform state, the standard caveat for any
sensitive attribute, named explicitly in the schema description and
README rather than left implicit.

Examples updated end to end: sentry_alert_rule's example now creates a
real sentry_notification_target and references its .id, instead of a
placeholder string.

Verified: client tests are real httptest.Server round trips. Schema
validation needs no Terraform binary.
TestAccNotificationTargetResource_basic is a real acceptance test,
skip-gated by TF_ACC same as the other two, including a
plancheck.ExpectResourceAction assertion that a config change actually
plans destroy-then-create, and (since secret really does round-trip
unredacted) a real ImportStateVerify on the secret attribute rather than
one papered over with ImportStateVerifyIgnore. Not run against a live
stack in this environment, same disclosed gap as everything else
Docker-gated in this repo.
2026-08-15 10:20:52 -07:00
jcoffey-dev b98f397221 Add sentry_alert_rule, the Terraform provider's second resource
Confirmed with the project owner first: alerting's REST API has no
PUT /rules/{id} at all -- confirmed down to rulestore.Store, which has
Create/List/Get/Delete but no Update method to even wire one to, a real
pre-existing gap in alerting's own API, not something new to this task.
Decided to model sentry_alert_rule as create/destroy only rather than
fake an in-place update via delete-then-recreate inside the resource:
every attribute carries a RequiresReplace plan modifier, so a config
change destroys and recreates the rule, surfacing in the plan output the
real side effect that has (alert_state/delivery-log continuity resets)
instead of hiding it. Adding a real PUT /rules/{id} to alerting would
remove this constraint but is a change to a different module's REST
API, out of scope here.

internal/provider/client.go's new rule type and createRule/getRule/
deleteRule methods talk the exact same JSON contract
sentryctl alerts apply already uses against alerting/internal/httpapi.
GET /rules/{id} actually returns rulestore.RuleWithState (Rule's fields
promoted via anonymous embedding, plus a "state" object) -- the local
rule type has no field for "state" by design, and a new client test
proves that extra key doesn't break parsing.

alerting is a genuinely separate service from api (its own base URL),
so this needed the provider to talk to more than one Sentry service for
the first time: providerData now wraps two *client instances (api,
alerting), with a new alerting_endpoint provider attribute defaulting
the same way sentryctl's --alerting-api/$SENTRYCTL_ALERTING_API_URL
does. dashboardResource's Configure updated to pull .api out of the new
wrapper type instead of a bare *client.

Schema mirrors sentry_dashboard's established pattern: comparator/
threshold_value/renotify_interval_minutes stay nullable (only meaningful
for threshold-condition rules), enabled/for_minutes/query_language are
Optional+Computed with a Terraform-side default matching the API's own
default (true/0/"") rather than leaving the API as sole source of truth
the way dashboard's default_earliest/default_latest deliberately do --
these three have no *pointer* type in the API's Rule struct, so their
"default when omitted" is unconditional, not a real API-side default
that could drift independently.

Verified: client tests are real httptest.Server round trips (same
pattern as sentry_dashboard's). Schema validation needs no Terraform
binary. TestAccAlertRuleResource_basic is a real acceptance test,
skip-gated by TF_ACC same as the dashboard one, including a
plancheck.ExpectResourceAction assertion that a config change actually
plans destroy-then-create -- the concrete, checked version of the
"create/destroy only" design decision, not just a comment. Not run
against a live stack in this environment, same disclosed gap as
everything else Docker-gated in this repo.
2026-08-15 00:15:45 -07:00
jcoffey-dev 49dd050689 Start the Terraform provider: sentry_dashboard, the first resource
CLAUDE.md names the Terraform provider a first-class deliverable
alongside sentryctl ("CLI and Terraform provider are first-class, not
afterthoughts"), but no phase before this one had actually built any of
it -- no terraform/ directory existed. This is a first slice, not a
finished provider: one resource, scoped and confirmed with the project
owner before starting (a new pinned external dependency and an
architectural decision not covered in /docs/architecture.md are both
things CLAUDE.md's own "When in doubt" section says to ask about).

New Go module (terraform/, github.com/sentry/sentry/terraform) built on
HashiCorp's terraform-plugin-framework -- the actively-developed
library, not the legacy SDKv2, since there's no existing provider code
to migrate and no reason to start new on the framework HashiCorp itself
steers people away from.

internal/provider/client.go talks the exact same JSON contract
sentryctl's "dashboards apply" and web's Export JSON button already use
against api/dashboards.Handler (POST/GET/PUT/DELETE /dashboards[/{id}]) --
cli/README.md already named this "the seed of a future Terraform
provider: one JSON contract, multiple callers," this is that third
caller, not a new contract invented for Terraform's sake.

sentry_dashboard's schema deliberately leaves default_earliest/
default_latest Optional+Computed with no Terraform-side static default,
even though the API defaults them to "-1h"/"now" when empty -- letting
the API stay the one source of truth for what "unset" means rather than
duplicating that default in two places that could drift. tenant_id is
Computed-only, matching api/dashboards.Handler's own tenantID() doc
comment that a client-supplied value is always overridden server-side.

Panels are not modeled by this resource -- a genuinely separate resource
shape (own lifecycle, own endpoints, own validation needs), scoped out
deliberately, not an oversight. Alert rules, notification targets, and
tenant/RBAC resources are the same: real, disclosed future work, not
attempted in this pass. See terraform/README.md for the full accounting.

Verified: client_test.go runs real HTTP round trips against httptest.
Server (request construction, response parsing, the 404-vs-other-error
distinction Read/Delete need for Terraform's out-of-band-deletion
convention) -- same pattern cli/cmd/sentryctl's own tests already use
against the same api/dashboards endpoints. provider_test.go validates
both schemas are internally well-formed without needing a Terraform
binary. dashboard_resource_test.go's TestAccDashboardResource_basic is a
real acceptance test (terraform-plugin-testing), skip-gated by TF_ACC=1
per that framework's own convention -- even with TF_ACC set it would
still need a live api service (Postgres+ClickHouse) to apply against,
which this environment has no Docker access to bring up, so it has not
actually run here, same disclosed gap as every other live-infra test in
this repo.
2026-08-15 00:06:26 -07:00
jcoffey-dev 2e8ab1ed6a Give chwriter.Registry periodic refresh, matching Tantivy's tracker
Closing search's active-tenant gap last commit surfaced a real asymmetry
by comparison: chwriter.Registry's per-tenant writer map was still a
snapshot built once at enterprise-ingest startup with no refresh at all,
while search's new ActiveTenantTracker refreshes every minute. A tenant
deprovisioned after enterprise-ingest started would keep writing
successfully to ClickHouse until the next restart -- a real, disclosed
staleness gap, not matched by anything on the Tantivy side anymore.

Registry.StartRefreshing spawns a goroutine that re-lists active tenants
every minute (dataSourceRefreshInterval, same interval as search's
tracker) via a new SourceLister callback and reconciles the writer map:
opens a connection for a newly-active tenant, closes and removes one no
longer active. New connections are dialed before taking the write lock,
so a slow/unreachable ClickHouse for one newly-active tenant never
blocks WriteBatch's read lock. A refresh failure (lister error, or one
tenant's connection failing to open) logs and leaves the existing map
untouched for that tick -- the same last-known-good posture
ActiveTenantTracker already uses, so a transient rbacstore/Postgres blip
doesn't evict every other tenant's already-working writer.

WriteBatch now takes a read lock and Close takes a write lock -- the
writer map was safe unsynchronized before only because it was immutable
after New() returned; StartRefreshing makes it mutable at runtime.

enterprise-ingest/main.go extracts the existing rbacstore-row-to-
DataSource adaptation into tenantDataSourceLister, reused for both the
initial synchronous load and StartRefreshing's periodic calls, so the
two can't drift into checking different things.

Verified: the lister-error-keeps-last-known-good path is Docker-free
(same "construct a Registry directly, bypass New" trick the existing
fail-closed tests use). The actual add/remove reconciliation against
real ClickHouse connections (TestRefreshAddsNewlyActiveTenant,
TestRefreshRemovesNoLongerActiveTenant) are skip-gated live-ClickHouse
tests, same CHWRITER_TEST_CLICKHOUSE_ADDR convention as this package's
existing integration tests -- not run against a live database in this
environment.

This closes the last disclosed gap from Phase 4's write-routing work:
both storage engines now share the same one-minute active-tenant
staleness bound instead of one being materially staler than the other.
2026-08-14 23:55:40 -07:00
jcoffey-dev 088677643f Close search's active-tenant write-routing gap with a polled allowlist
search/src/consumer.rs's write-routing (built last pass) had no active-
tenant check at all: IndexRegistry.resolve() would open-or-create an
index directory for any syntactically-valid tenant_id, active or not --
unlike ClickHouse's chwriter.Registry (an active-tenants-only snapshot
built at enterprise-ingest startup) or the read side (gated by
searchclient.TenantChecker, a direct rbacstore query). search is AGPL
core with no Postgres access and no enterprise/ import allowed, so it
needed a network boundary instead -- the same shape ingest's
TenantResolver already uses against enterprise-auth, just Rust calling
Go instead of Go calling Go.

New GET /internal/active-tenants endpoint on enterprise-auth
(rbacstore.ListActiveTenantIDs + authhandler.handleActiveTenants),
gated on a RoleService Bearer credential -- server-to-server auth, the
same shape alerting presents to api, minted via the already-generic
enterprise-auth -mint-service-token search. search/src/tenants.rs's
ActiveTenantTracker polls it every 60s, blocking startup on the first
fetch succeeding (fail-closed cold start -- a control-plane outage at
boot must not silently accept every tenant_id) and keeping the last-
known-good set on any later refresh failure (a transient blip shouldn't
stop every tenant's indexing, only prevent the allowlist from growing/
shrinking until connectivity resumes). consumer.rs refuses any tagged
record whose tenant isn't in the polled set, before ever calling
resolve() -- IndexRegistry itself stays policy-free, matching the same
mechanism/policy split clickhousewriter.Writer vs. chwriter.Registry
already draws on the ClickHouse side.

Off unless ENTERPRISE_AUTH_URL/ENTERPRISE_AUTH_SERVICE_TOKEN are both
set (search/src/config.rs rejects exactly one being set) -- every
existing deployment is unaffected.

Verified with real HTTP round trips in this environment: tenants.rs's
tests exercise real reqwest requests (actual Authorization: Bearer
header, actual JSON parsing) against a hand-rolled dependency-free TCP
test server, including both fail-closed paths (rejected first fetch,
unreachable server). authhandler's new tests cover the credential-kind
distinction this endpoint exists to enforce -- a real human session,
even for a genuine Owner, must not satisfy a check meant for a service
identity.

One asymmetry remains, disclosed rather than fixed: chwriter.Registry's
snapshot still never refreshes (stale until enterprise-ingest restarts),
while ActiveTenantTracker's 60s poll gives Tantivy a materially tighter
staleness window. Neither is a live per-write check -- that would mean
a database/HTTP round trip per record, a throughput cost neither
implementation accepts -- so both have some staleness window by design;
the gap between the two windows is what's disclosed, not a claim either
is fully live.
2026-08-14 23:47:25 -07:00
jcoffey-dev 5a845f06ee Add rbacstore.TransferOwner and enterprise-auth -transfer-owner-*
RevokeMembership refuses to revoke a tenant's current Owner (would leave
tenants.owner_user_id dangling), but there was no way to actually hand
ownership to someone else -- only the raw SetOwner primitive, which
-grant-membership-role=owner used without downgrading whoever held
Owner before, leaving tenant_memberships claiming two owners while
owner_user_id can only name one. Named as a real, disclosed gap in
docs/phase-4-runbook.md's "Known gaps".

rbacstore.TransferOwner closes it: downgrades the current owner's
membership to admin, promotes the new owner, and updates
tenants.owner_user_id, all in one pgx transaction -- this package's
first use of one. Every other mutation here is a single independent
statement because nothing else needs more than one row to agree; this
does, for the same reason RevokeMembership's doc comment already gives
for refusing to revoke Owner in the first place.

-grant-membership-role=owner now refuses when a *different* owner
already exists, pointing at the new -transfer-owner-tenant/
-transfer-owner-user-email flags instead of silently producing the
inconsistent state -- it remains correct, unchanged, for a tenant's
first owner assignment.

Verified with the same skip-gated live-Postgres discipline as the rest
of this package (TestTransferOwnerMovesOwnershipAndDowngradesPrevious
Owner proves the downgrade is real by then successfully revoking the
former owner's now-non-Owner membership; two refusal-path tests cover
no-current-owner and transfer-to-self) -- not run against a live
database in this environment, same disclosed gap as everything else
here.
2026-08-14 23:30:05 -07:00
jcoffey-dev 3cf1320881 Add sentryctl dashboards permissions list|grant|revoke
PUT/DELETE /dashboards/{id}/permissions/{userId} (per-resource dashboard
grants, built earlier this phase) had no caller but Go tests and curl --
named as a real, disclosed gap in docs/phase-4-runbook.md. Adds a CLI
surface: sentryctl dashboards permissions list/grant/revoke, following
the existing dashboards subcommand pattern.

grant/revoke needed a new httpclient.go helper (httpMutateNoBody) since
both endpoints respond 204 No Content -- the existing helpers all expect
a JSON body to pretty-print. grant validates the role client-side
(viewer/editor only, mirroring api/dashboards.validGrantRole) before
making a request, since Admin/Owner already have tenant-wide dashboard
access and a resource-level grant can never raise someone past Editor.

Verified with real httptest.Server round trips (method, path, request
body, and error-body parsing on a 501 from a deployment with no
enterprise permission service wired in) -- the same pattern every other
sentryctl subcommand's tests already use, no fake/mock client needed
since sentryctl itself is just an HTTP client with no store of its own.
2026-08-14 23:09:33 -07:00