WIP - better handling of viewAll modifyAll

This commit is contained in:
Francisco Gaona
2025-12-30 04:43:51 +01:00
parent d37183ba45
commit 9ac69e30d0
3 changed files with 104 additions and 211 deletions

View File

@@ -143,15 +143,15 @@ export class DynamicModelFactory {
this.id = randomUUID(); this.id = randomUUID();
} }
if (!this.created_at) { if (!this.created_at) {
this.created_at = new Date().toISOString(); this.created_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
} }
if (!this.updated_at) { if (!this.updated_at) {
this.updated_at = new Date().toISOString(); this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
} }
} }
async $beforeUpdate() { async $beforeUpdate() {
this.updated_at = new Date().toISOString(); this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
} }
} }

View File

@@ -468,73 +468,46 @@ export class ObjectService {
const tableName = this.getTableName(objectApiName); const tableName = this.getTableName(objectApiName);
// Ensure model is registered before attempting to use it // Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName); await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available // Use Objection model
let records = []; const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
try { let query = boundModel.query();
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
if (Model) {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query();
// Apply authorization scope (modifies query in place) // Apply authorization scope (modifies query in place)
await this.authService.applyScopeToQuery( await this.authService.applyScopeToQuery(
query, query,
objectDefModel, objectDefModel,
user, user,
'read', 'read',
knex, knex,
); );
// Build graph expression for lookup fields // Build graph expression for lookup fields
const lookupFields = objectDefModel.fields?.filter(f => const lookupFields = objectDefModel.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject f.type === 'LOOKUP' && f.referenceObject
) || []; ) || [];
if (lookupFields.length > 0) { if (lookupFields.length > 0) {
// Build relation expression - use singular lowercase for relation name // Build relation expression - use singular lowercase for relation name
const relationExpression = lookupFields const relationExpression = lookupFields
.map(f => f.apiName.replace(/Id$/, '').toLowerCase()) .map(f => f.apiName.replace(/Id$/, '').toLowerCase())
.filter(Boolean) .filter(Boolean)
.join(', '); .join(', ');
if (relationExpression) { if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`); query = query.withGraphFetched(`[${relationExpression}]`);
}
}
// Apply additional filters
if (filters) {
query = query.where(filters);
}
records = await query.select('*');
} }
} catch (error) {
this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual query: ${error.message}`);
// Fallback to Knex query with authorization
let query = knex(tableName);
// Apply additional filters before authorization scope
if (filters) {
query = query.where(filters);
}
// Apply authorization scope (modifies query in place)
await this.authService.applyScopeToQuery(
query,
objectDefModel,
user,
'read',
knex,
);
records = await query.select('*');
} }
// Apply additional filters
if (filters) {
query = query.where(filters);
}
const records = await query.select('*');
// Filter fields based on field-level permissions // Filter fields based on field-level permissions
const filteredRecords = await Promise.all( const filteredRecords = await Promise.all(
records.map(record => records.map(record =>
@@ -554,93 +527,62 @@ export class ObjectService {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Verify object exists and get field definitions // Get user with roles and permissions
const objectDef = await this.getObjectDefinition(tenantId, objectApiName); const user = await User.query(knex)
.findById(userId)
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
const tableName = this.getTableName(objectApiName); if (!user) {
throw new NotFoundException('User not found');
}
// Ensure model is registered before attempting to use it // Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName })
.withGraphFetched('fields');
if (!objectDefModel) {
throw new NotFoundException(`Object ${objectApiName} not found`);
}
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName); await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available // Use Objection model
try { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
const Model = this.modelService.getModel(resolvedTenantId, objectApiName); let query = boundModel.query().where({ id: recordId });
if (Model) {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query().where({ id: recordId });
// Build graph expression for lookup fields // Apply authorization scope (modifies query in place)
const lookupFields = objectDef.fields?.filter(f => await this.authService.applyScopeToQuery(
f.type === 'LOOKUP' && f.referenceObject query,
) || []; objectDefModel,
user,
'read',
knex,
);
if (lookupFields.length > 0) { // Build graph expression for lookup fields
// Build relation expression - use singular lowercase for relation name const lookupFields = objectDefModel.fields?.filter(f =>
const relationExpression = lookupFields
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
.filter(Boolean)
.join(', ');
if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`);
}
}
// Add ownership filter if ownerId field exists
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) {
query = query.where({ ownerId: userId });
}
const record = await query.first();
if (!record) {
throw new NotFoundException('Record not found');
}
return record;
}
} catch (error) {
this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`);
}
// Fallback to manual data hydration
let query = knex(tableName).where({ [`${tableName}.id`]: recordId });
// Add ownership filter if ownerId field exists
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) {
query = query.where({ [`${tableName}.ownerId`]: userId });
}
const record = await query.first();
if (!record) {
throw new NotFoundException('Record not found');
}
// Fetch and attach related records for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject f.type === 'LOOKUP' && f.referenceObject
) || []; ) || [];
if (lookupFields.length > 0) { if (lookupFields.length > 0) {
for (const field of lookupFields) { // Build relation expression - use singular lowercase for relation name
const relationName = field.apiName.replace(/Id$/, '').toLowerCase(); const relationExpression = lookupFields
const relatedTable = this.getTableName(field.referenceObject); .map(f => f.apiName.replace(/Id$/, '').toLowerCase())
const relatedId = record[field.apiName]; .filter(Boolean)
.join(', ');
if (relatedId) { if (relationExpression) {
// Fetch the related record query = query.withGraphFetched(`[${relationExpression}]`);
const relatedRecord = await knex(relatedTable)
.where({ id: relatedId })
.first();
if (relatedRecord) {
record[relationName] = relatedRecord;
}
}
} }
} }
const record = await query.first();
if (!record) {
throw new NotFoundException('Record not found');
}
return record; return record;
} }
@@ -680,47 +622,17 @@ export class ObjectService {
// Filter data to only editable fields // Filter data to only editable fields
const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user); const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user);
// Ensure model is registered before attempting to use it // Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName); await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available // Use Objection model
try { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
const Model = this.modelService.getModel(resolvedTenantId, objectApiName); const recordData = {
if (Model) {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
const recordData = {
...editableData,
ownerId: userId, // Auto-set owner
};
const record = await boundModel.query().insert(recordData);
return record;
}
} catch (error) {
console.warn(`Could not use Objection model for ${objectApiName}:`, error.message);
}
// Fallback to raw Knex if model not available
const tableName = this.getTableName(objectApiName);
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
// Generate UUID for the record
const result = await knex.raw('SELECT UUID() as uuid');
const uuid = result[0][0].uuid;
const recordData: any = {
id: uuid,
...editableData, ...editableData,
created_at: knex.fn.now(), ownerId: userId, // Auto-set owner
updated_at: knex.fn.now(),
}; };
const record = await boundModel.query().insert(recordData);
if (hasOwner) { return record;
recordData.ownerId = userId;
}
await knex(tableName).insert(recordData);
return knex(tableName).where({ id: uuid }).first();
} }
async updateRecord( async updateRecord(
@@ -771,27 +683,13 @@ export class ObjectService {
delete editableData.created_at; delete editableData.created_at;
delete editableData.tenantId; delete editableData.tenantId;
// Ensure model is registered before attempting to use it // Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName); await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available // Use Objection model
try { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
const Model = this.modelService.getModel(resolvedTenantId, objectApiName); await boundModel.query().where({ id: recordId }).update(editableData);
if (Model) { return boundModel.query().where({ id: recordId }).first();
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
await boundModel.query().where({ id: recordId }).update(editableData);
return boundModel.query().where({ id: recordId }).first();
}
} catch (error) {
console.warn(`Could not use Objection model for ${objectApiName}:`, error.message);
}
// Fallback to raw Knex
await knex(tableName)
.where({ id: recordId })
.update({ ...editableData, updated_at: knex.fn.now() });
return knex(tableName).where({ id: recordId }).first();
} }
async deleteRecord( async deleteRecord(
@@ -831,23 +729,12 @@ export class ObjectService {
// Check if user can delete this record // Check if user can delete this record
await this.authService.assertCanPerformAction('delete', objectDefModel, existingRecord, user, knex); await this.authService.assertCanPerformAction('delete', objectDefModel, existingRecord, user, knex);
// Ensure model is registered before attempting to use it // Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName); await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available // Use Objection model
try { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
const Model = this.modelService.getModel(resolvedTenantId, objectApiName); await boundModel.query().where({ id: recordId }).delete();
if (Model) {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
await boundModel.query().where({ id: recordId }).delete();
return { success: true };
}
} catch (error) {
console.warn(`Could not use Objection model for ${objectApiName}:`, error.message);
}
// Fallback to raw Knex
await knex(tableName).where({ id: recordId }).delete();
return { success: true }; return { success: true };
} }

View File

@@ -120,7 +120,13 @@ export class AuthorizationService {
const hasViewAll = ability.can('view_all', objectDef.id); const hasViewAll = ability.can('view_all', objectDef.id);
const hasModifyAll = ability.can('modify_all', objectDef.id); const hasModifyAll = ability.can('modify_all', objectDef.id);
if (hasViewAll || hasModifyAll) { // 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; return true;
} }