Enforce per-resource dashboard grants (RBAC matrix's own/granted qualifier)
api/dashboards' handler previously enforced only tenant-baseline role
(RoleEditor+), so any Editor could edit/delete any dashboard in their
tenant -- the matrix's "(own/granted)" qualifier was explicitly named
as unbuilt in this handler's own doc comment. This closes that gap.
New core interface api/dashboards.PermissionStore (nil-safe, same "not
wired == no-op" shape as authz.Authorizer) resolves a per-resource
dashboard_permissions grant. canEditDashboard now requires the
identity be Admin/Owner, the dashboard's creator, or hold a grant of at
least Editor; canManageGrants is deliberately stricter (creator or
Admin/Owner only, never grant-derived access) so a user who can edit a
dashboard only because of a grant can't extend or re-grant that access
to themselves or others. Wired handlers: PUT/DELETE
/dashboards/{id}/permissions/{userId}, GET .../permissions.
Two real bugs found and fixed while wiring this up, before any of it
touched a live database:
- handleCreate/handleImport never stamped created_by from the
authenticated identity, so every dashboard was owned by "anonymous"
regardless of who made it -- the ownership check would have been
meaningless. Also fixed: ImportDashboard trusted the exported JSON's
created_by verbatim, so re-importing someone else's export would
leave the actual importer unable to edit their own copy.
- metadata/migrations/0024_create_dashboard_permissions.sql's CHECK
constraint diverged from /docs/phase-4-rbac-design.md's schema
(allowed role='admin', nullable granted_by). Reconciled via
0033_restrict_dashboard_permissions_role.sql: Admin/Owner already
have tenant-wide access so a resource-level "admin" grant is
meaningless, and every real grant now always has an attributable
granter.
enterprise/internal/rbacstore gets the storage side: raw CRUD
(dashboard_permissions.go) plus DashboardPermissions
(dashboards_adapter.go), an adapter implementing
api/dashboards.PermissionStore -- same pattern as audit.QueryAPILogger
over queryapi.AuditLogger. Wired into enterprise/cmd/enterprise-api
only; plain api/cmd/api passes nil (ownership/Admin checks still work
via the nil-permissions fallback, just without the "granted" bonus).
Verified: the full own/granted/admin/creator matrix, including the
granted-editor-cannot-manage-grants regression, passes against a fake
PermissionStore (api/dashboards/handler_test.go, all existing tests
also still pass unmodified in behavior). Real integration tests exist
in enterprise/internal/rbacstore/rbacstore_test.go (skip-gated on
RBACSTORE_TEST_POSTGRES_ADDR, same convention as every other
Postgres-backed piece this phase) but have not run against a live
database in this environment -- disclosed in threat-model.md,
phase-4-runbook.md, and enterprise/README.md alongside every other
piece carrying the same gap. Also fixed a stale path in
phase-4-runbook.md's dashboards-tenant-scoping section
(./internal/dashboards/... -> ./dashboards/..., stale since that
package moved out of api/internal/ earlier in this phase).
This commit is contained in:
@@ -166,6 +166,18 @@ 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.
|
||||
|
||||
**Implementation note (Phase 4 task 5, added after this design was
|
||||
signed off):** `dashboard_permissions` is now built --
|
||||
`enterprise/internal/rbacstore`'s CRUD plus a `DashboardPermissions`
|
||||
adapter implementing a new core interface, `api/dashboards.
|
||||
PermissionStore`, enforced in `api/dashboards`' handler. The applied
|
||||
migration (`metadata/migrations/0024_create_dashboard_permissions.sql`)
|
||||
diverged slightly from the schema above -- it allowed `role='admin'`
|
||||
and left `granted_by` nullable -- and was reconciled to match this
|
||||
document via `0033_restrict_dashboard_permissions_role.sql`, found
|
||||
while wiring the enforcement code up. See `enterprise/README.md` and
|
||||
`/docs/security/threat-model.md` for verification status.
|
||||
|
||||
## Enforcement shape (design only — implementation is task 5)
|
||||
|
||||
RBAC checks happen server-side, on every `/api`/`/alerting` endpoint
|
||||
|
||||
+39
-3
@@ -232,15 +232,43 @@ tenant. Verify the real SQL, not just the fake-store unit tests:
|
||||
docker run --rm --network sentry_default -v $(pwd)/api:/src -w /src \
|
||||
-e DASHBOARDS_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
|
||||
-e DASHBOARDS_TEST_POSTGRES_PASSWORD=sentry-dev-only \
|
||||
golang:1.25-alpine go test ./internal/dashboards/... -run Integration -v
|
||||
golang:1.25-alpine go test ./dashboards/... -run Integration -v
|
||||
```
|
||||
|
||||
(Path fixed from an earlier `./internal/dashboards/...` -- stale since
|
||||
`dashboards` moved from `api/internal/dashboards` to `api/dashboards`
|
||||
earlier in Phase 4, once `enterprise/cmd/enterprise-api` needed to
|
||||
import it: Go's compiler-enforced `internal/` visibility rule meant a
|
||||
separate module like `enterprise/` could never import anything under
|
||||
`api/internal/...`, regardless of the AGPL/commercial licensing
|
||||
boundary, which only forbids the reverse direction.)
|
||||
|
||||
Expect all `TestIntegration*` tests to pass, including
|
||||
`TestIntegrationDashboardTenantForeignKeyRejectsUnknownTenant` (the
|
||||
`tenant_id` foreign key added in
|
||||
`metadata/migrations/0027_add_dashboards_tenant_fk.sql` rejecting a
|
||||
dashboard for a tenant that doesn't exist).
|
||||
|
||||
## 5a. Per-resource dashboard grants (new -- built and unit-tested, not yet run live)
|
||||
|
||||
`enterprise/internal/rbacstore`'s `dashboard_permissions` CRUD and its
|
||||
`DashboardPermissions` adapter (implementing `api/dashboards.
|
||||
PermissionStore`) have real integration tests, same skip-gated shape as
|
||||
§6 below:
|
||||
|
||||
```sh
|
||||
docker run --rm --network sentry_default -v $(pwd)/enterprise:/src -w /src \
|
||||
-e RBACSTORE_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
|
||||
-e RBACSTORE_TEST_POSTGRES_PASSWORD=sentry-dev-only \
|
||||
golang:1.25-alpine go test ./internal/rbacstore/... -run DashboardPermission -v
|
||||
```
|
||||
|
||||
Expect all `TestDashboardPermission*`/`TestSetDashboardPermission*`/
|
||||
`TestGetDashboardPermission*`/`TestRevokeDashboardPermission*`/
|
||||
`TestListDashboardPermissions` tests to pass. This only takes effect
|
||||
when `enterprise-api` (not plain `api`) is serving traffic -- see
|
||||
§8/§10.
|
||||
|
||||
## 6. `enterprise/internal/rbacstore` and `internal/audit` (already verified — reconfirm here)
|
||||
|
||||
```sh
|
||||
@@ -422,8 +450,16 @@ Full accounting: `/docs/security/threat-model.md`. Headline items:
|
||||
identity either (refused outright) for either protocol.
|
||||
- No admin UI to create a `tenant_memberships` row -- §3a's manual SQL
|
||||
bootstrap is the only way to grant a logged-in identity access today.
|
||||
- **No per-resource dashboard grants** (`dashboard_permissions` has a
|
||||
schema, no handler reads it).
|
||||
- **Per-resource dashboard grants are now enforced** (`api/dashboards`'
|
||||
handler reads `dashboard_permissions` via
|
||||
`enterprise/internal/rbacstore.DashboardPermissions`, only when
|
||||
`enterprise-api` -- not plain `api` -- serves traffic), but there's
|
||||
still no UI or `sentryctl` command to create a grant -- `PUT
|
||||
/dashboards/{id}/permissions/{userId}` has to be called directly.
|
||||
Verified against a fake store; the real-Postgres integration tests
|
||||
(`enterprise/internal/rbacstore/rbacstore_test.go`) haven't run
|
||||
against a live database in this environment, same gap as the rest of
|
||||
this phase's Postgres-backed pieces.
|
||||
- Three of the four adversarial ClickHouse/Tantivy probes named in
|
||||
`/docs/phase-4-isolation-design.md`'s verification plan are closed
|
||||
(§8, §9); the last (mid-provisioning-race handling) is still stubbed
|
||||
|
||||
@@ -219,12 +219,31 @@ means an operator who forgets to set `ENTERPRISE_AUTH_URL` in a
|
||||
multi-tenant deployment gets *no* enforcement at all, silently. Worth a
|
||||
deployment-time check a real rollout should add (not built here).
|
||||
|
||||
**Not yet enforced:** the RBAC matrix's `(own/granted)` qualifier for
|
||||
Editor-level dashboard actions — `dashboard_permissions` (per-resource
|
||||
grants beyond a user's tenant-baseline role) has a schema
|
||||
(`metadata/migrations/0024_create_dashboard_permissions.sql`) but no
|
||||
handler reads it yet. Every Editor in a tenant can act on every
|
||||
dashboard in that tenant, not just their own/granted ones.
|
||||
**Now enforced:** the RBAC matrix's `(own/granted)` qualifier for
|
||||
Editor-level dashboard actions. `dashboard_permissions`
|
||||
(`metadata/migrations/0024_create_dashboard_permissions.sql`, tightened
|
||||
by `0033_restrict_dashboard_permissions_role.sql`) is read via
|
||||
`api/dashboards.PermissionStore` — a core-defined interface, same shape
|
||||
as `queryapi.AuditLogger` — implemented by
|
||||
`enterprise/internal/rbacstore.DashboardPermissions` and wired in only
|
||||
by `enterprise/cmd/enterprise-api`. A plain Editor may now only
|
||||
edit/delete a dashboard (or its panels) they created, or one where a
|
||||
grant raises their effective role to Editor; Admin/Owner still act on
|
||||
any dashboard in their tenant. Managing grants themselves
|
||||
(`PUT`/`DELETE /dashboards/{id}/permissions/{userId}`) is deliberately
|
||||
stricter than editing content — only the creator or Admin/Owner may
|
||||
grant or revoke, never a user who can edit *only* because of a grant
|
||||
(closes a self-escalation path a looser check would allow). Verified by
|
||||
`api/dashboards/handler_test.go`'s fake-store tests (the ownership/
|
||||
grant/admin matrix, plus the granted-editor-cannot-manage-grants
|
||||
regression case) — real integration tests against a live Postgres exist
|
||||
in `enterprise/internal/rbacstore/rbacstore_test.go` but, like the rest
|
||||
of this phase's rbacstore work, have not been run against one in this
|
||||
environment. A plain `api/cmd/api` deployment with RBAC enforcement on
|
||||
but no enterprise permission service wired still enforces ownership/
|
||||
Admin — only the "granted" bonus and grant management are unavailable
|
||||
there (nil `PermissionStore` is a documented no-op, same shape as a nil
|
||||
`Authorizer`).
|
||||
|
||||
**Application-layer tenant scoping (dashboards only).** Every
|
||||
`dashboards` store query filters `WHERE tenant_id = $identity.TenantID`
|
||||
@@ -389,7 +408,7 @@ terms:
|
||||
| Human SSO login — OIDC | **Built, verified with a real fake IdP** (not yet tried against a real external IdP) |
|
||||
| Human SSO login — SAML | **Built, verified with a real fake IdP** (not yet tried against a real external IdP) |
|
||||
| Multi-tenant-membership login (tenant picker) | **Not implemented** — refused with a clear error, not guessed |
|
||||
| Per-resource dashboard grants (`own/granted`) | **Not implemented** |
|
||||
| Per-resource dashboard grants (`own/granted`) | **Built, unit-tested against a fake store; live-Postgres integration tests written, not run in this environment** (only when `enterprise-api` serves traffic — plain `api` falls back to own/Admin only) |
|
||||
| Query audit logging (routine queries) | **Enforced**, fail-open, and now wired to a real writer via `enterprise-api` (`audit.QueryAPILogger`) |
|
||||
| Audit log tamper detection (hash chain) | **Enforced**, verified live |
|
||||
| Audit log tamper prevention (external anchoring) | **Design only** — `FileSink` is a dev stand-in |
|
||||
|
||||
Reference in New Issue
Block a user