Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion api/config/swagger_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
18 changes: 3 additions & 15 deletions ui/src/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
37 changes: 37 additions & 0 deletions ui/src/services/roles.js
Original file line number Diff line number Diff line change
@@ -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')
);
});
};
51 changes: 51 additions & 0 deletions ui/src/services/roles.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading