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).
66 lines
2.3 KiB
Go
66 lines
2.3 KiB
Go
// 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
|
|
}
|