Compare commits
4 Commits
f4143ab106
...
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",
|
"version": "0.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@casl/ability": "^6.7.5",
|
||||||
"@nestjs/bullmq": "^10.1.0",
|
"@nestjs/bullmq": "^10.1.0",
|
||||||
"@nestjs/common": "^10.3.0",
|
"@nestjs/common": "^10.3.0",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
@@ -741,6 +742,18 @@
|
|||||||
"url": "https://github.com/sponsors/Borewit"
|
"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": {
|
"node_modules/@colors/colors": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||||
@@ -2882,6 +2895,41 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"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": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
"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"
|
"migrate:all-tenants": "ts-node -r tsconfig-paths/register scripts/migrate-all-tenants.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@casl/ability": "^6.7.5",
|
||||||
"@nestjs/bullmq": "^10.1.0",
|
"@nestjs/bullmq": "^10.1.0",
|
||||||
"@nestjs/common": "^10.3.0",
|
"@nestjs/common": "^10.3.0",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@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',
|
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;
|
description?: string;
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
isCustom: boolean;
|
isCustom: boolean;
|
||||||
|
orgWideDefault: 'private' | 'public_read' | 'public_read_write';
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
fields?: any[];
|
||||||
|
rolePermissions?: any[];
|
||||||
|
|
||||||
static get jsonSchema() {
|
static get jsonSchema() {
|
||||||
return {
|
return {
|
||||||
@@ -25,12 +28,14 @@ export class ObjectDefinition extends BaseModel {
|
|||||||
description: { type: 'string' },
|
description: { type: 'string' },
|
||||||
isSystem: { type: 'boolean' },
|
isSystem: { type: 'boolean' },
|
||||||
isCustom: { type: 'boolean' },
|
isCustom: { type: 'boolean' },
|
||||||
|
orgWideDefault: { type: 'string', enum: ['private', 'public_read', 'public_read_write'] },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static get relationMappings() {
|
static get relationMappings() {
|
||||||
const { FieldDefinition } = require('./field-definition.model');
|
const { FieldDefinition } = require('./field-definition.model');
|
||||||
|
const { RoleObjectPermission } = require('./role-object-permission.model');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fields: {
|
fields: {
|
||||||
@@ -41,6 +46,14 @@ export class ObjectDefinition extends BaseModel {
|
|||||||
to: 'field_definitions.objectDefinitionId',
|
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 { RolePermission } = require('./role-permission.model');
|
||||||
const { Permission } = require('./permission.model');
|
const { Permission } = require('./permission.model');
|
||||||
const { User } = require('./user.model');
|
const { User } = require('./user.model');
|
||||||
|
const { RoleObjectPermission } = require('./role-object-permission.model');
|
||||||
|
const { RoleFieldPermission } = require('./role-field-permission.model');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rolePermissions: {
|
rolePermissions: {
|
||||||
@@ -61,6 +63,22 @@ export class Role extends BaseModel {
|
|||||||
to: 'users.id',
|
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 { Model } from 'objection';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base model for all dynamic and system models
|
* Base model for all dynamic and system models
|
||||||
@@ -10,26 +11,23 @@ export class BaseModel extends Model {
|
|||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
created_at?: Date;
|
created_at?: string;
|
||||||
updated_at?: Date;
|
updated_at?: string;
|
||||||
|
|
||||||
// Hook to set system-managed fields
|
// Hook to set system-managed fields
|
||||||
$beforeInsert() {
|
async $beforeInsert() {
|
||||||
// created_at and updated_at are handled by the database
|
if (!this.id) {
|
||||||
// ownerId should be set by the controller/service
|
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() {
|
async $beforeUpdate() {
|
||||||
// updated_at is handled by the database
|
this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the API name for this object
|
|
||||||
* Override in subclasses
|
|
||||||
*/
|
|
||||||
static get objectApiName(): string {
|
|
||||||
return 'BaseModel';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { ModelClass, JSONSchema, RelationMappings, Model } from 'objection';
|
import { ModelClass, JSONSchema, RelationMappings, Model } from 'objection';
|
||||||
import { BaseModel } from './base.model';
|
import { BaseModel } from './base.model';
|
||||||
|
|
||||||
@@ -28,6 +27,14 @@ export interface ObjectMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DynamicModelFactory {
|
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
|
* Create a dynamic model class from object metadata
|
||||||
* @param meta Object metadata
|
* @param meta Object metadata
|
||||||
@@ -49,7 +56,8 @@ export class DynamicModelFactory {
|
|||||||
updated_at: { type: 'string', format: 'date-time' },
|
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
|
// Add custom fields
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
@@ -68,20 +76,13 @@ export class DynamicModelFactory {
|
|||||||
// Store lookup fields metadata for later use
|
// Store lookup fields metadata for later use
|
||||||
const lookupFieldsInfo = lookupFields.map(f => ({
|
const lookupFieldsInfo = lookupFields.map(f => ({
|
||||||
apiName: f.apiName,
|
apiName: f.apiName,
|
||||||
relationName: f.apiName.replace(/Id$/, '').toLowerCase(),
|
relationName: DynamicModelFactory.getRelationName(f.apiName),
|
||||||
referenceObject: f.referenceObject,
|
referenceObject: f.referenceObject,
|
||||||
targetTable: this.getTableName(f.referenceObject),
|
targetTable: this.getTableName(f.referenceObject),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create the dynamic model class extending Model directly
|
// Create the dynamic model class extending BaseModel
|
||||||
class DynamicModel extends Model {
|
class DynamicModel extends BaseModel {
|
||||||
id?: string;
|
|
||||||
tenantId?: string;
|
|
||||||
ownerId?: string;
|
|
||||||
name?: string;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
|
|
||||||
static tableName = tableName;
|
static tableName = tableName;
|
||||||
|
|
||||||
static objectApiName = apiName;
|
static objectApiName = apiName;
|
||||||
@@ -128,22 +129,6 @@ export class DynamicModelFactory {
|
|||||||
properties,
|
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;
|
return DynamicModel as any;
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { SchemaManagementService } from './schema-management.service';
|
|||||||
import { FieldMapperService } from './field-mapper.service';
|
import { FieldMapperService } from './field-mapper.service';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
import { MigrationModule } from '../migration/migration.module';
|
import { MigrationModule } from '../migration/migration.module';
|
||||||
|
import { RbacModule } from '../rbac/rbac.module';
|
||||||
import { ModelRegistry } from './models/model.registry';
|
import { ModelRegistry } from './models/model.registry';
|
||||||
import { ModelService } from './models/model.service';
|
import { ModelService } from './models/model.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TenantModule, MigrationModule],
|
imports: [TenantModule, MigrationModule, RbacModule],
|
||||||
providers: [
|
providers: [
|
||||||
ObjectService,
|
ObjectService,
|
||||||
SchemaManagementService,
|
SchemaManagementService,
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
|||||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
import { CustomMigrationService } from '../migration/custom-migration.service';
|
import { CustomMigrationService } from '../migration/custom-migration.service';
|
||||||
import { ModelService } from './models/model.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';
|
import { ObjectMetadata } from './models/dynamic-model.factory';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -12,6 +16,7 @@ export class ObjectService {
|
|||||||
private tenantDbService: TenantDatabaseService,
|
private tenantDbService: TenantDatabaseService,
|
||||||
private customMigrationService: CustomMigrationService,
|
private customMigrationService: CustomMigrationService,
|
||||||
private modelService: ModelService,
|
private modelService: ModelService,
|
||||||
|
private authService: AuthorizationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Setup endpoints - Object metadata management
|
// Setup endpoints - Object metadata management
|
||||||
@@ -225,6 +230,31 @@ export class ObjectService {
|
|||||||
return objectDef;
|
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(
|
async createFieldDefinition(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -418,62 +448,57 @@ export class ObjectService {
|
|||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
// Verify object exists and get field definitions
|
// Get user with roles and permissions
|
||||||
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
|
const user = await User.query(knex)
|
||||||
|
.findById(userId)
|
||||||
|
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
||||||
|
|
||||||
|
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);
|
const tableName = this.getTableName(objectApiName);
|
||||||
|
|
||||||
// Ensure model is registered before attempting to use it
|
// Ensure model is registered
|
||||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
||||||
|
|
||||||
// Try to use the Objection model if available
|
// Use Objection model
|
||||||
try {
|
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||||
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
|
let query = boundModel.query();
|
||||||
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);
|
|
||||||
|
|
||||||
// Add ownership filter if ownerId field exists
|
// Apply authorization scope (modifies query in place)
|
||||||
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
await this.authService.applyScopeToQuery(
|
||||||
if (hasOwner) {
|
query,
|
||||||
query = query.where({ [`${tableName}.ownerId`]: userId });
|
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
|
// Apply additional filters
|
||||||
@@ -481,49 +506,16 @@ export class ObjectService {
|
|||||||
query = query.where(filters);
|
query = query.where(filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get base records
|
const records = await query.select('*');
|
||||||
const records = await query.select(`${tableName}.*`);
|
|
||||||
|
|
||||||
// Fetch and attach related records for lookup fields
|
// Filter fields based on field-level permissions
|
||||||
const lookupFields = objectDef.fields?.filter(f =>
|
const filteredRecords = await Promise.all(
|
||||||
f.type === 'LOOKUP' && f.referenceObject
|
records.map(record =>
|
||||||
) || [];
|
this.authService.filterReadableFields(record, objectDefModel.fields, user)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (lookupFields.length > 0 && records.length > 0) {
|
return filteredRecords;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecord(
|
async getRecord(
|
||||||
@@ -535,93 +527,62 @@ export class ObjectService {
|
|||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
// Verify object exists and get field definitions
|
// Get user with roles and permissions
|
||||||
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
|
const user = await User.query(knex)
|
||||||
|
.findById(userId)
|
||||||
|
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
|
||||||
|
|
||||||
const tableName = this.getTableName(objectApiName);
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure model is registered before attempting to use it
|
// Get object definition with authorization settings
|
||||||
|
const objectDefModel = await ObjectDefinition.query(knex)
|
||||||
|
.findOne({ apiName: objectApiName })
|
||||||
|
.withGraphFetched('fields');
|
||||||
|
|
||||||
|
if (!objectDefModel) {
|
||||||
|
throw new NotFoundException(`Object ${objectApiName} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure model is registered
|
||||||
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
||||||
|
|
||||||
// Try to use the Objection model if available
|
// Use Objection model
|
||||||
try {
|
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||||
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
|
let query = boundModel.query().where({ id: recordId });
|
||||||
if (Model) {
|
|
||||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
|
||||||
let query = boundModel.query().where({ id: recordId });
|
|
||||||
|
|
||||||
// Build graph expression for lookup fields
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to manual data hydration
|
// Apply authorization scope (modifies query in place)
|
||||||
let query = knex(tableName).where({ [`${tableName}.id`]: recordId });
|
await this.authService.applyScopeToQuery(
|
||||||
|
query,
|
||||||
|
objectDefModel,
|
||||||
|
user,
|
||||||
|
'read',
|
||||||
|
knex,
|
||||||
|
);
|
||||||
|
|
||||||
// Add ownership filter if ownerId field exists
|
// Build graph expression for lookup fields
|
||||||
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
const lookupFields = objectDefModel.fields?.filter(f =>
|
||||||
if (hasOwner) {
|
|
||||||
query = query.where({ [`${tableName}.ownerId`]: userId });
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = await query.first();
|
|
||||||
|
|
||||||
if (!record) {
|
|
||||||
throw new NotFoundException('Record not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch and attach related records for lookup fields
|
|
||||||
const lookupFields = objectDef.fields?.filter(f =>
|
|
||||||
f.type === 'LOOKUP' && f.referenceObject
|
f.type === 'LOOKUP' && f.referenceObject
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
if (lookupFields.length > 0) {
|
if (lookupFields.length > 0) {
|
||||||
for (const field of lookupFields) {
|
// Build relation expression - use singular lowercase for relation name
|
||||||
const relationName = field.apiName.replace(/Id$/, '').toLowerCase();
|
const relationExpression = lookupFields
|
||||||
const relatedTable = this.getTableName(field.referenceObject);
|
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
|
||||||
const relatedId = record[field.apiName];
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
if (relatedId) {
|
|
||||||
// Fetch the related record
|
if (relationExpression) {
|
||||||
const relatedRecord = await knex(relatedTable)
|
query = query.withGraphFetched(`[${relationExpression}]`);
|
||||||
.where({ id: relatedId })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (relatedRecord) {
|
|
||||||
record[relationName] = relatedRecord;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const record = await query.first();
|
||||||
|
if (!record) {
|
||||||
|
throw new NotFoundException('Record not found');
|
||||||
|
}
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,46 +595,44 @@ export class ObjectService {
|
|||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
// Verify object exists
|
// Get user with roles and permissions
|
||||||
await this.getObjectDefinition(tenantId, objectApiName);
|
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);
|
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
|
||||||
|
|
||||||
// Try to use the Objection model if available
|
// Use Objection model
|
||||||
try {
|
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||||
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
|
const recordData = {
|
||||||
if (Model) {
|
...editableData,
|
||||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
ownerId: userId, // Auto-set owner
|
||||||
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(),
|
|
||||||
};
|
};
|
||||||
|
const record = await boundModel.query().insert(recordData);
|
||||||
if (hasOwner) {
|
return record;
|
||||||
recordData.ownerId = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [id] = await knex(tableName).insert(recordData);
|
|
||||||
|
|
||||||
return knex(tableName).where({ id }).first();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateRecord(
|
async updateRecord(
|
||||||
@@ -686,39 +645,51 @@ export class ObjectService {
|
|||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
// Verify object exists and user has access
|
// Get user with roles and permissions
|
||||||
await this.getRecord(tenantId, objectApiName, recordId, userId);
|
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
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to raw Knex
|
// Get object definition with authorization settings
|
||||||
await knex(tableName)
|
const objectDefModel = await ObjectDefinition.query(knex)
|
||||||
.where({ id: recordId })
|
.findOne({ apiName: objectApiName })
|
||||||
.update({ ...data, updated_at: knex.fn.now() });
|
.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(
|
async deleteRecord(
|
||||||
@@ -730,28 +701,40 @@ export class ObjectService {
|
|||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
// Verify object exists and user has access
|
// Get user with roles and permissions
|
||||||
await this.getRecord(tenantId, objectApiName, recordId, userId);
|
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
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to raw Knex
|
// Get object definition with authorization settings
|
||||||
await knex(tableName).where({ id: recordId }).delete();
|
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 };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
|
Patch,
|
||||||
Param,
|
Param,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -67,4 +68,13 @@ export class SetupObjectController {
|
|||||||
// Map the created field to frontend format
|
// Map the created field to frontend format
|
||||||
return this.fieldMapperService.mapFieldToDTO(field);
|
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 { Module } from '@nestjs/common';
|
||||||
import { RbacService } from './rbac.service';
|
import { RbacService } from './rbac.service';
|
||||||
|
import { AbilityFactory } from './ability.factory';
|
||||||
|
import { AuthorizationService } from './authorization.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [RbacService],
|
providers: [RbacService, AbilityFactory, AuthorizationService],
|
||||||
exports: [RbacService],
|
exports: [RbacService, AbilityFactory, AuthorizationService],
|
||||||
})
|
})
|
||||||
export class RbacModule {}
|
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 = () => {
|
const handleSave = () => {
|
||||||
if (validateForm()) {
|
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 = () => {
|
const handleSave = () => {
|
||||||
if (validateForm()) {
|
if (validateForm()) {
|
||||||
// Filter out system fields from save data
|
// Start with props.data to preserve system fields like id, then override with user edits
|
||||||
const saveData = { ...formData.value }
|
const saveData = {
|
||||||
const systemFields = ['id', 'tenantId', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy']
|
...props.data,
|
||||||
for (const field of systemFields) {
|
...formData.value,
|
||||||
delete saveData[field]
|
|
||||||
}
|
}
|
||||||
emit('save', saveData)
|
emit('save', saveData)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,9 @@
|
|||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<Tabs v-model="activeTab" default-value="fields" class="w-full">
|
<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="fields">Fields</TabsTrigger>
|
||||||
|
<TabsTrigger value="access">Access</TabsTrigger>
|
||||||
<TabsTrigger value="layouts">Page Layouts</TabsTrigger>
|
<TabsTrigger value="layouts">Page Layouts</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -55,6 +56,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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 -->
|
<!-- Page Layouts Tab -->
|
||||||
<TabsContent value="layouts" class="mt-6">
|
<TabsContent value="layouts" class="mt-6">
|
||||||
<div v-if="!selectedLayout" class="space-y-4">
|
<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 { Button } from '@/components/ui/button'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import PageLayoutEditor from '@/components/PageLayoutEditor.vue'
|
import PageLayoutEditor from '@/components/PageLayoutEditor.vue'
|
||||||
|
import ObjectAccessSettings from '@/components/ObjectAccessSettings.vue'
|
||||||
import type { PageLayout, FieldLayoutItem } from '~/types/page-layout'
|
import type { PageLayout, FieldLayoutItem } from '~/types/page-layout'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -247,7 +258,11 @@ watch(activeTab, (newTab) => {
|
|||||||
fetchLayouts()
|
fetchLayouts()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const handleAccessUpdate = (orgWideDefault: string) => {
|
||||||
|
if (object.value) {
|
||||||
|
object.value.orgWideDefault = orgWideDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchObject()
|
await fetchObject()
|
||||||
// If we start on layouts tab, load them
|
// If we start on layouts tab, load them
|
||||||
|
|||||||
Reference in New Issue
Block a user