From b4bdeeb9f6b157948c3e51ba4885fd8646e8ab06 Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Tue, 30 Dec 2025 03:26:50 +0100 Subject: [PATCH] WIP - permissions progress --- ...20250129000001_add_authorization_system.js | 102 +++++++ backend/package-lock.json | 48 ++++ backend/package.json | 1 + backend/scripts/seed-default-roles.ts | 181 ++++++++++++ backend/src/models/field-definition.model.ts | 8 + backend/src/models/object-definition.model.ts | 13 + backend/src/models/record-share.model.ts | 77 +++++ .../src/models/role-field-permission.model.ts | 51 ++++ .../models/role-object-permission.model.ts | 59 ++++ backend/src/models/role.model.ts | 18 ++ .../object/models/dynamic-model.factory.ts | 3 +- backend/src/object/object.module.ts | 3 +- backend/src/object/object.service.ts | 270 ++++++++++++------ backend/src/object/setup-object.controller.ts | 10 + backend/src/rbac/ability.factory.ts | 185 ++++++++++++ backend/src/rbac/authorization.service.ts | 267 +++++++++++++++++ backend/src/rbac/rbac.module.ts | 6 +- docs/SALESFORCE_AUTHORIZATION.md | 211 ++++++++++++++ frontend/components/ObjectAccessSettings.vue | 116 ++++++++ frontend/components/views/EditView.vue | 7 +- .../components/views/EditViewEnhanced.vue | 9 +- frontend/pages/setup/objects/[apiName].vue | 19 +- 22 files changed, 1565 insertions(+), 99 deletions(-) create mode 100644 backend/migrations/tenant/20250129000001_add_authorization_system.js create mode 100644 backend/scripts/seed-default-roles.ts create mode 100644 backend/src/models/record-share.model.ts create mode 100644 backend/src/models/role-field-permission.model.ts create mode 100644 backend/src/models/role-object-permission.model.ts create mode 100644 backend/src/rbac/ability.factory.ts create mode 100644 backend/src/rbac/authorization.service.ts create mode 100644 docs/SALESFORCE_AUTHORIZATION.md create mode 100644 frontend/components/ObjectAccessSettings.vue diff --git a/backend/migrations/tenant/20250129000001_add_authorization_system.js b/backend/migrations/tenant/20250129000001_add_authorization_system.js new file mode 100644 index 0000000..4590cbb --- /dev/null +++ b/backend/migrations/tenant/20250129000001_add_authorization_system.js @@ -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'); + }); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 8bd1bb0..a044bdd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "@casl/ability": "^6.7.5", "@nestjs/bullmq": "^10.1.0", "@nestjs/common": "^10.3.0", "@nestjs/config": "^3.1.1", @@ -741,6 +742,18 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@casl/ability": { + "version": "6.7.5", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.5.tgz", + "integrity": "sha512-NaOHPi9JMn8Kesh+GRkjNKAYkl4q8qMFAlqw7w2yrE+cBQZSbV9GkBGKvgzs3CdzEc5Yl1cn3JwDxxbBN5gjog==", + "license": "MIT", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -2882,6 +2895,41 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==", + "license": "Apache-2.0" + }, + "node_modules/@ucast/js": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.4.tgz", + "integrity": "sha512-TgG1aIaCMdcaEyckOZKQozn1hazE0w90SVdlpIJ/er8xVumE11gYAtSbw/LBeUnA4fFnFWTcw3t6reqseeH/4Q==", + "license": "Apache-2.0", + "dependencies": { + "@ucast/core": "^1.0.0" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "license": "Apache-2.0", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.4.0.tgz", + "integrity": "sha512-vR9RJ3BHlkI3RfKJIZFdVktxWvBCQRiSTeJSWN9NPxP5YJkpfXvcBWAMLwvyJx4HbB+qib5/AlSDEmQiuQyx2w==", + "license": "Apache-2.0", + "dependencies": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", diff --git a/backend/package.json b/backend/package.json index 4e02006..7097ad5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "migrate:all-tenants": "ts-node -r tsconfig-paths/register scripts/migrate-all-tenants.ts" }, "dependencies": { + "@casl/ability": "^6.7.5", "@nestjs/bullmq": "^10.1.0", "@nestjs/common": "^10.3.0", "@nestjs/config": "^3.1.1", diff --git a/backend/scripts/seed-default-roles.ts b/backend/scripts/seed-default-roles.ts new file mode 100644 index 0000000..a1dc76e --- /dev/null +++ b/backend/scripts/seed-default-roles.ts @@ -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); + }); diff --git a/backend/src/models/field-definition.model.ts b/backend/src/models/field-definition.model.ts index 382b708..661e389 100644 --- a/backend/src/models/field-definition.model.ts +++ b/backend/src/models/field-definition.model.ts @@ -74,5 +74,13 @@ export class FieldDefinition extends BaseModel { to: 'object_definitions.id', }, }, + rolePermissions: { + relation: BaseModel.HasManyRelation, + modelClass: () => require('./role-field-permission.model').RoleFieldPermission, + join: { + from: 'field_definitions.id', + to: 'role_field_permissions.fieldDefinitionId', + }, + }, }; } diff --git a/backend/src/models/object-definition.model.ts b/backend/src/models/object-definition.model.ts index 7f5516b..1f614c4 100644 --- a/backend/src/models/object-definition.model.ts +++ b/backend/src/models/object-definition.model.ts @@ -10,8 +10,11 @@ export class ObjectDefinition extends BaseModel { description?: string; isSystem: boolean; isCustom: boolean; + orgWideDefault: 'private' | 'public_read' | 'public_read_write'; createdAt: Date; updatedAt: Date; + fields?: any[]; + rolePermissions?: any[]; static get jsonSchema() { return { @@ -25,12 +28,14 @@ export class ObjectDefinition extends BaseModel { description: { type: 'string' }, isSystem: { type: 'boolean' }, isCustom: { type: 'boolean' }, + orgWideDefault: { type: 'string', enum: ['private', 'public_read', 'public_read_write'] }, }, }; } static get relationMappings() { const { FieldDefinition } = require('./field-definition.model'); + const { RoleObjectPermission } = require('./role-object-permission.model'); return { fields: { @@ -41,6 +46,14 @@ export class ObjectDefinition extends BaseModel { to: 'field_definitions.objectDefinitionId', }, }, + rolePermissions: { + relation: BaseModel.HasManyRelation, + modelClass: RoleObjectPermission, + join: { + from: 'object_definitions.id', + to: 'role_object_permissions.objectDefinitionId', + }, + }, }; } } diff --git a/backend/src/models/record-share.model.ts b/backend/src/models/record-share.model.ts new file mode 100644 index 0000000..acff015 --- /dev/null +++ b/backend/src/models/record-share.model.ts @@ -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', + }, + }, + }; + } +} diff --git a/backend/src/models/role-field-permission.model.ts b/backend/src/models/role-field-permission.model.ts new file mode 100644 index 0000000..d816add --- /dev/null +++ b/backend/src/models/role-field-permission.model.ts @@ -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', + }, + }, + }; + } +} diff --git a/backend/src/models/role-object-permission.model.ts b/backend/src/models/role-object-permission.model.ts new file mode 100644 index 0000000..290c771 --- /dev/null +++ b/backend/src/models/role-object-permission.model.ts @@ -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', + }, + }, + }; + } +} diff --git a/backend/src/models/role.model.ts b/backend/src/models/role.model.ts index 4d55bb6..f145027 100644 --- a/backend/src/models/role.model.ts +++ b/backend/src/models/role.model.ts @@ -27,6 +27,8 @@ export class Role extends BaseModel { const { RolePermission } = require('./role-permission.model'); const { Permission } = require('./permission.model'); const { User } = require('./user.model'); + const { RoleObjectPermission } = require('./role-object-permission.model'); + const { RoleFieldPermission } = require('./role-field-permission.model'); return { rolePermissions: { @@ -61,6 +63,22 @@ export class Role extends BaseModel { to: 'users.id', }, }, + objectPermissions: { + relation: BaseModel.HasManyRelation, + modelClass: RoleObjectPermission, + join: { + from: 'roles.id', + to: 'role_object_permissions.roleId', + }, + }, + fieldPermissions: { + relation: BaseModel.HasManyRelation, + modelClass: RoleFieldPermission, + join: { + from: 'roles.id', + to: 'role_field_permissions.roleId', + }, + }, }; } } diff --git a/backend/src/object/models/dynamic-model.factory.ts b/backend/src/object/models/dynamic-model.factory.ts index 669de82..575f5e8 100644 --- a/backend/src/object/models/dynamic-model.factory.ts +++ b/backend/src/object/models/dynamic-model.factory.ts @@ -49,7 +49,8 @@ export class DynamicModelFactory { updated_at: { type: 'string', format: 'date-time' }, }; - const required: string[] = ['id', 'tenantId']; + // Don't require id or tenantId - they'll be set automatically + const required: string[] = []; // Add custom fields for (const field of fields) { diff --git a/backend/src/object/object.module.ts b/backend/src/object/object.module.ts index bbb8ef0..7304302 100644 --- a/backend/src/object/object.module.ts +++ b/backend/src/object/object.module.ts @@ -6,11 +6,12 @@ import { SchemaManagementService } from './schema-management.service'; import { FieldMapperService } from './field-mapper.service'; import { TenantModule } from '../tenant/tenant.module'; import { MigrationModule } from '../migration/migration.module'; +import { RbacModule } from '../rbac/rbac.module'; import { ModelRegistry } from './models/model.registry'; import { ModelService } from './models/model.service'; @Module({ - imports: [TenantModule, MigrationModule], + imports: [TenantModule, MigrationModule, RbacModule], providers: [ ObjectService, SchemaManagementService, diff --git a/backend/src/object/object.service.ts b/backend/src/object/object.service.ts index 2ed66f3..26aa258 100644 --- a/backend/src/object/object.service.ts +++ b/backend/src/object/object.service.ts @@ -2,6 +2,10 @@ import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { TenantDatabaseService } from '../tenant/tenant-database.service'; import { CustomMigrationService } from '../migration/custom-migration.service'; import { ModelService } from './models/model.service'; +import { AuthorizationService } from '../rbac/authorization.service'; +import { ObjectDefinition } from '../models/object-definition.model'; +import { FieldDefinition } from '../models/field-definition.model'; +import { User } from '../models/user.model'; import { ObjectMetadata } from './models/dynamic-model.factory'; @Injectable() @@ -12,6 +16,7 @@ export class ObjectService { private tenantDbService: TenantDatabaseService, private customMigrationService: CustomMigrationService, private modelService: ModelService, + private authService: AuthorizationService, ) {} // Setup endpoints - Object metadata management @@ -225,6 +230,31 @@ export class ObjectService { return objectDef; } + async updateObjectDefinition( + tenantId: string, + objectApiName: string, + data: Partial<{ + label: string; + pluralLabel: string; + description: string; + orgWideDefault: 'private' | 'public_read' | 'public_read_write'; + }>, + ) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + // Update the object definition + await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }) + .patch({ + ...data, + updatedAt: new Date(), + }); + + // Return updated object + return await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }); + } async createFieldDefinition( tenantId: string, @@ -418,8 +448,23 @@ export class ObjectService { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); - // Verify object exists and get field definitions - const objectDef = await this.getObjectDefinition(tenantId, objectApiName); + // Get user with roles and permissions + const user = await User.query(knex) + .findById(userId) + .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Get object definition with authorization settings + const objectDefModel = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }) + .withGraphFetched('fields'); + + if (!objectDefModel) { + throw new NotFoundException(`Object ${objectApiName} not found`); + } const tableName = this.getTableName(objectApiName); @@ -427,14 +472,24 @@ export class ObjectService { await this.ensureModelRegistered(resolvedTenantId, objectApiName); // Try to use the Objection model if available + let records = []; try { const Model = this.modelService.getModel(resolvedTenantId, objectApiName); if (Model) { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); let query = boundModel.query(); + // Apply authorization scope (modifies query in place) + await this.authService.applyScopeToQuery( + query, + objectDefModel, + user, + 'read', + knex, + ); + // Build graph expression for lookup fields - const lookupFields = objectDef.fields?.filter(f => + const lookupFields = objectDefModel.fields?.filter(f => f.type === 'LOOKUP' && f.referenceObject ) || []; @@ -450,80 +505,44 @@ export class ObjectService { } } - // Add ownership filter if ownerId field exists - const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); - if (hasOwner) { - query = query.where({ ownerId: userId }); - } - // Apply additional filters if (filters) { query = query.where(filters); } - return query.select('*'); + records = await query.select('*'); } } catch (error) { - this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`); - } - - // Fallback to manual data hydration - let query = knex(tableName); - - // Add ownership filter if ownerId field exists - const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); - if (hasOwner) { - query = query.where({ [`${tableName}.ownerId`]: userId }); - } - - // Apply additional filters - if (filters) { - query = query.where(filters); - } - - // Get base records - const records = await query.select(`${tableName}.*`); - - // Fetch and attach related records for lookup fields - const lookupFields = objectDef.fields?.filter(f => - f.type === 'LOOKUP' && f.referenceObject - ) || []; - - if (lookupFields.length > 0 && records.length > 0) { - for (const field of lookupFields) { - const relationName = field.apiName.replace(/Id$/, '').toLowerCase(); - const relatedTable = this.getTableName(field.referenceObject); - - // Get unique IDs to fetch - const relatedIds = [...new Set( - records - .map(r => r[field.apiName]) - .filter(Boolean) - )]; - - if (relatedIds.length > 0) { - // Fetch all related records in one query - const relatedRecords = await knex(relatedTable) - .whereIn('id', relatedIds) - .select('*'); - - // Create a map for quick lookup - const relatedMap = new Map( - relatedRecords.map(r => [r.id, r]) - ); - - // Attach related records to main records - for (const record of records) { - const relatedId = record[field.apiName]; - if (relatedId && relatedMap.has(relatedId)) { - record[relationName] = relatedMap.get(relatedId); - } - } - } + this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual query: ${error.message}`); + + // Fallback to Knex query with authorization + let query = knex(tableName); + + // Apply additional filters before authorization scope + if (filters) { + query = query.where(filters); } + + // Apply authorization scope (modifies query in place) + await this.authService.applyScopeToQuery( + query, + objectDefModel, + user, + 'read', + knex, + ); + + records = await query.select('*'); } - return records; + // Filter fields based on field-level permissions + const filteredRecords = await Promise.all( + records.map(record => + this.authService.filterReadableFields(record, objectDefModel.fields, user) + ) + ); + + return filteredRecords; } async getRecord( @@ -634,8 +653,32 @@ export class ObjectService { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); - // Verify object exists - await this.getObjectDefinition(tenantId, objectApiName); + // Get user with roles and permissions + const user = await User.query(knex) + .findById(userId) + .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Get object definition with authorization settings + const objectDefModel = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }) + .withGraphFetched('fields'); + + if (!objectDefModel) { + throw new NotFoundException(`Object ${objectApiName} not found`); + } + + // Check if user has create permission + const canCreate = await this.authService.canCreate(objectDefModel, user); + if (!canCreate) { + throw new NotFoundException('You do not have permission to create records of this object'); + } + + // Filter data to only editable fields + const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user); // Ensure model is registered before attempting to use it await this.ensureModelRegistered(resolvedTenantId, objectApiName); @@ -646,7 +689,7 @@ export class ObjectService { if (Model) { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); const recordData = { - ...data, + ...editableData, ownerId: userId, // Auto-set owner }; const record = await boundModel.query().insert(recordData); @@ -660,9 +703,13 @@ export class ObjectService { const tableName = this.getTableName(objectApiName); const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); + // Generate UUID for the record + const result = await knex.raw('SELECT UUID() as uuid'); + const uuid = result[0][0].uuid; + const recordData: any = { - id: knex.raw('(UUID())'), - ...data, + id: uuid, + ...editableData, created_at: knex.fn.now(), updated_at: knex.fn.now(), }; @@ -671,9 +718,9 @@ export class ObjectService { recordData.ownerId = userId; } - const [id] = await knex(tableName).insert(recordData); + await knex(tableName).insert(recordData); - return knex(tableName).where({ id }).first(); + return knex(tableName).where({ id: uuid }).first(); } async updateRecord( @@ -686,10 +733,43 @@ export class ObjectService { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); - // Verify object exists and user has access - await this.getRecord(tenantId, objectApiName, recordId, userId); + // Get user with roles and permissions + const user = await User.query(knex) + .findById(userId) + .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Get object definition with authorization settings + const objectDefModel = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }) + .withGraphFetched('fields'); + + if (!objectDefModel) { + throw new NotFoundException(`Object ${objectApiName} not found`); + } const tableName = this.getTableName(objectApiName); + + // Get existing record + const existingRecord = await knex(tableName).where({ id: recordId }).first(); + if (!existingRecord) { + throw new NotFoundException('Record not found'); + } + + // Check if user can update this record + await this.authService.assertCanPerformAction('update', objectDefModel, existingRecord, user, knex); + + // Filter data to only editable fields + const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user); + + // Remove system fields + delete editableData.id; + delete editableData.ownerId; + delete editableData.created_at; + delete editableData.tenantId; // Ensure model is registered before attempting to use it await this.ensureModelRegistered(resolvedTenantId, objectApiName); @@ -699,14 +779,7 @@ export class ObjectService { const Model = this.modelService.getModel(resolvedTenantId, objectApiName); if (Model) { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); - // Don't allow updating ownerId or system fields - const allowedData = { ...data }; - delete allowedData.ownerId; - delete allowedData.id; - delete allowedData.created_at; - delete allowedData.tenantId; - - await boundModel.query().where({ id: recordId }).update(allowedData); + await boundModel.query().where({ id: recordId }).update(editableData); return boundModel.query().where({ id: recordId }).first(); } } catch (error) { @@ -716,7 +789,7 @@ export class ObjectService { // Fallback to raw Knex await knex(tableName) .where({ id: recordId }) - .update({ ...data, updated_at: knex.fn.now() }); + .update({ ...editableData, updated_at: knex.fn.now() }); return knex(tableName).where({ id: recordId }).first(); } @@ -730,10 +803,33 @@ export class ObjectService { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); - // Verify object exists and user has access - await this.getRecord(tenantId, objectApiName, recordId, userId); + // Get user with roles and permissions + const user = await User.query(knex) + .findById(userId) + .withGraphFetched('[roles.[objectPermissions, fieldPermissions]]'); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Get object definition with authorization settings + const objectDefModel = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }); + + if (!objectDefModel) { + throw new NotFoundException(`Object ${objectApiName} not found`); + } const tableName = this.getTableName(objectApiName); + + // Get existing record + const existingRecord = await knex(tableName).where({ id: recordId }).first(); + if (!existingRecord) { + throw new NotFoundException('Record not found'); + } + + // Check if user can delete this record + await this.authService.assertCanPerformAction('delete', objectDefModel, existingRecord, user, knex); // Ensure model is registered before attempting to use it await this.ensureModelRegistered(resolvedTenantId, objectApiName); diff --git a/backend/src/object/setup-object.controller.ts b/backend/src/object/setup-object.controller.ts index af849fa..ebb4713 100644 --- a/backend/src/object/setup-object.controller.ts +++ b/backend/src/object/setup-object.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Post, + Patch, Param, Body, UseGuards, @@ -67,4 +68,13 @@ export class SetupObjectController { // Map the created field to frontend format return this.fieldMapperService.mapFieldToDTO(field); } + + @Patch(':objectApiName') + async updateObjectDefinition( + @TenantId() tenantId: string, + @Param('objectApiName') objectApiName: string, + @Body() data: any, + ) { + return this.objectService.updateObjectDefinition(tenantId, objectApiName, data); + } } diff --git a/backend/src/rbac/ability.factory.ts b/backend/src/rbac/ability.factory.ts new file mode 100644 index 0000000..67178e5 --- /dev/null +++ b/backend/src/rbac/ability.factory.ts @@ -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 { + const { can, cannot, build } = new AbilityBuilder(PureAbility as AbilityClass); + + if (!user.roles || user.roles.length === 0) { + // No roles = no permissions + return build(); + } + + // Aggregate object permissions from all roles + const objectPermissionsMap = new Map(); + + // Aggregate field permissions from all roles + const fieldPermissionsMap = new Map(); + + // 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)); + } +} diff --git a/backend/src/rbac/authorization.service.ts b/backend/src/rbac/authorization.service.ts new file mode 100644 index 0000000..1a7bdf3 --- /dev/null +++ b/backend/src/rbac/authorization.service.ts @@ -0,0 +1,267 @@ +import { Injectable, ForbiddenException } from '@nestjs/common'; +import { Knex } from 'knex'; +import { User } from '../models/user.model'; +import { ObjectDefinition } from '../models/object-definition.model'; +import { FieldDefinition } from '../models/field-definition.model'; +import { RecordShare } from '../models/record-share.model'; +import { AbilityFactory, AppAbility, Action } from './ability.factory'; +import { subject } from '@casl/ability'; + +@Injectable() +export class AuthorizationService { + constructor(private abilityFactory: AbilityFactory) {} + + /** + * Apply authorization scope to a query based on OWD and user permissions + * This determines which records the user can see + * Modifies the query in place and returns void + */ + async applyScopeToQuery( + query: any, // Accept both Knex and Objection query builders + objectDef: ObjectDefinition, + user: User & { roles?: any[] }, + action: Action, + knex: Knex, + ): Promise { + // 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( + query: any, // Accept both Knex and Objection query builders + objectDef: ObjectDefinition, + user: User, + recordShares: RecordShare[], + knex: Knex, + ): Promise { + 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 { + const recordShares = await this.getActiveRecordShares(objectDef.id, user.id, knex); + const ability = await this.abilityFactory.defineAbilityFor(user, recordShares); + + // Check base permission - use object ID not API name + if (!ability.can(action, objectDef.id)) { + return false; + } + + // Check special permissions - use object ID not API name + const hasViewAll = ability.can('view_all', objectDef.id); + const hasModifyAll = ability.can('modify_all', objectDef.id); + + if (hasViewAll || hasModifyAll) { + return true; + } + + // Check OWD + switch (objectDef.orgWideDefault) { + case 'public_read_write': + return true; + + case 'public_read': + if (action === 'read') return true; + // For write actions, check ownership + return record.ownerId === user.id; + + case 'private': + default: + // Check ownership + if (record.ownerId === user.id) return true; + + // Check if record is shared with user + const share = recordShares.find(s => s.recordId === record.id); + if (share) { + if (action === 'read' && share.accessLevel.canRead) return true; + if (action === 'update' && share.accessLevel.canEdit) return true; + if (action === 'delete' && share.accessLevel.canDelete) return true; + } + + return false; + } + } + + /** + * Filter data based on field-level permissions + * Removes fields the user cannot read + */ + async filterReadableFields( + data: any, + fields: FieldDefinition[], + user: User & { roles?: any[] }, + ): Promise { + const filtered: any = {}; + + // Always include id - it's required for navigation and record identification + if (data.id !== undefined) { + filtered.id = data.id; + } + + for (const field of fields) { + if (this.abilityFactory.canAccessField(field.id, 'read', user)) { + if (data[field.apiName] !== undefined) { + filtered[field.apiName] = data[field.apiName]; + } + } + } + + return filtered; + } + + /** + * Filter data based on field-level permissions + * Removes fields the user cannot edit + */ + async filterEditableFields( + data: any, + fields: FieldDefinition[], + user: User & { roles?: any[] }, + ): Promise { + 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 { + 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 { + 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 { + 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'; + } + } +} diff --git a/backend/src/rbac/rbac.module.ts b/backend/src/rbac/rbac.module.ts index 2e7af4d..c648404 100644 --- a/backend/src/rbac/rbac.module.ts +++ b/backend/src/rbac/rbac.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { RbacService } from './rbac.service'; +import { AbilityFactory } from './ability.factory'; +import { AuthorizationService } from './authorization.service'; @Module({ - providers: [RbacService], - exports: [RbacService], + providers: [RbacService, AbilityFactory, AuthorizationService], + exports: [RbacService, AbilityFactory, AuthorizationService], }) export class RbacModule {} diff --git a/docs/SALESFORCE_AUTHORIZATION.md b/docs/SALESFORCE_AUTHORIZATION.md new file mode 100644 index 0000000..4371cb3 --- /dev/null +++ b/docs/SALESFORCE_AUTHORIZATION.md @@ -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 diff --git a/frontend/components/ObjectAccessSettings.vue b/frontend/components/ObjectAccessSettings.vue new file mode 100644 index 0000000..0df7aed --- /dev/null +++ b/frontend/components/ObjectAccessSettings.vue @@ -0,0 +1,116 @@ + + + diff --git a/frontend/components/views/EditView.vue b/frontend/components/views/EditView.vue index a4854dc..ad688da 100644 --- a/frontend/components/views/EditView.vue +++ b/frontend/components/views/EditView.vue @@ -137,7 +137,12 @@ const validateForm = (): boolean => { const handleSave = () => { if (validateForm()) { - emit('save', { ...formData.value }) + // Start with props.data to preserve system fields like id, then override with user edits + const dataToSave = { + ...props.data, + ...formData.value, + } + emit('save', dataToSave) } } diff --git a/frontend/components/views/EditViewEnhanced.vue b/frontend/components/views/EditViewEnhanced.vue index e968653..d802a6e 100644 --- a/frontend/components/views/EditViewEnhanced.vue +++ b/frontend/components/views/EditViewEnhanced.vue @@ -160,11 +160,10 @@ const validateForm = (): boolean => { const handleSave = () => { if (validateForm()) { - // Filter out system fields from save data - const saveData = { ...formData.value } - const systemFields = ['id', 'tenantId', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'] - for (const field of systemFields) { - delete saveData[field] + // Start with props.data to preserve system fields like id, then override with user edits + const saveData = { + ...props.data, + ...formData.value, } emit('save', saveData) } diff --git a/frontend/pages/setup/objects/[apiName].vue b/frontend/pages/setup/objects/[apiName].vue index b0e2a6c..e53862f 100644 --- a/frontend/pages/setup/objects/[apiName].vue +++ b/frontend/pages/setup/objects/[apiName].vue @@ -16,8 +16,9 @@
- + Fields + Access Page Layouts @@ -55,6 +56,15 @@
+ + + + +
@@ -138,6 +148,7 @@ import { Plus, Trash2, ArrowLeft } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import PageLayoutEditor from '@/components/PageLayoutEditor.vue' +import ObjectAccessSettings from '@/components/ObjectAccessSettings.vue' import type { PageLayout, FieldLayoutItem } from '~/types/page-layout' const route = useRoute() @@ -247,7 +258,11 @@ watch(activeTab, (newTab) => { fetchLayouts() } }) - +const handleAccessUpdate = (orgWideDefault: string) => { + if (object.value) { + object.value.orgWideDefault = orgWideDefault + } +} onMounted(async () => { await fetchObject() // If we start on layouts tab, load them