Implemented rbac
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
cancelSessionExpiration,
|
||||
dismissSubordinateModals,
|
||||
} from '../services/sessionExpiration';
|
||||
import { resolveUserRolesAndClaims } from '../services/roleClaimsCache';
|
||||
|
||||
export interface AuthUserProfile {
|
||||
id: string;
|
||||
@@ -495,6 +496,26 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setIsPendingActivation(false);
|
||||
setIsSynced(true);
|
||||
setActivationError(null);
|
||||
|
||||
// Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
|
||||
try {
|
||||
const resolved = await resolveUserRolesAndClaims(userSub, mergedClaims, profile);
|
||||
if (resolved.primaryRole) {
|
||||
profile.role = resolved.primaryRole;
|
||||
}
|
||||
if (resolved.tenant) {
|
||||
profile.tenant = resolved.tenant;
|
||||
setTenantName(resolved.tenant);
|
||||
}
|
||||
if (resolved.tenant_id !== undefined) {
|
||||
profile.tenant_id = resolved.tenant_id;
|
||||
setTenantId(resolved.tenant_id);
|
||||
}
|
||||
setUser({ ...profile });
|
||||
setClaims({ ...resolved.claims });
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
|
||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||
} catch {}
|
||||
}
|
||||
} catch (syncErr: any) {
|
||||
if (syncErr?.response?.status === 401) {
|
||||
@@ -613,6 +634,30 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setIsPendingActivation(false);
|
||||
setIsSynced(true);
|
||||
setActivationError(null);
|
||||
|
||||
// Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7)
|
||||
try {
|
||||
const resolved = await resolveUserRolesAndClaims(
|
||||
storedSub || '',
|
||||
parsedClaims,
|
||||
resolvedUser || {}
|
||||
);
|
||||
if (resolvedUser) {
|
||||
if (resolved.primaryRole) resolvedUser.role = resolved.primaryRole;
|
||||
if (resolved.tenant) {
|
||||
resolvedUser.tenant = resolved.tenant;
|
||||
setTenantName(resolved.tenant);
|
||||
}
|
||||
if (resolved.tenant_id !== undefined) {
|
||||
resolvedUser.tenant_id = resolved.tenant_id;
|
||||
setTenantId(resolved.tenant_id);
|
||||
}
|
||||
setUser({ ...resolvedUser });
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
|
||||
}
|
||||
setClaims({ ...resolved.claims });
|
||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||
} catch {}
|
||||
}
|
||||
} catch (syncErr: any) {
|
||||
if (syncErr?.response?.status === 401) {
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from './AuthProvider';
|
||||
|
||||
export type RoleClaim =
|
||||
| 'dashboard'
|
||||
| 'governance'
|
||||
| 'projects_milestones'
|
||||
| 'grant_financials'
|
||||
| 'evidence_risks'
|
||||
| 'beneficiaries'
|
||||
| 'change_management'
|
||||
| 'site_visits'
|
||||
| 'settings_invites'
|
||||
| 'settings_users'
|
||||
| 'settings_auditlog'
|
||||
| 'settings_notifications'
|
||||
| 'tenant_self_service'
|
||||
| 'tenant_management'
|
||||
| 'unrestricted';
|
||||
|
||||
export type ClaimAction = 'view' | 'add' | 'update' | 'export' | 'all';
|
||||
|
||||
export interface RoleClaimDefinition {
|
||||
claim: RoleClaim;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const ROLE_CLAIM_DEFINITIONS: RoleClaimDefinition[] = [
|
||||
{ claim: 'dashboard', label: 'Executive Dashboard', description: 'Strategic macro metrics and ISO 31000 portfolio health' },
|
||||
{ claim: 'governance', label: 'Governance & Structure', description: 'Holding entities, investment portfolios, and statutory programmes' },
|
||||
{ claim: 'projects_milestones', label: 'Projects & Milestones', description: 'Work breakdown structure, scheduled milestones, and progress' },
|
||||
{ claim: 'grant_financials', label: 'Grant & Financials', description: 'Funding awards, budget line allocations, and disbursements' },
|
||||
{ claim: 'evidence_risks', label: 'M&E, Evidence & Risks', description: 'Evidence verification queue and ISO 31000 risk registers' },
|
||||
{ claim: 'beneficiaries', label: 'Beneficiaries', description: 'Target demographics, compliance tracking, and outcomes' },
|
||||
{ claim: 'change_management', label: 'Change Management', description: 'Contractual change requests, issues, and enactments' },
|
||||
{ claim: 'site_visits', label: 'Site Visits', description: 'Physical field inspection reports and inspector findings' },
|
||||
{ claim: 'settings_invites', label: 'Settings Invites', description: 'Statutory onboarding invitations and applicant notifications' },
|
||||
{ claim: 'settings_users', label: 'Settings Users', description: 'Direct user account administration and role memberships' },
|
||||
{ claim: 'settings_auditlog', label: 'Settings Audit Log', description: 'System-wide activity ledger and telemetry records' },
|
||||
{ claim: 'settings_notifications', label: 'Settings Notifications', description: 'SMTP engines, notification dispatch, and gateways' },
|
||||
{ claim: 'tenant_self_service', label: 'Tenant Self-Service', description: 'Tenant configuration parameters and self-managed sub-users' },
|
||||
{ claim: 'tenant_management', label: 'Tenant Management', description: 'Multi-tenant partition directory and cross-tenant isolation' },
|
||||
{ claim: 'unrestricted', label: 'Unrestricted Access', description: 'Full system-wide administrative control' },
|
||||
];
|
||||
|
||||
export const CLAIM_ACTION_OPTIONS: { action: ClaimAction; label: string; description: string }[] = [
|
||||
{ action: 'view', label: 'view', description: 'Can view records based on role' },
|
||||
{ action: 'add', label: 'add', description: 'Can create records or give feedback; change non-approve statuses' },
|
||||
{ action: 'update', label: 'update', description: 'Can use approve status change in dropdowns' },
|
||||
{ action: 'export', label: 'export', description: 'Can click export buttons (Spreadsheet/PDF)' },
|
||||
{ action: 'all', label: 'all', description: 'Can do everything' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Base Role to Claims mapping according to PFM RBAC Specification.
|
||||
*/
|
||||
export const ROLE_CLAIM_MAP: Record<string, RoleClaim[]> = {
|
||||
admin: ['unrestricted'],
|
||||
executive: [
|
||||
'dashboard',
|
||||
'governance',
|
||||
'projects_milestones',
|
||||
'grant_financials',
|
||||
'evidence_risks',
|
||||
'tenant_self_service',
|
||||
],
|
||||
supervisor: [
|
||||
'dashboard',
|
||||
'projects_milestones',
|
||||
'grant_financials',
|
||||
'evidence_risks',
|
||||
'beneficiaries',
|
||||
'change_management',
|
||||
'site_visits',
|
||||
'settings_invites',
|
||||
'settings_users',
|
||||
],
|
||||
manager: [
|
||||
'dashboard',
|
||||
'projects_milestones',
|
||||
'grant_financials',
|
||||
'evidence_risks',
|
||||
'beneficiaries',
|
||||
'change_management',
|
||||
'site_visits',
|
||||
'settings_invites',
|
||||
'settings_users',
|
||||
'settings_auditlog',
|
||||
'settings_notifications',
|
||||
'tenant_self_service',
|
||||
],
|
||||
contributor: [
|
||||
'projects_milestones',
|
||||
'grant_financials',
|
||||
'evidence_risks',
|
||||
'beneficiaries',
|
||||
'change_management',
|
||||
'site_visits',
|
||||
'settings_invites',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves action capability set given a claim value string.
|
||||
* - view: can view records based on role
|
||||
* - add: can add/create records or give feedback, change statuses other than approve (+ view)
|
||||
* - update: can use approve status change in drop downs (+ view, + add)
|
||||
* - export: can click export buttons (+ view)
|
||||
* - all: can do everything
|
||||
*/
|
||||
export function resolveClaimActionSet(
|
||||
claimValue: string | null | undefined,
|
||||
roleName?: string
|
||||
): Set<ClaimAction> {
|
||||
const actions = new Set<ClaimAction>();
|
||||
if (!claimValue && !roleName) return actions;
|
||||
|
||||
const rawVal = (claimValue || '').toLowerCase().trim();
|
||||
const role = (roleName || '').toLowerCase().trim();
|
||||
|
||||
// Explicit claim assignment values
|
||||
if (rawVal) {
|
||||
if (rawVal === 'all' || rawVal === 'unrestricted' || rawVal === 'true' || rawVal === '1') {
|
||||
actions.add('all');
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
actions.add('update');
|
||||
actions.add('export');
|
||||
return actions;
|
||||
}
|
||||
if (rawVal === 'none' || rawVal === 'restricted' || rawVal === 'false' || rawVal === '0') {
|
||||
return actions;
|
||||
}
|
||||
|
||||
const tokens = rawVal.split(',').map((t) => t.trim());
|
||||
for (const val of tokens) {
|
||||
if (val === 'all') {
|
||||
actions.add('all');
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
actions.add('update');
|
||||
actions.add('export');
|
||||
} else if (val === 'view') {
|
||||
actions.add('view');
|
||||
} else if (val === 'add') {
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
} else if (val === 'update') {
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
actions.add('update');
|
||||
} else if (val === 'export') {
|
||||
actions.add('view');
|
||||
actions.add('export');
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
// Default fallback by role if no overriding claim value specified
|
||||
if (role === 'admin') {
|
||||
actions.add('all');
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
actions.add('update');
|
||||
actions.add('export');
|
||||
} else if (role === 'executive' || role === 'manager') {
|
||||
actions.add('all');
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
actions.add('update');
|
||||
actions.add('export');
|
||||
} else if (role === 'supervisor') {
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
actions.add('export');
|
||||
} else if (role === 'contributor') {
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
} else {
|
||||
actions.add('view');
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract explicit claim value from varied dictionary structures
|
||||
*/
|
||||
export function extractExplicitClaimValue(
|
||||
claims: Record<string, any> = {},
|
||||
claim: RoleClaim
|
||||
): string | null {
|
||||
if (!claims || typeof claims !== 'object') return null;
|
||||
|
||||
if (claims[claim] !== undefined && claims[claim] !== null) {
|
||||
return String(claims[claim]).trim();
|
||||
}
|
||||
if (claims[`role_claim:${claim}`] !== undefined) {
|
||||
return String(claims[`role_claim:${claim}`]).trim();
|
||||
}
|
||||
if (claims[`claim:${claim}`] !== undefined) {
|
||||
return String(claims[`claim:${claim}`]).trim();
|
||||
}
|
||||
|
||||
const noUnderscore = claim.replace(/_/g, '');
|
||||
if (claims[noUnderscore] !== undefined) {
|
||||
return String(claims[noUnderscore]).trim();
|
||||
}
|
||||
|
||||
const arrayCandidates = [
|
||||
claims.claims,
|
||||
claims.UserClaims,
|
||||
claims.user_claims,
|
||||
claims.role_claims,
|
||||
];
|
||||
for (const arr of arrayCandidates) {
|
||||
if (Array.isArray(arr)) {
|
||||
for (const item of arr) {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
const type = (item.type || item.claimType || item.name || '').toLowerCase().trim();
|
||||
if (type === claim || type === noUnderscore) {
|
||||
return String(item.value || item.claimValue || 'all').trim();
|
||||
}
|
||||
} else if (typeof item === 'string') {
|
||||
const [type, val] = item.split(':').map((s) => s.trim().toLowerCase());
|
||||
if (type === claim || type === noUnderscore) {
|
||||
return val || 'all';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates whether a user with given roles and token claims has permission for a specific claim and action.
|
||||
* If a role claim is not present, the user is fully restricted to performing actions associated with claim in role.
|
||||
*/
|
||||
export function evaluateUserPermission(
|
||||
roles: string[] = [],
|
||||
claims: Record<string, any> = {},
|
||||
claim: RoleClaim,
|
||||
action: ClaimAction = 'view'
|
||||
): boolean {
|
||||
// Security lockout: Partner role is completely locked out of this console
|
||||
const isPartner = roles.some((r) => r.toLowerCase().trim() === 'partner');
|
||||
if (isPartner) return false;
|
||||
|
||||
// Unrestricted Admin or unrestricted claim -> full access
|
||||
const hasUnrestrictedRole = roles.some((r) => r.toLowerCase().trim() === 'admin');
|
||||
if (hasUnrestrictedRole) return true;
|
||||
|
||||
const rawUnrestricted = extractExplicitClaimValue(claims, 'unrestricted');
|
||||
if (
|
||||
rawUnrestricted === 'all' ||
|
||||
rawUnrestricted === 'true' ||
|
||||
rawUnrestricted === '1' ||
|
||||
claims.unrestricted === true
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (claim === 'unrestricted') {
|
||||
return hasUnrestrictedRole;
|
||||
}
|
||||
|
||||
// 1. Check if user has explicit claim in claims dictionary
|
||||
const rawClaimValue = extractExplicitClaimValue(claims, claim);
|
||||
if (rawClaimValue !== null && rawClaimValue !== '') {
|
||||
const actionSet = resolveClaimActionSet(rawClaimValue);
|
||||
return actionSet.has('all') || actionSet.has(action);
|
||||
}
|
||||
|
||||
// 2. Check base role mappings
|
||||
let claimFoundInRole = false;
|
||||
for (const r of roles) {
|
||||
const normalizedRole = r.toLowerCase().trim();
|
||||
const allowedClaims = ROLE_CLAIM_MAP[normalizedRole];
|
||||
if (allowedClaims && allowedClaims.includes(claim)) {
|
||||
claimFoundInRole = true;
|
||||
const actionSet = resolveClaimActionSet(undefined, normalizedRole);
|
||||
if (actionSet.has('all') || actionSet.has(action)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If a role claim is not present, the user is fully restricted
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook exposing RBAC evaluation helpers for components.
|
||||
*/
|
||||
export function useRbac() {
|
||||
const { roles, claims } = useAuth();
|
||||
|
||||
const permissions = useMemo(() => {
|
||||
const isUnrestricted =
|
||||
roles.some((r) => r.toLowerCase().trim() === 'admin') ||
|
||||
claims?.unrestricted === true ||
|
||||
claims?.unrestricted === 'all';
|
||||
|
||||
const canAccess = (claim: RoleClaim, action: ClaimAction = 'view'): boolean => {
|
||||
return evaluateUserPermission(roles, claims, claim, action);
|
||||
};
|
||||
|
||||
const canView = (claim: RoleClaim): boolean => canAccess(claim, 'view');
|
||||
const canAdd = (claim: RoleClaim): boolean => canAccess(claim, 'add');
|
||||
const canApprove = (claim: RoleClaim): boolean => canAccess(claim, 'update');
|
||||
const canExport = (claim: RoleClaim): boolean => canAccess(claim, 'export');
|
||||
|
||||
return {
|
||||
isUnrestricted,
|
||||
canAccess,
|
||||
canView,
|
||||
canAdd,
|
||||
canApprove,
|
||||
canExport,
|
||||
};
|
||||
}, [roles, claims]);
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route protection component for claim-restricted screens.
|
||||
*/
|
||||
export const ClaimProtectedRoute: React.FC<{
|
||||
claim: RoleClaim;
|
||||
action?: ClaimAction;
|
||||
children: React.ReactNode;
|
||||
}> = ({ claim, action = 'view', children }) => {
|
||||
const { canAccess, canView } = useRbac();
|
||||
const allowed = canAccess(claim, action);
|
||||
|
||||
if (!allowed) {
|
||||
let fallbackPath = '/dashboard';
|
||||
if (canView('dashboard')) fallbackPath = '/dashboard';
|
||||
else if (canView('projects_milestones')) fallbackPath = '/projects';
|
||||
else if (canView('grant_financials')) fallbackPath = '/grants';
|
||||
else if (canView('evidence_risks')) fallbackPath = '/monitoring';
|
||||
else if (canView('governance')) fallbackPath = '/governance';
|
||||
else if (canView('beneficiaries')) fallbackPath = '/projects/beneficiaries';
|
||||
else if (canView('settings_users') || canView('settings_invites')) fallbackPath = '/settings';
|
||||
|
||||
return React.createElement(Navigate, { to: fallbackPath, replace: true });
|
||||
}
|
||||
|
||||
return React.createElement(React.Fragment, null, children);
|
||||
};
|
||||
Reference in New Issue
Block a user