Skip to content
Merged
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
62 changes: 62 additions & 0 deletions packages/audit/objects/audit_log.object.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: audit_log
label: Audit Log
icon: history
hidden: false
fields:
event_type:
type: select
label: Event Type
required: true
options:
- label: Create
value: create
- label: Update
value: update
- label: Delete
value: delete
- label: Read
value: read
- label: Login
value: login
- label: Logout
value: logout
- label: Permission Change
value: permission_change
- label: Custom
value: custom
index: true
object_name:
type: text
label: Object Name
index: true
record_id:
type: text
label: Record ID
index: true
user_id:
type: text
label: User ID
index: true
timestamp:
type: datetime
label: Timestamp
required: true
index: true
ip_address:
type: text
label: IP Address
user_agent:
type: text
label: User Agent
session_id:
type: text
label: Session ID
index: true
changes:
type: object
label: Field Changes
blackbox: true
metadata:
type: object
label: Additional Metadata
blackbox: true
182 changes: 182 additions & 0 deletions packages/audit/src/objectql-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* ObjectQL Audit Storage Implementation
*
* Storage adapter that persists audit events to ObjectOS/ObjectQL database
*/

import type { PluginContext } from '@objectstack/runtime';
import type {
AuditStorage,
AuditLogEntry,
AuditQueryOptions,
FieldChange,
AuditTrailEntry,
} from './types.js';

export class ObjectQLAuditStorage implements AuditStorage {
private context: PluginContext;

constructor(context: PluginContext) {
this.context = context;
}

/**
* Store an audit event
*/
async logEvent(entry: AuditLogEntry): Promise<void> {
await (this.context as any).broker.call('data.create', {
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The broker property is accessed via type casting (this.context as any).broker, which bypasses TypeScript's type safety. This same issue appears throughout all ObjectQL storage implementations. Consider adding proper typing for the broker property in PluginContext or creating a typed wrapper method.

Copilot uses AI. Check for mistakes.
object: 'audit_log',
doc: {
event_type: entry.eventType,
object_name: (entry as any).objectName,
record_id: (entry as any).recordId,
user_id: entry.userId,
timestamp: entry.timestamp || new Date().toISOString(),
ip_address: entry.ipAddress,
user_agent: entry.userAgent,
session_id: entry.sessionId,
changes: (entry as any).changes,
Comment on lines +23 to +38
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logEvent method casts entry to any to access properties (objectName, recordId, changes) that are defined in AuditTrailEntry but not in the base AuditLogEntry type. This type casting hides potential type mismatches. Consider using a type guard or having separate methods for different entry types, or ensure the method signature accepts the appropriate union type.

Suggested change
/**
* Store an audit event
*/
async logEvent(entry: AuditLogEntry): Promise<void> {
await (this.context as any).broker.call('data.create', {
object: 'audit_log',
doc: {
event_type: entry.eventType,
object_name: (entry as any).objectName,
record_id: (entry as any).recordId,
user_id: entry.userId,
timestamp: entry.timestamp || new Date().toISOString(),
ip_address: entry.ipAddress,
user_agent: entry.userAgent,
session_id: entry.sessionId,
changes: (entry as any).changes,
private isAuditTrailEntry(entry: AuditLogEntry | AuditTrailEntry): entry is AuditTrailEntry {
return (
typeof (entry as AuditTrailEntry).objectName === 'string' &&
typeof (entry as AuditTrailEntry).recordId === 'string'
);
}
/**
* Store an audit event
*/
async logEvent(entry: AuditLogEntry | AuditTrailEntry): Promise<void> {
const isTrail = this.isAuditTrailEntry(entry);
const objectName = isTrail ? entry.objectName : undefined;
const recordId = isTrail ? entry.recordId : undefined;
const changes = isTrail ? entry.changes : undefined;
await (this.context as any).broker.call('data.create', {
object: 'audit_log',
doc: {
event_type: entry.eventType,
object_name: objectName,
record_id: recordId,
user_id: entry.userId,
timestamp: entry.timestamp || new Date().toISOString(),
ip_address: entry.ipAddress,
user_agent: entry.userAgent,
session_id: entry.sessionId,
changes: changes,

Copilot uses AI. Check for mistakes.
metadata: entry.metadata,
}
});
}

/**
* Query audit events with filtering and pagination
*/
async queryEvents(options: AuditQueryOptions = {}): Promise<AuditLogEntry[]> {
const query: any = {};

// Apply filters
if (options.objectName) {
query.object_name = options.objectName;
}
if (options.recordId) {
query.record_id = options.recordId;
}
if (options.userId) {
query.user_id = options.userId;
}
if (options.eventType) {
query.event_type = options.eventType;
}
if (options.startDate) {
query.timestamp = { $gte: options.startDate };
}
if (options.endDate) {
if (query.timestamp) {
query.timestamp.$lte = options.endDate;
} else {
query.timestamp = { $lte: options.endDate };
}
}

// Sort
const sortOrder = options.sortOrder || 'desc';
const sort = sortOrder === 'asc' ? 'timestamp' : '-timestamp';

// Query
const results = await (this.context as any).broker.call('data.find', {
object: 'audit_log',
query: query,
sort: sort,
limit: options.limit,
skip: options.offset,
});

return results.map((doc: any) => this.mapDocToAuditEntry(doc));
}

/**
* Get field history for a specific record and field
*/
async getFieldHistory(
objectName: string,
recordId: string,
fieldName: string
): Promise<FieldChange[]> {
const auditTrail = await this.getAuditTrail(objectName, recordId);
const fieldChanges: FieldChange[] = [];

// Reverse to get chronological order (oldest first)
const chronologicalTrail = [...auditTrail].reverse();

for (const entry of chronologicalTrail) {
if (entry.changes) {
const change = entry.changes.find(c => c.field === fieldName);
if (change) {
fieldChanges.push(change);
}
}
}

return fieldChanges;
}

/**
* Get audit trail for a specific record
*/
async getAuditTrail(
objectName: string,
recordId: string
): Promise<AuditTrailEntry[]> {
const events = await this.queryEvents({ objectName, recordId });
return events as AuditTrailEntry[];
}

/**
* Delete events with timestamp before the given date
* Optionally filter by event type
*/
async deleteExpiredEvents(before: string, eventType?: string): Promise<number> {
const query: any = {
timestamp: { $lt: before }
};

if (eventType) {
query.event_type = eventType;
}

const toDelete = await (this.context as any).broker.call('data.find', {
object: 'audit_log',
query: query,
});

for (const doc of toDelete) {
await (this.context as any).broker.call('data.delete', {
object: 'audit_log',
id: doc._id || doc.id,
});
}

return toDelete.length;
}

/**
* Map document to AuditLogEntry
*/
private mapDocToAuditEntry(doc: any): AuditLogEntry {
const base = {
eventType: doc.event_type,
userId: doc.user_id,
timestamp: doc.timestamp,
ipAddress: doc.ip_address,
userAgent: doc.user_agent,
sessionId: doc.session_id,
metadata: doc.metadata,
};

// Add objectName and recordId if present (for AuditTrailEntry)
if (doc.object_name) {
(base as any).objectName = doc.object_name;
}
if (doc.record_id) {
(base as any).recordId = doc.record_id;
}
if (doc.changes) {
(base as any).changes = doc.changes;
}

return base as AuditLogEntry;
}
}
Comment on lines +16 to +182
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new ObjectQLAuditStorage class has no test coverage. Existing storage tests only cover InMemoryAuditStorage. The ObjectQL storage adapter should have tests that verify: 1) proper mapping between AuditLogEntry/AuditTrailEntry and database documents, 2) query filtering and pagination, 3) field history extraction, 4) error handling for broker call failures.

Copilot uses AI. Check for mistakes.
8 changes: 8 additions & 0 deletions packages/audit/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
PluginStartupResult,
} from './types.js';
import { InMemoryAuditStorage } from './storage.js';
import { ObjectQLAuditStorage } from './objectql-storage.js';

/**
* Audit Log Plugin
Expand Down Expand Up @@ -61,6 +62,13 @@ export class AuditLogPlugin implements Plugin {
this.context = context;
this.startedAt = Date.now();

// Upgrade storage to ObjectQL if not explicitly provided and broker is available
// We do this in init because we need the context
if (!this.config.storage && (context as any).broker) {
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The broker availability check (context as any).broker uses type casting without validating the broker interface. This same issue appears in all plugin init methods. Consider adding runtime validation or a more robust type guard.

Copilot uses AI. Check for mistakes.
this.storage = new ObjectQLAuditStorage(context);
context.logger.info('[Audit Log] Upgraded to ObjectQL storage');
}

// Register audit log service
context.registerService('audit-log', this);

Expand Down
4 changes: 0 additions & 4 deletions packages/automation/objects/automation_rule.object.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,6 @@ fields:
type: object
label: Trigger Configuration
blackbox: true
conditions:
type: object
label: Conditions
blackbox: true
actions:
type: object
label: Actions
Expand Down
18 changes: 18 additions & 0 deletions packages/automation/objects/formula_field.object.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: formula_field
label: Formula Field
icon: calculator
hidden: true
fields:
object_name:
type: text
required: true
label: Object Name
index: true
name:
type: text
required: true
label: Field Name
formula:
type: object
label: Formula Definition
blackbox: true
Loading
Loading