WIP - more progress with permissions

This commit is contained in:
Francisco Gaona
2025-12-28 06:48:03 +01:00
parent 88f656c3f5
commit ac4a4b68cd
8 changed files with 333 additions and 91 deletions

View File

@@ -49,7 +49,9 @@ export class DynamicModelFactory {
updated_at: { type: 'string', format: 'date-time' },
};
const required: string[] = ['id', 'tenantId'];
// Don't require system-managed fields (id, tenantId, ownerId, timestamps)
// These are auto-set by hooks or database
const required: string[] = [];
// Add custom fields
for (const field of fields) {
@@ -134,15 +136,16 @@ export class DynamicModelFactory {
this.id = randomUUID();
}
if (!this.created_at) {
this.created_at = new Date().toISOString();
this.created_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
}
if (!this.updated_at) {
this.updated_at = new Date().toISOString();
this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
}
}
async $beforeUpdate() {
this.updated_at = new Date().toISOString();
async $beforeUpdate(opt: any, queryContext: any) {
await super.$beforeUpdate(opt, queryContext);
this.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ');
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { CustomMigrationService } from '../migration/custom-migration.service';
import { ModelService } from './models/model.service';
@@ -350,6 +350,53 @@ export class ObjectService {
return typeMap[frontendType] || 'TEXT';
}
/**
* Filter incoming data to only include writable fields based on field definitions
* Removes system fields and fields that don't exist in the schema
*/
private async filterWritableFields(
tenantId: string,
objectApiName: string,
data: any,
isUpdate: boolean = false,
): Promise<any> {
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
const filtered: any = {};
for (const [key, value] of Object.entries(data)) {
// Find the field definition
const fieldDef = objectDef.fields.find((f: any) => f.apiName === key);
if (!fieldDef) {
// Field doesn't exist in schema, skip it
this.logger.warn(`Field ${key} not found in ${objectApiName} schema, skipping`);
continue;
}
// Skip system fields
if (fieldDef.isSystem) {
this.logger.debug(`Skipping system field ${key}`);
continue;
}
// Check if field is writable (for authorization)
if (fieldDef.defaultWritable === false) {
this.logger.warn(`Field ${key} is not writable, skipping`);
continue;
}
// For update operations, also skip ID field
if (isUpdate && key === 'id') {
continue;
}
// Field is valid and writable, include it
filtered[key] = value;
}
return filtered;
}
/**
* Ensure a model is registered for the given object.
* Delegates to ModelService which handles creating the model and all its dependencies.
@@ -553,6 +600,21 @@ export class ObjectService {
// Verify object exists and get field definitions
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDefModel) {
throw new NotFoundException('Object definition not found');
}
// Get user model for authorization
const user = await User.query(knex).findById(userId).withGraphFetched('roles');
if (!user) {
throw new NotFoundException('User not found');
}
const tableName = this.getTableName(objectApiName);
// Ensure model is registered before attempting to use it
@@ -565,6 +627,9 @@ export class ObjectService {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query().where({ id: recordId });
// Apply authorization scoping
query = applyReadScope(query, user, objectDefModel, knex);
// Build graph expression for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
@@ -582,15 +647,9 @@ 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 });
}
const record = await query.first();
if (!record) {
throw new NotFoundException('Record not found');
throw new NotFoundException('Record not found or you do not have access');
}
return record;
}
@@ -598,10 +657,10 @@ export class ObjectService {
this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`);
}
// Fallback to manual data hydration
// Fallback to manual data hydration - Note: This path doesn't support authorization scoping yet
let query = knex(tableName).where({ [`${tableName}.id`]: recordId });
// Add ownership filter if ownerId field exists
// Add ownership filter if ownerId field exists (basic fallback)
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) {
query = query.where({ [`${tableName}.ownerId`]: userId });
@@ -610,7 +669,7 @@ export class ObjectService {
const record = await query.first();
if (!record) {
throw new NotFoundException('Record not found');
throw new NotFoundException('Record not found or you do not have access');
}
// Fetch and attach related records for lookup fields
@@ -652,6 +711,32 @@ export class ObjectService {
// Verify object exists
await this.getObjectDefinition(tenantId, objectApiName);
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDefModel) {
throw new NotFoundException('Object definition not found');
}
// Check create permission
if (!objectDefModel.publicCreate) {
// Get user with roles to check role-based permissions
const user = await User.query(knex).findById(userId).withGraphFetched('roles');
if (!user) {
throw new NotFoundException('User not found');
}
// TODO: Check role-based create permissions from role_rules
// For now, only allow if publicCreate is true
throw new ForbiddenException('You do not have permission to create records for this object');
}
// Filter data to only include writable fields based on field definitions
// Do this BEFORE model registration so both Objection and fallback paths use clean data
const allowedData = await this.filterWritableFields(tenantId, objectApiName, data, false);
// Ensure model is registered before attempting to use it
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
@@ -660,8 +745,9 @@ export class ObjectService {
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
if (Model) {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
const recordData = {
...data,
...allowedData,
ownerId: userId, // Auto-set owner
};
const record = await boundModel.query().insert(recordData);
@@ -677,7 +763,7 @@ export class ObjectService {
const recordData: any = {
id: knex.raw('(UUID())'),
...data,
...allowedData, // Use filtered data instead of raw data
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
};
@@ -701,37 +787,65 @@ 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 object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
const tableName = this.getTableName(objectApiName);
// Ensure model is registered before attempting to use it
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available
try {
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);
return boundModel.query().where({ id: recordId }).first();
}
} catch (error) {
console.warn(`Could not use Objection model for ${objectApiName}:`, error.message);
if (!objectDefModel) {
throw new NotFoundException('Object definition not found');
}
// Get user model for authorization
const user = await User.query(knex).findById(userId).withGraphFetched('roles');
if (!user) {
throw new NotFoundException('User not found');
}
// Filter data to only include writable fields based on field definitions
// Do this BEFORE authorization checks so both paths use clean data
const allowedData = await this.filterWritableFields(tenantId, objectApiName, data, true);
// Verify user has access to read the record first (using authorization scope)
const tableName = this.getTableName(objectApiName);
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
if (Model) {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let checkQuery = boundModel.query().where({ id: recordId });
checkQuery = applyUpdateScope(checkQuery, user, objectDefModel, knex);
const existingRecord = await checkQuery.first();
if (!existingRecord) {
throw new ForbiddenException('You do not have permission to update this record');
}
this.logger.log(`[UPDATE] Record ID: ${recordId}, Type: ${typeof recordId}`);
this.logger.log(`[UPDATE] Existing record ID: ${existingRecord.id}, Type: ${typeof existingRecord.id}`);
this.logger.log(`[UPDATE] Allowed data:`, JSON.stringify(allowedData));
const numUpdated = await boundModel.query().where({ id: recordId }).update(allowedData);
this.logger.log(`[UPDATE] Number of records updated: ${numUpdated}`);
const updatedRecord = await boundModel.query().where({ id: recordId }).first();
this.logger.log(`[UPDATE] Updated record:`, updatedRecord ? 'found' : 'NOT FOUND');
return updatedRecord;
}
// Fallback to raw Knex with basic ownership check
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner && !objectDefModel.publicUpdate) {
const record = await knex(tableName).where({ id: recordId, ownerId: userId }).first();
if (!record) {
throw new ForbiddenException('You do not have permission to update this record');
}
}
// Fallback to raw Knex
await knex(tableName)
.where({ id: recordId })
.update({ ...data, updated_at: knex.fn.now() });
.update({ ...allowedData, updated_at: knex.fn.now() }); // Use filtered data
return knex(tableName).where({ id: recordId }).first();
}
@@ -745,27 +859,51 @@ 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 object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDefModel) {
throw new NotFoundException('Object definition not found');
}
// Get user model for authorization
const user = await User.query(knex).findById(userId).withGraphFetched('roles');
if (!user) {
throw new NotFoundException('User not found');
}
const tableName = this.getTableName(objectApiName);
// Ensure model is registered before attempting to use it
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available
try {
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
if (Model) {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
await boundModel.query().where({ id: recordId }).delete();
return { success: true };
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
if (Model) {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
// Check if user has permission to delete this record
let checkQuery = boundModel.query().where({ id: recordId });
checkQuery = applyDeleteScope(checkQuery, user, objectDefModel, knex);
const existingRecord = await checkQuery.first();
if (!existingRecord) {
throw new ForbiddenException('You do not have permission to delete this record');
}
await boundModel.query().where({ id: recordId }).delete();
return { success: true };
}
// Fallback to raw Knex with basic ownership check
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner && !objectDefModel.publicDelete) {
const record = await knex(tableName).where({ id: recordId, ownerId: userId }).first();
if (!record) {
throw new ForbiddenException('You do not have permission to delete this record');
}
} catch (error) {
console.warn(`Could not use Objection model for ${objectApiName}:`, error.message);
}
// Fallback to raw Knex
await knex(tableName).where({ id: recordId }).delete();
return { success: true };

View File

@@ -12,6 +12,8 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible'
console.log('[EditView] COMPONENT MOUNTING')
interface Props {
config: EditViewConfig
data?: any
@@ -25,6 +27,8 @@ const props = withDefaults(defineProps<Props>(), {
saving: false,
})
console.log('[EditView] Props received on mount:', JSON.stringify(props, null, 2))
const emit = defineEmits<{
'save': [data: any]
'cancel': []
@@ -35,10 +39,16 @@ const emit = defineEmits<{
const formData = ref<Record<string, any>>({ ...props.data })
const errors = ref<Record<string, string>>({})
console.log('[EditView] Initial props.data:', JSON.stringify(props.data, null, 2))
console.log('[EditView] props.data.id:', props.data?.id)
// Watch for data changes (useful for edit mode)
watch(() => props.data, (newData) => {
console.log('[EditView] Data changed:', JSON.stringify(newData, null, 2))
console.log('[EditView] newData.id:', newData?.id)
console.log('[EditView] Keys in newData:', Object.keys(newData))
formData.value = { ...newData }
}, { deep: true })
}, { deep: true, immediate: true })
// Organize fields into sections
const sections = computed<FieldSection[]>(() => {
@@ -137,7 +147,11 @@ const validateForm = (): boolean => {
const handleSave = () => {
if (validateForm()) {
emit('save', { ...formData.value })
// Preserve id and other system fields from original data when saving
emit('save', {
id: props.data?.id, // Preserve the record ID for updates
...formData.value
})
}
}

View File

@@ -45,11 +45,16 @@ const errors = ref<Record<string, string>>({})
// Watch for data changes (useful for edit mode)
watch(() => props.data, (newData) => {
console.log('[EditViewEnhanced] Data changed:', newData)
console.log('[EditViewEnhanced] Data has id?', newData?.id)
formData.value = { ...newData }
}, { deep: true })
}, { deep: true, immediate: true })
// Fetch page layout if objectId is provided
onMounted(async () => {
console.log('[EditViewEnhanced] Component mounted')
console.log('[EditViewEnhanced] Props:', props)
if (props.objectId) {
try {
loadingLayout.value = true
@@ -159,13 +164,27 @@ const validateForm = (): boolean => {
}
const handleSave = () => {
console.log('[EditViewEnhanced] handleSave called')
console.log('[EditViewEnhanced] props.data:', props.data)
console.log('[EditViewEnhanced] props.data?.id:', props.data?.id)
console.log('[EditViewEnhanced] formData before processing:', { ...formData.value })
if (validateForm()) {
// Filter out system fields from save data
// Preserve the id from props.data if it exists (needed for updates)
// Filter out other system fields that are auto-managed
const saveData = { ...formData.value }
const systemFields = ['id', 'tenantId', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy']
for (const field of systemFields) {
const systemFieldsToRemove = ['tenantId', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy']
for (const field of systemFieldsToRemove) {
delete saveData[field]
}
// Explicitly preserve id if it exists in the original data
if (props.data?.id) {
saveData.id = props.data.id
console.log('[EditViewEnhanced] Preserved id from props:', saveData.id)
}
console.log('[EditViewEnhanced] Final saveData:', saveData)
emit('save', saveData)
}
}

View File

@@ -22,6 +22,7 @@ interface Props {
loading?: boolean
selectable?: boolean
baseUrl?: string
canCreate?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -29,6 +30,7 @@ const props = withDefaults(defineProps<Props>(), {
loading: false,
selectable: false,
baseUrl: '/runtime/objects',
canCreate: true,
})
const emit = defineEmits<{
@@ -145,7 +147,7 @@ const handleAction = (actionId: string) => {
</Button>
<!-- Create -->
<Button size="sm" @click="emit('create')">
<Button v-if="props.canCreate" size="sm" @click="emit('create')">
<Plus class="h-4 w-4 mr-2" />
New
</Button>

View File

@@ -45,7 +45,9 @@ export const useApi = () => {
toast.error('Your session has expired. Please login again.')
router.push('/login')
}
throw new Error('Unauthorized')
const error = new Error('Unauthorized')
;(error as any).status = 401
throw error
}
if (response.status === 403) {
@@ -59,17 +61,24 @@ export const useApi = () => {
router.push('/login')
}
}
throw new Error('Forbidden')
// Don't log 403 errors - create error with status flag
const error = new Error('Forbidden')
;(error as any).status = 403
throw error
}
if (!response.ok) {
// Try to get error details from response
const text = await response.text()
console.error('API Error Response:', {
status: response.status,
statusText: response.statusText,
body: text
})
// Only log unexpected errors (not 401 or 403 which are handled above)
if (response.status !== 401 && response.status !== 403) {
console.error('API Error Response:', {
status: response.status,
statusText: response.statusText,
body: text
})
}
let errorMessage = `HTTP error! status: ${response.status}`
if (text) {

View File

@@ -197,7 +197,10 @@ export const useViewState = <T extends { id?: string }>(
records.value = response.data || response || []
} catch (e: any) {
error.value = e.message
console.error('Failed to fetch records:', e)
// Only log unexpected errors (not authorization failures)
if (e.status !== 401 && e.status !== 403) {
console.error('Failed to fetch records:', e)
}
} finally {
loading.value = false
}
@@ -210,9 +213,14 @@ export const useViewState = <T extends { id?: string }>(
const response = await api.get(`${apiEndpoint}/${id}`)
// Handle response - data might be directly in response or in response.data
currentRecord.value = response.data || response
console.log('[fetchRecord] Fetched record:', JSON.stringify(currentRecord.value, null, 2))
console.log('[fetchRecord] Record has id?', currentRecord.value?.id)
} catch (e: any) {
error.value = e.message
console.error('Failed to fetch record:', e)
// Only log unexpected errors (not authorization failures)
if (e.status !== 401 && e.status !== 403) {
console.error('Failed to fetch record:', e)
}
} finally {
loading.value = false
}
@@ -231,7 +239,7 @@ export const useViewState = <T extends { id?: string }>(
return recordData
} catch (e: any) {
error.value = e.message
console.error('Failed to create record:', e)
// Don't log to console - errors are already handled by useApi and shown via toast
throw e
} finally {
saving.value = false
@@ -256,7 +264,10 @@ export const useViewState = <T extends { id?: string }>(
return recordData
} catch (e: any) {
error.value = e.message
console.error('Failed to update record:', e)
// Only log unexpected errors (not authorization failures)
if (e.status !== 401 && e.status !== 403) {
console.error('Failed to update record:', e)
}
throw e
} finally {
saving.value = false
@@ -274,7 +285,10 @@ export const useViewState = <T extends { id?: string }>(
}
} catch (e: any) {
error.value = e.message
console.error('Failed to delete record:', e)
// Only log unexpected errors (not authorization failures)
if (e.status !== 401 && e.status !== 403) {
console.error('Failed to delete record:', e)
}
throw e
} finally {
loading.value = false
@@ -289,7 +303,10 @@ export const useViewState = <T extends { id?: string }>(
records.value = records.value.filter(r => !ids.includes(r.id!))
} catch (e: any) {
error.value = e.message
console.error('Failed to delete records:', e)
// Only log unexpected errors (not authorization failures)
if (e.status !== 401 && e.status !== 403) {
console.error('Failed to delete records:', e)
}
throw e
} finally {
loading.value = false
@@ -312,10 +329,17 @@ export const useViewState = <T extends { id?: string }>(
}
const handleSave = async (data: T) => {
// DEBUG: Check if id is present
console.log('[handleSave] Data received:', JSON.stringify(data, null, 2))
console.log('[handleSave] data.id:', data.id)
console.log('[handleSave] currentRecord.value:', currentRecord.value)
let savedRecord
if (data.id) {
console.log('[handleSave] Calling updateRecord (PUT)')
savedRecord = await updateRecord(data.id, data)
} else {
console.log('[handleSave] Calling createRecord (POST) - ID IS MISSING!')
savedRecord = await createRecord(data)
}
return savedRecord

View File

@@ -32,6 +32,7 @@ const view = computed(() => {
// State
const objectDefinition = ref<any>(null)
const objectAccess = ref<any>(null)
const loading = ref(true)
const error = ref<string | null>(null)
@@ -118,9 +119,23 @@ const detailConfig = computed(() => {
const editConfig = computed(() => {
if (!objectDefinition.value) return null
return buildEditViewConfig(objectDefinition.value)
const config = buildEditViewConfig(objectDefinition.value)
console.log('[PAGE] editConfig computed:', config ? 'EXISTS' : 'NULL')
return config
})
// Debug current view state
watch([view, recordId, editConfig, currentRecord, loading, dataLoading], ([v, rid, ec, cr, l, dl]) => {
console.log('[PAGE] View state changed:')
console.log(' - view:', v)
console.log(' - recordId:', rid)
console.log(' - editConfig exists?', !!ec)
console.log(' - currentRecord exists?', !!cr)
console.log(' - loading:', l)
console.log(' - dataLoading:', dl)
console.log(' - Should show EditView?', (v === 'edit' || rid === 'new') && !!ec)
}, { immediate: true })
// Fetch object definition
const fetchObjectDefinition = async () => {
try {
@@ -128,6 +143,21 @@ const fetchObjectDefinition = async () => {
error.value = null
const response = await api.get(`/setup/objects/${objectApiName.value}`)
objectDefinition.value = response
// Fetch access permissions
try {
const accessResponse = await api.get(`/setup/objects/${objectApiName.value}/access`)
objectAccess.value = accessResponse
} catch (e) {
console.warn('Failed to fetch access permissions:', e)
// Set defaults if fetch fails
objectAccess.value = {
publicCreate: true,
publicRead: true,
publicUpdate: true,
publicDelete: true,
}
}
} catch (e: any) {
error.value = e.message || 'Failed to load object definition'
console.error('Error fetching object definition:', e)
@@ -261,6 +291,7 @@ onMounted(async () => {
:data="records"
:loading="dataLoading"
:base-url="`/runtime/objects`"
:can-create="objectAccess?.publicCreate !== false"
selectable
@row-click="handleRowClick"
@create="handleCreate"
@@ -282,18 +313,20 @@ onMounted(async () => {
/>
<!-- Edit View -->
<EditView
v-else-if="(view === 'edit' || recordId === 'new') && editConfig"
:config="editConfig"
:data="currentRecord || {}"
:loading="dataLoading"
:saving="saving"
:object-id="objectDefinition?.id"
:base-url="`/runtime/objects`"
@save="handleSaveRecord"
@cancel="handleCancel"
@back="handleBack"
/>
<div v-else-if="(view === 'edit' || recordId === 'new') && editConfig">
<div v-if="false">DEBUG: EditView should render here. view={{ view }}, recordId={{ recordId }}, editConfig={{ !!editConfig }}, currentRecord={{ !!currentRecord }}</div>
<EditView
:config="editConfig"
:data="currentRecord || {}"
:loading="dataLoading"
:saving="saving"
:object-id="objectDefinition?.id"
:base-url="`/runtime/objects`"
@save="handleSaveRecord"
@cancel="handleCancel"
@back="handleBack"
/>
</div>
</div>
</NuxtLayout>
</template>