WIP - field level permission

This commit is contained in:
Francisco Gaona
2025-12-30 05:54:56 +01:00
parent 56c0c3838d
commit d15fc918d1
8 changed files with 357 additions and 18 deletions

View File

@@ -583,7 +583,10 @@ export class ObjectService {
throw new NotFoundException('Record not found');
}
return record;
// Filter fields based on field-level permissions
const filteredRecord = await this.authService.filterReadableFields(record, objectDefModel.fields, user);
return filteredRecord;
}
async createRecord(
@@ -738,4 +741,57 @@ export class ObjectService {
return { success: true };
}
async getFieldPermissions(tenantId: string, objectId: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get all field permissions for this object's fields
const permissions = await knex('role_field_permissions as rfp')
.join('field_definitions as fd', 'fd.id', 'rfp.fieldDefinitionId')
.where('fd.objectDefinitionId', objectId)
.select('rfp.*');
return permissions;
}
async updateFieldPermission(
tenantId: string,
roleId: string,
fieldDefinitionId: string,
canRead: boolean,
canEdit: boolean,
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Check if permission already exists
const existing = await knex('role_field_permissions')
.where({ roleId, fieldDefinitionId })
.first();
if (existing) {
// Update existing permission
await knex('role_field_permissions')
.where({ roleId, fieldDefinitionId })
.update({
canRead,
canEdit,
updated_at: knex.fn.now(),
});
} else {
// Create new permission
await knex('role_field_permissions').insert({
id: knex.raw('(UUID())'),
roleId,
fieldDefinitionId,
canRead,
canEdit,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
return { success: true };
}
}

View File

@@ -3,6 +3,7 @@ import {
Get,
Post,
Patch,
Put,
Param,
Body,
UseGuards,
@@ -11,6 +12,7 @@ import { ObjectService } from './object.service';
import { FieldMapperService } from './field-mapper.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
@Controller('setup/objects')
@UseGuards(JwtAuthGuard)
@@ -18,6 +20,7 @@ export class SetupObjectController {
constructor(
private objectService: ObjectService,
private fieldMapperService: FieldMapperService,
private tenantDbService: TenantDatabaseService,
) {}
@Get()
@@ -77,4 +80,21 @@ export class SetupObjectController {
) {
return this.objectService.updateObjectDefinition(tenantId, objectApiName, data);
}
@Get(':objectId/field-permissions')
async getFieldPermissions(
@TenantId() tenantId: string,
@Param('objectId') objectId: string,
) {
return this.objectService.getFieldPermissions(tenantId, objectId);
}
@Put(':objectId/field-permissions')
async updateFieldPermission(
@TenantId() tenantId: string,
@Param('objectId') objectId: string,
@Body() data: { roleId: string; fieldDefinitionId: string; canRead: boolean; canEdit: boolean },
) {
return this.objectService.updateFieldPermission(tenantId, data.roleId, data.fieldDefinitionId, data.canRead, data.canEdit);
}
}

View File

@@ -156,7 +156,20 @@ export class AbilityFactory {
return false;
}
// Check all roles for field permission
// Collect all field permissions from all roles
const allFieldPermissions: RoleFieldPermission[] = [];
for (const role of user.roles) {
if (role.fieldPermissions) {
allFieldPermissions.push(...role.fieldPermissions);
}
}
// If there are NO field permissions configured at all, allow by default
if (allFieldPermissions.length === 0) {
return true;
}
// If field permissions exist, check for explicit grants (union of all roles)
for (const role of user.roles) {
if (role.fieldPermissions) {
const fieldPerm = role.fieldPermissions.find(fp => fp.fieldDefinitionId === fieldDefinitionId);
@@ -167,8 +180,8 @@ export class AbilityFactory {
}
}
// Default: allow if no explicit restriction
return true;
// Field permissions exist but this field is not explicitly granted → deny
return false;
}
/**

View File

@@ -2,8 +2,12 @@ import { Module } from '@nestjs/common';
import { RbacService } from './rbac.service';
import { AbilityFactory } from './ability.factory';
import { AuthorizationService } from './authorization.service';
import { SetupRolesController } from './setup-roles.controller';
import { TenantModule } from '../tenant/tenant.module';
@Module({
imports: [TenantModule],
controllers: [SetupRolesController],
providers: [RbacService, AbilityFactory, AuthorizationService],
exports: [RbacService, AbilityFactory, AuthorizationService],
})

View File

@@ -0,0 +1,23 @@
import {
Controller,
Get,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { Role } from '../models/role.model';
@Controller('setup/roles')
@UseGuards(JwtAuthGuard)
export class SetupRolesController {
constructor(private tenantDbService: TenantDatabaseService) {}
@Get()
async getRoles(@TenantId() tenantId: string) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
return await Role.query(knex).select('*').orderBy('name', 'asc');
}
}

View File

@@ -0,0 +1,219 @@
<template>
<Card>
<CardHeader>
<CardTitle>Field-Level Security</CardTitle>
<CardDescription>
Control which fields each role can read and edit
</CardDescription>
</CardHeader>
<CardContent>
<div v-if="loading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<div v-else-if="roles.length === 0" class="text-sm text-muted-foreground py-4">
No roles available. Create roles first to manage field-level permissions.
</div>
<div v-else class="space-y-4">
<div class="rounded-md border">
<table class="w-full">
<thead>
<tr class="border-b bg-muted/50">
<th class="p-3 text-left font-medium">Field</th>
<th
v-for="role in roles"
:key="role.id"
class="p-3 text-center font-medium border-l"
:colspan="2"
>
{{ role.name }}
</th>
</tr>
<tr class="border-b bg-muted/30">
<th class="p-2 text-left text-xs font-medium text-muted-foreground"></th>
<template v-for="role in roles" :key="`${role.id}-headers`">
<th class="p-2 text-center text-xs font-medium text-muted-foreground border-l">Read</th>
<th class="p-2 text-center text-xs font-medium text-muted-foreground">Edit</th>
</template>
</tr>
</thead>
<tbody>
<tr
v-for="field in fields"
:key="field.id"
class="border-b hover:bg-muted/30"
>
<td class="p-3">
<div>
<div class="font-medium">{{ field.label }}</div>
<div class="text-xs text-muted-foreground">{{ field.apiName }}</div>
</div>
</td>
<template v-for="role in roles" :key="`${field.id}-${role.id}`">
<td class="p-3 text-center border-l">
<Checkbox
:model-value="hasPermission(field.id, role.id, 'read')"
@update:model-value="(checked: boolean) => updatePermission(field.id, role.id, 'read', checked)"
:disabled="field.isSystem"
/>
</td>
<td class="p-3 text-center">
<Checkbox
:model-value="hasPermission(field.id, role.id, 'edit')"
@update:model-value="(checked: boolean) => updatePermission(field.id, role.id, 'edit', checked)"
:disabled="field.isSystem || !hasPermission(field.id, role.id, 'read')"
/>
</td>
</template>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Info class="h-4 w-4" />
<span>System fields are always readable. Edit permissions require read permission first. Changes save automatically.</span>
</div>
<div v-if="saving" class="flex items-center gap-2 text-sm text-primary">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span>Saving...</span>
</div>
</div>
</CardContent>
</Card>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
import { Checkbox } from '~/components/ui/checkbox';
import { Info } from 'lucide-vue-next';
const props = defineProps<{
objectId: string;
fields: any[];
}>();
const { api } = useApi();
const { toast } = useToast();
const loading = ref(true);
const saving = ref(false);
const roles = ref<any[]>([]);
const permissions = ref<Map<string, Map<string, { canRead: boolean; canEdit: boolean }>>>(new Map());
// Load roles and permissions
onMounted(async () => {
try {
loading.value = true;
// Load roles
const rolesResponse = await api.get('/setup/roles');
roles.value = rolesResponse || [];
// Load existing permissions for this object
const permsResponse = await api.get(`/setup/objects/${props.objectId}/field-permissions`);
// Build permissions map: fieldId -> roleId -> {canRead, canEdit}
const permsMap = new Map();
if (permsResponse && Array.isArray(permsResponse)) {
for (const perm of permsResponse) {
if (!permsMap.has(perm.fieldDefinitionId)) {
permsMap.set(perm.fieldDefinitionId, new Map());
}
permsMap.get(perm.fieldDefinitionId).set(perm.roleId, {
canRead: Boolean(perm.canRead),
canEdit: Boolean(perm.canEdit),
});
}
}
permissions.value = permsMap;
} catch (error: any) {
console.error('Failed to load field permissions:', error);
toast.error('Failed to load field permissions');
} finally {
loading.value = false;
}
});
const hasPermission = (fieldId: string, roleId: string, type: 'read' | 'edit'): boolean => {
const fieldPerms = permissions.value.get(fieldId);
if (!fieldPerms) return true; // Default to true if no permissions set
const rolePerm = fieldPerms.get(roleId);
if (!rolePerm) return true; // Default to true if no permissions set
const value = type === 'read' ? rolePerm.canRead : rolePerm.canEdit;
return Boolean(value); // Convert 1/0 to true/false
};
const updatePermission = async (fieldId: string, roleId: string, type: 'read' | 'edit', checked: boolean) => {
console.log('updatePermission called:', { fieldId, roleId, type, checked });
try {
saving.value = true;
// Get current permissions
if (!permissions.value.has(fieldId)) {
permissions.value.set(fieldId, new Map());
}
const fieldPerms = permissions.value.get(fieldId)!;
if (!fieldPerms.has(roleId)) {
fieldPerms.set(roleId, { canRead: true, canEdit: true });
}
const perm = fieldPerms.get(roleId)!;
// Update permission
if (type === 'read') {
perm.canRead = checked;
// If disabling read, also disable edit
if (!checked) {
perm.canEdit = false;
}
} else {
perm.canEdit = checked;
// If enabling edit, also enable read
if (checked) {
perm.canRead = true;
}
}
console.log('Saving permission:', {
roleId,
fieldDefinitionId: fieldId,
canRead: perm.canRead,
canEdit: perm.canEdit,
});
// Save to backend
const result = await api.put(`/setup/objects/${props.objectId}/field-permissions`, {
roleId,
fieldDefinitionId: fieldId,
canRead: perm.canRead,
canEdit: perm.canEdit,
});
console.log('Save result:', result);
toast.success('Permission updated');
} catch (error: any) {
console.error('Failed to update field permission:', error);
toast.error(error.message || 'Failed to update permission');
// Revert change
if (!permissions.value.has(fieldId)) return;
const fieldPerms = permissions.value.get(fieldId)!;
if (!fieldPerms.has(roleId)) return;
const perm = fieldPerms.get(roleId)!;
if (type === 'read') {
perm.canRead = !checked;
} else {
perm.canEdit = !checked;
}
} finally {
saving.value = false;
}
};
</script>

View File

@@ -43,20 +43,19 @@
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Field-Level Security</CardTitle>
<CardDescription>
Control field visibility and editability by role (coming soon)
</CardDescription>
</CardHeader>
<CardContent>
<div class="text-sm text-muted-foreground">
Field-level permissions will be managed through role configuration.
Navigate to Setup Roles to configure field access for each role.
</div>
</CardContent>
</Card>
<FieldLevelSecurity
v-if="objectId && fields && fields.length > 0"
:object-id="objectId"
:fields="fields"
/>
<div v-else-if="!objectId" class="text-sm text-muted-foreground">
Object ID not available
</div>
<div v-else-if="!fields || fields.length === 0" class="text-sm text-muted-foreground">
No fields available
</div>
</div>
</template>
@@ -65,10 +64,13 @@ import { ref, watch } from 'vue';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
import { Label } from '~/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
import FieldLevelSecurity from '~/components/FieldLevelSecurity.vue';
const props = defineProps<{
objectApiName: string;
objectId?: string;
orgWideDefault?: string;
fields?: any[];
}>();
const emit = defineEmits<{

View File

@@ -60,7 +60,9 @@
<TabsContent value="access" class="mt-6">
<ObjectAccessSettings
:object-api-name="object.apiName"
:object-id="object.id"
:org-wide-default="object.orgWideDefault"
:fields="object.fields"
@update="handleAccessUpdate"
/>
</TabsContent>