RBAC Improvements

Caching improvements
Maker-Checker Implementation
This commit is contained in:
2026-10-21 07:27:47 +02:00
parent dee9098379
commit 5d7e2f74ce
25 changed files with 1269 additions and 309 deletions
@@ -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);
+54 -5
View File
@@ -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;
}