324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
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'
|
|
| 'compliance'
|
|
| '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: 'Partner Dashboard', description: 'Overview of ongoing projects, funding awards, and inbox' },
|
|
{ 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 submission and 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: 'compliance', label: 'Compliance', description: 'Statutory compliance, outcome fulfillment, and risk monitoring' },
|
|
{ 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: 'Notifications & Messages', description: 'In-app and email communications with the funding organisation' },
|
|
{ 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 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' },
|
|
];
|
|
|
|
/**
|
|
* RBAC on PartnerUI ONLY functions for Partner roles.
|
|
* No other roles (Admin, Executive, Supervisor, Manager, Contributor) are permitted on this app.
|
|
*/
|
|
export const ROLE_CLAIM_MAP: Record<string, RoleClaim[]> = {
|
|
partner: [
|
|
'dashboard',
|
|
'projects_milestones',
|
|
'grant_financials',
|
|
'evidence_risks',
|
|
'beneficiaries',
|
|
'change_management',
|
|
'site_visits',
|
|
'compliance',
|
|
'settings_notifications',
|
|
],
|
|
};
|
|
|
|
/**
|
|
* Checks whether the user's roles include the mandatory 'Partner' role.
|
|
* On PartnerUI, ONLY users with the 'Partner' role are permitted.
|
|
*/
|
|
export function isPartnerRoleOnlyPermitted(roles: string[] = []): boolean {
|
|
return Array.isArray(roles) && roles.some((r) => r.toLowerCase().trim() === 'partner');
|
|
}
|
|
|
|
/**
|
|
* Resolves action capability set given a claim value string for a Partner user.
|
|
*/
|
|
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();
|
|
|
|
// Only Partner role is recognized in PartnerUI
|
|
if (role && role !== 'partner') {
|
|
return actions;
|
|
}
|
|
|
|
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' || val === 'read') {
|
|
actions.add('view');
|
|
} else if (val === 'add' || val === 'edit' || val === 'write' || val === 'create') {
|
|
actions.add('view');
|
|
actions.add('add');
|
|
} else if (val === 'update' || val === 'approve' || val === 'verify') {
|
|
actions.add('view');
|
|
actions.add('add');
|
|
actions.add('update');
|
|
} else if (val === 'export') {
|
|
actions.add('view');
|
|
actions.add('export');
|
|
}
|
|
}
|
|
return actions;
|
|
}
|
|
|
|
// Default fallback for Partner role: view, add (submit evidence, log activities, send messages, execute contract), export
|
|
if (role === 'partner') {
|
|
actions.add('view');
|
|
actions.add('add');
|
|
actions.add('export');
|
|
}
|
|
|
|
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.
|
|
*
|
|
* CRITICAL RULE FOR PARTNER UI:
|
|
* RBAC on PartnerUI ONLY functions for Partner roles. No other roles are permitted on this app.
|
|
*/
|
|
export function evaluateUserPermission(
|
|
roles: string[] = [],
|
|
claims: Record<string, any> = {},
|
|
claim: RoleClaim,
|
|
action: ClaimAction = 'view'
|
|
): boolean {
|
|
// Security Gate: Only users with the 'Partner' role are permitted on PartnerUI.
|
|
const isPartner = isPartnerRoleOnlyPermitted(roles);
|
|
if (!isPartner) return false;
|
|
|
|
// 1. Check if Partner user has explicit claim in claims dictionary
|
|
const rawClaimValue = extractExplicitClaimValue(claims, claim);
|
|
if (rawClaimValue !== null && rawClaimValue !== '') {
|
|
const actionSet = resolveClaimActionSet(rawClaimValue, 'partner');
|
|
return actionSet.has('all') || actionSet.has(action);
|
|
}
|
|
|
|
// 2. Check Partner base role mappings
|
|
const allowedClaims = ROLE_CLAIM_MAP['partner'];
|
|
if (allowedClaims && allowedClaims.includes(claim)) {
|
|
const actionSet = resolveClaimActionSet(undefined, 'partner');
|
|
if (actionSet.has('all') || actionSet.has(action)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Evaluates Maker-Checker separation of duties constraint.
|
|
*/
|
|
export function isRecordMaker(
|
|
createdBy?: string | null,
|
|
currentUserId?: string | null,
|
|
currentUserSub?: string | null,
|
|
currentUserEmail?: string | null
|
|
): boolean {
|
|
if (!createdBy) return false;
|
|
const creator = createdBy.trim().toLowerCase();
|
|
if (currentUserId && creator === currentUserId.trim().toLowerCase()) return true;
|
|
if (currentUserSub && creator === currentUserSub.trim().toLowerCase()) return true;
|
|
if (currentUserEmail && creator === currentUserEmail.trim().toLowerCase()) return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* React hook exposing RBAC evaluation helpers for PartnerUI components.
|
|
* Enforces that only the Partner role has permissions in this application.
|
|
*/
|
|
export function useRbac() {
|
|
const { roles, claims, user, sub } = useAuth();
|
|
|
|
const permissions = useMemo(() => {
|
|
const isPartner = isPartnerRoleOnlyPermitted(roles);
|
|
|
|
const canAccess = (claim: RoleClaim, action: ClaimAction = 'view'): boolean => {
|
|
if (!isPartner) return false;
|
|
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');
|
|
|
|
const isMaker = (createdBy?: string | null): boolean => {
|
|
return isRecordMaker(createdBy, user?.id, sub, user?.email);
|
|
};
|
|
|
|
const canApproveRecord = (claim: RoleClaim, createdBy?: string | null): boolean => {
|
|
if (!canApprove(claim)) return false;
|
|
return !isMaker(createdBy);
|
|
};
|
|
|
|
const getApprovalRestrictionReason = (claim: RoleClaim, createdBy?: string | null): string | null => {
|
|
if (!isPartner) return 'Partner role required';
|
|
if (!canApprove(claim)) return 'Requires approval permission from the funding organisation';
|
|
if (isMaker(createdBy)) return 'Maker-Checker policy: Record creators cannot approve their own records';
|
|
return null;
|
|
};
|
|
|
|
return {
|
|
isPartner,
|
|
isUnrestricted: false,
|
|
canAccess,
|
|
canView,
|
|
canAdd,
|
|
canApprove,
|
|
canExport,
|
|
isMaker,
|
|
canApproveRecord,
|
|
getApprovalRestrictionReason,
|
|
};
|
|
}, [roles, claims, user, sub]);
|
|
|
|
return permissions;
|
|
}
|
|
|
|
/**
|
|
* Route protection component for claim-restricted screens in PartnerUI.
|
|
*/
|
|
export const ClaimProtectedRoute: React.FC<{
|
|
claim: RoleClaim;
|
|
action?: ClaimAction;
|
|
children: React.ReactNode;
|
|
}> = ({ claim, action = 'view', children }) => {
|
|
const { canAccess } = useRbac();
|
|
const allowed = canAccess(claim, action);
|
|
|
|
if (!allowed) {
|
|
return React.createElement(Navigate, { to: '/dashboard', replace: true });
|
|
}
|
|
|
|
return React.createElement(React.Fragment, null, children);
|
|
};
|
|
|