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:
+223
-19
@@ -27,26 +27,23 @@ type store interface {
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
logger *slog.Logger
|
||||
store store
|
||||
authorizer authz.Authorizer
|
||||
logger *slog.Logger
|
||||
store store
|
||||
authorizer authz.Authorizer
|
||||
permissions PermissionStore
|
||||
}
|
||||
|
||||
func NewHandler(logger *slog.Logger, store store, authorizer authz.Authorizer) *Handler {
|
||||
return &Handler{logger: logger, store: store, authorizer: authorizer}
|
||||
func NewHandler(logger *slog.Logger, store store, authorizer authz.Authorizer, permissions PermissionStore) *Handler {
|
||||
return &Handler{logger: logger, store: store, authorizer: authorizer, permissions: permissions}
|
||||
}
|
||||
|
||||
// RegisterRoutes wires the RBAC minimum-role bar from
|
||||
// /docs/phase-4-rbac-design.md's matrix. Note what's NOT enforced here
|
||||
// yet: the matrix's "(own/granted)" qualifier for Editor create/edit/
|
||||
// delete requires the dashboard_permissions/ownership lookup that
|
||||
// enterprise/internal/rbacstore hasn't been built yet -- until then,
|
||||
// RoleEditor is necessary but not sufficient per the matrix, and every
|
||||
// Editor can act on every dashboard *within their own tenant* (tenant
|
||||
// scoping itself -- a different, more basic property than the
|
||||
// per-resource "(own/granted)" qualifier -- is enforced, via tenantID
|
||||
// below and store.go's WHERE tenant_id = ... filtering). Tracked as
|
||||
// follow-up, not silently dropped.
|
||||
// /docs/phase-4-rbac-design.md's matrix, plus (as of Phase 4 task 5) the
|
||||
// matrix's "(own/granted)" qualifier: RoleEditor is necessary but not
|
||||
// sufficient for editing/deleting a dashboard or its panels -- see
|
||||
// canEditDashboard. Tenant scoping itself -- a different, more basic
|
||||
// property than the per-resource qualifier -- is enforced separately,
|
||||
// via tenantID below and store.go's WHERE tenant_id = ... filtering.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /dashboards", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleCreate))
|
||||
mux.HandleFunc("GET /dashboards", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleList))
|
||||
@@ -58,6 +55,60 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /dashboards/{id}/panels", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleAddPanel))
|
||||
mux.HandleFunc("PUT /dashboards/{id}/panels/{panelId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleUpdatePanel))
|
||||
mux.HandleFunc("DELETE /dashboards/{id}/panels/{panelId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleDeletePanel))
|
||||
mux.HandleFunc("GET /dashboards/{id}/permissions", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleListPermissions))
|
||||
mux.HandleFunc("PUT /dashboards/{id}/permissions/{userId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleSetPermission))
|
||||
mux.HandleFunc("DELETE /dashboards/{id}/permissions/{userId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleRevokePermission))
|
||||
}
|
||||
|
||||
// canEditDashboard reports whether the request's authenticated identity
|
||||
// may edit/delete d or its panels, per the matrix's Editor "(own/
|
||||
// granted)" qualifier: Admin/Owner may act on any dashboard in their
|
||||
// tenant (RequireRole already gated the RoleEditor floor); a plain
|
||||
// Editor may act only on a dashboard they created, or one where a
|
||||
// dashboard_permissions grant raises their effective role to at least
|
||||
// Editor. A nil authorizer is the same no-op RequireRole already treats
|
||||
// specially -- a single-tenant deployment has no identity to check
|
||||
// ownership against, so this must not start rejecting Phase 0-3
|
||||
// requests that never carried one. A nil permissions store (RBAC
|
||||
// enforced, but no enterprise permission service wired -- e.g. plain
|
||||
// api/cmd/api with ENTERPRISE_AUTH_URL set) still enforces own/Admin,
|
||||
// just without the "granted" bonus.
|
||||
func (h *Handler) canEditDashboard(ctx context.Context, d *Dashboard) bool {
|
||||
if h.authorizer == nil {
|
||||
return true
|
||||
}
|
||||
identity, ok := authz.IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if identity.Role.Satisfies(authz.RoleAdmin) {
|
||||
return true
|
||||
}
|
||||
if identity.UserID != "" && identity.UserID == d.CreatedBy {
|
||||
return true
|
||||
}
|
||||
if h.permissions == nil {
|
||||
return false
|
||||
}
|
||||
role, granted, err := h.permissions.GrantedRole(ctx, d.ID, identity.UserID)
|
||||
return err == nil && granted && role.Satisfies(authz.RoleEditor)
|
||||
}
|
||||
|
||||
// canManageGrants is deliberately stricter than canEditDashboard: the
|
||||
// matrix lists "manage a dashboard's per-user grants" as available to
|
||||
// the creator or Admin/Owner only ("if creator", not "own/granted") --
|
||||
// a user who themselves only has access *via* a grant must not be able
|
||||
// to extend that access to others or re-grant themselves a persistent
|
||||
// one, which a grant-based bypass here would allow.
|
||||
func (h *Handler) canManageGrants(ctx context.Context, d *Dashboard) bool {
|
||||
if h.authorizer == nil {
|
||||
return true
|
||||
}
|
||||
identity, ok := authz.IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return identity.Role.Satisfies(authz.RoleAdmin) || (identity.UserID != "" && identity.UserID == d.CreatedBy)
|
||||
}
|
||||
|
||||
const maxBodyBytes = 1 << 20 // 1 MiB, same cap as queryapi
|
||||
@@ -80,6 +131,18 @@ func (h *Handler) tenantID(r *http.Request) string {
|
||||
return "default"
|
||||
}
|
||||
|
||||
// createdBy resolves the authenticated identity's UserID to stamp onto a
|
||||
// newly-created dashboard, exactly like tenantID resolves TenantID --
|
||||
// empty (falling back to store.go's "anonymous" default) when no
|
||||
// identity is present, never from client-supplied JSON. This is what
|
||||
// canEditDashboard's ownership check compares against later.
|
||||
func (h *Handler) createdBy(r *http.Request) string {
|
||||
if id, ok := authz.IdentityFromContext(r.Context()); ok {
|
||||
return id.UserID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var d Dashboard
|
||||
if !decodeJSON(w, r, &d) {
|
||||
@@ -89,8 +152,11 @@ func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "name must not be empty")
|
||||
return
|
||||
}
|
||||
// Overrides any client-supplied tenant_id -- see tenantID's doc comment.
|
||||
// Overrides any client-supplied tenant_id/created_by -- see
|
||||
// tenantID's doc comment; same reasoning applies to created_by,
|
||||
// which canEditDashboard later trusts for the ownership check.
|
||||
d.TenantID = h.tenantID(r)
|
||||
d.CreatedBy = h.createdBy(r)
|
||||
if err := h.store.CreateDashboard(r.Context(), &d); err != nil {
|
||||
h.logger.Error("creating dashboard", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "creating dashboard failed")
|
||||
@@ -135,6 +201,15 @@ func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "name must not be empty")
|
||||
return
|
||||
}
|
||||
// Overrides any created_by the exported JSON carries -- store.go's
|
||||
// ImportDashboard trusts d.CreatedBy verbatim (unlike TenantID,
|
||||
// which it always overrides from the separate tenantID argument),
|
||||
// so without this an imported dashboard would keep its *original*
|
||||
// creator's ID, leaving the actual importer unable to edit their own
|
||||
// freshly-imported copy once canEditDashboard's ownership check
|
||||
// applies -- the same reasoning TestImportIgnoresExportedTenantID
|
||||
// already established for tenant_id extends to created_by.
|
||||
d.CreatedBy = h.createdBy(r)
|
||||
imported, err := h.store.ImportDashboard(r.Context(), h.tenantID(r), &d)
|
||||
if err != nil {
|
||||
h.logger.Error("importing dashboard", "error", err)
|
||||
@@ -154,6 +229,15 @@ func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
d.ID = r.PathValue("id")
|
||||
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), d.ID)
|
||||
if err != nil {
|
||||
h.writeStoreErr(w, err, "fetching dashboard")
|
||||
return
|
||||
}
|
||||
if !h.canEditDashboard(r.Context(), existing) {
|
||||
writeError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdateDashboard(r.Context(), h.tenantID(r), &d); err != nil {
|
||||
h.writeStoreErr(w, err, "updating dashboard")
|
||||
return
|
||||
@@ -162,7 +246,17 @@ func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.store.DeleteDashboard(r.Context(), h.tenantID(r), r.PathValue("id")); err != nil {
|
||||
id := r.PathValue("id")
|
||||
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), id)
|
||||
if err != nil {
|
||||
h.writeStoreErr(w, err, "fetching dashboard")
|
||||
return
|
||||
}
|
||||
if !h.canEditDashboard(r.Context(), existing) {
|
||||
writeError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteDashboard(r.Context(), h.tenantID(r), id); err != nil {
|
||||
h.writeStoreErr(w, err, "deleting dashboard")
|
||||
return
|
||||
}
|
||||
@@ -178,7 +272,17 @@ func (h *Handler) handleAddPanel(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.store.AddPanel(r.Context(), h.tenantID(r), r.PathValue("id"), &p); err != nil {
|
||||
dashboardID := r.PathValue("id")
|
||||
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), dashboardID)
|
||||
if err != nil {
|
||||
h.writeStoreErr(w, err, "fetching dashboard")
|
||||
return
|
||||
}
|
||||
if !h.canEditDashboard(r.Context(), existing) {
|
||||
writeError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
if err := h.store.AddPanel(r.Context(), h.tenantID(r), dashboardID, &p); err != nil {
|
||||
h.writeStoreErr(w, err, "adding panel")
|
||||
return
|
||||
}
|
||||
@@ -196,6 +300,15 @@ func (h *Handler) handleUpdatePanel(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
p.ID = r.PathValue("panelId")
|
||||
p.DashboardID = r.PathValue("id")
|
||||
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), p.DashboardID)
|
||||
if err != nil {
|
||||
h.writeStoreErr(w, err, "fetching dashboard")
|
||||
return
|
||||
}
|
||||
if !h.canEditDashboard(r.Context(), existing) {
|
||||
writeError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdatePanel(r.Context(), h.tenantID(r), &p); err != nil {
|
||||
h.writeStoreErr(w, err, "updating panel")
|
||||
return
|
||||
@@ -204,13 +317,104 @@ func (h *Handler) handleUpdatePanel(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeletePanel(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.store.DeletePanel(r.Context(), h.tenantID(r), r.PathValue("id"), r.PathValue("panelId")); err != nil {
|
||||
dashboardID := r.PathValue("id")
|
||||
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), dashboardID)
|
||||
if err != nil {
|
||||
h.writeStoreErr(w, err, "fetching dashboard")
|
||||
return
|
||||
}
|
||||
if !h.canEditDashboard(r.Context(), existing) {
|
||||
writeError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
if err := h.store.DeletePanel(r.Context(), h.tenantID(r), dashboardID, r.PathValue("panelId")); err != nil {
|
||||
h.writeStoreErr(w, err, "deleting panel")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) handleListPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
h.writeStoreErr(w, err, "fetching dashboard")
|
||||
return
|
||||
}
|
||||
if !h.canManageGrants(r.Context(), existing) {
|
||||
writeError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
if h.permissions == nil {
|
||||
writeError(w, http.StatusNotImplemented, "dashboard permission grants are not available on this deployment")
|
||||
return
|
||||
}
|
||||
perms, err := h.permissions.ListPermissions(r.Context(), existing.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("listing dashboard permissions", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "listing permissions failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, perms)
|
||||
}
|
||||
|
||||
type setPermissionRequest struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleSetPermission(w http.ResponseWriter, r *http.Request) {
|
||||
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
h.writeStoreErr(w, err, "fetching dashboard")
|
||||
return
|
||||
}
|
||||
if !h.canManageGrants(r.Context(), existing) {
|
||||
writeError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
if h.permissions == nil {
|
||||
writeError(w, http.StatusNotImplemented, "dashboard permission grants are not available on this deployment")
|
||||
return
|
||||
}
|
||||
var body setPermissionRequest
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
role := authz.Role(body.Role)
|
||||
if !validGrantRole(role) {
|
||||
writeError(w, http.StatusBadRequest, "role must be \"viewer\" or \"editor\"")
|
||||
return
|
||||
}
|
||||
identity, _ := authz.IdentityFromContext(r.Context())
|
||||
if err := h.permissions.SetPermission(r.Context(), existing.ID, r.PathValue("userId"), role, identity.UserID); err != nil {
|
||||
h.logger.Error("setting dashboard permission", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "granting permission failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRevokePermission(w http.ResponseWriter, r *http.Request) {
|
||||
existing, err := h.store.GetDashboard(r.Context(), h.tenantID(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
h.writeStoreErr(w, err, "fetching dashboard")
|
||||
return
|
||||
}
|
||||
if !h.canManageGrants(r.Context(), existing) {
|
||||
writeError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
if h.permissions == nil {
|
||||
writeError(w, http.StatusNotImplemented, "dashboard permission grants are not available on this deployment")
|
||||
return
|
||||
}
|
||||
if err := h.permissions.RevokePermission(r.Context(), existing.ID, r.PathValue("userId")); err != nil {
|
||||
h.logger.Error("revoking dashboard permission", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "revoking permission failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
|
||||
@@ -128,7 +128,7 @@ func (f *fakeStore) ImportDashboard(_ context.Context, tenantID string, d *Dashb
|
||||
}
|
||||
|
||||
func newTestMux(fs *fakeStore) *http.ServeMux {
|
||||
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs, nil)
|
||||
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs, nil, nil)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
return mux
|
||||
@@ -146,9 +146,61 @@ func (f *fakeAuthorizer) Authorize(*http.Request) (authz.Identity, error) {
|
||||
return f.identity, nil
|
||||
}
|
||||
|
||||
// fakePermissionStore is an in-memory PermissionStore, keyed by
|
||||
// dashboardID -> userID -> grant, enough to drive canEditDashboard/
|
||||
// canManageGrants's "granted" path and the permission-management
|
||||
// handlers without a real Postgres.
|
||||
type fakePermissionStore struct {
|
||||
grants map[string]map[string]Permission
|
||||
}
|
||||
|
||||
func newFakePermissionStore() *fakePermissionStore {
|
||||
return &fakePermissionStore{grants: map[string]map[string]Permission{}}
|
||||
}
|
||||
|
||||
func (f *fakePermissionStore) GrantedRole(_ context.Context, dashboardID, userID string) (authz.Role, bool, error) {
|
||||
p, ok := f.grants[dashboardID][userID]
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
return p.Role, true, nil
|
||||
}
|
||||
|
||||
func (f *fakePermissionStore) SetPermission(_ context.Context, dashboardID, userID string, role authz.Role, grantedBy string) error {
|
||||
if f.grants[dashboardID] == nil {
|
||||
f.grants[dashboardID] = map[string]Permission{}
|
||||
}
|
||||
f.grants[dashboardID][userID] = Permission{UserID: userID, Role: role, GrantedBy: grantedBy}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePermissionStore) RevokePermission(_ context.Context, dashboardID, userID string) error {
|
||||
delete(f.grants[dashboardID], userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePermissionStore) ListPermissions(_ context.Context, dashboardID string) ([]Permission, error) {
|
||||
var out []Permission
|
||||
for _, p := range f.grants[dashboardID] {
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newTestMuxWithTenant(fs *fakeStore, tenantID string) *http.ServeMux {
|
||||
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs,
|
||||
&fakeAuthorizer{identity: authz.Identity{TenantID: tenantID, Role: authz.RoleOwner}})
|
||||
&fakeAuthorizer{identity: authz.Identity{TenantID: tenantID, Role: authz.RoleOwner}}, nil)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
return mux
|
||||
}
|
||||
|
||||
// newTestMuxWithIdentity is newTestMuxWithTenant's more general sibling
|
||||
// -- lets ownership/grant tests control Role and UserID directly (Owner
|
||||
// always bypasses canEditDashboard/canManageGrants, so those tests can't
|
||||
// use newTestMuxWithTenant), and optionally wires a PermissionStore.
|
||||
func newTestMuxWithIdentity(fs *fakeStore, identity authz.Identity, permissions PermissionStore) *http.ServeMux {
|
||||
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs, &fakeAuthorizer{identity: identity}, permissions)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
return mux
|
||||
@@ -444,7 +496,7 @@ func TestServiceIdentityCannotAccessDashboards(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"}
|
||||
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs,
|
||||
&fakeAuthorizer{identity: authz.Identity{Role: authz.RoleService}})
|
||||
&fakeAuthorizer{identity: authz.Identity{Role: authz.RoleService}}, nil)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
@@ -462,3 +514,199 @@ func TestServiceIdentityCannotAccessDashboards(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- "(own/granted)" qualifier tests (Phase 4 task 5) ---
|
||||
|
||||
func TestEditorCannotEditOthersDashboardWithoutGrant(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-owner"}
|
||||
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-editor", Role: authz.RoleEditor}, newFakePermissionStore())
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1", `{"name": "Hijacked"}`)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fs.dashboards["dash-1"].Name != "Overview" {
|
||||
t.Fatalf("must not modify the dashboard, got Name = %q", fs.dashboards["dash-1"].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorCanEditOwnDashboard(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-editor"}
|
||||
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-editor", Role: authz.RoleEditor}, newFakePermissionStore())
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1", `{"name": "Updated"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorCanEditDashboardGrantedEditorRole(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-owner"}
|
||||
perms := newFakePermissionStore()
|
||||
if err := perms.SetPermission(context.Background(), "dash-1", "user-editor", authz.RoleEditor, "user-owner"); err != nil {
|
||||
t.Fatalf("seeding grant: %v", err)
|
||||
}
|
||||
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-editor", Role: authz.RoleEditor}, perms)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1", `{"name": "Updated"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (a granted Editor role must allow editing someone else's dashboard); body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorCannotEditDashboardGrantedViewerRoleOnly(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-owner"}
|
||||
perms := newFakePermissionStore()
|
||||
if err := perms.SetPermission(context.Background(), "dash-1", "user-editor", authz.RoleViewer, "user-owner"); err != nil {
|
||||
t.Fatalf("seeding grant: %v", err)
|
||||
}
|
||||
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-editor", Role: authz.RoleEditor}, perms)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1", `{"name": "Updated"}`)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 (a Viewer-level grant must not allow editing -- additive-only, not an escalation)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCanEditAnyDashboardWithoutGrant(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-owner"}
|
||||
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-admin", Role: authz.RoleAdmin}, newFakePermissionStore())
|
||||
|
||||
rec := doRequest(t, mux, http.MethodDelete, "/dashboards/dash-1", "")
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204 (Admin may act on any dashboard in its tenant); body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorCannotEditPanelsOfOthersDashboardWithoutGrant(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-owner"}
|
||||
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-editor", Role: authz.RoleEditor}, newFakePermissionStore())
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels", `{"query": "service=api", "viz_type": "table"}`)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(fs.dashboards["dash-1"].Panels) != 0 {
|
||||
t.Fatalf("must not attach a panel without edit access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoPermissionStoreFallsBackToOwnershipOnly(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-owner"}
|
||||
// authorizer is real (RBAC enforced) but permissions is nil -- e.g.
|
||||
// plain api/cmd/api with ENTERPRISE_AUTH_URL set but no enterprise
|
||||
// permission service wired. Own/Admin must still work; "granted"
|
||||
// simply isn't checkable.
|
||||
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-editor", Role: authz.RoleEditor}, nil)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1", `{"name": "Updated"}`)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 (no permission store means no 'granted' bonus, and this user isn't the creator)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- grant management endpoint tests ---
|
||||
|
||||
func TestCreatorCanManageGrants(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-creator"}
|
||||
perms := newFakePermissionStore()
|
||||
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-creator", Role: authz.RoleEditor}, perms)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1/permissions/user-2", `{"role": "editor"}`)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
role, ok, err := perms.GrantedRole(context.Background(), "dash-1", "user-2")
|
||||
if err != nil || !ok || role != authz.RoleEditor {
|
||||
t.Fatalf("GrantedRole = (%v, %v, %v), want (editor, true, nil)", role, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantedEditorCannotManageGrants is the regression test for the
|
||||
// matrix's distinction between "(own/granted)" (edit/delete content) and
|
||||
// "(if creator)" (manage grants) -- a user who can edit only because of
|
||||
// a grant must not be able to extend or re-grant that access to anyone,
|
||||
// including themselves.
|
||||
func TestGrantedEditorCannotManageGrants(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-owner"}
|
||||
perms := newFakePermissionStore()
|
||||
if err := perms.SetPermission(context.Background(), "dash-1", "user-editor", authz.RoleEditor, "user-owner"); err != nil {
|
||||
t.Fatalf("seeding grant: %v", err)
|
||||
}
|
||||
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-editor", Role: authz.RoleEditor}, perms)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1/permissions/user-3", `{"role": "editor"}`)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 (a granted Editor must not be able to grant access to others)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPermissionRejectsInvalidRole(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-creator"}
|
||||
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-creator", Role: authz.RoleEditor}, newFakePermissionStore())
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1/permissions/user-2", `{"role": "admin"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (admin is not a grantable resource-level role -- see metadata/migrations/0033)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokePermission(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-creator"}
|
||||
perms := newFakePermissionStore()
|
||||
if err := perms.SetPermission(context.Background(), "dash-1", "user-2", authz.RoleEditor, "user-creator"); err != nil {
|
||||
t.Fatalf("seeding grant: %v", err)
|
||||
}
|
||||
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-creator", Role: authz.RoleEditor}, perms)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodDelete, "/dashboards/dash-1/permissions/user-2", "")
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok, _ := perms.GrantedRole(context.Background(), "dash-1", "user-2"); ok {
|
||||
t.Fatalf("expected the grant to be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionEndpointsReturn501WithoutPermissionStore(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-creator"}
|
||||
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-creator", Role: authz.RoleEditor}, nil)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1/permissions/user-2", `{"role": "editor"}`)
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("status = %d, want 501 (no enterprise permission service wired)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportStampsImportingUserAsCreator is the regression test for the
|
||||
// created_by-on-import fix: without it, an imported dashboard would keep
|
||||
// the *exported* JSON's created_by, leaving the actual importer unable
|
||||
// to edit their own freshly-imported copy.
|
||||
func TestImportStampsImportingUserAsCreator(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-importer", Role: authz.RoleEditor}, newFakePermissionStore())
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/import", `{"name": "Imported", "created_by": "someone-else"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var imported Dashboard
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &imported); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if imported.CreatedBy != "user-importer" {
|
||||
t.Fatalf("CreatedBy = %q, want %q (the importing identity, not the exported JSON's value)", imported.CreatedBy, "user-importer")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package dashboards
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/sentry/sentry/api/authz"
|
||||
)
|
||||
|
||||
// Permission is one dashboard_permissions row -- see
|
||||
// /docs/phase-4-rbac-design.md's "additive-only per-resource grants"
|
||||
// section. Role is always RoleViewer or RoleEditor: Admin/Owner already
|
||||
// have tenant-wide access to every dashboard, so a resource-level grant
|
||||
// only ever needs to raise someone as high as Editor on one dashboard
|
||||
// (metadata/migrations/0033_restrict_dashboard_permissions_role.sql).
|
||||
type Permission struct {
|
||||
UserID string
|
||||
Role authz.Role
|
||||
GrantedBy string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// PermissionStore resolves and manages per-resource dashboard grants --
|
||||
// the RBAC matrix's "(own/granted)" qualifier that a baseline tenant
|
||||
// role alone can't answer (see RegisterRoutes' doc comment). nil is a
|
||||
// deliberate no-op, the same shape as a nil authz.Authorizer: a
|
||||
// single-tenant deployment, or one running plain api/cmd/api with RBAC
|
||||
// enforcement on but no enterprise permission service wired, simply
|
||||
// doesn't get the "granted" half of "(own/granted)" -- ownership and
|
||||
// Admin/Owner access still work (see canEditDashboard), only grants
|
||||
// beyond that are unavailable. The real implementation
|
||||
// (enterprise/internal/rbacstore.DashboardPermissions) is enterprise
|
||||
// code, wired in only by enterprise/cmd/enterprise-api -- core never
|
||||
// imports it, per the module boundary.
|
||||
type PermissionStore interface {
|
||||
// GrantedRole returns the role a specific user has been granted on a
|
||||
// specific dashboard, ok=false if no grant exists.
|
||||
GrantedRole(ctx context.Context, dashboardID, userID string) (role authz.Role, ok bool, err error)
|
||||
// SetPermission creates or updates a grant. grantedBy is the
|
||||
// authenticated identity performing the grant -- always recorded,
|
||||
// never empty, so every grant is attributable (see the migration
|
||||
// above's NOT NULL fix).
|
||||
SetPermission(ctx context.Context, dashboardID, userID string, role authz.Role, grantedBy string) error
|
||||
RevokePermission(ctx context.Context, dashboardID, userID string) error
|
||||
ListPermissions(ctx context.Context, dashboardID string) ([]Permission, error)
|
||||
}
|
||||
|
||||
// validGrantRole reports whether role is one a dashboard_permissions row
|
||||
// may actually hold -- see Permission's doc comment for why Admin/Owner
|
||||
// are excluded.
|
||||
func validGrantRole(role authz.Role) bool {
|
||||
return role == authz.RoleViewer || role == authz.RoleEditor
|
||||
}
|
||||
Reference in New Issue
Block a user