diff --git a/backend/migrations/tenant/20250129000001_add_authorization_system.js b/backend/migrations/tenant/20250129000001_add_authorization_system.js index 4590cbb..adc626a 100644 --- a/backend/migrations/tenant/20250129000001_add_authorization_system.js +++ b/backend/migrations/tenant/20250129000001_add_authorization_system.js @@ -68,6 +68,7 @@ exports.up = function (knex) { table.timestamp('expiresAt').nullable(); table.timestamp('revokedAt').nullable(); table.timestamp('createdAt').defaultTo(knex.fn.now()); + table.timestamp('updatedAt').defaultTo(knex.fn.now()); table .foreign('objectDefinitionId') diff --git a/backend/migrations/tenant/20250130000001_add_updated_at_to_record_shares.js b/backend/migrations/tenant/20250130000001_add_updated_at_to_record_shares.js new file mode 100644 index 0000000..1238a96 --- /dev/null +++ b/backend/migrations/tenant/20250130000001_add_updated_at_to_record_shares.js @@ -0,0 +1,13 @@ +exports.up = function (knex) { + return knex.schema + .table('record_shares', (table) => { + table.timestamp('updatedAt').defaultTo(knex.fn.now()); + }); +}; + +exports.down = function (knex) { + return knex.schema + .table('record_shares', (table) => { + table.dropColumn('updatedAt'); + }); +}; diff --git a/backend/src/models/record-share.model.ts b/backend/src/models/record-share.model.ts index acff015..07c5523 100644 --- a/backend/src/models/record-share.model.ts +++ b/backend/src/models/record-share.model.ts @@ -9,6 +9,18 @@ export interface RecordShareAccessLevel { export class RecordShare extends BaseModel { static tableName = 'record_shares'; + // Disable automatic snake_case conversion for this table + static get columnNameMappers() { + return { + parse(obj: any) { + return obj; + }, + format(obj: any) { + return obj; + }, + }; + } + id!: string; objectDefinitionId!: string; recordId!: string; @@ -37,8 +49,8 @@ export class RecordShare extends BaseModel { canDelete: { type: 'boolean' }, }, }, - expiresAt: { type: 'string', format: 'date-time' }, - revokedAt: { type: 'string', format: 'date-time' }, + expiresAt: { type: ['string', 'null'], format: 'date-time' }, + revokedAt: { type: ['string', 'null'], format: 'date-time' }, }, }; } diff --git a/backend/src/rbac/dto/create-record-share.dto.ts b/backend/src/rbac/dto/create-record-share.dto.ts new file mode 100644 index 0000000..998e648 --- /dev/null +++ b/backend/src/rbac/dto/create-record-share.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsBoolean, IsOptional, IsDateString } from 'class-validator'; + +export class CreateRecordShareDto { + @IsString() + granteeUserId: string; + + @IsBoolean() + canRead: boolean; + + @IsBoolean() + canEdit: boolean; + + @IsBoolean() + canDelete: boolean; + + @IsOptional() + @IsDateString() + expiresAt?: string; +} diff --git a/backend/src/rbac/rbac.module.ts b/backend/src/rbac/rbac.module.ts index 8e2a820..a7a8fcb 100644 --- a/backend/src/rbac/rbac.module.ts +++ b/backend/src/rbac/rbac.module.ts @@ -4,11 +4,12 @@ import { AbilityFactory } from './ability.factory'; import { AuthorizationService } from './authorization.service'; import { SetupRolesController } from './setup-roles.controller'; import { SetupUsersController } from './setup-users.controller'; +import { RecordSharingController } from './record-sharing.controller'; import { TenantModule } from '../tenant/tenant.module'; @Module({ imports: [TenantModule], - controllers: [SetupRolesController, SetupUsersController], + controllers: [SetupRolesController, SetupUsersController, RecordSharingController], providers: [RbacService, AbilityFactory, AuthorizationService], exports: [RbacService, AbilityFactory, AuthorizationService], }) diff --git a/backend/src/rbac/record-sharing.controller.ts b/backend/src/rbac/record-sharing.controller.ts new file mode 100644 index 0000000..04e0102 --- /dev/null +++ b/backend/src/rbac/record-sharing.controller.ts @@ -0,0 +1,318 @@ +import { + Controller, + Get, + Post, + Delete, + Param, + Body, + UseGuards, + ForbiddenException, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { TenantId } from '../tenant/tenant.decorator'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { TenantDatabaseService } from '../tenant/tenant-database.service'; +import { RecordShare } from '../models/record-share.model'; +import { ObjectDefinition } from '../models/object-definition.model'; +import { User } from '../models/user.model'; +import { AuthorizationService } from './authorization.service'; +import { CreateRecordShareDto } from './dto/create-record-share.dto'; + +@Controller('runtime/objects/:objectApiName/records/:recordId/shares') +@UseGuards(JwtAuthGuard) +export class RecordSharingController { + constructor( + private tenantDbService: TenantDatabaseService, + private authService: AuthorizationService, + ) {} + + @Get() + async getRecordShares( + @TenantId() tenantId: string, + @Param('objectApiName') objectApiName: string, + @Param('recordId') recordId: string, + @CurrentUser() currentUser: any, + ) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + // Get object definition + const objectDef = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }); + + if (!objectDef) { + throw new Error('Object not found'); + } + + // Get the record to check ownership + const tableName = this.getTableName(objectDef.apiName); + const record = await knex(tableName) + .where({ id: recordId }) + .first(); + + if (!record) { + throw new Error('Record not found'); + } + + // Only owner can view shares + if (record.ownerId !== currentUser.userId) { + // Check if user has modify all permission + const user: any = await User.query(knex) + .findById(currentUser.userId) + .withGraphFetched('roles.objectPermissions'); + + if (!user) { + throw new ForbiddenException('User not found'); + } + + const hasModifyAll = user.roles?.some(role => + role.objectPermissions?.some( + perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll + ) + ); + + if (!hasModifyAll) { + throw new ForbiddenException('Only the record owner or users with Modify All permission can view shares'); + } + } + + // Get all active shares for this record + const shares = await RecordShare.query(knex) + .where({ objectDefinitionId: objectDef.id, recordId }) + .whereNull('revokedAt') + .where(builder => { + builder.whereNull('expiresAt').orWhere('expiresAt', '>', new Date()); + }) + .withGraphFetched('[granteeUser]') + .orderBy('createdAt', 'desc'); + + return shares; + } + + @Post() + async createRecordShare( + @TenantId() tenantId: string, + @Param('objectApiName') objectApiName: string, + @Param('recordId') recordId: string, + @CurrentUser() currentUser: any, + @Body() data: CreateRecordShareDto, + ) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + // Get object definition + const objectDef = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }); + + if (!objectDef) { + throw new Error('Object not found'); + } + + // Get the record to check ownership + const tableName = this.getTableName(objectDef.apiName); + const record = await knex(tableName) + .where({ id: recordId }) + .first(); + + if (!record) { + throw new Error('Record not found'); + } + + // Check if user can share - either owner or has modify permissions + const canShare = await this.canUserShareRecord( + currentUser.userId, + record, + objectDef, + knex, + ); + + if (!canShare) { + throw new ForbiddenException('You do not have permission to share this record'); + } + + // Cannot share with self + if (data.granteeUserId === currentUser.userId) { + throw new Error('Cannot share record with yourself'); + } + + // Check if share already exists + const existingShare = await RecordShare.query(knex) + .where({ + objectDefinitionId: objectDef.id, + recordId, + granteeUserId: data.granteeUserId, + }) + .whereNull('revokedAt') + .first(); + + if (existingShare) { + // Update existing share + await RecordShare.query(knex) + .patchAndFetchById(existingShare.id, { + accessLevel: { + canRead: data.canRead, + canEdit: data.canEdit, + canDelete: data.canDelete, + }, + expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, + }); + + return RecordShare.query(knex) + .findById(existingShare.id) + .withGraphFetched('[granteeUser]'); + } + + // Create new share + const share = await RecordShare.query(knex).insert({ + objectDefinitionId: objectDef.id, + recordId, + granteeUserId: data.granteeUserId, + grantedByUserId: currentUser.userId, + accessLevel: { + canRead: data.canRead, + canEdit: data.canEdit, + canDelete: data.canDelete, + }, + expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, + }); + + return RecordShare.query(knex) + .findById(share.id) + .withGraphFetched('[granteeUser]'); + } + + @Delete(':shareId') + async deleteRecordShare( + @TenantId() tenantId: string, + @Param('objectApiName') objectApiName: string, + @Param('recordId') recordId: string, + @Param('shareId') shareId: string, + @CurrentUser() currentUser: any, + ) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + // Get object definition + const objectDef = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }); + + if (!objectDef) { + throw new Error('Object not found'); + } + + // Get the record to check ownership + const tableName = this.getTableName(objectDef.apiName); + const record = await knex(tableName) + .where({ id: recordId }) + .first(); + + if (!record) { + throw new Error('Record not found'); + } + + // Only owner can revoke shares + if (record.ownerId !== currentUser.userId) { + // Check if user has modify all permission + const user: any = await User.query(knex) + .findById(currentUser.userId) + .withGraphFetched('roles.objectPermissions'); + + if (!user) { + throw new ForbiddenException('User not found'); + } + + const hasModifyAll = user.roles?.some(role => + role.objectPermissions?.some( + perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll + ) + ); + + if (!hasModifyAll) { + throw new ForbiddenException('Only the record owner or users with Modify All permission can revoke shares'); + } + } + + // Revoke the share (soft delete) + await RecordShare.query(knex) + .patchAndFetchById(shareId, { + revokedAt: new Date(), + }); + + return { success: true }; + } + + private async canUserShareRecord( + userId: string, + record: any, + objectDef: ObjectDefinition, + knex: any, + ): Promise { + // Owner can always share + if (record.ownerId === userId) { + return true; + } + + // Check if user has modify all or edit permissions + const user: any = await User.query(knex) + .findById(userId) + .withGraphFetched('roles.objectPermissions'); + + if (!user) { + return false; + } + + // Check for canModifyAll permission + const hasModifyAll = user.roles?.some(role => + role.objectPermissions?.some( + perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll + ) + ); + + if (hasModifyAll) { + return true; + } + + // Check for canEdit permission (user needs edit to share) + const hasEdit = user.roles?.some(role => + role.objectPermissions?.some( + perm => perm.objectDefinitionId === objectDef.id && perm.canEdit + ) + ); + + // If user has edit permission, check if they can actually edit this record + // by using the authorization service + if (hasEdit) { + try { + await this.authService.assertCanPerformAction( + 'update', + objectDef, + record, + user, + knex, + ); + return true; + } catch { + return false; + } + } + + return false; + } + + 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 + 'es'; + } else { + return snakeCase + 's'; + } + } +} diff --git a/frontend/components/RecordSharing.vue b/frontend/components/RecordSharing.vue new file mode 100644 index 0000000..8519a7d --- /dev/null +++ b/frontend/components/RecordSharing.vue @@ -0,0 +1,317 @@ + + + diff --git a/frontend/components/ui/checkbox.vue b/frontend/components/ui/checkbox.vue new file mode 100644 index 0000000..1f2169d --- /dev/null +++ b/frontend/components/ui/checkbox.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/components/views/DetailViewEnhanced.vue b/frontend/components/views/DetailViewEnhanced.vue index 0de4bb8..bcb65ae 100644 --- a/frontend/components/views/DetailViewEnhanced.vue +++ b/frontend/components/views/DetailViewEnhanced.vue @@ -2,9 +2,11 @@ import { computed, ref, onMounted } from 'vue' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import FieldRenderer from '@/components/fields/FieldRenderer.vue' import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue' import RelatedList from '@/components/RelatedList.vue' +import RecordSharing from '@/components/RecordSharing.vue' import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types' import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next' import { @@ -20,11 +22,13 @@ interface Props { loading?: boolean objectId?: string // For fetching page layout baseUrl?: string + showSharing?: boolean } const props = withDefaults(defineProps(), { loading: false, baseUrl: '/runtime/objects', + showSharing: true, }) const emit = defineEmits<{ @@ -130,91 +134,123 @@ const usePageLayout = computed(() => {
- - - - Details - - - - - + + + + Details + + Related + + + Sharing + + - -
- - + + + + - -
+ Details + + + + + + + +
+ + + + +
+ {{ section.title }} + + {{ section.description }} + +
+
+
+ + +
+ +
+
+
+
+ + +
+
+ - - -
- - -
- -
+
+
+