import { Injectable, ForbiddenException } from '@nestjs/common'; import { Knex } from 'knex'; import { User } from '../models/user.model'; import { ObjectDefinition } from '../models/object-definition.model'; import { FieldDefinition } from '../models/field-definition.model'; import { RecordShare } from '../models/record-share.model'; import { AbilityFactory, AppAbility, Action } from './ability.factory'; import { DynamicModelFactory } from '../object/models/dynamic-model.factory'; import { subject } from '@casl/ability'; @Injectable() export class AuthorizationService { constructor(private abilityFactory: AbilityFactory) {} /** * Apply authorization scope to a query based on OWD and user permissions * This determines which records the user can see * Modifies the query in place and returns void */ async applyScopeToQuery( query: any, // Accept both Knex and Objection query builders objectDef: ObjectDefinition, user: User & { roles?: any[] }, action: Action, knex: Knex, ): Promise { // Get user's ability const recordShares = await this.getActiveRecordShares(objectDef.id, user.id, knex); const ability = await this.abilityFactory.defineAbilityFor(user, recordShares); // Check if user has the base permission for this action // Use object ID, not API name, since permissions are stored by object ID if (!ability.can(action, objectDef.id)) { // No permission at all - return empty result query.where(knex.raw('1 = 0')); return; } // Check special permissions const hasViewAll = ability.can('view_all', objectDef.id); const hasModifyAll = ability.can('modify_all', objectDef.id); // If user has view_all or modify_all, they can see all records if (hasViewAll || hasModifyAll) { // No filtering needed return; } // Apply OWD (Org-Wide Default) restrictions switch (objectDef.orgWideDefault) { case 'public_read_write': // Everyone can see all records return; case 'public_read': // Everyone can see all records (write operations checked separately) return; case 'private': default: // Only owner and explicitly shared records await this.applyPrivateScope(query, objectDef, user, recordShares, knex); return; } } /** * Apply private scope: owner + shared records */ private async applyPrivateScope( query: any, // Accept both Knex and Objection query builders objectDef: ObjectDefinition, user: User, recordShares: RecordShare[], knex: Knex, ): Promise { const tableName = this.getTableName(objectDef.apiName); // Check if table has ownerId column const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); if (!hasOwner && recordShares.length === 0) { // No ownership and no shares - user can't see anything query.where(knex.raw('1 = 0')); return; } // Build conditions: ownerId = user OR record shared with user query.where((builder) => { if (hasOwner) { builder.orWhere(`${tableName}.ownerId`, user.id); } if (recordShares.length > 0) { const sharedRecordIds = recordShares.map(share => share.recordId); builder.orWhereIn(`${tableName}.id`, sharedRecordIds); } }); } /** * Check if user can perform action on a specific record */ async canPerformAction( action: Action, objectDef: ObjectDefinition, record: any, user: User & { roles?: any[] }, knex: Knex, ): Promise { const recordShares = await this.getActiveRecordShares(objectDef.id, user.id, knex); const ability = await this.abilityFactory.defineAbilityFor(user, recordShares); // Check base permission - use object ID not API name if (!ability.can(action, objectDef.id)) { return false; } // Check special permissions - use object ID not API name const hasViewAll = ability.can('view_all', objectDef.id); const hasModifyAll = ability.can('modify_all', objectDef.id); // canViewAll only grants read access to all records if (action === 'read' && hasViewAll) { return true; } // canModifyAll grants edit/delete access to all records if ((action === 'update' || action === 'delete') && hasModifyAll) { return true; } // Check OWD switch (objectDef.orgWideDefault) { case 'public_read_write': return true; case 'public_read': if (action === 'read') return true; // For write actions, check ownership return record.ownerId === user.id; case 'private': default: // Check ownership if (record.ownerId === user.id) return true; // Check if record is shared with user const share = recordShares.find(s => s.recordId === record.id); if (share) { if (action === 'read' && share.accessLevel.canRead) return true; if (action === 'update' && share.accessLevel.canEdit) return true; if (action === 'delete' && share.accessLevel.canDelete) return true; } return false; } } /** * Filter data based on field-level permissions * Removes fields the user cannot read */ async filterReadableFields( data: any, fields: FieldDefinition[], user: User & { roles?: any[] }, ): Promise { const filtered: any = {}; // Always include id - it's required for navigation and record identification if (data.id !== undefined) { filtered.id = data.id; } for (const field of fields) { if (this.abilityFactory.canAccessField(field.id, 'read', user)) { if (data[field.apiName] !== undefined) { filtered[field.apiName] = data[field.apiName]; } // For lookup fields, also include the related object (e.g., ownerId -> owner) if (field.type === 'LOOKUP') { const relationName = DynamicModelFactory.getRelationName(field.apiName); if (data[relationName] !== undefined) { filtered[relationName] = data[relationName]; } } } } return filtered; } /** * Filter data based on field-level permissions * Removes fields the user cannot edit */ async filterEditableFields( data: any, fields: FieldDefinition[], user: User & { roles?: any[] }, ): Promise { const filtered: any = {}; for (const field of fields) { if (this.abilityFactory.canAccessField(field.id, 'edit', user)) { if (data[field.apiName] !== undefined) { filtered[field.apiName] = data[field.apiName]; } } } return filtered; } /** * Get active record shares for a user on an object */ private async getActiveRecordShares( objectDefinitionId: string, userId: string, knex: Knex, ): Promise { const now = new Date(); return await RecordShare.query(knex) .where('objectDefinitionId', objectDefinitionId) .where('granteeUserId', userId) .whereNull('revokedAt') .where((builder) => { builder.whereNull('expiresAt').orWhere('expiresAt', '>', now); }); } /** * Check if user has permission to create records */ async canCreate( objectDef: ObjectDefinition, user: User & { roles?: any[] }, ): Promise { const ability = await this.abilityFactory.defineAbilityFor(user, []); return ability.can('create', objectDef.id); } /** * Throw exception if user cannot perform action */ async assertCanPerformAction( action: Action, objectDef: ObjectDefinition, record: any, user: User & { roles?: any[] }, knex: Knex, ): Promise { const can = await this.canPerformAction(action, objectDef, record, user, knex); if (!can) { throw new ForbiddenException(`You do not have permission to ${action} this record`); } } /** * Get table name from API name */ private getTableName(apiName: string): string { // Convert CamelCase to snake_case and pluralize const snakeCase = apiName .replace(/([A-Z])/g, '_$1') .toLowerCase() .replace(/^_/, ''); // Simple pluralization if (snakeCase.endsWith('y')) { return snakeCase.slice(0, -1) + 'ies'; } else if (snakeCase.endsWith('s')) { return snakeCase; } else { return snakeCase + 's'; } } }