WIP - permissions progress
This commit is contained in:
@@ -74,5 +74,13 @@ export class FieldDefinition extends BaseModel {
|
||||
to: 'object_definitions.id',
|
||||
},
|
||||
},
|
||||
rolePermissions: {
|
||||
relation: BaseModel.HasManyRelation,
|
||||
modelClass: () => require('./role-field-permission.model').RoleFieldPermission,
|
||||
join: {
|
||||
from: 'field_definitions.id',
|
||||
to: 'role_field_permissions.fieldDefinitionId',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,8 +10,11 @@ export class ObjectDefinition extends BaseModel {
|
||||
description?: string;
|
||||
isSystem: boolean;
|
||||
isCustom: boolean;
|
||||
orgWideDefault: 'private' | 'public_read' | 'public_read_write';
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
fields?: any[];
|
||||
rolePermissions?: any[];
|
||||
|
||||
static get jsonSchema() {
|
||||
return {
|
||||
@@ -25,12 +28,14 @@ export class ObjectDefinition extends BaseModel {
|
||||
description: { type: 'string' },
|
||||
isSystem: { type: 'boolean' },
|
||||
isCustom: { type: 'boolean' },
|
||||
orgWideDefault: { type: 'string', enum: ['private', 'public_read', 'public_read_write'] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static get relationMappings() {
|
||||
const { FieldDefinition } = require('./field-definition.model');
|
||||
const { RoleObjectPermission } = require('./role-object-permission.model');
|
||||
|
||||
return {
|
||||
fields: {
|
||||
@@ -41,6 +46,14 @@ export class ObjectDefinition extends BaseModel {
|
||||
to: 'field_definitions.objectDefinitionId',
|
||||
},
|
||||
},
|
||||
rolePermissions: {
|
||||
relation: BaseModel.HasManyRelation,
|
||||
modelClass: RoleObjectPermission,
|
||||
join: {
|
||||
from: 'object_definitions.id',
|
||||
to: 'role_object_permissions.objectDefinitionId',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
77
backend/src/models/record-share.model.ts
Normal file
77
backend/src/models/record-share.model.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { BaseModel } from './base.model';
|
||||
|
||||
export interface RecordShareAccessLevel {
|
||||
canRead: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
export class RecordShare extends BaseModel {
|
||||
static tableName = 'record_shares';
|
||||
|
||||
id!: string;
|
||||
objectDefinitionId!: string;
|
||||
recordId!: string;
|
||||
granteeUserId!: string;
|
||||
grantedByUserId!: string;
|
||||
accessLevel!: RecordShareAccessLevel;
|
||||
expiresAt?: Date;
|
||||
revokedAt?: Date;
|
||||
createdAt!: Date;
|
||||
|
||||
static get jsonSchema() {
|
||||
return {
|
||||
type: 'object',
|
||||
required: ['objectDefinitionId', 'recordId', 'granteeUserId', 'grantedByUserId', 'accessLevel'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
objectDefinitionId: { type: 'string' },
|
||||
recordId: { type: 'string' },
|
||||
granteeUserId: { type: 'string' },
|
||||
grantedByUserId: { type: 'string' },
|
||||
accessLevel: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
canRead: { type: 'boolean' },
|
||||
canEdit: { type: 'boolean' },
|
||||
canDelete: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
expiresAt: { type: 'string', format: 'date-time' },
|
||||
revokedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static get relationMappings() {
|
||||
const { ObjectDefinition } = require('./object-definition.model');
|
||||
const { User } = require('./user.model');
|
||||
|
||||
return {
|
||||
objectDefinition: {
|
||||
relation: BaseModel.BelongsToOneRelation,
|
||||
modelClass: ObjectDefinition,
|
||||
join: {
|
||||
from: 'record_shares.objectDefinitionId',
|
||||
to: 'object_definitions.id',
|
||||
},
|
||||
},
|
||||
granteeUser: {
|
||||
relation: BaseModel.BelongsToOneRelation,
|
||||
modelClass: User,
|
||||
join: {
|
||||
from: 'record_shares.granteeUserId',
|
||||
to: 'users.id',
|
||||
},
|
||||
},
|
||||
grantedByUser: {
|
||||
relation: BaseModel.BelongsToOneRelation,
|
||||
modelClass: User,
|
||||
join: {
|
||||
from: 'record_shares.grantedByUserId',
|
||||
to: 'users.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
51
backend/src/models/role-field-permission.model.ts
Normal file
51
backend/src/models/role-field-permission.model.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { BaseModel } from './base.model';
|
||||
|
||||
export class RoleFieldPermission extends BaseModel {
|
||||
static tableName = 'role_field_permissions';
|
||||
|
||||
id!: string;
|
||||
roleId!: string;
|
||||
fieldDefinitionId!: string;
|
||||
canRead!: boolean;
|
||||
canEdit!: boolean;
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
|
||||
static get jsonSchema() {
|
||||
return {
|
||||
type: 'object',
|
||||
required: ['roleId', 'fieldDefinitionId'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
roleId: { type: 'string' },
|
||||
fieldDefinitionId: { type: 'string' },
|
||||
canRead: { type: 'boolean' },
|
||||
canEdit: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static get relationMappings() {
|
||||
const { Role } = require('./role.model');
|
||||
const { FieldDefinition } = require('./field-definition.model');
|
||||
|
||||
return {
|
||||
role: {
|
||||
relation: BaseModel.BelongsToOneRelation,
|
||||
modelClass: Role,
|
||||
join: {
|
||||
from: 'role_field_permissions.roleId',
|
||||
to: 'roles.id',
|
||||
},
|
||||
},
|
||||
fieldDefinition: {
|
||||
relation: BaseModel.BelongsToOneRelation,
|
||||
modelClass: FieldDefinition,
|
||||
join: {
|
||||
from: 'role_field_permissions.fieldDefinitionId',
|
||||
to: 'field_definitions.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
59
backend/src/models/role-object-permission.model.ts
Normal file
59
backend/src/models/role-object-permission.model.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { BaseModel } from './base.model';
|
||||
|
||||
export class RoleObjectPermission extends BaseModel {
|
||||
static tableName = 'role_object_permissions';
|
||||
|
||||
id!: string;
|
||||
roleId!: string;
|
||||
objectDefinitionId!: string;
|
||||
canCreate!: boolean;
|
||||
canRead!: boolean;
|
||||
canEdit!: boolean;
|
||||
canDelete!: boolean;
|
||||
canViewAll!: boolean;
|
||||
canModifyAll!: boolean;
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
|
||||
static get jsonSchema() {
|
||||
return {
|
||||
type: 'object',
|
||||
required: ['roleId', 'objectDefinitionId'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
roleId: { type: 'string' },
|
||||
objectDefinitionId: { type: 'string' },
|
||||
canCreate: { type: 'boolean' },
|
||||
canRead: { type: 'boolean' },
|
||||
canEdit: { type: 'boolean' },
|
||||
canDelete: { type: 'boolean' },
|
||||
canViewAll: { type: 'boolean' },
|
||||
canModifyAll: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static get relationMappings() {
|
||||
const { Role } = require('./role.model');
|
||||
const { ObjectDefinition } = require('./object-definition.model');
|
||||
|
||||
return {
|
||||
role: {
|
||||
relation: BaseModel.BelongsToOneRelation,
|
||||
modelClass: Role,
|
||||
join: {
|
||||
from: 'role_object_permissions.roleId',
|
||||
to: 'roles.id',
|
||||
},
|
||||
},
|
||||
objectDefinition: {
|
||||
relation: BaseModel.BelongsToOneRelation,
|
||||
modelClass: ObjectDefinition,
|
||||
join: {
|
||||
from: 'role_object_permissions.objectDefinitionId',
|
||||
to: 'object_definitions.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export class Role extends BaseModel {
|
||||
const { RolePermission } = require('./role-permission.model');
|
||||
const { Permission } = require('./permission.model');
|
||||
const { User } = require('./user.model');
|
||||
const { RoleObjectPermission } = require('./role-object-permission.model');
|
||||
const { RoleFieldPermission } = require('./role-field-permission.model');
|
||||
|
||||
return {
|
||||
rolePermissions: {
|
||||
@@ -61,6 +63,22 @@ export class Role extends BaseModel {
|
||||
to: 'users.id',
|
||||
},
|
||||
},
|
||||
objectPermissions: {
|
||||
relation: BaseModel.HasManyRelation,
|
||||
modelClass: RoleObjectPermission,
|
||||
join: {
|
||||
from: 'roles.id',
|
||||
to: 'role_object_permissions.roleId',
|
||||
},
|
||||
},
|
||||
fieldPermissions: {
|
||||
relation: BaseModel.HasManyRelation,
|
||||
modelClass: RoleFieldPermission,
|
||||
join: {
|
||||
from: 'roles.id',
|
||||
to: 'role_field_permissions.roleId',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ export class DynamicModelFactory {
|
||||
updated_at: { type: 'string', format: 'date-time' },
|
||||
};
|
||||
|
||||
const required: string[] = ['id', 'tenantId'];
|
||||
// Don't require id or tenantId - they'll be set automatically
|
||||
const required: string[] = [];
|
||||
|
||||
// Add custom fields
|
||||
for (const field of fields) {
|
||||
|
||||
@@ -6,11 +6,12 @@ import { SchemaManagementService } from './schema-management.service';
|
||||
import { FieldMapperService } from './field-mapper.service';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { MigrationModule } from '../migration/migration.module';
|
||||
import { RbacModule } from '../rbac/rbac.module';
|
||||
import { ModelRegistry } from './models/model.registry';
|
||||
import { ModelService } from './models/model.service';
|
||||
|
||||
@Module({
|
||||
imports: [TenantModule, MigrationModule],
|
||||
imports: [TenantModule, MigrationModule, RbacModule],
|
||||
providers: [
|
||||
ObjectService,
|
||||
SchemaManagementService,
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||
import { CustomMigrationService } from '../migration/custom-migration.service';
|
||||
import { ModelService } from './models/model.service';
|
||||
import { AuthorizationService } from '../rbac/authorization.service';
|
||||
import { ObjectDefinition } from '../models/object-definition.model';
|
||||
import { FieldDefinition } from '../models/field-definition.model';
|
||||
import { User } from '../models/user.model';
|
||||
import { ObjectMetadata } from './models/dynamic-model.factory';
|
||||
|
||||
@Injectable()
|
||||
@@ -12,6 +16,7 @@ export class ObjectService {
|
||||
private tenantDbService: TenantDatabaseService,
|
||||
private customMigrationService: CustomMigrationService,
|
||||
private modelService: ModelService,
|
||||
private authService: AuthorizationService,
|
||||
) {}
|
||||
|
||||
// Setup endpoints - Object metadata management
|
||||
@@ -225,6 +230,31 @@ export class ObjectService {
|
||||
return objectDef;
|
||||
}
|
||||
|
||||
async updateObjectDefinition(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
data: Partial<{
|
||||
label: string;
|
||||
pluralLabel: string;
|
||||
description: string;
|
||||
orgWideDefault: 'private' | 'public_read' | 'public_read_write';
|
||||
}>,
|
||||
) {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
// Update the object definition
|
||||
await ObjectDefinition.query(knex)
|
||||
.findOne({ apiName: objectApiName })
|
||||
.patch({
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
// Return updated object
|
||||
return await ObjectDefinition.query(knex)
|
||||
.findOne({ apiName: objectApiName });
|
||||
}
|
||||
|
||||
async createFieldDefinition(
|
||||
tenantId: string,
|
||||
@@ -418,8 +448,23 @@ export class ObjectService {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
// Verify object exists and get field definitions
|
||||
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
|
||||
// Get user with roles and permissions
|
||||
const user = await User.query(knex)
|
||||
.findById(userId)
|
||||
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
|
||||
const tableName = this.getTableName(objectApiName);
|
||||
|
||||
@@ -427,14 +472,24 @@ export class ObjectService {
|
||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
||||
|
||||
// Try to use the Objection model if available
|
||||
let records = [];
|
||||
try {
|
||||
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)
|
||||
await this.authService.applyScopeToQuery(
|
||||
query,
|
||||
objectDefModel,
|
||||
user,
|
||||
'read',
|
||||
knex,
|
||||
);
|
||||
|
||||
// Build graph expression for lookup fields
|
||||
const lookupFields = objectDef.fields?.filter(f =>
|
||||
const lookupFields = objectDefModel.fields?.filter(f =>
|
||||
f.type === 'LOOKUP' && f.referenceObject
|
||||
) || [];
|
||||
|
||||
@@ -450,80 +505,44 @@ 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 });
|
||||
}
|
||||
|
||||
// Apply additional filters
|
||||
if (filters) {
|
||||
query = query.where(filters);
|
||||
}
|
||||
|
||||
return query.select('*');
|
||||
records = await query.select('*');
|
||||
}
|
||||
} 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);
|
||||
|
||||
// Add ownership filter if ownerId field exists
|
||||
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
||||
if (hasOwner) {
|
||||
query = query.where({ [`${tableName}.ownerId`]: userId });
|
||||
}
|
||||
|
||||
// Apply additional filters
|
||||
if (filters) {
|
||||
query = query.where(filters);
|
||||
}
|
||||
|
||||
// Get base records
|
||||
const records = await query.select(`${tableName}.*`);
|
||||
|
||||
// Fetch and attach related records for lookup fields
|
||||
const lookupFields = objectDef.fields?.filter(f =>
|
||||
f.type === 'LOOKUP' && f.referenceObject
|
||||
) || [];
|
||||
|
||||
if (lookupFields.length > 0 && records.length > 0) {
|
||||
for (const field of lookupFields) {
|
||||
const relationName = field.apiName.replace(/Id$/, '').toLowerCase();
|
||||
const relatedTable = this.getTableName(field.referenceObject);
|
||||
|
||||
// Get unique IDs to fetch
|
||||
const relatedIds = [...new Set(
|
||||
records
|
||||
.map(r => r[field.apiName])
|
||||
.filter(Boolean)
|
||||
)];
|
||||
|
||||
if (relatedIds.length > 0) {
|
||||
// Fetch all related records in one query
|
||||
const relatedRecords = await knex(relatedTable)
|
||||
.whereIn('id', relatedIds)
|
||||
.select('*');
|
||||
|
||||
// Create a map for quick lookup
|
||||
const relatedMap = new Map(
|
||||
relatedRecords.map(r => [r.id, r])
|
||||
);
|
||||
|
||||
// Attach related records to main records
|
||||
for (const record of records) {
|
||||
const relatedId = record[field.apiName];
|
||||
if (relatedId && relatedMap.has(relatedId)) {
|
||||
record[relationName] = relatedMap.get(relatedId);
|
||||
}
|
||||
}
|
||||
}
|
||||
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('*');
|
||||
}
|
||||
|
||||
return records;
|
||||
// Filter fields based on field-level permissions
|
||||
const filteredRecords = await Promise.all(
|
||||
records.map(record =>
|
||||
this.authService.filterReadableFields(record, objectDefModel.fields, user)
|
||||
)
|
||||
);
|
||||
|
||||
return filteredRecords;
|
||||
}
|
||||
|
||||
async getRecord(
|
||||
@@ -634,8 +653,32 @@ export class ObjectService {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
// Verify object exists
|
||||
await this.getObjectDefinition(tenantId, objectApiName);
|
||||
// Get user with roles and permissions
|
||||
const user = await User.query(knex)
|
||||
.findById(userId)
|
||||
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
|
||||
// Check if user has create permission
|
||||
const canCreate = await this.authService.canCreate(objectDefModel, user);
|
||||
if (!canCreate) {
|
||||
throw new NotFoundException('You do not have permission to create records of this object');
|
||||
}
|
||||
|
||||
// Filter data to only editable fields
|
||||
const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user);
|
||||
|
||||
// Ensure model is registered before attempting to use it
|
||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
||||
@@ -646,7 +689,7 @@ export class ObjectService {
|
||||
if (Model) {
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
const recordData = {
|
||||
...data,
|
||||
...editableData,
|
||||
ownerId: userId, // Auto-set owner
|
||||
};
|
||||
const record = await boundModel.query().insert(recordData);
|
||||
@@ -660,9 +703,13 @@ export class ObjectService {
|
||||
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: knex.raw('(UUID())'),
|
||||
...data,
|
||||
id: uuid,
|
||||
...editableData,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
};
|
||||
@@ -671,9 +718,9 @@ export class ObjectService {
|
||||
recordData.ownerId = userId;
|
||||
}
|
||||
|
||||
const [id] = await knex(tableName).insert(recordData);
|
||||
await knex(tableName).insert(recordData);
|
||||
|
||||
return knex(tableName).where({ id }).first();
|
||||
return knex(tableName).where({ id: uuid }).first();
|
||||
}
|
||||
|
||||
async updateRecord(
|
||||
@@ -686,10 +733,43 @@ 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 user with roles and permissions
|
||||
const user = await User.query(knex)
|
||||
.findById(userId)
|
||||
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
|
||||
const tableName = this.getTableName(objectApiName);
|
||||
|
||||
// Get existing record
|
||||
const existingRecord = await knex(tableName).where({ id: recordId }).first();
|
||||
if (!existingRecord) {
|
||||
throw new NotFoundException('Record not found');
|
||||
}
|
||||
|
||||
// Check if user can update this record
|
||||
await this.authService.assertCanPerformAction('update', objectDefModel, existingRecord, user, knex);
|
||||
|
||||
// Filter data to only editable fields
|
||||
const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user);
|
||||
|
||||
// Remove system fields
|
||||
delete editableData.id;
|
||||
delete editableData.ownerId;
|
||||
delete editableData.created_at;
|
||||
delete editableData.tenantId;
|
||||
|
||||
// Ensure model is registered before attempting to use it
|
||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
||||
@@ -699,14 +779,7 @@ export class ObjectService {
|
||||
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);
|
||||
await boundModel.query().where({ id: recordId }).update(editableData);
|
||||
return boundModel.query().where({ id: recordId }).first();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -716,7 +789,7 @@ export class ObjectService {
|
||||
// Fallback to raw Knex
|
||||
await knex(tableName)
|
||||
.where({ id: recordId })
|
||||
.update({ ...data, updated_at: knex.fn.now() });
|
||||
.update({ ...editableData, updated_at: knex.fn.now() });
|
||||
|
||||
return knex(tableName).where({ id: recordId }).first();
|
||||
}
|
||||
@@ -730,10 +803,33 @@ 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 user with roles and permissions
|
||||
const user = await User.query(knex)
|
||||
.findById(userId)
|
||||
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Get object definition with authorization settings
|
||||
const objectDefModel = await ObjectDefinition.query(knex)
|
||||
.findOne({ apiName: objectApiName });
|
||||
|
||||
if (!objectDefModel) {
|
||||
throw new NotFoundException(`Object ${objectApiName} not found`);
|
||||
}
|
||||
|
||||
const tableName = this.getTableName(objectApiName);
|
||||
|
||||
// Get existing record
|
||||
const existingRecord = await knex(tableName).where({ id: recordId }).first();
|
||||
if (!existingRecord) {
|
||||
throw new NotFoundException('Record not found');
|
||||
}
|
||||
|
||||
// Check if user can delete this record
|
||||
await this.authService.assertCanPerformAction('delete', objectDefModel, existingRecord, user, knex);
|
||||
|
||||
// Ensure model is registered before attempting to use it
|
||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
@@ -67,4 +68,13 @@ export class SetupObjectController {
|
||||
// Map the created field to frontend format
|
||||
return this.fieldMapperService.mapFieldToDTO(field);
|
||||
}
|
||||
|
||||
@Patch(':objectApiName')
|
||||
async updateObjectDefinition(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectApiName') objectApiName: string,
|
||||
@Body() data: any,
|
||||
) {
|
||||
return this.objectService.updateObjectDefinition(tenantId, objectApiName, data);
|
||||
}
|
||||
}
|
||||
|
||||
185
backend/src/rbac/ability.factory.ts
Normal file
185
backend/src/rbac/ability.factory.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// Check all roles for field permission
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: allow if no explicit restriction
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
267
backend/src/rbac/authorization.service.ts
Normal file
267
backend/src/rbac/authorization.service.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
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 { 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);
|
||||
|
||||
if (hasViewAll || 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RbacService } from './rbac.service';
|
||||
import { AbilityFactory } from './ability.factory';
|
||||
import { AuthorizationService } from './authorization.service';
|
||||
|
||||
@Module({
|
||||
providers: [RbacService],
|
||||
exports: [RbacService],
|
||||
providers: [RbacService, AbilityFactory, AuthorizationService],
|
||||
exports: [RbacService, AbilityFactory, AuthorizationService],
|
||||
})
|
||||
export class RbacModule {}
|
||||
|
||||
Reference in New Issue
Block a user