283 lines
8.3 KiB
TypeScript
283 lines
8.3 KiB
TypeScript
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<T = any>(
|
|
query: any, // Accept both Knex and Objection query builders
|
|
objectDef: ObjectDefinition,
|
|
user: User & { roles?: any[] },
|
|
action: Action,
|
|
knex: Knex,
|
|
): Promise<void> {
|
|
// 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<T = any>(
|
|
query: any, // Accept both Knex and Objection query builders
|
|
objectDef: ObjectDefinition,
|
|
user: User,
|
|
recordShares: RecordShare[],
|
|
knex: Knex,
|
|
): Promise<void> {
|
|
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<boolean> {
|
|
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<any> {
|
|
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<any> {
|
|
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<RecordShare[]> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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';
|
|
}
|
|
}
|
|
}
|