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
4 changes: 4 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
41 changes: 30 additions & 11 deletions docs/AccessRights/AccessRightsModelFields.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion docs/AiAssistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
186 changes: 172 additions & 14 deletions fixture/helpers/ai/OpenAiDataAgentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -70,9 +70,15 @@ export class OpenAiDataAgentService extends AbstractAiModelService {
}

private createAgent(user: UserAP): Agent<AgentContext> {
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({
Expand Down Expand Up @@ -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<AgentContext>) => {
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()) {
Expand All @@ -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;
Expand All @@ -140,19 +149,113 @@ 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<AgentContext>) => {
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<AgentContext>) => {
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<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.',
'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.',
'',
'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 Expand Up @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
Loading