Compare commits
4 Commits
schemaoper
...
56c0c3838d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56c0c3838d | ||
|
|
9ac69e30d0 | ||
|
|
d37183ba45 | ||
|
|
b4bdeeb9f6 |
@@ -0,0 +1,102 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema
|
||||
// Add orgWideDefault to object_definitions
|
||||
.alterTable('object_definitions', (table) => {
|
||||
table
|
||||
.enum('orgWideDefault', ['private', 'public_read', 'public_read_write'])
|
||||
.defaultTo('private')
|
||||
.notNullable();
|
||||
})
|
||||
// Create role_object_permissions table
|
||||
.createTable('role_object_permissions', (table) => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||
table.uuid('roleId').notNullable();
|
||||
table.uuid('objectDefinitionId').notNullable();
|
||||
table.boolean('canCreate').defaultTo(false);
|
||||
table.boolean('canRead').defaultTo(false);
|
||||
table.boolean('canEdit').defaultTo(false);
|
||||
table.boolean('canDelete').defaultTo(false);
|
||||
table.boolean('canViewAll').defaultTo(false);
|
||||
table.boolean('canModifyAll').defaultTo(false);
|
||||
table.timestamps(true, true);
|
||||
|
||||
table
|
||||
.foreign('roleId')
|
||||
.references('id')
|
||||
.inTable('roles')
|
||||
.onDelete('CASCADE');
|
||||
table
|
||||
.foreign('objectDefinitionId')
|
||||
.references('id')
|
||||
.inTable('object_definitions')
|
||||
.onDelete('CASCADE');
|
||||
table.unique(['roleId', 'objectDefinitionId']);
|
||||
table.index(['roleId']);
|
||||
table.index(['objectDefinitionId']);
|
||||
})
|
||||
// Create role_field_permissions table
|
||||
.createTable('role_field_permissions', (table) => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||
table.uuid('roleId').notNullable();
|
||||
table.uuid('fieldDefinitionId').notNullable();
|
||||
table.boolean('canRead').defaultTo(true);
|
||||
table.boolean('canEdit').defaultTo(true);
|
||||
table.timestamps(true, true);
|
||||
|
||||
table
|
||||
.foreign('roleId')
|
||||
.references('id')
|
||||
.inTable('roles')
|
||||
.onDelete('CASCADE');
|
||||
table
|
||||
.foreign('fieldDefinitionId')
|
||||
.references('id')
|
||||
.inTable('field_definitions')
|
||||
.onDelete('CASCADE');
|
||||
table.unique(['roleId', 'fieldDefinitionId']);
|
||||
table.index(['roleId']);
|
||||
table.index(['fieldDefinitionId']);
|
||||
})
|
||||
// Create record_shares table for sharing specific records
|
||||
.createTable('record_shares', (table) => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||
table.uuid('objectDefinitionId').notNullable();
|
||||
table.uuid('recordId').notNullable();
|
||||
table.uuid('granteeUserId').notNullable();
|
||||
table.uuid('grantedByUserId').notNullable();
|
||||
table.json('accessLevel').notNullable(); // { canRead, canEdit, canDelete }
|
||||
table.timestamp('expiresAt').nullable();
|
||||
table.timestamp('revokedAt').nullable();
|
||||
table.timestamp('createdAt').defaultTo(knex.fn.now());
|
||||
|
||||
table
|
||||
.foreign('objectDefinitionId')
|
||||
.references('id')
|
||||
.inTable('object_definitions')
|
||||
.onDelete('CASCADE');
|
||||
table
|
||||
.foreign('granteeUserId')
|
||||
.references('id')
|
||||
.inTable('users')
|
||||
.onDelete('CASCADE');
|
||||
table
|
||||
.foreign('grantedByUserId')
|
||||
.references('id')
|
||||
.inTable('users')
|
||||
.onDelete('CASCADE');
|
||||
table.index(['objectDefinitionId', 'recordId']);
|
||||
table.index(['granteeUserId']);
|
||||
table.index(['expiresAt']);
|
||||
table.index(['revokedAt']);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema
|
||||
.dropTableIfExists('record_shares')
|
||||
.dropTableIfExists('role_field_permissions')
|
||||
.dropTableIfExists('role_object_permissions')
|
||||
.alterTable('object_definitions', (table) => {
|
||||
table.dropColumn('orgWideDefault');
|
||||
});
|
||||
};
|
||||
48
backend/package-lock.json
generated
48
backend/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.7.5",
|
||||
"@nestjs/bullmq": "^10.1.0",
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
@@ -741,6 +742,18 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/@casl/ability": {
|
||||
"version": "6.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.5.tgz",
|
||||
"integrity": "sha512-NaOHPi9JMn8Kesh+GRkjNKAYkl4q8qMFAlqw7w2yrE+cBQZSbV9GkBGKvgzs3CdzEc5Yl1cn3JwDxxbBN5gjog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ucast/mongo2js": "^1.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/stalniy/casl/blob/master/BACKERS.md"
|
||||
}
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||
@@ -2882,6 +2895,41 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@ucast/core": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz",
|
||||
"integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@ucast/js": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.4.tgz",
|
||||
"integrity": "sha512-TgG1aIaCMdcaEyckOZKQozn1hazE0w90SVdlpIJ/er8xVumE11gYAtSbw/LBeUnA4fFnFWTcw3t6reqseeH/4Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ucast/core": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ucast/mongo": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz",
|
||||
"integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ucast/core": "^1.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ucast/mongo2js": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.4.0.tgz",
|
||||
"integrity": "sha512-vR9RJ3BHlkI3RfKJIZFdVktxWvBCQRiSTeJSWN9NPxP5YJkpfXvcBWAMLwvyJx4HbB+qib5/AlSDEmQiuQyx2w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ucast/core": "^1.6.1",
|
||||
"@ucast/js": "^3.0.0",
|
||||
"@ucast/mongo": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"migrate:all-tenants": "ts-node -r tsconfig-paths/register scripts/migrate-all-tenants.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.7.5",
|
||||
"@nestjs/bullmq": "^10.1.0",
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
|
||||
181
backend/scripts/seed-default-roles.ts
Normal file
181
backend/scripts/seed-default-roles.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Knex } from 'knex';
|
||||
import * as knexLib from 'knex';
|
||||
|
||||
/**
|
||||
* Create a Knex connection for tenant database
|
||||
*/
|
||||
function createKnexConnection(database: string): Knex {
|
||||
return knexLib.default({
|
||||
client: 'mysql2',
|
||||
connection: {
|
||||
host: process.env.DB_HOST || 'db',
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
user: 'root',
|
||||
password: 'asjdnfqTash37faggT',
|
||||
database: database,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface RoleWithPermissions {
|
||||
name: string;
|
||||
description: string;
|
||||
objectPermissions: {
|
||||
[objectApiName: string]: {
|
||||
canCreate: boolean;
|
||||
canRead: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
canViewAll: boolean;
|
||||
canModifyAll: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_ROLES: RoleWithPermissions[] = [
|
||||
{
|
||||
name: 'System Administrator',
|
||||
description: 'Full access to all objects and records. Can view and modify all data.',
|
||||
objectPermissions: {
|
||||
'*': {
|
||||
canCreate: true,
|
||||
canRead: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
canViewAll: true,
|
||||
canModifyAll: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Standard User',
|
||||
description: 'Can create, read, edit, and delete own records. Respects OWD settings.',
|
||||
objectPermissions: {
|
||||
'*': {
|
||||
canCreate: true,
|
||||
canRead: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
canViewAll: false,
|
||||
canModifyAll: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Read Only',
|
||||
description: 'Can only read records based on OWD settings. No create, edit, or delete.',
|
||||
objectPermissions: {
|
||||
'*': {
|
||||
canCreate: false,
|
||||
canRead: true,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
canViewAll: false,
|
||||
canModifyAll: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function seedRolesForTenant(knex: Knex, tenantName: string) {
|
||||
console.log(`\n🌱 Seeding roles for tenant: ${tenantName}`);
|
||||
|
||||
// Get all object definitions
|
||||
const objectDefinitions = await knex('object_definitions').select('id', 'apiName');
|
||||
|
||||
for (const roleData of DEFAULT_ROLES) {
|
||||
// Check if role already exists
|
||||
const existingRole = await knex('roles')
|
||||
.where({ name: roleData.name })
|
||||
.first();
|
||||
|
||||
let roleId: string;
|
||||
|
||||
if (existingRole) {
|
||||
console.log(` ℹ️ Role "${roleData.name}" already exists, skipping...`);
|
||||
roleId = existingRole.id;
|
||||
} else {
|
||||
// Create role
|
||||
await knex('roles').insert({
|
||||
name: roleData.name,
|
||||
guardName: 'api',
|
||||
description: roleData.description,
|
||||
});
|
||||
|
||||
// Get the inserted role
|
||||
const newRole = await knex('roles')
|
||||
.where({ name: roleData.name })
|
||||
.first();
|
||||
|
||||
roleId = newRole.id;
|
||||
console.log(` ✅ Created role: ${roleData.name}`);
|
||||
}
|
||||
|
||||
// Create object permissions for all objects
|
||||
const wildcardPermissions = roleData.objectPermissions['*'];
|
||||
|
||||
for (const objectDef of objectDefinitions) {
|
||||
// Check if permission already exists
|
||||
const existingPermission = await knex('role_object_permissions')
|
||||
.where({
|
||||
roleId: roleId,
|
||||
objectDefinitionId: objectDef.id,
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!existingPermission) {
|
||||
await knex('role_object_permissions').insert({
|
||||
roleId: roleId,
|
||||
objectDefinitionId: objectDef.id,
|
||||
canCreate: wildcardPermissions.canCreate,
|
||||
canRead: wildcardPermissions.canRead,
|
||||
canEdit: wildcardPermissions.canEdit,
|
||||
canDelete: wildcardPermissions.canDelete,
|
||||
canViewAll: wildcardPermissions.canViewAll,
|
||||
canModifyAll: wildcardPermissions.canModifyAll,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` 📋 Set permissions for ${objectDefinitions.length} objects`);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAllTenants() {
|
||||
console.log('🚀 Starting role seeding for all tenants...\n');
|
||||
|
||||
// For now, seed the main tenant database
|
||||
const databases = ['tenant_tenant1'];
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const database of databases) {
|
||||
try {
|
||||
const knex = createKnexConnection(database);
|
||||
await seedRolesForTenant(knex, database);
|
||||
await knex.destroy();
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`❌ ${database}: Seeding failed:`, error.message);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n============================================================');
|
||||
console.log('📊 Seeding Summary');
|
||||
console.log('============================================================');
|
||||
console.log(`✅ Successful: ${successCount}`);
|
||||
console.log(`❌ Failed: ${errorCount}`);
|
||||
|
||||
if (errorCount === 0) {
|
||||
console.log('\n🎉 All tenant roles seeded successfully!');
|
||||
}
|
||||
}
|
||||
|
||||
seedAllTenants()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
console.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Model } from 'objection';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
/**
|
||||
* Base model for all dynamic and system models
|
||||
@@ -10,26 +11,23 @@ export class BaseModel extends Model {
|
||||
tenantId?: string;
|
||||
ownerId?: string;
|
||||
name?: string;
|
||||
created_at?: Date;
|
||||
updated_at?: Date;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
|
||||
// Hook to set system-managed fields
|
||||
$beforeInsert() {
|
||||
// created_at and updated_at are handled by the database
|
||||
// ownerId should be set by the controller/service
|
||||
async $beforeInsert() {
|
||||
if (!this.id) {
|
||||
this.id = randomUUID();
|
||||
}
|
||||
if (!this.created_at) {
|
||||
this.created_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
if (!this.updated_at) {
|
||||
this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
$beforeUpdate() {
|
||||
// updated_at is handled by the database
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get the API name for this object
|
||||
* Override in subclasses
|
||||
*/
|
||||
static get objectApiName(): string {
|
||||
return 'BaseModel';
|
||||
async $beforeUpdate() {
|
||||
this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ModelClass, JSONSchema, RelationMappings, Model } from 'objection';
|
||||
import { BaseModel } from './base.model';
|
||||
|
||||
@@ -28,6 +27,14 @@ export interface ObjectMetadata {
|
||||
}
|
||||
|
||||
export class DynamicModelFactory {
|
||||
/**
|
||||
* Get relation name from lookup field API name
|
||||
* Converts "ownerId" -> "owner", "customFieldId" -> "customfield"
|
||||
*/
|
||||
static getRelationName(lookupFieldApiName: string): string {
|
||||
return lookupFieldApiName.replace(/Id$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dynamic model class from object metadata
|
||||
* @param meta Object metadata
|
||||
@@ -49,7 +56,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) {
|
||||
@@ -68,20 +76,13 @@ export class DynamicModelFactory {
|
||||
// Store lookup fields metadata for later use
|
||||
const lookupFieldsInfo = lookupFields.map(f => ({
|
||||
apiName: f.apiName,
|
||||
relationName: f.apiName.replace(/Id$/, '').toLowerCase(),
|
||||
relationName: DynamicModelFactory.getRelationName(f.apiName),
|
||||
referenceObject: f.referenceObject,
|
||||
targetTable: this.getTableName(f.referenceObject),
|
||||
}));
|
||||
|
||||
// Create the dynamic model class extending Model directly
|
||||
class DynamicModel extends Model {
|
||||
id?: string;
|
||||
tenantId?: string;
|
||||
ownerId?: string;
|
||||
name?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
|
||||
// Create the dynamic model class extending BaseModel
|
||||
class DynamicModel extends BaseModel {
|
||||
static tableName = tableName;
|
||||
|
||||
static objectApiName = apiName;
|
||||
@@ -128,22 +129,6 @@ export class DynamicModelFactory {
|
||||
properties,
|
||||
};
|
||||
}
|
||||
|
||||
async $beforeInsert() {
|
||||
if (!this.id) {
|
||||
this.id = randomUUID();
|
||||
}
|
||||
if (!this.created_at) {
|
||||
this.created_at = new Date().toISOString();
|
||||
}
|
||||
if (!this.updated_at) {
|
||||
this.updated_at = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
async $beforeUpdate() {
|
||||
this.updated_at = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
return DynamicModel as any;
|
||||
|
||||
@@ -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,62 +448,57 @@ 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);
|
||||
|
||||
// Ensure model is registered before attempting to use it
|
||||
// Ensure model is registered
|
||||
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);
|
||||
let query = boundModel.query();
|
||||
|
||||
// Build graph expression for lookup fields
|
||||
const lookupFields = objectDef.fields?.filter(f =>
|
||||
f.type === 'LOOKUP' && f.referenceObject
|
||||
) || [];
|
||||
|
||||
if (lookupFields.length > 0) {
|
||||
// Build relation expression - use singular lowercase for relation name
|
||||
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 });
|
||||
}
|
||||
|
||||
// Apply additional filters
|
||||
if (filters) {
|
||||
query = query.where(filters);
|
||||
}
|
||||
|
||||
return 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);
|
||||
// Use Objection model
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
let query = boundModel.query();
|
||||
|
||||
// Add ownership filter if ownerId field exists
|
||||
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
||||
if (hasOwner) {
|
||||
query = query.where({ [`${tableName}.ownerId`]: userId });
|
||||
// Apply authorization scope (modifies query in place)
|
||||
await this.authService.applyScopeToQuery(
|
||||
query,
|
||||
objectDefModel,
|
||||
user,
|
||||
'read',
|
||||
knex,
|
||||
);
|
||||
|
||||
// Build graph expression for lookup fields
|
||||
const lookupFields = objectDefModel.fields?.filter(f =>
|
||||
f.type === 'LOOKUP' && f.referenceObject
|
||||
) || [];
|
||||
|
||||
if (lookupFields.length > 0) {
|
||||
// Build relation expression - use singular lowercase for relation name
|
||||
const relationExpression = lookupFields
|
||||
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
if (relationExpression) {
|
||||
query = query.withGraphFetched(`[${relationExpression}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply additional filters
|
||||
@@ -481,49 +506,16 @@ export class ObjectService {
|
||||
query = query.where(filters);
|
||||
}
|
||||
|
||||
// Get base records
|
||||
const records = await query.select(`${tableName}.*`);
|
||||
const records = await query.select('*');
|
||||
|
||||
// Fetch and attach related records for lookup fields
|
||||
const lookupFields = objectDef.fields?.filter(f =>
|
||||
f.type === 'LOOKUP' && f.referenceObject
|
||||
) || [];
|
||||
// Filter fields based on field-level permissions
|
||||
const filteredRecords = await Promise.all(
|
||||
records.map(record =>
|
||||
this.authService.filterReadableFields(record, objectDefModel.fields, user)
|
||||
)
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
return filteredRecords;
|
||||
}
|
||||
|
||||
async getRecord(
|
||||
@@ -535,93 +527,62 @@ 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]]');
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
let query = boundModel.query().where({ id: recordId });
|
||||
|
||||
// Build graph expression for lookup fields
|
||||
const lookupFields = objectDef.fields?.filter(f =>
|
||||
f.type === 'LOOKUP' && f.referenceObject
|
||||
) || [];
|
||||
|
||||
if (lookupFields.length > 0) {
|
||||
// Build relation expression - use singular lowercase for relation name
|
||||
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}`);
|
||||
}
|
||||
// Use Objection model
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
let query = boundModel.query().where({ id: recordId });
|
||||
|
||||
// Fallback to manual data hydration
|
||||
let query = knex(tableName).where({ [`${tableName}.id`]: recordId });
|
||||
// Apply authorization scope (modifies query in place)
|
||||
await this.authService.applyScopeToQuery(
|
||||
query,
|
||||
objectDefModel,
|
||||
user,
|
||||
'read',
|
||||
knex,
|
||||
);
|
||||
|
||||
// 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 =>
|
||||
// Build graph expression for lookup fields
|
||||
const lookupFields = objectDefModel.fields?.filter(f =>
|
||||
f.type === 'LOOKUP' && f.referenceObject
|
||||
) || [];
|
||||
|
||||
if (lookupFields.length > 0) {
|
||||
for (const field of lookupFields) {
|
||||
const relationName = field.apiName.replace(/Id$/, '').toLowerCase();
|
||||
const relatedTable = this.getTableName(field.referenceObject);
|
||||
const relatedId = record[field.apiName];
|
||||
|
||||
if (relatedId) {
|
||||
// Fetch the related record
|
||||
const relatedRecord = await knex(relatedTable)
|
||||
.where({ id: relatedId })
|
||||
.first();
|
||||
|
||||
if (relatedRecord) {
|
||||
record[relationName] = relatedRecord;
|
||||
}
|
||||
}
|
||||
// Build relation expression - use singular lowercase for relation name
|
||||
const relationExpression = lookupFields
|
||||
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
if (relationExpression) {
|
||||
query = query.withGraphFetched(`[${relationExpression}]`);
|
||||
}
|
||||
}
|
||||
|
||||
const record = await query.first();
|
||||
if (!record) {
|
||||
throw new NotFoundException('Record not found');
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
@@ -634,46 +595,44 @@ 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]]');
|
||||
|
||||
// Ensure model is registered before attempting to use it
|
||||
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
|
||||
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);
|
||||
const recordData = {
|
||||
...data,
|
||||
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');
|
||||
|
||||
const recordData: any = {
|
||||
id: knex.raw('(UUID())'),
|
||||
...data,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
// Use Objection model
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
const recordData = {
|
||||
...editableData,
|
||||
ownerId: userId, // Auto-set owner
|
||||
};
|
||||
|
||||
if (hasOwner) {
|
||||
recordData.ownerId = userId;
|
||||
}
|
||||
|
||||
const [id] = await knex(tableName).insert(recordData);
|
||||
|
||||
return knex(tableName).where({ id }).first();
|
||||
const record = await boundModel.query().insert(recordData);
|
||||
return record;
|
||||
}
|
||||
|
||||
async updateRecord(
|
||||
@@ -686,39 +645,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 user with roles and permissions
|
||||
const user = await User.query(knex)
|
||||
.findById(userId)
|
||||
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
||||
|
||||
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 (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Fallback to raw Knex
|
||||
await knex(tableName)
|
||||
.where({ id: recordId })
|
||||
.update({ ...data, updated_at: knex.fn.now() });
|
||||
// Get object definition with authorization settings
|
||||
const objectDefModel = await ObjectDefinition.query(knex)
|
||||
.findOne({ apiName: objectApiName })
|
||||
.withGraphFetched('fields');
|
||||
|
||||
return knex(tableName).where({ id: recordId }).first();
|
||||
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
|
||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
||||
|
||||
// Use Objection model
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
await boundModel.query().where({ id: recordId }).update(editableData);
|
||||
return boundModel.query().where({ id: recordId }).first();
|
||||
}
|
||||
|
||||
async deleteRecord(
|
||||
@@ -730,28 +701,40 @@ 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]]');
|
||||
|
||||
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 };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Could not use Objection model for ${objectApiName}:`, error.message);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Fallback to raw Knex
|
||||
await knex(tableName).where({ id: recordId }).delete();
|
||||
// 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
|
||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
||||
|
||||
// Use Objection model
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
await boundModel.query().where({ id: recordId }).delete();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
282
backend/src/rbac/authorization.service.ts
Normal file
282
backend/src/rbac/authorization.service.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
211
docs/SALESFORCE_AUTHORIZATION.md
Normal file
211
docs/SALESFORCE_AUTHORIZATION.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Salesforce-Style Authorization System
|
||||
|
||||
## Overview
|
||||
Implemented a comprehensive authorization system based on Salesforce's model with:
|
||||
- **Org-Wide Defaults (OWD)** for record visibility
|
||||
- **Role-based permissions** for object and field access
|
||||
- **Record sharing** for granular access control
|
||||
- **CASL** for flexible permission evaluation
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Org-Wide Defaults (OWD)
|
||||
Controls baseline record visibility for each object:
|
||||
- `private`: Only owner can see records
|
||||
- `public_read`: Everyone can see, only owner can edit/delete
|
||||
- `public_read_write`: Everyone can see and modify all records
|
||||
|
||||
### 2. Role-Based Object Permissions
|
||||
Table: `role_object_permissions`
|
||||
- `canCreate`: Can create new records
|
||||
- `canRead`: Can read records (subject to OWD)
|
||||
- `canEdit`: Can edit records (subject to OWD)
|
||||
- `canDelete`: Can delete records (subject to OWD)
|
||||
- `canViewAll`: Override OWD to see ALL records
|
||||
- `canModifyAll`: Override OWD to edit ALL records
|
||||
|
||||
### 3. Field-Level Security
|
||||
Table: `role_field_permissions`
|
||||
- `canRead`: Can view field value
|
||||
- `canEdit`: Can modify field value
|
||||
|
||||
### 4. Record Sharing
|
||||
Table: `record_shares`
|
||||
Grants specific users access to individual records with:
|
||||
```json
|
||||
{
|
||||
"canRead": boolean,
|
||||
"canEdit": boolean,
|
||||
"canDelete": boolean
|
||||
}
|
||||
```
|
||||
|
||||
## Permission Evaluation Flow
|
||||
|
||||
```
|
||||
1. Check role_object_permissions
|
||||
├─ Does user have canCreate/Read/Edit/Delete?
|
||||
│ └─ NO → Deny
|
||||
│ └─ YES → Continue
|
||||
│
|
||||
2. Check canViewAll / canModifyAll
|
||||
├─ Does user have special "all" permissions?
|
||||
│ └─ YES → Grant access
|
||||
│ └─ NO → Continue
|
||||
│
|
||||
3. Check OWD (orgWideDefault)
|
||||
├─ public_read_write → Grant access
|
||||
├─ public_read → Grant read, check ownership for write
|
||||
└─ private → Check ownership or sharing
|
||||
|
||||
4. Check Ownership
|
||||
├─ Is user the record owner?
|
||||
│ └─ YES → Grant access
|
||||
│ └─ NO → Continue
|
||||
│
|
||||
5. Check Record Shares
|
||||
└─ Is record explicitly shared with user?
|
||||
└─ Check accessLevel permissions
|
||||
```
|
||||
|
||||
## Field-Level Security
|
||||
|
||||
Fields are filtered after record access is granted:
|
||||
1. User queries records → Apply record-level scope
|
||||
2. System filters readable fields based on `role_field_permissions`
|
||||
3. User updates records → System filters editable fields
|
||||
|
||||
## Key Features
|
||||
|
||||
### Multiple Role Support
|
||||
- Users can have multiple roles
|
||||
- Permissions are **unioned** (any role grants = user has it)
|
||||
- More flexible than Salesforce's single profile model
|
||||
|
||||
### Active Share Detection
|
||||
- Shares can expire (`expiresAt`)
|
||||
- Shares can be revoked (`revokedAt`)
|
||||
- Only active shares are evaluated
|
||||
|
||||
### CASL Integration
|
||||
- Dynamic ability building per request
|
||||
- Condition-based rules
|
||||
- Field-level permission support
|
||||
|
||||
## Usage Example
|
||||
|
||||
```typescript
|
||||
// In a controller/service
|
||||
constructor(
|
||||
private authService: AuthorizationService,
|
||||
private tenantDbService: TenantDatabaseService,
|
||||
) {}
|
||||
|
||||
async getRecords(tenantId: string, objectApiName: string, userId: string) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
// Get user with roles
|
||||
const user = await User.query(knex)
|
||||
.findById(userId)
|
||||
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
||||
|
||||
// Get object definition
|
||||
const objectDef = await ObjectDefinition.query(knex)
|
||||
.findOne({ apiName: objectApiName });
|
||||
|
||||
// Build query with authorization scope
|
||||
let query = knex(objectApiName.toLowerCase());
|
||||
query = await this.authService.applyScopeToQuery(
|
||||
query,
|
||||
objectDef,
|
||||
user,
|
||||
'read',
|
||||
knex,
|
||||
);
|
||||
|
||||
const records = await query;
|
||||
|
||||
// Get field definitions
|
||||
const fields = await FieldDefinition.query(knex)
|
||||
.where('objectDefinitionId', objectDef.id);
|
||||
|
||||
// Filter fields user can read
|
||||
const filteredRecords = await Promise.all(
|
||||
records.map(record =>
|
||||
this.authService.filterReadableFields(record, fields, user)
|
||||
)
|
||||
);
|
||||
|
||||
return filteredRecords;
|
||||
}
|
||||
|
||||
async updateRecord(tenantId: string, objectApiName: string, recordId: string, data: any, userId: string) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
const user = await User.query(knex)
|
||||
.findById(userId)
|
||||
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
||||
|
||||
const objectDef = await ObjectDefinition.query(knex)
|
||||
.findOne({ apiName: objectApiName });
|
||||
|
||||
// Get existing record
|
||||
const record = await knex(objectApiName.toLowerCase())
|
||||
.where({ id: recordId })
|
||||
.first();
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundException('Record not found');
|
||||
}
|
||||
|
||||
// Check if user can update this record
|
||||
await this.authService.assertCanPerformAction(
|
||||
'update',
|
||||
objectDef,
|
||||
record,
|
||||
user,
|
||||
knex,
|
||||
);
|
||||
|
||||
// Get field definitions
|
||||
const fields = await FieldDefinition.query(knex)
|
||||
.where('objectDefinitionId', objectDef.id);
|
||||
|
||||
// Filter to only editable fields
|
||||
const editableData = await this.authService.filterEditableFields(
|
||||
data,
|
||||
fields,
|
||||
user,
|
||||
);
|
||||
|
||||
// Perform update
|
||||
await knex(objectApiName.toLowerCase())
|
||||
.where({ id: recordId })
|
||||
.update(editableData);
|
||||
|
||||
return knex(objectApiName.toLowerCase())
|
||||
.where({ id: recordId })
|
||||
.first();
|
||||
}
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
Run the migration to add authorization tables:
|
||||
```bash
|
||||
npm run knex migrate:latest
|
||||
```
|
||||
|
||||
The migration creates:
|
||||
- `orgWideDefault` column in `object_definitions`
|
||||
- `role_object_permissions` table
|
||||
- `role_field_permissions` table
|
||||
- `record_shares` table
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Migrate existing data**: Set default `orgWideDefault` values for existing objects
|
||||
2. **Create default roles**: Create Admin, Standard User, etc. with appropriate permissions
|
||||
3. **Update API endpoints**: Integrate authorization service into all CRUD operations
|
||||
4. **UI for permission management**: Build admin interface to manage role permissions
|
||||
5. **Sharing UI**: Build interface for users to share records with others
|
||||
116
frontend/components/ObjectAccessSettings.vue
Normal file
116
frontend/components/ObjectAccessSettings.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Org-Wide Default</CardTitle>
|
||||
<CardDescription>
|
||||
Control the baseline visibility for records of this object
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="orgWideDefault">Record Visibility</Label>
|
||||
<Select v-model="localOrgWideDefault" @update:model-value="handleOrgWideDefaultChange">
|
||||
<SelectTrigger id="orgWideDefault">
|
||||
<SelectValue placeholder="Select visibility level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="private">
|
||||
<div>
|
||||
<div class="font-semibold">Private</div>
|
||||
<div class="text-xs text-muted-foreground">Only record owner can see</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="public_read">
|
||||
<div>
|
||||
<div class="font-semibold">Public Read Only</div>
|
||||
<div class="text-xs text-muted-foreground">Everyone can read, only owner can edit/delete</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="public_read_write">
|
||||
<div>
|
||||
<div class="font-semibold">Public Read/Write</div>
|
||||
<div class="text-xs text-muted-foreground">Everyone can read, edit, and delete all records</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
This setting controls who can see records by default. Individual user permissions are granted through roles.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Field-Level Security</CardTitle>
|
||||
<CardDescription>
|
||||
Control field visibility and editability by role (coming soon)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Field-level permissions will be managed through role configuration.
|
||||
Navigate to Setup → Roles to configure field access for each role.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
|
||||
import { Label } from '~/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
|
||||
|
||||
const props = defineProps<{
|
||||
objectApiName: string;
|
||||
orgWideDefault?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [orgWideDefault: string];
|
||||
}>();
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const localOrgWideDefault = ref(props.orgWideDefault || 'private');
|
||||
|
||||
// Watch for prop changes
|
||||
watch(() => props.orgWideDefault, (newValue) => {
|
||||
if (newValue) {
|
||||
localOrgWideDefault.value = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
const handleOrgWideDefaultChange = async (value: string) => {
|
||||
try {
|
||||
// Update object definition
|
||||
await $api(`/api/setup/objects/${props.objectApiName}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
orgWideDefault: value
|
||||
}
|
||||
});
|
||||
|
||||
showToast({
|
||||
title: 'Success',
|
||||
description: 'Org-Wide Default saved successfully',
|
||||
variant: 'default'
|
||||
});
|
||||
|
||||
emit('update', value);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update org-wide default:', error);
|
||||
showToast({
|
||||
title: 'Error',
|
||||
description: error.data?.message || 'Failed to save changes',
|
||||
variant: 'destructive'
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -137,7 +137,12 @@ const validateForm = (): boolean => {
|
||||
|
||||
const handleSave = () => {
|
||||
if (validateForm()) {
|
||||
emit('save', { ...formData.value })
|
||||
// Start with props.data to preserve system fields like id, then override with user edits
|
||||
const dataToSave = {
|
||||
...props.data,
|
||||
...formData.value,
|
||||
}
|
||||
emit('save', dataToSave)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,11 +160,10 @@ const validateForm = (): boolean => {
|
||||
|
||||
const handleSave = () => {
|
||||
if (validateForm()) {
|
||||
// Filter out system fields from save data
|
||||
const saveData = { ...formData.value }
|
||||
const systemFields = ['id', 'tenantId', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy']
|
||||
for (const field of systemFields) {
|
||||
delete saveData[field]
|
||||
// Start with props.data to preserve system fields like id, then override with user edits
|
||||
const saveData = {
|
||||
...props.data,
|
||||
...formData.value,
|
||||
}
|
||||
emit('save', saveData)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
<!-- Tabs -->
|
||||
<div class="mb-8">
|
||||
<Tabs v-model="activeTab" default-value="fields" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-2 max-w-md">
|
||||
<TabsList class="grid w-full grid-cols-3 max-w-2xl">
|
||||
<TabsTrigger value="fields">Fields</TabsTrigger>
|
||||
<TabsTrigger value="access">Access</TabsTrigger>
|
||||
<TabsTrigger value="layouts">Page Layouts</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -55,6 +56,15 @@
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Access Tab -->
|
||||
<TabsContent value="access" class="mt-6">
|
||||
<ObjectAccessSettings
|
||||
:object-api-name="object.apiName"
|
||||
:org-wide-default="object.orgWideDefault"
|
||||
@update="handleAccessUpdate"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Page Layouts Tab -->
|
||||
<TabsContent value="layouts" class="mt-6">
|
||||
<div v-if="!selectedLayout" class="space-y-4">
|
||||
@@ -138,6 +148,7 @@ import { Plus, Trash2, ArrowLeft } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import PageLayoutEditor from '@/components/PageLayoutEditor.vue'
|
||||
import ObjectAccessSettings from '@/components/ObjectAccessSettings.vue'
|
||||
import type { PageLayout, FieldLayoutItem } from '~/types/page-layout'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -247,7 +258,11 @@ watch(activeTab, (newTab) => {
|
||||
fetchLayouts()
|
||||
}
|
||||
})
|
||||
|
||||
const handleAccessUpdate = (orgWideDefault: string) => {
|
||||
if (object.value) {
|
||||
object.value.orgWideDefault = orgWideDefault
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
await fetchObject()
|
||||
// If we start on layouts tab, load them
|
||||
|
||||
Reference in New Issue
Block a user