Phase 4: SSO scaffolding, RBAC enforcement, tenant-scoped dashboards, audit logging, K8s deployment
RBAC (api/internal/authz) is live on /query and /dashboards, backed by a new enterprise/ module (session issuance, audit logging, RBAC storage, OIDC/SAML protocol wiring) that core never imports -- only calls over HTTP. Found and fixed a real cross-tenant vulnerability in dashboards (no tenant_id filtering at all) while writing the threat model doc. Two things are explicitly NOT done, documented rather than hidden: tenant isolation for log data itself (/query still shares one ClickHouse connection and Tantivy index across every tenant -- RBAC controls who can query, not what a query can see), and human SSO login (protocol wiring exists, no HTTP handler calls it yet). See docs/security/threat-model.md and docs/phase-4-runbook.md. Also adds deploy/ (Go Operator + Helm chart, validated offline only -- no cluster was reachable in this environment).
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveTokenFromEnv(t *testing.T) {
|
||||
env := func(k string) string {
|
||||
if k == "SENTRYCTL_TOKEN" {
|
||||
return "secret-token"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if got := resolveToken(env); got != "secret-token" {
|
||||
t.Errorf("got %q, want %q", got, "secret-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPGetJSONForwardsBearerToken(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := httpGetJSON(srv.URL, "/thing", "my-token", &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
if gotAuth != "Bearer my-token" {
|
||||
t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer my-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPGetJSONOmitsAuthorizationWhenNoToken(t *testing.T) {
|
||||
sawHeader := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawHeader = r.Header.Get("Authorization") != ""
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := httpGetJSON(srv.URL, "/thing", "", &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
if sawHeader {
|
||||
t.Fatalf("expected no Authorization header when no token is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPostFileJSONForwardsBearerToken(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "payload-*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("creating temp file: %v", err)
|
||||
}
|
||||
if _, err := f.WriteString(`{"name":"test"}`); err != nil {
|
||||
t.Fatalf("writing temp file: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := httpPostFileJSON(srv.URL, "/thing", "my-token", f.Name(), &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
if gotAuth != "Bearer my-token" {
|
||||
t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer my-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdPingForwardsBearerToken(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("SENTRYCTL_TOKEN", "ping-token")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := cmdPing([]string{"--api", srv.URL}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
if gotAuth != "Bearer ping-token" {
|
||||
t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer ping-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdQueryForwardsBearerToken(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"columns":[],"rows":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("SENTRYCTL_TOKEN", "query-token")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := cmdQuery([]string{"--api", srv.URL, "service=api"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
if gotAuth != "Bearer query-token" {
|
||||
t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer query-token")
|
||||
}
|
||||
}
|
||||
@@ -12,16 +12,17 @@ func cmdAlerts(args []string, stdout, stderr io.Writer) int {
|
||||
return 1
|
||||
}
|
||||
alertingURL, rest := extractAlertingAPIFlag(args[1:], os.Getenv)
|
||||
token := resolveToken(os.Getenv)
|
||||
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return httpGetJSON(alertingURL, "/rules", stdout, stderr)
|
||||
return httpGetJSON(alertingURL, "/rules", token, stdout, stderr)
|
||||
case "get":
|
||||
if len(rest) == 0 {
|
||||
fmt.Fprintln(stderr, "sentryctl alerts get: missing rule id")
|
||||
return 1
|
||||
}
|
||||
return httpGetJSON(alertingURL, "/rules/"+rest[0], stdout, stderr)
|
||||
return httpGetJSON(alertingURL, "/rules/"+rest[0], token, stdout, stderr)
|
||||
case "apply":
|
||||
if len(rest) == 0 {
|
||||
fmt.Fprintln(stderr, "sentryctl alerts apply: missing file path")
|
||||
@@ -30,7 +31,7 @@ func cmdAlerts(args []string, stdout, stderr io.Writer) int {
|
||||
// POST /rules accepts the same shape it returns -- a rule
|
||||
// definition file (query, condition, interval, notification
|
||||
// target ID) applies directly with no reshaping.
|
||||
return httpPostFileJSON(alertingURL, "/rules", rest[0], stdout, stderr)
|
||||
return httpPostFileJSON(alertingURL, "/rules", token, rest[0], stdout, stderr)
|
||||
default:
|
||||
fmt.Fprintf(stderr, "sentryctl alerts: unknown subcommand %q (want list, get, apply)\n", args[0])
|
||||
return 1
|
||||
|
||||
@@ -12,16 +12,17 @@ func cmdDashboards(args []string, stdout, stderr io.Writer) int {
|
||||
return 1
|
||||
}
|
||||
apiURL, rest := extractAPIFlag(args[1:], os.Getenv)
|
||||
token := resolveToken(os.Getenv)
|
||||
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return httpGetJSON(apiURL, "/dashboards", stdout, stderr)
|
||||
return httpGetJSON(apiURL, "/dashboards", token, stdout, stderr)
|
||||
case "get":
|
||||
if len(rest) == 0 {
|
||||
fmt.Fprintln(stderr, "sentryctl dashboards get: missing dashboard id")
|
||||
return 1
|
||||
}
|
||||
return httpGetJSON(apiURL, "/dashboards/"+rest[0], stdout, stderr)
|
||||
return httpGetJSON(apiURL, "/dashboards/"+rest[0], token, stdout, stderr)
|
||||
case "apply":
|
||||
if len(rest) == 0 {
|
||||
fmt.Fprintln(stderr, "sentryctl dashboards apply: missing file path")
|
||||
@@ -30,7 +31,7 @@ func cmdDashboards(args []string, stdout, stderr io.Writer) int {
|
||||
// The import endpoint consumes exactly the shape GET
|
||||
// /dashboards/{id}/export produces and the web UI's Export JSON
|
||||
// button downloads -- one JSON contract, three call sites.
|
||||
return httpPostFileJSON(apiURL, "/dashboards/import", rest[0], stdout, stderr)
|
||||
return httpPostFileJSON(apiURL, "/dashboards/import", token, rest[0], stdout, stderr)
|
||||
default:
|
||||
fmt.Fprintf(stderr, "sentryctl dashboards: unknown subcommand %q (want list, get, apply)\n", args[0])
|
||||
return 1
|
||||
|
||||
@@ -26,8 +26,15 @@ func parsePingArgs(args []string, env func(string) string) string {
|
||||
func cmdPing(args []string, stdout, stderr io.Writer) int {
|
||||
apiURL := parsePingArgs(args, os.Getenv)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, apiURL+"/healthz", nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "building request: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
setAuth(req, resolveToken(os.Getenv))
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get(apiURL + "/healthz")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "ping failed: %v\n", err)
|
||||
return 1
|
||||
|
||||
@@ -72,8 +72,16 @@ func cmdQuery(args []string, stdout, stderr io.Writer) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, qa.apiURL+"/query", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "building request: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
setAuth(req, resolveToken(os.Getenv))
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Post(qa.apiURL+"/query", "application/json", bytes.NewReader(reqBody))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "query failed: %v\n", err)
|
||||
return 1
|
||||
|
||||
@@ -12,11 +12,26 @@ import (
|
||||
|
||||
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// setAuth attaches SENTRYCTL_TOKEN (see resolveToken) as a Bearer
|
||||
// credential, a no-op when token is empty -- matches every backend's
|
||||
// nil-authorizer no-op default (see api/internal/authz.RequireRole*).
|
||||
func setAuth(req *http.Request, token string) {
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
}
|
||||
|
||||
// httpGetJSON GETs path and prints the pretty-printed JSON response to
|
||||
// stdout, or the error body/status to stderr. Shared by dashboards/alerts
|
||||
// list and get, which otherwise differ only in path and resource name.
|
||||
func httpGetJSON(baseURL, path string, stdout, stderr io.Writer) int {
|
||||
resp, err := httpClient.Get(baseURL + path)
|
||||
func httpGetJSON(baseURL, path, token string, stdout, stderr io.Writer) int {
|
||||
req, err := http.NewRequest(http.MethodGet, baseURL+path, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "building request: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
setAuth(req, token)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "request failed: %v\n", err)
|
||||
return 1
|
||||
@@ -31,13 +46,20 @@ func httpGetJSON(baseURL, path string, stdout, stderr io.Writer) int {
|
||||
// expects (the same JSON the web UI's export button and POST /rules
|
||||
// produce/accept respectively). This is what makes "apply" the seed of a
|
||||
// future Terraform provider: one JSON contract, multiple callers.
|
||||
func httpPostFileJSON(baseURL, path, file string, stdout, stderr io.Writer) int {
|
||||
func httpPostFileJSON(baseURL, path, token, file string, stdout, stderr io.Writer) int {
|
||||
body, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "reading %s: %v\n", file, err)
|
||||
return 1
|
||||
}
|
||||
resp, err := httpClient.Post(baseURL+path, "application/json", bytes.NewReader(body))
|
||||
req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "building request: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
setAuth(req, token)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "request failed: %v\n", err)
|
||||
return 1
|
||||
|
||||
@@ -73,7 +73,13 @@ Commands:
|
||||
|
||||
--api defaults to $SENTRYCTL_API_URL, or `+defaultAPIURL+` if unset.
|
||||
--alerting-api defaults to $SENTRYCTL_ALERTING_API_URL, or `+defaultAlertingURL+` if unset.
|
||||
--language overrides auto-detection; omit it for the common case.`)
|
||||
--language overrides auto-detection; omit it for the common case.
|
||||
|
||||
$SENTRYCTL_TOKEN, if set, is sent as "Authorization: Bearer <token>" on
|
||||
every request -- required once a deployment configures enterprise-auth
|
||||
(see /docs/phase-4-rbac-design.md). No flag equivalent, deliberately:
|
||||
unlike --api, a credential shouldn't be typed where shell history or
|
||||
`+"`ps`"+` output can capture it.`)
|
||||
}
|
||||
|
||||
func resolveAPIURL(env func(string) string) string {
|
||||
@@ -90,6 +96,14 @@ func resolveAlertingURL(env func(string) string) string {
|
||||
return defaultAlertingURL
|
||||
}
|
||||
|
||||
// resolveToken reads the RoleService/human bearer credential sentryctl
|
||||
// presents to api/alerting once enterprise-auth enforcement is turned
|
||||
// on (api/internal/authz.RequireRole*) -- empty by default, matching
|
||||
// every other Phase 0-3 client's nil-authorizer no-op behavior.
|
||||
func resolveToken(env func(string) string) string {
|
||||
return env("SENTRYCTL_TOKEN")
|
||||
}
|
||||
|
||||
type errorResponseBody struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user