@jmruthers/pace-core 0.5.109 → 0.5.111
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/{AuthService-DrHrvXNZ.d.ts → AuthService-CVgsgtaZ.d.ts} +8 -0
- package/dist/{DataTable-5HITILXS.js → DataTable-5W2HVLLV.js} +8 -8
- package/dist/{UnifiedAuthProvider-A7I23UCN.js → UnifiedAuthProvider-LUM3QLS5.js} +3 -3
- package/dist/{api-5I3E47G2.js → api-SIZPFBFX.js} +5 -3
- package/dist/{audit-65VNHEV2.js → audit-5JI5T3SL.js} +2 -2
- package/dist/{chunk-P72NKAT5.js → chunk-2BIDKXQU.js} +157 -120
- package/dist/chunk-2BIDKXQU.js.map +1 -0
- package/dist/{chunk-S4D3Z723.js → chunk-ACYQNYHB.js} +7 -7
- package/dist/{chunk-D6MEKC27.js → chunk-EFVQBYFN.js} +2 -2
- package/dist/{chunk-EZ64QG2I.js → chunk-I5YM5BGS.js} +2 -2
- package/dist/{chunk-Q7APDV6H.js → chunk-IWJYNWXN.js} +13 -5
- package/dist/chunk-IWJYNWXN.js.map +1 -0
- package/dist/{chunk-YFMENCR4.js → chunk-JE2GFA3O.js} +3 -3
- package/dist/{chunk-AUXS7XSO.js → chunk-MW73E7SP.js} +35 -11
- package/dist/chunk-MW73E7SP.js.map +1 -0
- package/dist/{chunk-F6TSYCKP.js → chunk-PXXS26G5.js} +68 -29
- package/dist/chunk-PXXS26G5.js.map +1 -0
- package/dist/{chunk-UW2DE6JX.js → chunk-TD4BXGPE.js} +4 -4
- package/dist/{chunk-EYSXQ756.js → chunk-TDFBX7KJ.js} +2 -2
- package/dist/{chunk-WWNOVFDC.js → chunk-UGVU7L7N.js} +52 -90
- package/dist/chunk-UGVU7L7N.js.map +1 -0
- package/dist/{chunk-2W4WKJVF.js → chunk-X7SPKHYZ.js} +290 -255
- package/dist/chunk-X7SPKHYZ.js.map +1 -0
- package/dist/{chunk-3TKTL5AZ.js → chunk-ZL45MG76.js} +60 -60
- package/dist/chunk-ZL45MG76.js.map +1 -0
- package/dist/components.js +10 -10
- package/dist/hooks.d.ts +11 -1
- package/dist/hooks.js +9 -7
- package/dist/hooks.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +13 -13
- package/dist/providers.d.ts +2 -2
- package/dist/providers.js +2 -2
- package/dist/rbac/index.d.ts +46 -29
- package/dist/rbac/index.js +9 -9
- package/dist/utils.js +1 -1
- package/docs/api/classes/ColumnFactory.md +1 -1
- package/docs/api/classes/ErrorBoundary.md +1 -1
- package/docs/api/classes/InvalidScopeError.md +4 -4
- package/docs/api/classes/MissingUserContextError.md +4 -4
- package/docs/api/classes/OrganisationContextRequiredError.md +4 -4
- package/docs/api/classes/PermissionDeniedError.md +4 -4
- package/docs/api/classes/PublicErrorBoundary.md +1 -1
- package/docs/api/classes/RBACAuditManager.md +8 -8
- package/docs/api/classes/RBACCache.md +8 -8
- package/docs/api/classes/RBACEngine.md +9 -8
- package/docs/api/classes/RBACError.md +4 -4
- package/docs/api/classes/RBACNotInitializedError.md +4 -4
- package/docs/api/classes/SecureSupabaseClient.md +1 -1
- package/docs/api/classes/StorageUtils.md +1 -1
- package/docs/api/enums/FileCategory.md +1 -1
- package/docs/api/interfaces/AggregateConfig.md +1 -1
- package/docs/api/interfaces/ButtonProps.md +1 -1
- package/docs/api/interfaces/CardProps.md +1 -1
- package/docs/api/interfaces/ColorPalette.md +1 -1
- package/docs/api/interfaces/ColorShade.md +1 -1
- package/docs/api/interfaces/DataAccessRecord.md +1 -1
- package/docs/api/interfaces/DataRecord.md +1 -1
- package/docs/api/interfaces/DataTableAction.md +1 -1
- package/docs/api/interfaces/DataTableColumn.md +1 -1
- package/docs/api/interfaces/DataTableProps.md +1 -1
- package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
- package/docs/api/interfaces/EmptyStateConfig.md +1 -1
- package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
- package/docs/api/interfaces/FileDisplayProps.md +1 -1
- package/docs/api/interfaces/FileMetadata.md +1 -1
- package/docs/api/interfaces/FileReference.md +1 -1
- package/docs/api/interfaces/FileSizeLimits.md +1 -1
- package/docs/api/interfaces/FileUploadOptions.md +1 -1
- package/docs/api/interfaces/FileUploadProps.md +1 -1
- package/docs/api/interfaces/FooterProps.md +1 -1
- package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
- package/docs/api/interfaces/InputProps.md +1 -1
- package/docs/api/interfaces/LabelProps.md +1 -1
- package/docs/api/interfaces/LoginFormProps.md +1 -1
- package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
- package/docs/api/interfaces/NavigationContextType.md +1 -1
- package/docs/api/interfaces/NavigationGuardProps.md +1 -1
- package/docs/api/interfaces/NavigationItem.md +1 -1
- package/docs/api/interfaces/NavigationMenuProps.md +1 -1
- package/docs/api/interfaces/NavigationProviderProps.md +1 -1
- package/docs/api/interfaces/Organisation.md +1 -1
- package/docs/api/interfaces/OrganisationContextType.md +1 -1
- package/docs/api/interfaces/OrganisationMembership.md +1 -1
- package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
- package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
- package/docs/api/interfaces/PaceAppLayoutProps.md +27 -27
- package/docs/api/interfaces/PaceLoginPageProps.md +4 -4
- package/docs/api/interfaces/PageAccessRecord.md +1 -1
- package/docs/api/interfaces/PagePermissionContextType.md +1 -1
- package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
- package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
- package/docs/api/interfaces/PaletteData.md +1 -1
- package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
- package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
- package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
- package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
- package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
- package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
- package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
- package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
- package/docs/api/interfaces/RBACConfig.md +19 -8
- package/docs/api/interfaces/RBACLogger.md +5 -5
- package/docs/api/interfaces/RoleBasedRouterContextType.md +8 -8
- package/docs/api/interfaces/RoleBasedRouterProps.md +10 -10
- package/docs/api/interfaces/RouteAccessRecord.md +10 -10
- package/docs/api/interfaces/RouteConfig.md +19 -6
- package/docs/api/interfaces/SecureDataContextType.md +1 -1
- package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
- package/docs/api/interfaces/StorageConfig.md +1 -1
- package/docs/api/interfaces/StorageFileInfo.md +1 -1
- package/docs/api/interfaces/StorageFileMetadata.md +1 -1
- package/docs/api/interfaces/StorageListOptions.md +1 -1
- package/docs/api/interfaces/StorageListResult.md +1 -1
- package/docs/api/interfaces/StorageUploadOptions.md +1 -1
- package/docs/api/interfaces/StorageUploadResult.md +1 -1
- package/docs/api/interfaces/StorageUrlOptions.md +1 -1
- package/docs/api/interfaces/StyleImport.md +1 -1
- package/docs/api/interfaces/SwitchProps.md +1 -1
- package/docs/api/interfaces/ToastActionElement.md +1 -1
- package/docs/api/interfaces/ToastProps.md +1 -1
- package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
- package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
- package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
- package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
- package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
- package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
- package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
- package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
- package/docs/api/interfaces/UserEventAccess.md +1 -1
- package/docs/api/interfaces/UserMenuProps.md +1 -1
- package/docs/api/interfaces/UserProfile.md +1 -1
- package/docs/api/modules.md +44 -43
- package/docs/api-reference/hooks.md +8 -4
- package/docs/architecture/rpc-function-standards.md +3 -1
- package/docs/best-practices/common-patterns.md +3 -3
- package/docs/best-practices/deployment.md +10 -4
- package/docs/best-practices/performance.md +11 -3
- package/docs/core-concepts/organisations.md +8 -8
- package/docs/core-concepts/permissions.md +133 -72
- package/docs/documentation-index.md +0 -2
- package/docs/migration/rbac-migration.md +65 -66
- package/docs/rbac/README.md +114 -38
- package/docs/rbac/advanced-patterns.md +15 -22
- package/docs/rbac/api-reference.md +63 -16
- package/docs/rbac/examples.md +12 -12
- package/docs/rbac/getting-started.md +19 -19
- package/docs/rbac/quick-start.md +110 -35
- package/docs/rbac/troubleshooting.md +127 -3
- package/package.json +1 -1
- package/src/components/DataTable/components/__tests__/ActionButtons.test.tsx +913 -0
- package/src/components/DataTable/components/__tests__/ColumnFilter.test.tsx +609 -0
- package/src/components/DataTable/components/__tests__/EmptyState.test.tsx +434 -0
- package/src/components/DataTable/components/__tests__/LoadingState.test.tsx +120 -0
- package/src/components/DataTable/components/__tests__/PaginationControls.test.tsx +519 -0
- package/src/components/DataTable/examples/__tests__/HierarchicalActionsExample.test.tsx +316 -0
- package/src/components/DataTable/examples/__tests__/InitialPageSizeExample.test.tsx +211 -0
- package/src/components/FileUpload/FileUpload.tsx +2 -8
- package/src/components/NavigationMenu/NavigationMenu.test.tsx +38 -4
- package/src/components/NavigationMenu/NavigationMenu.tsx +71 -6
- package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +193 -63
- package/src/components/PaceAppLayout/PaceAppLayout.tsx +102 -135
- package/src/components/PaceAppLayout/__tests__/PaceAppLayout.accessibility.test.tsx +41 -2
- package/src/components/PaceAppLayout/__tests__/PaceAppLayout.integration.test.tsx +61 -6
- package/src/components/PaceAppLayout/__tests__/PaceAppLayout.performance.test.tsx +71 -21
- package/src/components/PaceAppLayout/__tests__/PaceAppLayout.security.test.tsx +113 -41
- package/src/components/PaceAppLayout/__tests__/PaceAppLayout.unit.test.tsx +155 -45
- package/src/components/PaceLoginPage/PaceLoginPage.tsx +30 -1
- package/src/hooks/__tests__/usePermissionCache.simple.test.ts +63 -5
- package/src/hooks/__tests__/usePermissionCache.unit.test.ts +156 -72
- package/src/hooks/__tests__/useRBAC.unit.test.ts +4 -38
- package/src/hooks/index.ts +1 -1
- package/src/hooks/useFileDisplay.ts +51 -0
- package/src/hooks/usePermissionCache.test.ts +112 -68
- package/src/hooks/usePermissionCache.ts +55 -15
- package/src/rbac/README.md +81 -39
- package/src/rbac/__tests__/adapters.comprehensive.test.tsx +3 -3
- package/src/rbac/__tests__/engine.comprehensive.test.ts +15 -6
- package/src/rbac/__tests__/rbac-core.test.tsx +1 -1
- package/src/rbac/__tests__/rbac-engine-core-logic.test.ts +57 -4
- package/src/rbac/__tests__/rbac-engine-simplified.test.ts +3 -2
- package/src/rbac/adapters.tsx +4 -4
- package/src/rbac/api.test.ts +39 -15
- package/src/rbac/api.ts +27 -9
- package/src/rbac/audit.test.ts +2 -2
- package/src/rbac/audit.ts +14 -5
- package/src/rbac/cache.test.ts +12 -0
- package/src/rbac/cache.ts +29 -9
- package/src/rbac/components/EnhancedNavigationMenu.test.tsx +1 -1
- package/src/rbac/components/NavigationGuard.tsx +14 -14
- package/src/rbac/components/NavigationProvider.test.tsx +1 -1
- package/src/rbac/components/PagePermissionGuard.tsx +22 -38
- package/src/rbac/components/PagePermissionProvider.test.tsx +1 -1
- package/src/rbac/components/PermissionEnforcer.tsx +19 -15
- package/src/rbac/components/RoleBasedRouter.tsx +16 -9
- package/src/rbac/components/__tests__/NavigationGuard.test.tsx +123 -107
- package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +2 -2
- package/src/rbac/components/__tests__/PermissionEnforcer.test.tsx +121 -103
- package/src/rbac/config.ts +2 -0
- package/src/rbac/docs/event-based-apps.md +6 -6
- package/src/rbac/engine.ts +27 -7
- package/src/rbac/hooks/useCan.test.ts +29 -2
- package/src/rbac/hooks/usePermissions.test.ts +25 -25
- package/src/rbac/hooks/usePermissions.ts +47 -23
- package/src/rbac/hooks/useRBAC.simple.test.ts +1 -8
- package/src/rbac/hooks/useRBAC.test.ts +3 -40
- package/src/rbac/hooks/useRBAC.ts +0 -55
- package/src/rbac/hooks/useResolvedScope.ts +23 -31
- package/src/rbac/permissions.test.ts +11 -7
- package/src/rbac/security.test.ts +2 -2
- package/src/rbac/security.ts +23 -8
- package/src/rbac/types.test.ts +2 -2
- package/src/rbac/types.ts +1 -2
- package/src/services/EventService.ts +41 -13
- package/src/services/__tests__/EventService.test.ts +25 -4
- package/src/services/interfaces/IEventService.ts +1 -0
- package/src/utils/file-reference.ts +9 -0
- package/dist/chunk-2W4WKJVF.js.map +0 -1
- package/dist/chunk-3TKTL5AZ.js.map +0 -1
- package/dist/chunk-AUXS7XSO.js.map +0 -1
- package/dist/chunk-F6TSYCKP.js.map +0 -1
- package/dist/chunk-P72NKAT5.js.map +0 -1
- package/dist/chunk-Q7APDV6H.js.map +0 -1
- package/dist/chunk-WWNOVFDC.js.map +0 -1
- package/docs/rbac/breaking-changes-v3.md +0 -222
- package/docs/rbac/migration-guide.md +0 -260
- /package/dist/{DataTable-5HITILXS.js.map → DataTable-5W2HVLLV.js.map} +0 -0
- /package/dist/{UnifiedAuthProvider-A7I23UCN.js.map → UnifiedAuthProvider-LUM3QLS5.js.map} +0 -0
- /package/dist/{api-5I3E47G2.js.map → api-SIZPFBFX.js.map} +0 -0
- /package/dist/{audit-65VNHEV2.js.map → audit-5JI5T3SL.js.map} +0 -0
- /package/dist/{chunk-S4D3Z723.js.map → chunk-ACYQNYHB.js.map} +0 -0
- /package/dist/{chunk-D6MEKC27.js.map → chunk-EFVQBYFN.js.map} +0 -0
- /package/dist/{chunk-EZ64QG2I.js.map → chunk-I5YM5BGS.js.map} +0 -0
- /package/dist/{chunk-YFMENCR4.js.map → chunk-JE2GFA3O.js.map} +0 -0
- /package/dist/{chunk-UW2DE6JX.js.map → chunk-TD4BXGPE.js.map} +0 -0
- /package/dist/{chunk-EYSXQ756.js.map → chunk-TDFBX7KJ.js.map} +0 -0
package/src/rbac/api.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
PermissionMap,
|
|
18
18
|
PermissionCheck,
|
|
19
19
|
RBACNotInitializedError,
|
|
20
|
+
OrganisationContextRequiredError,
|
|
20
21
|
RBACAppContext,
|
|
21
22
|
RBACRoleContext,
|
|
22
23
|
} from './types';
|
|
@@ -49,7 +50,8 @@ export function setupRBAC(supabase: SupabaseClient<Database>, config?: Partial<R
|
|
|
49
50
|
|
|
50
51
|
createRBACConfig(fullConfig);
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
// Pass security config to engine if provided
|
|
54
|
+
globalEngine = createRBACEngine(supabase, config?.security);
|
|
53
55
|
|
|
54
56
|
// Setup audit manager
|
|
55
57
|
const auditManager = createAuditManager(supabase);
|
|
@@ -146,7 +148,7 @@ export async function getRoleContext(input: {
|
|
|
146
148
|
* const canManage = await isPermitted({
|
|
147
149
|
* userId: 'user-123',
|
|
148
150
|
* scope: { organisationId: 'org-456' },
|
|
149
|
-
* permission: '
|
|
151
|
+
* permission: 'update:events',
|
|
150
152
|
* pageId: 'page-789'
|
|
151
153
|
* });
|
|
152
154
|
* ```
|
|
@@ -154,11 +156,16 @@ export async function getRoleContext(input: {
|
|
|
154
156
|
export async function isPermitted(input: PermissionCheck): Promise<boolean> {
|
|
155
157
|
const engine = getEngine();
|
|
156
158
|
|
|
159
|
+
// Validate organisation context is required
|
|
160
|
+
if (!input.scope.organisationId) {
|
|
161
|
+
throw new OrganisationContextRequiredError();
|
|
162
|
+
}
|
|
163
|
+
|
|
157
164
|
// Create security context from input
|
|
158
|
-
//
|
|
165
|
+
// OrganisationId is required - it can always be derived from event context in event-based apps
|
|
159
166
|
const securityContext: SecurityContext = {
|
|
160
167
|
userId: input.userId,
|
|
161
|
-
organisationId: input.scope.organisationId
|
|
168
|
+
organisationId: input.scope.organisationId, // Required - no fallback
|
|
162
169
|
timestamp: new Date(),
|
|
163
170
|
// Optional fields can be omitted
|
|
164
171
|
};
|
|
@@ -348,11 +355,19 @@ export async function isEventAdmin(userId: UUID, scope: Scope): Promise<boolean>
|
|
|
348
355
|
* @param organisationId - Organisation ID (optional)
|
|
349
356
|
*/
|
|
350
357
|
export function invalidateUserCache(userId: UUID, organisationId?: UUID): void {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
358
|
+
const patterns = organisationId
|
|
359
|
+
? [
|
|
360
|
+
CACHE_PATTERNS.PERMISSION(userId, organisationId),
|
|
361
|
+
`access:${userId}:${organisationId}:`,
|
|
362
|
+
`map:${userId}:${organisationId}:`,
|
|
363
|
+
]
|
|
364
|
+
: [
|
|
365
|
+
`perm:${userId}:`,
|
|
366
|
+
`access:${userId}:`,
|
|
367
|
+
`map:${userId}:`,
|
|
368
|
+
];
|
|
369
|
+
|
|
370
|
+
patterns.forEach(pattern => rbacCache.invalidate(pattern));
|
|
356
371
|
}
|
|
357
372
|
|
|
358
373
|
/**
|
|
@@ -388,3 +403,6 @@ export function invalidateAppCache(appId: UUID): void {
|
|
|
388
403
|
export function clearCache(): void {
|
|
389
404
|
rbacCache.clear();
|
|
390
405
|
}
|
|
406
|
+
|
|
407
|
+
// Re-export OrganisationContextRequiredError for convenience
|
|
408
|
+
export { OrganisationContextRequiredError } from './types';
|
package/src/rbac/audit.test.ts
CHANGED
|
@@ -143,7 +143,7 @@ describe('RBACAuditManager', () => {
|
|
|
143
143
|
eventId: 'event-789',
|
|
144
144
|
appId: 'app-101' as UUID,
|
|
145
145
|
pageId: 'page-202' as UUID,
|
|
146
|
-
permission: '
|
|
146
|
+
permission: 'update:users',
|
|
147
147
|
source: 'api' as AuditEventSource,
|
|
148
148
|
metadata: { reason: 'Insufficient role' }
|
|
149
149
|
};
|
|
@@ -159,7 +159,7 @@ describe('RBACAuditManager', () => {
|
|
|
159
159
|
event_id: 'event-789',
|
|
160
160
|
app_id: 'app-101',
|
|
161
161
|
page_id: 'page-202',
|
|
162
|
-
permission: '
|
|
162
|
+
permission: 'update:users',
|
|
163
163
|
source: 'api',
|
|
164
164
|
metadata: expect.objectContaining({ reason: 'Insufficient role' })
|
|
165
165
|
})
|
package/src/rbac/audit.ts
CHANGED
|
@@ -163,13 +163,22 @@ export class RBACAuditManager {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
try {
|
|
166
|
-
//
|
|
166
|
+
// Since organisationId is now required in SecurityContext, this should rarely happen
|
|
167
|
+
// But we still handle the edge case properly without masking it
|
|
168
|
+
if (!event.organisationId) {
|
|
169
|
+
console.warn('[RBAC Audit] Audit event without organisation context - this should be investigated:', {
|
|
170
|
+
userId: event.userId,
|
|
171
|
+
eventType: event.type,
|
|
172
|
+
note: 'Organisation context is required for RBAC operations. This may indicate a security issue or missing context derivation.'
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
167
176
|
const auditEvent: Omit<RBACAuditEvent, 'id' | 'created_at'> = {
|
|
168
177
|
event_type: event.type,
|
|
169
178
|
user_id: event.userId,
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
organisation_id: event.organisationId ||
|
|
179
|
+
// Store organisationId - nullable to properly track missing context cases
|
|
180
|
+
// Do NOT use fallback UUID as it masks security issues
|
|
181
|
+
organisation_id: event.organisationId || null, // Explicitly null if missing
|
|
173
182
|
event_id: 'eventId' in event ? event.eventId : undefined,
|
|
174
183
|
app_id: 'appId' in event ? event.appId : undefined,
|
|
175
184
|
page_id: 'pageId' in event ? event.pageId : undefined,
|
|
@@ -182,7 +191,7 @@ export class RBACAuditManager {
|
|
|
182
191
|
...event.metadata,
|
|
183
192
|
cache_hit: 'cache_hit' in event ? event.cache_hit : undefined,
|
|
184
193
|
cache_source: 'cache_source' in event ? event.cache_source : undefined,
|
|
185
|
-
//
|
|
194
|
+
// Explicit flag indicating this event had no organisation context
|
|
186
195
|
no_organisation_context: !event.organisationId,
|
|
187
196
|
},
|
|
188
197
|
};
|
package/src/rbac/cache.test.ts
CHANGED
|
@@ -129,6 +129,18 @@ describe('RBACCache', () => {
|
|
|
129
129
|
expect(cache.get('org-789-data')).not.toBeNull();
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
it('supports wildcard patterns spanning cache segments', () => {
|
|
133
|
+
cache.set('perm:user-123:org-789:null:null:view:dashboard', true);
|
|
134
|
+
cache.set('perm:user-456:org-789:null:null:view:dashboard', true);
|
|
135
|
+
cache.set('perm:user-123:org-000:null:null:view:dashboard', true);
|
|
136
|
+
|
|
137
|
+
cache.invalidate('perm:*:org-789:*');
|
|
138
|
+
|
|
139
|
+
expect(cache.get('perm:user-123:org-789:null:null:view:dashboard')).toBeNull();
|
|
140
|
+
expect(cache.get('perm:user-456:org-789:null:null:view:dashboard')).toBeNull();
|
|
141
|
+
expect(cache.get('perm:user-123:org-000:null:null:view:dashboard')).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
132
144
|
it('handles empty pattern gracefully', () => {
|
|
133
145
|
expect(() => cache.invalidate('')).not.toThrow();
|
|
134
146
|
});
|
package/src/rbac/cache.ts
CHANGED
|
@@ -72,18 +72,38 @@ export class RBACCache {
|
|
|
72
72
|
* @param pattern - Pattern to match against cache keys
|
|
73
73
|
*/
|
|
74
74
|
invalidate(pattern: string): void {
|
|
75
|
+
const trimmedPattern = pattern?.trim();
|
|
76
|
+
|
|
77
|
+
if (!trimmedPattern) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const matcher = this.createMatcher(trimmedPattern);
|
|
75
82
|
const keysToDelete: string[] = [];
|
|
76
|
-
|
|
83
|
+
|
|
77
84
|
for (const key of this.cache.keys()) {
|
|
78
|
-
if (key
|
|
85
|
+
if (matcher(key)) {
|
|
79
86
|
keysToDelete.push(key);
|
|
80
87
|
}
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
keysToDelete.forEach(key => this.cache.delete(key));
|
|
84
|
-
|
|
91
|
+
|
|
85
92
|
// Notify invalidation callbacks
|
|
86
|
-
this.invalidationCallbacks.forEach(callback => callback(
|
|
93
|
+
this.invalidationCallbacks.forEach(callback => callback(trimmedPattern));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private createMatcher(pattern: string): (key: string) => boolean {
|
|
97
|
+
if (pattern.includes('*')) {
|
|
98
|
+
const escapedSegments = pattern
|
|
99
|
+
.split('*')
|
|
100
|
+
.map(segment => segment.replace(/[|\\{}()[\]^$+?.-]/g, '\\$&'));
|
|
101
|
+
const regexPattern = escapedSegments.join('.*');
|
|
102
|
+
const regex = new RegExp(regexPattern);
|
|
103
|
+
return (key: string) => regex.test(key);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (key: string) => key.includes(pattern);
|
|
87
107
|
}
|
|
88
108
|
|
|
89
109
|
/**
|
|
@@ -239,9 +259,9 @@ export const rbacCache = new RBACCache();
|
|
|
239
259
|
* Cache key patterns for invalidation
|
|
240
260
|
*/
|
|
241
261
|
export const CACHE_PATTERNS = {
|
|
242
|
-
USER: (userId: UUID) =>
|
|
243
|
-
ORGANISATION: (organisationId: UUID) =>
|
|
244
|
-
EVENT: (eventId: string) =>
|
|
245
|
-
APP: (appId: UUID) =>
|
|
246
|
-
PERMISSION: (userId: UUID, organisationId: UUID) => `perm:${userId}:${organisationId}
|
|
262
|
+
USER: (userId: UUID) => `:${userId}:`,
|
|
263
|
+
ORGANISATION: (organisationId: UUID) => `:${organisationId}:`,
|
|
264
|
+
EVENT: (eventId: string) => `:${eventId}:`,
|
|
265
|
+
APP: (appId: UUID) => `:${appId}`,
|
|
266
|
+
PERMISSION: (userId: UUID, organisationId: UUID) => `perm:${userId}:${organisationId}:`,
|
|
247
267
|
} as const;
|
|
@@ -74,7 +74,7 @@ const mockNavigationItems: NavigationItem[] = [
|
|
|
74
74
|
id: 'admin',
|
|
75
75
|
label: 'Admin',
|
|
76
76
|
path: '/admin',
|
|
77
|
-
permissions: ['
|
|
77
|
+
permissions: ['update:admin'] as Permission[],
|
|
78
78
|
pageId: 'page-admin',
|
|
79
79
|
accessLevel: 'admin',
|
|
80
80
|
meta: {
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
*/
|
|
66
66
|
|
|
67
67
|
import React, { useMemo, useCallback, useEffect, useState } from 'react';
|
|
68
|
-
import {
|
|
68
|
+
import { useMultiplePermissions } from '../hooks/usePermissions';
|
|
69
69
|
import { useUnifiedAuth } from '../../providers/UnifiedAuthProvider';
|
|
70
70
|
import { UUID, Permission, Scope } from '../types';
|
|
71
71
|
import { createScopeFromEvent } from '../utils/eventContext';
|
|
@@ -176,26 +176,26 @@ export function NavigationGuard({
|
|
|
176
176
|
resolveScope();
|
|
177
177
|
}, [scope, selectedOrganisation, selectedEvent, supabase]);
|
|
178
178
|
|
|
179
|
-
// Check permissions using
|
|
180
|
-
|
|
181
|
-
const representativePermission = navigationItem.permissions[0];
|
|
182
|
-
const { can, isLoading, error } = useCan(
|
|
179
|
+
// Check all permissions using useMultiplePermissions hook
|
|
180
|
+
const { results: permissionResults, isLoading, error } = useMultiplePermissions(
|
|
183
181
|
user?.id || '',
|
|
184
182
|
resolvedScope || { eventId: selectedEvent?.event_id || undefined },
|
|
185
|
-
|
|
186
|
-
navigationItem.pageId,
|
|
183
|
+
navigationItem.permissions || [],
|
|
187
184
|
true // Use cache
|
|
188
185
|
);
|
|
189
186
|
|
|
190
|
-
// Determine if user has required permissions
|
|
187
|
+
// Determine if user has required permissions based on requireAll prop
|
|
191
188
|
const hasRequiredPermissions = useMemo((): boolean => {
|
|
192
|
-
if (navigationItem.permissions.length === 0) return true;
|
|
189
|
+
if (!navigationItem.permissions || navigationItem.permissions.length === 0) return true;
|
|
193
190
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
191
|
+
if (requireAll) {
|
|
192
|
+
// User must have ALL permissions
|
|
193
|
+
return Object.values(permissionResults).every(result => result === true);
|
|
194
|
+
} else {
|
|
195
|
+
// User must have ANY permission (default behavior)
|
|
196
|
+
return Object.values(permissionResults).some(result => result === true);
|
|
197
|
+
}
|
|
198
|
+
}, [navigationItem.permissions, permissionResults, requireAll]);
|
|
199
199
|
|
|
200
200
|
// Handle permission check completion
|
|
201
201
|
useEffect(() => {
|
|
@@ -148,41 +148,6 @@ const PagePermissionGuardComponent = ({
|
|
|
148
148
|
const supabaseRef = useRef(supabase);
|
|
149
149
|
supabaseRef.current = supabase;
|
|
150
150
|
|
|
151
|
-
// Track the last scope we called useCan with to prevent infinite loops
|
|
152
|
-
const lastScopeRef = useRef<string | null>(null);
|
|
153
|
-
|
|
154
|
-
// Use a ref to store the stable scope and only update it when it actually changes
|
|
155
|
-
const stableScopeRef = useRef<{ organisationId: string; appId: string; eventId: string | undefined }>({
|
|
156
|
-
organisationId: '',
|
|
157
|
-
appId: '',
|
|
158
|
-
eventId: undefined
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
// Only update the stable scope if the resolved scope has actually changed
|
|
162
|
-
if (resolvedScope && resolvedScope.organisationId) {
|
|
163
|
-
const newScope = {
|
|
164
|
-
organisationId: resolvedScope.organisationId,
|
|
165
|
-
appId: resolvedScope.appId,
|
|
166
|
-
eventId: resolvedScope.eventId
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
// Only update if the scope has actually changed
|
|
170
|
-
if (stableScopeRef.current.organisationId !== newScope.organisationId ||
|
|
171
|
-
stableScopeRef.current.eventId !== newScope.eventId ||
|
|
172
|
-
stableScopeRef.current.appId !== newScope.appId) {
|
|
173
|
-
stableScopeRef.current = {
|
|
174
|
-
organisationId: newScope.organisationId,
|
|
175
|
-
appId: newScope.appId || '',
|
|
176
|
-
eventId: newScope.eventId
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
} else if (!resolvedScope) {
|
|
180
|
-
// Reset to empty scope when no resolved scope
|
|
181
|
-
stableScopeRef.current = { organisationId: '', appId: '', eventId: undefined };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const stableScope = stableScopeRef.current;
|
|
185
|
-
|
|
186
151
|
// Resolve scope - either use provided scope or resolve from context
|
|
187
152
|
useEffect(() => {
|
|
188
153
|
const abortController = new AbortController();
|
|
@@ -412,9 +377,23 @@ const PagePermissionGuardComponent = ({
|
|
|
412
377
|
return `${operation}:page.${pageName}` as Permission;
|
|
413
378
|
}, [operation, pageName]);
|
|
414
379
|
|
|
380
|
+
// Create a stable scope that only includes valid values
|
|
381
|
+
// OrganisationId is required - use undefined if not available, useCan will handle loading state
|
|
382
|
+
const stableScope = useMemo(() => {
|
|
383
|
+
if (resolvedScope && resolvedScope.organisationId) {
|
|
384
|
+
return {
|
|
385
|
+
organisationId: resolvedScope.organisationId,
|
|
386
|
+
appId: resolvedScope.appId || undefined,
|
|
387
|
+
eventId: resolvedScope.eventId || undefined
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
// Return scope without organisationId - useCan will keep loading state until resolved
|
|
391
|
+
// Scope.organisationId is optional, so undefined is valid
|
|
392
|
+
return { organisationId: undefined, appId: undefined, eventId: undefined };
|
|
393
|
+
}, [resolvedScope]);
|
|
415
394
|
|
|
416
|
-
// Check if user has permission - only call useCan when we have a resolved scope
|
|
417
|
-
// If resolvedScope is null
|
|
395
|
+
// Check if user has permission - only call useCan when we have a resolved scope with valid organisationId
|
|
396
|
+
// If resolvedScope is null or has no organisationId, useCan will keep isLoading=true
|
|
418
397
|
const { can, isLoading: canIsLoading, error: canError } = useCan(
|
|
419
398
|
user?.id || '',
|
|
420
399
|
stableScope,
|
|
@@ -464,12 +443,17 @@ const PagePermissionGuardComponent = ({
|
|
|
464
443
|
console.error(`[PagePermissionGuard] STRICT MODE VIOLATION: User attempted to access protected page without permission`, {
|
|
465
444
|
pageName,
|
|
466
445
|
operation,
|
|
446
|
+
permission: `${operation}:page.${pageName}`,
|
|
447
|
+
pageId: effectivePageId,
|
|
467
448
|
userId: user?.id,
|
|
468
449
|
scope: resolvedScope,
|
|
450
|
+
scopeValid: resolvedScope && resolvedScope.organisationId ? true : false,
|
|
451
|
+
checkError,
|
|
452
|
+
canError,
|
|
469
453
|
timestamp: new Date().toISOString()
|
|
470
454
|
});
|
|
471
455
|
}
|
|
472
|
-
}, [strictMode, hasChecked, isLoading, can, pageName, operation, user?.id, resolvedScope]);
|
|
456
|
+
}, [strictMode, hasChecked, isLoading, can, pageName, operation, effectivePageId, user?.id, resolvedScope, checkError, canError]);
|
|
473
457
|
|
|
474
458
|
// Calculate the actual render state - FIXED: Proper state calculation
|
|
475
459
|
// Add defensive checks to ensure we have valid state
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* ```tsx
|
|
20
20
|
* // Basic permission enforcement
|
|
21
21
|
* <PermissionEnforcer
|
|
22
|
-
* permissions={['read:events', '
|
|
22
|
+
* permissions={['read:events', 'update:events']}
|
|
23
23
|
* operation="event-management"
|
|
24
24
|
* fallback={<AccessDeniedPage />}
|
|
25
25
|
* >
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
*/
|
|
68
68
|
|
|
69
69
|
import React, { useMemo, useCallback, useEffect, useState } from 'react';
|
|
70
|
-
import {
|
|
70
|
+
import { useMultiplePermissions } from '../hooks/usePermissions';
|
|
71
71
|
import { useUnifiedAuth } from '../../providers/UnifiedAuthProvider';
|
|
72
72
|
import { UUID, Permission, Scope } from '../types';
|
|
73
73
|
import { createScopeFromEvent } from '../utils/eventContext';
|
|
@@ -129,7 +129,6 @@ export function PermissionEnforcer({
|
|
|
129
129
|
const { user, selectedOrganisation, selectedEvent, supabase } = useUnifiedAuth();
|
|
130
130
|
const [hasChecked, setHasChecked] = useState(false);
|
|
131
131
|
const [checkError, setCheckError] = useState<Error | null>(null);
|
|
132
|
-
const [permissionResults, setPermissionResults] = useState<Record<string, boolean>>({});
|
|
133
132
|
const [resolvedScope, setResolvedScope] = useState<Scope | null>(null);
|
|
134
133
|
|
|
135
134
|
// Resolve scope - either use provided scope or resolve from context
|
|
@@ -182,26 +181,31 @@ export function PermissionEnforcer({
|
|
|
182
181
|
resolveScope();
|
|
183
182
|
}, [scope, selectedOrganisation, selectedEvent, supabase]);
|
|
184
183
|
|
|
185
|
-
// Check permissions using
|
|
186
|
-
|
|
187
|
-
const representativePermission = permissions[0];
|
|
188
|
-
const { can, isLoading, error } = useCan(
|
|
184
|
+
// Check all permissions using useMultiplePermissions hook
|
|
185
|
+
const { results: permissionResults, isLoading, error } = useMultiplePermissions(
|
|
189
186
|
user?.id || '',
|
|
190
187
|
resolvedScope || { eventId: selectedEvent?.event_id || undefined },
|
|
191
|
-
|
|
192
|
-
undefined,
|
|
188
|
+
permissions,
|
|
193
189
|
true // Use cache
|
|
194
190
|
);
|
|
195
191
|
|
|
196
|
-
// Determine if user has required permissions
|
|
192
|
+
// Determine if user has required permissions based on requireAll prop
|
|
197
193
|
const hasRequiredPermissions = useMemo((): boolean => {
|
|
198
194
|
if (permissions.length === 0) return true;
|
|
199
195
|
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
196
|
+
// If permissionResults is not yet available or empty, deny access
|
|
197
|
+
if (!permissionResults || Object.keys(permissionResults).length === 0) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (requireAll) {
|
|
202
|
+
// User must have ALL permissions
|
|
203
|
+
return Object.values(permissionResults).every(result => result === true);
|
|
204
|
+
} else {
|
|
205
|
+
// User must have ANY permission (default behavior)
|
|
206
|
+
return Object.values(permissionResults).some(result => result === true);
|
|
207
|
+
}
|
|
208
|
+
}, [permissions, permissionResults, requireAll]);
|
|
205
209
|
|
|
206
210
|
// Handle permission check completion
|
|
207
211
|
useEffect(() => {
|
|
@@ -79,6 +79,9 @@ export interface RouteConfig {
|
|
|
79
79
|
/** Permissions required for this route */
|
|
80
80
|
permissions: Permission[];
|
|
81
81
|
|
|
82
|
+
/** If true, this route is public and doesn't require permission checks */
|
|
83
|
+
public?: boolean;
|
|
84
|
+
|
|
82
85
|
/** Roles that can access this route */
|
|
83
86
|
roles?: string[];
|
|
84
87
|
|
|
@@ -232,10 +235,13 @@ export function RoleBasedRouter({
|
|
|
232
235
|
currentRouteConfig?.pageId
|
|
233
236
|
);
|
|
234
237
|
|
|
235
|
-
//
|
|
238
|
+
// Check if route is public
|
|
239
|
+
const isPublicRoute = currentRouteConfig?.public === true;
|
|
240
|
+
|
|
241
|
+
// If route has no permissions and is not public, deny access (secure by default)
|
|
236
242
|
const hasPermissions = currentRouteConfig?.permissions && currentRouteConfig.permissions.length > 0;
|
|
237
|
-
const finalCanAccess = hasPermissions ? canAccessCurrentRoute : false;
|
|
238
|
-
const finalLoading = hasPermissions ? permissionLoading : false;
|
|
243
|
+
const finalCanAccess = isPublicRoute ? true : (hasPermissions ? canAccessCurrentRoute : false);
|
|
244
|
+
const finalLoading = isPublicRoute ? false : (hasPermissions ? permissionLoading : false);
|
|
239
245
|
|
|
240
246
|
// Get all accessible routes for current user
|
|
241
247
|
const getAccessibleRoutes = useCallback((): RouteConfig[] => {
|
|
@@ -323,13 +329,14 @@ export function RoleBasedRouter({
|
|
|
323
329
|
|
|
324
330
|
// Use the actual permission check result
|
|
325
331
|
const allowed = finalCanAccess;
|
|
332
|
+
// Log route access (including public routes for audit monitoring)
|
|
326
333
|
recordRouteAccess(currentPath, allowed, currentRouteConfig);
|
|
327
334
|
|
|
328
|
-
if (!allowed) {
|
|
329
|
-
// Redirect to fallback route
|
|
335
|
+
if (!allowed && !isPublicRoute) {
|
|
336
|
+
// Redirect to fallback route (skip for public routes)
|
|
330
337
|
navigate(fallbackRoute, { replace: true });
|
|
331
338
|
}
|
|
332
|
-
}, [location.pathname, currentRouteConfig, canAccessCurrentRoute, recordRouteAccess, strictMode, user?.id, currentScope, onStrictModeViolation, navigate, fallbackRoute]);
|
|
339
|
+
}, [location.pathname, currentRouteConfig, canAccessCurrentRoute, recordRouteAccess, strictMode, user?.id, currentScope, onStrictModeViolation, navigate, fallbackRoute, isPublicRoute]);
|
|
333
340
|
|
|
334
341
|
// Context value
|
|
335
342
|
const contextValue = useMemo((): RoleBasedRouterContextType => ({
|
|
@@ -350,8 +357,8 @@ export function RoleBasedRouter({
|
|
|
350
357
|
auditLog
|
|
351
358
|
]);
|
|
352
359
|
|
|
353
|
-
// Show loading state while checking permissions
|
|
354
|
-
if (finalLoading) {
|
|
360
|
+
// Show loading state while checking permissions (skip for public routes)
|
|
361
|
+
if (finalLoading && !isPublicRoute) {
|
|
355
362
|
return (
|
|
356
363
|
<div className="flex items-center justify-center min-h-screen">
|
|
357
364
|
<div className="text-center">
|
|
@@ -363,7 +370,7 @@ export function RoleBasedRouter({
|
|
|
363
370
|
}
|
|
364
371
|
|
|
365
372
|
// Show unauthorized component if user can't access current route
|
|
366
|
-
if (currentRouteConfig && !finalCanAccess) {
|
|
373
|
+
if (currentRouteConfig && !finalCanAccess && !isPublicRoute) {
|
|
367
374
|
return (
|
|
368
375
|
<UnauthorizedComponent
|
|
369
376
|
route={currentRoute}
|