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:
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user