RBAC Improvements
Caching improvements Maker-Checker Implementation
This commit is contained in:
@@ -581,62 +581,65 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Verify tenant lockout at the instance tenant and tenant_id claims are retrieved
|
||||
const tenantLockoutResult = await verifyTenantLockout(
|
||||
storedTenantId ?? tenantClaimValues.tenantId,
|
||||
storedTenantClaim
|
||||
);
|
||||
|
||||
if (!tenantLockoutResult.isActive) {
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tenantLockoutResult.tenant?.name) {
|
||||
if (resolvedUser) {
|
||||
resolvedUser.tenant = tenantLockoutResult.tenant.name;
|
||||
setUser({ ...resolvedUser });
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
|
||||
}
|
||||
setTenantName(tenantLockoutResult.tenant.name);
|
||||
} else if (storedTenantClaim) {
|
||||
if (storedTenantClaim) {
|
||||
setTenantName(storedTenantClaim);
|
||||
}
|
||||
|
||||
// Check if session is already marked pending activation or needs sync check
|
||||
const wasPending = isActivationPending();
|
||||
setIsPendingActivation(wasPending);
|
||||
setActivationError(getActivationPendingReason());
|
||||
// Initial fast unblock: valid persisted session found, unblock UI immediately
|
||||
setIsLoading(false);
|
||||
|
||||
try {
|
||||
// Verify sync with browser cache priority (hotspot prevention)
|
||||
const syncResult = await syncUserAccount(
|
||||
storedSub || '',
|
||||
storedToken,
|
||||
resolvedUser || undefined,
|
||||
false
|
||||
);
|
||||
// Asynchronous background revalidation (Tenant Lockout, User Sync, Role Claims)
|
||||
(async () => {
|
||||
try {
|
||||
const tenantLockoutResult = await verifyTenantLockout(
|
||||
storedTenantId ?? tenantClaimValues.tenantId,
|
||||
storedTenantClaim
|
||||
);
|
||||
|
||||
if (syncResult.isUnauthorized) {
|
||||
// HTTP 401: Normal process of routing user to landing page
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
if (!tenantLockoutResult.isActive) {
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncResult.isActivationPending) {
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(syncResult.errorMessage || null);
|
||||
} else if (syncResult.success) {
|
||||
setIsPendingActivation(false);
|
||||
setIsSynced(true);
|
||||
setActivationError(null);
|
||||
if (tenantLockoutResult.tenant?.name) {
|
||||
if (resolvedUser) {
|
||||
resolvedUser.tenant = tenantLockoutResult.tenant.name;
|
||||
setUser({ ...resolvedUser });
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
|
||||
}
|
||||
setTenantName(tenantLockoutResult.tenant.name);
|
||||
}
|
||||
|
||||
const wasPending = isActivationPending();
|
||||
setIsPendingActivation(wasPending);
|
||||
setActivationError(getActivationPendingReason());
|
||||
|
||||
const syncResult = await syncUserAccount(
|
||||
storedSub || '',
|
||||
storedToken,
|
||||
resolvedUser || undefined,
|
||||
false
|
||||
);
|
||||
|
||||
if (syncResult.isUnauthorized) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncResult.isActivationPending) {
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(syncResult.errorMessage || null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncResult.success) {
|
||||
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,
|
||||
@@ -657,17 +660,19 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}
|
||||
setClaims({ ...resolved.claims });
|
||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims));
|
||||
} catch {}
|
||||
}
|
||||
} catch (syncErr: any) {
|
||||
if (syncErr?.response?.status === 401) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(syncErr?.message || 'Account activation pending');
|
||||
}
|
||||
} catch (syncErr: any) {
|
||||
if (syncErr?.response?.status === 401) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
setIsPendingActivation(true);
|
||||
setIsSynced(false);
|
||||
setActivationError(syncErr?.message || 'Account activation pending');
|
||||
}
|
||||
})();
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('OIDC initialization notice:', err);
|
||||
|
||||
@@ -142,12 +142,14 @@ export function resolveClaimActionSet(
|
||||
actions.add('add');
|
||||
actions.add('update');
|
||||
actions.add('export');
|
||||
} else if (val === 'view') {
|
||||
} else if (val === 'view' || val === 'read') {
|
||||
actions.add('view');
|
||||
} else if (val === 'add') {
|
||||
} else if (val === 'add' || val === 'edit' || val === 'write' || val === 'create') {
|
||||
// 'add' encompasses create and non-approval edits
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
} else if (val === 'update') {
|
||||
} else if (val === 'update' || val === 'approve' || val === 'verify') {
|
||||
// 'update' strictly grants approval capability (+ view and add)
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
actions.add('update');
|
||||
@@ -173,10 +175,13 @@ export function resolveClaimActionSet(
|
||||
actions.add('update');
|
||||
actions.add('export');
|
||||
} else if (role === 'supervisor') {
|
||||
// Supervisor performs operational review and verification/approval of milestones & evidence
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
actions.add('update');
|
||||
actions.add('export');
|
||||
} else if (role === 'contributor') {
|
||||
// Contributor is strictly working-level execution: creating activities, submitting evidence, etc. (NO approve/update)
|
||||
actions.add('view');
|
||||
actions.add('add');
|
||||
} else {
|
||||
@@ -294,11 +299,36 @@ export function evaluateUserPermission(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates Maker-Checker separation of duties constraint.
|
||||
* In PFM governance, the user who created (or initiated) a record ("Maker")
|
||||
* is strictly barred from approving ("Checking") their own record.
|
||||
*
|
||||
* @param createdBy The user identifier of the record creator (Guid, sub, email)
|
||||
* @param currentUserId Current logged-in user ID (user.id or sub)
|
||||
* @param currentUserSub Current logged-in user subject claim
|
||||
* @param currentUserEmail Current logged-in user email
|
||||
* @returns boolean true if user is the creator (Maker), false otherwise
|
||||
*/
|
||||
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 components.
|
||||
*/
|
||||
export function useRbac() {
|
||||
const { roles, claims } = useAuth();
|
||||
const { roles, claims, user, sub } = useAuth();
|
||||
|
||||
const permissions = useMemo(() => {
|
||||
const isUnrestricted =
|
||||
@@ -315,6 +345,22 @@ export function useRbac() {
|
||||
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;
|
||||
// Maker-Checker policy: record creator cannot approve their own record
|
||||
return !isMaker(createdBy);
|
||||
};
|
||||
|
||||
const getApprovalRestrictionReason = (claim: RoleClaim, createdBy?: string | null): string | null => {
|
||||
if (!canApprove(claim)) return 'Requires update/approval permission';
|
||||
if (isMaker(createdBy)) return 'Maker-Checker policy: Record creators cannot approve their own records';
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
isUnrestricted,
|
||||
canAccess,
|
||||
@@ -322,8 +368,11 @@ export function useRbac() {
|
||||
canAdd,
|
||||
canApprove,
|
||||
canExport,
|
||||
isMaker,
|
||||
canApproveRecord,
|
||||
getApprovalRestrictionReason,
|
||||
};
|
||||
}, [roles, claims]);
|
||||
}, [roles, claims, user, sub]);
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user