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:
+26
-8
@@ -105,15 +105,34 @@ what `samlidp`'s own default assertion builder does. Neither protocol
|
||||
has been tried against a real external IdP or a running
|
||||
`enterprise-auth` container -- see `/docs/phase-4-runbook.md` §3a/§3b.
|
||||
|
||||
`dashboard_permissions` is now wired end to end: `rbacstore/
|
||||
dashboard_permissions.go` is the raw CRUD, `rbacstore/
|
||||
dashboards_adapter.go`'s `DashboardPermissions` implements
|
||||
`api/dashboards.PermissionStore` (the interface core defines and
|
||||
carries as a nil-by-default field, same shape as
|
||||
`queryapi.AuditLogger`), and `enterprise-api`'s `main.go` wires it in.
|
||||
`api/dashboards`' handler now enforces the matrix's "(own/granted)"
|
||||
qualifier: an Editor may edit/delete a dashboard (or its panels) they
|
||||
created, or one where a grant raises their effective role to Editor;
|
||||
managing grants themselves is stricter still -- creator or Admin/Owner
|
||||
only, closing a self-escalation path where a granted-but-not-creator
|
||||
Editor could otherwise extend their own access. `metadata/migrations/
|
||||
0033_restrict_dashboard_permissions_role.sql` fixes a divergence
|
||||
between 0024's actual CHECK constraint (allowed `role='admin'`, nullable
|
||||
`granted_by`) and the design doc's schema (viewer/editor only,
|
||||
`granted_by` required) found while wiring this up. Verified against a
|
||||
fake `PermissionStore` in `api/dashboards/handler_test.go` (the full
|
||||
own/granted/admin/creator matrix, including the granted-editor-cannot-
|
||||
manage-grants regression); real integration tests exist in
|
||||
`rbacstore_test.go` but haven't run against a live Postgres in this
|
||||
environment, same disclosed gap as the rest of this package's
|
||||
Postgres-backed pieces.
|
||||
|
||||
**Deliberately deferred, not half-built** -- named explicitly rather than
|
||||
silently left out:
|
||||
- A tenant-picker UI/flow for an identity with more than one
|
||||
`tenant_memberships` row -- `loginhandler` refuses these logins
|
||||
outright rather than guessing (`ErrMultipleMemberships`).
|
||||
- `dashboard_permissions` CRUD (schema exists,
|
||||
`metadata/migrations/0024`; no caller reads per-resource grants
|
||||
yet -- `dashboards`' handler enforces tenant-baseline role only, not
|
||||
the matrix's "(own/granted)" qualifier).
|
||||
- **Ingest tenant-awareness, for either storage engine** -- `chrunner`/
|
||||
`searchclient` prove read isolation given tenant-scoped data exists,
|
||||
but nothing writes it: every record `ingest` produces still lands in
|
||||
@@ -139,7 +158,7 @@ internal/saml/ crewjam/saml wiring: SP setup, login redirect, respons
|
||||
internal/session/ issues/validates signed session + RoleService tokens
|
||||
internal/authhandler/ POST /internal/authorize, GET /auth/features
|
||||
internal/loginhandler/ GET /auth/oidc/{login,callback} + GET /auth/saml/login + POST /auth/saml/acs -- the human login flow
|
||||
internal/rbacstore/ users/tenants/tenant_memberships/data_sources CRUD (pgx against sentry_metadata)
|
||||
internal/rbacstore/ users/tenants/tenant_memberships/data_sources/dashboard_permissions CRUD (pgx against sentry_metadata)
|
||||
internal/tenantprovision/ real ClickHouse CREATE DATABASE/USER/GRANT
|
||||
internal/chrunner/ tenant-scoped api/querylang/executor.SQLRunner
|
||||
internal/searchclient/ tenant-scoped api/querylang/executor.SearchClient
|
||||
@@ -149,9 +168,8 @@ internal/apiconfig/ enterprise-api's own env-var config
|
||||
internal/config/ enterprise-auth's env-var config
|
||||
```
|
||||
|
||||
Future additions: `dashboard_permissions` CRUD, ingest tenant-awareness
|
||||
(undesigned), and real deployment-topology wiring for `enterprise-api`
|
||||
-- see "Status" above.
|
||||
Future additions: ingest tenant-awareness (undesigned), and real
|
||||
deployment-topology wiring for `enterprise-api` -- see "Status" above.
|
||||
|
||||
## Why OIDC and SAML aren't hand-rolled
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ func main() {
|
||||
auditLogger := audit.NewQueryAPILogger(audit.NewStore(auditPool), audit.SourceAPI)
|
||||
|
||||
queryHandler := queryapi.NewHandler(logger, registry, search, cfg.QueryTimeout, auditLogger, authorizer)
|
||||
dashboardsHandler := dashboards.NewHandler(logger, dashboards.NewStore(pgPool), authorizer)
|
||||
dashboardsHandler := dashboards.NewHandler(logger, dashboards.NewStore(pgPool), authorizer, rbacstore.NewDashboardPermissions(rbac))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
queryHandler.RegisterRoutes(mux)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package rbacstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// DashboardPermission is one dashboard_permissions row -- see
|
||||
// /docs/phase-4-rbac-design.md's "additive-only per-resource grants"
|
||||
// section and metadata/migrations/0024/0033. Role is always RoleViewer
|
||||
// or RoleEditor: metadata/migrations/0033_restrict_dashboard_permissions_role.sql
|
||||
// narrowed the CHECK constraint to match, since Admin/Owner already have
|
||||
// tenant-wide access and never need a resource-level grant.
|
||||
type DashboardPermission struct {
|
||||
DashboardID string
|
||||
UserID string
|
||||
Role Role
|
||||
GrantedBy string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// SetDashboardPermission upserts a grant -- the sole mutation path,
|
||||
// same "one method, ON CONFLICT DO UPDATE" shape as SetMembership, so a
|
||||
// future audit-log hook has one call site to wrap. grantedBy is
|
||||
// required (metadata/migrations/0033 made granted_by NOT NULL): every
|
||||
// grant must be attributable to the identity that created it.
|
||||
func (s *Store) SetDashboardPermission(ctx context.Context, dashboardID, userID string, role Role, grantedBy string) error {
|
||||
if grantedBy == "" {
|
||||
return fmt.Errorf("rbacstore: grantedBy is required")
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO dashboard_permissions (id, dashboard_id, user_id, role, granted_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (dashboard_id, user_id) DO UPDATE
|
||||
SET role = EXCLUDED.role, granted_by = EXCLUDED.granted_by`,
|
||||
uuid.NewString(), dashboardID, userID, string(role), grantedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rbacstore: setting dashboard permission: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) RevokeDashboardPermission(ctx context.Context, dashboardID, userID string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM dashboard_permissions WHERE dashboard_id = $1 AND user_id = $2`, dashboardID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rbacstore: revoking dashboard permission: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetDashboardPermission(ctx context.Context, dashboardID, userID string) (*DashboardPermission, error) {
|
||||
var p DashboardPermission
|
||||
var role string
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT dashboard_id, user_id, role, granted_by, created_at
|
||||
FROM dashboard_permissions WHERE dashboard_id = $1 AND user_id = $2`, dashboardID, userID)
|
||||
if err := row.Scan(&p.DashboardID, &p.UserID, &role, &p.GrantedBy, &p.CreatedAt); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("rbacstore: getting dashboard permission: %w", err)
|
||||
}
|
||||
p.Role = Role(role)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ListDashboardPermissions supports the "manage a dashboard's per-user
|
||||
// grants" UI/endpoint -- every grant on one dashboard, for a
|
||||
// creator/Admin/Owner to review or revoke.
|
||||
func (s *Store) ListDashboardPermissions(ctx context.Context, dashboardID string) ([]DashboardPermission, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT dashboard_id, user_id, role, granted_by, created_at
|
||||
FROM dashboard_permissions WHERE dashboard_id = $1 ORDER BY created_at`, dashboardID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rbacstore: listing dashboard permissions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []DashboardPermission
|
||||
for rows.Next() {
|
||||
var p DashboardPermission
|
||||
var role string
|
||||
if err := rows.Scan(&p.DashboardID, &p.UserID, &role, &p.GrantedBy, &p.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("rbacstore: scanning dashboard permission: %w", err)
|
||||
}
|
||||
p.Role = Role(role)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Adapts *Store to api/dashboards.PermissionStore -- the interface core
|
||||
// defines and has carried as a nil-by-default field
|
||||
// (api/dashboards.Handler.permissions) since Phase 4 task 5, waiting on
|
||||
// exactly this: a real implementation, wired in by
|
||||
// enterprise/cmd/enterprise-api, the one binary allowed to import both
|
||||
// packages. Same shape as audit.QueryAPILogger's adapter over
|
||||
// api/queryapi.AuditLogger.
|
||||
package rbacstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/sentry/sentry/api/authz"
|
||||
"github.com/sentry/sentry/api/dashboards"
|
||||
)
|
||||
|
||||
// DashboardPermissions implements dashboards.PermissionStore by
|
||||
// translating between authz.Role (core's type) and this package's Role
|
||||
// (kept separate rather than importing authz's constants directly --
|
||||
// see Role's own doc comment for why).
|
||||
type DashboardPermissions struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
func NewDashboardPermissions(store *Store) *DashboardPermissions {
|
||||
return &DashboardPermissions{store: store}
|
||||
}
|
||||
|
||||
func (d *DashboardPermissions) GrantedRole(ctx context.Context, dashboardID, userID string) (authz.Role, bool, error) {
|
||||
p, err := d.store.GetDashboardPermission(ctx, dashboardID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
return authz.Role(p.Role), true, nil
|
||||
}
|
||||
|
||||
func (d *DashboardPermissions) SetPermission(ctx context.Context, dashboardID, userID string, role authz.Role, grantedBy string) error {
|
||||
if role != authz.RoleViewer && role != authz.RoleEditor {
|
||||
return fmt.Errorf("rbacstore: dashboard permission role must be viewer or editor, got %q", role)
|
||||
}
|
||||
return d.store.SetDashboardPermission(ctx, dashboardID, userID, Role(role), grantedBy)
|
||||
}
|
||||
|
||||
func (d *DashboardPermissions) RevokePermission(ctx context.Context, dashboardID, userID string) error {
|
||||
return d.store.RevokeDashboardPermission(ctx, dashboardID, userID)
|
||||
}
|
||||
|
||||
func (d *DashboardPermissions) ListPermissions(ctx context.Context, dashboardID string) ([]dashboards.Permission, error) {
|
||||
rows, err := d.store.ListDashboardPermissions(ctx, dashboardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dashboards.Permission, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, dashboards.Permission{
|
||||
UserID: r.UserID, Role: authz.Role(r.Role), GrantedBy: r.GrantedBy, CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -8,17 +8,15 @@
|
||||
// append-only ledger, so it has no analogous reason to restrict its own
|
||||
// write access.
|
||||
//
|
||||
// This package is the storage building block a future OIDC/SAML login
|
||||
// HTTP handler would call to resolve "which tenant/role does this SSO
|
||||
// identity map to" and issue a session (internal/session) accordingly --
|
||||
// that handler itself isn't built yet (see cmd/enterprise-auth/main.go's
|
||||
// doc comment), so today rbacstore's only production caller is
|
||||
// -mint-service-token's future tenant-aware successor and its own tests.
|
||||
// dashboard_permissions doesn't have CRUD here yet -- no caller reads
|
||||
// per-resource grants (see api/dashboards/handler.go's doc
|
||||
// comment). data_sources CRUD was added once enterprise/internal/
|
||||
// chrunner needed a real place to read per-tenant ClickHouse credentials
|
||||
// from at startup (see that package's doc comment).
|
||||
// This package is the storage building block internal/loginhandler's
|
||||
// OIDC/SAML handlers call to resolve "which tenant/role does this SSO
|
||||
// identity map to" and issue a session (internal/session) accordingly.
|
||||
// dashboard_permissions CRUD (dashboard_permissions.go) is wrapped by
|
||||
// DashboardPermissions (dashboards_adapter.go) to implement
|
||||
// api/dashboards.PermissionStore -- see that adapter's doc comment.
|
||||
// data_sources CRUD was added once enterprise/internal/chrunner needed a
|
||||
// real place to read per-tenant ClickHouse credentials from at startup
|
||||
// (see that package's doc comment).
|
||||
package rbacstore
|
||||
|
||||
import (
|
||||
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/sentry/sentry/api/authz"
|
||||
)
|
||||
|
||||
func testStore(t *testing.T) *Store {
|
||||
@@ -346,3 +348,232 @@ func TestListProvisionedDataSourcesExcludesUnprovisionedAndInactive(t *testing.T
|
||||
t.Fatal("expected the active, provisioned data source to be in the list")
|
||||
}
|
||||
}
|
||||
|
||||
// createTestDashboard inserts directly into the dashboards table (owned
|
||||
// by api/dashboards, not this package) -- dashboard_permissions.
|
||||
// dashboard_id has a real foreign-key constraint
|
||||
// (metadata/migrations/0024), so a permission row for a dashboard that
|
||||
// doesn't exist is rejected by Postgres itself. Mirrors
|
||||
// api/dashboards/store_integration_test.go's createTestTenant, which
|
||||
// does the same thing in reverse (inserting into tenants, a table that
|
||||
// package doesn't own either).
|
||||
func createTestDashboard(t *testing.T, s *Store, tenantID, createdBy string) string {
|
||||
t.Helper()
|
||||
id := uuid.NewString()
|
||||
_, err := s.pool.Exec(context.Background(), `
|
||||
INSERT INTO dashboards (id, tenant_id, name, default_earliest, default_latest, created_by)
|
||||
VALUES ($1, $2, $3, '-1h', 'now', $4)`, id, tenantID, "Test Dashboard "+uniqueSuffix(), createdBy)
|
||||
if err != nil {
|
||||
t.Fatalf("inserting test dashboard: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestSetDashboardPermissionThenGet(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
tenantID := "test-tenant-" + uniqueSuffix()
|
||||
if _, err := s.CreateTenant(ctx, tenantID, "Test Tenant"); err != nil {
|
||||
t.Fatalf("CreateTenant: %v", err)
|
||||
}
|
||||
creator, err := s.UpsertUserBySSO(ctx, "sub-creator-"+uniqueSuffix(), "creator-"+uniqueSuffix()+"@example.com", "Creator")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUserBySSO creator: %v", err)
|
||||
}
|
||||
grantee, err := s.UpsertUserBySSO(ctx, "sub-grantee-"+uniqueSuffix(), "grantee-"+uniqueSuffix()+"@example.com", "Grantee")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUserBySSO grantee: %v", err)
|
||||
}
|
||||
dashboardID := createTestDashboard(t, s, tenantID, creator.ID)
|
||||
|
||||
if err := s.SetDashboardPermission(ctx, dashboardID, grantee.ID, RoleEditor, creator.ID); err != nil {
|
||||
t.Fatalf("SetDashboardPermission: %v", err)
|
||||
}
|
||||
got, err := s.GetDashboardPermission(ctx, dashboardID, grantee.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDashboardPermission: %v", err)
|
||||
}
|
||||
if got.Role != RoleEditor || got.GrantedBy != creator.ID {
|
||||
t.Fatalf("unexpected permission: %+v", got)
|
||||
}
|
||||
|
||||
// Re-setting (e.g. a role change from viewer to editor) must update
|
||||
// in place, not create a duplicate row for the same (dashboard, user).
|
||||
if err := s.SetDashboardPermission(ctx, dashboardID, grantee.ID, RoleViewer, creator.ID); err != nil {
|
||||
t.Fatalf("SetDashboardPermission (update): %v", err)
|
||||
}
|
||||
got, err = s.GetDashboardPermission(ctx, dashboardID, grantee.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDashboardPermission after update: %v", err)
|
||||
}
|
||||
if got.Role != RoleViewer {
|
||||
t.Fatalf("role after update = %q, want viewer", got.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDashboardPermissionRequiresGrantedBy(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
tenantID := "test-tenant-" + uniqueSuffix()
|
||||
if _, err := s.CreateTenant(ctx, tenantID, "Test Tenant"); err != nil {
|
||||
t.Fatalf("CreateTenant: %v", err)
|
||||
}
|
||||
user, err := s.UpsertUserBySSO(ctx, "sub-"+uniqueSuffix(), "user-"+uniqueSuffix()+"@example.com", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUserBySSO: %v", err)
|
||||
}
|
||||
dashboardID := createTestDashboard(t, s, tenantID, user.ID)
|
||||
|
||||
if err := s.SetDashboardPermission(ctx, dashboardID, user.ID, RoleEditor, ""); err == nil {
|
||||
t.Fatal("expected an error for an empty grantedBy -- every grant must be attributable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardPermissionNotFound(t *testing.T) {
|
||||
s := testStore(t)
|
||||
if _, err := s.GetDashboardPermission(context.Background(), uuid.NewString(), uuid.NewString()); err != ErrNotFound {
|
||||
t.Fatalf("GetDashboardPermission error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeDashboardPermission(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
tenantID := "test-tenant-" + uniqueSuffix()
|
||||
if _, err := s.CreateTenant(ctx, tenantID, "Test Tenant"); err != nil {
|
||||
t.Fatalf("CreateTenant: %v", err)
|
||||
}
|
||||
creator, err := s.UpsertUserBySSO(ctx, "sub-creator-"+uniqueSuffix(), "creator-"+uniqueSuffix()+"@example.com", "Creator")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUserBySSO creator: %v", err)
|
||||
}
|
||||
grantee, err := s.UpsertUserBySSO(ctx, "sub-grantee-"+uniqueSuffix(), "grantee-"+uniqueSuffix()+"@example.com", "Grantee")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUserBySSO grantee: %v", err)
|
||||
}
|
||||
dashboardID := createTestDashboard(t, s, tenantID, creator.ID)
|
||||
if err := s.SetDashboardPermission(ctx, dashboardID, grantee.ID, RoleEditor, creator.ID); err != nil {
|
||||
t.Fatalf("SetDashboardPermission: %v", err)
|
||||
}
|
||||
|
||||
if err := s.RevokeDashboardPermission(ctx, dashboardID, grantee.ID); err != nil {
|
||||
t.Fatalf("RevokeDashboardPermission: %v", err)
|
||||
}
|
||||
if _, err := s.GetDashboardPermission(ctx, dashboardID, grantee.ID); err != ErrNotFound {
|
||||
t.Fatalf("GetDashboardPermission after revoke = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDashboardPermissions(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
tenantID := "test-tenant-" + uniqueSuffix()
|
||||
if _, err := s.CreateTenant(ctx, tenantID, "Test Tenant"); err != nil {
|
||||
t.Fatalf("CreateTenant: %v", err)
|
||||
}
|
||||
creator, err := s.UpsertUserBySSO(ctx, "sub-creator-"+uniqueSuffix(), "creator-"+uniqueSuffix()+"@example.com", "Creator")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUserBySSO creator: %v", err)
|
||||
}
|
||||
dashboardID := createTestDashboard(t, s, tenantID, creator.ID)
|
||||
otherDashboardID := createTestDashboard(t, s, tenantID, creator.ID)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
grantee, err := s.UpsertUserBySSO(ctx, fmt.Sprintf("sub-grantee-%d-%s", i, uniqueSuffix()), fmt.Sprintf("grantee-%d-%[email protected]", i, uniqueSuffix()), "Grantee")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUserBySSO grantee %d: %v", i, err)
|
||||
}
|
||||
if err := s.SetDashboardPermission(ctx, dashboardID, grantee.ID, RoleEditor, creator.ID); err != nil {
|
||||
t.Fatalf("SetDashboardPermission %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// A grant on a different dashboard must not leak into this one's list.
|
||||
otherGrantee, err := s.UpsertUserBySSO(ctx, "sub-other-"+uniqueSuffix(), "other-"+uniqueSuffix()+"@example.com", "Other")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUserBySSO otherGrantee: %v", err)
|
||||
}
|
||||
if err := s.SetDashboardPermission(ctx, otherDashboardID, otherGrantee.ID, RoleViewer, creator.ID); err != nil {
|
||||
t.Fatalf("SetDashboardPermission otherDashboard: %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListDashboardPermissions(ctx, dashboardID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDashboardPermissions: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("len(list) = %d, want 2", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashboardPermissionsAdapterImplementsPermissionStore drives the
|
||||
// adapter (dashboards_adapter.go) end to end -- the same interface
|
||||
// api/dashboards.Handler actually calls -- rather than only testing the
|
||||
// raw Store methods above, so a mismatch between the two (e.g. a bad
|
||||
// authz.Role<->Role conversion) would be caught here.
|
||||
func TestDashboardPermissionsAdapterImplementsPermissionStore(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
tenantID := "test-tenant-" + uniqueSuffix()
|
||||
if _, err := s.CreateTenant(ctx, tenantID, "Test Tenant"); err != nil {
|
||||
t.Fatalf("CreateTenant: %v", err)
|
||||
}
|
||||
creator, err := s.UpsertUserBySSO(ctx, "sub-creator-"+uniqueSuffix(), "creator-"+uniqueSuffix()+"@example.com", "Creator")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUserBySSO creator: %v", err)
|
||||
}
|
||||
grantee, err := s.UpsertUserBySSO(ctx, "sub-grantee-"+uniqueSuffix(), "grantee-"+uniqueSuffix()+"@example.com", "Grantee")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUserBySSO grantee: %v", err)
|
||||
}
|
||||
dashboardID := createTestDashboard(t, s, tenantID, creator.ID)
|
||||
|
||||
adapter := NewDashboardPermissions(s)
|
||||
|
||||
if _, ok, err := adapter.GrantedRole(ctx, dashboardID, grantee.ID); err != nil || ok {
|
||||
t.Fatalf("GrantedRole before any grant = (_, %v, %v), want (_, false, nil)", ok, err)
|
||||
}
|
||||
if err := adapter.SetPermission(ctx, dashboardID, grantee.ID, authz.RoleEditor, creator.ID); err != nil {
|
||||
t.Fatalf("SetPermission: %v", err)
|
||||
}
|
||||
role, ok, err := adapter.GrantedRole(ctx, dashboardID, grantee.ID)
|
||||
if err != nil || !ok || role != authz.RoleEditor {
|
||||
t.Fatalf("GrantedRole = (%v, %v, %v), want (editor, true, nil)", role, ok, err)
|
||||
}
|
||||
|
||||
list, err := adapter.ListPermissions(ctx, dashboardID)
|
||||
if err != nil || len(list) != 1 || list[0].Role != authz.RoleEditor {
|
||||
t.Fatalf("ListPermissions = (%+v, %v), want one editor grant", list, err)
|
||||
}
|
||||
|
||||
if err := adapter.RevokePermission(ctx, dashboardID, grantee.ID); err != nil {
|
||||
t.Fatalf("RevokePermission: %v", err)
|
||||
}
|
||||
if _, ok, _ := adapter.GrantedRole(ctx, dashboardID, grantee.ID); ok {
|
||||
t.Fatal("expected the grant to be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashboardPermissionsAdapterRejectsAdminRole is the regression test
|
||||
// for Permission's doc comment: Admin/Owner already have tenant-wide
|
||||
// dashboard access, so a resource-level grant of "admin" is meaningless
|
||||
// under this design and metadata/migrations/0033 tightened the CHECK
|
||||
// constraint to match -- the adapter must reject it before it ever
|
||||
// reaches SQL, not rely on the constraint alone.
|
||||
func TestDashboardPermissionsAdapterRejectsAdminRole(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
tenantID := "test-tenant-" + uniqueSuffix()
|
||||
if _, err := s.CreateTenant(ctx, tenantID, "Test Tenant"); err != nil {
|
||||
t.Fatalf("CreateTenant: %v", err)
|
||||
}
|
||||
creator, err := s.UpsertUserBySSO(ctx, "sub-creator-"+uniqueSuffix(), "creator-"+uniqueSuffix()+"@example.com", "Creator")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertUserBySSO creator: %v", err)
|
||||
}
|
||||
dashboardID := createTestDashboard(t, s, tenantID, creator.ID)
|
||||
|
||||
adapter := NewDashboardPermissions(s)
|
||||
if err := adapter.SetPermission(ctx, dashboardID, uuid.NewString(), authz.RoleAdmin, creator.ID); err == nil {
|
||||
t.Fatal("expected an error granting role=admin via a dashboard permission")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user