diff --git a/HISTORY.md b/HISTORY.md index 70354f92..45e63312 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,7 @@ +## 4.3.7 +- Added a `listAccessibleFields()` helper to `DataAccessor`, documented the metadata shape, and covered it with tests for permission-sensitive scenarios. +- Expanded the fixture OpenAI data agent with field discovery and record-creation tools that rely on `DataAccessor` permissions so the assistant can create entries safely. + ## 4.3.6 - Configured OpenAI API integration through environment variables for AI assistant functionality. - Added dotenv support for loading environment variables from .env file in fixture startup. diff --git a/docs/AccessRights/AccessRightsModelFields.md b/docs/AccessRights/AccessRightsModelFields.md index 893ebc6a..f543f84a 100644 --- a/docs/AccessRights/AccessRightsModelFields.md +++ b/docs/AccessRights/AccessRightsModelFields.md @@ -21,17 +21,36 @@ This class: --- -#### **Key Features** - -* **Access-aware field resolution**: Only exposes fields the current user is allowed to see or edit. -* **Dynamic config merging**: Combines global and action-specific model field configs, ensuring the right context is applied. -* **Association support**: Handles both `BelongsTo` and `HasMany` style associations with optional population and recursive field filtering. -* **Multi-level access logic**: Enforces both direct and intermediate relation-based access restrictions using the `userAccessRelation` model config key. -* **CRUD-agnostic**: Designed to be used with various actions (`add`, `edit`, `view`, `list`) with unified processing logic. - ---- - -#### **Typical Use Cases** +#### **Key Features** + +* **Access-aware field resolution**: Only exposes fields the current user is allowed to see or edit. +* **Dynamic config merging**: Combines global and action-specific model field configs, ensuring the right context is applied. +* **Association support**: Handles both `BelongsTo` and `HasMany` style associations with optional population and recursive field filtering. +* **Multi-level access logic**: Enforces both direct and intermediate relation-based access restrictions using the `userAccessRelation` model config key. +* **CRUD-agnostic**: Designed to be used with various actions (`add`, `edit`, `view`, `list`) with unified processing logic. +* **Field metadata helper**: `listAccessibleFields()` returns a normalized summary of writable fields so automated clients (for example, AI agents) can build valid payloads without guessing configuration details. + +#### **Listing Accessible Fields** + +Call `listAccessibleFields()` after constructing a `DataAccessor` to receive an array of metadata objects with the following shape: + +```ts +{ + name: string; + label: string; + type?: string; + required: boolean; + description?: string; + association?: { model?: string; collection?: string; multiple: boolean }; + options?: BaseFieldConfig['options']; +} +``` + +Only fields that pass the current user's permission checks are returned. This makes it safe to auto-generate forms or AI prompts because the helper mirrors the same field filtering applied during `add`/`edit` operations. + +--- + +#### **Typical Use Cases** * Building an admin panel or API layer where user-specific permissions restrict what data can be read, written, or modified * Ensuring consistent permission logic across different parts of the system (e.g., listing vs viewing a single item) diff --git a/docs/AiAssistant.md b/docs/AiAssistant.md index 75417d4e..7fdaac04 100644 --- a/docs/AiAssistant.md +++ b/docs/AiAssistant.md @@ -40,7 +40,7 @@ Example payload for creating a record: } ``` -If the user lacks the required access token (for example, `create-example-model`), the agent responds with an authorization error instead of touching the database. The `openai` fixture user (`login: openai`, `password: openai`) belongs to the administrators group, granting full access for experimentation. Regular users can be granted permissions by assigning the `ai-assistant-openai` token to their groups. +If the user lacks the required access token (for example, `create-example-model`), the agent responds with an authorization error instead of touching the database. The `openai` fixture user (`login: openai`, `password: openai`) belongs to the administrators group, granting full access for experimentation. Regular users can be granted permissions by assigning the `ai-assistant-openai` token to their groups. The fixture agent now exposes helper tooling so conversations can first discover which fields are writable (`describe_model_fields`) and then create data (`create_model_record`) without violating model permissions. ## Backend Overview @@ -81,6 +81,9 @@ OpenAI's Agents API while still relying on Adminizer's abstractions: `AbstractAiModelService`. * Database reads are performed through `DataAccessor`, which means the usual access control and field sanitisation rules are enforced automatically. +* `describe_model_fields` surfaces the list of writable fields using `DataAccessor.listAccessibleFields()` so prompts can include accurate payloads. +* `query_model_records` retrieves live data with per-field filtering while respecting the caller's `read-*` permissions. +* `create_model_record` persists new rows via `DataAccessor` and automatically applies user ownership rules before saving. * Conversation history is converted into the `@openai/agents` protocol so follow-up questions can build on previous answers. diff --git a/fixture/helpers/ai/OpenAiDataAgentService.ts b/fixture/helpers/ai/OpenAiDataAgentService.ts index 321744f7..cc9b092e 100644 --- a/fixture/helpers/ai/OpenAiDataAgentService.ts +++ b/fixture/helpers/ai/OpenAiDataAgentService.ts @@ -9,7 +9,7 @@ import { } from '@openai/agents'; import {AbstractAiModelService} from '../../../dist/lib/ai-assistant/AbstractAiModelService'; import {AiAssistantMessage, Entity} from '../../../dist/interfaces/types'; -import {ModelConfig} from '../../../dist/interfaces/adminpanelConfig'; +import {ActionType, ModelConfig} from '../../../dist/interfaces/adminpanelConfig'; import {Adminizer} from '../../../dist/lib/Adminizer'; import {DataAccessor} from '../../../dist/lib/DataAccessor'; import {UserAP} from '../../../dist/models/UserAP'; @@ -70,9 +70,15 @@ export class OpenAiDataAgentService extends AbstractAiModelService { } private createAgent(user: UserAP): Agent { - const accessibleModels = this.listReadableModels(user); + const accessibleModels = this.listModelCapabilities(user); const modelSummary = accessibleModels.length > 0 - ? accessibleModels.map(({name, config}) => `• ${name} (model key: ${config.model})`).join('\n') + ? accessibleModels.map(({name, config, canRead, canCreate}) => { + const abilities = [ + canRead ? 'read' : null, + canCreate ? 'create' : null, + ].filter(Boolean).join(', '); + return `• ${name} (model key: ${config.model}) — ${abilities}`; + }).join('\n') : 'No models are currently accessible.'; const dataQueryTool = tool({ @@ -102,21 +108,22 @@ export class OpenAiDataAgentService extends AbstractAiModelService { description: 'Maximum number of records to return (default 10).' } }, - required: ['model', 'filter', 'fields', 'limit'], + required: ['model'], additionalProperties: false }, execute: async (input: any, runContext?: RunContext) => { const activeUser = runContext?.context?.user ?? user; - + if (!input.model) { throw new Error('Model name is required'); } - + const entity = this.resolveEntity(input.model); if (!entity.model) { throw new Error(`Model "${input.model}" is not registered in Adminizer.`); } + this.ensurePermission(entity, activeUser, 'list'); const accessor = new DataAccessor(this.adminizer, activeUser, entity, 'list'); let criteria = {}; if (input.filter && input.filter.trim()) { @@ -127,7 +134,9 @@ export class OpenAiDataAgentService extends AbstractAiModelService { } } const records = await entity.model.find(criteria, accessor); - const limited = records.slice(0, input.limit ?? 10); + const limit = typeof input.limit === 'number' ? Math.floor(input.limit) : undefined; + const safeLimit = limit ? Math.min(Math.max(limit, 1), 50) : 10; + const limited = records.slice(0, safeLimit); const projected = input.fields && input.fields.length > 0 ? limited.map((record) => this.pickFields(record, input.fields ?? [])) : limited; @@ -140,11 +149,105 @@ export class OpenAiDataAgentService extends AbstractAiModelService { }, }); + const describeFieldsTool = tool({ + name: 'describe_model_fields', + description: 'List the fields that can be populated when creating records for the selected model.', + parameters: { + type: 'object', + properties: { + model: { + type: 'string', + description: 'Model name as defined in the Adminizer configuration', + minLength: 1, + }, + }, + required: ['model'], + additionalProperties: false, + }, + execute: async (input: any, runContext?: RunContext) => { + const activeUser = runContext?.context?.user ?? user; + + if (!input.model) { + throw new Error('Model name is required'); + } + + const entity = this.resolveEntity(input.model); + if (!entity.model) { + throw new Error(`Model "${input.model}" is not registered in Adminizer.`); + } + + this.ensurePermission(entity, activeUser, 'add'); + const accessor = new DataAccessor(this.adminizer, activeUser, entity, 'add'); + const fields = accessor.listAccessibleFields(); + + if (fields.length === 0) { + return JSON.stringify({ + model: entity.name, + fields: [], + note: 'No fields are available with the current permissions.', + }, null, 2); + } + + return JSON.stringify({ + model: entity.name, + fields, + }, null, 2); + }, + }); + + const createRecordTool = tool({ + name: 'create_model_record', + description: 'Create a new record in an Adminizer model using DataAccessor with full permission checks.', + parameters: { + type: 'object', + properties: { + model: { + type: 'string', + description: 'Model name as defined in the Adminizer configuration', + minLength: 1, + }, + data: { + type: 'object', + description: 'Field values for the new record. Use describe_model_fields to learn available fields.', + additionalProperties: true, + }, + }, + required: ['model', 'data'], + additionalProperties: false, + }, + execute: async (input: any, runContext?: RunContext) => { + const activeUser = runContext?.context?.user ?? user; + + if (!input.model) { + throw new Error('Model name is required'); + } + + if (!input.data || typeof input.data !== 'object') { + throw new Error('The "data" property must be an object with field values.'); + } + + const entity = this.resolveEntity(input.model); + if (!entity.model) { + throw new Error(`Model "${input.model}" is not registered in Adminizer.`); + } + + this.ensurePermission(entity, activeUser, 'add'); + const accessor = new DataAccessor(this.adminizer, activeUser, entity, 'add'); + const created = await entity.model.create(input.data, accessor); + + return JSON.stringify({ + model: entity.name, + record: created, + }, null, 2); + }, + }); + return new Agent({ name: 'Adminizer data agent', instructions: [ 'You are an assistant that answers questions using Adminizer data.', 'Always rely on the provided tool to inspect database records.', + 'Use describe_model_fields before creating new records so that payloads include the correct fields.', 'Only include fields that are relevant to the question.', 'Summaries should explain how the answer was derived from the data.', '', @@ -152,7 +255,7 @@ export class OpenAiDataAgentService extends AbstractAiModelService { modelSummary, ].join('\n'), handoffDescription: 'Retrieves Adminizer records using DataAccessor with full permission checks.', - tools: [dataQueryTool], + tools: [describeFieldsTool, dataQueryTool, createRecordTool], model: this.model, }); } @@ -195,8 +298,8 @@ export class OpenAiDataAgentService extends AbstractAiModelService { }); } - private listReadableModels(user: UserAP): Array<{name: string; config: ModelConfig}> { - const readable: Array<{name: string; config: ModelConfig}> = []; + private listModelCapabilities(user: UserAP): Array<{name: string; config: ModelConfig; canRead: boolean; canCreate: boolean}> { + const result: Array<{name: string; config: ModelConfig; canRead: boolean; canCreate: boolean}> = []; for (const [entityName, rawConfig] of Object.entries(this.adminizer.config.models ?? {})) { const config = this.ensureModelConfig(entityName, rawConfig); @@ -208,13 +311,23 @@ export class OpenAiDataAgentService extends AbstractAiModelService { continue; } - const token = `read-${modelInstance.modelname}-model`; - if (this.adminizer.accessRightsHelper.hasPermission(token, user)) { - readable.push({name: entityName, config}); + const entity: Entity = { + name: entityName, + config, + model: modelInstance, + type: 'model', + uri: `${this.adminizer.config.routePrefix}/model/${entityName}`, + }; + + const canRead = this.adminizer.accessRightsHelper.hasPermission(this.getActionToken(entity, 'list'), user); + const canCreate = this.adminizer.accessRightsHelper.hasPermission(this.getActionToken(entity, 'add'), user); + + if (canRead || canCreate) { + result.push({name: entityName, config, canRead, canCreate}); } } - return readable; + return result; } private resolveEntity(modelName: string): Entity { @@ -249,6 +362,51 @@ export class OpenAiDataAgentService extends AbstractAiModelService { throw new Error(`Unknown model "${modelName}".`); } + private ensurePermission(entity: Entity, user: UserAP, action: ActionType): void { + const token = this.getActionToken(entity, action); + if (!this.adminizer.accessRightsHelper.hasPermission(token, user)) { + const actionName = this.describeAction(action); + throw new Error(`User "${user.login}" does not have permission to ${actionName} ${entity.name} records.`); + } + } + + private getActionToken(entity: Entity, action: ActionType): string { + const verb = this.resolveActionVerb(action); + const modelName = entity.model?.modelname ?? entity.config?.model ?? entity.name; + return `${verb}-${modelName}-${entity.type}`.toLowerCase(); + } + + private resolveActionVerb(action: ActionType): 'create' | 'read' | 'update' | 'delete' { + switch (action) { + case 'add': + return 'create'; + case 'edit': + return 'update'; + case 'remove': + return 'delete'; + case 'view': + case 'list': + default: + return 'read'; + } + } + + private describeAction(action: ActionType): string { + switch (action) { + case 'add': + return 'create'; + case 'edit': + return 'update'; + case 'remove': + return 'delete'; + case 'view': + return 'view'; + case 'list': + default: + return 'list'; + } + } + private ensureModelConfig(entityName: string, config: ModelConfig | boolean): ModelConfig { if (typeof config === 'boolean') { const modelId = entityName.toLowerCase(); diff --git a/src/lib/DataAccessor.ts b/src/lib/DataAccessor.ts index d2163ae0..5ed553d3 100644 --- a/src/lib/DataAccessor.ts +++ b/src/lib/DataAccessor.ts @@ -16,6 +16,20 @@ import { GroupAP } from "models/GroupAP"; import { UserAP } from "models/UserAP"; import { isObject } from "../helpers/JsUtils"; +export interface AccessibleFieldSummary { + name: string; + label: string; + type?: FieldsTypes | string; + required: boolean; + description?: string; + association?: { + model?: string; + collection?: string; + multiple: boolean; + }; + options?: BaseFieldConfig["options"]; +} + export class DataAccessor { public readonly adminizer: Adminizer; user: UserAP; @@ -155,6 +169,66 @@ export class DataAccessor { return result; } + /** + * Returns human-readable information about fields that are available for the current + * user and action. Useful for AI assistants or clients that need to understand which + * fields can be populated when creating or editing records. + */ + public listAccessibleFields(): AccessibleFieldSummary[] { + const fieldsConfig = this.getFieldsConfig(); + + if (!fieldsConfig) { + return []; + } + + const summaries: AccessibleFieldSummary[] = []; + + for (const [fieldName, field] of Object.entries(fieldsConfig)) { + if (!field || !field.config) { + continue; + } + + if (typeof field.config === "boolean") { + continue; + } + + const config = typeof field.config === "object" + ? field.config + : {title: typeof field.config === "string" ? field.config : undefined}; + + const attribute = field.model; + const type = (config?.type ?? attribute?.type) as FieldsTypes | string | undefined; + const required = Boolean(config?.required ?? attribute?.required ?? false); + + const summary: AccessibleFieldSummary = { + name: fieldName, + label: config?.title ?? fieldName, + type, + required, + }; + + if (config?.tooltip) { + summary.description = config.tooltip; + } + + if (config?.options) { + summary.options = config.options; + } + + if (attribute && (attribute.model || attribute.collection)) { + summary.association = { + model: attribute.model, + collection: attribute.collection, + multiple: Boolean(attribute.collection) || type === "association-many", + }; + } + + summaries.push(summary); + } + + return summaries; + } + private getAssociatedFieldsConfig(modelName: string): { [fieldName: string]: Field } | undefined { const model = this.adminizer.modelHandler.model.get(modelName); diff --git a/test/dataAccesor.spec.ts b/test/dataAccesor.spec.ts index 7fc0beee..b6e7f4ab 100644 --- a/test/dataAccesor.spec.ts +++ b/test/dataAccesor.spec.ts @@ -160,6 +160,25 @@ describe('DataAccessor test', () => { expect(result[0]).toHaveProperty('guardedField'); }); + it('`listAccessibleFields()` describes fields based on permissions', () => { + instance = new DataAccessor(adminizer, editorUser, entity, 'add'); + const editorFields = instance.listAccessibleFields(); + const guardedField = editorFields.find(field => field.name === 'guardedField'); + const titleField = editorFields.find(field => field.name === 'title'); + + expect(editorFields.length).toBeGreaterThan(0); + expect(guardedField?.label).toBe('Restricted Field'); + expect(titleField?.label).toBe('Title'); + expect(titleField?.required).toBe(true); + + instance = new DataAccessor(adminizer, managerUser, entity, 'add'); + const managerFields = instance.listAccessibleFields(); + expect(managerFields.find(field => field.name === 'guardedField')).toBeUndefined(); + + instance = new DataAccessor(adminizer, defaultUser, entity, 'add'); + expect(instance.listAccessibleFields()).toEqual([]); + }); + it('`sanitizeUserRelationAccess()` includes user ID in criteria', async () => { // if (entity.config && entity.config.userAccessRelation) { // entity.config.userAccessRelation = {