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).
97 lines
3.5 KiB
Go
97 lines
3.5 KiB
Go
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()
|
|
}
|