Files
neo/backend/src/rbac/ability.factory.ts
2026-01-05 07:48:22 +01:00

199 lines
6.4 KiB
TypeScript

import { AbilityBuilder, PureAbility, AbilityClass } from '@casl/ability';
import { Injectable } from '@nestjs/common';
import { User } from '../models/user.model';
import { RoleObjectPermission } from '../models/role-object-permission.model';
import { RoleFieldPermission } from '../models/role-field-permission.model';
import { RecordShare } from '../models/record-share.model';
// Define action types
export type Action = 'create' | 'read' | 'update' | 'delete' | 'view_all' | 'modify_all';
// Define subject types - can be string (object API name) or actual object with fields
export type Subject = string | { objectApiName: string; ownerId?: string; id?: string; [key: string]: any };
// Define field actions
export type FieldAction = 'read' | 'edit';
export type AppAbility = PureAbility<[Action, Subject], { field?: string }>;
@Injectable()
export class AbilityFactory {
/**
* Build CASL ability for a user based on their roles and permissions
* This aggregates permissions from all roles the user has
*/
async defineAbilityFor(
user: User & { roles?: Array<{ objectPermissions?: RoleObjectPermission[]; fieldPermissions?: RoleFieldPermission[] }> },
recordShares?: RecordShare[],
): Promise<AppAbility> {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(PureAbility as AbilityClass<AppAbility>);
if (!user.roles || user.roles.length === 0) {
// No roles = no permissions
return build();
}
// Aggregate object permissions from all roles
const objectPermissionsMap = new Map<string, {
canCreate: boolean;
canRead: boolean;
canEdit: boolean;
canDelete: boolean;
canViewAll: boolean;
canModifyAll: boolean;
}>();
// Aggregate field permissions from all roles
const fieldPermissionsMap = new Map<string, {
canRead: boolean;
canEdit: boolean;
}>();
// Process all roles
for (const role of user.roles) {
// Aggregate object permissions
if (role.objectPermissions) {
for (const perm of role.objectPermissions) {
const existing = objectPermissionsMap.get(perm.objectDefinitionId) || {
canCreate: false,
canRead: false,
canEdit: false,
canDelete: false,
canViewAll: false,
canModifyAll: false,
};
// Union of permissions (if any role grants it, user has it)
objectPermissionsMap.set(perm.objectDefinitionId, {
canCreate: existing.canCreate || perm.canCreate,
canRead: existing.canRead || perm.canRead,
canEdit: existing.canEdit || perm.canEdit,
canDelete: existing.canDelete || perm.canDelete,
canViewAll: existing.canViewAll || perm.canViewAll,
canModifyAll: existing.canModifyAll || perm.canModifyAll,
});
}
}
// Aggregate field permissions
if (role.fieldPermissions) {
for (const perm of role.fieldPermissions) {
const existing = fieldPermissionsMap.get(perm.fieldDefinitionId) || {
canRead: false,
canEdit: false,
};
fieldPermissionsMap.set(perm.fieldDefinitionId, {
canRead: existing.canRead || perm.canRead,
canEdit: existing.canEdit || perm.canEdit,
});
}
}
}
// Convert aggregated permissions to CASL rules
for (const [objectId, perms] of objectPermissionsMap) {
// Create permission
if (perms.canCreate) {
can('create', objectId);
}
// Read permission
if (perms.canRead) {
can('read', objectId);
}
// View all permission (can see all records regardless of ownership)
if (perms.canViewAll) {
can('view_all', objectId);
}
// Edit permission
if (perms.canEdit) {
can('update', objectId);
}
// Modify all permission (can edit all records regardless of ownership)
if (perms.canModifyAll) {
can('modify_all', objectId);
}
// Delete permission
if (perms.canDelete) {
can('delete', objectId);
}
}
// Add record sharing permissions
if (recordShares) {
for (const share of recordShares) {
// Only add if share is active (not expired, not revoked)
const now = new Date();
const isExpired = share.expiresAt && share.expiresAt < now;
const isRevoked = share.revokedAt !== null;
if (!isExpired && !isRevoked) {
// Note: Record-level sharing will be checked in authorization service
// CASL abilities are primarily for object-level permissions
// Individual record access is validated in applyScopeToQuery
}
}
}
return build();
}
/**
* Check if user can access a specific field
* Returns true if user has permission or if no restriction exists
*/
canAccessField(
fieldDefinitionId: string,
action: FieldAction,
user: User & { roles?: Array<{ fieldPermissions?: RoleFieldPermission[] }> },
): boolean {
if (!user.roles || user.roles.length === 0) {
return false;
}
// Collect all field permissions from all roles
const allFieldPermissions: RoleFieldPermission[] = [];
for (const role of user.roles) {
if (role.fieldPermissions) {
allFieldPermissions.push(...role.fieldPermissions);
}
}
// If there are NO field permissions configured at all, allow by default
if (allFieldPermissions.length === 0) {
return true;
}
// If field permissions exist, check for explicit grants (union of all roles)
for (const role of user.roles) {
if (role.fieldPermissions) {
const fieldPerm = role.fieldPermissions.find(fp => fp.fieldDefinitionId === fieldDefinitionId);
if (fieldPerm) {
if (action === 'read' && fieldPerm.canRead) return true;
if (action === 'edit' && fieldPerm.canEdit) return true;
}
}
}
// Field permissions exist but this field is not explicitly granted → deny
return false;
}
/**
* Filter fields based on user permissions
* Returns array of field IDs the user can access with the specified action
*/
filterFields(
fieldDefinitionIds: string[],
action: FieldAction,
user: User & { roles?: Array<{ fieldPermissions?: RoleFieldPermission[] }> },
): string[] {
return fieldDefinitionIds.filter(fieldId => this.canAccessField(fieldId, action, user));
}
}