From 34b259d992c79872a2a63b96ff6b4f29d6d284a1 Mon Sep 17 00:00:00 2001 From: xziy Date: Sat, 27 Sep 2025 12:23:49 +0700 Subject: [PATCH] Add DataAccessor metadata and OpenAI agent write support --- HISTORY.md | 5 + docs/AccessRights/AccessRightsModelFields.md | 35 +++++- docs/AiAssistant.md | 15 ++- fixture/helpers/ai/OpenAiDataAgentService.ts | 113 ++++++++++++++++++- src/lib/DataAccessor.ts | 51 +++++++++ test/dataAccesor.spec.ts | 14 +++ 6 files changed, 223 insertions(+), 10 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 70354f92..53bce3db 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,8 @@ +## 4.3.7 +- Exposed `DataAccessor#listAccessibleFields` so callers (including AI agents) can inspect writeable fields with metadata. +- Enabled the fixture OpenAI data agent to describe model fields and create records through `DataAccessor` respecting user permissions. +- Documented the new metadata API and AI assistant tooling updates. + ## 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..3b5f7f71 100644 --- a/docs/AccessRights/AccessRightsModelFields.md +++ b/docs/AccessRights/AccessRightsModelFields.md @@ -23,11 +23,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. +* **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 discovery**: `listAccessibleFields()` returns sanitized metadata describing every field the current user can touch for the current action. + +--- + +#### **Field Metadata API** + +The `listAccessibleFields()` helper exposes a lightweight description of the fields that remain after +all permission checks. Each entry includes: + +| Property | Description | +| --- | --- | +| `key` | Field identifier as used in payloads and criteria | +| `label` | Human-friendly title derived from the model configuration | +| `type` | Normalised field type (e.g. `string`, `association-many`, `jsoneditor`) | +| `required` | Indicates whether the field must be supplied when creating or editing records | +| `description` | Tooltip/description text from the configuration (when available) | +| `readOnly` | True if the field is marked as disabled/readonly for the current user | +| `isAssociation` | True for relation fields (`association` or `association-many`) | +| `isCollection` | True only for `association-many` relations | +| `options` | Widget-specific options (when defined) | +| `choices` | The `isIn` enumeration, useful for select-style inputs | + +Because the method respects the accessor action (`add`, `edit`, `list`, or `view`), it can be used to +drive dynamic form generation, AI assistant hints, or API schema discovery without leaking fields that +the caller cannot access. --- diff --git a/docs/AiAssistant.md b/docs/AiAssistant.md index 75417d4e..ec409734 100644 --- a/docs/AiAssistant.md +++ b/docs/AiAssistant.md @@ -79,7 +79,7 @@ OpenAI's Agents API while still relying on Adminizer's abstractions: * The agent implementation lives in `fixture/helpers/ai/OpenAiDataAgentService.ts` and extends `AbstractAiModelService`. -* Database reads are performed through `DataAccessor`, which means the usual access control and field +* Database reads and writes are performed through `DataAccessor`, which means the usual access control and field sanitisation rules are enforced automatically. * Conversation history is converted into the `@openai/agents` protocol so follow-up questions can build on previous answers. @@ -95,3 +95,16 @@ export OPENAI_AGENT_MODEL="gpt-4.1-mini" # optional override available the fixture automatically registers the model, exposes it in the assistant model list, and prefers it as the default chat model. If the key is missing the agent stays disabled and a warning is logged during boot. + +### Tools exposed to the agent + +The `openai-data` agent wires three tools into the Agents runtime so prompts can stay declarative: + +1. `describe_model_fields` – lists the fields that are available for a given action (`add`, `edit`, `list`, or `view`). + The response mirrors `DataAccessor#listAccessibleFields`, so the assistant can reason about required inputs before + attempting a mutation. +2. `query_model_records` – fetches records with the active user's `list` permissions, optionally filtered and projected. +3. `create_model_record` – creates a new record using the `add` permissions of the active user. + +Every tool call is executed with the same permission checks the admin panel would apply, so the agent cannot escalate +privileges beyond the assigned access tokens. diff --git a/fixture/helpers/ai/OpenAiDataAgentService.ts b/fixture/helpers/ai/OpenAiDataAgentService.ts index 321744f7..e09dd994 100644 --- a/fixture/helpers/ai/OpenAiDataAgentService.ts +++ b/fixture/helpers/ai/OpenAiDataAgentService.ts @@ -75,6 +75,60 @@ export class OpenAiDataAgentService extends AbstractAiModelService { ? accessibleModels.map(({name, config}) => `• ${name} (model key: ${config.model})`).join('\n') : 'No models are currently accessible.'; + const describeFieldsTool = tool({ + name: 'describe_model_fields', + description: 'List accessible fields for a model action so payloads can be prepared correctly.', + parameters: { + type: 'object', + properties: { + model: { + type: 'string', + description: 'Model name as defined in the Adminizer configuration', + minLength: 1, + }, + action: { + type: 'string', + enum: ['add', 'edit', 'list', 'view'], + description: 'Adminizer action to inspect. Defaults to "add".', + }, + }, + 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 rawAction = typeof input.action === 'string' ? input.action.toLowerCase() : 'add'; + const action: 'add' | 'edit' | 'list' | 'view' = ['add', 'edit', 'list', 'view'].includes(rawAction) + ? rawAction as 'add' | 'edit' | 'list' | 'view' + : 'add'; + + const entity = this.resolveEntity(input.model); + if (!entity.model) { + throw new Error(`Model "${input.model}" is not registered in Adminizer.`); + } + + const accessor = new DataAccessor(this.adminizer, activeUser, entity, action); + const fieldsConfig = accessor.getFieldsConfig(); + + if (!fieldsConfig) { + throw new Error(`The user does not have permission to ${action} ${entity.name}.`); + } + + const fields = accessor.listAccessibleFields(); + + return JSON.stringify({ + model: entity.name, + action, + fields, + }, null, 2); + }, + }); + const dataQueryTool = tool({ name: 'query_model_records', description: 'Query Adminizer models using DataAccessor. Provide the model name from the admin panel configuration.', @@ -102,12 +156,12 @@ 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'); } @@ -140,11 +194,62 @@ export class OpenAiDataAgentService extends AbstractAiModelService { }, }); + const createRecordTool = tool({ + name: 'create_model_record', + description: 'Create a new record using DataAccessor with the active user permissions.', + 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 inspect requirements.', + 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' || Array.isArray(input.data)) { + throw new Error('The "data" payload 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.`); + } + + const accessor = new DataAccessor(this.adminizer, activeUser, entity, 'add'); + if (!accessor.getFieldsConfig()) { + throw new Error(`The user does not have permission to add ${entity.name} records.`); + } + + 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.', + 'Always rely on the provided tools to inspect database records or to create new entries.', + 'Call describe_model_fields before creating data so you can match required fields.', 'Only include fields that are relevant to the question.', 'Summaries should explain how the answer was derived from the data.', '', @@ -152,7 +257,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, }); } diff --git a/src/lib/DataAccessor.ts b/src/lib/DataAccessor.ts index d2163ae0..1822287f 100644 --- a/src/lib/DataAccessor.ts +++ b/src/lib/DataAccessor.ts @@ -16,6 +16,19 @@ import { GroupAP } from "models/GroupAP"; import { UserAP } from "models/UserAP"; import { isObject } from "../helpers/JsUtils"; +export interface DataAccessorFieldMetadata { + key: string; + label: string; + type: FieldsTypes; + required: boolean; + description?: string; + readOnly: boolean; + isAssociation: boolean; + isCollection: boolean; + options?: BaseFieldConfig["options"]; + choices?: BaseFieldConfig["isIn"]; +} + export class DataAccessor { public readonly adminizer: Adminizer; user: UserAP; @@ -155,6 +168,44 @@ export class DataAccessor { return result; } + public listAccessibleFields(): DataAccessorFieldMetadata[] { + const config = this.getFieldsConfig(); + + if (!config) { + return []; + } + + return Object.entries(config).reduce((acc, [key, field]) => { + if (!field || !isObject(field.config)) { + return acc; + } + + const normalizedConfig = field.config as BaseFieldConfig & Record; + const rawType = typeof normalizedConfig.type === "string" + ? normalizedConfig.type + : typeof field.model?.type === "string" + ? field.model.type + : "string"; + const normalizedType = rawType.toLowerCase() as FieldsTypes; + + const metadata: DataAccessorFieldMetadata = { + key, + label: normalizedConfig.title ?? key, + type: normalizedType, + required: Boolean(normalizedConfig.required), + description: normalizedConfig.tooltip ?? (normalizedConfig as { description?: string }).description, + readOnly: Boolean((normalizedConfig as { disabled?: boolean }).disabled ?? (normalizedConfig as { readonly?: boolean }).readonly), + isAssociation: normalizedType === "association" || normalizedType === "association-many", + isCollection: normalizedType === "association-many", + options: normalizedConfig.options, + choices: normalizedConfig.isIn, + }; + + acc.push(metadata); + return acc; + }, []); + } + 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..e245cf68 100644 --- a/test/dataAccesor.spec.ts +++ b/test/dataAccesor.spec.ts @@ -122,6 +122,20 @@ describe('DataAccessor test', () => { expect(instance.getFieldsConfig()).toBeUndefined(); }); + it('listAccessibleFields reports metadata for allowed fields only', () => { + instance = new DataAccessor(adminizer, editorUser, entity, 'add'); + const metadata = instance.listAccessibleFields(); + const titleField = metadata.find(field => field.key === 'title'); + const guardedField = metadata.find(field => field.key === 'guardedField'); + + expect(titleField?.label).toBe('Title'); + expect(titleField?.required).toBe(true); + expect(guardedField).toBeDefined(); + + instance = new DataAccessor(adminizer, defaultUser, entity, 'add'); + expect(instance.listAccessibleFields()).toEqual([]); + }); + it('Populated selfAssociation has guardedField for admin', () => { instance = new DataAccessor(adminizer, adminUser, entity, 'edit'); expect(instance.getFieldsConfig().selfAssociation.populated).toHaveProperty('guardedField');