Skip to content

Gate ServicePulse UI on user permissions#3041

Open
dvdstelt wants to merge 9 commits into
show-hide-tabsfrom
authz-resource-scopes
Open

Gate ServicePulse UI on user permissions#3041
dvdstelt wants to merge 9 commits into
show-hide-tabsfrom
authz-resource-scopes

Conversation

@dvdstelt

@dvdstelt dvdstelt commented Jun 24, 2026

Copy link
Copy Markdown
Member

Why this PR builds on #3035, and what changes

#3035 (with the follow-up fixes in #3036) established this feature: the idea of hiding UI a user cannot act on, the permission plumbing, the User Permissions page, and a working end-to-end flow. This PR stands on that foundation. What it changes is the underlying model used to decide what to show, and we think that change is worth merging. None of this is a knock on the original approach; it is the natural next iteration now that the per-permission endpoint exists.

The core difference

#3035 (7-bool summary) #3041 (permission vocabulary)
What the server sends A coarse summary of 7 booleans (failed_messages_read/write, auditing_read, monitoring_read/write, admin_read/write) The user's actual effective permission strings (error:messages:retry, error:heartbeats:view, …)
How the UI decides Maps each feature onto one of the 7 buckets Checks the exact permission the server enforces
Translation layers Two (server collapses N perms into 7 bools; UI re-maps M features onto those 7 bools) None (the UI asks the same question the server answers)

In short, #3035 introduces an intermediate, coarser vocabulary and asks the frontend to map every feature back onto it. This PR removes that middle step so the UI mirrors the server's own permission decision directly.

What this buys us

  1. It removes a class of mapping mistakes. With two independent mappings and no shared source of truth, the two can drift. That already happened once and was caught in review: Throughput was initially gated on failed_messages_read and Review fixes for user authorization tab hiding (#3035) #3036 corrected it to admin_read. It is simply an easy thing to get subtly wrong. In this PR there is no bucket to assign, Throughput gates on error:throughput:view (the exact permission the server checks), so the mapping is mechanical.

  2. It is fine-grained where the summary is coarse. Hide tabs based on user authorization levels #3035's single failed_messages_write covers retry, edit, archive, unarchive and delete together. This PR gates each action on its own permission (error:messages:retry, :edit, :archive, :unarchive), so a user can be granted retry without edit. Configuration tabs likewise gate on the specific permission each one needs instead of a single admin_read.

  3. It matches the server's real authorization model. We checked against ServiceControl's Permissions.cs and RolePermissions (reader = *:*:view, writer = *:*:*): the strings in this PR checks are exactly the ones the server grants and enforces, rather than a separate set of buckets.

  4. Fewer moving parts, while doing more. This PR removes the summary endpoint usage, usePermissionGate, UserPermissionsStore, the supportsUserPermissions capability flag and the mypermissions_*_url plumbing, and still offers finer control.

  5. It leaves a clean path to per-resource scopes. The same vocabulary extends to per-queue checks, can("error:messages:retry", queueName), with no structural change. This PR includes the scope matcher in a dormant state plus a shared scope-vectors.json fixture, so turning on per-queue authorization later is additive. The 7-boolean summary has no way to express per-queue scoping.

  6. Clearer runtime behavior. Gate-on-ready avoids items flashing in and then disappearing, the UI fails open when auth is disabled or the descriptor cannot be loaded (so non-OIDC deployments are unaffected), and there is broader test coverage (matcher, composable, store and button gating).

Additionally

  1. Disabled-with-tooltip actions on the appropriate screens
    Where Hide tabs based on user authorization levels #3035 only hid tabs, the retry/delete/restore actions now
    stay visible but become disabled, with a tooltip explaining why, when you lack
    the permission. This keeps the capability discoverable and stops clicks from
    silently failing server-side.

    A shared PermissionGate component centralizes this (tooltip on a wrapper so
    it shows even over a disabled button, plus the disabled styling).

  2. Redesigned User Permissions page. Instead of the flat boolean matrix, it
    now groups your permissions by instance (Error / Audit / Monitoring) and by
    feature (Messages, Heartbeats, Custom checks, ...), with a checkmark per
    granted action in an aligned, compact, left-aligned matrix. Features and
    instances you have no access to are omitted, and hovering a feature shows the
    exact permission strings you hold for it.

  3. A small robustness fix surfaced along the way: the throughput auto-refresh
    now handles errors instead of throwing an unhandled promise rejection (e.g.
    when the token is cleared on logout).

  4. Dormant groundwork for per-queue scopes (the scope matcher plus a shared
    scope-vectors.json test fixture), so per-resource authorization can be added
    later without restructuring.

Trade-offs

  • It couples the frontend to the server's permission names: a rename on the server has to be reflected here. The 7-boolean summary was a smaller, more stable contract. The names mirror the catalog one to one and are easy to verify, and the shared scope-vectors fixture is the pattern we will use to keep both sides in step as scopes land.
  • It depends on the flat my/permissions/all endpoint (#5538). Against an older ServiceControl it returns 404 and the UI fails open.
  • It is a deliberate behavior change in one place: a reader now sees the view-gated configuration tabs (License, Connections, and similar) that Hide tabs based on user authorization levels #3035 hid behind admin_read. This is closer to what the server actually allows (a reader genuinely has :view), but it is worth being aware of.

Bottom line

#3035 proved the feature and got it working end to end. This PR keeps that and changes the model underneath so the frontend asks the server the same authorization question the server already answers. That removes the hand-maintained translation, makes per-action control possible, keeps the UI aligned with what the server enforces, and leaves an additive path to per-queue scoping. Happy to walk through any part of it together and adjust based on feedback.

dvdstelt added 7 commits June 23, 2026 12:01
Gate UI on the current user's permissions, fetched from ServiceControl's
GET api/my/permissions/all (a flat list of permission strings such as
"error:messages:retry"; see ServiceControl PR #5538).

- PermissionsStore: holds the user and a Set<string> of granted permissions.
- usePermissions: can(permission)/canAny(permissions) (set membership) plus
  fetchDescriptor() (deduped, fail-safe). Gating is fail-open — it only applies
  once authorization is enabled, the user is authenticated and the descriptor
  has loaded, so the UI is unchanged for auth-disabled/older deployments.

No UI consumes this yet.
Not wired into the UI. ServiceControl does not yet support per-resource
(per-queue) scopes — limiting a permission to queues like "sales.*" — so this is
kept ready for when it does, at which point can(permission, resource) can enforce
a grant's allow/deny patterns.

- permissionMatching: pure matchesPattern/entryPermits (exact, prefix.*, *,
  deny-wins, case-insensitive), self-contained.
- scope-vectors.json: shared test vectors so the frontend matcher and the future
  ServiceControl ResourceScope/FilterByQueueScope cannot drift; both suites run
  the same cases.
refresh() runs from a watch/auto-refresh, so a rejected throughput connection
test (ServiceControl unreachable, or the auth token cleared during logout)
surfaced as an unhandled promise rejection. Catch and log instead.
PageHeader now shows each nav item only when the user holds the matching
ServiceControl permission (e.g. Heartbeats -> error:heartbeats:view, Throughput
-> error:throughput:view, Monitoring -> monitoring:endpoint:view, Audit ->
audit:message:view), replacing the coarse 7-bool usePermissionGate.

- App.vue loads my/permissions/all once authenticated.
- usePermissions exposes ready (gate-on-ready): the nav renders nothing until
  permissions are known, so items never appear and then disappear. Fail-open on
  auth-disabled / failed load (loadAttempted settles, can() permits).
- Move the descriptor fetch + in-flight dedupe into PermissionsStore so each
  Pinia instance owns it; a module-level singleton leaked a stale request across
  app re-mounts, leaving ready stuck false.
- Tests: hasUserPermissions precondition (mocks my/permissions/all, granted by
  default when auth is enabled); ready unit tests.

ConfigurationView still uses the old summary path until the config-tab step (F5).
Each failed-message action button now shows only when the user holds the matching
ServiceControl permission, in addition to its existing visibility conditions:

- Retry        -> error:messages:retry
- Edit & retry -> error:messages:edit
- Delete       -> error:messages:archive
- Restore      -> error:messages:unarchive

Fail-open via can() (unchanged for auth-disabled deployments). Verb-level only;
the per-queue resource argument is deferred until ServiceControl supports scopes.
Adds messageButtonPermissions.spec.ts covering shown-when-granted/hidden-when-denied.
ConfigurationView tabs now gate on the specific ServiceControl permission each
needs, via usePermissions (gate-on-ready, consistent with the nav):

- License        -> error:licensing:view
- Usage Setup    -> error:throughput:manage
- MassTransit    -> error:connections:view
- Health Checks  -> error:notifications:view
- Retry Redirects-> error:redirects:view
- Connections    -> error:connections:view
- Endpoint Conn. -> error:endpoints:view
- User Permissions tab -> shown when auth is enabled

Connections / Endpoint Connection were previously ungated; they now require the
permission the server actually enforces.

The User Permissions page is reworked to render the new flat descriptor (user +
sorted list of granted permission strings) instead of the retired 7-bool matrix.
Nothing reads the old summary path anymore (PageHeader, ConfigurationView and the
User Permissions page now use usePermissions / PermissionsStore), so delete it:

- usePermissionGate composable (+ spec)
- UserPermissionsStore (+ spec)
- App.vue: drop the old summary-fetch watch and its supportsUserPermissions gate,
  leaving only the my/permissions/all load
- EnvironmentAndVersionsStore: drop the now-unused supportsUserPermissions,
  mypermissions_all_url and mypermissions_summary_url fields
dvdstelt added 2 commits June 24, 2026 16:42
Replace the flat list of permission strings with a table per instance (Error/
Audit/Monitoring): features as rows, actions as columns, a checkmark where the
action is granted. All tables share the same column set, ordered most-widely-
shared first (View, Delete, then the rest) so columns line up across instances; a
column shows its header and checks only for instances that use it. Feature names
are left-aligned and action columns have a fixed width, so the table shrinks to
fit and stays left-aligned (a view-only user sees just Feature | View). Features
and instances with no granted permission are omitted.
Across the failed-message screens, retry/delete/restore actions stay visible but
are disabled with a tooltip explaining why when the user lacks the permission,
instead of silently failing server-side:
- Groups list: Request retry / Delete group -> error:recoverabilitygroups:retry / :archive
- All failed messages toolbar: Retry/Delete selected -> error:messages:retry / :archive;
  Retry all / Delete all -> error:recoverabilitygroups:retry / :archive
- Deleted message groups: Restore group -> error:recoverabilitygroups:unarchive
- Deleted messages toolbar: Restore selected -> error:messages:unarchive

A shared PermissionGate wrapper centralises the behaviour: it shows the tooltip
(via a span so it appears even over a disabled button), lets pointer events reach
the wrapper, and tidies the disabled look of link-style buttons. Toolbar spacing
for the wrapper is handled by the existing .btn-toolbar rule in main.css.

Adds PermissionGate (+ spec) and messageGroupButtonPermissions.spec.ts.
@dvdstelt dvdstelt requested a review from ramonsmits June 24, 2026 18:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant