WIP - more progress with permissions
This commit is contained in:
@@ -49,7 +49,9 @@ export class DynamicModelFactory {
|
||||
updated_at: { type: 'string', format: 'date-time' },
|
||||
};
|
||||
|
||||
const required: string[] = ['id', 'tenantId'];
|
||||
// Don't require system-managed fields (id, tenantId, ownerId, timestamps)
|
||||
// These are auto-set by hooks or database
|
||||
const required: string[] = [];
|
||||
|
||||
// Add custom fields
|
||||
for (const field of fields) {
|
||||
@@ -134,15 +136,16 @@ export class DynamicModelFactory {
|
||||
this.id = randomUUID();
|
||||
}
|
||||
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) {
|
||||
this.updated_at = new Date().toISOString();
|
||||
this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
async $beforeUpdate() {
|
||||
this.updated_at = new Date().toISOString();
|
||||
async $beforeUpdate(opt: any, queryContext: any) {
|
||||
await super.$beforeUpdate(opt, queryContext);
|
||||
this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||
import { CustomMigrationService } from '../migration/custom-migration.service';
|
||||
import { ModelService } from './models/model.service';
|
||||
@@ -350,6 +350,53 @@ export class ObjectService {
|
||||
return typeMap[frontendType] || 'TEXT';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter incoming data to only include writable fields based on field definitions
|
||||
* Removes system fields and fields that don't exist in the schema
|
||||
*/
|
||||
private async filterWritableFields(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
data: any,
|
||||
isUpdate: boolean = false,
|
||||
): Promise<any> {
|
||||
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
|
||||
const filtered: any = {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// Find the field definition
|
||||
const fieldDef = objectDef.fields.find((f: any) => f.apiName === key);
|
||||
|
||||
if (!fieldDef) {
|
||||
// Field doesn't exist in schema, skip it
|
||||
this.logger.warn(`Field ${key} not found in ${objectApiName} schema, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip system fields
|
||||
if (fieldDef.isSystem) {
|
||||
this.logger.debug(`Skipping system field ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if field is writable (for authorization)
|
||||
if (fieldDef.defaultWritable === false) {
|
||||
this.logger.warn(`Field ${key} is not writable, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// For update operations, also skip ID field
|
||||
if (isUpdate && key === 'id') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Field is valid and writable, include it
|
||||
filtered[key] = value;
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a model is registered for the given object.
|
||||
* Delegates to ModelService which handles creating the model and all its dependencies.
|
||||
@@ -553,6 +600,21 @@ export class ObjectService {
|
||||
// Verify object exists and get field definitions
|
||||
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
|
||||
|
||||
// Get object definition with authorization settings
|
||||
const objectDefModel = await ObjectDefinition.query(knex)
|
||||
.findOne({ apiName: objectApiName });
|
||||
|
||||
if (!objectDefModel) {
|
||||
throw new NotFoundException('Object definition not found');
|
||||
}
|
||||
|
||||
// Get user model for authorization
|
||||
const user = await User.query(knex).findById(userId).withGraphFetched('roles');
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const tableName = this.getTableName(objectApiName);
|
||||
|
||||
// Ensure model is registered before attempting to use it
|
||||
@@ -565,6 +627,9 @@ export class ObjectService {
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
let query = boundModel.query().where({ id: recordId });
|
||||
|
||||
// Apply authorization scoping
|
||||
query = applyReadScope(query, user, objectDefModel, knex);
|
||||
|
||||
// Build graph expression for lookup fields
|
||||
const lookupFields = objectDef.fields?.filter(f =>
|
||||
f.type === 'LOOKUP' && f.referenceObject
|
||||
@@ -582,26 +647,20 @@ export class ObjectService {
|
||||
}
|
||||
}
|
||||
|
||||
// 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');
|
||||
throw new NotFoundException('Record not found or you do not have access');
|
||||
}
|
||||
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
|
||||
|
||||
// Fallback to manual data hydration - Note: This path doesn't support authorization scoping yet
|
||||
let query = knex(tableName).where({ [`${tableName}.id`]: recordId });
|
||||
|
||||
// Add ownership filter if ownerId field exists
|
||||
// Add ownership filter if ownerId field exists (basic fallback)
|
||||
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
||||
if (hasOwner) {
|
||||
query = query.where({ [`${tableName}.ownerId`]: userId });
|
||||
@@ -610,7 +669,7 @@ export class ObjectService {
|
||||
const record = await query.first();
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundException('Record not found');
|
||||
throw new NotFoundException('Record not found or you do not have access');
|
||||
}
|
||||
|
||||
// Fetch and attach related records for lookup fields
|
||||
@@ -652,6 +711,32 @@ export class ObjectService {
|
||||
// Verify object exists
|
||||
await this.getObjectDefinition(tenantId, objectApiName);
|
||||
|
||||
// Get object definition with authorization settings
|
||||
const objectDefModel = await ObjectDefinition.query(knex)
|
||||
.findOne({ apiName: objectApiName });
|
||||
|
||||
if (!objectDefModel) {
|
||||
throw new NotFoundException('Object definition not found');
|
||||
}
|
||||
|
||||
// Check create permission
|
||||
if (!objectDefModel.publicCreate) {
|
||||
// Get user with roles to check role-based permissions
|
||||
const user = await User.query(knex).findById(userId).withGraphFetched('roles');
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// TODO: Check role-based create permissions from role_rules
|
||||
// For now, only allow if publicCreate is true
|
||||
throw new ForbiddenException('You do not have permission to create records for this object');
|
||||
}
|
||||
|
||||
// Filter data to only include writable fields based on field definitions
|
||||
// Do this BEFORE model registration so both Objection and fallback paths use clean data
|
||||
const allowedData = await this.filterWritableFields(tenantId, objectApiName, data, false);
|
||||
|
||||
// Ensure model is registered before attempting to use it
|
||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
||||
|
||||
@@ -660,8 +745,9 @@ export class ObjectService {
|
||||
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
|
||||
if (Model) {
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
|
||||
const recordData = {
|
||||
...data,
|
||||
...allowedData,
|
||||
ownerId: userId, // Auto-set owner
|
||||
};
|
||||
const record = await boundModel.query().insert(recordData);
|
||||
@@ -677,7 +763,7 @@ export class ObjectService {
|
||||
|
||||
const recordData: any = {
|
||||
id: knex.raw('(UUID())'),
|
||||
...data,
|
||||
...allowedData, // Use filtered data instead of raw data
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
};
|
||||
@@ -701,37 +787,65 @@ export class ObjectService {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
// Verify object exists and user has access
|
||||
await this.getRecord(tenantId, objectApiName, recordId, userId);
|
||||
// Get object definition with authorization settings
|
||||
const objectDefModel = await ObjectDefinition.query(knex)
|
||||
.findOne({ apiName: objectApiName });
|
||||
|
||||
const tableName = this.getTableName(objectApiName);
|
||||
|
||||
// Ensure model is registered before attempting to use it
|
||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
||||
|
||||
// Try to use the Objection model if available
|
||||
try {
|
||||
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
|
||||
if (Model) {
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
// Don't allow updating ownerId or system fields
|
||||
const allowedData = { ...data };
|
||||
delete allowedData.ownerId;
|
||||
delete allowedData.id;
|
||||
delete allowedData.created_at;
|
||||
delete allowedData.tenantId;
|
||||
|
||||
await boundModel.query().where({ id: recordId }).update(allowedData);
|
||||
return boundModel.query().where({ id: recordId }).first();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Could not use Objection model for ${objectApiName}:`, error.message);
|
||||
if (!objectDefModel) {
|
||||
throw new NotFoundException('Object definition not found');
|
||||
}
|
||||
|
||||
// Get user model for authorization
|
||||
const user = await User.query(knex).findById(userId).withGraphFetched('roles');
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Filter data to only include writable fields based on field definitions
|
||||
// Do this BEFORE authorization checks so both paths use clean data
|
||||
const allowedData = await this.filterWritableFields(tenantId, objectApiName, data, true);
|
||||
|
||||
// Verify user has access to read the record first (using authorization scope)
|
||||
const tableName = this.getTableName(objectApiName);
|
||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
||||
|
||||
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
|
||||
if (Model) {
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
let checkQuery = boundModel.query().where({ id: recordId });
|
||||
checkQuery = applyUpdateScope(checkQuery, user, objectDefModel, knex);
|
||||
|
||||
const existingRecord = await checkQuery.first();
|
||||
if (!existingRecord) {
|
||||
throw new ForbiddenException('You do not have permission to update this record');
|
||||
}
|
||||
|
||||
this.logger.log(`[UPDATE] Record ID: ${recordId}, Type: ${typeof recordId}`);
|
||||
this.logger.log(`[UPDATE] Existing record ID: ${existingRecord.id}, Type: ${typeof existingRecord.id}`);
|
||||
this.logger.log(`[UPDATE] Allowed data:`, JSON.stringify(allowedData));
|
||||
|
||||
const numUpdated = await boundModel.query().where({ id: recordId }).update(allowedData);
|
||||
this.logger.log(`[UPDATE] Number of records updated: ${numUpdated}`);
|
||||
|
||||
const updatedRecord = await boundModel.query().where({ id: recordId }).first();
|
||||
this.logger.log(`[UPDATE] Updated record:`, updatedRecord ? 'found' : 'NOT FOUND');
|
||||
|
||||
return updatedRecord;
|
||||
}
|
||||
|
||||
// Fallback to raw Knex with basic ownership check
|
||||
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
||||
if (hasOwner && !objectDefModel.publicUpdate) {
|
||||
const record = await knex(tableName).where({ id: recordId, ownerId: userId }).first();
|
||||
if (!record) {
|
||||
throw new ForbiddenException('You do not have permission to update this record');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to raw Knex
|
||||
await knex(tableName)
|
||||
.where({ id: recordId })
|
||||
.update({ ...data, updated_at: knex.fn.now() });
|
||||
.update({ ...allowedData, updated_at: knex.fn.now() }); // Use filtered data
|
||||
|
||||
return knex(tableName).where({ id: recordId }).first();
|
||||
}
|
||||
@@ -745,27 +859,51 @@ export class ObjectService {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
// Verify object exists and user has access
|
||||
await this.getRecord(tenantId, objectApiName, recordId, userId);
|
||||
// Get object definition with authorization settings
|
||||
const objectDefModel = await ObjectDefinition.query(knex)
|
||||
.findOne({ apiName: objectApiName });
|
||||
|
||||
if (!objectDefModel) {
|
||||
throw new NotFoundException('Object definition not found');
|
||||
}
|
||||
|
||||
// Get user model for authorization
|
||||
const user = await User.query(knex).findById(userId).withGraphFetched('roles');
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const tableName = this.getTableName(objectApiName);
|
||||
|
||||
// Ensure model is registered before attempting to use it
|
||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
||||
|
||||
// Try to use the Objection model if available
|
||||
try {
|
||||
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
|
||||
if (Model) {
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
await boundModel.query().where({ id: recordId }).delete();
|
||||
return { success: true };
|
||||
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
|
||||
if (Model) {
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
|
||||
// Check if user has permission to delete this record
|
||||
let checkQuery = boundModel.query().where({ id: recordId });
|
||||
checkQuery = applyDeleteScope(checkQuery, user, objectDefModel, knex);
|
||||
|
||||
const existingRecord = await checkQuery.first();
|
||||
if (!existingRecord) {
|
||||
throw new ForbiddenException('You do not have permission to delete this record');
|
||||
}
|
||||
|
||||
await boundModel.query().where({ id: recordId }).delete();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Fallback to raw Knex with basic ownership check
|
||||
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
||||
if (hasOwner && !objectDefModel.publicDelete) {
|
||||
const record = await knex(tableName).where({ id: recordId, ownerId: userId }).first();
|
||||
if (!record) {
|
||||
throw new ForbiddenException('You do not have permission to delete this record');
|
||||
}
|
||||
} 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 };
|
||||
|
||||
Reference in New Issue
Block a user