199 lines
6.4 KiB
TypeScript
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));
|
|
}
|
|
}
|