Add local login, agent extra log paths, IPv4/IPv6 metrics; remediate security audit findings

This is a large squashed commit covering two batches of prior uncommitted
work plus a full security-audit remediation pass, kept together because
go.mod/go.sum and several shared files (main.go, handler.go) were touched
by both and splitting risked non-building intermediate commits.

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

Security audit remediation (this pass, all live-verified in production):
- Critical: block ClickHouse SSRF table functions (url/remote/file/s3/...)
  in the raw-SQL query escape hatch.
- High: deny sensitive paths and require Admin to add agent
  extra_file_paths (Editor could previously point an agent at /etc/shadow
  or an SSH key); alerting webhook targets now validate against
  internal/metadata/loopback addresses, both at creation and send time;
  alerting's session middleware now enforces an Editor+ floor on
  mutating requests instead of "any authenticated session"; bumped
  goxmldsig to close a SAML signature-verification bypass (GO-2026-4753).
- Medium: per-IP login rate limiting; security response headers
  (HSTS/CSP/nosniff/X-Frame-Options/Referrer-Policy/Permissions-Policy)
  on web/nginx.conf; a DevCredentialWarnings check in every Go service's
  config loader, logging loudly at startup if a deployment is still on
  docker-compose.yml's literal dev-only credentials; dependency bumps
  (golang.org/x/text, grpc, x/net, quick-xml, h2) across every affected
  Go module and both Rust crates, including a previously-uncovered x/net
  vulnerability in deploy/operator; a new security-scan.yml CI workflow
  running cargo-deny/govulncheck/npm-audit, mirroring the existing
  license-compliance.yml matrix shape.
- Low: removed sentryctl's plaintext --password flag (shell
  history/`ps` exposure) in favor of stdin and a --password-stdin flag
  for reset-password's optional specific-password path; a dummy bcrypt
  comparison closes a login response-time username-enumeration
  side-channel.
This commit is contained in:
2026-08-18 23:53:20 -07:00
parent d2bb9de245
commit 4b5dae5879
87 changed files with 5095 additions and 164 deletions
+81
View File
@@ -0,0 +1,81 @@
name: Security scan
# Closes a real gap the security audit found: license-compliance.yml
# (this repo's only other workflow) checks license text, never
# vulnerabilities -- and agent/deny.toml and search/deny.toml already
# ship an [advisories] policy that nothing in CI ever invoked. Same
# matrix-per-language shape as license-compliance.yml, extended to the
# equivalent vulnerability-scanning tool per ecosystem: cargo-deny's
# other command for Rust, govulncheck for Go, npm audit for the one
# npm package. A new dependency with a known vulnerability now fails
# the build here, not months later when someone happens to re-run this
# by hand.
on:
push:
branches: [master, main]
pull_request:
jobs:
rust-advisories:
name: Rust vulnerability check (cargo-deny)
runs-on: ubuntu-latest
strategy:
matrix:
crate_dir: [agent, search]
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2
with:
manifest-path: ${{ matrix.crate_dir }}/Cargo.toml
command: check advisories
go-vulncheck:
name: Go vulnerability check (govulncheck)
runs-on: ubuntu-latest
strategy:
matrix:
# Same module list as license-compliance.yml's go-licenses job --
# see that job's own comment for why cli/hack-webhook-sink/
# hack-alert-load-test are excluded (no third-party dependencies
# at audit time).
module_dir:
- api
- ingest
- alerting
- enterprise
- deploy/operator
- terraform
- proto
- hack/benchmark-fixture
- hack/windows-fixture
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: ${{ matrix.module_dir }}/go.mod
- run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Check for known vulnerabilities
working-directory: ${{ matrix.module_dir }}
run: govulncheck ./...
npm-audit:
name: npm vulnerability check (npm audit)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- working-directory: web
run: npm ci
- name: Audit production dependencies
working-directory: web
# --omit=dev, not the deprecated --production: this deliberately
# only gates the runtime bundle a real deployment actually
# ships. The one known finding in web's full dependency tree
# today (a `cookie` advisory) lives entirely in the SvelteKit
# build toolchain, not the production bundle -- fixing it needs
# a deliberate, tested major-version bump, not an automated
# `audit fix --force`, so it's out of scope for this gate.
run: npm audit --omit=dev
+8 -8
View File
@@ -258,7 +258,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -348,9 +348,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [
"atomic-waker",
"bytes",
@@ -477,7 +477,7 @@ dependencies = [
"hyper",
"libc",
"pin-project-lite",
"socket2 0.6.5",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -737,9 +737,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.36.2"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
@@ -842,7 +842,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1079,7 +1079,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
+6 -1
View File
@@ -8,8 +8,13 @@ WORKDIR /src
COPY proto ./proto
COPY agent ./agent
WORKDIR /src/agent
# file-tail is additive (no extra deps -- see Cargo.toml's [features]
# doc comment) and needed on any host whose logs aren't in journald
# (e.g. nginx's plain access/error log files), so it's built in
# alongside the journald default rather than left as an opt-in most
# deployments would otherwise have to remember to ask for.
RUN rustup target add x86_64-unknown-linux-musl \
&& cargo build --release --target x86_64-unknown-linux-musl -p sentry-agent
&& cargo build --release --target x86_64-unknown-linux-musl -p sentry-agent --features journald,file-tail
FROM scratch
COPY --from=builder /src/agent/target/x86_64-unknown-linux-musl/release/sentry-agent /sentry-agent
+24
View File
@@ -153,6 +153,30 @@ alerting engine already detects natively via an `absence`-condition
alert rule. See `/docs/agent-heartbeat-monitoring.md` for the exact rule
to create.
## Host CPU/memory/disk metrics
Same shape as heartbeat, same reasoning: `[metrics]` in the config
(`enabled = false` by default) sends a periodic record — CPU%, memory
used/total, disk used/total for `/` — tagged `sentry.metrics=true`, with
the individual numbers as their own attributes (`cpu_percent`,
`mem_used_bytes`, `mem_total_bytes`, `disk_used_bytes`,
`disk_total_bytes`), queryable directly (e.g. `cpu_percent > 80`) since
the query language transparently maps any non-standard field name to
`attributes['field']` with automatic numeric casting. Powers the web
UI's "Hosts" nav section. Linux-only for now (`src/metrics.rs`) — reads
`/proc/stat`/`/proc/meminfo` and shells out to `df`, no new
dependencies, same "shell out to a boring, ubiquitous tool" precedent
`journalctl` already sets.
**Enable this on only one agent process per physical host.** It's
common for one host to run several `sentry-agent` processes (one per
log source, each needing its own `[agent] host` value to work around
the `agents` table's `UNIQUE (tenant_id, host)` constraint — see
`/docs/agent-management-design.md`) — turning `[metrics]` on for more
than one of them reports the same physical machine as multiple
different "hosts" with conflicting metric series. Pick the one process
using that host's real, unoverridden hostname.
## Running as a Windows service
"A native Windows service, not a WSL wrapper" means implementing the Win32
+1 -1
View File
@@ -47,7 +47,7 @@ windows = { version = "0.58", features = [
"Win32_Security",
] }
windows-service = "0.7"
quick-xml = "0.36"
quick-xml = "0.41"
[build-dependencies]
tonic-build = "0.12"
@@ -51,6 +51,17 @@ interval = "60s"
# interval = "5m"
# interval = "1h"
[metrics]
# Host CPU/memory/disk usage, sent as its own periodic record the same
# way heartbeat is (see web/'s "Hosts" nav section). Off by default --
# unlike heartbeat, this is a deliberate per-host decision: if several
# sentry-agent processes run on the same physical host (e.g. one per log
# source), enable this on only ONE of them, or the same host will report
# multiple conflicting metric series. Linux-only for now. Root disk ("/")
# only -- not configurable in this release.
enabled = false
interval = "60s"
[ingest]
endpoint = "https://ingest.internal:4317"
+50
View File
@@ -15,6 +15,7 @@ pub struct Config {
pub source: SourceConfig,
pub batch: BatchConfig,
pub heartbeat: HeartbeatConfig,
pub metrics: MetricsConfig,
pub ingest: IngestConfig,
pub tls: TlsConfig,
}
@@ -168,6 +169,31 @@ impl Default for HeartbeatConfig {
}
}
/// Off by default -- same "off unless configured" posture as every other
/// optional feature in this codebase -- since collecting host metrics is
/// a deliberate per-host decision (see /agent/README.md's Hosts-feature
/// notes: only one agent process per physical host should have this on,
/// to avoid duplicate/fragmented metric series when several agent
/// processes share a host under different `[agent] host` overrides).
/// Sent independently of `batch`, same reasoning and mechanism as
/// `HeartbeatConfig` above (see main.rs's `send_metrics`).
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct MetricsConfig {
pub enabled: bool,
#[serde(deserialize_with = "deserialize_duration")]
pub interval: Duration,
}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
enabled: false,
interval: Duration::from_secs(60),
}
}
}
/// Parses a human-friendly duration string with an explicit unit suffix
/// -- "30s", "5m", "1h" -- deliberately the same s/m/h vocabulary
/// `earliest=`/`latest=` use in the query language
@@ -237,6 +263,30 @@ mod heartbeat_config_tests {
}
}
#[cfg(test)]
mod metrics_config_tests {
use super::*;
#[test]
fn default_is_60_seconds_and_disabled() {
let cfg = MetricsConfig::default();
assert!(!cfg.enabled);
assert_eq!(cfg.interval, Duration::from_secs(60));
}
#[test]
fn toml_field_parses_via_deserialize() {
#[derive(Deserialize)]
struct Wrapper {
#[serde(default)]
metrics: MetricsConfig,
}
let w: Wrapper = toml::from_str("[metrics]\nenabled = true\ninterval = \"30s\"\n").unwrap();
assert!(w.metrics.enabled);
assert_eq!(w.metrics.interval, Duration::from_secs(30));
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct IngestConfig {
+149 -11
View File
@@ -1,6 +1,7 @@
mod batch;
mod config;
mod grpc;
mod metrics;
mod source;
#[cfg(windows)]
@@ -22,6 +23,8 @@ use clap::Parser;
use config::Config;
use pb::agent::v1::{agent_control_client::AgentControlClient, AgentCommand, CheckInRequest, DesiredOverride, ReportedConfig};
use pb::{log_ingest_client::LogIngestClient, LogRecord, Severity};
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::mpsc;
@@ -92,12 +95,33 @@ pub async fn run_agent(config_path: Option<PathBuf>) -> Result<()> {
let host = cfg.agent.host.clone().unwrap_or_else(default_hostname);
let service = cfg.agent.service.clone();
// One long-lived channel for the whole process, not one per source
// task -- both the primary source and every extra-file-path task
// (see apply_override's extra_file_paths handling) send into clones
// of the same `tx`. This matters specifically because the primary
// source can be aborted and respawned at runtime (a journald_unit
// override): if each respawn created a *new* channel the way this
// used to work, any extra-file-path task still holding a clone of
// the old `tx` would be silently orphaned (sending into a channel
// whose `rx` had just been replaced/dropped). Creating the channel
// once and only ever swapping the *task* (never the channel) avoids
// that entirely.
let (tx, mut rx) = mpsc::channel(1024);
// source_cfg is mutable: a remote DesiredOverride's journald_unit
// field (see apply_override below) can change it at runtime, which
// means aborting and respawning the source task with the new
// filter -- source_handle/rx are mutable for the same reason.
// filter -- source_handle is mutable for the same reason.
let mut source_cfg = cfg.source.clone();
let (mut source_handle, mut rx) = spawn_source_task(source_cfg.clone());
let mut source_handle = spawn_source_task(source_cfg.clone(), tx.clone());
// Extra file paths an operator has remotely added via the web UI
// (see apply_override's extra_file_paths handling) -- empty until
// the first such override arrives. Keyed by path so a later
// override with a different path list can be diffed against what's
// already running: abort tasks for paths that were removed, spawn
// tasks for paths that are new, leave everything else untouched.
let mut extra_file_tasks: HashMap<PathBuf, tokio::task::JoinHandle<()>> = HashMap::new();
let channel = grpc::connect(&cfg.ingest, &cfg.tls)
.await
@@ -116,6 +140,12 @@ pub async fn run_agent(config_path: Option<PathBuf>) -> Result<()> {
let mut flush_interval = Duration::from_millis(cfg.batch.flush_interval_ms);
let mut heartbeat_enabled = cfg.heartbeat.enabled;
let mut heartbeat_interval = cfg.heartbeat.interval;
// Not remotely overridable (unlike batch/heartbeat above) -- see
// /agent/README.md's Hosts-feature notes on why this is a
// deliberate, per-host, config-file-only decision, not something
// the web UI can flip on for an arbitrary agent.
let metrics_enabled = cfg.metrics.enabled;
let metrics_interval = cfg.metrics.interval;
// Empty until the first override is ever applied -- echoed back on
// every CheckIn as-is so the server can tell "pending" (an edit
// exists this agent hasn't picked up) from "applied."
@@ -134,8 +164,17 @@ pub async fn run_agent(config_path: Option<PathBuf>) -> Result<()> {
// heartbeat_enabled.
let mut heartbeat_ticker = tokio::time::interval(heartbeat_interval.max(Duration::from_millis(50)));
// Unlike heartbeat_ticker, metrics has no secondary purpose (no
// CheckIn-equivalent side effect) to keep it running when disabled,
// so the select! arm below skips it entirely via `if metrics_enabled`
// rather than always firing and conditionally acting.
let mut metrics_ticker = tokio::time::interval(metrics_interval.max(Duration::from_millis(50)));
loop {
tokio::select! {
_ = metrics_ticker.tick(), if metrics_enabled => {
send_metrics(&mut client, &host, &service).await;
}
_ = heartbeat_ticker.tick() => {
if heartbeat_enabled {
send_heartbeat(&mut client, &host, &service).await;
@@ -164,7 +203,8 @@ pub async fn run_agent(config_path: Option<PathBuf>) -> Result<()> {
&mut batch_max_size, &mut flush_interval,
&mut heartbeat_enabled, &mut heartbeat_interval,
&mut batcher, &mut ticker, &mut heartbeat_ticker,
&mut source_cfg, &mut source_handle, &mut rx,
&mut source_cfg, &mut source_handle,
&mut extra_file_tasks, &tx,
&mut client,
).await;
applied_override_version = ov.version.clone();
@@ -272,7 +312,8 @@ async fn apply_override(
heartbeat_ticker: &mut tokio::time::Interval,
source_cfg: &mut config::SourceConfig,
source_handle: &mut tokio::task::JoinHandle<()>,
rx: &mut mpsc::Receiver<source::RawLine>,
extra_file_tasks: &mut HashMap<PathBuf, tokio::task::JoinHandle<()>>,
tx: &source::LineSender,
client: &mut LogIngestClient<Channel>,
) {
if let Some(v) = ov.batch_max_size {
@@ -310,19 +351,57 @@ async fn apply_override(
if *current_unit != new_unit {
*source_cfg = config::SourceConfig::Journald { unit: new_unit };
source_handle.abort();
let (new_handle, new_rx) = spawn_source_task(source_cfg.clone());
*source_handle = new_handle;
*rx = new_rx;
*source_handle = spawn_source_task(source_cfg.clone(), tx.clone());
tracing::info!(unit = ?unit, "applied remote journald unit override, restarted source");
}
}
}
// Extra file paths to tail alongside whatever the primary source
// above is -- see agent_control.proto's DesiredOverride.
// extra_file_paths comment. Diffed against what's already running
// (extra_file_tasks' keys) rather than blindly tearing everything
// down and respawning: an edit to, say, heartbeat_interval_ms must
// never interrupt a file that's already being tailed and hasn't
// changed. `ov.extra_file_paths` is the complete desired list every
// time (never a partial patch -- see the proto field's own doc
// comment), so anything not in it gets removed.
let desired: HashSet<PathBuf> = ov.extra_file_paths.iter().map(PathBuf::from).collect();
let current: HashSet<PathBuf> = extra_file_tasks.keys().cloned().collect();
for removed in current.difference(&desired) {
if let Some(handle) = extra_file_tasks.remove(removed) {
handle.abort();
tracing::info!(path = %removed.display(), "stopped tailing removed extra file path");
}
}
for added in desired.difference(&current) {
extra_file_tasks.insert(added.clone(), spawn_extra_file_task(added.clone(), tx.clone()));
tracing::info!(path = %added.display(), "started tailing new extra file path");
}
}
fn spawn_source_task(source_cfg: config::SourceConfig) -> (tokio::task::JoinHandle<()>, mpsc::Receiver<source::RawLine>) {
let (tx, rx) = mpsc::channel(1024);
let handle = tokio::spawn(spawn_source(source_cfg, tx));
(handle, rx)
fn spawn_source_task(source_cfg: config::SourceConfig, tx: source::LineSender) -> tokio::task::JoinHandle<()> {
tokio::spawn(spawn_source(source_cfg, tx))
}
/// Same shape as `spawn_source`'s own per-source-kind feature gating --
/// an extra file path is really just another `File` source, tailed
/// from the end (no `from_beginning` knob for these; matches the
/// primary `file` source's own sensible default), feeding into the
/// same shared channel every other source in this process uses.
#[cfg_attr(not(feature = "file-tail"), allow(unused_variables))]
fn spawn_extra_file_task(path: PathBuf, tx: source::LineSender) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
#[cfg(feature = "file-tail")]
let result = source::file_tail::run(&path, false, tx).await;
#[cfg(not(feature = "file-tail"))]
let result: Result<(), anyhow::Error> =
Err(anyhow::anyhow!("this build was compiled without the `file-tail` feature"));
if let Err(e) = result {
tracing::error!(error = %e, path = %path.display(), "extra file path exited with error");
}
})
}
fn source_kind_name(cfg: &config::SourceConfig) -> String {
@@ -418,6 +497,65 @@ async fn send_heartbeat(client: &mut LogIngestClient<Channel>, host: &str, servi
}
}
/// Same "no new proto, no new ingest code, no new ClickHouse schema"
/// shape as `send_heartbeat` above -- a metrics sample is just another
/// tagged `LogRecord`, distinguished by the `sentry.metrics` attribute.
/// Unlike heartbeat, the numeric fields themselves are real query-language
/// attributes too (`cpu_percent`, `mem_used_bytes`, etc.) rather than
/// being folded into `message` -- confirmed before building this that
/// arbitrary attribute names are transparently queryable/comparable as
/// numbers (`api/querylang/executor/sql.go`'s `topLevelFields` fallback
/// to `attributes['field']` with automatic numeric casting), so there's
/// no need to encode them as a JSON blob in `message` and parse it
/// client-side instead.
async fn send_metrics(client: &mut LogIngestClient<Channel>, host: &str, service: &str) {
let m = match metrics::collect("/").await {
Ok(m) => m,
Err(e) => {
tracing::warn!(error = %e, host, "collecting host metrics failed");
return;
}
};
let record = LogRecord {
timestamp_unix_nano: now_unix_nanos(),
host: host.to_string(),
service: service.to_string(),
severity: Severity::Info as i32,
message: "host metrics".to_string(),
attributes: std::collections::HashMap::from([
("sentry.metrics".to_string(), "true".to_string()),
("cpu_percent".to_string(), format!("{:.2}", m.cpu_percent)),
("mem_used_bytes".to_string(), m.mem_used_bytes.to_string()),
("mem_total_bytes".to_string(), m.mem_total_bytes.to_string()),
("disk_used_bytes".to_string(), m.disk_used_bytes.to_string()),
("disk_total_bytes".to_string(), m.disk_total_bytes.to_string()),
// Static-or-slow-changing context, not utilization numbers --
// sent on the same record so a viewer never has to correlate
// two different samples to make sense of the numbers above
// (see metrics::Metrics's doc comment).
("cpu_cores".to_string(), m.cpu_cores.to_string()),
("os_name".to_string(), m.os_name.clone()),
("kernel_version".to_string(), m.kernel_version.clone()),
("arch".to_string(), m.arch.to_string()),
("uptime_seconds".to_string(), m.uptime_seconds.to_string()),
// Comma-joined -- LogRecord attributes are string-valued,
// and a host can have more than one address per family
// (multi-NIC, or a v6 privacy/temporary address alongside
// the stable one). Empty string, not an omitted key, when
// a host genuinely has none of a given family -- matches
// every other soft-failed context field here (see
// metrics::collect's unwrap_or_default for this one).
("ipv4_addresses".to_string(), m.ipv4_addresses.join(",")),
("ipv6_addresses".to_string(), m.ipv6_addresses.join(",")),
]),
record_id: String::new(),
};
match grpc::send_batch(client, format!("metrics-{}", batch_id()), vec![record]).await {
Ok(_) => tracing::debug!(host, "metrics sent"),
Err(e) => tracing::warn!(error = %e, host, "metrics send failed"),
}
}
fn now_unix_nanos() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+394
View File
@@ -0,0 +1,394 @@
use anyhow::{Context, Result};
use std::fs;
use std::process::Command;
use std::time::Duration;
pub struct Metrics {
pub cpu_percent: f64,
pub mem_used_bytes: u64,
pub mem_total_bytes: u64,
pub disk_used_bytes: u64,
pub disk_total_bytes: u64,
/// The rest of these are static-or-slow-changing context, not
/// utilization numbers -- sent alongside the utilization fields on
/// the same record (rather than as a separate one-off record) so a
/// viewer never has to correlate two different samples to answer
/// "is 21% CPU busy or idle for this box" (needs core count) or
/// "is this usage normal" (needs how long it's been running).
pub cpu_cores: u32,
pub os_name: String,
pub kernel_version: String,
pub arch: &'static str,
pub uptime_seconds: u64,
/// Non-loopback, non-link-local addresses only -- a host's `fe80::/10`
/// and `127.0.0.1`/`::1` are never what a viewer means by "this
/// host's IP", and would just add noise. Sorted and deduplicated,
/// but otherwise unfiltered: a multi-NIC host reports every address
/// it has, not just one "primary" guess (there's no reliable way to
/// pick a single "the" address from userspace without also knowing
/// which interface actually carries this host's traffic).
pub ipv4_addresses: Vec<String>,
pub ipv6_addresses: Vec<String>,
}
/// Collects a point-in-time snapshot of host resource usage plus the
/// system context needed to make it legible. Linux-only for now (a
/// disclosed gap, not a silent assumption -- see /agent/README.md):
/// every host this has been deployed to so far is Linux, and a Windows
/// implementation (perf counters/WMI) is real future work, not
/// attempted here.
pub async fn collect(disk_path: &str) -> Result<Metrics> {
let cpu_percent = cpu_percent().await.context("reading CPU usage")?;
let (mem_used_bytes, mem_total_bytes) = memory().context("reading memory usage")?;
let (disk_used_bytes, disk_total_bytes) = disk(disk_path).context("reading disk usage")?;
// Soft-fail on all four: none of these should ever cost a whole
// sample (losing real cpu_percent/mem/disk numbers) just because
// e.g. /etc/os-release is missing on some minimal distro --
// consistent with this codebase's existing "an optional feature's
// failure must never take down the thing it's supplementing"
// posture (see send_heartbeat/CheckIn's own graceful degradation).
let cpu_cores = cpu_cores().unwrap_or(0);
let os_name = os_name().unwrap_or_else(|_| "unknown".to_string());
let kernel_version = kernel_version().unwrap_or_else(|_| "unknown".to_string());
let uptime_seconds = uptime_seconds().unwrap_or(0);
let (ipv4_addresses, ipv6_addresses) = ip_addresses().unwrap_or_default();
Ok(Metrics {
cpu_percent,
mem_used_bytes,
mem_total_bytes,
disk_used_bytes,
disk_total_bytes,
cpu_cores,
os_name,
kernel_version,
arch: std::env::consts::ARCH,
uptime_seconds,
ipv4_addresses,
ipv6_addresses,
})
}
/// Two `/proc/stat` samples ~200ms apart, delta-based -- the standard
/// technique every `top`-like tool uses, since a single snapshot of
/// cumulative jiffies-since-boot can't express a percentage on its own.
/// Self-contained (no state threaded through main.rs's `select!` loop)
/// at the cost of blocking this one `collect()` call for ~200ms once
/// per `metrics.interval` tick -- an acceptable trade against the
/// complexity of holding a previous-sample struct across ticks in an
/// already-busy loop, for a feature that only runs once a minute by
/// default.
async fn cpu_percent() -> Result<f64> {
let (total1, idle1) = read_proc_stat()?;
tokio::time::sleep(Duration::from_millis(200)).await;
let (total2, idle2) = read_proc_stat()?;
let total_delta = total2.saturating_sub(total1);
let idle_delta = idle2.saturating_sub(idle1);
if total_delta == 0 {
return Ok(0.0);
}
Ok((1.0 - (idle_delta as f64 / total_delta as f64)) * 100.0)
}
fn read_proc_stat() -> Result<(u64, u64)> {
let contents = fs::read_to_string("/proc/stat").context("reading /proc/stat")?;
parse_proc_stat(&contents)
}
/// Parses `/proc/stat`'s leading "cpu " line: user nice system idle
/// iowait irq softirq steal guest guest_nice, all in USER_HZ jiffies
/// since boot. Returns (total, idle) -- idle here is idle+iowait,
/// matching what every standard CPU%-from-/proc/stat implementation
/// treats as "not busy" (iowait is a CPU waiting on I/O, not doing
/// work, even though the kernel's own `idle` field alone doesn't
/// include it).
fn parse_proc_stat(contents: &str) -> Result<(u64, u64)> {
let line = contents
.lines()
.find(|l| l.starts_with("cpu "))
.context("/proc/stat has no leading \"cpu \" line")?;
let fields: Vec<u64> = line.split_whitespace().skip(1).filter_map(|f| f.parse().ok()).collect();
if fields.len() < 4 {
anyhow::bail!("unexpected /proc/stat format: {line:?}");
}
let idle = fields[3] + fields.get(4).copied().unwrap_or(0);
let total: u64 = fields.iter().sum();
Ok((total, idle))
}
fn memory() -> Result<(u64, u64)> {
let contents = fs::read_to_string("/proc/meminfo").context("reading /proc/meminfo")?;
parse_meminfo(&contents)
}
/// Parses `/proc/meminfo`'s MemTotal/MemAvailable (kB). MemAvailable
/// (not MemFree) is the kernel's own "how much could a new process
/// actually get" estimate, accounting for reclaimable caches/buffers --
/// what a human means by "memory used" far better than MemFree alone
/// (a system with most of RAM in disk cache but MemFree near zero is
/// not actually under memory pressure).
fn parse_meminfo(contents: &str) -> Result<(u64, u64)> {
let mut total_kb = None;
let mut available_kb = None;
for line in contents.lines() {
if let Some(v) = line.strip_prefix("MemTotal:") {
total_kb = parse_meminfo_kb(v);
} else if let Some(v) = line.strip_prefix("MemAvailable:") {
available_kb = parse_meminfo_kb(v);
}
}
let total_kb = total_kb.context("MemTotal not found in /proc/meminfo")?;
let available_kb = available_kb.context("MemAvailable not found in /proc/meminfo")?;
let used_kb = total_kb.saturating_sub(available_kb);
Ok((used_kb * 1024, total_kb * 1024))
}
fn parse_meminfo_kb(s: &str) -> Option<u64> {
s.trim().trim_end_matches("kB").trim().parse().ok()
}
fn disk(path: &str) -> Result<(u64, u64)> {
let output = Command::new("df").arg("-B1").arg(path).output().context("running df")?;
if !output.status.success() {
anyhow::bail!("df exited with status {}: {}", output.status, String::from_utf8_lossy(&output.stderr));
}
parse_df_output(&String::from_utf8_lossy(&output.stdout))
}
/// Shells out to `df` rather than linking a statvfs binding -- same
/// "shell out to a boring, ubiquitous tool rather than add a dependency
/// or FFI binding" precedent `source/journald.rs` already sets for
/// `journalctl` (see /agent/README.md's "Why journalctl, not
/// libsystemd"). `-B1` requests byte-granularity output instead of the
/// default 1K-block units, so no unit conversion is needed here. Total
/// is `used + available`, not the raw block count `df` also reports --
/// some filesystems (ext4's default ~5% root reservation) hold back
/// blocks a normal process can never use, which would make a "percent
/// full" computed against the raw total look artificially low; `used +
/// available` matches what `df`'s own `Use%` column is computed
/// against.
fn parse_df_output(stdout: &str) -> Result<(u64, u64)> {
let data_line = stdout.lines().nth(1).context("df produced no data line")?;
let fields: Vec<&str> = data_line.split_whitespace().collect();
// Filesystem, 1B-blocks, Used, Available, Use%, Mounted on
if fields.len() < 4 {
anyhow::bail!("unexpected df output: {data_line:?}");
}
let used: u64 = fields[2].parse().context("parsing df's Used column")?;
let available: u64 = fields[3].parse().context("parsing df's Available column")?;
Ok((used, used + available))
}
fn cpu_cores() -> Result<u32> {
let contents = fs::read_to_string("/proc/cpuinfo").context("reading /proc/cpuinfo")?;
parse_cpuinfo_core_count(&contents)
}
/// Counts `processor\t: N` lines in `/proc/cpuinfo` -- one per logical
/// CPU (a hyperthreaded core counts as two, same as what `nproc`/every
/// scheduler-facing tool means by "CPU count"), which is what
/// `cpu_percent`'s 0-100 scale is an average across.
fn parse_cpuinfo_core_count(contents: &str) -> Result<u32> {
let n = contents.lines().filter(|l| l.starts_with("processor")).count() as u32;
if n == 0 {
anyhow::bail!("no \"processor\" lines found in /proc/cpuinfo");
}
Ok(n)
}
fn os_name() -> Result<String> {
let contents = fs::read_to_string("/etc/os-release").context("reading /etc/os-release")?;
parse_os_release_pretty_name(&contents)
}
/// Parses `/etc/os-release`'s `PRETTY_NAME="..."` line (e.g. "Debian
/// GNU/Linux 13 (trixie)") -- the one field every distro's os-release
/// is guaranteed to carry for exactly this "show a human a readable OS
/// name" purpose (see os-release(5)).
fn parse_os_release_pretty_name(contents: &str) -> Result<String> {
contents
.lines()
.find_map(|l| l.strip_prefix("PRETTY_NAME="))
.map(|v| v.trim().trim_matches('"').to_string())
.context("PRETTY_NAME not found in /etc/os-release")
}
/// `/proc/sys/kernel/osrelease` is just the bare version string (e.g.
/// "6.12.90+deb13.1-amd64") with no parsing needed -- simpler and more
/// robust than picking the version back out of `/proc/version`'s
/// free-form `uname -a`-style sentence.
fn kernel_version() -> Result<String> {
Ok(fs::read_to_string("/proc/sys/kernel/osrelease")
.context("reading /proc/sys/kernel/osrelease")?
.trim()
.to_string())
}
fn uptime_seconds() -> Result<u64> {
let contents = fs::read_to_string("/proc/uptime").context("reading /proc/uptime")?;
parse_uptime(&contents)
}
/// `/proc/uptime`'s first field is seconds since boot (as a float, to
/// centisecond precision) -- the second field (total idle time summed
/// across all cores) isn't relevant here.
fn parse_uptime(contents: &str) -> Result<u64> {
let first = contents.split_whitespace().next().context("/proc/uptime is empty")?;
let seconds: f64 = first.parse().context("parsing /proc/uptime's first field")?;
Ok(seconds as u64)
}
fn ip_addresses() -> Result<(Vec<String>, Vec<String>)> {
let output = Command::new("ip").arg("-o").arg("addr").arg("show").output().context("running ip addr show")?;
if !output.status.success() {
anyhow::bail!("ip exited with status {}: {}", output.status, String::from_utf8_lossy(&output.stderr));
}
Ok(parse_ip_addr_output(&String::from_utf8_lossy(&output.stdout)))
}
/// Shells out to `ip -o addr show` -- same "boring, ubiquitous tool"
/// precedent `disk`'s `df` call and `source/journald.rs`'s `journalctl`
/// call already set, over an FFI binding to `getifaddrs(3)`. `-o`
/// (oneline) puts each address on its own line, e.g.:
/// 2: eth0 inet 172.239.44.244/24 brd ... scope global eth0\ ...
/// 2: eth0 inet6 fe80::1/64 scope link \ ...
/// Skips the loopback interface by name (`lo`) and any address whose
/// line mentions `scope link` (IPv6 link-local, `fe80::/10`) or
/// `scope host` (loopback addresses `ip` sometimes reports even on a
/// non-`lo` line) -- neither is what a viewer means by "this host's
/// IP". Field 1 is the interface name, field 2 is the address family
/// (`inet`/`inet6`), field 3 is `address/prefix-length`.
fn parse_ip_addr_output(stdout: &str) -> (Vec<String>, Vec<String>) {
let mut v4 = Vec::new();
let mut v6 = Vec::new();
for line in stdout.lines() {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 4 {
continue;
}
let iface = fields[1];
if iface == "lo" || line.contains("scope link") || line.contains("scope host") {
continue;
}
let addr = fields[3].split('/').next().unwrap_or(fields[3]);
match fields[2] {
"inet" => v4.push(addr.to_string()),
"inet6" => v6.push(addr.to_string()),
_ => {}
}
}
v4.sort();
v4.dedup();
v6.sort();
v6.dedup();
(v4, v6)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_proc_stat() {
let contents = "cpu 100 0 50 800 20 0 0 0 0 0\ncpu0 100 0 50 800 20 0 0 0 0 0\n";
let (total, idle) = parse_proc_stat(contents).unwrap();
// total = 100+0+50+800+20 = 970; idle = 800 (idle) + 20 (iowait) = 820
assert_eq!(total, 970);
assert_eq!(idle, 820);
}
#[test]
fn rejects_proc_stat_with_no_cpu_line() {
assert!(parse_proc_stat("not cpu data\n").is_err());
}
#[test]
fn parses_meminfo() {
let contents = "MemTotal: 16384000 kB\nMemFree: 1000000 kB\nMemAvailable: 8192000 kB\n";
let (used, total) = parse_meminfo(contents).unwrap();
assert_eq!(total, 16384000 * 1024);
assert_eq!(used, (16384000 - 8192000) * 1024);
}
#[test]
fn rejects_meminfo_missing_fields() {
assert!(parse_meminfo("MemTotal: 16384000 kB\n").is_err());
}
#[test]
fn parses_df_output() {
let stdout = "Filesystem 1B-blocks Used Available Use% Mounted on\n/dev/sda1 80000000000 20000000000 60000000000 25% /\n";
let (used, total) = parse_df_output(stdout).unwrap();
assert_eq!(used, 20000000000);
assert_eq!(total, 20000000000 + 60000000000);
}
#[test]
fn rejects_df_output_with_no_data_line() {
assert!(parse_df_output("Filesystem 1B-blocks Used Available Use% Mounted on\n").is_err());
}
#[test]
fn counts_cpuinfo_processors() {
let contents = "processor\t: 0\nmodel name\t: x\n\nprocessor\t: 1\nmodel name\t: x\n";
assert_eq!(parse_cpuinfo_core_count(contents).unwrap(), 2);
}
#[test]
fn rejects_cpuinfo_with_no_processor_lines() {
assert!(parse_cpuinfo_core_count("model name: x\n").is_err());
}
#[test]
fn parses_os_release_pretty_name() {
let contents = "NAME=\"Debian GNU/Linux\"\nPRETTY_NAME=\"Debian GNU/Linux 13 (trixie)\"\nVERSION_ID=\"13\"\n";
assert_eq!(parse_os_release_pretty_name(contents).unwrap(), "Debian GNU/Linux 13 (trixie)");
}
#[test]
fn rejects_os_release_missing_pretty_name() {
assert!(parse_os_release_pretty_name("NAME=\"Debian\"\n").is_err());
}
#[test]
fn parses_uptime() {
assert_eq!(parse_uptime("12345.67 98765.43\n").unwrap(), 12345);
}
#[test]
fn rejects_empty_uptime() {
assert!(parse_uptime("").is_err());
}
#[test]
fn parses_ip_addr_output_excluding_loopback_and_link_local() {
let stdout = concat!(
"1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever preferred_lft forever\n",
"1: lo inet6 ::1/128 scope host \\ valid_lft forever preferred_lft forever\n",
"2: eth0 inet 172.239.44.244/24 brd 172.239.44.255 scope global eth0\\ valid_lft forever preferred_lft forever\n",
"2: eth0 inet6 2600:3c06::1/64 scope global dynamic mngtmpaddr noprefixroute \\ valid_lft forever preferred_lft forever\n",
"2: eth0 inet6 fe80::abcd/64 scope link \\ valid_lft forever preferred_lft forever\n",
);
let (v4, v6) = parse_ip_addr_output(stdout);
assert_eq!(v4, vec!["172.239.44.244".to_string()]);
assert_eq!(v6, vec!["2600:3c06::1".to_string()]);
}
#[test]
fn parses_ip_addr_output_dedupes_and_sorts_multiple_interfaces() {
let stdout = concat!(
"2: eth0 inet 10.0.0.5/24 scope global eth0\\ valid_lft forever preferred_lft forever\n",
"3: eth1 inet 10.0.0.2/24 scope global eth1\\ valid_lft forever preferred_lft forever\n",
"3: eth1 inet 10.0.0.5/24 scope global secondary eth1\\ valid_lft forever preferred_lft forever\n",
);
let (v4, _v6) = parse_ip_addr_output(stdout);
assert_eq!(v4, vec!["10.0.0.2".to_string(), "10.0.0.5".to_string()]);
}
#[test]
fn parses_ip_addr_output_with_no_addresses() {
let (v4, v6) = parse_ip_addr_output("1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever preferred_lft forever\n");
assert!(v4.is_empty());
assert!(v6.is_empty());
}
}
+19 -1
View File
@@ -29,6 +29,7 @@ import (
"github.com/sentry/sentry/alerting/internal/notifystore"
"github.com/sentry/sentry/alerting/internal/queryclient"
"github.com/sentry/sentry/alerting/internal/rulestore"
"github.com/sentry/sentry/alerting/internal/sessioncheck"
)
func main() {
@@ -39,6 +40,9 @@ func main() {
logger.Error("loading config", "error", err)
os.Exit(1)
}
for _, w := range cfg.DevCredentialWarnings() {
logger.Warn(w)
}
// -healthcheck: self-check mode for Docker's HEALTHCHECK, mirrors
// api/cmd/api/main.go's runHealthcheck -- this image is distroless too
@@ -71,9 +75,23 @@ func main() {
handler := httpapi.NewHandler(logger, rules, targets, rules)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
// Local login (see /docs -- deployment runbook, and
// api/localauth's package doc comment for the full feature):
// alerting has no per-route role plumbing of its own, so this is one
// blanket "must have a valid session" gate in front of the whole
// mux, same shape CORS already wraps it in below. /healthz stays
// reachable regardless -- see sessioncheck.RequireSession's doc
// comment.
var gatedMux http.Handler = mux
corsFn := httpserver.WithCORS
if cfg.LocalAuthEnabled {
gatedMux = sessioncheck.RequireSession(sessioncheck.NewChecker(pgPool), mux)
corsFn = httpserver.WithCredentialedCORS
}
srv := &http.Server{
Addr: cfg.HTTPListenAddr,
Handler: httpserver.WithCORS(mux, cfg.CORSAllowedOrigin),
Handler: corsFn(gatedMux, cfg.CORSAllowedOrigin),
}
eval := evaluator.New(rules, targets, qc, cfg.Evaluator.QueryTimeout, cfg.Evaluator.ClaimBatchSize, cfg.Evaluator.WorkerPoolSize, logger)
+1 -1
View File
@@ -12,5 +12,5 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/text v0.39.0 // indirect
)
+2 -2
View File
@@ -20,8 +20,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+30
View File
@@ -16,6 +16,11 @@ type Config struct {
APIServiceToken string // RoleService credential presented to /api's POST /query -- see queryclient.New's doc comment
CORSAllowedOrigin string
Evaluator EvaluatorConfig
// LocalAuthEnabled gates alerting's own session-required middleware
// (see internal/sessioncheck) -- same env var name as api's
// LOCAL_AUTH_ENABLED, one consistent on/off switch across both
// services for a single-tenant deployment turning local login on.
LocalAuthEnabled bool
}
type PostgresConfig struct {
@@ -43,6 +48,25 @@ type EvaluatorConfig struct {
QueryTimeout time.Duration // per-evaluation POST /query timeout
}
// devOnlyCredential is docker-compose.yml's zero-config default for
// every Postgres/ClickHouse password in this repo -- see
// api/internal/config.Config.DevCredentialWarnings for the full
// reasoning (duplicated here per this repo's no-shared-code-between-
// services convention).
const devOnlyCredential = "sentry-dev-only"
// DevCredentialWarnings reports whether the configured Postgres
// credential still equals the literal dev-only default --
// cmd/alerting/main.go logs it loudly at startup. A warning, not a
// startup-refusing error: local dev's zero-config docker-compose.yml
// path legitimately leaves it at this value.
func (c Config) DevCredentialWarnings() []string {
if c.Postgres.Password == devOnlyCredential {
return []string{"POSTGRES_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev"}
}
return nil
}
func Load() (Config, error) {
cfg := Config{
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8081"),
@@ -90,6 +114,12 @@ func Load() (Config, error) {
}
cfg.Evaluator.QueryTimeout = time.Duration(queryTimeoutSec) * time.Second
localAuthEnabled, err := strconv.ParseBool(getenv("LOCAL_AUTH_ENABLED", "false"))
if err != nil {
return Config{}, fmt.Errorf("LOCAL_AUTH_ENABLED: %w", err)
}
cfg.LocalAuthEnabled = localAuthEnabled
return cfg, nil
}
+11
View File
@@ -162,6 +162,17 @@ func (w *Worker) attempt(ctx context.Context, c claimedDelivery) {
return
}
// Re-validated here, not just at target-creation time
// (httpapi.handleCreateTarget already checks this too): a hostname
// that resolved to a public IP when the target was created can be
// repointed at an internal/metadata address later via DNS rebinding,
// and this is the point that actually issues the outbound request --
// see notifystore.ValidateWebhookURL's doc comment.
if err := notifystore.ValidateWebhookURL(target.WebhookURL); err != nil {
w.fail(ctx, c, 0, fmt.Sprintf("webhook_url no longer valid: %v", err))
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.WebhookURL, bytes.NewReader(c.payload))
if err != nil {
w.fail(ctx, c, 0, fmt.Sprintf("building request: %v", err))
+4
View File
@@ -151,6 +151,10 @@ func (h *Handler) handleCreateTarget(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "webhook_url must not be empty")
return
}
if err := notifystore.ValidateWebhookURL(target.WebhookURL); err != nil {
writeError(w, http.StatusBadRequest, "webhook_url: "+err.Error())
return
}
if err := h.targets.Create(r.Context(), &target); err != nil {
h.logger.Error("creating notification target", "error", err)
writeError(w, http.StatusInternalServerError, "creating notification target failed")
+18 -1
View File
@@ -231,12 +231,29 @@ func TestCreateTargetRejectsInvalidKind(t *testing.T) {
func TestCreateSlackTarget(t *testing.T) {
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodPost, "/targets", `{"name": "oncall", "kind": "slack", "webhook_url": "https://hooks.slack.com/services/x"}`)
// A literal public IP, not a real hostname like hooks.slack.com --
// ValidateWebhookURL (see notifystore/ssrf.go) now resolves the
// target host and rejects internal/metadata addresses, so this test
// stays deterministic without depending on live DNS; ssrf_test.go
// covers the validation logic itself in depth.
rec := doRequest(t, mux, http.MethodPost, "/targets", `{"name": "oncall", "kind": "slack", "webhook_url": "https://8.8.8.8/services/x"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
}
}
// TestCreateTargetRejectsSSRFWebhookURL is the regression test for the
// security-audit finding that target creation performed no URL
// validation at all -- any authenticated user could point a webhook at
// an internal or cloud-metadata address.
func TestCreateTargetRejectsSSRFWebhookURL(t *testing.T) {
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodPost, "/targets", `{"name": "x", "kind": "webhook", "webhook_url": "http://169.254.169.254/latest/meta-data/"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestListDeliveriesForRule(t *testing.T) {
deliveries := &fakeDeliveryReader{entries: []rulestore.DeliveryLogEntry{
{ID: 1, RuleID: "rule-1", EventType: "firing", Status: "sent"},
+24
View File
@@ -22,3 +22,27 @@ func WithCORS(next http.Handler, allowedOrigin string) http.Handler {
next.ServeHTTP(w, r)
})
}
// WithCredentialedCORS is WithCORS's sibling for local login (see
// /docs -- deployment runbook): once alerting requires a session
// cookie/bearer token (sessioncheck.RequireSession), a browser calling
// it cross-origin must send credentials, and browsers categorically
// refuse to combine a credentialed request with
// Access-Control-Allow-Origin: "*" -- allowedOrigin must be a real,
// literal origin, not the wildcard WithCORS's own zero-config default
// relies on. Deliberately duplicated from api/httpserver's identical
// function rather than shared, same convention as WithCORS's own doc
// comment above.
func WithCredentialedCORS(next http.Handler, allowedOrigin string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+64
View File
@@ -0,0 +1,64 @@
package notifystore
import (
"fmt"
"net"
"net/url"
)
// ValidateWebhookURL rejects a notification target URL that resolves to
// an internal, loopback, link-local, or cloud-metadata address --
// closes an SSRF path a security audit found: without this, any user
// who could create a notification target could point
// delivery.Worker.attempt's outbound POST at
// http://169.254.169.254/... or an internal service address, and read
// back what happened via delivery_log's recorded status code -- a
// semi-blind SSRF oracle. Applies to all three Kind values (webhook,
// slack, pagerduty), since all three deliver through the same
// WebhookURL-addressed POST -- see webhook.go's package doc comment.
//
// Callers should invoke this both at target-creation time
// (httpapi.handleCreateTarget) and again immediately before every
// delivery attempt (delivery.Worker.attempt): a hostname that resolved
// to a public IP at creation time can be repointed at an internal one
// later (DNS rebinding), so checking only once would leave that gap
// open.
func ValidateWebhookURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("URL scheme must be http or https, got %q", u.Scheme)
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("URL must have a host")
}
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("resolving host %q: %w", host, err)
}
if len(ips) == 0 {
return fmt.Errorf("host %q did not resolve to any address", host)
}
for _, ip := range ips {
if isDisallowedWebhookTarget(ip) {
return fmt.Errorf("host %q resolves to %s, a disallowed address -- internal, loopback, link-local, and cloud-metadata addresses are not allowed as notification target URLs", host, ip)
}
}
return nil
}
// isDisallowedWebhookTarget covers RFC1918/RFC4193 private ranges,
// loopback, link-local (which also covers 169.254.169.254, the AWS/GCP/
// Azure instance-metadata address), unspecified, and multicast.
func isDisallowedWebhookTarget(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsPrivate() ||
ip.IsUnspecified() ||
ip.IsMulticast()
}
@@ -0,0 +1,48 @@
package notifystore
import "testing"
// Uses literal IP addresses throughout, not real hostnames -- net.LookupIP
// resolves a literal IP without a network round trip, so these tests stay
// deterministic and fast in any environment, including one with no DNS/
// network access.
func TestValidateWebhookURLRejectsInternalAndMetadataAddresses(t *testing.T) {
disallowed := []string{
"http://169.254.169.254/latest/meta-data/", // cloud instance metadata
"http://127.0.0.1:8123/", // loopback -- e.g. ClickHouse
"http://10.0.0.5:5432/", // RFC1918
"http://172.17.0.2:9092/", // RFC1918 (default docker bridge range)
"http://192.168.1.1/", // RFC1918
"http://[::1]/", // IPv6 loopback
"http://[fe80::1]/", // IPv6 link-local
"http://[fc00::1]/", // IPv6 unique local (RFC4193)
"http://0.0.0.0/", // unspecified
}
for _, u := range disallowed {
if err := ValidateWebhookURL(u); err == nil {
t.Errorf("ValidateWebhookURL(%q): want error, got nil", u)
}
}
}
func TestValidateWebhookURLAllowsPublicAddress(t *testing.T) {
// A real-looking public IP literal, not a hostname needing DNS.
if err := ValidateWebhookURL("https://8.8.8.8/webhook"); err != nil {
t.Errorf("ValidateWebhookURL on a public IP: err = %v, want nil", err)
}
}
func TestValidateWebhookURLRejectsBadScheme(t *testing.T) {
cases := []string{"ftp://8.8.8.8/", "file:///etc/passwd", "not-a-url"}
for _, u := range cases {
if err := ValidateWebhookURL(u); err == nil {
t.Errorf("ValidateWebhookURL(%q): want error, got nil", u)
}
}
}
func TestValidateWebhookURLRejectsEmptyHost(t *testing.T) {
if err := ValidateWebhookURL("http:///path"); err == nil {
t.Error("ValidateWebhookURL with no host: want error, got nil")
}
}
@@ -0,0 +1,91 @@
package sessioncheck
import (
"encoding/json"
"net/http"
)
// sessionCookieName must match api/localauth's sessionCookieName
// exactly (unexported there too, deliberately duplicated rather than
// imported -- see this package's doc comment) -- the same cookie
// api/localauth.Handler.setCookie writes, scoped (via SESSION_COOKIE_
// DOMAIN) to cover both api's and alerting's subdomains in production.
const sessionCookieName = "sentry_local_session"
type errorResponse struct {
Error string `json:"error"`
}
func writeUnauthorized(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(errorResponse{Error: "unauthorized"})
}
func writeForbidden(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(errorResponse{Error: "forbidden"})
}
// mutatingRoleFloor is the minimum role RequireSession enforces for any
// non-read request -- closes a real gap the security audit found: this
// package used to be a pure "logged in or not" gate with no role check
// at all, meaning a Viewer-role session could create/delete alert rules
// and notification targets exactly like an Editor. GET/HEAD (read-only)
// stay at "any valid session," matching every role floor in this
// codebase's other RBAC-gated resources (queries, dashboards) using
// Viewer as their read bar.
const mutatingRoleFloor = "editor"
func isReadOnly(method string) bool {
return method == http.MethodGet || method == http.MethodHead
}
func credentialFromRequest(r *http.Request) string {
if auth := r.Header.Get("Authorization"); auth != "" {
const prefix = "Bearer "
if len(auth) > len(prefix) && auth[:len(prefix)] == prefix {
return auth[len(prefix):]
}
}
if cookie, err := r.Cookie(sessionCookieName); err == nil {
return cookie.Value
}
return ""
}
// RequireSession wraps next so every request needs a valid local-login
// session -- a blanket gate, not per-route roles: alerting has no
// role-check plumbing at all today (unlike api/authz's per-route
// RequireRole), and building a full parallel system just for this
// feature is out of scope (see /docs/agent-management-design.md-style
// "resist scope creep" discipline this codebase applies everywhere).
// GET /healthz is deliberately exempt -- Docker's HEALTHCHECK execs
// this same binary against itself over loopback (cmd/alerting/main.go's
// runHealthcheck), pre-auth, and must keep working regardless of
// whether local auth is enabled.
func RequireSession(checker *Checker, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
raw := credentialFromRequest(r)
if raw == "" {
writeUnauthorized(w)
return
}
role, err := checker.Validate(r.Context(), raw)
if err != nil {
writeUnauthorized(w)
return
}
if !isReadOnly(r.Method) && !roleSatisfies(role, mutatingRoleFloor) {
writeForbidden(w)
return
}
next.ServeHTTP(w, r)
})
}
@@ -0,0 +1,73 @@
// Package sessioncheck is alerting's half of local login (see
// api/localauth's package doc comment for the full feature). It only
// ever validates an already-issued session against the shared
// local_sessions table api/localauth writes to (same Postgres, no Go
// import) -- it never handles a raw password, never creates a session,
// and has no user-management surface at all; that stays exclusively in
// api. Deliberately its own small package rather than an import of
// api/localauth: this repo's hard, documented convention is no shared
// Go store/HTTP code between api and alerting, only /proto (see
// alerting/internal/httpserver/cors.go's WithCORS doc comment) --
// duplicating this one hash-and-look-up check is a small, low-risk
// price for keeping that boundary real.
package sessioncheck
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrInvalidSession = errors.New("sessioncheck: invalid or expired session")
type Checker struct {
pool *pgxpool.Pool
}
func NewChecker(pool *pgxpool.Pool) *Checker {
return &Checker{pool: pool}
}
// roleRank duplicates api/authz.Role's rank table -- same "no shared Go
// code between api and alerting" boundary this package's doc comment
// already explains for hashToken, applied to the one extra column
// (role) middleware.go now needs to enforce a floor on mutating
// requests (see RequireSession).
var roleRank = map[string]int{"viewer": 1, "editor": 2, "admin": 3, "owner": 4}
// roleSatisfies reports whether role meets minRole on the same
// Viewer<Editor<Admin<Owner scale api/authz.Role.Satisfies uses.
func roleSatisfies(role, minRole string) bool {
return roleRank[role] >= roleRank[minRole]
}
// Validate hashes raw (plain SHA-256, no bcrypt -- see
// api/localauth/token.go's hashToken doc comment for why a session
// token doesn't need bcrypt's deliberate slowness) and checks it
// against local_sessions, returning the session's role snapshot
// alongside. Returns ErrInvalidSession for both "no such session" and
// "expired" -- middleware.go's caller doesn't distinguish them either,
// same posture api/localauth.Store.GetSession already takes for the
// same two cases.
func (c *Checker) Validate(ctx context.Context, raw string) (role string, err error) {
sum := sha256.Sum256([]byte(raw))
hash := hex.EncodeToString(sum[:])
var expiresAt time.Time
err = c.pool.QueryRow(ctx, `SELECT role, expires_at FROM local_sessions WHERE token_hash = $1`, hash).Scan(&role, &expiresAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrInvalidSession
}
return "", err
}
if expiresAt.Before(time.Now()) {
return "", ErrInvalidSession
}
return role, nil
}
@@ -0,0 +1,182 @@
// Exercises Checker.Validate against a real local_sessions row --
// unlike a fake, this confirms alerting can actually read the rows
// api/localauth (a separate Go module/service) writes into the shared
// Postgres, including the exact hash function agreeing on both sides.
// Same "skip unless a live-Postgres env var is set" convention as
// api/dashboards/store_integration_test.go.
//
// Skipped unless SESSIONCHECK_TEST_POSTGRES_ADDR is set; run via:
//
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/alerting \
// -e SESSIONCHECK_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
// -e SESSIONCHECK_TEST_POSTGRES_PASSWORD=sentry-dev-only \
// golang:1.25-alpine go test ./internal/sessioncheck/... -run Integration -v
package sessioncheck
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func integrationPool(t *testing.T) *pgxpool.Pool {
t.Helper()
addr := os.Getenv("SESSIONCHECK_TEST_POSTGRES_ADDR")
if addr == "" {
t.Skip("SESSIONCHECK_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test")
}
password := os.Getenv("SESSIONCHECK_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 pool
}
// insertTestSession writes directly into local_sessions and users --
// this package has no Store type of its own (see package doc comment:
// creating a session is api/localauth's job, this only ever validates
// one), so a real row has to come from somewhere for the test to check
// against.
func insertTestSession(t *testing.T, pool *pgxpool.Pool, ttl time.Duration) (raw string) {
t.Helper()
return insertTestSessionWithRole(t, pool, ttl, "viewer")
}
func insertTestSessionWithRole(t *testing.T, pool *pgxpool.Pool, ttl time.Duration, role string) (raw string) {
t.Helper()
ctx := context.Background()
userID := uuid.NewString()
if _, err := pool.Exec(ctx, `
INSERT INTO users (id, username, password_hash, display_name, created_at, updated_at)
VALUES ($1, $2, 'unused', $2, now(), now())`,
userID, "test-"+userID[:8]); err != nil {
t.Fatalf("inserting test user: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) })
buf := make([]byte, 32)
sum := sha256.Sum256([]byte(userID + role)) // deterministic-enough per-test randomness without crypto/rand here
copy(buf, sum[:])
raw = base64.RawURLEncoding.EncodeToString(buf)
hashSum := sha256.Sum256([]byte(raw))
hash := hex.EncodeToString(hashSum[:])
if _, err := pool.Exec(ctx, `
INSERT INTO local_sessions (id, user_id, tenant_id, role, token_hash, expires_at)
VALUES ($1, $2, 'default', $3, $4, $5)`,
uuid.NewString(), userID, role, hash, time.Now().Add(ttl)); err != nil {
t.Fatalf("inserting test session: %v", err)
}
return raw
}
func TestIntegrationValidateAcceptsRealSession(t *testing.T) {
pool := integrationPool(t)
raw := insertTestSession(t, pool, time.Hour)
role, err := NewChecker(pool).Validate(context.Background(), raw)
if err != nil {
t.Errorf("Validate on a real, unexpired session: err = %v, want nil", err)
}
if role != "viewer" {
t.Errorf("role = %q, want %q (matches insertTestSession's role column)", role, "viewer")
}
}
func TestIntegrationValidateRejectsExpiredSession(t *testing.T) {
pool := integrationPool(t)
raw := insertTestSession(t, pool, -time.Hour)
if _, err := NewChecker(pool).Validate(context.Background(), raw); !errors.Is(err, ErrInvalidSession) {
t.Errorf("Validate on an expired session: err = %v, want ErrInvalidSession", err)
}
}
func TestIntegrationValidateRejectsUnknownToken(t *testing.T) {
pool := integrationPool(t)
if _, err := NewChecker(pool).Validate(context.Background(), "not-a-real-token"); !errors.Is(err, ErrInvalidSession) {
t.Errorf("Validate on an unknown token: err = %v, want ErrInvalidSession", err)
}
}
// TestIntegrationRequireSessionForbidsMutatingRequestFromViewer is the
// regression test for the security-audit finding that this middleware
// used to be a pure "logged in or not" gate: a Viewer-role session
// could create/delete alert rules and notification targets exactly like
// an Editor. A POST from a Viewer session must now be 403, not passed
// through to the handler.
func TestIntegrationRequireSessionForbidsMutatingRequestFromViewer(t *testing.T) {
pool := integrationPool(t)
raw := insertTestSessionWithRole(t, pool, time.Hour, "viewer")
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true; w.WriteHeader(http.StatusOK) })
handler := RequireSession(NewChecker(pool), next)
req := httptest.NewRequest(http.MethodPost, "/targets", nil)
req.Header.Set("Authorization", "Bearer "+raw)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", rec.Code)
}
if called {
t.Error("handler must not run for a Viewer's mutating request")
}
}
// TestIntegrationRequireSessionAllowsMutatingRequestFromEditor is the
// positive counterpart: an Editor-role session (the new floor) must
// still be able to reach mutating routes.
func TestIntegrationRequireSessionAllowsMutatingRequestFromEditor(t *testing.T) {
pool := integrationPool(t)
raw := insertTestSessionWithRole(t, pool, time.Hour, "editor")
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
handler := RequireSession(NewChecker(pool), next)
req := httptest.NewRequest(http.MethodPost, "/targets", nil)
req.Header.Set("Authorization", "Bearer "+raw)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
// TestIntegrationRequireSessionAllowsReadFromViewer confirms the read
// path is untouched: GET still only needs a valid session, any role.
func TestIntegrationRequireSessionAllowsReadFromViewer(t *testing.T) {
pool := integrationPool(t)
raw := insertTestSessionWithRole(t, pool, time.Hour, "viewer")
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
handler := RequireSession(NewChecker(pool), next)
req := httptest.NewRequest(http.MethodGet, "/targets", nil)
req.Header.Set("Authorization", "Bearer "+raw)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
@@ -0,0 +1,32 @@
package sessioncheck
import "testing"
func TestRoleSatisfies(t *testing.T) {
cases := []struct {
role, min string
want bool
}{
{"viewer", "editor", false},
{"editor", "editor", true},
{"admin", "editor", true},
{"owner", "editor", true},
{"", "editor", false}, // unknown/empty role never satisfies a real floor
}
for _, c := range cases {
if got := roleSatisfies(c.role, c.min); got != c.want {
t.Errorf("roleSatisfies(%q, %q) = %v, want %v", c.role, c.min, got, c.want)
}
}
}
func TestIsReadOnly(t *testing.T) {
if !isReadOnly("GET") || !isReadOnly("HEAD") {
t.Error("GET/HEAD should be read-only")
}
for _, m := range []string{"POST", "PUT", "DELETE", "PATCH"} {
if isReadOnly(m) {
t.Errorf("%s should not be read-only", m)
}
}
}
+115
View File
@@ -4,8 +4,11 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"path"
"strings"
"github.com/sentry/sentry/api/authz"
)
@@ -128,6 +131,30 @@ func (h *Handler) handleSetConfig(w http.ResponseWriter, r *http.Request) {
return
}
// extra_file_paths is a materially different capability than the
// rest of this override: every other field tunes an already-running
// source, but this one tells the agent (which runs as root, with no
// filesystem sandboxing today -- see the security audit) to read and
// ship an arbitrary local file. RoleEditor is the right bar for
// "adjust batch size," not for "grant read access to any file on the
// host" -- so a request that actually *changes* the set of extra
// paths (adds or edits one -- shrinking or clearing never needs
// this, since that only removes capability) requires RoleAdmin,
// checked here rather than by splitting /agents/{host}/config into
// two routes with two RegisterRoutes role floors, which would break
// the "PUT replaces the whole override" contract every field here
// otherwise shares.
// A nil authorizer means no RBAC is configured at all (Phase 0-3
// default-open behavior) -- consistent with RequireRole's own
// no-op-when-nil posture, this extra gate only applies once an
// authorizer resolves a real Identity to check.
if identity, ok := authz.IdentityFromContext(r.Context()); ok && !identity.Role.Satisfies(authz.RoleAdmin) {
if changesExtraFilePaths(h.currentExtraFilePaths(r.Context(), h.tenantID(r), r.PathValue("host")), override.ExtraFilePaths) {
writeError(w, http.StatusForbidden, "extra_file_paths requires the admin role")
return
}
}
a, err := h.store.SetOverride(r.Context(), h.tenantID(r), r.PathValue("host"), override, h.updatedBy(r))
if err != nil {
h.writeStoreErr(w, err, "setting agent config")
@@ -202,9 +229,97 @@ func validateOverride(o ConfigOverride) error {
if o.HeartbeatIntervalMS != nil && *o.HeartbeatIntervalMS < 5000 {
return errors.New("heartbeat_interval_ms must be at least 5000 (5s)")
}
if len(o.ExtraFilePaths) > 20 {
return errors.New("extra_file_paths: at most 20 paths")
}
for _, p := range o.ExtraFilePaths {
if err := validateExtraFilePath(p); err != nil {
return err
}
}
return nil
}
// extraFilePathDenylistPrefixes blocks whole directory trees that are
// never legitimate log-file locations but very commonly hold sensitive
// material an agent (which runs as root, unsandboxed, on every host
// this deployment has been checked against -- see the security audit)
// can otherwise read: OS credential/config storage, home directories,
// and kernel/process pseudo-filesystems.
var extraFilePathDenylistPrefixes = []string{"/etc/", "/root/", "/home/", "/proc/", "/sys/", "/boot/"}
// extraFilePathDenylistSubstrings catches credential material that can
// live outside the directories above too (e.g. a service account's
// SSH/cloud-credential directory under an app's own working directory,
// not necessarily /home or /root).
var extraFilePathDenylistSubstrings = []string{"/.ssh/", "/.gnupg/", "/.aws/", "/.kube/"}
// extraFilePathDenylistSuffixes catches specific high-value filenames by
// name, regardless of directory -- named here because the audit that
// motivated this check demonstrated /etc/shadow and an SSH private key
// specifically, and this covers both even outside the prefix-denylisted
// directories above (e.g. a private key accidentally copied to /opt).
var extraFilePathDenylistSuffixes = []string{"-key.pem", "id_rsa", "id_ecdsa", "id_ed25519", "id_dsa", "/shadow", "/gshadow"}
func validateExtraFilePath(p string) error {
if p == "" || !strings.HasPrefix(p, "/") {
return errors.New("extra_file_paths: each path must be a non-empty absolute path")
}
if strings.Contains(p, "..") {
return errors.New(`extra_file_paths: path must not contain ".."`)
}
if cleaned := path.Clean(p); cleaned != p {
return fmt.Errorf("extra_file_paths: %q must be in canonical form (e.g. %q)", p, cleaned)
}
for _, prefix := range extraFilePathDenylistPrefixes {
if p == strings.TrimSuffix(prefix, "/") || strings.HasPrefix(p, prefix) {
return fmt.Errorf("extra_file_paths: %q is not an allowed path (under denylisted %s)", p, prefix)
}
}
for _, substr := range extraFilePathDenylistSubstrings {
if strings.Contains(p, substr) {
return fmt.Errorf("extra_file_paths: %q is not an allowed path", p)
}
}
for _, suffix := range extraFilePathDenylistSuffixes {
if strings.HasSuffix(p, suffix) {
return fmt.Errorf("extra_file_paths: %q is not an allowed path", p)
}
}
return nil
}
// currentExtraFilePaths reads back the agent's already-stored override
// (empty/nil if the agent or override doesn't exist yet) so
// handleSetConfig can tell an addition/change apart from a pure
// shrink-or-clear -- see changesExtraFilePaths.
func (h *Handler) currentExtraFilePaths(ctx context.Context, tenantID, host string) []string {
a, err := h.store.Get(ctx, tenantID, host)
if err != nil || a.DesiredOverride == nil {
return nil
}
return a.DesiredOverride.ExtraFilePaths
}
// changesExtraFilePaths reports whether desired introduces any path not
// already present in current -- an addition or an edit, either of which
// grants the agent read access to something it couldn't read before.
// Removing paths (desired is a subset of current) is never a capability
// grant, so that alone never requires the stricter role handleSetConfig
// applies around this.
func changesExtraFilePaths(current, desired []string) bool {
existing := make(map[string]struct{}, len(current))
for _, p := range current {
existing[p] = struct{}{}
}
for _, p := range desired {
if _, ok := existing[p]; !ok {
return true
}
}
return false
}
func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "agent not found")
+110
View File
@@ -199,6 +199,116 @@ func TestHandleSetConfigRejectsTooSmallHeartbeatInterval(t *testing.T) {
}
}
func TestHandleSetConfigExtraFilePathsRoundTrips(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
h := newTestHandler(s)
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"/var/log/nginx/access.log", "/var/log/nginx/error.log"},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got Agent
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding response: %v", err)
}
if got.DesiredOverride == nil || len(got.DesiredOverride.ExtraFilePaths) != 2 {
t.Fatalf("unexpected override: %+v", got.DesiredOverride)
}
}
func TestHandleSetConfigRejectsRelativeExtraFilePath(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
h := newTestHandler(s)
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"relative/path.log"},
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestHandleSetConfigRejectsTooManyExtraFilePaths(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
h := newTestHandler(s)
paths := make([]string, 21)
for i := range paths {
paths[i] = "/var/log/x.log"
}
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{ExtraFilePaths: paths})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
// TestHandleSetConfigDenylistsSensitivePaths is the regression test for
// the security-audit finding that a root, unsandboxed agent plus an
// unrestricted extra_file_paths let any Editor read arbitrary files
// (e.g. /etc/shadow, SSH keys) and have them shipped into ClickHouse.
func TestHandleSetConfigDenylistsSensitivePaths(t *testing.T) {
denied := []string{
"/etc/shadow",
"/etc/passwd",
"/root/.bash_history",
"/home/alice/.ssh/id_rsa",
"/home/alice/.ssh/authorized_keys",
"/proc/1/environ",
"/etc/sentry-agent/client-key.pem",
"/opt/app/../../etc/shadow",
"/opt/app/id_ed25519",
}
for _, p := range denied {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
h := newTestHandler(s)
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{ExtraFilePaths: []string{p}})
if rec.Code != http.StatusBadRequest {
t.Errorf("path %q: status = %d, want 400 (should be denylisted), body=%s", p, rec.Code, rec.Body.String())
}
}
}
// TestHandleSetConfigExtraFilePathsRequiresAdminToAdd is the regression
// test for the audit's role-floor fix: adding/changing extra_file_paths
// needs Admin, not just Editor, since it grants the agent read access to
// a new file. Purely shrinking or clearing an existing set stays at the
// Editor floor everything else in this override uses.
func TestHandleSetConfigExtraFilePathsRequiresAdminToAdd(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
editor := NewHandler(discardLogger(), s, fakeAuthorizer{role: authz.RoleEditor}, nil)
admin := NewHandler(discardLogger(), s, fakeAuthorizer{role: authz.RoleAdmin}, nil)
rec := doRequest(t, editor, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"/var/log/nginx/access.log"},
})
if rec.Code != http.StatusForbidden {
t.Fatalf("editor adding a path: status = %d, want 403", rec.Code)
}
rec = doRequest(t, admin, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"/var/log/nginx/access.log", "/var/log/nginx/error.log"},
})
if rec.Code != http.StatusOK {
t.Fatalf("admin adding paths: status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
// Shrinking back down to one path is a pure removal -- Editor should
// be allowed to do this even though they couldn't have added it.
rec = doRequest(t, editor, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"/var/log/nginx/access.log"},
})
if rec.Code != http.StatusOK {
t.Fatalf("editor removing a path: status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleSetConfigUnknownHostIsNotFound(t *testing.T) {
h := newTestHandler(newFakeStore())
interval := int64(30000)
+1
View File
@@ -40,6 +40,7 @@ type ConfigOverride struct {
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
HeartbeatIntervalMS *int64 `json:"heartbeat_interval_ms,omitempty"`
JournaldUnit *string `json:"journald_unit,omitempty"`
ExtraFilePaths []string `json:"extra_file_paths,omitempty"`
}
type Agent struct {
+96 -4
View File
@@ -7,7 +7,11 @@ package main
import (
"context"
"crypto/rand"
"encoding/base64"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"os"
@@ -28,6 +32,7 @@ import (
"github.com/sentry/sentry/api/dashboards"
"github.com/sentry/sentry/api/httpserver"
"github.com/sentry/sentry/api/internal/config"
"github.com/sentry/sentry/api/localauth"
"github.com/sentry/sentry/api/queryapi"
"github.com/sentry/sentry/api/querylang/executor"
"github.com/sentry/sentry/api/searchclient"
@@ -48,6 +53,9 @@ func main() {
logger.Error("loading config", "error", err)
os.Exit(1)
}
for _, w := range cfg.DevCredentialWarnings() {
logger.Warn(w)
}
// -healthcheck: a self-check mode for Docker's HEALTHCHECK, not a
// flag anyone runs by hand. The api image is distroless (no shell,
@@ -59,6 +67,13 @@ func main() {
os.Exit(runHealthcheck(cfg.HTTPListenAddr))
}
// -seed-admin: a one-shot action, not part of the normal server
// startup path -- mirrors enterprise-api's -provision-tenant shape
// (declare, flag.Parse(), short-circuit before the rest of main's
// dependencies matter to it). See runSeedAdmin's doc comment.
seedAdmin := flag.Bool("seed-admin", false, "create the default local-auth admin user with a random password if none exists, print it once, and exit")
flag.Parse()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -101,12 +116,25 @@ func main() {
os.Exit(1)
}
if *seedAdmin {
os.Exit(runSeedAdmin(ctx, logger, os.Stdout, localauth.NewStore(pgPool)))
}
// authorizer is nil (RequireRole* becomes a no-op) unless
// ENTERPRISE_AUTH_URL is configured -- matches Phase 0-3 behavior
// for a single-tenant deployment with no enterprise/ deployed.
// ENTERPRISE_AUTH_URL or LOCAL_AUTH_ENABLED is configured -- matches
// Phase 0-3 behavior for a single-tenant deployment with neither
// enterprise/ nor local login turned on. EnterpriseAuthURL wins if
// both were somehow set -- a deployment with real SSO configured has
// no use for a second, local auth mechanism (see LocalAuthConfig's
// doc comment).
var authorizer authz.Authorizer
if cfg.EnterpriseAuthURL != "" {
var localAuthStore *localauth.Store
switch {
case cfg.EnterpriseAuthURL != "":
authorizer = authz.NewHTTPAuthorizer(cfg.EnterpriseAuthURL)
case cfg.LocalAuth.Enabled:
localAuthStore = localauth.NewStore(pgPool)
authorizer = localauth.NewAuthorizer(localAuthStore)
}
sqlRunner := executor.NewChRunner(conn)
@@ -139,6 +167,18 @@ func main() {
dashboardsHandler.RegisterRoutes(mux)
agentsHandler.RegisterRoutes(mux)
// Only registered when local auth is actually enabled -- see
// localauth.Handler.RegisterRoutes' doc comment for why a disabled
// deployment gets a plain 404 on /auth/* rather than a dedicated
// "feature off" response.
if localAuthStore != nil {
localauthHandler := localauth.NewHandler(logger, localAuthStore, authorizer, cfg.LocalAuth.SessionTTL, localauth.CookieConfig{
Domain: cfg.LocalAuth.CookieDomain,
Secure: cfg.LocalAuth.CookieSecure,
})
localauthHandler.RegisterRoutes(mux)
}
// AI routes (Phase 7) are only registered at all when OLLAMA_BASE_URL
// is set -- an unconfigured deployment gets a plain 404 on /ai/*
// rather than every request failing against an unreachable
@@ -165,9 +205,19 @@ func main() {
logger.Info("ai routes enabled", "ollama_base_url", cfg.AI.OllamaBaseURL, "model", cfg.AI.OllamaModel)
}
// Once an authorizer is live, requests carry a session cookie/bearer
// token that must survive a cross-origin browser fetch --
// WithCredentialedCORS is WithCORS's sibling for exactly that (see
// httpserver/cors.go). This also fixes a latent gap: previously,
// enterprise mode applied plain WithCORS here despite needing
// cookies too.
corsHandler := httpserver.WithCORS(mux, cfg.CORSAllowedOrigin)
if authorizer != nil {
corsHandler = httpserver.WithCredentialedCORS(mux, cfg.CORSAllowedOrigin)
}
srv := &http.Server{
Addr: cfg.HTTPListenAddr,
Handler: httpserver.WithCORS(mux, cfg.CORSAllowedOrigin),
Handler: corsHandler,
}
errCh := make(chan error, 1)
@@ -191,6 +241,48 @@ func main() {
}
}
// runSeedAdmin is the operator action that bootstraps local login on a
// fresh deployment: idempotent (a no-op if any local user already
// exists, safe to run on every deploy per the runbook), so there's no
// separate "has this already run" flag to track. The generated
// password is printed to stdout exactly once and never stored in
// plaintext anywhere -- losing it means resetting it
// (POST /auth/users/{id}/reset-password), not recovering it.
func runSeedAdmin(ctx context.Context, logger *slog.Logger, stdout io.Writer, store *localauth.Store) int {
n, err := store.CountLocalUsers(ctx)
if err != nil {
logger.Error("counting local users", "error", err)
return 1
}
if n > 0 {
fmt.Fprintln(stdout, "admin already provisioned, skipping")
return 0
}
buf := make([]byte, 20)
if _, err := rand.Read(buf); err != nil {
logger.Error("generating random password", "error", err)
return 1
}
password := base64.RawURLEncoding.EncodeToString(buf)
hash, err := localauth.HashPassword(password)
if err != nil {
logger.Error("hashing password", "error", err)
return 1
}
if _, err := store.CreateUser(ctx, "admin", hash, authz.RoleOwner); err != nil {
logger.Error("creating admin user", "error", err)
return 1
}
fmt.Fprintln(stdout, "created default admin user:")
fmt.Fprintln(stdout, " username: admin")
fmt.Fprintf(stdout, " password: %s\n", password)
fmt.Fprintln(stdout, "this password will not be shown again -- save it now.")
return 0
}
// runHealthcheck GETs its own /healthz and returns an exit code, for
// Docker's HEALTHCHECK to exec directly (see the -healthcheck flag
// above). listenAddr is HTTP_LISTEN_ADDR-shaped (e.g. ":8080") --
+3 -2
View File
@@ -5,7 +5,9 @@ go 1.25.0
require (
github.com/ClickHouse/clickhouse-go/v2 v2.48.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/sentry/sentry/proto v0.0.0-00010101000000-000000000000
golang.org/x/crypto v0.55.0
google.golang.org/grpc v1.83.0
)
@@ -19,7 +21,6 @@ require (
github.com/go-faster/errors v0.7.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/paulmach/orb v0.13.0 // indirect
@@ -31,7 +32,7 @@ require (
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
+4 -2
View File
@@ -63,14 +63,16 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+72
View File
@@ -18,6 +18,32 @@ type Config struct {
CORSAllowedOrigin string
EnterpriseAuthURL string
AI AIConfig
LocalAuth LocalAuthConfig
}
// LocalAuthConfig gates single-tenant mode's local username/password
// login (see api/localauth) -- off unless Enabled, same "off unless
// configured" convention as EnterpriseAuthURL/AI.OllamaBaseURL. Only
// meaningful when EnterpriseAuthURL is empty -- a deployment with real
// SSO configured has no use for a second, local auth mechanism, and
// main.go's authorizer selection treats EnterpriseAuthURL as taking
// priority if both were somehow set.
type LocalAuthConfig struct {
Enabled bool
// SessionTTL is deliberately long (30 days default) compared to
// enterprise/'s session TTL -- there's no SSO round-trip here to
// silently refresh a session against, so a short TTL would just mean
// re-entering a password often on a self-hosted single-operator tool.
SessionTTL time.Duration
// CookieDomain empty means a host-only cookie (fine for local dev,
// where web/api are both localhost:<port>). Set to e.g.
// ".sentry.example.com" in production so the cookie is also sent to
// api.sentry.example.com/alerting.sentry.example.com.
CookieDomain string
// CookieSecure defaults true (never sent over plain HTTP) --
// deliberately opt-out via LOCAL_AUTH_COOKIE_SECURE=false, only
// useful to test the login flow locally over http://localhost.
CookieSecure bool
}
// AIConfig gates Phase 7's AI-assisted query features (Track A/B) --
@@ -50,6 +76,33 @@ type PostgresConfig struct {
Password string
}
// devOnlyCredential is docker-compose.yml's zero-config default for
// every Postgres/ClickHouse password in this repo -- genuinely fine for
// local dev (that's the whole point of a zero-config default), but a
// real deployment that skips docker-compose.override.yml would
// otherwise go live with a password anyone can read straight off
// GitHub. See DevCredentialWarnings.
const devOnlyCredential = "sentry-dev-only"
// DevCredentialWarnings reports which configured credentials still
// equal docker-compose.yml's literal dev-only default -- cmd/api/main.go
// logs each one loudly at startup. Deliberately a warning, not a
// startup-refusing error: local dev's documented zero-config path is
// exactly "run docker-compose.yml with no override," which legitimately
// leaves every password at this literal value, so hard-failing here
// would break that path rather than only catching real deployments that
// forgot to override it.
func (c Config) DevCredentialWarnings() []string {
var warnings []string
if c.ClickHouse.Password == devOnlyCredential {
warnings = append(warnings, "CLICKHOUSE_PASSWORD is still the default dev-only value -- set a real password via docker-compose.override.yml (or your deployment's equivalent) before this is reachable outside local dev")
}
if c.Postgres.Password == devOnlyCredential {
warnings = append(warnings, "POSTGRES_PASSWORD is still the default dev-only value -- set a real password via docker-compose.override.yml (or your deployment's equivalent) before this is reachable outside local dev")
}
return warnings
}
func Load() (Config, error) {
cfg := Config{
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8080"),
@@ -98,6 +151,25 @@ func Load() (Config, error) {
}
cfg.QueryTimeout = time.Duration(timeoutSec) * time.Second
localAuthEnabled, err := strconv.ParseBool(getenv("LOCAL_AUTH_ENABLED", "false"))
if err != nil {
return Config{}, fmt.Errorf("LOCAL_AUTH_ENABLED: %w", err)
}
sessionTTLHours, err := strconv.Atoi(getenv("LOCAL_SESSION_TTL_HOURS", "720")) // 30 days
if err != nil {
return Config{}, fmt.Errorf("LOCAL_SESSION_TTL_HOURS: %w", err)
}
cookieSecure, err := strconv.ParseBool(getenv("LOCAL_AUTH_COOKIE_SECURE", "true"))
if err != nil {
return Config{}, fmt.Errorf("LOCAL_AUTH_COOKIE_SECURE: %w", err)
}
cfg.LocalAuth = LocalAuthConfig{
Enabled: localAuthEnabled,
SessionTTL: time.Duration(sessionTTLHours) * time.Hour,
CookieDomain: getenv("SESSION_COOKIE_DOMAIN", ""),
CookieSecure: cookieSecure,
}
return cfg, nil
}
+20
View File
@@ -30,3 +30,23 @@ func TestLoadInvalidTimeoutErrors(t *testing.T) {
t.Fatal("expected error for non-numeric QUERY_TIMEOUT_SECONDS, got nil")
}
}
// TestDevCredentialWarnings is the regression test for the
// security-audit finding that docker-compose.yml's hardcoded
// "sentry-dev-only" password has no runtime fail-safe if an operator
// forgets to override it for a real deployment.
func TestDevCredentialWarnings(t *testing.T) {
if got := (Config{}).DevCredentialWarnings(); len(got) != 0 {
t.Errorf("empty passwords: warnings = %v, want none", got)
}
real := Config{ClickHouse: ClickHouseConfig{Password: "a-real-password"}, Postgres: PostgresConfig{Password: "another-real-one"}}
if got := real.DevCredentialWarnings(); len(got) != 0 {
t.Errorf("real passwords: warnings = %v, want none", got)
}
devOnly := Config{ClickHouse: ClickHouseConfig{Password: devOnlyCredential}, Postgres: PostgresConfig{Password: devOnlyCredential}}
if got := devOnly.DevCredentialWarnings(); len(got) != 2 {
t.Errorf("both dev-default passwords: warnings = %v, want 2 entries", got)
}
}
+21
View File
@@ -259,6 +259,24 @@ func defaultAggAlias(a ast.AggCall) string {
// SQL parser -- same tradeoffs as the Phase 0/1 version this replaces.
var disallowedKeyword = regexp.MustCompile(`(?i)\b(insert|update|delete|alter|drop|truncate|create|grant|revoke|attach|detach|rename|kill|optimize|system|set|exchange|watch)\b`)
// disallowedTableFunction blocks ClickHouse's built-in table functions
// that reach outside ClickHouse itself -- a keyword blocklist for
// mutating statements (above) doesn't touch these at all, since
// `SELECT * FROM url(...)` is a perfectly ordinary read-only SELECT as
// far as validateSelectOnly's other checks are concerned. Every
// function here lets a SELECT-only, RoleViewer-gated query make
// ClickHouse itself issue an outbound request or read a local file on
// the caller's behalf -- cloud-metadata SSRF via url(), a proxy into
// other internal ClickHouse/MySQL/Postgres instances via
// remote()/remoteSecure()/mysql()/postgresql(), and local/object-storage
// file reads via file()/hdfs()/s3()/azureBlobStorage()/deltaLake()/
// iceberg()/hudi(). Same word-boundary-regex tradeoff as
// disallowedKeyword above: this is a blocklist, not a real SQL parser,
// so it can't be the only control -- see the ClickHouse-grant-level
// hardening this should be paired with (table-function usage revoked
// for the role api's raw-SQL path connects as).
var disallowedTableFunction = regexp.MustCompile(`(?i)\b(url|remote|remoteSecure|mysql|postgresql|s3|s3Cluster|hdfs|hdfsCluster|file|odbc|jdbc|executable|cluster|clusterAllReplicas|azureBlobStorage|deltaLake|iceberg|hudi|redis|mongodb)\s*\(`)
func validateSelectOnly(sql string) error {
trimmed := strings.TrimSpace(sql)
if trimmed == "" {
@@ -281,6 +299,9 @@ func validateSelectOnly(sql string) error {
if disallowedKeyword.MatchString(trimmed) {
return fmt.Errorf("query contains a disallowed keyword")
}
if disallowedTableFunction.MatchString(trimmed) {
return fmt.Errorf("query contains a disallowed table function")
}
return nil
}
@@ -40,6 +40,48 @@ func TestCompileRejectsNonSelectSQLKeyword(t *testing.T) {
}
}
// TestCompileRejectsSSRFTableFunctions guards against a real finding: a
// SELECT-only, keyword-blocklist check alone doesn't stop ClickHouse's
// built-in table functions, which let an otherwise-ordinary read-only
// SELECT make ClickHouse itself issue an outbound request (url,
// remote/remoteSecure, mysql, postgresql, s3, hdfs, ...) or read a local
// file (file) on the caller's behalf -- an SSRF/file-read primitive
// reachable by RoleViewer, the platform's lowest role.
func TestCompileRejectsSSRFTableFunctions(t *testing.T) {
queries := []string{
`SELECT * FROM url('http://169.254.169.254/latest/meta-data/', 'LineAsString', 's String')`,
`select * from remote('internal-host:9000', system, tables)`,
`SELECT * FROM remoteSecure('attacker.example:9440', db, tbl, 'user', 'pass')`,
`select * from mysql('host:3306', 'db', 'table', 'user', 'pass')`,
`SELECT * FROM postgresql('host:5432', 'db', 'table', 'user', 'pass')`,
`select * from s3('https://bucket.s3.amazonaws.com/key', 'CSV')`,
`SELECT * FROM hdfs('hdfs://host:9000/path', 'CSV')`,
`select * from file('/etc/passwd', 'LineAsString')`,
`SELECT * FROM odbc('DSN=foo', 'db', 'table')`,
`select * from executable('id', 'TSV', 'x String')`,
`SELECT * FROM cluster('some_cluster', system, tables)`,
}
for _, q := range queries {
if _, err := Compile(q, SQL, fixedNow); err == nil {
t.Errorf("expected Compile(%q) to reject a table-function SSRF vector, got no error", q)
}
}
}
// TestCompileAllowsOrdinaryColumnNamesResemblingTableFunctions makes
// sure the table-function blocklist only fires on actual function-call
// syntax (name immediately followed by "(") and not merely a column or
// identifier that happens to share a name with a blocked function.
func TestCompileAllowsOrdinaryColumnNamesResemblingTableFunctions(t *testing.T) {
plan, err := Compile(`SELECT cluster_id, file_name FROM logs WHERE cluster_id = 1`, SQL, fixedNow)
if err != nil {
t.Fatalf("Compile() error = %v", err)
}
if plan.RawSQL == "" {
t.Fatal("expected RawSQL to be set")
}
}
func TestCompileExplicitLanguageOverridesAutoDetect(t *testing.T) {
// "select" as a bare free-text search term -- would be misdetected
// as SQL by the heuristic alone, hence the override.
+70
View File
@@ -0,0 +1,70 @@
package localauth
import (
"context"
"errors"
"net/http"
"github.com/sentry/sentry/api/authz"
)
// sessionStore is the narrow interface Authorizer depends on -- *Store
// is the production implementation; tests use a fake.
type sessionStore interface {
GetSession(ctx context.Context, tokenHash string) (*Session, error)
}
// sessionCookieName is also read directly by handler.go (Set-Cookie on
// login/logout) and is the one piece of this package's shape web/
// needs to know about implicitly (via credentials: 'include', not by
// name -- the browser handles the cookie, JS never reads it since it's
// HttpOnly).
const sessionCookieName = "sentry_local_session"
// Authorizer implements api/authz.Authorizer against local_sessions --
// wiring a non-nil *Authorizer into api/cmd/api/main.go's authorizer
// variable is what turns every existing RequireRole-wrapped route in
// dashboards/agents/queryapi/aiapi from a no-op into real enforcement,
// with no changes needed to any of those handler files (see this
// package's doc comment).
type Authorizer struct {
store sessionStore
}
func NewAuthorizer(store sessionStore) *Authorizer {
return &Authorizer{store: store}
}
var errNoCredential = errors.New("localauth: no session credential presented")
// Authorize checks Authorization: Bearer first (sentryctl and other
// non-browser callers), then the session cookie (the web UI) -- same
// precedence authz.HTTPAuthorizer's caller-side forwarding implies,
// and the same reason POST /auth/login's response body returns the raw
// token alongside setting the cookie (see handler.go): one opaque
// value works both ways.
func (a *Authorizer) Authorize(r *http.Request) (authz.Identity, error) {
raw, err := credentialFromRequest(r)
if err != nil {
return authz.Identity{}, err
}
sess, err := a.store.GetSession(r.Context(), hashToken(raw))
if err != nil {
return authz.Identity{}, err
}
return authz.Identity{TenantID: sess.TenantID, UserID: sess.UserID, Role: sess.Role}, nil
}
func credentialFromRequest(r *http.Request) (string, error) {
if auth := r.Header.Get("Authorization"); auth != "" {
const prefix = "Bearer "
if len(auth) > len(prefix) && auth[:len(prefix)] == prefix {
return auth[len(prefix):], nil
}
}
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
return cookie.Value, nil
}
return "", errNoCredential
}
+127
View File
@@ -0,0 +1,127 @@
package localauth
import (
"context"
"strconv"
"time"
"github.com/sentry/sentry/api/authz"
)
// fakeStore implements both store (handler.go) and sessionStore
// (authorizer.go) -- a real *Store satisfies both too, this is just the
// in-memory test double, same "fake enforces the same invariants the
// real pgx-backed Store does" posture dashboards/handler_test.go's
// fakeStore documents.
type fakeStore struct {
users map[string]*User // by id
hashes map[string]string
byUsername map[string]string // username -> id
sessions map[string]Session
nextID int
createErr error
}
func newFakeStore() *fakeStore {
return &fakeStore{
users: map[string]*User{},
hashes: map[string]string{},
byUsername: map[string]string{},
sessions: map[string]Session{},
}
}
func (f *fakeStore) CreateUser(_ context.Context, username, passwordHash string, role authz.Role) (*User, error) {
if f.createErr != nil {
return nil, f.createErr
}
if _, ok := f.byUsername[username]; ok {
return nil, ErrUsernameTaken
}
f.nextID++
id := "user-" + strconv.Itoa(f.nextID)
u := &User{ID: id, Username: username, Role: role, CreatedAt: time.Now()}
f.users[id] = u
f.hashes[id] = passwordHash
f.byUsername[username] = id
return u, nil
}
func (f *fakeStore) ListUsers(_ context.Context) ([]User, error) {
var out []User
for _, u := range f.users {
out = append(out, *u)
}
return out, nil
}
func (f *fakeStore) GetUserForLogin(_ context.Context, username string) (*User, string, error) {
id, ok := f.byUsername[username]
if !ok {
return nil, "", ErrNotFound
}
return f.users[id], f.hashes[id], nil
}
func (f *fakeStore) GetUserByID(_ context.Context, id string) (*User, error) {
u, ok := f.users[id]
if !ok {
return nil, ErrNotFound
}
return u, nil
}
func (f *fakeStore) DeleteUser(_ context.Context, id string) error {
u, ok := f.users[id]
if !ok {
return ErrNotFound
}
delete(f.users, id)
delete(f.hashes, id)
delete(f.byUsername, u.Username)
for hash, sess := range f.sessions {
if sess.UserID == id {
delete(f.sessions, hash)
}
}
return nil
}
func (f *fakeStore) SetPasswordHash(_ context.Context, userID, hash string) error {
if _, ok := f.users[userID]; !ok {
return ErrNotFound
}
f.hashes[userID] = hash
for h, sess := range f.sessions {
if sess.UserID == userID {
delete(f.sessions, h)
}
}
return nil
}
func (f *fakeStore) CountLocalUsers(_ context.Context) (int, error) {
return len(f.users), nil
}
func (f *fakeStore) CreateSession(_ context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error) {
raw, hash, err := newOpaqueToken()
if err != nil {
return "", err
}
f.sessions[hash] = Session{UserID: userID, TenantID: tenantID, Role: role, ExpiresAt: time.Now().Add(ttl)}
return raw, nil
}
func (f *fakeStore) GetSession(_ context.Context, tokenHash string) (*Session, error) {
sess, ok := f.sessions[tokenHash]
if !ok || sess.ExpiresAt.Before(time.Now()) {
return nil, ErrNotFound
}
return &sess, nil
}
func (f *fakeStore) DeleteSessionByHash(_ context.Context, tokenHash string) error {
delete(f.sessions, tokenHash)
return nil
}
+407
View File
@@ -0,0 +1,407 @@
package localauth
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"github.com/sentry/sentry/api/authz"
)
const maxBodyBytes = 1 << 20 // 1 MiB, same cap as queryapi/dashboards/agents
// store is the narrow interface Handler depends on -- *Store (store.go)
// is the production implementation; tests use a fake, same pattern as
// dashboards.store/agents.store.
type store interface {
CreateUser(ctx context.Context, username, passwordHash string, role authz.Role) (*User, error)
ListUsers(ctx context.Context) ([]User, error)
GetUserForLogin(ctx context.Context, username string) (*User, string, error)
GetUserByID(ctx context.Context, id string) (*User, error)
DeleteUser(ctx context.Context, id string) error
SetPasswordHash(ctx context.Context, userID, hash string) error
CreateSession(ctx context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error)
DeleteSessionByHash(ctx context.Context, tokenHash string) error
}
// CookieConfig is the deployment-specific half of how the session
// cookie is set -- everything else about it (name, HttpOnly, SameSite)
// is fixed by this package, not configurable per deployment.
type CookieConfig struct {
// Domain is typically empty for local dev (host-only cookie, works
// fine when web/api are both localhost:<port>) and something like
// ".sentry.example.com" in production, so the same cookie is sent to
// api.sentry.example.com and alerting.sentry.example.com too -- see
// /docs (deployment runbook) for the subdomain scheme this assumes.
Domain string
// Secure defaults to true (the cookie is never sent over plain
// HTTP) -- deliberately opt-out, not opt-in, since the real
// deployment this feature exists for is always behind HTTPS. Only
// worth setting false to test the login flow locally over plain
// http://localhost.
Secure bool
}
// loginRateLimitMax/Window bound how many login attempts one client IP
// may make -- see loginLimiter's doc comment for why this is per-IP,
// in-memory, and counts both successful and failed attempts. 10 per 5
// minutes is generous enough that a real user mistyping a password a
// few times never notices, while still bounding an online brute-force
// attempt to a few attempts per minute.
const (
loginRateLimitMax = 10
loginRateLimitWindow = 5 * time.Minute
)
type Handler struct {
logger *slog.Logger
store store
authorizer authz.Authorizer
sessionTTL time.Duration
cookies CookieConfig
loginLimits *loginLimiter
}
func NewHandler(logger *slog.Logger, store store, authorizer authz.Authorizer, sessionTTL time.Duration, cookies CookieConfig) *Handler {
return &Handler{
logger: logger,
store: store,
authorizer: authorizer,
sessionTTL: sessionTTL,
cookies: cookies,
loginLimits: newLoginLimiter(loginRateLimitMax, loginRateLimitWindow),
}
}
// RegisterRoutes is only ever called when local auth is enabled (see
// cmd/api/main.go) -- a deployment that doesn't enable it simply never
// registers these routes at all, so GET /auth/session (etc.) 404s
// rather than needing its own "is this feature even on" response
// shape. Login/logout/session are deliberately NOT RequireRole-wrapped
// with anything above RoleViewer's floor: login is how you become
// authenticated in the first place, logout/session must work for any
// already-authenticated user regardless of role.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /auth/login", h.handleLogin)
mux.HandleFunc("POST /auth/logout", h.handleLogout)
mux.HandleFunc("GET /auth/session", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleGetSession))
mux.HandleFunc("GET /auth/users", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleListUsers))
mux.HandleFunc("POST /auth/users", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleCreateUser))
mux.HandleFunc("DELETE /auth/users/{id}", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleDeleteUser))
mux.HandleFunc("POST /auth/users/{id}/reset-password", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleResetPassword))
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type sessionResponse struct {
// Token duplicates what the Set-Cookie header already carries,
// specifically for non-browser callers with no cookie jar --
// sentryctl captures this into SENTRYCTL_TOKEN and sends it back as
// Authorization: Bearer (see authorizer.go's credentialFromRequest,
// which accepts either). The web UI ignores this field entirely and
// relies on the cookie.
Token string `json:"token"`
UserID string `json:"user_id"`
TenantID string `json:"tenant_id"`
Username string `json:"username"`
Role string `json:"role"`
}
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
if !h.loginLimits.allow(clientIP(r)) {
writeError(w, http.StatusTooManyRequests, "too many login attempts, try again later")
return
}
var req loginRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "username and password are required")
return
}
user, hash, err := h.store.GetUserForLogin(r.Context(), req.Username)
if err != nil {
if errors.Is(err, ErrNotFound) {
// Run a dummy bcrypt comparison even though there's no real
// hash to check -- otherwise this branch returns immediately
// while a known-username branch always pays bcrypt's cost
// below, and that timing gap lets a patient caller enumerate
// valid usernames by response latency alone even though the
// error message text is identical either way.
ComparePassword(dummyPasswordHash, req.Password)
writeError(w, http.StatusUnauthorized, "invalid username or password")
return
}
h.logger.Error("looking up user for login", "error", err)
writeError(w, http.StatusInternalServerError, "login failed")
return
}
if !ComparePassword(hash, req.Password) {
writeError(w, http.StatusUnauthorized, "invalid username or password")
return
}
raw, err := h.store.CreateSession(r.Context(), user.ID, defaultTenantID, user.Role, h.sessionTTL)
if err != nil {
h.logger.Error("creating session", "error", err)
writeError(w, http.StatusInternalServerError, "login failed")
return
}
h.setCookie(w, raw, h.sessionTTL)
writeJSON(w, http.StatusOK, sessionResponse{
Token: raw, UserID: user.ID, TenantID: defaultTenantID,
Username: user.Username, Role: string(user.Role),
})
}
// handleLogout always responds 204, whether or not a valid session was
// presented -- "log me out" is idempotent from the caller's point of
// view either way.
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
if raw, err := credentialFromRequest(r); err == nil {
if err := h.store.DeleteSessionByHash(r.Context(), hashToken(raw)); err != nil {
h.logger.Error("deleting session", "error", err)
}
}
h.clearCookie(w)
w.WriteHeader(http.StatusNoContent)
}
// handleGetSession is what web's route guard (+layout.ts) polls on
// every navigation -- RequireRole(RoleViewer) above already turns "no
// valid session" into a 401 before this ever runs, so by the time
// we're here the identity is real.
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
identity, _ := authz.IdentityFromContext(r.Context())
user, err := h.store.GetUserByID(r.Context(), identity.UserID)
if err != nil {
h.writeStoreErr(w, err, "fetching session user")
return
}
writeJSON(w, http.StatusOK, sessionResponse{
UserID: user.ID, TenantID: identity.TenantID, Username: user.Username, Role: string(user.Role),
})
}
type userResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
func (h *Handler) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.store.ListUsers(r.Context())
if err != nil {
h.logger.Error("listing users", "error", err)
writeError(w, http.StatusInternalServerError, "listing users failed")
return
}
out := make([]userResponse, len(users))
for i, u := range users {
out[i] = userResponse{ID: u.ID, Username: u.Username, Role: string(u.Role), CreatedAt: u.CreatedAt}
}
writeJSON(w, http.StatusOK, out)
}
type createUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
func validRole(r authz.Role) bool {
switch r {
case authz.RoleViewer, authz.RoleEditor, authz.RoleAdmin, authz.RoleOwner:
return true
default:
return false
}
}
func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Username == "" {
writeError(w, http.StatusBadRequest, "username must not be empty")
return
}
if len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
role := authz.Role(req.Role)
if role == "" {
role = authz.RoleEditor
}
if !validRole(role) {
writeError(w, http.StatusBadRequest, `role must be "viewer", "editor", "admin", or "owner"`)
return
}
hash, err := HashPassword(req.Password)
if err != nil {
h.logger.Error("hashing password", "error", err)
writeError(w, http.StatusInternalServerError, "creating user failed")
return
}
user, err := h.store.CreateUser(r.Context(), req.Username, hash, role)
if err != nil {
if errors.Is(err, ErrUsernameTaken) {
writeError(w, http.StatusConflict, "username already taken")
return
}
h.logger.Error("creating user", "error", err)
writeError(w, http.StatusInternalServerError, "creating user failed")
return
}
writeJSON(w, http.StatusCreated, userResponse{ID: user.ID, Username: user.Username, Role: string(user.Role), CreatedAt: user.CreatedAt})
}
// handleDeleteUser deliberately does not stop an admin from deleting
// their own account -- this package has no separate "you can't remove
// the last admin" guard; a single-operator prototype deployment is
// expected to know what it's doing here, same trust level the rest of
// this codebase's admin-only endpoints assume.
func (h *Handler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
if err := h.store.DeleteUser(r.Context(), r.PathValue("id")); err != nil {
h.writeStoreErr(w, err, "deleting user")
return
}
w.WriteHeader(http.StatusNoContent)
}
type resetPasswordRequest struct {
// Password is optional -- omitted, a random one is generated and
// returned in the response body exactly once, same "shown once,
// never stored, never recoverable" posture as -seed-admin's initial
// password (see cmd/api/main.go's runSeedAdmin).
Password string `json:"password,omitempty"`
}
type resetPasswordResponse struct {
// Password is only set when the request didn't supply one --
// omitempty so an admin-supplied reset doesn't echo it back.
Password string `json:"password,omitempty"`
}
func (h *Handler) handleResetPassword(w http.ResponseWriter, r *http.Request) {
var req resetPasswordRequest
// An empty body is valid here (generate a random password) --
// decodeJSON's json.Decode on an empty io.Reader would error, so
// this endpoint reads the body directly instead of reusing
// decodeJSON, tolerating "no body at all" as "use defaults."
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
if r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
}
plaintext := req.Password
generated := false
if plaintext == "" {
raw, _, err := newOpaqueToken()
if err != nil {
h.logger.Error("generating random password", "error", err)
writeError(w, http.StatusInternalServerError, "resetting password failed")
return
}
plaintext = raw
generated = true
} else if len(plaintext) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
hash, err := HashPassword(plaintext)
if err != nil {
h.logger.Error("hashing password", "error", err)
writeError(w, http.StatusInternalServerError, "resetting password failed")
return
}
if err := h.store.SetPasswordHash(r.Context(), r.PathValue("id"), hash); err != nil {
h.writeStoreErr(w, err, "resetting password")
return
}
resp := resetPasswordResponse{}
if generated {
resp.Password = plaintext
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) setCookie(w http.ResponseWriter, raw string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: raw,
Domain: h.cookies.Domain,
Path: "/",
MaxAge: int(ttl.Seconds()),
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
}
func (h *Handler) clearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Domain: h.cookies.Domain,
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
}
func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "not found")
return
}
h.logger.Error(action, "error", err)
writeError(w, http.StatusInternalServerError, action+" failed")
}
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type errorResponse struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
}
+261
View File
@@ -0,0 +1,261 @@
package localauth
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/sentry/sentry/api/authz"
)
func newTestHandler(t *testing.T, fs *fakeStore) (*Handler, *http.ServeMux) {
t.Helper()
authorizer := NewAuthorizer(fs)
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs, authorizer, time.Hour, CookieConfig{})
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return h, mux
}
func doRequest(t *testing.T, mux *http.ServeMux, method, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, r)
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func mustCreateUser(t *testing.T, fs *fakeStore, username, password string, role authz.Role) *User {
t.Helper()
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("hashing password: %v", err)
}
u, err := fs.CreateUser(t.Context(), username, hash, role)
if err != nil {
t.Fatalf("creating user: %v", err)
}
return u
}
func sessionCookieFrom(rec *httptest.ResponseRecorder) *http.Cookie {
for _, c := range rec.Result().Cookies() {
if c.Name == sessionCookieName {
return c
}
}
return nil
}
func TestLoginSuccess(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
rec := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
cookie := sessionCookieFrom(rec)
if cookie == nil || cookie.Value == "" {
t.Fatalf("expected a session cookie to be set, got none")
}
if !cookie.HttpOnly {
t.Errorf("session cookie must be HttpOnly")
}
var resp sessionResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Token == "" {
t.Errorf("expected the response body to also carry the raw token for non-browser callers")
}
if resp.Role != "editor" {
t.Errorf("role = %q, want editor", resp.Role)
}
}
func TestLoginWrongPassword(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
rec := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong"}`, nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestLoginUnknownUserSameErrorAsWrongPassword(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
unknown := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"bob","password":"whatever"}`, nil)
wrongPass := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong"}`, nil)
if unknown.Code != http.StatusUnauthorized || wrongPass.Code != http.StatusUnauthorized {
t.Fatalf("both must be 401, got unknown=%d wrongPass=%d", unknown.Code, wrongPass.Code)
}
if unknown.Body.String() != wrongPass.Body.String() {
t.Errorf("responses must be identical (no username enumeration): unknown=%q wrongPass=%q", unknown.Body.String(), wrongPass.Body.String())
}
}
func TestSessionRequiresAuth(t *testing.T) {
fs := newFakeStore()
_, mux := newTestHandler(t, fs)
rec := doRequest(t, mux, http.MethodGet, "/auth/session", "", nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 with no session cookie", rec.Code)
}
}
func TestLoginThenSessionRoundTrip(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
cookie := sessionCookieFrom(login)
sess := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie)
if sess.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", sess.Code, sess.Body.String())
}
var resp sessionResponse
if err := json.Unmarshal(sess.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Username != "alice" {
t.Errorf("username = %q, want alice", resp.Username)
}
}
func TestLogoutInvalidatesSession(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
cookie := sessionCookieFrom(login)
logout := doRequest(t, mux, http.MethodPost, "/auth/logout", "", cookie)
if logout.Code != http.StatusNoContent {
t.Fatalf("logout status = %d, want 204", logout.Code)
}
sess := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie)
if sess.Code != http.StatusUnauthorized {
t.Fatalf("status after logout = %d, want 401 (session must be revoked)", sess.Code)
}
}
func TestNonOwnerCannotManageUsers(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
cookie := sessionCookieFrom(login)
rec := doRequest(t, mux, http.MethodGet, "/auth/users", "", cookie)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 for a non-owner listing users", rec.Code)
}
}
func TestOwnerCanCreateAndDeleteUsers(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
cookie := sessionCookieFrom(login)
create := doRequest(t, mux, http.MethodPost, "/auth/users", `{"username":"bob","password":"bobspassword","role":"viewer"}`, cookie)
if create.Code != http.StatusCreated {
t.Fatalf("create status = %d, want 201; body=%s", create.Code, create.Body.String())
}
var created userResponse
if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil {
t.Fatalf("decoding response: %v", err)
}
if created.Role != "viewer" {
t.Errorf("role = %q, want viewer", created.Role)
}
if created.CreatedAt.IsZero() {
t.Errorf("created_at was not populated in the create response")
}
list := doRequest(t, mux, http.MethodGet, "/auth/users", "", cookie)
var users []userResponse
if err := json.Unmarshal(list.Body.Bytes(), &users); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(users) != 2 {
t.Fatalf("len(users) = %d, want 2 (admin + bob)", len(users))
}
del := doRequest(t, mux, http.MethodDelete, "/auth/users/"+created.ID, "", cookie)
if del.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, want 204", del.Code)
}
}
func TestCreateUserRejectsShortPassword(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
cookie := sessionCookieFrom(login)
rec := doRequest(t, mux, http.MethodPost, "/auth/users", `{"username":"bob","password":"short"}`, cookie)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for a too-short password", rec.Code)
}
}
func TestResetPasswordRevokesExistingSessions(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
bob := mustCreateUser(t, fs, "bob", "bobspassword", authz.RoleViewer)
_, mux := newTestHandler(t, fs)
adminLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
adminCookie := sessionCookieFrom(adminLogin)
bobLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"bob","password":"bobspassword"}`, nil)
bobCookie := sessionCookieFrom(bobLogin)
reset := doRequest(t, mux, http.MethodPost, "/auth/users/"+bob.ID+"/reset-password", "", adminCookie)
if reset.Code != http.StatusOK {
t.Fatalf("reset status = %d, want 200; body=%s", reset.Code, reset.Body.String())
}
var resp resetPasswordResponse
if err := json.Unmarshal(reset.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Password == "" {
t.Fatalf("expected a generated password in the response when none was supplied")
}
stale := doRequest(t, mux, http.MethodGet, "/auth/session", "", bobCookie)
if stale.Code != http.StatusUnauthorized {
t.Fatalf("bob's pre-reset session status = %d, want 401 (reset must revoke existing sessions)", stale.Code)
}
}
+29
View File
@@ -0,0 +1,29 @@
package localauth
import "golang.org/x/crypto/bcrypt"
// dummyPasswordHash is a precomputed bcrypt hash of an arbitrary,
// never-used-as-a-real-password string -- handleLogin runs
// ComparePassword against this on the "no such user" path purely to pay
// the same bcrypt cost the "wrong password" path already pays, closing
// a response-time side channel that would otherwise let a caller
// distinguish the two despite their identical error message. There is
// no real password behind this hash; it exists only to burn comparable
// CPU time.
const dummyPasswordHash = "$2a$10$fH9R3O6ViQ6c7bq0N7yyBO1JP2TOw/bZEopMyZKBYrBjgYBZO9rCa"
// HashPassword and ComparePassword are the only two places this package
// touches a raw password -- everywhere else, a user is identified by an
// already-issued session token (see token.go), never by re-checking a
// password on every request.
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func ComparePassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
+91
View File
@@ -0,0 +1,91 @@
package localauth
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
// loginLimiter is a simple in-memory sliding-window rate limiter for
// POST /auth/login, keyed by client IP -- closes a real gap the
// security audit found: nothing in the application, nginx, or the host
// (no fail2ban either) throttled repeated login attempts, making
// sustained online brute-forcing of a weaker, human-chosen password
// possible. (The auto-generated admin password is high-entropy, but
// every user created afterward only needs 8 characters with no
// complexity check -- see handleCreateUser.)
//
// Per-IP rather than per-username: a per-username-only limiter is
// itself a denial-of-service vector (deliberately fail a real
// username's login repeatedly, from anywhere, to lock them out), and
// wouldn't bound an attacker guessing across many usernames from one
// source. Both successful and failed attempts count against the
// window, not just failures -- simpler, and it means a low-and-slow
// guesser can't reset their budget by occasionally succeeding against
// an unrelated account.
//
// Deliberately in-memory, not Postgres-backed: login rate limiting is
// inherently best-effort per-process state (a restart clearing it is
// fine, unlike a session or password), and adding a database
// round-trip to every login attempt is the wrong tradeoff for a check
// whose only job is bounding attempt *rate*. Memory for IPs that stop
// attempting entirely is only reclaimed the next time that exact key is
// looked up -- a deliberate, bounded-in-practice simplicity tradeoff
// (real attacker/user IP cardinality against one deployment is small
// relative to a process's lifetime between deploys), not an oversight.
type loginLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
max int
window time.Duration
}
func newLoginLimiter(max int, window time.Duration) *loginLimiter {
return &loginLimiter{attempts: map[string][]time.Time{}, max: max, window: window}
}
// allow reports whether key may attempt another login right now, and
// records this attempt if so (a denied call does not itself count as a
// new attempt -- it just reports the existing window is full).
func (l *loginLimiter) allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
cutoff := now.Add(-l.window)
var kept []time.Time
for _, t := range l.attempts[key] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= l.max {
l.attempts[key] = kept
return false
}
l.attempts[key] = append(kept, now)
return true
}
// clientIP extracts the caller's address for rate-limiting purposes.
// Trusts the first hop of X-Forwarded-For when present -- correct for
// this deployment's actual topology (always behind nginx, which sets
// it), but note this is spoofable by any caller that reaches the
// application directly rather than through the trusted proxy; a
// deployment that exposes api's port directly to untrusted clients
// should not rely on this header. Falls back to r.RemoteAddr, which is
// always accurate for whoever the TCP connection is actually with.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if first, _, ok := strings.Cut(xff, ","); ok {
return strings.TrimSpace(first)
}
return strings.TrimSpace(xff)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
+84
View File
@@ -0,0 +1,84 @@
package localauth
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/sentry/sentry/api/authz"
)
func TestLoginLimiterAllowsUpToMax(t *testing.T) {
l := newLoginLimiter(3, time.Minute)
for i := 0; i < 3; i++ {
if !l.allow("1.2.3.4") {
t.Fatalf("attempt %d: want allowed", i+1)
}
}
if l.allow("1.2.3.4") {
t.Fatal("4th attempt within the window: want denied")
}
}
func TestLoginLimiterIsPerKey(t *testing.T) {
l := newLoginLimiter(1, time.Minute)
if !l.allow("1.2.3.4") {
t.Fatal("first attempt from 1.2.3.4: want allowed")
}
if !l.allow("5.6.7.8") {
t.Fatal("a different IP must have its own budget")
}
if l.allow("1.2.3.4") {
t.Fatal("second attempt from 1.2.3.4: want denied")
}
}
func TestLoginLimiterResetsAfterWindow(t *testing.T) {
l := newLoginLimiter(1, 10*time.Millisecond)
if !l.allow("1.2.3.4") {
t.Fatal("first attempt: want allowed")
}
if l.allow("1.2.3.4") {
t.Fatal("second attempt within the window: want denied")
}
time.Sleep(20 * time.Millisecond)
if !l.allow("1.2.3.4") {
t.Fatal("attempt after the window elapsed: want allowed")
}
}
func TestClientIPPrefersForwardedFor(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/auth/login", nil)
r.RemoteAddr = "10.0.0.1:5555"
r.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
if got := clientIP(r); got != "203.0.113.9" {
t.Errorf("clientIP() = %q, want %q", got, "203.0.113.9")
}
}
func TestClientIPFallsBackToRemoteAddr(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/auth/login", nil)
r.RemoteAddr = "198.51.100.7:5555"
if got := clientIP(r); got != "198.51.100.7" {
t.Errorf("clientIP() = %q, want %q", got, "198.51.100.7")
}
}
// TestHandleLoginRateLimited is the regression test for the
// security-audit finding that POST /auth/login had no rate limiting at
// all -- repeated attempts from the same client must eventually get a
// 429, not another 401.
func TestHandleLoginRateLimited(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
var last *httptest.ResponseRecorder
for i := 0; i < loginRateLimitMax+1; i++ {
last = doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong-password"}`, nil)
}
if last.Code != http.StatusTooManyRequests {
t.Fatalf("status after exceeding the limit = %d, want 429", last.Code)
}
}
+302
View File
@@ -0,0 +1,302 @@
// Package localauth is single-tenant mode's local username/password
// login: a real login page and session-based auth covering both /api
// and /alerting, plus a simple admin-managed user list, for deployments
// reachable over the internet that can no longer rely on Phase 0-3's
// "no auth yet" default (see /docs/architecture.md and CLAUDE.md's
// Phase 4 section for the enterprise/ SSO alternative this is not --
// this package has no tenant/RBAC-service concept, just "is this a
// valid logged-in user").
//
// Deliberately extends the existing users/tenants/tenant_memberships
// schema (0017/0018/0020_*.sql, built for Phase 4 SSO) rather than a
// parallel local_users table: tenant_memberships.role is already
// constrained to exactly authz.Role's four human values, so a local
// login gets real 4-tier roles for free, and a deployment that later
// turns on enterprise SSO has one identity graph to reconcile, not two.
// Every local user is a member of the "default" tenant only -- this
// package has no notion of provisioning additional tenants.
//
// Authorizer (authorizer.go) is what api/cmd/api/main.go wires into
// api/authz's Authorizer slot for a single-tenant deployment that wants
// real auth -- once that's non-nil, every existing RequireRole-wrapped
// route in dashboards/agents/queryapi/aiapi starts enforcing roles for
// free, no other handler file needs to change. alerting has no such
// per-route plumbing at all, so it gets its own, much smaller,
// deliberately-duplicated package (alerting/internal/sessioncheck) that
// only ever validates an already-issued session -- see that package's
// doc comment for why this isn't imported from here instead.
package localauth
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/authz"
)
var (
ErrNotFound = errors.New("not found")
ErrUsernameTaken = errors.New("username already taken")
)
// defaultTenantID is the only tenant a local user can ever belong to --
// see the package doc comment. Matches every other single-tenant
// deployment's "default" tenant_id convention (dashboards, agents,
// alert_rules).
const defaultTenantID = "default"
type User struct {
ID string
Username string
Role authz.Role
CreatedAt time.Time
}
type Session struct {
UserID string
TenantID string
Role authz.Role
ExpiresAt time.Time
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// CreateUser inserts a new local user and, in the same transaction, the
// tenant_memberships row that gives them role in the default tenant --
// a local user with no membership row would authenticate successfully
// (CreateSession has nothing that requires one) but satisfy no
// RequireRole check at all, so the two rows are never created
// separately.
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string, role authz.Role) (*User, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
id := uuid.NewString()
var createdAt time.Time
err = tx.QueryRow(ctx, `
INSERT INTO users (id, username, password_hash, display_name, created_at, updated_at)
VALUES ($1, $2, $3, $2, now(), now())
RETURNING created_at`,
id, username, passwordHash).Scan(&createdAt)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrUsernameTaken
}
return nil, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO tenant_memberships (id, tenant_id, user_id, role)
VALUES ($1, $2, $3, $4)`,
uuid.NewString(), defaultTenantID, id, string(role)); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &User{ID: id, Username: username, Role: role, CreatedAt: createdAt}, nil
}
const listColumns = `
u.id, u.username, tm.role, u.created_at`
// ListUsers only ever returns local users (username IS NOT NULL) --
// an SSO-provisioned user with no password_hash/username set never
// appears here, since there's nothing for this package's user manager
// to do with one.
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.pool.Query(ctx, `
SELECT `+listColumns+`
FROM users u
JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1
WHERE u.username IS NOT NULL
ORDER BY u.username`, defaultTenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
var role string
if err := rows.Scan(&u.ID, &u.Username, &role, &u.CreatedAt); err != nil {
return nil, err
}
u.Role = authz.Role(role)
out = append(out, u)
}
return out, rows.Err()
}
// GetUserForLogin returns the user and their password hash together --
// the only place this package ever reads a password_hash back out, and
// only to feed ComparePassword. Everywhere else uses User, which never
// carries the hash.
func (s *Store) GetUserForLogin(ctx context.Context, username string) (*User, string, error) {
var u User
var role, hash string
err := s.pool.QueryRow(ctx, `
SELECT u.id, u.username, u.password_hash, tm.role, u.created_at
FROM users u
JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1
WHERE u.username = $2`, defaultTenantID, username).
Scan(&u.ID, &u.Username, &hash, &role, &u.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", ErrNotFound
}
return nil, "", err
}
u.Role = authz.Role(role)
return &u, hash, nil
}
// GetUserByID backs GET /auth/session -- looking up the identity
// RequireRole already resolved and attached to the request context, to
// return its username (Session/Identity carry no username, only IDs).
func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
var u User
var role string
err := s.pool.QueryRow(ctx, `
SELECT u.id, u.username, tm.role, u.created_at
FROM users u
JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1
WHERE u.id = $2 AND u.username IS NOT NULL`, defaultTenantID, id).
Scan(&u.ID, &u.Username, &role, &u.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
u.Role = authz.Role(role)
return &u, nil
}
// DeleteUser cascades to the user's tenant_memberships and
// local_sessions rows (both ON DELETE CASCADE) -- a deleted user's
// existing sessions stop validating immediately, not just their next
// login.
func (s *Store) DeleteUser(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM users WHERE id = $1 AND username IS NOT NULL`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// SetPasswordHash also revokes every existing session for userID, in
// the same transaction -- Session.Role/TenantID are a snapshot taken at
// login (see 0041_create_local_sessions.sql's doc comment), so without
// this an account whose password was just reset for security reasons
// would keep any already-issued session working regardless.
func (s *Store) SetPasswordHash(ctx context.Context, userID, hash string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2 AND username IS NOT NULL`, hash, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `DELETE FROM local_sessions WHERE user_id = $1`, userID); err != nil {
return err
}
return tx.Commit(ctx)
}
// CountLocalUsers backs -seed-admin's idempotency check (see
// cmd/api/main.go's runSeedAdmin): a deployment that already has at
// least one local user never gets a second auto-created admin account.
func (s *Store) CountLocalUsers(ctx context.Context) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE username IS NOT NULL`).Scan(&n)
return n, err
}
// CreateSession mints a fresh opaque token for an already-authenticated
// user (login has already verified their password by the time this is
// called) and stores its hash plus a role/tenant snapshot. Returns the
// raw token -- the only time it's ever available in plaintext again
// after this call.
func (s *Store) CreateSession(ctx context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error) {
raw, hash, err := newOpaqueToken()
if err != nil {
return "", err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO local_sessions (id, user_id, tenant_id, role, token_hash, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
uuid.NewString(), userID, tenantID, string(role), hash, time.Now().Add(ttl))
if err != nil {
return "", err
}
return raw, nil
}
// GetSession looks up an already-hashed lookup key rather than a raw
// token -- see authorizer.go, the only caller, which re-derives the
// hash from whatever the request presented before calling this.
// Deliberately does not delete an expired row itself (that's a plain
// SELECT with no side effect); the goal here is a fast, obviously-
// correct read path, not a lookup that also mutates state, so
// expired-session cleanup is a separate, simpler concern.
func (s *Store) GetSession(ctx context.Context, tokenHash string) (*Session, error) {
var sess Session
var role string
err := s.pool.QueryRow(ctx, `
SELECT user_id, tenant_id, role, expires_at
FROM local_sessions WHERE token_hash = $1`, tokenHash).
Scan(&sess.UserID, &sess.TenantID, &role, &sess.ExpiresAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
sess.Role = authz.Role(role)
if sess.ExpiresAt.Before(time.Now()) {
return nil, ErrNotFound
}
return &sess, nil
}
// DeleteSessionByHash backs logout -- a no-op (not an error) if the
// session is already gone, matching logout's own "always succeeds"
// posture (handler.go's handleLogout).
func (s *Store) DeleteSessionByHash(ctx context.Context, tokenHash string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM local_sessions WHERE token_hash = $1`, tokenHash)
return err
}
// isUniqueViolation checks for Postgres error code 23505 (unique_violation),
// same pgconn.PgError.Code pattern rbacstore.go's SetDataSourceCredentials
// already uses for 22P02.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
+157
View File
@@ -0,0 +1,157 @@
// Exercises the actual parameterized SQL in store.go against a real
// Postgres -- handler_test.go's fakeStore is hand-written to mimic this
// SQL's behavior, but can't catch a real gap like a typo in a WHERE
// clause, a wrong column name, or (the specific thing worth testing
// here) whether 0040/0041's schema/FK/CHECK constraints actually hold
// the shape this package assumes. Same "skip unless a live-Postgres env
// var is set" convention as api/dashboards/store_integration_test.go.
//
// Skipped unless LOCALAUTH_TEST_POSTGRES_ADDR is set; run via:
//
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/api \
// -e LOCALAUTH_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
// -e LOCALAUTH_TEST_POSTGRES_PASSWORD=sentry-dev-only \
// golang:1.25-alpine go test ./localauth/... -run Integration -v
package localauth
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/authz"
)
func integrationStore(t *testing.T) *Store {
t.Helper()
addr := os.Getenv("LOCALAUTH_TEST_POSTGRES_ADDR")
if addr == "" {
t.Skip("LOCALAUTH_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test")
}
password := os.Getenv("LOCALAUTH_TEST_POSTGRES_PASSWORD")
dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr)
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("opening pool: %v", err)
}
t.Cleanup(pool.Close)
return NewStore(pool)
}
func testUsername(t *testing.T) string {
t.Helper()
return "test-" + uuid.NewString()[:8]
}
func TestIntegrationCreateAndLoginUser(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatalf("hashing password: %v", err)
}
created, err := store.CreateUser(ctx, username, hash, authz.RoleEditor)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, created.ID) })
got, gotHash, err := store.GetUserForLogin(ctx, username)
if err != nil {
t.Fatalf("GetUserForLogin: %v", err)
}
if got.ID != created.ID || got.Role != authz.RoleEditor {
t.Errorf("GetUserForLogin = %+v, want id=%s role=editor", got, created.ID)
}
if !ComparePassword(gotHash, "correct horse battery staple") {
t.Errorf("stored hash does not verify against the original password")
}
}
func TestIntegrationDuplicateUsernameRejected(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
created, err := store.CreateUser(ctx, username, hash, authz.RoleViewer)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, created.ID) })
if _, err := store.CreateUser(ctx, username, hash, authz.RoleViewer); !errors.Is(err, ErrUsernameTaken) {
t.Fatalf("second CreateUser with the same username: err = %v, want ErrUsernameTaken", err)
}
}
func TestIntegrationSessionRoundTripAndExpiry(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
user, err := store.CreateUser(ctx, username, hash, authz.RoleAdmin)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) })
raw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleAdmin, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
sess, err := store.GetSession(ctx, hashToken(raw))
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if sess.UserID != user.ID || sess.Role != authz.RoleAdmin {
t.Errorf("GetSession = %+v, want user_id=%s role=admin", sess, user.ID)
}
// An already-expired session (negative TTL) must not validate --
// exercises the real expires_at comparison against Postgres's own
// now(), not just Go's clock.
expiredRaw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleAdmin, -time.Hour)
if err != nil {
t.Fatalf("CreateSession (expired): %v", err)
}
if _, err := store.GetSession(ctx, hashToken(expiredRaw)); !errors.Is(err, ErrNotFound) {
t.Errorf("GetSession on an expired session: err = %v, want ErrNotFound", err)
}
}
func TestIntegrationSetPasswordHashRevokesSessions(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
user, err := store.CreateUser(ctx, username, hash, authz.RoleViewer)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) })
raw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleViewer, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
newHash, _ := HashPassword("a-new-password")
if err := store.SetPasswordHash(ctx, user.ID, newHash); err != nil {
t.Fatalf("SetPasswordHash: %v", err)
}
if _, err := store.GetSession(ctx, hashToken(raw)); !errors.Is(err, ErrNotFound) {
t.Errorf("GetSession after password reset: err = %v, want ErrNotFound (reset must revoke existing sessions)", err)
}
}
+37
View File
@@ -0,0 +1,37 @@
package localauth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
// newOpaqueToken returns a fresh session credential: raw is what's set
// in the cookie/returned to the caller (base64url, URL/cookie-safe),
// hash is what's stored in local_sessions.token_hash. Only the hash is
// ever persisted -- same reasoning 0034_create_ingest_credentials.sql
// gives for hashing its own bearer tokens: the server only ever needs
// to check "does the presented value match," never recover the raw
// value.
func newOpaqueToken() (raw, hash string, err error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", "", err
}
raw = base64.RawURLEncoding.EncodeToString(buf)
return raw, hashToken(raw), nil
}
// hashToken re-derives a token's hash from a raw value a caller
// presents (Authorization header or cookie), for lookup against
// local_sessions.token_hash. Plain SHA-256, not bcrypt: unlike a
// password, a session token is already high-entropy random data, not
// something an attacker could feasibly brute-force offline even from a
// leaked hash, so there's no need for bcrypt's deliberate slowness here
// -- alerting/internal/sessioncheck validates sessions on every request
// and does the same plain hash, with no bcrypt dependency at all.
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
+205
View File
@@ -0,0 +1,205 @@
// Command surface for api/localauth -- single-tenant mode's local
// username/password login and user manager (see /docs -- deployment
// runbook, and api/localauth's package doc comment for the full
// feature). Same list/create/delete shape as agents/dashboards, plus a
// "login" subcommand: unlike every other resource this CLI manages,
// there's no way to get a first SENTRYCTL_TOKEN without one.
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func cmdUsers(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "sentryctl users: expected a subcommand (login, list, create, delete, reset-password)")
return 1
}
apiURL, rest := extractAPIFlag(args[1:], os.Getenv)
token := resolveToken(os.Getenv)
switch args[0] {
case "login":
if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl users login: missing username")
return 1
}
return cmdUsersLogin(rest[0], rest[1:], apiURL, stdin, stdout, stderr)
case "list":
return httpGetJSON(apiURL, "/auth/users", token, stdout, stderr)
case "create":
if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl users create: missing username")
return 1
}
return cmdUsersCreate(rest[0], rest[1:], apiURL, token, stdin, stdout, stderr)
case "delete":
if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl users delete: missing user id")
return 1
}
return httpMutateNoBody(http.MethodDelete, apiURL, "/auth/users/"+rest[0], token, "", "user deleted", stdout, stderr)
case "reset-password":
if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl users reset-password: missing user id")
return 1
}
return cmdUsersResetPassword(rest[0], rest[1:], apiURL, token, stdin, stdout, stderr)
default:
fmt.Fprintf(stderr, "sentryctl users: unknown subcommand %q (want login, list, create, delete, reset-password)\n", args[0])
return 1
}
}
// extractPasswordStdinFlag pulls the boolean --password-stdin flag out
// of args if present -- same "walk args, splice out the one flag this
// caller cares about" shape extractAPIFlag already uses at the
// top-level dispatch layer. Unlike the --password <value> flag this
// replaced (security-audit finding L-4), this flag never carries the
// secret itself -- only readPasswordFromStdin's caller decides to
// actually read one, same "docker login --password-stdin" convention,
// chosen over inventing a new one: a plaintext password passed as a CLI
// argument is visible to any other local user via `ps`/
// `/proc/<pid>/cmdline` and typically lands in shell history too.
func extractPasswordStdinFlag(args []string) (useStdin bool, rest []string) {
for _, a := range args {
if a == "--password-stdin" {
useStdin = true
continue
}
rest = append(rest, a)
}
return useStdin, rest
}
// readPasswordFromStdin reads a single line from stdin. Not masked
// (this codebase has no terminal/raw-mode dependency to draw on -- see
// resolveToken's doc comment for the same tradeoff already accepted for
// SENTRYCTL_TOKEN); pipe the value in (`echo "$PW" | sentryctl users
// login admin`) rather than typing it at an interactive terminal where
// that matters.
func readPasswordFromStdin(stdin io.Reader) (string, error) {
line, err := bufio.NewReader(stdin).ReadString('\n')
if err != nil && line == "" {
return "", err
}
return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil
}
type loginRequestBody struct {
Username string `json:"username"`
Password string `json:"password"`
}
type loginResponseBody struct {
Token string `json:"token"`
Error string `json:"error"`
}
// cmdUsersLogin prints only the raw token to stdout on success (nothing
// else) -- deliberately pipeable: `export SENTRYCTL_TOKEN=$(sentryctl
// users login admin)`.
func cmdUsersLogin(username string, _ []string, apiURL string, stdin io.Reader, stdout, stderr io.Writer) int {
password, err := readPasswordFromStdin(stdin)
if err != nil {
fmt.Fprintf(stderr, "reading password: %v\n", err)
return 1
}
body, err := json.Marshal(loginRequestBody{Username: username, Password: password})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
req, err := http.NewRequest(http.MethodPost, apiURL+"/auth/login", strings.NewReader(string(body)))
if err != nil {
fmt.Fprintf(stderr, "building request: %v\n", err)
return 1
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
fmt.Fprintf(stderr, "request failed: %v\n", err)
return 1
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(stderr, "reading response: %v\n", err)
return 1
}
var login loginResponseBody
_ = json.Unmarshal(respBody, &login)
if resp.StatusCode != http.StatusOK {
if login.Error != "" {
fmt.Fprintf(stderr, "login failed: %s\n", login.Error)
} else {
fmt.Fprintf(stderr, "login failed: status %d\n", resp.StatusCode)
}
return 1
}
fmt.Fprintln(stdout, login.Token)
return 0
}
func cmdUsersCreate(username string, flagArgs []string, apiURL, token string, stdin io.Reader, stdout, stderr io.Writer) int {
role := "editor"
for i := 0; i < len(flagArgs); i++ {
if flagArgs[i] == "--role" && i+1 < len(flagArgs) {
role = flagArgs[i+1]
i++
continue
}
}
password, err := readPasswordFromStdin(stdin)
if err != nil {
fmt.Fprintf(stderr, "reading password: %v\n", err)
return 1
}
body, err := json.Marshal(struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}{Username: username, Password: password, Role: role})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
return httpPostJSON(apiURL, "/auth/users", token, string(body), stdout, stderr)
}
// cmdUsersResetPassword defaults to requesting a server-generated
// random password (empty body -- see api/localauth's handleResetPassword
// doc comment): pass --password-stdin to instead set a specific password
// read from stdin. There is deliberately no --password <value> flag (see
// extractPasswordStdinFlag's doc comment) -- a specific password chosen
// this way must be piped in, never typed as a bare CLI argument.
func cmdUsersResetPassword(id string, flagArgs []string, apiURL, token string, stdin io.Reader, stdout, stderr io.Writer) int {
useStdin, _ := extractPasswordStdinFlag(flagArgs)
body := "{}"
if useStdin {
password, err := readPasswordFromStdin(stdin)
if err != nil {
fmt.Fprintf(stderr, "reading password: %v\n", err)
return 1
}
encoded, err := json.Marshal(struct {
Password string `json:"password"`
}{Password: password})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
body = string(encoded)
}
return httpPostJSON(apiURL, "/auth/users/"+id+"/reset-password", token, body, stdout, stderr)
}
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCmdUsersMissingSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdUsers(nil, strings.NewReader(""), &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdUsersLoginPrintsOnlyTheToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/auth/login" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body loginRequestBody
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Username != "admin" || body.Password != "s3cret!!" {
t.Errorf("unexpected credentials: %+v", body)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"token":"abc123","user_id":"u1","username":"admin","role":"owner"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"login", "admin", "--api", srv.URL}, strings.NewReader("s3cret!!\n"), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if got := strings.TrimSpace(stdout.String()); got != "abc123" {
t.Fatalf("stdout = %q, want exactly the raw token (pipeable into SENTRYCTL_TOKEN)", got)
}
}
func TestCmdUsersLoginFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"invalid username or password"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"login", "admin", "--api", srv.URL}, strings.NewReader("wrong\n"), &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "invalid username or password") {
t.Fatalf("stderr = %q, want it to surface the server's error message", stderr.String())
}
if stdout.String() != "" {
t.Fatalf("stdout = %q, want empty on failure (nothing pipeable into SENTRYCTL_TOKEN)", stdout.String())
}
}
func TestCmdUsersCreateSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/auth/users" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Role != "viewer" {
t.Errorf("role = %q, want viewer (from --role)", body.Role)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":"u2","username":"bob","role":"viewer"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"create", "bob", "--role", "viewer", "--api", srv.URL}, strings.NewReader("bobspassword\n"), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "bob") {
t.Fatalf("stdout = %q, want it to contain the created user", stdout.String())
}
}
func TestCmdUsersCreateDefaultsRoleToEditor(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Role string `json:"role"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Role != "editor" {
t.Errorf("role = %q, want editor (the default when --role is omitted)", body.Role)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":"u2","username":"bob","role":"editor"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"create", "bob", "--api", srv.URL}, strings.NewReader("bobspassword\n"), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
}
func TestCmdUsersDeleteMissingID(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"delete"}, strings.NewReader(""), &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdUsersDeleteSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/auth/users/u2" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"delete", "u2", "--api", srv.URL}, strings.NewReader(""), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
}
func TestCmdUsersResetPasswordWithGeneratedPassword(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/auth/users/u2/reset-password" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
if strings.TrimSpace(string(body)) != "{}" {
t.Errorf("body = %q, want {} (no --password-stdin supplied)", body)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"password":"generated-abc"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"reset-password", "u2", "--api", srv.URL}, strings.NewReader(""), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "generated-abc") {
t.Fatalf("stdout = %q, want it to contain the generated password", stdout.String())
}
}
// TestCmdUsersResetPasswordWithStdinPassword is the regression test for
// the security-audit finding that this CLI accepted a plaintext
// --password <value> flag (visible via `ps`/shell history). Setting a
// specific password must go through --password-stdin plus piped input
// instead, never a bare argument.
func TestCmdUsersResetPasswordWithStdinPassword(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Password != "a-specific-password" {
t.Errorf("password = %q, want the value piped via stdin", body.Password)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"reset-password", "u2", "--password-stdin", "--api", srv.URL}, strings.NewReader("a-specific-password\n"), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
}
+12 -1
View File
@@ -115,7 +115,18 @@ func httpMutateNoBody(method, baseURL, path, token, body, successMsg string, std
// for callers that construct the body themselves rather than reading it
// from a file (agents config set, agents restart).
func httpPutJSON(baseURL, path, token, body string, stdout, stderr io.Writer) int {
req, err := http.NewRequest(http.MethodPut, baseURL+path, strings.NewReader(body))
return httpSendJSON(http.MethodPut, baseURL, path, token, body, stdout, stderr)
}
// httpPostJSON is httpPutJSON's POST sibling -- for callers creating a
// resource from a body they built themselves rather than reading it
// from a file (users create, users reset-password).
func httpPostJSON(baseURL, path, token, body string, stdout, stderr io.Writer) int {
return httpSendJSON(http.MethodPost, baseURL, path, token, body, stdout, stderr)
}
func httpSendJSON(method, baseURL, path, token, body string, stdout, stderr io.Writer) int {
req, err := http.NewRequest(method, baseURL+path, strings.NewReader(body))
if err != nil {
fmt.Fprintf(stderr, "building request: %v\n", err)
return 1
+18
View File
@@ -40,6 +40,8 @@ func run(args []string, stdout, stderr io.Writer) int {
return cmdAlerts(args[1:], stdout, stderr)
case "agents":
return cmdAgents(args[1:], stdout, stderr)
case "users":
return cmdUsers(args[1:], os.Stdin, stdout, stderr)
case "-h", "--help", "help":
usage(stdout)
return 0
@@ -67,6 +69,11 @@ Usage:
[--heartbeat-enabled true|false] [--heartbeat-interval-ms N]
[--journald-unit UNIT] [--api <url>]
sentryctl agents restart <host> [--yes] [--api <url>]
sentryctl users login <username> [--password <pw>] [--api <url>]
sentryctl users list [--api <url>]
sentryctl users create <username> [--password <pw>] [--role viewer|editor|admin|owner] [--api <url>]
sentryctl users delete <id> [--api <url>]
sentryctl users reset-password <id> [--password <pw>] [--api <url>]
Commands:
ping Checks that the api service is reachable via GET /healthz.
@@ -95,6 +102,17 @@ Commands:
as the web UI's edit form. "restart" briefly interrupts
log collection on that host and prompts for confirmation
unless --yes is given.
users Local username/password login and user management (see
api/localauth -- only meaningful on a deployment with
LOCAL_AUTH_ENABLED set; a 404 on any of these means it
isn't). "login" is the only command that works with no
$SENTRYCTL_TOKEN set yet -- it prints just the raw token
to stdout: `+"`export SENTRYCTL_TOKEN=$(sentryctl users login admin)`"+`.
--password (on any users subcommand) is read from stdin
if omitted -- same shell-history/ps caveat as typing a
credential in any flag, prefer piping it in.
"create"/"list"/"delete"/"reset-password" require an
owner-role token (see RegisterRoutes in api/localauth).
--api defaults to $SENTRYCTL_API_URL, or `+defaultAPIURL+` if unset.
--alerting-api defaults to $SENTRYCTL_ALERTING_API_URL, or `+defaultAlertingURL+` if unset.
+4 -4
View File
@@ -45,11 +45,11 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/term v0.21.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.3.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
+10 -10
View File
@@ -121,8 +121,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -131,22 +131,22 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+3
View File
@@ -73,6 +73,9 @@ func main() {
logger.Error("loading config", "error", err)
os.Exit(1)
}
for _, w := range cfg.DevCredentialWarnings() {
logger.Warn(w)
}
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
os.Exit(runHealthcheck(cfg.HTTPListenAddr))
+3
View File
@@ -56,6 +56,9 @@ func main() {
logger.Error("loading config", "error", err)
os.Exit(1)
}
for _, w := range cfg.DevCredentialWarnings() {
logger.Warn(w)
}
// -mint-service-token issues a RoleService credential and prints it
// to stdout, then exits -- an operator bootstrap step (run once,
+3
View File
@@ -59,6 +59,9 @@ func main() {
logger.Error("loading config", "error", err)
os.Exit(1)
}
for _, w := range cfg.DevCredentialWarnings() {
logger.Warn(w)
}
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
os.Exit(runHealthcheck(cfg.HTTPListenAddr))
+6 -6
View File
@@ -36,6 +36,7 @@ require (
github.com/jackc/pgx/v5 v5.10.0
github.com/sentry/sentry/proto v0.0.0-00010101000000-000000000000
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
google.golang.org/grpc v1.83.0
k8s.io/api v0.31.0
k8s.io/apimachinery v0.31.0
@@ -45,7 +46,7 @@ require (
require (
github.com/ClickHouse/ch-go v0.74.0 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/beevik/etree v1.5.0 // indirect
github.com/beevik/etree v1.6.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
@@ -66,7 +67,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.19.1 // indirect
@@ -78,7 +79,7 @@ require (
github.com/paulmach/orb v0.13.0 // indirect
github.com/pierrec/lz4/v4 v4.1.27 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/russellhaering/goxmldsig v1.4.0 // indirect
github.com/russellhaering/goxmldsig v1.6.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/kafka-go v0.4.51 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
@@ -86,12 +87,11 @@ require (
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
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/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.3.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/protobuf v1.36.12 // indirect
+12 -21
View File
@@ -4,9 +4,8 @@ github.com/ClickHouse/clickhouse-go/v2 v2.48.0 h1:auzd4VkapQYhQF8F2Gog7s3x78Bi1J
github.com/ClickHouse/clickhouse-go/v2 v2.48.0/go.mod h1:lBjUCPRG6RpRQdMbkXq+JV8rY0/O5lw+Z7jShgReFjM=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs=
github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE=
github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
@@ -69,8 +68,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -79,9 +78,7 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -107,18 +104,15 @@ github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys=
github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks=
github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno=
@@ -165,8 +159,8 @@ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUS
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
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/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -191,16 +185,16 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -214,10 +208,8 @@ google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4J
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
@@ -226,7 +218,6 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
@@ -87,6 +87,34 @@ type AuditWriterConfig struct {
Password string
}
// devOnlyCredential/devOnlyAuditWriterCredential are docker-compose.yml's
// zero-config defaults -- see api/internal/config.Config.
// DevCredentialWarnings for the full reasoning (duplicated here per
// this repo's no-shared-code-between-services convention).
const (
devOnlyCredential = "sentry-dev-only"
devOnlyAuditWriterCredential = "audit-writer-dev-only"
)
// DevCredentialWarnings reports which configured credentials still
// equal their literal dev-only defaults -- cmd/enterprise-api/main.go
// logs each one loudly at startup. A warning, not a startup-refusing
// error: local dev's zero-config docker-compose.yml path legitimately
// leaves these at their default values.
func (c Config) DevCredentialWarnings() []string {
var warnings []string
if c.ClickHouseAdmin.Password == devOnlyCredential {
warnings = append(warnings, "CLICKHOUSE_ADMIN_PASSWORD is still the default dev-only value -- set a real password 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 before this is reachable outside local dev")
}
if c.AuditWriter.Password == devOnlyAuditWriterCredential {
warnings = append(warnings, "AUDIT_WRITER_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev")
}
return warnings
}
func Load() (Config, error) {
cfg := Config{
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8083"),
+30
View File
@@ -37,6 +37,36 @@ type Config struct {
CORSAllowedOrigin string
}
// devOnlyCredential is docker-compose.yml's zero-config default for
// every Postgres/ClickHouse password in this repo -- see
// api/internal/config.Config.DevCredentialWarnings for the full
// reasoning (duplicated here per this repo's no-shared-code-between-
// services convention). enterprise-auth also has its own dev-only
// literal for ENTERPRISE_SESSION_SIGNING_KEY, checked alongside it below
// -- per the threat model doc, this is "the single highest-value secret
// in the enterprise deployment," since compromising it lets an attacker
// forge any identity, including the RoleService credential.
const (
devOnlyCredential = "sentry-dev-only"
devOnlySigningKey = "sentry-dev-only-session-signing-key-32bytes+"
)
// DevCredentialWarnings reports which configured secrets still equal
// their literal dev-only defaults -- cmd/enterprise-*/main.go logs each
// one loudly at startup. A warning, not a startup-refusing error: local
// dev's zero-config docker-compose.yml path legitimately leaves these
// at their default values.
func (c Config) DevCredentialWarnings() []string {
var warnings []string
if c.Postgres.Password == devOnlyCredential {
warnings = append(warnings, "POSTGRES_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev")
}
if string(c.SessionSigningKey) == devOnlySigningKey {
warnings = append(warnings, "ENTERPRISE_SESSION_SIGNING_KEY is still the default dev-only value -- this is the single highest-value secret in an enterprise deployment (compromise lets an attacker forge any identity); set a real, random one before this is reachable outside local dev")
}
return warnings
}
type PostgresConfig struct {
Addr string
Database string
@@ -49,6 +49,25 @@ type BatchConfig struct {
FlushIntervalMS int
}
// devOnlyCredential is docker-compose.yml's zero-config default for
// every Postgres/ClickHouse password in this repo -- see
// api/internal/config.Config.DevCredentialWarnings for the full
// reasoning (duplicated here per this repo's no-shared-code-between-
// services convention).
const devOnlyCredential = "sentry-dev-only"
// DevCredentialWarnings reports whether the configured Postgres
// credential still equals the literal dev-only default --
// cmd/enterprise-ingest/main.go logs it loudly at startup. A warning,
// not a startup-refusing error: local dev's zero-config
// docker-compose.yml path legitimately leaves it at this value.
func (c Config) DevCredentialWarnings() []string {
if c.Postgres.Password == devOnlyCredential {
return []string{"POSTGRES_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev"}
}
return nil
}
func Load() (Config, error) {
cfg := Config{
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8084"),
+1 -1
View File
@@ -12,7 +12,7 @@ require (
require (
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/text v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
+2 -2
View File
@@ -26,8 +26,8 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
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=
+1 -1
View File
@@ -12,7 +12,7 @@ require (
require (
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/text v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
+2 -2
View File
@@ -26,8 +26,8 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
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=
+3
View File
@@ -69,6 +69,9 @@ func main() {
logger.Error("loading config", "error", err)
os.Exit(1)
}
for _, w := range cfg.DevCredentialWarnings() {
logger.Warn(w)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -39,6 +39,7 @@ type overrideFields struct {
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
HeartbeatIntervalMS *uint64 `json:"heartbeat_interval_ms,omitempty"`
JournaldUnit *string `json:"journald_unit,omitempty"`
ExtraFilePaths []string `json:"extra_file_paths,omitempty"`
}
type Registry struct {
@@ -146,6 +147,7 @@ func (r *Registry) CheckIn(ctx context.Context, tenantID string, info grpcserver
HeartbeatEnabled: fields.HeartbeatEnabled,
HeartbeatIntervalMS: fields.HeartbeatIntervalMS,
JournaldUnit: fields.JournaldUnit,
ExtraFilePaths: fields.ExtraFilePaths,
Version: *desiredVersion,
}
return result, nil
+23
View File
@@ -75,6 +75,29 @@ type BatchConfig struct {
FlushIntervalMS int
}
// devOnlyCredential is docker-compose.yml's zero-config default for
// every Postgres/ClickHouse password in this repo -- see
// api/internal/config.Config.DevCredentialWarnings for the full
// reasoning (duplicated here per this repo's no-shared-code-between-
// services convention).
const devOnlyCredential = "sentry-dev-only"
// DevCredentialWarnings reports which configured credentials still
// equal the literal dev-only default -- cmd/ingest/main.go logs each
// one loudly at startup. A warning, not a startup-refusing error: local
// dev's zero-config docker-compose.yml path legitimately leaves every
// password at this value.
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 before this is reachable outside local dev")
}
if c.AgentRegistry.Postgres.Password == devOnlyCredential {
warnings = append(warnings, "AGENT_REGISTRY_POSTGRES_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev")
}
return warnings
}
func Load() (Config, error) {
cfg := Config{
GRPC: GRPCConfig{
+6
View File
@@ -147,6 +147,11 @@ type AgentOverride struct {
HeartbeatEnabled *bool
HeartbeatIntervalMS *uint64
JournaldUnit *string
// Extra file paths this agent should tail in addition to its local
// [source] -- see agent_control.proto's DesiredOverride.
// extra_file_paths comment for why this has no "unset" state the
// way the pointer fields above do.
ExtraFilePaths []string
Version string
}
@@ -298,6 +303,7 @@ func (s *Server) CheckIn(ctx context.Context, req *agentv1.CheckInRequest) (*age
HeartbeatEnabled: result.Override.HeartbeatEnabled,
HeartbeatIntervalMs: result.Override.HeartbeatIntervalMS,
JournaldUnit: result.Override.JournaldUnit,
ExtraFilePaths: result.Override.ExtraFilePaths,
Version: result.Override.Version,
}
}
@@ -0,0 +1,18 @@
-- Local username/password login (single-tenant deployments with no
-- enterprise-auth/SSO configured) -- see api/localauth's package doc
-- comment. Reuses 0017's users table and 0020's tenant_memberships
-- rather than a parallel identity model, so a deployment that later
-- turns on enterprise SSO has one identity graph, not two to reconcile.
--
-- email relaxed to nullable: a local user has no SSO identity to hang
-- an email off of. enterprise/internal/rbacstore.UpsertUserBySSO
-- already requires a non-empty email in Go before ever inserting, so
-- this is a no-op for the SSO path.
ALTER TABLE users
ALTER COLUMN email DROP NOT NULL;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS username TEXT UNIQUE;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS password_hash TEXT;
@@ -0,0 +1,20 @@
-- Opaque, revocable session tokens for local login -- only the SHA-256
-- hash of the token is stored, same posture as 0034's
-- ingest_credentials: the server never needs the raw value back, only
-- "does this match." role/tenant_id are a snapshot taken at login, not
-- a live join to tenant_memberships on every request -- api/localauth's
-- Authorizer runs on every HTTP request, so this keeps that path to one
-- indexed lookup. Consequence: a role change doesn't take effect for an
-- existing session until it's revoked/expires and the user logs in
-- again -- api/localauth.Store.SetPasswordHash deliberately revokes a
-- user's sessions for exactly this reason.
CREATE TABLE IF NOT EXISTS local_sessions
(
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
role TEXT NOT NULL CHECK (role IN ('viewer', 'editor', 'admin', 'owner')),
token_hash TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
)
+1 -1
View File
@@ -10,6 +10,6 @@ require (
require (
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/text v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
)
+2 -2
View File
@@ -26,8 +26,8 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
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=
+21 -2
View File
@@ -273,6 +273,17 @@ type DesiredOverride struct {
// CheckInRequest.applied_override_version once applied -- it never
// interprets the value itself.
Version string `protobuf:"bytes,6,opt,name=version,proto3" json:"version,omitempty"`
// Extra file paths this agent should tail in addition to whatever its
// local [source] already is -- never a replacement for the primary
// source (an agent whose local source is journald can still be told
// to also tail a file, and vice versa). Unlike every field above,
// there's no real "unset" state for a list: the web UI/CLI always
// resubmit the complete desired list on every edit (same "PUT
// replaces the whole override" convention every other field already
// follows -- see api/agents/handler.go's handleSetConfig), so an
// empty list unambiguously means "no extra paths right now," not
// "don't touch this."
ExtraFilePaths []string `protobuf:"bytes,7,rep,name=extra_file_paths,json=extraFilePaths,proto3" json:"extra_file_paths,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -349,6 +360,13 @@ func (x *DesiredOverride) GetVersion() string {
return ""
}
func (x *DesiredOverride) GetExtraFilePaths() []string {
if x != nil {
return x.ExtraFilePaths
}
return nil
}
type CheckInResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// False when no override has ever been set for this agent -- it
@@ -442,14 +460,15 @@ const file_sentry_agent_v1_agent_control_proto_rawDesc = "" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x18\n" +
"\aservice\x18\x02 \x01(\tR\aservice\x12F\n" +
"\x0ecurrent_config\x18\x03 \x01(\v2\x1f.sentry.agent.v1.ReportedConfigR\rcurrentConfig\x128\n" +
"\x18applied_override_version\x18\x04 \x01(\tR\x16appliedOverrideVersion\"\x98\x03\n" +
"\x18applied_override_version\x18\x04 \x01(\tR\x16appliedOverrideVersion\"\xc2\x03\n" +
"\x0fDesiredOverride\x12)\n" +
"\x0ebatch_max_size\x18\x01 \x01(\x04H\x00R\fbatchMaxSize\x88\x01\x01\x12:\n" +
"\x17batch_flush_interval_ms\x18\x02 \x01(\x04H\x01R\x14batchFlushIntervalMs\x88\x01\x01\x120\n" +
"\x11heartbeat_enabled\x18\x03 \x01(\bH\x02R\x10heartbeatEnabled\x88\x01\x01\x127\n" +
"\x15heartbeat_interval_ms\x18\x04 \x01(\x04H\x03R\x13heartbeatIntervalMs\x88\x01\x01\x12(\n" +
"\rjournald_unit\x18\x05 \x01(\tH\x04R\fjournaldUnit\x88\x01\x01\x12\x18\n" +
"\aversion\x18\x06 \x01(\tR\aversionB\x11\n" +
"\aversion\x18\x06 \x01(\tR\aversion\x12(\n" +
"\x10extra_file_paths\x18\a \x03(\tR\x0eextraFilePathsB\x11\n" +
"\x0f_batch_max_sizeB\x1a\n" +
"\x18_batch_flush_interval_msB\x14\n" +
"\x12_heartbeat_enabledB\x18\n" +
+11
View File
@@ -69,6 +69,17 @@ message DesiredOverride {
// CheckInRequest.applied_override_version once applied -- it never
// interprets the value itself.
string version = 6;
// Extra file paths this agent should tail in addition to whatever its
// local [source] already is -- never a replacement for the primary
// source (an agent whose local source is journald can still be told
// to also tail a file, and vice versa). Unlike every field above,
// there's no real "unset" state for a list: the web UI/CLI always
// resubmit the complete desired list on every edit (same "PUT
// replaces the whole override" convention every other field already
// follows -- see api/agents/handler.go's handleSetConfig), so an
// empty list unambiguously means "no extra paths right now," not
// "don't touch this."
repeated string extra_file_paths = 7;
}
// AgentCommand is a one-shot action, not a persistent desired state like
+7 -7
View File
@@ -344,7 +344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -534,9 +534,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [
"atomic-waker",
"bytes",
@@ -698,7 +698,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.5",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -1089,7 +1089,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1548,7 +1548,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1954,7 +1954,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
+9 -9
View File
@@ -48,15 +48,15 @@ require (
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/zclconf/go-cty v1.18.1 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.39.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+30 -30
View File
@@ -163,34 +163,34 @@ github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -202,32 +202,32 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+7
View File
@@ -22,9 +22,16 @@ COPY . .
ARG VITE_API_BASE_URL=http://localhost:8080
ARG VITE_ALERTING_API_BASE_URL=http://localhost:8081
ARG VITE_ENTERPRISE_AUTH_BASE_URL
# Off by default -- local dev's plain WithCORS (wildcard
# Access-Control-Allow-Origin) can't be combined with a credentialed
# fetch at all, so api.ts must not send cookies unless the deployment
# actually turned local auth on server-side too (LOCAL_AUTH_ENABLED).
# See api.ts's requestFrom/alertingRequest doc comment.
ARG VITE_LOCAL_AUTH_ENABLED=false
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ENV VITE_ALERTING_API_BASE_URL=${VITE_ALERTING_API_BASE_URL}
ENV VITE_ENTERPRISE_AUTH_BASE_URL=${VITE_ENTERPRISE_AUTH_BASE_URL}
ENV VITE_LOCAL_AUTH_ENABLED=${VITE_LOCAL_AUTH_ENABLED}
RUN npm run build
# Not distroless: serving a static SPA needs *some* HTTP server, and
+37
View File
@@ -3,6 +3,43 @@ server {
root /usr/share/nginx/html;
index index.html;
# Security-audit remediation (M-3): baseline browser security headers,
# absent entirely before this. HSTS/nosniff/frame-options/referrer-
# policy/permissions-policy carry no functional risk to this app and
# are unconditionally safe to add.
#
# CSP is the one directive that needed real care rather than a
# copy-pasted strict default: adapter-static's own build output
# (web/build/index.html) genuinely contains two inline <script>
# blocks -- SvelteKit's own hydration bootstrap and the dark-mode-
# before-paint snippet -- so a naive `script-src 'self'` would break
# every page load, not just XSS. Hash-pinning those two scripts
# instead was considered and rejected: nginx.conf is a static file
# this Docker image ships unmodified, with nothing in the build
# pipeline that regenerates it per build, and SvelteKit's bootstrap
# script embeds a build-specific identifier -- a hardcoded hash here
# would silently start blocking the app's own hydration script on the
# next unrelated rebuild, a far worse outcome than the gap this is
# closing. `script-src`/`style-src` therefore keep `'unsafe-inline'`
# (inline style attributes are also genuinely used, e.g. the Hosts
# page's dynamic bar-width styling) -- this CSP is not a script-
# injection defense by itself (the codebase already has no known XSS
# vector to defend against -- see the security audit's "verified
# clean" section), it's the other directives doing real work:
# `object-src none` (no plugin-embed vector), `frame-ancestors none`
# (clickjacking, redundant with X-Frame-Options but cheap insurance),
# `base-uri self` (blocks base-tag hijacking), `form-action self`.
# `connect-src *` stays permissive rather than enumerating this
# deployment's actual api/alerting/enterprise-auth hosts, since those
# are configured per-deployment via VITE_*_BASE_URL build args (see
# web/Dockerfile) and a static nginx.conf has no way to know them.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src *; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
location / {
# adapter-static writes prerendered routes as flat <route>.html
# files (e.g. /dashboards -> dashboards.html, confirmed by
+164
View File
@@ -14,6 +14,18 @@ export const alertingBase = import.meta.env.VITE_ALERTING_API_BASE_URL ?? 'http:
// disabled, no broken links.
export const enterpriseAuthBase = import.meta.env.VITE_ENTERPRISE_AUTH_BASE_URL as string | undefined;
// Local login (see api/localauth's package doc comment). Baked in at
// build time same as the base URLs above -- requestFrom/alertingRequest
// below only send `credentials: 'include'` when this is true, since
// api's/alerting's own CORS stays the permissive wildcard-friendly
// WithCORS (no Access-Control-Allow-Credentials) unless the deployment
// set LOCAL_AUTH_ENABLED server-side too -- browsers categorically
// refuse to combine a credentialed fetch with a wildcard
// Access-Control-Allow-Origin, so sending credentials unconditionally
// would break every plain `docker compose up` local-dev deployment,
// which never sets either of these.
export const localAuthEnabled = import.meta.env.VITE_LOCAL_AUTH_ENABLED === 'true';
export type Language = '' | 'sql' | 'spl';
// warnings (Phase 7) is populated by the shared costguard package's
@@ -59,6 +71,7 @@ class ApiError extends Error {}
async function requestFrom<T>(base: string, path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${base}${path}`, {
headers: { 'Content-Type': 'application/json' },
...(localAuthEnabled ? { credentials: 'include' as RequestCredentials } : {}),
...init
});
if (!res.ok) {
@@ -393,6 +406,74 @@ export function injectTimeRange(query: string, earliest: string, latest: string)
return `${clauses.join(' ')} ${query}`;
}
// --- local login (single-tenant mode, see api/localauth) --------------
export type LocalSession = { user_id: string; tenant_id: string; username: string; role: string };
export function login(username: string, password: string): Promise<LocalSession & { token: string }> {
return request('/auth/login', {
method: 'POST',
credentials: 'include',
body: JSON.stringify({ username, password })
});
}
export function logout(): Promise<void> {
return request('/auth/logout', { method: 'POST', credentials: 'include' });
}
// getLocalSession is three-state, not a boolean -- +layout.ts's route
// guard needs to tell "not logged in" (null, redirect to /login) apart
// from "this deployment doesn't have local auth turned on at all"
// ('disabled', let the request through) -- GET /auth/session is only
// ever registered server-side when LOCAL_AUTH_ENABLED is set (see
// api/localauth.Handler.RegisterRoutes' doc comment), so a 404 here
// means the latter, same "absence is a normal deployment shape" posture
// getAuthFeatures/getCurrentSession above already use for enterprise
// auth. Always sends credentials regardless of the module-level
// localAuthEnabled flag -- this is the one call the route guard makes
// unconditionally to *discover* whether local auth is on, so it can't
// rely on that flag being true first.
export async function getLocalSession(): Promise<LocalSession | 'disabled' | null> {
try {
const res = await fetch(`${apiBase}/auth/session`, { credentials: 'include' });
if (res.status === 404) return 'disabled';
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export type LocalUser = { id: string; username: string; role: string; created_at: string };
export function listUsers(): Promise<LocalUser[]> {
return request<LocalUser[]>('/auth/users', { credentials: 'include' }).then((u) => u ?? []);
}
export function createUser(username: string, password: string, role: string): Promise<LocalUser> {
return request('/auth/users', {
method: 'POST',
credentials: 'include',
body: JSON.stringify({ username, password, role })
});
}
export function deleteUser(id: string): Promise<void> {
return request(`/auth/users/${id}`, { method: 'DELETE', credentials: 'include' });
}
// resetPassword's response only carries `password` when the caller
// didn't supply one -- see api/localauth/handler.go's
// resetPasswordResponse doc comment.
export function resetPassword(id: string, newPassword?: string): Promise<{ password?: string }> {
return request(`/auth/users/${id}/reset-password`, {
method: 'POST',
credentials: 'include',
body: JSON.stringify(newPassword ? { password: newPassword } : {})
});
}
// --- alerting ---------------------------------------------------------
export type ConditionType = 'threshold' | 'absence';
@@ -484,6 +565,7 @@ export type ConfigOverride = {
heartbeat_enabled?: boolean;
heartbeat_interval_ms?: number;
journald_unit?: string;
extra_file_paths?: string[];
};
export type Agent = {
@@ -545,3 +627,85 @@ export function issueAgentCommand(host: string, command: 'restart'): Promise<Age
body: JSON.stringify({ command })
});
}
// ---- Host CPU/memory/disk metrics ----
// No new REST endpoints -- a metrics sample is an ordinary log record
// (see agent/README.md's "Host CPU/memory/disk metrics" section and
// agent/sentry-agent/src/main.rs's send_metrics), tagged
// `sentry.metrics=true`, fetched through the same POST /query every
// other page already uses via runQuery(). Only ever set on one agent
// process per physical host, so `stats count by host` over this tag
// naturally lists real hosts, not every fragmented per-source agent
// identity `/agents` shows (see the deployment notes on why several
// agent processes can share one physical host under different
// `[agent] host` values).
export type HostSummary = { host: string; sampleCount: number };
export async function listMetricsHosts(): Promise<HostSummary[]> {
const result = await runQuery('sentry.metrics=true | stats count by host', 'spl');
const hostIdx = result.columns.indexOf('host');
const countIdx = result.columns.indexOf('count');
return result.rows.map((r) => ({ host: String(r[hostIdx]), sampleCount: Number(r[countIdx]) }));
}
export type HostMetrics = {
host: string;
timestamp: string;
cpuPercent: number;
memUsedBytes: number;
memTotalBytes: number;
diskUsedBytes: number;
diskTotalBytes: number;
// Static-or-slow-changing context (see agent/src/metrics.rs's
// Metrics doc comment) -- sent on the same record specifically so a
// viewer never has to correlate two different samples to make sense
// of the utilization numbers above (is 21% CPU busy or idle depends
// on core count; is this usage normal depends on uptime).
cpuCores: number;
osName: string;
kernelVersion: string;
arch: string;
uptimeSeconds: number;
ipv4Addresses: string[];
ipv6Addresses: string[];
};
// Reads straight out of the record's `attributes` object (already
// returned in full on every query result row) rather than trying to
// project attribute-derived fields as top-level query-language columns
// -- simpler, and doesn't depend on `fields` supporting synthetic
// attribute columns the same way filtering does.
export async function getHostMetrics(host: string): Promise<HostMetrics | null> {
const result = await runQuery(
`host="${host}" sentry.metrics=true | sort -timestamp | head 1`,
'spl'
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
const timestampIdx = result.columns.indexOf('timestamp');
const attributesIdx = result.columns.indexOf('attributes');
const attrs = (row[attributesIdx] ?? {}) as Record<string, string>;
const num = (key: string) => Number(attrs[key] ?? 0);
// Comma-joined by the agent (see agent/src/main.rs's send_metrics) --
// split back into a list here, filtering out the empty string a
// host with no addresses of a given family produces (''.split(',')
// is [''], not [], so the filter is load-bearing, not defensive).
const addrList = (key: string) => (attrs[key] ?? '').split(',').filter((a) => a !== '');
return {
host,
timestamp: String(row[timestampIdx]),
cpuPercent: num('cpu_percent'),
memUsedBytes: num('mem_used_bytes'),
memTotalBytes: num('mem_total_bytes'),
diskUsedBytes: num('disk_used_bytes'),
diskTotalBytes: num('disk_total_bytes'),
cpuCores: num('cpu_cores'),
osName: attrs['os_name'] ?? 'unknown',
kernelVersion: attrs['kernel_version'] ?? 'unknown',
arch: attrs['arch'] ?? 'unknown',
uptimeSeconds: num('uptime_seconds'),
ipv4Addresses: addrList('ipv4_addresses'),
ipv6Addresses: addrList('ipv6_addresses')
};
}
+49 -1
View File
@@ -1,6 +1,14 @@
<script lang="ts">
import { page } from '$app/state';
import { getCurrentSession, enterpriseAuthBase, type CurrentSession } from '$lib/api';
import {
getCurrentSession,
enterpriseAuthBase,
localAuthEnabled,
getLocalSession,
logout,
type CurrentSession,
type LocalSession
} from '$lib/api';
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte';
import { getDensity, toggleDensity } from '$lib/density.svelte';
@@ -16,6 +24,7 @@
{ href: '/alerts', label: 'Alerts', icon: '▲' },
{ href: '/data-sources', label: 'Data Sources', icon: '◈' },
{ href: '/agents', label: 'Agents', icon: '●' },
{ href: '/hosts', label: 'Hosts', icon: '▣' },
{ href: '/settings', label: 'Settings', icon: '⚙' }
];
@@ -29,6 +38,22 @@
getCurrentSession().then((s) => (session = s));
});
let localSession: LocalSession | null = $state(null);
$effect(() => {
if (!localAuthEnabled) return;
getLocalSession().then((s) => (localSession = s === 'disabled' ? null : s));
});
let loggingOut = $state(false);
async function handleLogout() {
loggingOut = true;
try {
await logout();
} finally {
window.location.href = '/login';
}
}
const themeOptions: { value: Theme; label: string }[] = [
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
@@ -60,6 +85,17 @@
<a class="switch signin" href="{enterpriseAuthBase}/auth/oidc/login">Sign in</a>
{/if}
</div>
{:else if localAuthEnabled && localSession}
<div class="tenant">
<div class="tenant-pill">
<span class="dot" aria-hidden="true"></span>
<span class="tenant-name">{localSession.username}</span>
<span class="role">{localSession.role}</span>
</div>
<button type="button" class="switch logout-btn" onclick={handleLogout} disabled={loggingOut}>
{loggingOut ? 'Signing out…' : 'Log out'}
</button>
</div>
{/if}
<nav aria-label="Main">
@@ -177,6 +213,18 @@
.switch:hover {
color: var(--color-accent);
}
.logout-btn {
background: none;
border: none;
font-family: var(--font-ui);
width: 100%;
text-align: left;
cursor: pointer;
}
.logout-btn:disabled {
cursor: default;
opacity: 0.6;
}
.switch.signin {
display: block;
padding: var(--space-2) var(--space-3);
+70 -3
View File
@@ -3,10 +3,60 @@
import favicon from '$lib/assets/favicon.svg';
import NavSidebar from '$lib/components/NavSidebar.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import { page } from '$app/state';
import { getLocalSession } from '$lib/api';
let { children } = $props();
let paletteOpen = $state(false);
let mobileNavOpen = $state(false);
const isLoginPage = $derived(page.url.pathname === '/login');
// Gates rendering of {@render children()} entirely until the first
// auth check resolves -- set once, on the very first check, never
// reset by later ones (see the $effect below). Without this, the
// shell (and every child page's own onMount/effect data-fetching)
// mounted immediately on every navigation, so a protected page's real
// content -- and a first request for it, before the redirect below
// even fired -- was visible for one frame on every load. Login-page
// visits and "local auth isn't configured on this deployment" both
// count as immediately checked -- neither has anything to gate.
let initialCheckDone = $state(false);
let authorized = $state(false);
// Route guard for local login (see api/localauth's package doc
// comment) -- client-only, same "no +page.ts/hooks.server.ts load,
// everything client-fetched" posture every other data-dependent page
// in this app already uses (this is a prerendered static SPA;
// checking during SvelteKit's build-time prerender pass would mean
// fetching a live endpoint at build time, which nothing else here
// does). getLocalSession() returning 'disabled' means this
// deployment has no local auth configured at all -- same as a
// deployment with neither enterprise-auth nor local auth turned on,
// let every route through unchanged. Re-runs on every navigation
// ($effect re-fires when isLoginPage's dependency, page.url, changes),
// same "poll on every navigation" posture GET /auth/session's own
// doc comment (api/localauth/handler.go) describes -- but only ever
// sets initialCheckDone, never clears it, so a session expiring
// mid-use redirects without re-blanking an already-rendered page (a
// full navigation to /login is already underway by the time that'd
// matter anyway).
$effect(() => {
if (isLoginPage) {
authorized = true;
initialCheckDone = true;
return;
}
getLocalSession().then((session) => {
if (session === null) {
const next = encodeURIComponent(page.url.pathname + page.url.search);
window.location.href = `/login?next=${next}`;
} else {
authorized = true;
}
initialCheckDone = true;
});
});
</script>
<svelte:head>
@@ -14,7 +64,10 @@
<link rel="icon" href={favicon} />
</svelte:head>
<div class="shell">
{#if isLoginPage}
{@render children()}
{:else if initialCheckDone && authorized}
<div class="shell">
<NavSidebar
onOpenPalette={() => (paletteOpen = true)}
mobileOpen={mobileNavOpen}
@@ -28,11 +81,25 @@
{@render children()}
</div>
</div>
</div>
</div>
<CommandPalette bind:open={paletteOpen} />
<CommandPalette bind:open={paletteOpen} />
{:else}
<!-- initialCheckDone is false: the auth check is still in flight (or
we're already navigating away to /login) -- nothing protected has
mounted yet, this is the whole point. A brief blank screen with no
feedback could look frozen on a slow connection, so show the same
plain "Loading…" text every other data-dependent page in this app
already uses (see e.g. settings/+page.svelte) rather than nothing
at all. -->
<p class="auth-loading">Loading…</p>
{/if}
<style>
.auth-loading {
color: var(--color-text-muted);
padding: var(--space-6);
}
.shell {
display: grid;
grid-template-columns: 15rem 1fr;
+51 -1
View File
@@ -26,6 +26,13 @@
let heartbeatEnabled = $state(true);
let heartbeatIntervalMs = $state('0');
let journaldUnit = $state('');
// Extra file paths to tail in addition to the agent's primary
// source -- unlike every field above, there's no "effective value"
// to fall back to when no override exists yet (this is purely a
// remote-override concept, agent.toml has no equivalent field), so
// an agent with no override starts with an empty list, not
// something derived from `agent`.
let extraFilePaths = $state<string[]>([]);
function resetForm(a: Agent) {
const o = a.desired_override;
@@ -34,6 +41,15 @@
heartbeatEnabled = o?.heartbeat_enabled ?? a.heartbeat_enabled;
heartbeatIntervalMs = String(o?.heartbeat_interval_ms ?? a.heartbeat_interval_ms);
journaldUnit = o?.journald_unit ?? '';
extraFilePaths = o?.extra_file_paths ? [...o.extra_file_paths] : [];
}
function addExtraFilePath() {
extraFilePaths = [...extraFilePaths, ''];
}
function removeExtraFilePath(index: number) {
extraFilePaths = extraFilePaths.filter((_, i) => i !== index);
}
async function load() {
@@ -59,7 +75,8 @@
batch_flush_interval_ms: Number(batchFlushIntervalMs),
heartbeat_enabled: heartbeatEnabled,
heartbeat_interval_ms: Number(heartbeatIntervalMs),
...(agent?.source_kind === 'journald' ? { journald_unit: journaldUnit } : {})
...(agent?.source_kind === 'journald' ? { journald_unit: journaldUnit } : {}),
extra_file_paths: extraFilePaths.map((p) => p.trim()).filter((p) => p !== '')
});
} catch (e) {
saveError = e instanceof Error ? e.message : String(e);
@@ -179,6 +196,20 @@
<Input id="journald-unit" placeholder="(empty = whole journal)" bind:value={journaldUnit} />
</div>
{/if}
<div class="field extra-paths">
<span class="field-label">Additional log paths</span>
<p class="hint">
Extra files this agent should tail alongside its primary source above -- never a replacement for it. Applied
the same way as every other field here, on the agent's next check-in.
</p>
{#each extraFilePaths as _, i}
<div class="path-row">
<Input placeholder="/var/log/example.log" bind:value={extraFilePaths[i]} />
<Button variant="secondary" onclick={() => removeExtraFilePath(i)}>Remove</Button>
</div>
{/each}
<Button variant="secondary" onclick={addExtraFilePath}>Add path</Button>
</div>
{#if saveError}<p class="error">Error: {saveError}</p>{/if}
@@ -275,6 +306,9 @@
margin-bottom: var(--space-3);
max-width: 20rem;
}
.field.extra-paths {
max-width: none;
}
.field label {
font-size: var(--text-sm);
color: var(--color-text-muted);
@@ -285,6 +319,22 @@
gap: var(--space-2);
color: var(--color-text);
}
.field-label {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.field .hint {
margin-bottom: var(--space-2);
}
.path-row {
display: flex;
gap: var(--space-2);
align-items: center;
margin-bottom: var(--space-2);
}
.path-row :global(input) {
flex: 1;
}
.actions {
display: flex;
gap: var(--space-3);
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
import { listMetricsHosts, type HostSummary } from '$lib/api';
import { EmptyState, Skeleton, Table } from '$lib/components/ui';
let hosts = $state<HostSummary[]>([]);
let loading = $state(true);
let error = $state('');
async function load() {
loading = true;
error = '';
try {
hosts = await listMetricsHosts();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
load();
</script>
<main>
<h1>Hosts</h1>
<p class="subtitle">
CPU, memory, and disk usage for every host reporting metrics. Only one agent process per
physical host reports these -- see agent/README.md's "Host CPU/memory/disk metrics" section.
</p>
{#if error}<p class="error">Error: {error}</p>{/if}
{#if loading}
<div class="skeleton-list">
{#each Array(3) as _, i (i)}
<Skeleton height="2.25rem" />
{/each}
</div>
{:else if hosts.length === 0}
<EmptyState
icon="▣"
title="No hosts reporting metrics yet"
description="A host appears here once an agent with [metrics] enabled = true has sent its first sample -- see agent/README.md."
/>
{:else}
<Table>
<thead>
<tr>
<th>Host</th>
</tr>
</thead>
<tbody>
{#each hosts as h (h.host)}
<tr>
<td><a href={`/hosts/${encodeURIComponent(h.host)}`}>{h.host}</a></td>
</tr>
{/each}
</tbody>
</Table>
{/if}
</main>
<style>
main {
max-width: 56rem;
}
h1 {
font-size: var(--text-xl);
margin-bottom: var(--space-2);
}
.subtitle {
color: var(--color-text-muted);
font-size: var(--text-sm);
margin-bottom: var(--space-5);
}
.error {
color: var(--color-danger);
}
.skeleton-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
a {
color: var(--color-text);
font-weight: var(--font-weight-medium);
text-decoration: none;
}
a:hover {
color: var(--color-accent);
}
</style>
+3
View File
@@ -0,0 +1,3 @@
// No route params, data comes from a client-side fetch -- same shape as
// the agents list page's +page.ts.
export const prerender = true;
+186
View File
@@ -0,0 +1,186 @@
<script lang="ts">
import { page } from '$app/state';
import { getHostMetrics, type HostMetrics } from '$lib/api';
import { Card, Skeleton } from '$lib/components/ui';
const host = page.params.host!;
let metrics = $state<HostMetrics | null>(null);
let loading = $state(true);
let error = $state('');
async function load() {
loading = true;
error = '';
try {
metrics = await getHostMetrics(host);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
load();
function formatBytes(bytes: number): string {
if (bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;
}
function percent(used: number, total: number): number {
if (total <= 0) return 0;
return Math.min(100, Math.max(0, (used / total) * 100));
}
function relativeTime(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s ago`;
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h ago`;
return `${Math.round(ms / 86_400_000)}d ago`;
}
function formatUptime(seconds: number): string {
if (seconds <= 0) return '—';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
</script>
<main>
<a class="back" href="/hosts">← Hosts</a>
<h1>{host}</h1>
{#if loading}
<Skeleton height="12rem" />
{:else if error}
<p class="error">Error: {error}</p>
{:else if !metrics}
<p class="hint">No metrics samples for this host yet.</p>
{:else}
<p class="hint">Last sample {relativeTime(metrics.timestamp)}.</p>
<section class="system">
<dl>
<dt>OS</dt>
<dd>{metrics.osName}</dd>
<dt>Kernel</dt>
<dd>{metrics.kernelVersion}</dd>
<dt>Architecture</dt>
<dd>{metrics.arch}</dd>
<dt>Uptime</dt>
<dd>{formatUptime(metrics.uptimeSeconds)}</dd>
<dt>IPv4</dt>
<dd>{metrics.ipv4Addresses.length > 0 ? metrics.ipv4Addresses.join(', ') : '—'}</dd>
<dt>IPv6</dt>
<dd>{metrics.ipv6Addresses.length > 0 ? metrics.ipv6Addresses.join(', ') : '—'}</dd>
</dl>
</section>
<div class="stats">
<Card title="CPU">
<div class="big-number">{metrics.cpuPercent.toFixed(1)}%</div>
<div class="bar">
<div class="bar-fill" style="width: {metrics.cpuPercent.toFixed(1)}%"></div>
</div>
<div class="detail">{metrics.cpuCores} core{metrics.cpuCores === 1 ? '' : 's'}</div>
</Card>
<Card title="Memory">
<div class="big-number">{percent(metrics.memUsedBytes, metrics.memTotalBytes).toFixed(1)}%</div>
<div class="bar">
<div
class="bar-fill"
style="width: {percent(metrics.memUsedBytes, metrics.memTotalBytes).toFixed(1)}%"
></div>
</div>
<div class="detail">{formatBytes(metrics.memUsedBytes)} / {formatBytes(metrics.memTotalBytes)}</div>
</Card>
<Card title="Disk (/)">
<div class="big-number">{percent(metrics.diskUsedBytes, metrics.diskTotalBytes).toFixed(1)}%</div>
<div class="bar">
<div
class="bar-fill"
style="width: {percent(metrics.diskUsedBytes, metrics.diskTotalBytes).toFixed(1)}%"
></div>
</div>
<div class="detail">{formatBytes(metrics.diskUsedBytes)} / {formatBytes(metrics.diskTotalBytes)}</div>
</Card>
</div>
{/if}
</main>
<style>
main {
max-width: 48rem;
}
.back {
font-size: var(--text-sm);
color: var(--color-text-muted);
text-decoration: none;
}
.back:hover {
color: var(--color-accent);
}
h1 {
font-size: var(--text-xl);
margin: var(--space-2) 0 var(--space-2);
font-family: var(--font-mono);
}
.hint {
color: var(--color-text-muted);
font-size: var(--text-sm);
margin-bottom: var(--space-5);
}
.error {
color: var(--color-danger);
}
.system {
margin-bottom: var(--space-5);
}
.system dl {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-1) var(--space-4);
font-size: var(--text-sm);
}
.system dt {
color: var(--color-text-muted);
}
.system dd {
margin: 0;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: var(--space-4);
}
.big-number {
font-size: var(--text-xl);
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-3);
}
.bar {
height: 0.5rem;
border-radius: var(--radius-sm);
background: var(--color-bg);
border: 1px solid var(--color-border);
overflow: hidden;
}
.bar-fill {
height: 100%;
background: var(--color-accent);
}
.detail {
margin-top: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
</style>
+4
View File
@@ -0,0 +1,4 @@
// The host param doesn't exist at build time -- same reasoning as
// agents/[host]/+page.ts.
export const prerender = false;
export const ssr = false;
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
import { page } from '$app/state';
import { login } from '$lib/api';
let username = $state('');
let password = $state('');
let error = $state('');
let submitting = $state(false);
async function submit(e: SubmitEvent) {
e.preventDefault();
if (submitting) return;
submitting = true;
error = '';
try {
await login(username, password);
// Full navigation, not SvelteKit's router -- same reasoning
// select-tenant/+page.svelte's choose() gives for the tenant
// picker: reloading picks up the session cookie login() just
// set, which client-side routing wouldn't need to know about
// but a fresh page load makes unambiguous.
const next = page.url.searchParams.get('next') || '/';
window.location.href = next;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
submitting = false;
}
}
</script>
<main>
<h1>Sign in</h1>
<form onsubmit={submit}>
{#if error}
<p class="error">{error}</p>
{/if}
<label>
<span>Username</span>
<input type="text" autocomplete="username" bind:value={username} disabled={submitting} required />
</label>
<label>
<span>Password</span>
<input
type="password"
autocomplete="current-password"
bind:value={password}
disabled={submitting}
required
/>
</label>
<button type="submit" disabled={submitting}>{submitting ? 'Signing in…' : 'Sign in'}</button>
</form>
</main>
<style>
main {
max-width: 22rem;
margin: var(--space-8) auto;
}
h1 {
font-size: var(--text-lg);
margin-bottom: var(--space-5);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
label {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
input {
font-family: var(--font-ui);
font-size: var(--text-base);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
}
input:focus {
outline: none;
border-color: var(--color-accent);
}
button {
margin-top: var(--space-2);
padding: var(--space-2) var(--space-4);
font-family: var(--font-ui);
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
color: var(--color-bg);
background: var(--color-accent);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.6;
}
.error {
color: var(--color-danger);
font-size: var(--text-sm);
}
</style>
+4
View File
@@ -0,0 +1,4 @@
// Client-only, same reasoning as select-tenant/+page.ts -- nothing to
// prerender server-side, everything here is a fetch against api's
// /auth/login.
export const prerender = true;
+222 -1
View File
@@ -1,5 +1,17 @@
<script lang="ts">
import { getAuthFeatures, enterpriseAuthBase, type AuthFeatures } from '$lib/api';
import {
getAuthFeatures,
enterpriseAuthBase,
localAuthEnabled,
getLocalSession,
listUsers,
createUser,
deleteUser,
resetPassword,
type AuthFeatures,
type LocalSession,
type LocalUser
} from '$lib/api';
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte';
import { getDensity, setDensity, type Density } from '$lib/density.svelte';
@@ -13,6 +25,75 @@
}
load();
// --- local user management (owner-role only, see api/localauth) ---
let localSession = $state<LocalSession | 'disabled' | null>(null);
let users = $state<LocalUser[]>([]);
let usersLoading = $state(false);
let usersError = $state('');
let newUsername = $state('');
let newPassword = $state('');
let newRole = $state('editor');
let creating = $state(false);
// lastReset holds a just-generated password so it can be shown once
// (never stored, never recoverable after -- same posture
// -seed-admin's initial password takes, see cmd/api/main.go).
let lastReset = $state<{ userId: string; password: string } | null>(null);
async function loadUsers() {
if (!localAuthEnabled) return;
localSession = await getLocalSession();
if (localSession === 'disabled' || localSession === null || localSession.role !== 'owner') return;
usersLoading = true;
usersError = '';
try {
users = await listUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
} finally {
usersLoading = false;
}
}
loadUsers();
async function handleCreate(e: SubmitEvent) {
e.preventDefault();
if (creating) return;
creating = true;
usersError = '';
try {
await createUser(newUsername, newPassword, newRole);
newUsername = '';
newPassword = '';
newRole = 'editor';
await loadUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
} finally {
creating = false;
}
}
async function handleDelete(id: string) {
usersError = '';
try {
await deleteUser(id);
await loadUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
}
}
async function handleReset(id: string) {
usersError = '';
lastReset = null;
try {
const { password } = await resetPassword(id);
if (password) lastReset = { userId: id, password };
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
}
}
const themeOptions: { value: Theme; label: string; hint: string }[] = [
{ value: 'dark', label: 'Dark', hint: 'Default' },
{ value: 'light', label: 'Light', hint: '' },
@@ -64,6 +145,69 @@
<p>Single-tenant deployment settings live here. Nothing configurable yet.</p>
</section>
{#if localAuthEnabled && localSession && localSession !== 'disabled'}
<section>
<h2>Users</h2>
{#if localSession.role !== 'owner'}
<p class="note">Only an owner can manage users. Signed in as {localSession.username} ({localSession.role}).</p>
{:else}
{#if usersError}<p class="error">{usersError}</p>{/if}
{#if usersLoading}
<p class="muted">Loading…</p>
{:else}
<table>
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
<tr>
<td>{u.username}</td>
<td class="role-cell">{u.role}</td>
<td class="actions">
<button type="button" onclick={() => handleReset(u.id)}>Reset password</button>
<button type="button" class="danger" onclick={() => handleDelete(u.id)}>Delete</button>
</td>
</tr>
{#if lastReset?.userId === u.id}
<tr>
<td colspan="3">
<p class="note">
New password (shown once): <code>{lastReset.password}</code>
</p>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{/if}
<form onsubmit={handleCreate} class="create-user">
<input type="text" placeholder="Username" bind:value={newUsername} disabled={creating} required />
<input
type="password"
placeholder="Password (min. 8 characters)"
bind:value={newPassword}
disabled={creating}
required
/>
<select bind:value={newRole} disabled={creating}>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</select>
<button type="submit" disabled={creating}>{creating ? 'Adding…' : 'Add user'}</button>
</form>
{/if}
</section>
{/if}
{#if loading}
<p class="muted">Loading…</p>
{:else if features.sso_configured}
@@ -154,4 +298,81 @@
.option.selected .option-hint {
color: var(--color-text);
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--space-4);
font-size: var(--text-sm);
}
th,
td {
text-align: left;
padding: var(--space-2) var(--space-2);
border-bottom: 1px solid var(--color-border);
}
.role-cell {
color: var(--color-text-muted);
text-transform: capitalize;
}
.actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
.actions button {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
cursor: pointer;
}
.actions button.danger {
color: var(--color-danger);
border-color: var(--color-danger);
}
.create-user {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
align-items: center;
}
.create-user input,
.create-user select {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
}
.create-user button {
padding: var(--space-2) var(--space-4);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-bg);
background: var(--color-accent);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.create-user button:disabled {
cursor: default;
opacity: 0.6;
}
.error {
color: var(--color-danger);
font-size: var(--text-sm);
}
code {
font-family: var(--font-mono);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 0.1rem 0.4rem;
}
</style>