WIP - record sharing
This commit is contained in:
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
|
||||
import { RbacService } from './rbac.service';
|
||||
import { ShareController } from './share.controller';
|
||||
import { RoleController, RoleRuleController } from './role.controller';
|
||||
import { UserController } from './user.controller';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
|
||||
@Module({
|
||||
imports: [TenantModule],
|
||||
providers: [RbacService],
|
||||
controllers: [ShareController, RoleController, RoleRuleController],
|
||||
controllers: [ShareController, RoleController, RoleRuleController, UserController],
|
||||
exports: [RbacService],
|
||||
})
|
||||
export class RbacModule {}
|
||||
|
||||
@@ -8,34 +8,59 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Inject,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { IsString, IsArray, IsOptional, IsDateString } from 'class-validator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/auth.decorators';
|
||||
import { User } from '../models/user.model';
|
||||
import { RecordShare } from '../models/record-share.model';
|
||||
import { ObjectDefinition } from '../models/object-definition.model';
|
||||
import { Knex } from 'knex';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { TenantId } from '../tenant/tenant.decorator';
|
||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||
|
||||
export class CreateShareDto {
|
||||
objectDefinitionId: string;
|
||||
@IsString()
|
||||
objectApiName: string;
|
||||
|
||||
@IsString()
|
||||
recordId: string;
|
||||
|
||||
@IsString()
|
||||
granteeUserId: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
actions: string[]; // ["read"], ["read", "update"], etc.
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
fields?: string[]; // Optional field scoping
|
||||
expiresAt?: Date;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class UpdateShareDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
actions?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
fields?: string[];
|
||||
expiresAt?: Date;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
@Controller('shares')
|
||||
@Controller('rbac/shares')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ShareController {
|
||||
constructor(@Inject('KnexConnection') private readonly knex: Knex) {}
|
||||
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||
|
||||
/**
|
||||
* Create a new share
|
||||
@@ -43,156 +68,175 @@ export class ShareController {
|
||||
*/
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: User,
|
||||
@TenantId() tenantId: string,
|
||||
@CurrentUser() currentUser: any,
|
||||
@Body() createDto: CreateShareDto,
|
||||
) {
|
||||
// Verify the user owns the record or has permission to share
|
||||
const objectDef = await ObjectDefinition.query(this.knex)
|
||||
.findById(createDto.objectDefinitionId);
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
// Get object definition by apiName
|
||||
const objectDef = await knex('object_definitions')
|
||||
.where({ apiName: createDto.objectApiName })
|
||||
.first();
|
||||
|
||||
if (!objectDef) {
|
||||
throw new Error('Object definition not found');
|
||||
throw new NotFoundException('Object definition not found');
|
||||
}
|
||||
|
||||
// TODO: Verify ownership or share permission via CASL
|
||||
// For now, we'll assume authorized
|
||||
// Get the table name for the object
|
||||
const tableName = this.getTableName(createDto.objectApiName);
|
||||
|
||||
const share = await RecordShare.query(this.knex).insert({
|
||||
objectDefinitionId: createDto.objectDefinitionId,
|
||||
recordId: createDto.recordId,
|
||||
granteeUserId: createDto.granteeUserId,
|
||||
grantedByUserId: user.id,
|
||||
// Verify the user owns the record
|
||||
const record = await knex(tableName)
|
||||
.where({ id: createDto.recordId })
|
||||
.first();
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundException('Record not found');
|
||||
}
|
||||
|
||||
if (record.ownerId !== currentUser.userId) {
|
||||
throw new ForbiddenException('Only the record owner can share it');
|
||||
}
|
||||
|
||||
// Create the share
|
||||
const shareId = require('crypto').randomUUID();
|
||||
await knex('record_shares').insert({
|
||||
id: shareId,
|
||||
object_definition_id: objectDef.id,
|
||||
record_id: createDto.recordId,
|
||||
grantee_user_id: createDto.granteeUserId,
|
||||
granted_by_user_id: currentUser.userId,
|
||||
actions: JSON.stringify(createDto.actions),
|
||||
fields: createDto.fields ? JSON.stringify(createDto.fields) : null,
|
||||
expiresAt: createDto.expiresAt,
|
||||
expires_at: createDto.expiresAt,
|
||||
created_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
return share;
|
||||
const share = await knex('record_shares').where({ id: shareId }).first();
|
||||
|
||||
return {
|
||||
...share,
|
||||
actions: typeof share.actions === 'string' ? JSON.parse(share.actions) : share.actions,
|
||||
fields: share.fields ? (typeof share.fields === 'string' ? JSON.parse(share.fields) : share.fields) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private getTableName(objectApiName: string): string {
|
||||
const snakeCase = objectApiName
|
||||
.replace(/([A-Z])/g, '_$1')
|
||||
.toLowerCase()
|
||||
.replace(/^_/, '');
|
||||
|
||||
if (snakeCase.endsWith('y')) {
|
||||
return snakeCase.slice(0, -1) + 'ies';
|
||||
} else if (snakeCase.endsWith('s')) {
|
||||
return snakeCase;
|
||||
} else {
|
||||
return snakeCase + 's';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List shares for a specific record
|
||||
* Only owner or users with access can see shares
|
||||
*/
|
||||
@Get('record/:objectDefinitionId/:recordId')
|
||||
@Get(':objectApiName/:recordId')
|
||||
async listForRecord(
|
||||
@CurrentUser() user: User,
|
||||
@Param('objectDefinitionId') objectDefinitionId: string,
|
||||
@TenantId() tenantId: string,
|
||||
@CurrentUser() currentUser: any,
|
||||
@Param('objectApiName') objectApiName: string,
|
||||
@Param('recordId') recordId: string,
|
||||
) {
|
||||
// TODO: Verify user has access to this record
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
const shares = await RecordShare.query(this.knex)
|
||||
// Get object definition
|
||||
const objectDef = await knex('object_definitions')
|
||||
.where({ apiName: objectApiName })
|
||||
.first();
|
||||
|
||||
if (!objectDef) {
|
||||
throw new NotFoundException('Object definition not found');
|
||||
}
|
||||
|
||||
// Get shares for this record
|
||||
const shares = await knex('record_shares')
|
||||
.where({
|
||||
objectDefinitionId,
|
||||
recordId,
|
||||
object_definition_id: objectDef.id,
|
||||
record_id: recordId,
|
||||
})
|
||||
.whereNull('revokedAt')
|
||||
.withGraphFetched('[granteeUser, grantedByUser]');
|
||||
.whereNull('revoked_at')
|
||||
.select('*');
|
||||
|
||||
return shares.map((share) => ({
|
||||
...share,
|
||||
actions: typeof share.actions === 'string' ? JSON.parse(share.actions) : share.actions,
|
||||
fields: share.fields && typeof share.fields === 'string' ? JSON.parse(share.fields) : share.fields,
|
||||
}));
|
||||
}
|
||||
// Fetch user details for each share
|
||||
const sharesWithUsers = await Promise.all(
|
||||
shares.map(async (share: any) => {
|
||||
const granteeUser = await knex('users')
|
||||
.where({ id: share.grantee_user_id })
|
||||
.select('id', 'email', 'firstName', 'lastName', 'name')
|
||||
.first();
|
||||
|
||||
/**
|
||||
* List shares granted by current user
|
||||
*/
|
||||
@Get('granted')
|
||||
async listGranted(@CurrentUser() user: User) {
|
||||
const shares = await RecordShare.query(this.knex)
|
||||
.where('grantedByUserId', user.id)
|
||||
.whereNull('revokedAt')
|
||||
.withGraphFetched('[granteeUser, objectDefinition]');
|
||||
const grantedByUser = await knex('users')
|
||||
.where({ id: share.granted_by_user_id })
|
||||
.select('id', 'email', 'firstName', 'lastName', 'name')
|
||||
.first();
|
||||
|
||||
return shares.map((share) => ({
|
||||
...share,
|
||||
actions: typeof share.actions === 'string' ? JSON.parse(share.actions) : share.actions,
|
||||
fields: share.fields && typeof share.fields === 'string' ? JSON.parse(share.fields) : share.fields,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* List shares received by current user
|
||||
*/
|
||||
@Get('received')
|
||||
async listReceived(@CurrentUser() user: User) {
|
||||
const shares = await RecordShare.query(this.knex)
|
||||
.where('granteeUserId', user.id)
|
||||
.whereNull('revokedAt')
|
||||
.where(function () {
|
||||
this.whereNull('expiresAt').orWhere('expiresAt', '>', new Date());
|
||||
return {
|
||||
id: share.id,
|
||||
recordId: share.record_id,
|
||||
actions: typeof share.actions === 'string' ? JSON.parse(share.actions) : share.actions,
|
||||
fields: share.fields ? (typeof share.fields === 'string' ? JSON.parse(share.fields) : share.fields) : null,
|
||||
expiresAt: share.expires_at,
|
||||
createdAt: share.created_at,
|
||||
granteeUser: {
|
||||
id: granteeUser.id,
|
||||
email: granteeUser.email,
|
||||
name: granteeUser.firstName && granteeUser.lastName
|
||||
? `${granteeUser.firstName} ${granteeUser.lastName}`
|
||||
: granteeUser.name || granteeUser.email,
|
||||
},
|
||||
grantedByUser: {
|
||||
id: grantedByUser.id,
|
||||
email: grantedByUser.email,
|
||||
name: grantedByUser.firstName && grantedByUser.lastName
|
||||
? `${grantedByUser.firstName} ${grantedByUser.lastName}`
|
||||
: grantedByUser.name || grantedByUser.email,
|
||||
},
|
||||
};
|
||||
})
|
||||
.withGraphFetched('[grantedByUser, objectDefinition]');
|
||||
);
|
||||
|
||||
return shares.map((share) => ({
|
||||
...share,
|
||||
actions: typeof share.actions === 'string' ? JSON.parse(share.actions) : share.actions,
|
||||
fields: share.fields && typeof share.fields === 'string' ? JSON.parse(share.fields) : share.fields,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a share
|
||||
*/
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateShareDto,
|
||||
) {
|
||||
const share = await RecordShare.query(this.knex).findById(id);
|
||||
|
||||
if (!share) {
|
||||
throw new Error('Share not found');
|
||||
}
|
||||
|
||||
// Only the grantor can update
|
||||
if (share.grantedByUserId !== user.id) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const updates: any = {};
|
||||
if (updateDto.actions) {
|
||||
updates.actions = JSON.stringify(updateDto.actions);
|
||||
}
|
||||
if (updateDto.fields !== undefined) {
|
||||
updates.fields = updateDto.fields ? JSON.stringify(updateDto.fields) : null;
|
||||
}
|
||||
if (updateDto.expiresAt !== undefined) {
|
||||
updates.expiresAt = updateDto.expiresAt;
|
||||
}
|
||||
|
||||
const updated = await RecordShare.query(this.knex)
|
||||
.patchAndFetchById(id, updates);
|
||||
|
||||
return {
|
||||
...updated,
|
||||
actions: typeof updated.actions === 'string' ? JSON.parse(updated.actions) : updated.actions,
|
||||
fields: updated.fields && typeof updated.fields === 'string' ? JSON.parse(updated.fields) : updated.fields,
|
||||
};
|
||||
return sharesWithUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a share (soft delete)
|
||||
*/
|
||||
@Delete(':id')
|
||||
async revoke(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
const share = await RecordShare.query(this.knex).findById(id);
|
||||
async revoke(
|
||||
@TenantId() tenantId: string,
|
||||
@CurrentUser() currentUser: any,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
const share = await knex('record_shares').where({ id }).first();
|
||||
|
||||
if (!share) {
|
||||
throw new Error('Share not found');
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
// Only the grantor can revoke
|
||||
if (share.grantedByUserId !== user.id) {
|
||||
throw new Error('Unauthorized');
|
||||
if (share.granted_by_user_id !== currentUser.userId) {
|
||||
throw new ForbiddenException('Unauthorized');
|
||||
}
|
||||
|
||||
await RecordShare.query(this.knex)
|
||||
.patchAndFetchById(id, { revokedAt: new Date() });
|
||||
await knex('record_shares')
|
||||
.where({ id })
|
||||
.update({ revoked_at: knex.fn.now() });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
41
backend/src/rbac/user.controller.ts
Normal file
41
backend/src/rbac/user.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Controller, Get, UseGuards } 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 { User } from '../models/user.model';
|
||||
|
||||
@Controller('rbac/users')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserController {
|
||||
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||
|
||||
@Get()
|
||||
async getUsers(
|
||||
@TenantId() tenantId: string,
|
||||
@CurrentUser() currentUser: any,
|
||||
) {
|
||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||
|
||||
// Get all active users from tenant database (excluding current user)
|
||||
let query = User.query(knex)
|
||||
.select('id', 'email', 'firstName', 'lastName')
|
||||
.where('isActive', true);
|
||||
|
||||
// Exclude current user if we have their ID
|
||||
if (currentUser?.userId) {
|
||||
query = query.whereNot('id', currentUser.userId);
|
||||
}
|
||||
|
||||
const users = await query;
|
||||
|
||||
return users.map((user) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.firstName && user.lastName
|
||||
? `${user.firstName} ${user.lastName}`
|
||||
: user.email,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user