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.
This commit is contained in:
2026-08-18 23:53:20 -07:00
parent d2bb9de245
commit 4b5dae5879
87 changed files with 5095 additions and 164 deletions
+115
View File
@@ -4,8 +4,11 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"path"
"strings"
"github.com/sentry/sentry/api/authz"
)
@@ -128,6 +131,30 @@ func (h *Handler) handleSetConfig(w http.ResponseWriter, r *http.Request) {
return
}
// extra_file_paths is a materially different capability than the
// rest of this override: every other field tunes an already-running
// source, but this one tells the agent (which runs as root, with no
// filesystem sandboxing today -- see the security audit) to read and
// ship an arbitrary local file. RoleEditor is the right bar for
// "adjust batch size," not for "grant read access to any file on the
// host" -- so a request that actually *changes* the set of extra
// paths (adds or edits one -- shrinking or clearing never needs
// this, since that only removes capability) requires RoleAdmin,
// checked here rather than by splitting /agents/{host}/config into
// two routes with two RegisterRoutes role floors, which would break
// the "PUT replaces the whole override" contract every field here
// otherwise shares.
// A nil authorizer means no RBAC is configured at all (Phase 0-3
// default-open behavior) -- consistent with RequireRole's own
// no-op-when-nil posture, this extra gate only applies once an
// authorizer resolves a real Identity to check.
if identity, ok := authz.IdentityFromContext(r.Context()); ok && !identity.Role.Satisfies(authz.RoleAdmin) {
if changesExtraFilePaths(h.currentExtraFilePaths(r.Context(), h.tenantID(r), r.PathValue("host")), override.ExtraFilePaths) {
writeError(w, http.StatusForbidden, "extra_file_paths requires the admin role")
return
}
}
a, err := h.store.SetOverride(r.Context(), h.tenantID(r), r.PathValue("host"), override, h.updatedBy(r))
if err != nil {
h.writeStoreErr(w, err, "setting agent config")
@@ -202,9 +229,97 @@ func validateOverride(o ConfigOverride) error {
if o.HeartbeatIntervalMS != nil && *o.HeartbeatIntervalMS < 5000 {
return errors.New("heartbeat_interval_ms must be at least 5000 (5s)")
}
if len(o.ExtraFilePaths) > 20 {
return errors.New("extra_file_paths: at most 20 paths")
}
for _, p := range o.ExtraFilePaths {
if err := validateExtraFilePath(p); err != nil {
return err
}
}
return nil
}
// extraFilePathDenylistPrefixes blocks whole directory trees that are
// never legitimate log-file locations but very commonly hold sensitive
// material an agent (which runs as root, unsandboxed, on every host
// this deployment has been checked against -- see the security audit)
// can otherwise read: OS credential/config storage, home directories,
// and kernel/process pseudo-filesystems.
var extraFilePathDenylistPrefixes = []string{"/etc/", "/root/", "/home/", "/proc/", "/sys/", "/boot/"}
// extraFilePathDenylistSubstrings catches credential material that can
// live outside the directories above too (e.g. a service account's
// SSH/cloud-credential directory under an app's own working directory,
// not necessarily /home or /root).
var extraFilePathDenylistSubstrings = []string{"/.ssh/", "/.gnupg/", "/.aws/", "/.kube/"}
// extraFilePathDenylistSuffixes catches specific high-value filenames by
// name, regardless of directory -- named here because the audit that
// motivated this check demonstrated /etc/shadow and an SSH private key
// specifically, and this covers both even outside the prefix-denylisted
// directories above (e.g. a private key accidentally copied to /opt).
var extraFilePathDenylistSuffixes = []string{"-key.pem", "id_rsa", "id_ecdsa", "id_ed25519", "id_dsa", "/shadow", "/gshadow"}
func validateExtraFilePath(p string) error {
if p == "" || !strings.HasPrefix(p, "/") {
return errors.New("extra_file_paths: each path must be a non-empty absolute path")
}
if strings.Contains(p, "..") {
return errors.New(`extra_file_paths: path must not contain ".."`)
}
if cleaned := path.Clean(p); cleaned != p {
return fmt.Errorf("extra_file_paths: %q must be in canonical form (e.g. %q)", p, cleaned)
}
for _, prefix := range extraFilePathDenylistPrefixes {
if p == strings.TrimSuffix(prefix, "/") || strings.HasPrefix(p, prefix) {
return fmt.Errorf("extra_file_paths: %q is not an allowed path (under denylisted %s)", p, prefix)
}
}
for _, substr := range extraFilePathDenylistSubstrings {
if strings.Contains(p, substr) {
return fmt.Errorf("extra_file_paths: %q is not an allowed path", p)
}
}
for _, suffix := range extraFilePathDenylistSuffixes {
if strings.HasSuffix(p, suffix) {
return fmt.Errorf("extra_file_paths: %q is not an allowed path", p)
}
}
return nil
}
// currentExtraFilePaths reads back the agent's already-stored override
// (empty/nil if the agent or override doesn't exist yet) so
// handleSetConfig can tell an addition/change apart from a pure
// shrink-or-clear -- see changesExtraFilePaths.
func (h *Handler) currentExtraFilePaths(ctx context.Context, tenantID, host string) []string {
a, err := h.store.Get(ctx, tenantID, host)
if err != nil || a.DesiredOverride == nil {
return nil
}
return a.DesiredOverride.ExtraFilePaths
}
// changesExtraFilePaths reports whether desired introduces any path not
// already present in current -- an addition or an edit, either of which
// grants the agent read access to something it couldn't read before.
// Removing paths (desired is a subset of current) is never a capability
// grant, so that alone never requires the stricter role handleSetConfig
// applies around this.
func changesExtraFilePaths(current, desired []string) bool {
existing := make(map[string]struct{}, len(current))
for _, p := range current {
existing[p] = struct{}{}
}
for _, p := range desired {
if _, ok := existing[p]; !ok {
return true
}
}
return false
}
func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "agent not found")
+110
View File
@@ -199,6 +199,116 @@ func TestHandleSetConfigRejectsTooSmallHeartbeatInterval(t *testing.T) {
}
}
func TestHandleSetConfigExtraFilePathsRoundTrips(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
h := newTestHandler(s)
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"/var/log/nginx/access.log", "/var/log/nginx/error.log"},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got Agent
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding response: %v", err)
}
if got.DesiredOverride == nil || len(got.DesiredOverride.ExtraFilePaths) != 2 {
t.Fatalf("unexpected override: %+v", got.DesiredOverride)
}
}
func TestHandleSetConfigRejectsRelativeExtraFilePath(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
h := newTestHandler(s)
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"relative/path.log"},
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestHandleSetConfigRejectsTooManyExtraFilePaths(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
h := newTestHandler(s)
paths := make([]string, 21)
for i := range paths {
paths[i] = "/var/log/x.log"
}
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{ExtraFilePaths: paths})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
// TestHandleSetConfigDenylistsSensitivePaths is the regression test for
// the security-audit finding that a root, unsandboxed agent plus an
// unrestricted extra_file_paths let any Editor read arbitrary files
// (e.g. /etc/shadow, SSH keys) and have them shipped into ClickHouse.
func TestHandleSetConfigDenylistsSensitivePaths(t *testing.T) {
denied := []string{
"/etc/shadow",
"/etc/passwd",
"/root/.bash_history",
"/home/alice/.ssh/id_rsa",
"/home/alice/.ssh/authorized_keys",
"/proc/1/environ",
"/etc/sentry-agent/client-key.pem",
"/opt/app/../../etc/shadow",
"/opt/app/id_ed25519",
}
for _, p := range denied {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
h := newTestHandler(s)
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{ExtraFilePaths: []string{p}})
if rec.Code != http.StatusBadRequest {
t.Errorf("path %q: status = %d, want 400 (should be denylisted), body=%s", p, rec.Code, rec.Body.String())
}
}
}
// TestHandleSetConfigExtraFilePathsRequiresAdminToAdd is the regression
// test for the audit's role-floor fix: adding/changing extra_file_paths
// needs Admin, not just Editor, since it grants the agent read access to
// a new file. Purely shrinking or clearing an existing set stays at the
// Editor floor everything else in this override uses.
func TestHandleSetConfigExtraFilePathsRequiresAdminToAdd(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
editor := NewHandler(discardLogger(), s, fakeAuthorizer{role: authz.RoleEditor}, nil)
admin := NewHandler(discardLogger(), s, fakeAuthorizer{role: authz.RoleAdmin}, nil)
rec := doRequest(t, editor, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"/var/log/nginx/access.log"},
})
if rec.Code != http.StatusForbidden {
t.Fatalf("editor adding a path: status = %d, want 403", rec.Code)
}
rec = doRequest(t, admin, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"/var/log/nginx/access.log", "/var/log/nginx/error.log"},
})
if rec.Code != http.StatusOK {
t.Fatalf("admin adding paths: status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
// Shrinking back down to one path is a pure removal -- Editor should
// be allowed to do this even though they couldn't have added it.
rec = doRequest(t, editor, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"/var/log/nginx/access.log"},
})
if rec.Code != http.StatusOK {
t.Fatalf("editor removing a path: status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleSetConfigUnknownHostIsNotFound(t *testing.T) {
h := newTestHandler(newFakeStore())
interval := int64(30000)
+6 -5
View File
@@ -35,11 +35,12 @@ func validCommand(c string) bool {
// grpcserver.TenantIDHeaderKey, enterprise/internal/apiconfig.AIConfig).
// Keep the three in sync by hand.
type ConfigOverride struct {
BatchMaxSize *int64 `json:"batch_max_size,omitempty"`
BatchFlushIntervalMS *int64 `json:"batch_flush_interval_ms,omitempty"`
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
HeartbeatIntervalMS *int64 `json:"heartbeat_interval_ms,omitempty"`
JournaldUnit *string `json:"journald_unit,omitempty"`
BatchMaxSize *int64 `json:"batch_max_size,omitempty"`
BatchFlushIntervalMS *int64 `json:"batch_flush_interval_ms,omitempty"`
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
HeartbeatIntervalMS *int64 `json:"heartbeat_interval_ms,omitempty"`
JournaldUnit *string `json:"journald_unit,omitempty"`
ExtraFilePaths []string `json:"extra_file_paths,omitempty"`
}
type Agent struct {
+96 -4
View File
@@ -7,7 +7,11 @@ package main
import (
"context"
"crypto/rand"
"encoding/base64"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"os"
@@ -28,6 +32,7 @@ import (
"github.com/sentry/sentry/api/dashboards"
"github.com/sentry/sentry/api/httpserver"
"github.com/sentry/sentry/api/internal/config"
"github.com/sentry/sentry/api/localauth"
"github.com/sentry/sentry/api/queryapi"
"github.com/sentry/sentry/api/querylang/executor"
"github.com/sentry/sentry/api/searchclient"
@@ -48,6 +53,9 @@ func main() {
logger.Error("loading config", "error", err)
os.Exit(1)
}
for _, w := range cfg.DevCredentialWarnings() {
logger.Warn(w)
}
// -healthcheck: a self-check mode for Docker's HEALTHCHECK, not a
// flag anyone runs by hand. The api image is distroless (no shell,
@@ -59,6 +67,13 @@ func main() {
os.Exit(runHealthcheck(cfg.HTTPListenAddr))
}
// -seed-admin: a one-shot action, not part of the normal server
// startup path -- mirrors enterprise-api's -provision-tenant shape
// (declare, flag.Parse(), short-circuit before the rest of main's
// dependencies matter to it). See runSeedAdmin's doc comment.
seedAdmin := flag.Bool("seed-admin", false, "create the default local-auth admin user with a random password if none exists, print it once, and exit")
flag.Parse()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -101,12 +116,25 @@ func main() {
os.Exit(1)
}
if *seedAdmin {
os.Exit(runSeedAdmin(ctx, logger, os.Stdout, localauth.NewStore(pgPool)))
}
// authorizer is nil (RequireRole* becomes a no-op) unless
// ENTERPRISE_AUTH_URL is configured -- matches Phase 0-3 behavior
// for a single-tenant deployment with no enterprise/ deployed.
// ENTERPRISE_AUTH_URL or LOCAL_AUTH_ENABLED is configured -- matches
// Phase 0-3 behavior for a single-tenant deployment with neither
// enterprise/ nor local login turned on. EnterpriseAuthURL wins if
// both were somehow set -- a deployment with real SSO configured has
// no use for a second, local auth mechanism (see LocalAuthConfig's
// doc comment).
var authorizer authz.Authorizer
if cfg.EnterpriseAuthURL != "" {
var localAuthStore *localauth.Store
switch {
case cfg.EnterpriseAuthURL != "":
authorizer = authz.NewHTTPAuthorizer(cfg.EnterpriseAuthURL)
case cfg.LocalAuth.Enabled:
localAuthStore = localauth.NewStore(pgPool)
authorizer = localauth.NewAuthorizer(localAuthStore)
}
sqlRunner := executor.NewChRunner(conn)
@@ -139,6 +167,18 @@ func main() {
dashboardsHandler.RegisterRoutes(mux)
agentsHandler.RegisterRoutes(mux)
// Only registered when local auth is actually enabled -- see
// localauth.Handler.RegisterRoutes' doc comment for why a disabled
// deployment gets a plain 404 on /auth/* rather than a dedicated
// "feature off" response.
if localAuthStore != nil {
localauthHandler := localauth.NewHandler(logger, localAuthStore, authorizer, cfg.LocalAuth.SessionTTL, localauth.CookieConfig{
Domain: cfg.LocalAuth.CookieDomain,
Secure: cfg.LocalAuth.CookieSecure,
})
localauthHandler.RegisterRoutes(mux)
}
// AI routes (Phase 7) are only registered at all when OLLAMA_BASE_URL
// is set -- an unconfigured deployment gets a plain 404 on /ai/*
// rather than every request failing against an unreachable
@@ -165,9 +205,19 @@ func main() {
logger.Info("ai routes enabled", "ollama_base_url", cfg.AI.OllamaBaseURL, "model", cfg.AI.OllamaModel)
}
// Once an authorizer is live, requests carry a session cookie/bearer
// token that must survive a cross-origin browser fetch --
// WithCredentialedCORS is WithCORS's sibling for exactly that (see
// httpserver/cors.go). This also fixes a latent gap: previously,
// enterprise mode applied plain WithCORS here despite needing
// cookies too.
corsHandler := httpserver.WithCORS(mux, cfg.CORSAllowedOrigin)
if authorizer != nil {
corsHandler = httpserver.WithCredentialedCORS(mux, cfg.CORSAllowedOrigin)
}
srv := &http.Server{
Addr: cfg.HTTPListenAddr,
Handler: httpserver.WithCORS(mux, cfg.CORSAllowedOrigin),
Handler: corsHandler,
}
errCh := make(chan error, 1)
@@ -191,6 +241,48 @@ func main() {
}
}
// runSeedAdmin is the operator action that bootstraps local login on a
// fresh deployment: idempotent (a no-op if any local user already
// exists, safe to run on every deploy per the runbook), so there's no
// separate "has this already run" flag to track. The generated
// password is printed to stdout exactly once and never stored in
// plaintext anywhere -- losing it means resetting it
// (POST /auth/users/{id}/reset-password), not recovering it.
func runSeedAdmin(ctx context.Context, logger *slog.Logger, stdout io.Writer, store *localauth.Store) int {
n, err := store.CountLocalUsers(ctx)
if err != nil {
logger.Error("counting local users", "error", err)
return 1
}
if n > 0 {
fmt.Fprintln(stdout, "admin already provisioned, skipping")
return 0
}
buf := make([]byte, 20)
if _, err := rand.Read(buf); err != nil {
logger.Error("generating random password", "error", err)
return 1
}
password := base64.RawURLEncoding.EncodeToString(buf)
hash, err := localauth.HashPassword(password)
if err != nil {
logger.Error("hashing password", "error", err)
return 1
}
if _, err := store.CreateUser(ctx, "admin", hash, authz.RoleOwner); err != nil {
logger.Error("creating admin user", "error", err)
return 1
}
fmt.Fprintln(stdout, "created default admin user:")
fmt.Fprintln(stdout, " username: admin")
fmt.Fprintf(stdout, " password: %s\n", password)
fmt.Fprintln(stdout, "this password will not be shown again -- save it now.")
return 0
}
// runHealthcheck GETs its own /healthz and returns an exit code, for
// Docker's HEALTHCHECK to exec directly (see the -healthcheck flag
// above). listenAddr is HTTP_LISTEN_ADDR-shaped (e.g. ":8080") --
+3 -2
View File
@@ -5,7 +5,9 @@ go 1.25.0
require (
github.com/ClickHouse/clickhouse-go/v2 v2.48.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/sentry/sentry/proto v0.0.0-00010101000000-000000000000
golang.org/x/crypto v0.55.0
google.golang.org/grpc v1.83.0
)
@@ -19,7 +21,6 @@ require (
github.com/go-faster/errors v0.7.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/paulmach/orb v0.13.0 // indirect
@@ -31,7 +32,7 @@ require (
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
+4 -2
View File
@@ -63,14 +63,16 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+72
View File
@@ -18,6 +18,32 @@ type Config struct {
CORSAllowedOrigin string
EnterpriseAuthURL string
AI AIConfig
LocalAuth LocalAuthConfig
}
// LocalAuthConfig gates single-tenant mode's local username/password
// login (see api/localauth) -- off unless Enabled, same "off unless
// configured" convention as EnterpriseAuthURL/AI.OllamaBaseURL. Only
// meaningful when EnterpriseAuthURL is empty -- a deployment with real
// SSO configured has no use for a second, local auth mechanism, and
// main.go's authorizer selection treats EnterpriseAuthURL as taking
// priority if both were somehow set.
type LocalAuthConfig struct {
Enabled bool
// SessionTTL is deliberately long (30 days default) compared to
// enterprise/'s session TTL -- there's no SSO round-trip here to
// silently refresh a session against, so a short TTL would just mean
// re-entering a password often on a self-hosted single-operator tool.
SessionTTL time.Duration
// CookieDomain empty means a host-only cookie (fine for local dev,
// where web/api are both localhost:<port>). Set to e.g.
// ".sentry.example.com" in production so the cookie is also sent to
// api.sentry.example.com/alerting.sentry.example.com.
CookieDomain string
// CookieSecure defaults true (never sent over plain HTTP) --
// deliberately opt-out via LOCAL_AUTH_COOKIE_SECURE=false, only
// useful to test the login flow locally over http://localhost.
CookieSecure bool
}
// AIConfig gates Phase 7's AI-assisted query features (Track A/B) --
@@ -50,6 +76,33 @@ type PostgresConfig struct {
Password string
}
// devOnlyCredential is docker-compose.yml's zero-config default for
// every Postgres/ClickHouse password in this repo -- genuinely fine for
// local dev (that's the whole point of a zero-config default), but a
// real deployment that skips docker-compose.override.yml would
// otherwise go live with a password anyone can read straight off
// GitHub. See DevCredentialWarnings.
const devOnlyCredential = "sentry-dev-only"
// DevCredentialWarnings reports which configured credentials still
// equal docker-compose.yml's literal dev-only default -- cmd/api/main.go
// logs each one loudly at startup. Deliberately a warning, not a
// startup-refusing error: local dev's documented zero-config path is
// exactly "run docker-compose.yml with no override," which legitimately
// leaves every password at this literal value, so hard-failing here
// would break that path rather than only catching real deployments that
// forgot to override it.
func (c Config) DevCredentialWarnings() []string {
var warnings []string
if c.ClickHouse.Password == devOnlyCredential {
warnings = append(warnings, "CLICKHOUSE_PASSWORD is still the default dev-only value -- set a real password via docker-compose.override.yml (or your deployment's equivalent) before this is reachable outside local dev")
}
if c.Postgres.Password == devOnlyCredential {
warnings = append(warnings, "POSTGRES_PASSWORD is still the default dev-only value -- set a real password via docker-compose.override.yml (or your deployment's equivalent) before this is reachable outside local dev")
}
return warnings
}
func Load() (Config, error) {
cfg := Config{
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8080"),
@@ -98,6 +151,25 @@ func Load() (Config, error) {
}
cfg.QueryTimeout = time.Duration(timeoutSec) * time.Second
localAuthEnabled, err := strconv.ParseBool(getenv("LOCAL_AUTH_ENABLED", "false"))
if err != nil {
return Config{}, fmt.Errorf("LOCAL_AUTH_ENABLED: %w", err)
}
sessionTTLHours, err := strconv.Atoi(getenv("LOCAL_SESSION_TTL_HOURS", "720")) // 30 days
if err != nil {
return Config{}, fmt.Errorf("LOCAL_SESSION_TTL_HOURS: %w", err)
}
cookieSecure, err := strconv.ParseBool(getenv("LOCAL_AUTH_COOKIE_SECURE", "true"))
if err != nil {
return Config{}, fmt.Errorf("LOCAL_AUTH_COOKIE_SECURE: %w", err)
}
cfg.LocalAuth = LocalAuthConfig{
Enabled: localAuthEnabled,
SessionTTL: time.Duration(sessionTTLHours) * time.Hour,
CookieDomain: getenv("SESSION_COOKIE_DOMAIN", ""),
CookieSecure: cookieSecure,
}
return cfg, nil
}
+20
View File
@@ -30,3 +30,23 @@ func TestLoadInvalidTimeoutErrors(t *testing.T) {
t.Fatal("expected error for non-numeric QUERY_TIMEOUT_SECONDS, got nil")
}
}
// TestDevCredentialWarnings is the regression test for the
// security-audit finding that docker-compose.yml's hardcoded
// "sentry-dev-only" password has no runtime fail-safe if an operator
// forgets to override it for a real deployment.
func TestDevCredentialWarnings(t *testing.T) {
if got := (Config{}).DevCredentialWarnings(); len(got) != 0 {
t.Errorf("empty passwords: warnings = %v, want none", got)
}
real := Config{ClickHouse: ClickHouseConfig{Password: "a-real-password"}, Postgres: PostgresConfig{Password: "another-real-one"}}
if got := real.DevCredentialWarnings(); len(got) != 0 {
t.Errorf("real passwords: warnings = %v, want none", got)
}
devOnly := Config{ClickHouse: ClickHouseConfig{Password: devOnlyCredential}, Postgres: PostgresConfig{Password: devOnlyCredential}}
if got := devOnly.DevCredentialWarnings(); len(got) != 2 {
t.Errorf("both dev-default passwords: warnings = %v, want 2 entries", got)
}
}
+21
View File
@@ -259,6 +259,24 @@ func defaultAggAlias(a ast.AggCall) string {
// SQL parser -- same tradeoffs as the Phase 0/1 version this replaces.
var disallowedKeyword = regexp.MustCompile(`(?i)\b(insert|update|delete|alter|drop|truncate|create|grant|revoke|attach|detach|rename|kill|optimize|system|set|exchange|watch)\b`)
// disallowedTableFunction blocks ClickHouse's built-in table functions
// that reach outside ClickHouse itself -- a keyword blocklist for
// mutating statements (above) doesn't touch these at all, since
// `SELECT * FROM url(...)` is a perfectly ordinary read-only SELECT as
// far as validateSelectOnly's other checks are concerned. Every
// function here lets a SELECT-only, RoleViewer-gated query make
// ClickHouse itself issue an outbound request or read a local file on
// the caller's behalf -- cloud-metadata SSRF via url(), a proxy into
// other internal ClickHouse/MySQL/Postgres instances via
// remote()/remoteSecure()/mysql()/postgresql(), and local/object-storage
// file reads via file()/hdfs()/s3()/azureBlobStorage()/deltaLake()/
// iceberg()/hudi(). Same word-boundary-regex tradeoff as
// disallowedKeyword above: this is a blocklist, not a real SQL parser,
// so it can't be the only control -- see the ClickHouse-grant-level
// hardening this should be paired with (table-function usage revoked
// for the role api's raw-SQL path connects as).
var disallowedTableFunction = regexp.MustCompile(`(?i)\b(url|remote|remoteSecure|mysql|postgresql|s3|s3Cluster|hdfs|hdfsCluster|file|odbc|jdbc|executable|cluster|clusterAllReplicas|azureBlobStorage|deltaLake|iceberg|hudi|redis|mongodb)\s*\(`)
func validateSelectOnly(sql string) error {
trimmed := strings.TrimSpace(sql)
if trimmed == "" {
@@ -281,6 +299,9 @@ func validateSelectOnly(sql string) error {
if disallowedKeyword.MatchString(trimmed) {
return fmt.Errorf("query contains a disallowed keyword")
}
if disallowedTableFunction.MatchString(trimmed) {
return fmt.Errorf("query contains a disallowed table function")
}
return nil
}
@@ -40,6 +40,48 @@ func TestCompileRejectsNonSelectSQLKeyword(t *testing.T) {
}
}
// TestCompileRejectsSSRFTableFunctions guards against a real finding: a
// SELECT-only, keyword-blocklist check alone doesn't stop ClickHouse's
// built-in table functions, which let an otherwise-ordinary read-only
// SELECT make ClickHouse itself issue an outbound request (url,
// remote/remoteSecure, mysql, postgresql, s3, hdfs, ...) or read a local
// file (file) on the caller's behalf -- an SSRF/file-read primitive
// reachable by RoleViewer, the platform's lowest role.
func TestCompileRejectsSSRFTableFunctions(t *testing.T) {
queries := []string{
`SELECT * FROM url('http://169.254.169.254/latest/meta-data/', 'LineAsString', 's String')`,
`select * from remote('internal-host:9000', system, tables)`,
`SELECT * FROM remoteSecure('attacker.example:9440', db, tbl, 'user', 'pass')`,
`select * from mysql('host:3306', 'db', 'table', 'user', 'pass')`,
`SELECT * FROM postgresql('host:5432', 'db', 'table', 'user', 'pass')`,
`select * from s3('https://bucket.s3.amazonaws.com/key', 'CSV')`,
`SELECT * FROM hdfs('hdfs://host:9000/path', 'CSV')`,
`select * from file('/etc/passwd', 'LineAsString')`,
`SELECT * FROM odbc('DSN=foo', 'db', 'table')`,
`select * from executable('id', 'TSV', 'x String')`,
`SELECT * FROM cluster('some_cluster', system, tables)`,
}
for _, q := range queries {
if _, err := Compile(q, SQL, fixedNow); err == nil {
t.Errorf("expected Compile(%q) to reject a table-function SSRF vector, got no error", q)
}
}
}
// TestCompileAllowsOrdinaryColumnNamesResemblingTableFunctions makes
// sure the table-function blocklist only fires on actual function-call
// syntax (name immediately followed by "(") and not merely a column or
// identifier that happens to share a name with a blocked function.
func TestCompileAllowsOrdinaryColumnNamesResemblingTableFunctions(t *testing.T) {
plan, err := Compile(`SELECT cluster_id, file_name FROM logs WHERE cluster_id = 1`, SQL, fixedNow)
if err != nil {
t.Fatalf("Compile() error = %v", err)
}
if plan.RawSQL == "" {
t.Fatal("expected RawSQL to be set")
}
}
func TestCompileExplicitLanguageOverridesAutoDetect(t *testing.T) {
// "select" as a bare free-text search term -- would be misdetected
// as SQL by the heuristic alone, hence the override.
+70
View File
@@ -0,0 +1,70 @@
package localauth
import (
"context"
"errors"
"net/http"
"github.com/sentry/sentry/api/authz"
)
// sessionStore is the narrow interface Authorizer depends on -- *Store
// is the production implementation; tests use a fake.
type sessionStore interface {
GetSession(ctx context.Context, tokenHash string) (*Session, error)
}
// sessionCookieName is also read directly by handler.go (Set-Cookie on
// login/logout) and is the one piece of this package's shape web/
// needs to know about implicitly (via credentials: 'include', not by
// name -- the browser handles the cookie, JS never reads it since it's
// HttpOnly).
const sessionCookieName = "sentry_local_session"
// Authorizer implements api/authz.Authorizer against local_sessions --
// wiring a non-nil *Authorizer into api/cmd/api/main.go's authorizer
// variable is what turns every existing RequireRole-wrapped route in
// dashboards/agents/queryapi/aiapi from a no-op into real enforcement,
// with no changes needed to any of those handler files (see this
// package's doc comment).
type Authorizer struct {
store sessionStore
}
func NewAuthorizer(store sessionStore) *Authorizer {
return &Authorizer{store: store}
}
var errNoCredential = errors.New("localauth: no session credential presented")
// Authorize checks Authorization: Bearer first (sentryctl and other
// non-browser callers), then the session cookie (the web UI) -- same
// precedence authz.HTTPAuthorizer's caller-side forwarding implies,
// and the same reason POST /auth/login's response body returns the raw
// token alongside setting the cookie (see handler.go): one opaque
// value works both ways.
func (a *Authorizer) Authorize(r *http.Request) (authz.Identity, error) {
raw, err := credentialFromRequest(r)
if err != nil {
return authz.Identity{}, err
}
sess, err := a.store.GetSession(r.Context(), hashToken(raw))
if err != nil {
return authz.Identity{}, err
}
return authz.Identity{TenantID: sess.TenantID, UserID: sess.UserID, Role: sess.Role}, nil
}
func credentialFromRequest(r *http.Request) (string, error) {
if auth := r.Header.Get("Authorization"); auth != "" {
const prefix = "Bearer "
if len(auth) > len(prefix) && auth[:len(prefix)] == prefix {
return auth[len(prefix):], nil
}
}
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
return cookie.Value, nil
}
return "", errNoCredential
}
+127
View File
@@ -0,0 +1,127 @@
package localauth
import (
"context"
"strconv"
"time"
"github.com/sentry/sentry/api/authz"
)
// fakeStore implements both store (handler.go) and sessionStore
// (authorizer.go) -- a real *Store satisfies both too, this is just the
// in-memory test double, same "fake enforces the same invariants the
// real pgx-backed Store does" posture dashboards/handler_test.go's
// fakeStore documents.
type fakeStore struct {
users map[string]*User // by id
hashes map[string]string
byUsername map[string]string // username -> id
sessions map[string]Session
nextID int
createErr error
}
func newFakeStore() *fakeStore {
return &fakeStore{
users: map[string]*User{},
hashes: map[string]string{},
byUsername: map[string]string{},
sessions: map[string]Session{},
}
}
func (f *fakeStore) CreateUser(_ context.Context, username, passwordHash string, role authz.Role) (*User, error) {
if f.createErr != nil {
return nil, f.createErr
}
if _, ok := f.byUsername[username]; ok {
return nil, ErrUsernameTaken
}
f.nextID++
id := "user-" + strconv.Itoa(f.nextID)
u := &User{ID: id, Username: username, Role: role, CreatedAt: time.Now()}
f.users[id] = u
f.hashes[id] = passwordHash
f.byUsername[username] = id
return u, nil
}
func (f *fakeStore) ListUsers(_ context.Context) ([]User, error) {
var out []User
for _, u := range f.users {
out = append(out, *u)
}
return out, nil
}
func (f *fakeStore) GetUserForLogin(_ context.Context, username string) (*User, string, error) {
id, ok := f.byUsername[username]
if !ok {
return nil, "", ErrNotFound
}
return f.users[id], f.hashes[id], nil
}
func (f *fakeStore) GetUserByID(_ context.Context, id string) (*User, error) {
u, ok := f.users[id]
if !ok {
return nil, ErrNotFound
}
return u, nil
}
func (f *fakeStore) DeleteUser(_ context.Context, id string) error {
u, ok := f.users[id]
if !ok {
return ErrNotFound
}
delete(f.users, id)
delete(f.hashes, id)
delete(f.byUsername, u.Username)
for hash, sess := range f.sessions {
if sess.UserID == id {
delete(f.sessions, hash)
}
}
return nil
}
func (f *fakeStore) SetPasswordHash(_ context.Context, userID, hash string) error {
if _, ok := f.users[userID]; !ok {
return ErrNotFound
}
f.hashes[userID] = hash
for h, sess := range f.sessions {
if sess.UserID == userID {
delete(f.sessions, h)
}
}
return nil
}
func (f *fakeStore) CountLocalUsers(_ context.Context) (int, error) {
return len(f.users), nil
}
func (f *fakeStore) CreateSession(_ context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error) {
raw, hash, err := newOpaqueToken()
if err != nil {
return "", err
}
f.sessions[hash] = Session{UserID: userID, TenantID: tenantID, Role: role, ExpiresAt: time.Now().Add(ttl)}
return raw, nil
}
func (f *fakeStore) GetSession(_ context.Context, tokenHash string) (*Session, error) {
sess, ok := f.sessions[tokenHash]
if !ok || sess.ExpiresAt.Before(time.Now()) {
return nil, ErrNotFound
}
return &sess, nil
}
func (f *fakeStore) DeleteSessionByHash(_ context.Context, tokenHash string) error {
delete(f.sessions, tokenHash)
return nil
}
+407
View File
@@ -0,0 +1,407 @@
package localauth
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"github.com/sentry/sentry/api/authz"
)
const maxBodyBytes = 1 << 20 // 1 MiB, same cap as queryapi/dashboards/agents
// store is the narrow interface Handler depends on -- *Store (store.go)
// is the production implementation; tests use a fake, same pattern as
// dashboards.store/agents.store.
type store interface {
CreateUser(ctx context.Context, username, passwordHash string, role authz.Role) (*User, error)
ListUsers(ctx context.Context) ([]User, error)
GetUserForLogin(ctx context.Context, username string) (*User, string, error)
GetUserByID(ctx context.Context, id string) (*User, error)
DeleteUser(ctx context.Context, id string) error
SetPasswordHash(ctx context.Context, userID, hash string) error
CreateSession(ctx context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error)
DeleteSessionByHash(ctx context.Context, tokenHash string) error
}
// CookieConfig is the deployment-specific half of how the session
// cookie is set -- everything else about it (name, HttpOnly, SameSite)
// is fixed by this package, not configurable per deployment.
type CookieConfig struct {
// Domain is typically empty for local dev (host-only cookie, works
// fine when web/api are both localhost:<port>) and something like
// ".sentry.example.com" in production, so the same cookie is sent to
// api.sentry.example.com and alerting.sentry.example.com too -- see
// /docs (deployment runbook) for the subdomain scheme this assumes.
Domain string
// Secure defaults to true (the cookie is never sent over plain
// HTTP) -- deliberately opt-out, not opt-in, since the real
// deployment this feature exists for is always behind HTTPS. Only
// worth setting false to test the login flow locally over plain
// http://localhost.
Secure bool
}
// loginRateLimitMax/Window bound how many login attempts one client IP
// may make -- see loginLimiter's doc comment for why this is per-IP,
// in-memory, and counts both successful and failed attempts. 10 per 5
// minutes is generous enough that a real user mistyping a password a
// few times never notices, while still bounding an online brute-force
// attempt to a few attempts per minute.
const (
loginRateLimitMax = 10
loginRateLimitWindow = 5 * time.Minute
)
type Handler struct {
logger *slog.Logger
store store
authorizer authz.Authorizer
sessionTTL time.Duration
cookies CookieConfig
loginLimits *loginLimiter
}
func NewHandler(logger *slog.Logger, store store, authorizer authz.Authorizer, sessionTTL time.Duration, cookies CookieConfig) *Handler {
return &Handler{
logger: logger,
store: store,
authorizer: authorizer,
sessionTTL: sessionTTL,
cookies: cookies,
loginLimits: newLoginLimiter(loginRateLimitMax, loginRateLimitWindow),
}
}
// RegisterRoutes is only ever called when local auth is enabled (see
// cmd/api/main.go) -- a deployment that doesn't enable it simply never
// registers these routes at all, so GET /auth/session (etc.) 404s
// rather than needing its own "is this feature even on" response
// shape. Login/logout/session are deliberately NOT RequireRole-wrapped
// with anything above RoleViewer's floor: login is how you become
// authenticated in the first place, logout/session must work for any
// already-authenticated user regardless of role.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /auth/login", h.handleLogin)
mux.HandleFunc("POST /auth/logout", h.handleLogout)
mux.HandleFunc("GET /auth/session", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleGetSession))
mux.HandleFunc("GET /auth/users", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleListUsers))
mux.HandleFunc("POST /auth/users", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleCreateUser))
mux.HandleFunc("DELETE /auth/users/{id}", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleDeleteUser))
mux.HandleFunc("POST /auth/users/{id}/reset-password", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleResetPassword))
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type sessionResponse struct {
// Token duplicates what the Set-Cookie header already carries,
// specifically for non-browser callers with no cookie jar --
// sentryctl captures this into SENTRYCTL_TOKEN and sends it back as
// Authorization: Bearer (see authorizer.go's credentialFromRequest,
// which accepts either). The web UI ignores this field entirely and
// relies on the cookie.
Token string `json:"token"`
UserID string `json:"user_id"`
TenantID string `json:"tenant_id"`
Username string `json:"username"`
Role string `json:"role"`
}
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
if !h.loginLimits.allow(clientIP(r)) {
writeError(w, http.StatusTooManyRequests, "too many login attempts, try again later")
return
}
var req loginRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "username and password are required")
return
}
user, hash, err := h.store.GetUserForLogin(r.Context(), req.Username)
if err != nil {
if errors.Is(err, ErrNotFound) {
// Run a dummy bcrypt comparison even though there's no real
// hash to check -- otherwise this branch returns immediately
// while a known-username branch always pays bcrypt's cost
// below, and that timing gap lets a patient caller enumerate
// valid usernames by response latency alone even though the
// error message text is identical either way.
ComparePassword(dummyPasswordHash, req.Password)
writeError(w, http.StatusUnauthorized, "invalid username or password")
return
}
h.logger.Error("looking up user for login", "error", err)
writeError(w, http.StatusInternalServerError, "login failed")
return
}
if !ComparePassword(hash, req.Password) {
writeError(w, http.StatusUnauthorized, "invalid username or password")
return
}
raw, err := h.store.CreateSession(r.Context(), user.ID, defaultTenantID, user.Role, h.sessionTTL)
if err != nil {
h.logger.Error("creating session", "error", err)
writeError(w, http.StatusInternalServerError, "login failed")
return
}
h.setCookie(w, raw, h.sessionTTL)
writeJSON(w, http.StatusOK, sessionResponse{
Token: raw, UserID: user.ID, TenantID: defaultTenantID,
Username: user.Username, Role: string(user.Role),
})
}
// handleLogout always responds 204, whether or not a valid session was
// presented -- "log me out" is idempotent from the caller's point of
// view either way.
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
if raw, err := credentialFromRequest(r); err == nil {
if err := h.store.DeleteSessionByHash(r.Context(), hashToken(raw)); err != nil {
h.logger.Error("deleting session", "error", err)
}
}
h.clearCookie(w)
w.WriteHeader(http.StatusNoContent)
}
// handleGetSession is what web's route guard (+layout.ts) polls on
// every navigation -- RequireRole(RoleViewer) above already turns "no
// valid session" into a 401 before this ever runs, so by the time
// we're here the identity is real.
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
identity, _ := authz.IdentityFromContext(r.Context())
user, err := h.store.GetUserByID(r.Context(), identity.UserID)
if err != nil {
h.writeStoreErr(w, err, "fetching session user")
return
}
writeJSON(w, http.StatusOK, sessionResponse{
UserID: user.ID, TenantID: identity.TenantID, Username: user.Username, Role: string(user.Role),
})
}
type userResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
func (h *Handler) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.store.ListUsers(r.Context())
if err != nil {
h.logger.Error("listing users", "error", err)
writeError(w, http.StatusInternalServerError, "listing users failed")
return
}
out := make([]userResponse, len(users))
for i, u := range users {
out[i] = userResponse{ID: u.ID, Username: u.Username, Role: string(u.Role), CreatedAt: u.CreatedAt}
}
writeJSON(w, http.StatusOK, out)
}
type createUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
func validRole(r authz.Role) bool {
switch r {
case authz.RoleViewer, authz.RoleEditor, authz.RoleAdmin, authz.RoleOwner:
return true
default:
return false
}
}
func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Username == "" {
writeError(w, http.StatusBadRequest, "username must not be empty")
return
}
if len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
role := authz.Role(req.Role)
if role == "" {
role = authz.RoleEditor
}
if !validRole(role) {
writeError(w, http.StatusBadRequest, `role must be "viewer", "editor", "admin", or "owner"`)
return
}
hash, err := HashPassword(req.Password)
if err != nil {
h.logger.Error("hashing password", "error", err)
writeError(w, http.StatusInternalServerError, "creating user failed")
return
}
user, err := h.store.CreateUser(r.Context(), req.Username, hash, role)
if err != nil {
if errors.Is(err, ErrUsernameTaken) {
writeError(w, http.StatusConflict, "username already taken")
return
}
h.logger.Error("creating user", "error", err)
writeError(w, http.StatusInternalServerError, "creating user failed")
return
}
writeJSON(w, http.StatusCreated, userResponse{ID: user.ID, Username: user.Username, Role: string(user.Role), CreatedAt: user.CreatedAt})
}
// handleDeleteUser deliberately does not stop an admin from deleting
// their own account -- this package has no separate "you can't remove
// the last admin" guard; a single-operator prototype deployment is
// expected to know what it's doing here, same trust level the rest of
// this codebase's admin-only endpoints assume.
func (h *Handler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
if err := h.store.DeleteUser(r.Context(), r.PathValue("id")); err != nil {
h.writeStoreErr(w, err, "deleting user")
return
}
w.WriteHeader(http.StatusNoContent)
}
type resetPasswordRequest struct {
// Password is optional -- omitted, a random one is generated and
// returned in the response body exactly once, same "shown once,
// never stored, never recoverable" posture as -seed-admin's initial
// password (see cmd/api/main.go's runSeedAdmin).
Password string `json:"password,omitempty"`
}
type resetPasswordResponse struct {
// Password is only set when the request didn't supply one --
// omitempty so an admin-supplied reset doesn't echo it back.
Password string `json:"password,omitempty"`
}
func (h *Handler) handleResetPassword(w http.ResponseWriter, r *http.Request) {
var req resetPasswordRequest
// An empty body is valid here (generate a random password) --
// decodeJSON's json.Decode on an empty io.Reader would error, so
// this endpoint reads the body directly instead of reusing
// decodeJSON, tolerating "no body at all" as "use defaults."
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
if r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
}
plaintext := req.Password
generated := false
if plaintext == "" {
raw, _, err := newOpaqueToken()
if err != nil {
h.logger.Error("generating random password", "error", err)
writeError(w, http.StatusInternalServerError, "resetting password failed")
return
}
plaintext = raw
generated = true
} else if len(plaintext) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
hash, err := HashPassword(plaintext)
if err != nil {
h.logger.Error("hashing password", "error", err)
writeError(w, http.StatusInternalServerError, "resetting password failed")
return
}
if err := h.store.SetPasswordHash(r.Context(), r.PathValue("id"), hash); err != nil {
h.writeStoreErr(w, err, "resetting password")
return
}
resp := resetPasswordResponse{}
if generated {
resp.Password = plaintext
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) setCookie(w http.ResponseWriter, raw string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: raw,
Domain: h.cookies.Domain,
Path: "/",
MaxAge: int(ttl.Seconds()),
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
}
func (h *Handler) clearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Domain: h.cookies.Domain,
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
}
func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "not found")
return
}
h.logger.Error(action, "error", err)
writeError(w, http.StatusInternalServerError, action+" failed")
}
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type errorResponse struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
}
+261
View File
@@ -0,0 +1,261 @@
package localauth
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/sentry/sentry/api/authz"
)
func newTestHandler(t *testing.T, fs *fakeStore) (*Handler, *http.ServeMux) {
t.Helper()
authorizer := NewAuthorizer(fs)
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs, authorizer, time.Hour, CookieConfig{})
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return h, mux
}
func doRequest(t *testing.T, mux *http.ServeMux, method, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, r)
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func mustCreateUser(t *testing.T, fs *fakeStore, username, password string, role authz.Role) *User {
t.Helper()
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("hashing password: %v", err)
}
u, err := fs.CreateUser(t.Context(), username, hash, role)
if err != nil {
t.Fatalf("creating user: %v", err)
}
return u
}
func sessionCookieFrom(rec *httptest.ResponseRecorder) *http.Cookie {
for _, c := range rec.Result().Cookies() {
if c.Name == sessionCookieName {
return c
}
}
return nil
}
func TestLoginSuccess(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
rec := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
cookie := sessionCookieFrom(rec)
if cookie == nil || cookie.Value == "" {
t.Fatalf("expected a session cookie to be set, got none")
}
if !cookie.HttpOnly {
t.Errorf("session cookie must be HttpOnly")
}
var resp sessionResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Token == "" {
t.Errorf("expected the response body to also carry the raw token for non-browser callers")
}
if resp.Role != "editor" {
t.Errorf("role = %q, want editor", resp.Role)
}
}
func TestLoginWrongPassword(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
rec := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong"}`, nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestLoginUnknownUserSameErrorAsWrongPassword(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
unknown := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"bob","password":"whatever"}`, nil)
wrongPass := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong"}`, nil)
if unknown.Code != http.StatusUnauthorized || wrongPass.Code != http.StatusUnauthorized {
t.Fatalf("both must be 401, got unknown=%d wrongPass=%d", unknown.Code, wrongPass.Code)
}
if unknown.Body.String() != wrongPass.Body.String() {
t.Errorf("responses must be identical (no username enumeration): unknown=%q wrongPass=%q", unknown.Body.String(), wrongPass.Body.String())
}
}
func TestSessionRequiresAuth(t *testing.T) {
fs := newFakeStore()
_, mux := newTestHandler(t, fs)
rec := doRequest(t, mux, http.MethodGet, "/auth/session", "", nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 with no session cookie", rec.Code)
}
}
func TestLoginThenSessionRoundTrip(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
cookie := sessionCookieFrom(login)
sess := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie)
if sess.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", sess.Code, sess.Body.String())
}
var resp sessionResponse
if err := json.Unmarshal(sess.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Username != "alice" {
t.Errorf("username = %q, want alice", resp.Username)
}
}
func TestLogoutInvalidatesSession(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
cookie := sessionCookieFrom(login)
logout := doRequest(t, mux, http.MethodPost, "/auth/logout", "", cookie)
if logout.Code != http.StatusNoContent {
t.Fatalf("logout status = %d, want 204", logout.Code)
}
sess := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie)
if sess.Code != http.StatusUnauthorized {
t.Fatalf("status after logout = %d, want 401 (session must be revoked)", sess.Code)
}
}
func TestNonOwnerCannotManageUsers(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
cookie := sessionCookieFrom(login)
rec := doRequest(t, mux, http.MethodGet, "/auth/users", "", cookie)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 for a non-owner listing users", rec.Code)
}
}
func TestOwnerCanCreateAndDeleteUsers(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
cookie := sessionCookieFrom(login)
create := doRequest(t, mux, http.MethodPost, "/auth/users", `{"username":"bob","password":"bobspassword","role":"viewer"}`, cookie)
if create.Code != http.StatusCreated {
t.Fatalf("create status = %d, want 201; body=%s", create.Code, create.Body.String())
}
var created userResponse
if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil {
t.Fatalf("decoding response: %v", err)
}
if created.Role != "viewer" {
t.Errorf("role = %q, want viewer", created.Role)
}
if created.CreatedAt.IsZero() {
t.Errorf("created_at was not populated in the create response")
}
list := doRequest(t, mux, http.MethodGet, "/auth/users", "", cookie)
var users []userResponse
if err := json.Unmarshal(list.Body.Bytes(), &users); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(users) != 2 {
t.Fatalf("len(users) = %d, want 2 (admin + bob)", len(users))
}
del := doRequest(t, mux, http.MethodDelete, "/auth/users/"+created.ID, "", cookie)
if del.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, want 204", del.Code)
}
}
func TestCreateUserRejectsShortPassword(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
cookie := sessionCookieFrom(login)
rec := doRequest(t, mux, http.MethodPost, "/auth/users", `{"username":"bob","password":"short"}`, cookie)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for a too-short password", rec.Code)
}
}
func TestResetPasswordRevokesExistingSessions(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
bob := mustCreateUser(t, fs, "bob", "bobspassword", authz.RoleViewer)
_, mux := newTestHandler(t, fs)
adminLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
adminCookie := sessionCookieFrom(adminLogin)
bobLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"bob","password":"bobspassword"}`, nil)
bobCookie := sessionCookieFrom(bobLogin)
reset := doRequest(t, mux, http.MethodPost, "/auth/users/"+bob.ID+"/reset-password", "", adminCookie)
if reset.Code != http.StatusOK {
t.Fatalf("reset status = %d, want 200; body=%s", reset.Code, reset.Body.String())
}
var resp resetPasswordResponse
if err := json.Unmarshal(reset.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Password == "" {
t.Fatalf("expected a generated password in the response when none was supplied")
}
stale := doRequest(t, mux, http.MethodGet, "/auth/session", "", bobCookie)
if stale.Code != http.StatusUnauthorized {
t.Fatalf("bob's pre-reset session status = %d, want 401 (reset must revoke existing sessions)", stale.Code)
}
}
+29
View File
@@ -0,0 +1,29 @@
package localauth
import "golang.org/x/crypto/bcrypt"
// dummyPasswordHash is a precomputed bcrypt hash of an arbitrary,
// never-used-as-a-real-password string -- handleLogin runs
// ComparePassword against this on the "no such user" path purely to pay
// the same bcrypt cost the "wrong password" path already pays, closing
// a response-time side channel that would otherwise let a caller
// distinguish the two despite their identical error message. There is
// no real password behind this hash; it exists only to burn comparable
// CPU time.
const dummyPasswordHash = "$2a$10$fH9R3O6ViQ6c7bq0N7yyBO1JP2TOw/bZEopMyZKBYrBjgYBZO9rCa"
// HashPassword and ComparePassword are the only two places this package
// touches a raw password -- everywhere else, a user is identified by an
// already-issued session token (see token.go), never by re-checking a
// password on every request.
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func ComparePassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
+91
View File
@@ -0,0 +1,91 @@
package localauth
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
// loginLimiter is a simple in-memory sliding-window rate limiter for
// POST /auth/login, keyed by client IP -- closes a real gap the
// security audit found: nothing in the application, nginx, or the host
// (no fail2ban either) throttled repeated login attempts, making
// sustained online brute-forcing of a weaker, human-chosen password
// possible. (The auto-generated admin password is high-entropy, but
// every user created afterward only needs 8 characters with no
// complexity check -- see handleCreateUser.)
//
// Per-IP rather than per-username: a per-username-only limiter is
// itself a denial-of-service vector (deliberately fail a real
// username's login repeatedly, from anywhere, to lock them out), and
// wouldn't bound an attacker guessing across many usernames from one
// source. Both successful and failed attempts count against the
// window, not just failures -- simpler, and it means a low-and-slow
// guesser can't reset their budget by occasionally succeeding against
// an unrelated account.
//
// Deliberately in-memory, not Postgres-backed: login rate limiting is
// inherently best-effort per-process state (a restart clearing it is
// fine, unlike a session or password), and adding a database
// round-trip to every login attempt is the wrong tradeoff for a check
// whose only job is bounding attempt *rate*. Memory for IPs that stop
// attempting entirely is only reclaimed the next time that exact key is
// looked up -- a deliberate, bounded-in-practice simplicity tradeoff
// (real attacker/user IP cardinality against one deployment is small
// relative to a process's lifetime between deploys), not an oversight.
type loginLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
max int
window time.Duration
}
func newLoginLimiter(max int, window time.Duration) *loginLimiter {
return &loginLimiter{attempts: map[string][]time.Time{}, max: max, window: window}
}
// allow reports whether key may attempt another login right now, and
// records this attempt if so (a denied call does not itself count as a
// new attempt -- it just reports the existing window is full).
func (l *loginLimiter) allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
cutoff := now.Add(-l.window)
var kept []time.Time
for _, t := range l.attempts[key] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= l.max {
l.attempts[key] = kept
return false
}
l.attempts[key] = append(kept, now)
return true
}
// clientIP extracts the caller's address for rate-limiting purposes.
// Trusts the first hop of X-Forwarded-For when present -- correct for
// this deployment's actual topology (always behind nginx, which sets
// it), but note this is spoofable by any caller that reaches the
// application directly rather than through the trusted proxy; a
// deployment that exposes api's port directly to untrusted clients
// should not rely on this header. Falls back to r.RemoteAddr, which is
// always accurate for whoever the TCP connection is actually with.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if first, _, ok := strings.Cut(xff, ","); ok {
return strings.TrimSpace(first)
}
return strings.TrimSpace(xff)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
+84
View File
@@ -0,0 +1,84 @@
package localauth
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/sentry/sentry/api/authz"
)
func TestLoginLimiterAllowsUpToMax(t *testing.T) {
l := newLoginLimiter(3, time.Minute)
for i := 0; i < 3; i++ {
if !l.allow("1.2.3.4") {
t.Fatalf("attempt %d: want allowed", i+1)
}
}
if l.allow("1.2.3.4") {
t.Fatal("4th attempt within the window: want denied")
}
}
func TestLoginLimiterIsPerKey(t *testing.T) {
l := newLoginLimiter(1, time.Minute)
if !l.allow("1.2.3.4") {
t.Fatal("first attempt from 1.2.3.4: want allowed")
}
if !l.allow("5.6.7.8") {
t.Fatal("a different IP must have its own budget")
}
if l.allow("1.2.3.4") {
t.Fatal("second attempt from 1.2.3.4: want denied")
}
}
func TestLoginLimiterResetsAfterWindow(t *testing.T) {
l := newLoginLimiter(1, 10*time.Millisecond)
if !l.allow("1.2.3.4") {
t.Fatal("first attempt: want allowed")
}
if l.allow("1.2.3.4") {
t.Fatal("second attempt within the window: want denied")
}
time.Sleep(20 * time.Millisecond)
if !l.allow("1.2.3.4") {
t.Fatal("attempt after the window elapsed: want allowed")
}
}
func TestClientIPPrefersForwardedFor(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/auth/login", nil)
r.RemoteAddr = "10.0.0.1:5555"
r.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
if got := clientIP(r); got != "203.0.113.9" {
t.Errorf("clientIP() = %q, want %q", got, "203.0.113.9")
}
}
func TestClientIPFallsBackToRemoteAddr(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/auth/login", nil)
r.RemoteAddr = "198.51.100.7:5555"
if got := clientIP(r); got != "198.51.100.7" {
t.Errorf("clientIP() = %q, want %q", got, "198.51.100.7")
}
}
// TestHandleLoginRateLimited is the regression test for the
// security-audit finding that POST /auth/login had no rate limiting at
// all -- repeated attempts from the same client must eventually get a
// 429, not another 401.
func TestHandleLoginRateLimited(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
var last *httptest.ResponseRecorder
for i := 0; i < loginRateLimitMax+1; i++ {
last = doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong-password"}`, nil)
}
if last.Code != http.StatusTooManyRequests {
t.Fatalf("status after exceeding the limit = %d, want 429", last.Code)
}
}
+302
View File
@@ -0,0 +1,302 @@
// Package localauth is single-tenant mode's local username/password
// login: a real login page and session-based auth covering both /api
// and /alerting, plus a simple admin-managed user list, for deployments
// reachable over the internet that can no longer rely on Phase 0-3's
// "no auth yet" default (see /docs/architecture.md and CLAUDE.md's
// Phase 4 section for the enterprise/ SSO alternative this is not --
// this package has no tenant/RBAC-service concept, just "is this a
// valid logged-in user").
//
// Deliberately extends the existing users/tenants/tenant_memberships
// schema (0017/0018/0020_*.sql, built for Phase 4 SSO) rather than a
// parallel local_users table: tenant_memberships.role is already
// constrained to exactly authz.Role's four human values, so a local
// login gets real 4-tier roles for free, and a deployment that later
// turns on enterprise SSO has one identity graph to reconcile, not two.
// Every local user is a member of the "default" tenant only -- this
// package has no notion of provisioning additional tenants.
//
// Authorizer (authorizer.go) is what api/cmd/api/main.go wires into
// api/authz's Authorizer slot for a single-tenant deployment that wants
// real auth -- once that's non-nil, every existing RequireRole-wrapped
// route in dashboards/agents/queryapi/aiapi starts enforcing roles for
// free, no other handler file needs to change. alerting has no such
// per-route plumbing at all, so it gets its own, much smaller,
// deliberately-duplicated package (alerting/internal/sessioncheck) that
// only ever validates an already-issued session -- see that package's
// doc comment for why this isn't imported from here instead.
package localauth
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/authz"
)
var (
ErrNotFound = errors.New("not found")
ErrUsernameTaken = errors.New("username already taken")
)
// defaultTenantID is the only tenant a local user can ever belong to --
// see the package doc comment. Matches every other single-tenant
// deployment's "default" tenant_id convention (dashboards, agents,
// alert_rules).
const defaultTenantID = "default"
type User struct {
ID string
Username string
Role authz.Role
CreatedAt time.Time
}
type Session struct {
UserID string
TenantID string
Role authz.Role
ExpiresAt time.Time
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// CreateUser inserts a new local user and, in the same transaction, the
// tenant_memberships row that gives them role in the default tenant --
// a local user with no membership row would authenticate successfully
// (CreateSession has nothing that requires one) but satisfy no
// RequireRole check at all, so the two rows are never created
// separately.
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string, role authz.Role) (*User, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
id := uuid.NewString()
var createdAt time.Time
err = tx.QueryRow(ctx, `
INSERT INTO users (id, username, password_hash, display_name, created_at, updated_at)
VALUES ($1, $2, $3, $2, now(), now())
RETURNING created_at`,
id, username, passwordHash).Scan(&createdAt)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrUsernameTaken
}
return nil, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO tenant_memberships (id, tenant_id, user_id, role)
VALUES ($1, $2, $3, $4)`,
uuid.NewString(), defaultTenantID, id, string(role)); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &User{ID: id, Username: username, Role: role, CreatedAt: createdAt}, nil
}
const listColumns = `
u.id, u.username, tm.role, u.created_at`
// ListUsers only ever returns local users (username IS NOT NULL) --
// an SSO-provisioned user with no password_hash/username set never
// appears here, since there's nothing for this package's user manager
// to do with one.
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.pool.Query(ctx, `
SELECT `+listColumns+`
FROM users u
JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1
WHERE u.username IS NOT NULL
ORDER BY u.username`, defaultTenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
var role string
if err := rows.Scan(&u.ID, &u.Username, &role, &u.CreatedAt); err != nil {
return nil, err
}
u.Role = authz.Role(role)
out = append(out, u)
}
return out, rows.Err()
}
// GetUserForLogin returns the user and their password hash together --
// the only place this package ever reads a password_hash back out, and
// only to feed ComparePassword. Everywhere else uses User, which never
// carries the hash.
func (s *Store) GetUserForLogin(ctx context.Context, username string) (*User, string, error) {
var u User
var role, hash string
err := s.pool.QueryRow(ctx, `
SELECT u.id, u.username, u.password_hash, tm.role, u.created_at
FROM users u
JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1
WHERE u.username = $2`, defaultTenantID, username).
Scan(&u.ID, &u.Username, &hash, &role, &u.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", ErrNotFound
}
return nil, "", err
}
u.Role = authz.Role(role)
return &u, hash, nil
}
// GetUserByID backs GET /auth/session -- looking up the identity
// RequireRole already resolved and attached to the request context, to
// return its username (Session/Identity carry no username, only IDs).
func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
var u User
var role string
err := s.pool.QueryRow(ctx, `
SELECT u.id, u.username, tm.role, u.created_at
FROM users u
JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1
WHERE u.id = $2 AND u.username IS NOT NULL`, defaultTenantID, id).
Scan(&u.ID, &u.Username, &role, &u.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
u.Role = authz.Role(role)
return &u, nil
}
// DeleteUser cascades to the user's tenant_memberships and
// local_sessions rows (both ON DELETE CASCADE) -- a deleted user's
// existing sessions stop validating immediately, not just their next
// login.
func (s *Store) DeleteUser(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM users WHERE id = $1 AND username IS NOT NULL`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// SetPasswordHash also revokes every existing session for userID, in
// the same transaction -- Session.Role/TenantID are a snapshot taken at
// login (see 0041_create_local_sessions.sql's doc comment), so without
// this an account whose password was just reset for security reasons
// would keep any already-issued session working regardless.
func (s *Store) SetPasswordHash(ctx context.Context, userID, hash string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2 AND username IS NOT NULL`, hash, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `DELETE FROM local_sessions WHERE user_id = $1`, userID); err != nil {
return err
}
return tx.Commit(ctx)
}
// CountLocalUsers backs -seed-admin's idempotency check (see
// cmd/api/main.go's runSeedAdmin): a deployment that already has at
// least one local user never gets a second auto-created admin account.
func (s *Store) CountLocalUsers(ctx context.Context) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE username IS NOT NULL`).Scan(&n)
return n, err
}
// CreateSession mints a fresh opaque token for an already-authenticated
// user (login has already verified their password by the time this is
// called) and stores its hash plus a role/tenant snapshot. Returns the
// raw token -- the only time it's ever available in plaintext again
// after this call.
func (s *Store) CreateSession(ctx context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error) {
raw, hash, err := newOpaqueToken()
if err != nil {
return "", err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO local_sessions (id, user_id, tenant_id, role, token_hash, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
uuid.NewString(), userID, tenantID, string(role), hash, time.Now().Add(ttl))
if err != nil {
return "", err
}
return raw, nil
}
// GetSession looks up an already-hashed lookup key rather than a raw
// token -- see authorizer.go, the only caller, which re-derives the
// hash from whatever the request presented before calling this.
// Deliberately does not delete an expired row itself (that's a plain
// SELECT with no side effect); the goal here is a fast, obviously-
// correct read path, not a lookup that also mutates state, so
// expired-session cleanup is a separate, simpler concern.
func (s *Store) GetSession(ctx context.Context, tokenHash string) (*Session, error) {
var sess Session
var role string
err := s.pool.QueryRow(ctx, `
SELECT user_id, tenant_id, role, expires_at
FROM local_sessions WHERE token_hash = $1`, tokenHash).
Scan(&sess.UserID, &sess.TenantID, &role, &sess.ExpiresAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
sess.Role = authz.Role(role)
if sess.ExpiresAt.Before(time.Now()) {
return nil, ErrNotFound
}
return &sess, nil
}
// DeleteSessionByHash backs logout -- a no-op (not an error) if the
// session is already gone, matching logout's own "always succeeds"
// posture (handler.go's handleLogout).
func (s *Store) DeleteSessionByHash(ctx context.Context, tokenHash string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM local_sessions WHERE token_hash = $1`, tokenHash)
return err
}
// isUniqueViolation checks for Postgres error code 23505 (unique_violation),
// same pgconn.PgError.Code pattern rbacstore.go's SetDataSourceCredentials
// already uses for 22P02.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
+157
View File
@@ -0,0 +1,157 @@
// Exercises the actual parameterized SQL in store.go against a real
// Postgres -- handler_test.go's fakeStore is hand-written to mimic this
// SQL's behavior, but can't catch a real gap like a typo in a WHERE
// clause, a wrong column name, or (the specific thing worth testing
// here) whether 0040/0041's schema/FK/CHECK constraints actually hold
// the shape this package assumes. Same "skip unless a live-Postgres env
// var is set" convention as api/dashboards/store_integration_test.go.
//
// Skipped unless LOCALAUTH_TEST_POSTGRES_ADDR is set; run via:
//
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/api \
// -e LOCALAUTH_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
// -e LOCALAUTH_TEST_POSTGRES_PASSWORD=sentry-dev-only \
// golang:1.25-alpine go test ./localauth/... -run Integration -v
package localauth
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/authz"
)
func integrationStore(t *testing.T) *Store {
t.Helper()
addr := os.Getenv("LOCALAUTH_TEST_POSTGRES_ADDR")
if addr == "" {
t.Skip("LOCALAUTH_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test")
}
password := os.Getenv("LOCALAUTH_TEST_POSTGRES_PASSWORD")
dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr)
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("opening pool: %v", err)
}
t.Cleanup(pool.Close)
return NewStore(pool)
}
func testUsername(t *testing.T) string {
t.Helper()
return "test-" + uuid.NewString()[:8]
}
func TestIntegrationCreateAndLoginUser(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatalf("hashing password: %v", err)
}
created, err := store.CreateUser(ctx, username, hash, authz.RoleEditor)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, created.ID) })
got, gotHash, err := store.GetUserForLogin(ctx, username)
if err != nil {
t.Fatalf("GetUserForLogin: %v", err)
}
if got.ID != created.ID || got.Role != authz.RoleEditor {
t.Errorf("GetUserForLogin = %+v, want id=%s role=editor", got, created.ID)
}
if !ComparePassword(gotHash, "correct horse battery staple") {
t.Errorf("stored hash does not verify against the original password")
}
}
func TestIntegrationDuplicateUsernameRejected(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
created, err := store.CreateUser(ctx, username, hash, authz.RoleViewer)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, created.ID) })
if _, err := store.CreateUser(ctx, username, hash, authz.RoleViewer); !errors.Is(err, ErrUsernameTaken) {
t.Fatalf("second CreateUser with the same username: err = %v, want ErrUsernameTaken", err)
}
}
func TestIntegrationSessionRoundTripAndExpiry(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
user, err := store.CreateUser(ctx, username, hash, authz.RoleAdmin)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) })
raw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleAdmin, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
sess, err := store.GetSession(ctx, hashToken(raw))
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if sess.UserID != user.ID || sess.Role != authz.RoleAdmin {
t.Errorf("GetSession = %+v, want user_id=%s role=admin", sess, user.ID)
}
// An already-expired session (negative TTL) must not validate --
// exercises the real expires_at comparison against Postgres's own
// now(), not just Go's clock.
expiredRaw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleAdmin, -time.Hour)
if err != nil {
t.Fatalf("CreateSession (expired): %v", err)
}
if _, err := store.GetSession(ctx, hashToken(expiredRaw)); !errors.Is(err, ErrNotFound) {
t.Errorf("GetSession on an expired session: err = %v, want ErrNotFound", err)
}
}
func TestIntegrationSetPasswordHashRevokesSessions(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
user, err := store.CreateUser(ctx, username, hash, authz.RoleViewer)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) })
raw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleViewer, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
newHash, _ := HashPassword("a-new-password")
if err := store.SetPasswordHash(ctx, user.ID, newHash); err != nil {
t.Fatalf("SetPasswordHash: %v", err)
}
if _, err := store.GetSession(ctx, hashToken(raw)); !errors.Is(err, ErrNotFound) {
t.Errorf("GetSession after password reset: err = %v, want ErrNotFound (reset must revoke existing sessions)", err)
}
}
+37
View File
@@ -0,0 +1,37 @@
package localauth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
// newOpaqueToken returns a fresh session credential: raw is what's set
// in the cookie/returned to the caller (base64url, URL/cookie-safe),
// hash is what's stored in local_sessions.token_hash. Only the hash is
// ever persisted -- same reasoning 0034_create_ingest_credentials.sql
// gives for hashing its own bearer tokens: the server only ever needs
// to check "does the presented value match," never recover the raw
// value.
func newOpaqueToken() (raw, hash string, err error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", "", err
}
raw = base64.RawURLEncoding.EncodeToString(buf)
return raw, hashToken(raw), nil
}
// hashToken re-derives a token's hash from a raw value a caller
// presents (Authorization header or cookie), for lookup against
// local_sessions.token_hash. Plain SHA-256, not bcrypt: unlike a
// password, a session token is already high-entropy random data, not
// something an attacker could feasibly brute-force offline even from a
// leaked hash, so there's no need for bcrypt's deliberate slowness here
// -- alerting/internal/sessioncheck validates sessions on every request
// and does the same plain hash, with no bcrypt dependency at all.
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}