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:
2026-08-14 07:11:18 -07:00
parent 08a90a27aa
commit 243f4dc2ab
15 changed files with 1064 additions and 54 deletions
+39 -3
View File
@@ -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