From 4eefdca90a7878337e9911ab8b61f06158bb8275 Mon Sep 17 00:00:00 2001 From: Raul Bardaji Date: Sat, 25 Jul 2026 01:04:31 -0600 Subject: [PATCH] fix(ui): show management areas to endpoint administrators The admin dashboard and access-request review page were gated on a client-side check that recognized only ndp_admin or role names ending in _admin. The role an endpoint administrator actually holds is the canonical group:{uuid}:admin, which ends in :admin, so endpoint admins were locked out of their own endpoint's management screens even though the API treats them as administrators. The backend already reports effective_role, resolving every admin role form including the endpoint-scoped one. The UI now trusts that value instead of re-deriving admin status from role strings. The helper moved to services/roles.js so it can be unit tested without importing the axios client, and the string fallback (for an older backend) now also recognizes the :admin form. Closes #202 --- CHANGELOG.md | 8 ++++++ api/config/swagger_settings.py | 2 +- ui/src/services/api.js | 18 ++---------- ui/src/services/roles.js | 37 ++++++++++++++++++++++++ ui/src/services/roles.test.js | 51 ++++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 ui/src/services/roles.js create mode 100644 ui/src/services/roles.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index fa2398a..b749a82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.33.2] - 2026-07-25 + +### Fixed +- **Endpoint administrators can now reach the UI management areas.** The admin dashboard and the access-request review page were shown only to holders of the platform-wide `ndp_admin` role. A user who administers *this* endpoint holds `group:{endpoint-uuid}:admin`, which the UI's client-side check missed — it matched only `ndp_admin` or names ending in `_admin`, and the canonical role ends in `:admin`. The UI now trusts the backend's `effective_role` (which already resolves every admin role form) to decide admin status, so endpoint administrators see the areas their role grants. The admin helper moved to `ui/src/services/roles.js` and is unit-tested. + +### Backwards compatibility +- UI only. No API behavior, routes, or request/response shapes change. The backend already reported `effective_role`; the fallback that matches role strings is kept for older backends and now also recognizes the `:admin` form. + ## [0.33.1] - 2026-07-25 ### Fixed diff --git a/api/config/swagger_settings.py b/api/config/swagger_settings.py index d7f1ce9..cb44c8c 100644 --- a/api/config/swagger_settings.py +++ b/api/config/swagger_settings.py @@ -12,7 +12,7 @@ class Settings(BaseSettings): swagger_title: str = "API Documentation" swagger_description: str = "This is the API documentation." - swagger_version: str = "0.33.1" + swagger_version: str = "0.33.2" root_path: str = "" # API root path prefix (e.g., "/test" or "") is_public: bool = True metrics_endpoint: str = "https://federation.ndp.utah.edu/metrics/" diff --git a/ui/src/services/api.js b/ui/src/services/api.js index d703243..c7fd0f3 100644 --- a/ui/src/services/api.js +++ b/ui/src/services/api.js @@ -425,21 +425,9 @@ export const accessRequestsAPI = { }), }; -/** - * Return true if the given user_info payload grants admin access to the - * access-request management page. Admins are: holders of the `ndp_admin` - * realm role OR holders of any role ending in `_admin` (which covers the - * endpoint-scoped `{UUID}_admin` role). - */ -export const isAccessRequestAdmin = (userInfo) => { - const roles = userInfo?.roles; - if (!Array.isArray(roles)) return false; - return roles.some((role) => { - if (typeof role !== 'string') return false; - const lower = role.trim().toLowerCase(); - return lower === 'ndp_admin' || lower.endsWith('_admin'); - }); -}; +// Admin detection lives in ./roles so it can be unit tested without importing +// the axios client. Re-exported here to keep the existing import path working. +export { isAccessRequestAdmin } from './roles'; // Authentication API - Enhanced with proper user info validation export const authAPI = { diff --git a/ui/src/services/roles.js b/ui/src/services/roles.js new file mode 100644 index 0000000..d18ae37 --- /dev/null +++ b/ui/src/services/roles.js @@ -0,0 +1,37 @@ +/** + * Role helpers, kept free of any network dependency so they can be unit + * tested without pulling in the axios client. + */ + +/** + * Return true if the given `/user/info` payload grants admin access to the + * management areas (admin dashboard, access-request review). + * + * The authority on this is the backend, which reports `effective_role` after + * resolving every admin role form — including the endpoint-scoped + * `group:{UUID}:admin` — so we trust that value. Re-deriving admin status from + * role strings here is what caused endpoint admins to be locked out: the old + * check matched only `ndp_admin` or names ending in `_admin`, missing the + * canonical `group:{UUID}:admin` (which ends in `:admin`). + * + * The role-string fallback is kept only for an older backend that does not + * send `effective_role`, and it now also recognizes the `:admin` form. + */ +export const isAccessRequestAdmin = (userInfo) => { + const effective = userInfo?.effective_role; + if (typeof effective === 'string') { + return effective.trim().toLowerCase() === 'admin'; + } + + const roles = userInfo?.roles; + if (!Array.isArray(roles)) return false; + return roles.some((role) => { + if (typeof role !== 'string') return false; + const lower = role.trim().toLowerCase(); + return ( + lower === 'ndp_admin' || + lower.endsWith('_admin') || + lower.endsWith(':admin') + ); + }); +}; diff --git a/ui/src/services/roles.test.js b/ui/src/services/roles.test.js new file mode 100644 index 0000000..cc57b64 --- /dev/null +++ b/ui/src/services/roles.test.js @@ -0,0 +1,51 @@ +import { isAccessRequestAdmin } from './roles'; + +describe('isAccessRequestAdmin', () => { + // The bug this guards: an endpoint administrator, whose role is the + // canonical group:{uuid}:admin, was denied the management areas because the + // UI matched only ndp_admin / *_admin. + it('trusts the backend effective_role when present', () => { + expect(isAccessRequestAdmin({ effective_role: 'admin' })).toBe(true); + expect(isAccessRequestAdmin({ effective_role: 'writer' })).toBe(false); + expect(isAccessRequestAdmin({ effective_role: 'viewer' })).toBe(false); + expect(isAccessRequestAdmin({ effective_role: 'none' })).toBe(false); + }); + + it('lets effective_role override the raw roles array', () => { + // A platform admin role is present, but effective_role says the caller is + // not an admin on this endpoint — the authoritative value wins. + expect( + isAccessRequestAdmin({ effective_role: 'viewer', roles: ['ndp_admin'] }) + ).toBe(false); + }); + + // Fallback path, for a backend old enough not to send effective_role. + describe('role-string fallback (no effective_role)', () => { + it('recognizes the platform admin role', () => { + expect(isAccessRequestAdmin({ roles: ['ndp_admin'] })).toBe(true); + }); + + it('recognizes the canonical endpoint admin role', () => { + expect(isAccessRequestAdmin({ roles: ['group:6a4bd301-ab:admin'] })).toBe( + true + ); + }); + + it('recognizes the legacy endpoint admin role', () => { + expect(isAccessRequestAdmin({ roles: ['6a4bd301-ab_admin'] })).toBe(true); + }); + + it('denies a non-admin', () => { + expect( + isAccessRequestAdmin({ roles: ['group:6a4bd301-ab:writer', 'user'] }) + ).toBe(false); + }); + + it('handles missing or malformed input', () => { + expect(isAccessRequestAdmin(undefined)).toBe(false); + expect(isAccessRequestAdmin({})).toBe(false); + expect(isAccessRequestAdmin({ roles: 'ndp_admin' })).toBe(false); + expect(isAccessRequestAdmin({ roles: [null, 42] })).toBe(false); + }); + }); +});