351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
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,
|
|
objectDef.label,
|
|
objectDef.pluralLabel,
|
|
);
|
|
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,
|
|
objectDef.label,
|
|
objectDef.pluralLabel,
|
|
);
|
|
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
|
|
const updated = await RecordShare.query(knex)
|
|
.patchAndFetchById(existingShare.id, {
|
|
accessLevel: {
|
|
canRead: data.canRead,
|
|
canEdit: data.canEdit,
|
|
canDelete: data.canDelete,
|
|
},
|
|
// Convert ISO string to MySQL datetime format
|
|
expiresAt: data.expiresAt
|
|
? knex.raw('?', [new Date(data.expiresAt).toISOString().slice(0, 19).replace('T', ' ')])
|
|
: null,
|
|
} as any);
|
|
|
|
return RecordShare.query(knex)
|
|
.findById(updated.id)
|
|
.withGraphFetched('[granteeUser]');
|
|
}
|
|
|
|
// Create new share
|
|
const share = await RecordShare.query(knex).insertAndFetch({
|
|
objectDefinitionId: objectDef.id,
|
|
recordId,
|
|
granteeUserId: data.granteeUserId,
|
|
grantedByUserId: currentUser.userId,
|
|
accessLevel: {
|
|
canRead: data.canRead,
|
|
canEdit: data.canEdit,
|
|
canDelete: data.canDelete,
|
|
},
|
|
// Convert ISO string to MySQL datetime format: YYYY-MM-DD HH:MM:SS
|
|
expiresAt: data.expiresAt
|
|
? knex.raw('?', [new Date(data.expiresAt).toISOString().slice(0, 19).replace('T', ' ')])
|
|
: null,
|
|
} as any);
|
|
|
|
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,
|
|
objectDef.label,
|
|
objectDef.pluralLabel,
|
|
);
|
|
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: knex.fn.now() as any,
|
|
});
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
private async canUserShareRecord(
|
|
userId: string,
|
|
record: any,
|
|
objectDef: ObjectDefinition,
|
|
knex: any,
|
|
): Promise<boolean> {
|
|
// 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, objectLabel?: string, pluralLabel?: string): string {
|
|
const toSnakePlural = (source: string): string => {
|
|
const cleaned = source.replace(/[\s-]+/g, '_');
|
|
const snake = cleaned
|
|
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
.replace(/__+/g, '_')
|
|
.toLowerCase()
|
|
.replace(/^_/, '');
|
|
|
|
if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
|
|
if (snake.endsWith('s')) return snake;
|
|
return `${snake}s`;
|
|
};
|
|
|
|
const fromApi = toSnakePlural(apiName);
|
|
const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null;
|
|
const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null;
|
|
|
|
if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) {
|
|
return fromLabel;
|
|
}
|
|
if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) {
|
|
return fromPlural;
|
|
}
|
|
|
|
if (fromLabel && fromLabel !== fromApi) return fromLabel;
|
|
if (fromPlural && fromPlural !== fromApi) return fromPlural;
|
|
|
|
return fromApi;
|
|
}
|
|
}
|