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,213 @@
|
||||
# RBAC design
|
||||
|
||||
> **Status:** Design, awaiting sign-off. Task 3 of Phase 4 — stop here
|
||||
> before implementing enforcement/store code (`internal/rbacstore`,
|
||||
> the `internal/session` middleware, RBAC checks wired into `/api`,
|
||||
> `/alerting`, `/web`), per explicit instruction, same discipline as
|
||||
> `/docs/phase-4-isolation-design.md`. Read that document first — this
|
||||
> one assumes its module-placement decision (isolation and RBAC
|
||||
> mechanisms live in `enterprise/` only) and its `alerting`↔`api`
|
||||
> service-identity finding, which this doc's role model has to account
|
||||
> for as a distinct category, not a variant of human roles.
|
||||
|
||||
## Why this design, in one paragraph
|
||||
|
||||
A plain three-tier viewer/editor/admin model (the original ask) has two
|
||||
real gaps once you actually walk through who does what: nothing prevents
|
||||
admins from locking each other out of a tenant entirely (the last admin
|
||||
demotes themselves, or two admins race to remove each other — a shape of
|
||||
bug organizations with real access-control systems hit often enough that
|
||||
GitHub/GitLab-style products all converge on the same fix), and nothing
|
||||
answers "who can grant additional access to a specific resource" — if
|
||||
any editor can, an editor can silently self-escalate. This design adds a
|
||||
non-removable `Owner` above `Admin` for the first gap, and scopes
|
||||
per-resource grant management to resource creators + admins/owner for
|
||||
the second, with every grant change itself audit-logged (task 4) — the
|
||||
exact thing a security reviewer asks "show me every time access
|
||||
widened" about. It also formalizes `alerting`'s evaluator as a distinct
|
||||
**service identity** category, not a point on the human role scale,
|
||||
because task 2's design doc found it needs categorically narrower
|
||||
authority ("run this one already-persisted rule's query," never general
|
||||
tenant browsing) than even a Viewer has.
|
||||
|
||||
## Roles
|
||||
|
||||
**Owner** → **Admin** → **Editor** → **Viewer**, strictly ordered — each
|
||||
role's baseline access is a superset of the one below it. Exactly one
|
||||
Owner per tenant at a time; Owner is non-removable and non-demotable
|
||||
except by itself (voluntary transfer) or a platform operator
|
||||
(break-glass, itself audit-logged and out of normal tenant-admin
|
||||
control). This is the answer to "can admins lock each other out": they
|
||||
can't lock out the Owner, and the Owner can always recover.
|
||||
|
||||
Plus **additive-only per-resource grants** — a `dashboard_permissions`
|
||||
row lets a specific user exceed their baseline tenant role on one
|
||||
specific dashboard (e.g. a Viewer given Editor-level access to one
|
||||
dashboard they need to maintain, without making them a tenant-wide
|
||||
Editor). No deny-overrides: a grant can never take away access someone's
|
||||
baseline role already has. Deny-overrides (restricting a specific
|
||||
Editor from a specific sensitive dashboard, say) are named future work,
|
||||
not solved here — the additive-only model is simpler to reason about
|
||||
and implement correctly, and covers the more common real need ("let this
|
||||
one person help with this one thing").
|
||||
|
||||
Plus a distinct **service identity** category, not on the role scale at
|
||||
all: `alerting`'s evaluator authenticates as itself (a service
|
||||
credential, task 5), authorized narrowly to "execute the query belonging
|
||||
to already-persisted rule X, where X's tenant is resolved server-side" —
|
||||
never general read access to a tenant's dashboards, users, or anything
|
||||
else. A compromised or buggy evaluator can re-run known rule queries; it
|
||||
cannot browse.
|
||||
|
||||
## Permission matrix
|
||||
|
||||
| Action | Viewer | Editor | Admin | Owner |
|
||||
|---|---|---|---|---|
|
||||
| View dashboard (baseline or granted access) | ✓ | ✓ | ✓ | ✓ |
|
||||
| Create dashboard | ✗ | ✓ | ✓ | ✓ |
|
||||
| Edit/delete dashboard | ✗ | ✓ (own, or granted) | ✓ (any) | ✓ |
|
||||
| Manage a dashboard's per-user grants | ✗ | ✓ (only if creator) | ✓ | ✓ |
|
||||
| Run ad hoc query (UI/CLI/API) | ✓ | ✓ | ✓ | ✓ |
|
||||
| Create/edit/delete alert rule | ✗ | ✓ | ✓ | ✓ |
|
||||
| Enable/disable alert rule | ✗ | ✓ | ✓ | ✓ |
|
||||
| View alert delivery log | ✓ | ✓ | ✓ | ✓ |
|
||||
| Create/edit notification target | ✗ | ✓ | ✓ | ✓ |
|
||||
| View notification target secret (webhook URL/token) | ✗ | ✗ | ✓ | ✓ |
|
||||
| Delete notification target | ✗ | ✗ | ✓ | ✓ |
|
||||
| View data source config | ✓ | ✓ | ✓ | ✓ |
|
||||
| Manage tenant users / role assignments | ✗ | ✗ | ✓ (not Owner) | ✓ |
|
||||
| Manage SSO config | ✗ | ✗ | ✓ | ✓ |
|
||||
| View tenant audit log | own history only | own history only | ✓ | ✓ |
|
||||
| Transfer tenant Owner | ✗ | ✗ | ✗ | ✓ (or platform break-glass) |
|
||||
|
||||
Every row in the "Admin/Owner-only" section that changes *someone else's*
|
||||
access (role assignment, grant management, SSO config, Owner transfer) is
|
||||
written to the audit log (task 4) as its own event type, distinct from a
|
||||
query execution — reviewers ask for this list specifically.
|
||||
|
||||
Editors intentionally cannot see notification target secrets (webhook
|
||||
URLs/tokens often encode credentials) even though they can select and
|
||||
use a target when creating a rule — this mirrors the existing plaintext-
|
||||
secret disclosure in `/docs/phase-3-alerting-design.md`'s "Known gaps":
|
||||
Phase 3 already flagged `notification_targets.secret` as stored
|
||||
plaintext; RBAC narrows *who can read it back*, it doesn't change how
|
||||
it's stored (that's still named future hardening work, not solved here).
|
||||
|
||||
## `data_sources`: the honest scope of "per-data-source scoping"
|
||||
|
||||
Today, and through the end of this phase, every tenant has exactly one
|
||||
data source: their own ClickHouse database + Tantivy index pair from
|
||||
`/docs/phase-4-isolation-design.md`. A `data_sources` table (tenant-
|
||||
scoped, one row auto-created per tenant at provisioning time) exists as
|
||||
the extension point for a real future multi-source-per-tenant feature —
|
||||
e.g. a tenant connecting a second ClickHouse cluster, or a distinct log
|
||||
stream with its own retention. Role grants can reference a
|
||||
`data_source_id` in the schema now, but with one data source per tenant
|
||||
there is nothing meaningfully different a per-data-source grant does
|
||||
yet. Stated plainly so this doesn't read as more built than it is.
|
||||
|
||||
## Schema
|
||||
|
||||
Lives in `/metadata` (`sentry_metadata`), alongside everything else from
|
||||
Phase 3, per `/docs/phase-4-isolation-design.md`'s existing schema
|
||||
additions (`tenants`, the `tenant_id` backfill on `alert_state`/
|
||||
`delivery_log`). New tables, continuing that migration sequence:
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
-- Identity provenance, not a password -- SSO is the only login path.
|
||||
-- One user row can in principle federate from either an OIDC or a
|
||||
-- SAML IdP; which one isn't fixed at the user level, it's determined
|
||||
-- per tenant_memberships row via the tenant's configured SSO method.
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE tenant_memberships (
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL CHECK (role IN ('viewer', 'editor', 'admin', 'owner')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_id, user_id)
|
||||
);
|
||||
-- Application-level invariant (not a DB constraint -- Postgres has no
|
||||
-- native "exactly one row matching a predicate per group" check):
|
||||
-- exactly one 'owner' row per tenant_id at a time. Enforced in
|
||||
-- internal/rbacstore's transfer/provisioning logic, not the schema.
|
||||
|
||||
CREATE TABLE data_sources (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
||||
name TEXT NOT NULL DEFAULT 'default',
|
||||
clickhouse_database_name TEXT NOT NULL,
|
||||
tantivy_index_path TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE dashboard_permissions (
|
||||
id UUID PRIMARY KEY,
|
||||
dashboard_id UUID NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
-- Additive only: this grant can only raise access above the user's
|
||||
-- tenant-wide baseline role for this one dashboard, never lower it.
|
||||
permission TEXT NOT NULL CHECK (permission IN ('viewer', 'editor')),
|
||||
granted_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (dashboard_id, user_id)
|
||||
);
|
||||
```
|
||||
|
||||
`alert_rules`/`notification_targets`/`dashboards` already carry
|
||||
`tenant_id` (Phase 3); no per-row ownership beyond `created_by` (already
|
||||
present) is needed for the matrix above — "own vs. any" in the matrix is
|
||||
`created_by = current user` vs. tenant-wide Admin/Owner authority, not a
|
||||
separate grants table for those resource types.
|
||||
|
||||
## Enforcement shape (design only — implementation is task 5)
|
||||
|
||||
RBAC checks happen server-side, on every `/api`/`/alerting` endpoint
|
||||
that touches tenant data — never UI-level button-hiding alone, per the
|
||||
explicit requirement. The shape: middleware resolves `(tenant.ID, user,
|
||||
role)` from a validated session (or, for `alerting`, the service
|
||||
identity) before a handler runs; each handler declares the minimum role
|
||||
an action requires; a request failing that check gets a 403 before any
|
||||
tenant-scoped connection is even acquired — RBAC is a gate in front of
|
||||
the isolation mechanism from `/docs/phase-4-isolation-design.md`, not a
|
||||
replacement for it. Full middleware/handler wiring is task 5's scope.
|
||||
|
||||
## Web UI boundary: a runtime capability check, not a conditional import
|
||||
|
||||
Core `web` never bundles enterprise-licensed Svelte components into its
|
||||
build — that would put commercial-licensed source inside an AGPL
|
||||
artifact, the UI-layer equivalent of the Go import-boundary problem
|
||||
`hack/check-tenant-boundary.sh` already guards against. Instead: core
|
||||
`web` ships a generic settings/admin route
|
||||
(`web/src/routes/settings/+page.svelte`, added in task 5) that, on load,
|
||||
calls `GET {enterprise-auth base URL}/auth/features` and renders
|
||||
sections conditionally based on the response:
|
||||
|
||||
```json
|
||||
{"sso_configured": true, "oidc_enabled": true, "saml_enabled": false}
|
||||
```
|
||||
|
||||
If `enterprise-auth` isn't deployed or configured, that fetch fails or
|
||||
returns all-`false`, and the settings page simply shows core-only
|
||||
content — no broken links, no "upgrade to unlock" dead ends, just an
|
||||
absent section. This is a runtime capability check against a documented
|
||||
REST contract, the same pattern `web` already uses for its two backend
|
||||
base URLs (`apiBase`/`alertingBase` in `web/src/lib/api.ts`), not a new
|
||||
mechanism — just pointed at a third, optional backend.
|
||||
|
||||
## What this document deliberately does not solve here
|
||||
|
||||
- Deny-override grants (named future work above).
|
||||
- Enforcement middleware implementation (task 5).
|
||||
- Audit logging of grant/role changes (task 4 builds the audit log
|
||||
itself; this doc only names which actions must be logged).
|
||||
- SSO-to-role mapping policy (e.g. IdP group claims auto-assigning
|
||||
roles) — a real feature, not designed here; Phase 4's baseline is
|
||||
manual role assignment by an Admin/Owner after a user's first SSO
|
||||
login creates their `users` row.
|
||||
Reference in New Issue
Block a user