Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
35 changes: 30 additions & 5 deletions docs/AccessRights/AccessRightsModelFields.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
15 changes: 14 additions & 1 deletion docs/AiAssistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
113 changes: 109 additions & 4 deletions fixture/helpers/ai/OpenAiDataAgentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentContext>) => {
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.',
Expand Down Expand Up @@ -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<AgentContext>) => {
const activeUser = runContext?.context?.user ?? user;

if (!input.model) {
throw new Error('Model name is required');
}
Expand Down Expand Up @@ -140,19 +194,70 @@ 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<AgentContext>) => {
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<AgentContext>({
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.',
'',
'Accessible models:',
modelSummary,
].join('\n'),
handoffDescription: 'Retrieves Adminizer records using DataAccessor with full permission checks.',
tools: [dataQueryTool],
tools: [describeFieldsTool, dataQueryTool, createRecordTool],
model: this.model,
});
}
Expand Down
51 changes: 51 additions & 0 deletions src/lib/DataAccessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -155,6 +168,44 @@ export class DataAccessor {
return result;
}

public listAccessibleFields(): DataAccessorFieldMetadata[] {
const config = this.getFieldsConfig();

if (!config) {
return [];
}

return Object.entries(config).reduce<DataAccessorFieldMetadata[]>((acc, [key, field]) => {
if (!field || !isObject(field.config)) {
return acc;
}

const normalizedConfig = field.config as BaseFieldConfig & Record<string, unknown>;
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);
Expand Down
14 changes: 14 additions & 0 deletions test/dataAccesor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down