Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions internal/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ import (

// Event is a single audit record forwarded to sinks.
type Event struct {
Action string `json:"action"`
Username string `json:"username,omitempty"`
ConnectionID string `json:"connection_id,omitempty"`
Details string `json:"details,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
Timestamp string `json:"timestamp"`
Action string `json:"action"`
Username string `json:"username,omitempty"`
// ClickhouseUser is the ClickHouse account the action ran as, when known.
ClickhouseUser string `json:"ch_user,omitempty"`
ConnectionID string `json:"connection_id,omitempty"`
Details string `json:"details,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
Timestamp string `json:"timestamp"`
}

// Sink delivers an event to one destination. Implementations must be safe for
Expand Down Expand Up @@ -133,6 +135,7 @@ func (StdoutSink) Emit(e Event) error {
slog.Info("audit",
"action", e.Action,
"username", e.Username,
"ch_user", e.ClickhouseUser,
"connection_id", e.ConnectionID,
"ip_address", e.IPAddress,
"details", e.Details,
Expand Down
12 changes: 10 additions & 2 deletions internal/database/query_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,15 +172,23 @@ func (db *DB) GetQueryHistory(actor, connectionID, search, status string, limit,
}

// DeleteQueryHistoryEntry deletes one entry if it belongs to the actor on the
// given connection (same scoping as List and Clear).
// given connection (same scoping as List and Clear). It returns sql.ErrNoRows
// when no such entry exists for that actor, so callers can answer 404.
func (db *DB) DeleteQueryHistoryEntry(id, actor, connectionID string) error {
_, err := db.conn.Exec(
res, err := db.conn.Exec(
`DELETE FROM query_history WHERE id = ? AND actor = ? AND COALESCE(connection_id, '') = COALESCE(?, '')`,
id, actor, nilIfEmpty(connectionID),
)
if err != nil {
return fmt.Errorf("delete query history entry: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete query history entry: %w", err)
}
if n == 0 {
return sql.ErrNoRows
}
return nil
}

Expand Down
18 changes: 10 additions & 8 deletions internal/database/query_history_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package database

import (
"database/sql"
"errors"
"fmt"
"testing"
)
Expand Down Expand Up @@ -201,17 +203,17 @@ func TestQueryHistory_DeleteAndClear(t *testing.T) {
insertHistoryAt(t, db, "h2", "alice", "conn-1", "SELECT 2", "success", "datetime('now', '-2 minutes')")
insertHistoryAt(t, db, "h3", "bob", "conn-1", "SELECT 3", "success", "datetime('now', '-3 minutes')")

// Deleting someone else's entry is a silent no-op.
if err := db.DeleteQueryHistoryEntry("h3", "alice", "conn-1"); err != nil {
t.Fatalf("DeleteQueryHistoryEntry foreign: %v", err)
// Deleting someone else's entry deletes nothing and reports not found.
if err := db.DeleteQueryHistoryEntry("h3", "alice", "conn-1"); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("DeleteQueryHistoryEntry foreign: want sql.ErrNoRows, got %v", err)
}
if entries, _ := db.GetQueryHistory("bob", "conn-1", "", "", 50, 0); len(entries) != 1 {
t.Fatalf("bob's entry should survive alice's delete")
}

// Deleting own entry from a different connection is also a no-op.
if err := db.DeleteQueryHistoryEntry("h1", "alice", "conn-2"); err != nil {
t.Fatalf("DeleteQueryHistoryEntry wrong conn: %v", err)
// Deleting own entry from a different connection is also not found.
if err := db.DeleteQueryHistoryEntry("h1", "alice", "conn-2"); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("DeleteQueryHistoryEntry wrong conn: want sql.ErrNoRows, got %v", err)
}
if entries, _ := db.GetQueryHistory("alice", "conn-1", "", "", 50, 0); len(entries) != 2 {
t.Fatalf("delete must be connection-scoped")
Expand Down Expand Up @@ -278,8 +280,8 @@ func TestQueryHistory_SSOPeopleSharingAccountAreIsolated(t *testing.T) {

// Bob cannot delete alice's row by id, nor the shared pre-migration row.
for _, id := range []string{"a1", "shared", "p1"} {
if err := db.DeleteQueryHistoryEntry(id, ssoBob, "conn-1"); err != nil {
t.Fatalf("DeleteQueryHistoryEntry(%s): %v", id, err)
if err := db.DeleteQueryHistoryEntry(id, ssoBob, "conn-1"); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("DeleteQueryHistoryEntry(%s): want sql.ErrNoRows, got %v", id, err)
}
}
if got := historyIDs(t, db, ssoAlice, "conn-1"); len(got) != 2 {
Expand Down
15 changes: 8 additions & 7 deletions internal/server/handlers/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,10 @@ func (h *AdminHandler) GetUserRoles(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, roles)
}

// roleUsernameParam returns the decoded {username} path parameter. Role keys
// can hold ':' and '@' ("sso:<email>", or a ClickHouse user named like an
// email), which the UI percent-encodes; chi returns the raw escaped segment.
// roleUsernameParam returns the decoded {username} path parameter, for role
// and ClickHouse user routes alike. Names can hold ':' and '@' ("sso:<email>",
// or a ClickHouse user named like an email), which the UI percent-encodes;
// chi returns the raw escaped segment.
func roleUsernameParam(r *http.Request) (string, bool) {
username, err := url.PathUnescape(chi.URLParam(r, "username"))
if err != nil || strings.TrimSpace(username) == "" {
Expand Down Expand Up @@ -645,8 +646,8 @@ func (h *AdminHandler) UpdateClickHouseUserPassword(w http.ResponseWriter, r *ht
return
}

username := strings.TrimSpace(chi.URLParam(r, "username"))
if username == "" {
username, ok := roleUsernameParam(r)
if !ok {
writeError(w, http.StatusBadRequest, "username is required")
return
}
Expand Down Expand Up @@ -772,8 +773,8 @@ func (h *AdminHandler) DeleteClickHouseUser(w http.ResponseWriter, r *http.Reque
return
}

username := strings.TrimSpace(chi.URLParam(r, "username"))
if username == "" {
username, ok := roleUsernameParam(r)
if !ok {
writeError(w, http.StatusBadRequest, "username is required")
return
}
Expand Down
3 changes: 2 additions & 1 deletion internal/server/handlers/governance_auditlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,13 @@ func (h *GovernanceHandler) GetAuditLogsExport(w http.ResponseWriter, r *http.Re
w.Header().Set("Content-Disposition", `attachment; filename="ch-ui-audit-logs.csv"`)
cw := csv.NewWriter(w)
defer cw.Flush()
_ = cw.Write([]string{"created_at", "action", "username", "connection_id", "ip_address", "details"})
_ = cw.Write([]string{"created_at", "action", "username", "ch_user", "connection_id", "ip_address", "details"})
for _, l := range logs {
_ = cw.Write([]string{
l.CreatedAt,
l.Action,
derefStr(l.Username),
derefStr(l.ChUser),
derefStr(l.ConnectionID),
derefStr(l.IPAddress),
derefStr(l.Details),
Expand Down
6 changes: 6 additions & 0 deletions internal/server/handlers/query_history.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package handlers

import (
"database/sql"
"errors"
"log/slog"
"net/http"
"strconv"
Expand Down Expand Up @@ -74,6 +76,10 @@ func (h *QueryHistoryHandler) DeleteEntry(w http.ResponseWriter, r *http.Request
}

if err := h.DB.DeleteQueryHistoryEntry(id, middleware.Actor(session), session.ConnectionID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "History entry not found")
return
}
slog.Error("Failed to delete query history entry", "error", err, "id", id)
writeError(w, http.StatusInternalServerError, "Failed to delete history entry")
return
Expand Down
6 changes: 3 additions & 3 deletions internal/server/handlers/sso_per_person_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ func TestQueryHistorySSOPeopleIsolated(t *testing.T) {
if err := db.Conn().QueryRow(`SELECT id FROM query_history WHERE actor = 'alice@example.com'`).Scan(&aliceID); err != nil {
t.Fatal(err)
}
// Bob deletes alice's row by id and clears his own: alice, the password
// user and the shared row all survive.
if res := ssoRequest(r, p.bob, "DELETE", "/history/"+aliceID, ""); res.Code != 200 {
// Bob deleting alice's row by id gets 404 and deletes nothing; clearing
// his own leaves alice, the password user and the shared row in place.
if res := ssoRequest(r, p.bob, "DELETE", "/history/"+aliceID, ""); res.Code != 404 {
t.Fatalf("delete: %d %s", res.Code, res.Body)
}
if res := ssoRequest(r, p.bob, "DELETE", "/history/", ""); res.Code != 200 {
Expand Down
16 changes: 11 additions & 5 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,18 @@ func New(cfg *config.Config, db *database.DB, frontendFS fs.FS, agents *embedded
if !auditGate.Allow() {
return
}
// ch_user only when it differs from the actor, as stored.
chUser := deref(p.ClickhouseUser)
if chUser == deref(p.Username) {
chUser = ""
}
auditFwd.Emit(audit.Event{
Action: p.Action,
Username: deref(p.Username),
ConnectionID: deref(p.ConnectionID),
Details: deref(p.Details),
IPAddress: deref(p.IPAddress),
Action: p.Action,
Username: deref(p.Username),
ClickhouseUser: chUser,
ConnectionID: deref(p.ConnectionID),
Details: deref(p.Details),
IPAddress: deref(p.IPAddress),
})
}
}
Expand Down
6 changes: 3 additions & 3 deletions site/src/content/docs/docs/audit-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ an optional **target** (e.g. the affected user or connection), structured
For people who sign in with [SSO](/docs/sso) (v2.13.2+), the actor is the
person's email, and the ClickHouse account the action ran as (the shared
service account) is kept in a separate `ch_user` field. `ch_user` is only set
when it differs from the actor. It is returned by `GET
/api/governance/audit-logs` and the JSON export; the CSV export and SIEM
forwarding carry the actor only. Rows written before v2.13.2 keep the service
when it differs from the actor. It shows as the **ClickHouse user** column on
the page and is carried by `GET /api/governance/audit-logs`, the JSON and CSV
exports, and SIEM forwarding. Rows written before v2.13.2 keep the service
account as the actor.

## Using it
Expand Down
4 changes: 2 additions & 2 deletions site/src/content/docs/docs/cant-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ Use this guide if the login screen appears but sign-in fails, local URL is wrong
| What you see | Likely cause | What to do |
|---|---|---|
| `Authentication failed` / `Invalid credentials` | ClickHouse rejected the username or password | Retry with the correct username/password for the selected connection. Each rejected attempt counts toward the lockout |
| `Connection unavailable` with `Connection offline`, or `Connection "<name>" is offline` | The connector for this connection is not connected (the second form appears when the login page already knows the connection is offline) | Start the connector/agent for the connection, then retry. If the local URL is wrong, use **Open setup guide**, update setup and restart CH-UI |
| `Connection unavailable` with `Connection to ClickHouse failed` | ClickHouse did not answer the credential check (timeout, connection refused, network or TLS error, tunnel dropped) | Check that ClickHouse is up and reachable from the connector, start the connector if it is stopped, then retry. These attempts do not count toward the lockout |
| `Connector offline` under the connection picker, `Connection unavailable` with `Connection offline`, or `Connection "<name>" is offline` | The connector for this connection is not connected (the second form appears when the login page already knows the connection is offline) | Start the connector/agent for the connection, then retry. If the local URL is wrong, use **Open setup guide**, update setup and restart CH-UI |
| `Connection unavailable` with `Connection to ClickHouse failed`, while the picker says `Connector online` | ClickHouse did not answer the credential check (timeout, connection refused, network or TLS error, tunnel dropped). `Connector online` only means the connector is connected; ClickHouse is checked when you sign in | Check that ClickHouse is up and reachable from the connector, start the connector if it is stopped, then retry. These attempts do not count toward the lockout |
| `Login temporarily blocked` / `Too many login attempts` | Temporary lockout after repeated failed logins (3 per user or 5 per IP within 15 minutes) | Wait for the retry window shown (`3m`, then `5m`, then capped at `10m`); if URL was wrong, fix setup and restart before retry |
| `No connections configured` | Embedded local connection not reachable/initialized | Start CH-UI with explicit local URL and connection name |

Expand Down
4 changes: 4 additions & 0 deletions site/src/content/docs/docs/monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ Each forwarded event is JSON:
}
```

Events that ran as a different ClickHouse account than `username` (people
signed in with SSO share a service account) also carry `ch_user`, the
ClickHouse account used.

- **stdout**: pick this up with any log pipeline (Fluent Bit, Vector, Loki,
CloudWatch, Datadog agent).
- **file**: tail the JSONL file with a log shipper.
Expand Down
5 changes: 5 additions & 0 deletions ui/src/lib/components/governance/AuditLogSection.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
{ key: 'created_at', label: 'Time', mono: true, width: '160px', format: (v) => formatDate(v) },
{ key: 'action', label: 'Action', width: '220px' },
{ key: 'username', label: 'User', format: (v) => (v ? String(v) : '—') },
{ key: 'ch_user', label: 'ClickHouse user', mono: true, format: (v) => (v ? String(v) : '—') },
{ key: 'details', label: 'Details', mono: true, truncate: true, width: '45%', sortable: false, format: (v) => (v ? String(v) : '—') },
{ key: 'ip_address', label: 'IP', mono: true, format: (v) => (v ? String(v) : '—') },
]
Expand Down Expand Up @@ -178,6 +179,10 @@
<dt class="text-fg-3">User</dt>
<dd class="text-fg">{selected.username || '—'}</dd>
</div>
<div class="flex items-center justify-between gap-4 px-3 py-2 text-[13px]">
<dt class="text-fg-3">ClickHouse user</dt>
<dd class="font-mono text-xs text-fg">{selected.ch_user || '—'}</dd>
</div>
<div class="flex items-center justify-between gap-4 px-3 py-2 text-[13px]">
<dt class="text-fg-3">IP</dt>
<dd class="font-mono text-xs text-fg">{selected.ip_address || '—'}</dd>
Expand Down
10 changes: 10 additions & 0 deletions ui/src/lib/components/telemetry/LogsSection.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import LogDetailPanel from './LogDetailPanel.svelte'
import SavedSearchMenu from './SavedSearchMenu.svelte'
import { setSection } from '../../stores/nav.svelte'
import { isMissingTableError } from '../../utils/ch-error'
import { searchLogs, logsHistogram, logsFacets } from '../../api/telemetry'
import type { TelemetrySource, LogRow, LogFacets, HistogramBucket } from '../../types/telemetry'
import { encodeAbsoluteDashboardRange } from '../../utils/dashboard-time'
Expand Down Expand Up @@ -378,6 +379,14 @@
</Button>
</div>

{#if error && isMissingTableError(error)}
<EmptyState
icon={Search}
title="Telemetry tables not found"
description="The logs table this source points at does not exist in ClickHouse. Check the source, or start the OpenTelemetry collector so it creates its tables."
primary={{ label: 'Configure sources', onclick: () => setSection('sources') }}
/>
{:else}
{#if error}
<div class="shrink-0 border-b border-edge-subtle bg-danger-soft px-5 py-1.5 text-xs text-danger">{error}</div>
{/if}
Expand Down Expand Up @@ -443,4 +452,5 @@
/>
{/if}
</div>
{/if}
{/if}
10 changes: 10 additions & 0 deletions ui/src/lib/components/telemetry/MetricsSection.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import TimeRangeSelector from '../dashboard/TimeRangeSelector.svelte'
import MetricQueryCard, { defaultAggregation, type MetricCardSpec } from './MetricQueryCard.svelte'
import { setSection } from '../../stores/nav.svelte'
import { isMissingTableError } from '../../utils/ch-error'
import { metricsCatalog } from '../../api/telemetryPro'
import type { TelemetrySource } from '../../types/telemetry'
import type { MetricCatalogEntry, MetricType, MetricAggregation } from '../../types/telemetryPro'
Expand Down Expand Up @@ -183,6 +184,14 @@
</div>
</div>

{#if error && isMissingTableError(error)}
<EmptyState
icon={Gauge}
title="Telemetry tables not found"
description="The metrics table this source points at does not exist in ClickHouse. Check the source, or start the OpenTelemetry collector so it creates its tables."
primary={{ label: 'Configure sources', onclick: () => setSection('sources') }}
/>
{:else}
{#if error}
<div class="shrink-0 border-b border-edge-subtle bg-danger-soft px-5 py-1.5 text-xs text-danger">{error}</div>
{/if}
Expand Down Expand Up @@ -215,4 +224,5 @@
</div>
{/if}
</PageBody>
{/if}
{/if}
10 changes: 10 additions & 0 deletions ui/src/lib/components/telemetry/TracesSection.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import TraceFacets from './TraceFacets.svelte'
import TraceView from './TraceView.svelte'
import { setSection } from '../../stores/nav.svelte'
import { isMissingTableError } from '../../utils/ch-error'
import { searchTraces, tracesHistogram, tracesFacets } from '../../api/telemetryPro'
import type { TelemetrySource } from '../../types/telemetry'
import type { TraceSummary, TraceFacets as TraceFacetsT, TraceHistogramBucket } from '../../types/telemetryPro'
Expand Down Expand Up @@ -237,6 +238,14 @@
</Button>
</div>

{#if error && isMissingTableError(error)}
<EmptyState
icon={Waypoints}
title="Telemetry tables not found"
description="The traces table this source points at does not exist in ClickHouse. Check the source, or start the OpenTelemetry collector so it creates its tables."
primary={{ label: 'Configure sources', onclick: () => setSection('sources') }}
/>
{:else}
{#if error}
<div class="shrink-0 border-b border-edge-subtle bg-danger-soft px-5 py-1.5 text-xs text-danger">{error}</div>
{/if}
Expand Down Expand Up @@ -301,4 +310,5 @@
{/if}
</div>
</div>
{/if}
{/if}
8 changes: 8 additions & 0 deletions ui/src/lib/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ export function isPageRouteType(type: string | undefined | null): type is PageRo
return !!type && type in PAGE_ROUTES
}

/**
* Old paths that still arrive from docs, emails and bookmarks. The router
* rewrites them to the current path, keeping the query string.
*/
export const PATH_ALIASES: Record<string, string> = {
'/settings': PAGE_ROUTES.settings.path,
}

/** Path -> route type, for parsing the URL. */
export const PATH_TO_PAGE: Record<string, PageRoute> = Object.fromEntries(
(Object.keys(PAGE_ROUTES) as PageRoute[]).map((k) => [PAGE_ROUTES[k].path, k]),
Expand Down
12 changes: 10 additions & 2 deletions ui/src/lib/stores/router.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { withBase, stripBase } from '../basePath'
import { getActiveTab, getTabs, openHomeTab, setActiveTab } from './tabs.svelte'
import { isPageRouteType, PAGE_ROUTES, PATH_TO_PAGE, type PageRoute } from '../routes'
import { isPageRouteType, PAGE_ROUTES, PATH_ALIASES, PATH_TO_PAGE, type PageRoute } from '../routes'
import { syncSectionFromUrl } from './nav.svelte'

// ── Current route type (reactive) ────────────────────────────────
Expand Down Expand Up @@ -133,7 +133,15 @@ export function pushPipelineList(): void {
// ── Parse current URL ───────────────────────────────────────────

export function parseRoute(): { type: string; dashboardId?: string; pipelineId?: string } {
const path = stripBase(window.location.pathname)
let path = stripBase(window.location.pathname)

// Old path (/settings?section=license): move the URL to the current one
// so ?section= and later navigation see the canonical path.
const alias = PATH_ALIASES[path]
if (alias) {
history.replaceState(null, '', withBase(alias) + window.location.search + window.location.hash)
path = alias
}

// /dashboards/:id
const dashMatch = path.match(/^\/dashboards\/(.+)$/)
Expand Down
2 changes: 2 additions & 0 deletions ui/src/lib/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ export interface AuditLog {
id: string
action: string
username: string | null
/** ClickHouse account the action ran as; omitted when it equals username */
ch_user?: string | null
details: string | null
ip_address: string | null
created_at: string
Expand Down
Loading
Loading